detached c117990

fix(web): MR description as Markdown, latest tag row inline, no install button (#60)

commit
c1179903253ff4075db07b94eb163628bc5d2851
author
brice · · 2026-10-18 09:06 UTC
committer
bnema · · 2026-10-18 09:06 UTC
parent
ef38ad0
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
46 files · +491 -695
M internal/adapters/web/ASSETS.md +1 -1
@@ -16,7 +16,7 @@ content-hashed names. Nothing is fetched at runtime.
1616 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1717 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
1818 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
19-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `download` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `download`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
19+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
2020 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
2121 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |
2222 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
@@ -16,7 +16,7 @@ content-hashed names. Nothing is fetched at runtime.
16 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |16 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
17 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |17 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
18 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |18 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
19-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `download` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `download`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |19+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
20 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |20 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
21 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |21 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |
22 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |22 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
M internal/adapters/web/jstest/install.test.mjs +5 -71
@@ -1,22 +1,15 @@
11 // Tests of the install block of static/js/ermite.js (ermiteInstall): the
2-// service worker registration and the "install app" button. Run with
2+// service worker registration. Run with
33 // node --test 'internal/adapters/web/jstest/*.test.mjs'
44 import assert from 'node:assert/strict';
55 import { readFileSync } from 'node:fs';
66 import { test } from 'node:test';
7-import { dom, el, installDom } from './dom.mjs';
7+import { installDom } from './dom.mjs';
88
99 const doc = installDom();
10-const button = el('button', { attrs: { 'data-install': '' } });
11-button.hidden = true;
12-document.body.append(button);
13-
1410 const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });
1511 const listeners = {};
1612 globalThis.addEventListener = (type, fn) => { listeners[type] = fn; };
17-const fire = (type, ev) => listeners[type](ev);
18-let standalone = false;
19-globalThis.matchMedia = () => ({ matches: standalone });
2013 doc.readyState = 'loading';
2114 const registered = [];
2215 setNavigator({ serviceWorker: { register: async (url, opts) => { registered.push([url, opts]); return {}; } } });
@@ -25,17 +18,9 @@ setNavigator({ serviceWorker: { register: async (url, opts) => { registered.push
2518 const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
2619 const start = src.indexOf(' window.ermiteInstall = ');
2720 const end = src.indexOf(' })();', start) + ' })();'.length;
28-new Function('$$', src.slice(start, end))((sel) => document.querySelectorAll(sel));
21+new Function(src.slice(start, end))();
2922 const I = window.ermiteInstall;
3023
31-// promptEvent is a beforeinstallprompt event.
32-function promptEvent(outcome = 'accepted') {
33- const e = { prevented: false, prompts: 0, preventDefault() { this.prevented = true; } };
34- e.prompt = async () => { e.prompts++; };
35- e.userChoice = Promise.resolve({ outcome });
36- return e;
37-}
38-
3924 test('the service worker is registered at the root scope once the page has loaded', async () => {
4025 assert.equal(registered.length, 0, 'not before load');
4126 await listeners.load();
@@ -49,57 +34,6 @@ test('a refused registration is not an error', async () => {
4934 assert.equal(await I.register(), null);
5035 });
5136
52-test('the button shows on beforeinstallprompt, and a click prompts once', async () => {
53- const e = promptEvent();
54- assert.equal(button.hidden, true);
55- fire('beforeinstallprompt', e);
56- assert.equal(e.prevented, true, 'the browser banner is replaced by the button');
57- assert.equal(button.hidden, false);
58- const done = listeners.click({ target: { closest: (s) => (s === '[data-install]' ? button : null) } });
59- assert.equal(done, undefined);
60- assert.equal(await I.pending, 'accepted');
61- assert.equal(await I.prompt(), '', 'the event was used by the click');
62- assert.equal(e.prompts, 1);
63- assert.equal(button.hidden, true);
64-});
65-
66-test('the button stays while the dialog is up; its focus then moves to the search', async () => {
67- const search = el('button', { attrs: { 'aria-controls': 'panel-cmd' } });
68- document.body.append(search);
69- const e = promptEvent('dismissed');
70- let shownDuring = null;
71- e.prompt = async () => { shownDuring = !button.hidden; };
72- fire('beforeinstallprompt', e);
73- button.focus();
74- dom.focused = [];
75- assert.equal(await I.prompt(), 'dismissed');
76- assert.equal(shownDuring, true, 'not hidden under the dialog');
77- assert.equal(button.hidden, true);
78- assert.deepEqual(dom.focused, [search]);
79- dom.active = null;
80- search.remove();
81-});
82-
83-test('prompt without an event does nothing; a dismissal reports its outcome', async () => {
84- assert.equal(await I.prompt(), '');
85- const e = promptEvent('dismissed');
86- fire('beforeinstallprompt', e);
87- assert.equal(await I.prompt(), 'dismissed');
88- assert.equal(button.hidden, true);
89-});
90-
91-test('appinstalled hides the button', () => {
92- fire('beforeinstallprompt', promptEvent());
93- assert.equal(button.hidden, false);
94- fire('appinstalled', {});
95- assert.equal(button.hidden, true);
96-});
97-
98-test('an app that runs installed never shows the button', () => {
99- standalone = true;
100- const e = promptEvent();
101- fire('beforeinstallprompt', e);
102- assert.equal(button.hidden, true);
103- assert.equal(e.prevented, false);
104- standalone = false;
37+test('the page leaves the browser its own install offer', () => {
38+ assert.equal(listeners.beforeinstallprompt, undefined, 'no preventDefault on beforeinstallprompt');
10539 });
@@ -1,22 +1,15 @@
1 // Tests of the install block of static/js/ermite.js (ermiteInstall): the1 // Tests of the install block of static/js/ermite.js (ermiteInstall): the
2-// service worker registration and the "install app" button. Run with2+// service worker registration. Run with
3 // node --test 'internal/adapters/web/jstest/*.test.mjs'3 // node --test 'internal/adapters/web/jstest/*.test.mjs'
4 import assert from 'node:assert/strict';4 import assert from 'node:assert/strict';
5 import { readFileSync } from 'node:fs';5 import { readFileSync } from 'node:fs';
6 import { test } from 'node:test';6 import { test } from 'node:test';
7-import { dom, el, installDom } from './dom.mjs';7+import { installDom } from './dom.mjs';
8 8
9 const doc = installDom();9 const doc = installDom();
10-const button = el('button', { attrs: { 'data-install': '' } });
11-button.hidden = true;
12-document.body.append(button);
13-
14 const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });10 const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });
15 const listeners = {};11 const listeners = {};
16 globalThis.addEventListener = (type, fn) => { listeners[type] = fn; };12 globalThis.addEventListener = (type, fn) => { listeners[type] = fn; };
17-const fire = (type, ev) => listeners[type](ev);
18-let standalone = false;
19-globalThis.matchMedia = () => ({ matches: standalone });
20 doc.readyState = 'loading';13 doc.readyState = 'loading';
21 const registered = [];14 const registered = [];
22 setNavigator({ serviceWorker: { register: async (url, opts) => { registered.push([url, opts]); return {}; } } });15 setNavigator({ serviceWorker: { register: async (url, opts) => { registered.push([url, opts]); return {}; } } });
@@ -25,17 +18,9 @@ setNavigator({ serviceWorker: { register: async (url, opts) => { registered.push
25 const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');18 const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
26 const start = src.indexOf(' window.ermiteInstall = ');19 const start = src.indexOf(' window.ermiteInstall = ');
27 const end = src.indexOf(' })();', start) + ' })();'.length;20 const end = src.indexOf(' })();', start) + ' })();'.length;
28-new Function('$$', src.slice(start, end))((sel) => document.querySelectorAll(sel));21+new Function(src.slice(start, end))();
29 const I = window.ermiteInstall;22 const I = window.ermiteInstall;
30 23
31-// promptEvent is a beforeinstallprompt event.
32-function promptEvent(outcome = 'accepted') {
33- const e = { prevented: false, prompts: 0, preventDefault() { this.prevented = true; } };
34- e.prompt = async () => { e.prompts++; };
35- e.userChoice = Promise.resolve({ outcome });
36- return e;
37-}
38-
39 test('the service worker is registered at the root scope once the page has loaded', async () => {24 test('the service worker is registered at the root scope once the page has loaded', async () => {
40 assert.equal(registered.length, 0, 'not before load');25 assert.equal(registered.length, 0, 'not before load');
41 await listeners.load();26 await listeners.load();
@@ -49,57 +34,6 @@ test('a refused registration is not an error', async () => {
49 assert.equal(await I.register(), null);34 assert.equal(await I.register(), null);
50 });35 });
51 36
52-test('the button shows on beforeinstallprompt, and a click prompts once', async () => {37+test('the page leaves the browser its own install offer', () => {
53- const e = promptEvent();38+ assert.equal(listeners.beforeinstallprompt, undefined, 'no preventDefault on beforeinstallprompt');
54- assert.equal(button.hidden, true);
55- fire('beforeinstallprompt', e);
56- assert.equal(e.prevented, true, 'the browser banner is replaced by the button');
57- assert.equal(button.hidden, false);
58- const done = listeners.click({ target: { closest: (s) => (s === '[data-install]' ? button : null) } });
59- assert.equal(done, undefined);
60- assert.equal(await I.pending, 'accepted');
61- assert.equal(await I.prompt(), '', 'the event was used by the click');
62- assert.equal(e.prompts, 1);
63- assert.equal(button.hidden, true);
64-});
65-
66-test('the button stays while the dialog is up; its focus then moves to the search', async () => {
67- const search = el('button', { attrs: { 'aria-controls': 'panel-cmd' } });
68- document.body.append(search);
69- const e = promptEvent('dismissed');
70- let shownDuring = null;
71- e.prompt = async () => { shownDuring = !button.hidden; };
72- fire('beforeinstallprompt', e);
73- button.focus();
74- dom.focused = [];
75- assert.equal(await I.prompt(), 'dismissed');
76- assert.equal(shownDuring, true, 'not hidden under the dialog');
77- assert.equal(button.hidden, true);
78- assert.deepEqual(dom.focused, [search]);
79- dom.active = null;
80- search.remove();
81-});
82-
83-test('prompt without an event does nothing; a dismissal reports its outcome', async () => {
84- assert.equal(await I.prompt(), '');
85- const e = promptEvent('dismissed');
86- fire('beforeinstallprompt', e);
87- assert.equal(await I.prompt(), 'dismissed');
88- assert.equal(button.hidden, true);
89-});
90-
91-test('appinstalled hides the button', () => {
92- fire('beforeinstallprompt', promptEvent());
93- assert.equal(button.hidden, false);
94- fire('appinstalled', {});
95- assert.equal(button.hidden, true);
96-});
97-
98-test('an app that runs installed never shows the button', () => {
99- standalone = true;
100- const e = promptEvent();
101- fire('beforeinstallprompt', e);
102- assert.equal(button.hidden, true);
103- assert.equal(e.prevented, false);
104- standalone = false;
105 });39 });
M internal/adapters/web/manifest_test.go +0 -1
@@ -57,7 +57,6 @@ func TestWebAppManifest(t *testing.T) {
5757 page := renderToString(t, Shell(Layout{Assets: u}, nil))
5858 assert.Contains(t, page, `<link rel="manifest" href="`+u.Manifest+`">`)
5959 assert.Contains(t, page, `<link rel="apple-touch-icon" href="`+u.AppleIcon+`">`)
60- assert.Contains(t, page, `data-install`)
6160 }
6261
6362 // The icons are exactly the size the manifest declares: Chrome does not
@@ -57,7 +57,6 @@ func TestWebAppManifest(t *testing.T) {
57 page := renderToString(t, Shell(Layout{Assets: u}, nil))57 page := renderToString(t, Shell(Layout{Assets: u}, nil))
58 assert.Contains(t, page, `<link rel="manifest" href="`+u.Manifest+`">`)58 assert.Contains(t, page, `<link rel="manifest" href="`+u.Manifest+`">`)
59 assert.Contains(t, page, `<link rel="apple-touch-icon" href="`+u.AppleIcon+`">`)59 assert.Contains(t, page, `<link rel="apple-touch-icon" href="`+u.AppleIcon+`">`)
60- assert.Contains(t, page, `data-install`)
61 }60 }
62 61
63 // The icons are exactly the size the manifest declares: Chrome does not62 // The icons are exactly the size the manifest declares: Chrome does not
M internal/adapters/web/mr.go +14 -2
@@ -5,6 +5,7 @@ package web
55 import (
66 "context"
77 "html"
8+ "html/template"
89 "io"
910 "net/http"
1011 "net/url"
@@ -196,7 +197,9 @@ type MRPage struct {
196197 Opened Person
197198 Updated string
198199 UpdatedAt string
199- Desc []TextPart
200+ // Desc is the description rendered as Markdown (as a README: no raw
201+ // HTML, filtered URLs), "" when it is empty.
202+ Desc template.HTML
200203 Head string
201204 HeadShort string
202205 MergedShort string
@@ -469,7 +472,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
469472 State: mrStateName(m), Open: open, Signed: signed,
470473 Source: visibleText(m.Source), Target: visibleText(m.Target),
471474 Author: s.who(m.Author),
472- Desc: plainParts(m.Description.String()),
473475 Head: head, HeadShort: short(head),
474476 NFiles: res.Stats.FilesChanged,
475477 Adds: res.Stats.Additions,
@@ -477,6 +479,16 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
477479 Files: diffFiles(res.Diff.Files),
478480 }
479481 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
482+ if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
483+ // Relative links point into the source branch as it is now (its head
484+ // commit); the heading ids carry their own prefix, apart from the
485+ // ids of the diff.
486+ h, _, err := s.md.renderWithIDs([]byte(desc), d.name(), head, "", "mr-desc-")
487+ if err != nil {
488+ return nil, 0, err
489+ }
490+ p.Desc = h
491+ }
480492 p.Opened.Name = p.Author
481493 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
482494 if !m.CreatedAt.IsZero() {
@@ -5,6 +5,7 @@ package web
5 import (5 import (
6 "context"6 "context"
7 "html"7 "html"
8+ "html/template"
8 "io"9 "io"
9 "net/http"10 "net/http"
10 "net/url"11 "net/url"
@@ -196,7 +197,9 @@ type MRPage struct {
196 Opened Person197 Opened Person
197 Updated string198 Updated string
198 UpdatedAt string199 UpdatedAt string
199- Desc []TextPart200+ // Desc is the description rendered as Markdown (as a README: no raw
201+ // HTML, filtered URLs), "" when it is empty.
202+ Desc template.HTML
200 Head string203 Head string
201 HeadShort string204 HeadShort string
202 MergedShort string205 MergedShort string
@@ -469,7 +472,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
469 State: mrStateName(m), Open: open, Signed: signed,472 State: mrStateName(m), Open: open, Signed: signed,
470 Source: visibleText(m.Source), Target: visibleText(m.Target),473 Source: visibleText(m.Source), Target: visibleText(m.Target),
471 Author: s.who(m.Author),474 Author: s.who(m.Author),
472- Desc: plainParts(m.Description.String()),
473 Head: head, HeadShort: short(head),475 Head: head, HeadShort: short(head),
474 NFiles: res.Stats.FilesChanged,476 NFiles: res.Stats.FilesChanged,
475 Adds: res.Stats.Additions,477 Adds: res.Stats.Additions,
@@ -477,6 +479,16 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
477 Files: diffFiles(res.Diff.Files),479 Files: diffFiles(res.Diff.Files),
478 }480 }
479 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))481 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
482+ if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
483+ // Relative links point into the source branch as it is now (its head
484+ // commit); the heading ids carry their own prefix, apart from the
485+ // ids of the diff.
486+ h, _, err := s.md.renderWithIDs([]byte(desc), d.name(), head, "", "mr-desc-")
487+ if err != nil {
488+ return nil, 0, err
489+ }
490+ p.Desc = h
491+ }
480 p.Opened.Name = p.Author492 p.Opened.Name = p.Author
481 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)493 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
482 if !m.CreatedAt.IsZero() {494 if !m.CreatedAt.IsZero() {
M internal/adapters/web/mr.templ +5 -3
@@ -140,8 +140,10 @@ templ MergeRequest(l Layout, p MRPage) {
140140 </dl>
141141 </header>
142142 @LiveSlot("mr-notice")
143- if len(p.Desc) > 0 {
144- <p class="mr-desc">@userText(p.Desc)</p>
143+ if p.Desc != "" {
144+ <article class="mr-desc prose" aria-label="description" hx-ignore>
145+ @templ.Raw(string(p.Desc))
146+ </article>
145147 }
146148 if p.Since != nil {
147149 <nav class="since" aria-label="Diff range">
@@ -481,7 +483,7 @@ templ Compare(l Layout, p ComparePage) {
481483 <label for="mr-title">title</label>
482484 <input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
483485 <label for="mr-desc">description</label>
484- <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="plain text, optional"></textarea>
486+ <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="Markdown, optional"></textarea>
485487 <div class="f-btns">
486488 <button type="submit" class="btn primary">Create merge request</button>
487489 </div>
@@ -140,8 +140,10 @@ templ MergeRequest(l Layout, p MRPage) {
140 </dl>140 </dl>
141 </header>141 </header>
142 @LiveSlot("mr-notice")142 @LiveSlot("mr-notice")
143- if len(p.Desc) > 0 {143+ if p.Desc != "" {
144- <p class="mr-desc">@userText(p.Desc)</p>144+ <article class="mr-desc prose" aria-label="description" hx-ignore>
145+ @templ.Raw(string(p.Desc))
146+ </article>
145 }147 }
146 if p.Since != nil {148 if p.Since != nil {
147 <nav class="since" aria-label="Diff range">149 <nav class="since" aria-label="Diff range">
@@ -481,7 +483,7 @@ templ Compare(l Layout, p ComparePage) {
481 <label for="mr-title">title</label>483 <label for="mr-title">title</label>
482 <input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>484 <input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
483 <label for="mr-desc">description</label>485 <label for="mr-desc">description</label>
484- <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="plain text, optional"></textarea>486+ <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="Markdown, optional"></textarea>
485 <div class="f-btns">487 <div class="f-btns">
486 <button type="submit" class="btn primary">Create merge request</button>488 <button type="submit" class="btn primary">Create merge request</button>
487 </div>489 </div>
M internal/adapters/web/mr_templ.go +100 -100 generated file: collapsed
M internal/adapters/web/pages.templ +3 -2
@@ -68,10 +68,11 @@ templ latestTagFact(t LatestTag) {
6868 <dt>latest tag</dt>
6969 <dd class="latest-tag">
7070 if t.Name != "" {
71- <a class="tag-name" href={ templ.SafeURL(t.Href) } title={ t.Subject }><span class="ico i-tag" aria-hidden="true"></span>{ t.Name }</a>
71+ <a class="tag-name" href={ templ.SafeURL(t.Href) } title={ t.Subject }>{ t.Name }</a>
7272 if t.WhenAt != "" {
73- <time class="dim" datetime={ t.WhenAt } title={ t.WhenAt }>{ t.When }</time>
73+ { " · " }<time datetime={ t.WhenAt } title={ t.WhenAt }>{ t.When }</time>
7474 }
75+ { " · " }
7576 }
7677 <a class="all-tags" href={ templ.SafeURL(t.RefsHref) }>{ plural(t.Count, "tag", "tags") }</a>
7778 </dd>
@@ -68,10 +68,11 @@ templ latestTagFact(t LatestTag) {
68 <dt>latest tag</dt>68 <dt>latest tag</dt>
69 <dd class="latest-tag">69 <dd class="latest-tag">
70 if t.Name != "" {70 if t.Name != "" {
71- <a class="tag-name" href={ templ.SafeURL(t.Href) } title={ t.Subject }><span class="ico i-tag" aria-hidden="true"></span>{ t.Name }</a>71+ <a class="tag-name" href={ templ.SafeURL(t.Href) } title={ t.Subject }>{ t.Name }</a>
72 if t.WhenAt != "" {72 if t.WhenAt != "" {
73- <time class="dim" datetime={ t.WhenAt } title={ t.WhenAt }>{ t.When }</time>73+ { " · " }<time datetime={ t.WhenAt } title={ t.WhenAt }>{ t.When }</time>
74 }74 }
75+ { " · " }
75 }76 }
76 <a class="all-tags" href={ templ.SafeURL(t.RefsHref) }>{ plural(t.Count, "tag", "tags") }</a>77 <a class="all-tags" href={ templ.SafeURL(t.RefsHref) }>{ plural(t.Count, "tag", "tags") }</a>
77 </dd>78 </dd>
M internal/adapters/web/pages_templ.go +325 -299 generated file: collapsed
M internal/adapters/web/shell.templ +0 -10
@@ -128,16 +128,6 @@ templ Header(l Layout) {
128128 <span class="search-label">Go to…</span>
129129 <kbd aria-hidden="true">ctrl k</kbd>
130130 </button>
131- <button
132- type="button"
133- class="btn icon"
134- data-install
135- hidden
136- aria-label="Install app"
137- title="install ermite as an app"
138- >
139- <span class="ico i-download" aria-hidden="true"></span>
140- </button>
141131 if SignedIn(ctx) {
142132 <form class="owner" method="post" action="/-/logout">
143133 <span class="owner-badge" title="signed in as the owner"><span class="ico i-user" aria-hidden="true"></span>{ l.Owner }</span>
@@ -128,16 +128,6 @@ templ Header(l Layout) {
128 <span class="search-label">Go to…</span>128 <span class="search-label">Go to…</span>
129 <kbd aria-hidden="true">ctrl k</kbd>129 <kbd aria-hidden="true">ctrl k</kbd>
130 </button>130 </button>
131- <button
132- type="button"
133- class="btn icon"
134- data-install
135- hidden
136- aria-label="Install app"
137- title="install ermite as an app"
138- >
139- <span class="ico i-download" aria-hidden="true"></span>
140- </button>
141 if SignedIn(ctx) {131 if SignedIn(ctx) {
142 <form class="owner" method="post" action="/-/logout">132 <form class="owner" method="post" action="/-/logout">
143 <span class="owner-badge" title="signed in as the owner"><span class="ico i-user" aria-hidden="true"></span>{ l.Owner }</span>133 <span class="owner-badge" title="signed in as the owner"><span class="ico i-user" aria-hidden="true"></span>{ l.Owner }</span>
M internal/adapters/web/shell_templ.go +11 -11
@@ -565,7 +565,7 @@ func Header(l Layout) templ.Component {
565565 return templ_7745c5c3_Err
566566 }
567567 }
568- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-install hidden aria-label=\"Install app\" title=\"install ermite as an app\"><span class=\"ico i-download\" aria-hidden=\"true\"></span></button> ")
568+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
569569 if templ_7745c5c3_Err != nil {
570570 return templ_7745c5c3_Err
571571 }
@@ -577,7 +577,7 @@ func Header(l Layout) templ.Component {
577577 var templ_7745c5c3_Var30 string
578578 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
579579 if templ_7745c5c3_Err != nil {
580- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 123}
580+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 123}
581581 }
582582 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
583583 if templ_7745c5c3_Err != nil {
@@ -620,7 +620,7 @@ func Header(l Layout) templ.Component {
620620 var templ_7745c5c3_Var31 templ.SafeURL
621621 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
622622 if templ_7745c5c3_Err != nil {
623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 160, Col: 36}
623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 150, Col: 36}
624624 }
625625 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
626626 if templ_7745c5c3_Err != nil {
@@ -633,7 +633,7 @@ func Header(l Layout) templ.Component {
633633 var templ_7745c5c3_Var32 string
634634 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
635635 if templ_7745c5c3_Err != nil {
636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 161, Col: 24}
636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 151, Col: 24}
637637 }
638638 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
639639 if templ_7745c5c3_Err != nil {
@@ -646,7 +646,7 @@ func Header(l Layout) templ.Component {
646646 var templ_7745c5c3_Var33 string
647647 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
648648 if templ_7745c5c3_Err != nil {
649- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 162, Col: 33}
649+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 152, Col: 33}
650650 }
651651 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
652652 if templ_7745c5c3_Err != nil {
@@ -669,7 +669,7 @@ func Header(l Layout) templ.Component {
669669 var templ_7745c5c3_Var34 string
670670 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
671671 if templ_7745c5c3_Err != nil {
672- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 167, Col: 17}
672+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 17}
673673 }
674674 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
675675 if templ_7745c5c3_Err != nil {
@@ -687,7 +687,7 @@ func Header(l Layout) templ.Component {
687687 var templ_7745c5c3_Var35 string
688688 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
689689 if templ_7745c5c3_Err != nil {
690- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 169, Col: 38}
690+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 38}
691691 }
692692 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
693693 if templ_7745c5c3_Err != nil {
@@ -700,7 +700,7 @@ func Header(l Layout) templ.Component {
700700 var templ_7745c5c3_Var36 string
701701 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
702702 if templ_7745c5c3_Err != nil {
703- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 169, Col: 66}
703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 66}
704704 }
705705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
706706 if templ_7745c5c3_Err != nil {
@@ -718,7 +718,7 @@ func Header(l Layout) templ.Component {
718718 var templ_7745c5c3_Var37 string
719719 templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
720720 if templ_7745c5c3_Err != nil {
721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 171, Col: 39}
721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 161, Col: 39}
722722 }
723723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
724724 if templ_7745c5c3_Err != nil {
@@ -772,7 +772,7 @@ func Footer(l Layout) templ.Component {
772772 var templ_7745c5c3_Var39 string
773773 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
774774 if templ_7745c5c3_Err != nil {
775- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 185, Col: 26}
775+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 175, Col: 26}
776776 }
777777 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
778778 if templ_7745c5c3_Err != nil {
@@ -785,7 +785,7 @@ func Footer(l Layout) templ.Component {
785785 var templ_7745c5c3_Var40 templ.SafeURL
786786 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
787787 if templ_7745c5c3_Err != nil {
788- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 186, Col: 38}
788+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 38}
789789 }
790790 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
791791 if templ_7745c5c3_Err != nil {
@@ -565,7 +565,7 @@ func Header(l Layout) templ.Component {
565 return templ_7745c5c3_Err565 return templ_7745c5c3_Err
566 }566 }
567 }567 }
568- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-install hidden aria-label=\"Install app\" title=\"install ermite as an app\"><span class=\"ico i-download\" aria-hidden=\"true\"></span></button> ")568+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
569 if templ_7745c5c3_Err != nil {569 if templ_7745c5c3_Err != nil {
570 return templ_7745c5c3_Err570 return templ_7745c5c3_Err
571 }571 }
@@ -577,7 +577,7 @@ func Header(l Layout) templ.Component {
577 var templ_7745c5c3_Var30 string577 var templ_7745c5c3_Var30 string
578 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)578 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
579 if templ_7745c5c3_Err != nil {579 if templ_7745c5c3_Err != nil {
580- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 123}580+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 123}
581 }581 }
582 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))582 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
583 if templ_7745c5c3_Err != nil {583 if templ_7745c5c3_Err != nil {
@@ -620,7 +620,7 @@ func Header(l Layout) templ.Component {
620 var templ_7745c5c3_Var31 templ.SafeURL620 var templ_7745c5c3_Var31 templ.SafeURL
621 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))621 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
622 if templ_7745c5c3_Err != nil {622 if templ_7745c5c3_Err != nil {
623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 160, Col: 36}623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 150, Col: 36}
624 }624 }
625 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))625 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
626 if templ_7745c5c3_Err != nil {626 if templ_7745c5c3_Err != nil {
@@ -633,7 +633,7 @@ func Header(l Layout) templ.Component {
633 var templ_7745c5c3_Var32 string633 var templ_7745c5c3_Var32 string
634 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)634 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
635 if templ_7745c5c3_Err != nil {635 if templ_7745c5c3_Err != nil {
636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 161, Col: 24}636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 151, Col: 24}
637 }637 }
638 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)638 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
639 if templ_7745c5c3_Err != nil {639 if templ_7745c5c3_Err != nil {
@@ -646,7 +646,7 @@ func Header(l Layout) templ.Component {
646 var templ_7745c5c3_Var33 string646 var templ_7745c5c3_Var33 string
647 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)647 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
648 if templ_7745c5c3_Err != nil {648 if templ_7745c5c3_Err != nil {
649- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 162, Col: 33}649+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 152, Col: 33}
650 }650 }
651 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)651 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
652 if templ_7745c5c3_Err != nil {652 if templ_7745c5c3_Err != nil {
@@ -669,7 +669,7 @@ func Header(l Layout) templ.Component {
669 var templ_7745c5c3_Var34 string669 var templ_7745c5c3_Var34 string
670 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)670 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
671 if templ_7745c5c3_Err != nil {671 if templ_7745c5c3_Err != nil {
672- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 167, Col: 17}672+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 17}
673 }673 }
674 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))674 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
675 if templ_7745c5c3_Err != nil {675 if templ_7745c5c3_Err != nil {
@@ -687,7 +687,7 @@ func Header(l Layout) templ.Component {
687 var templ_7745c5c3_Var35 string687 var templ_7745c5c3_Var35 string
688 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)688 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
689 if templ_7745c5c3_Err != nil {689 if templ_7745c5c3_Err != nil {
690- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 169, Col: 38}690+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 38}
691 }691 }
692 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))692 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
693 if templ_7745c5c3_Err != nil {693 if templ_7745c5c3_Err != nil {
@@ -700,7 +700,7 @@ func Header(l Layout) templ.Component {
700 var templ_7745c5c3_Var36 string700 var templ_7745c5c3_Var36 string
701 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(" open")701 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
702 if templ_7745c5c3_Err != nil {702 if templ_7745c5c3_Err != nil {
703- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 169, Col: 66}703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 66}
704 }704 }
705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
706 if templ_7745c5c3_Err != nil {706 if templ_7745c5c3_Err != nil {
@@ -718,7 +718,7 @@ func Header(l Layout) templ.Component {
718 var templ_7745c5c3_Var37 string718 var templ_7745c5c3_Var37 string
719 templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)719 templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
720 if templ_7745c5c3_Err != nil {720 if templ_7745c5c3_Err != nil {
721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 171, Col: 39}721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 161, Col: 39}
722 }722 }
723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
724 if templ_7745c5c3_Err != nil {724 if templ_7745c5c3_Err != nil {
@@ -772,7 +772,7 @@ func Footer(l Layout) templ.Component {
772 var templ_7745c5c3_Var39 string772 var templ_7745c5c3_Var39 string
773 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)773 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
774 if templ_7745c5c3_Err != nil {774 if templ_7745c5c3_Err != nil {
775- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 185, Col: 26}775+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 175, Col: 26}
776 }776 }
777 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))777 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
778 if templ_7745c5c3_Err != nil {778 if templ_7745c5c3_Err != nil {
@@ -785,7 +785,7 @@ func Footer(l Layout) templ.Component {
785 var templ_7745c5c3_Var40 templ.SafeURL785 var templ_7745c5c3_Var40 templ.SafeURL
786 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))786 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
787 if templ_7745c5c3_Err != nil {787 if templ_7745c5c3_Err != nil {
788- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 186, Col: 38}788+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 38}
789 }789 }
790 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))790 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
791 if templ_7745c5c3_Err != nil {791 if templ_7745c5c3_Err != nil {
M internal/adapters/web/static/css/20-shell.css +5 -9
@@ -26,7 +26,6 @@
2626 .i-key { --i: url(icons/key.svg); }
2727 .i-log-in { --i: url(icons/log-in.svg); }
2828 .i-log-out { --i: url(icons/log-out.svg); }
29- .i-download { --i: url(icons/download.svg); }
3029
3130 .pill {
3231 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
@@ -135,15 +134,12 @@
135134 .rail section > .list { margin: var(--s2) 0; }
136135 .rail section > p + p { margin-top: calc(-1 * var(--s2)); }
137136
138- /* newest tag, a row of About in the summary rail */
137+ /* newest tag, a row of About in the summary rail: one line of text like
138+ the rows above it ("v0.9.0 · 36h ago · 9 tags") */
139139 .kv dd.latest-tag {
140- display: flex; align-items: baseline; gap: var(--s2);
141- & .tag-name { display: inline-flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; font-weight: 700; font-family: var(--mono); color: var(--ink-hi); text-decoration: none; }
142- & .tag-name .ico { flex: none; }
143- & .tag-name:hover { text-decoration: underline; }
144- & time { white-space: nowrap; }
145- & .all-tags { margin-left: auto; white-space: nowrap; color: var(--ink-dim); text-decoration: none; }
146- & .all-tags:hover { color: var(--ink-hi); text-decoration: underline; }
140+ & a { color: inherit; text-decoration: none; }
141+ & .tag-name { font-weight: 700; color: var(--ink-hi); }
142+ & a:hover { color: var(--ink-hi); text-decoration: underline; }
147143 }
148144
149145 .kv {
@@ -26,7 +26,6 @@
26 .i-key { --i: url(icons/key.svg); }26 .i-key { --i: url(icons/key.svg); }
27 .i-log-in { --i: url(icons/log-in.svg); }27 .i-log-in { --i: url(icons/log-in.svg); }
28 .i-log-out { --i: url(icons/log-out.svg); }28 .i-log-out { --i: url(icons/log-out.svg); }
29- .i-download { --i: url(icons/download.svg); }
30 29
31 .pill {30 .pill {
32 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);31 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
@@ -135,15 +134,12 @@
135 .rail section > .list { margin: var(--s2) 0; }134 .rail section > .list { margin: var(--s2) 0; }
136 .rail section > p + p { margin-top: calc(-1 * var(--s2)); }135 .rail section > p + p { margin-top: calc(-1 * var(--s2)); }
137 136
138- /* newest tag, a row of About in the summary rail */137+ /* newest tag, a row of About in the summary rail: one line of text like
138+ the rows above it ("v0.9.0 · 36h ago · 9 tags") */
139 .kv dd.latest-tag {139 .kv dd.latest-tag {
140- display: flex; align-items: baseline; gap: var(--s2);140+ & a { color: inherit; text-decoration: none; }
141- & .tag-name { display: inline-flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; font-weight: 700; font-family: var(--mono); color: var(--ink-hi); text-decoration: none; }141+ & .tag-name { font-weight: 700; color: var(--ink-hi); }
142- & .tag-name .ico { flex: none; }142+ & a:hover { color: var(--ink-hi); text-decoration: underline; }
143- & .tag-name:hover { text-decoration: underline; }
144- & time { white-space: nowrap; }
145- & .all-tags { margin-left: auto; white-space: nowrap; color: var(--ink-dim); text-decoration: none; }
146- & .all-tags:hover { color: var(--ink-hi); text-decoration: underline; }
147 }143 }
148 144
149 .kv {145 .kv {
M internal/adapters/web/static/css/60-mr.css +6 -2
@@ -6,8 +6,12 @@
66 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
77 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
88 .mr-state[data-state="draft"]::before { content: "◌ "; color: var(--ink-dim); }
9- .mr-desc { max-width: var(--measure); white-space: pre-wrap; overflow-wrap: anywhere; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
10- .mr-desc a, .c-text a { text-decoration: underline; }
9+ /* the description: Markdown as a README, as wide as the header card above;
10+ the text inside keeps the reading measure */
11+ .mr-desc.prose { max-width: none; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
12+ .mr-desc.prose > * { max-width: var(--measure); }
13+ .mr-desc.prose > pre, .mr-desc.prose > table { max-width: 100%; }
14+ .c-text a { text-decoration: underline; }
1115
1216 .since {
1317 display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);
@@ -6,8 +6,12 @@
6 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }6 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
7 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }7 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
8 .mr-state[data-state="draft"]::before { content: "◌ "; color: var(--ink-dim); }8 .mr-state[data-state="draft"]::before { content: "◌ "; color: var(--ink-dim); }
9- .mr-desc { max-width: var(--measure); white-space: pre-wrap; overflow-wrap: anywhere; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }9+ /* the description: Markdown as a README, as wide as the header card above;
10- .mr-desc a, .c-text a { text-decoration: underline; }10+ the text inside keeps the reading measure */
11+ .mr-desc.prose { max-width: none; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
12+ .mr-desc.prose > * { max-width: var(--measure); }
13+ .mr-desc.prose > pre, .mr-desc.prose > table { max-width: 100%; }
14+ .c-text a { text-decoration: underline; }
11 15
12 .since {16 .since {
13 display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);17 display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);
D internal/adapters/web/static/icons/download.svg +0 -1
@@ -1,1 +0,0 @@
1-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>
@@ -1,1 +0,0 @@
1-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>
M internal/adapters/web/static/js/ermite.js +6 -51
@@ -11,7 +11,7 @@
1111 // 4. The file of the diff in view is marked in the table of contents.
1212 // 5. A handler that fails is shown in the hint badge.
1313 // 6. The lines a diff hides load in place (ermiteGaps; e and E).
14-// 7. The service worker and the "install app" button (ermiteInstall).
14+// 7. The service worker (ermiteInstall).
1515 (() => {
1616 // Marks the page as scripted: what does nothing without scripts (the filters,
1717 // the "go to" and help buttons) is hidden until this class is set (see the
@@ -366,20 +366,12 @@
366366 };
367367 })();
368368
369- // The installed app. The service worker (/-/sw.js) is registered once the page
370- // has loaded. Chrome offers the install by itself only as an icon in the
371- // address bar of a desktop (or a mini-bar on a phone): the page keeps the
372- // beforeinstallprompt event and shows its own button ([data-install] in the
373- // header), which asks the browser to prompt. The button stays hidden when the
374- // app is already running installed or once it was installed. Self-contained:
375- // jstest/install.test.mjs evaluates this block alone.
369+ // The installed app: the service worker (/-/sw.js) is registered once the
370+ // page has loaded. With it and the manifest the browser offers the install
371+ // by itself (the address bar of a desktop, the menu or a banner on a phone);
372+ // the page leaves that offer alone. Self-contained: jstest/install.test.mjs
373+ // evaluates this block alone.
376374 window.ermiteInstall = (() => {
377- let deferred = null;
378- const buttons = () => $$('[data-install]');
379- const show = (on) => { for (const b of buttons()) b.hidden = !on; };
380- const installed = () =>
381- !!window.matchMedia?.('(display-mode: standalone), (display-mode: minimal-ui), (display-mode: window-controls-overlay)').matches ||
382- navigator.standalone === true;
383375 const api = {
384376 // register installs the service worker; a browser without one, or a
385377 // refusal, leaves the site working as before.
@@ -387,44 +379,7 @@
387379 if (!navigator.serviceWorker) return Promise.resolve(null);
388380 return navigator.serviceWorker.register('/-/sw.js', { scope: '/' }).catch(() => null);
389381 },
390- // offer keeps the event the browser sent and shows the button.
391- offer(e) {
392- if (installed()) return;
393- e.preventDefault();
394- deferred = e;
395- show(true);
396- },
397- // prompt asks the browser to show its install dialog. The event works
398- // once: a new one comes if the visitor dismisses the dialog. The button
399- // stays while the dialog is up; once it hides, the focus it held moves
400- // to the search of the header rather than to the body.
401- async prompt() {
402- const e = deferred;
403- if (!e) return '';
404- deferred = null;
405- let outcome = '';
406- try {
407- await e.prompt();
408- outcome = (await e.userChoice).outcome;
409- } catch {
410- outcome = '';
411- }
412- const had = buttons().some((b) => b === document.activeElement);
413- show(false);
414- if (had) document.querySelector('[aria-controls="panel-cmd"]')?.focus?.();
415- return outcome;
416- },
417- done() {
418- deferred = null;
419- show(false);
420- },
421382 };
422- addEventListener('beforeinstallprompt', api.offer);
423- addEventListener('appinstalled', api.done);
424- addEventListener('click', (e) => {
425- // pending is the prompt the last click started (its outcome)
426- if (e.target.closest?.('[data-install]')) api.pending = api.prompt();
427- });
428383 if (document.readyState === 'complete') api.register();
429384 else addEventListener('load', () => api.register(), { once: true });
430385 return api;
@@ -11,7 +11,7 @@
11 // 4. The file of the diff in view is marked in the table of contents.11 // 4. The file of the diff in view is marked in the table of contents.
12 // 5. A handler that fails is shown in the hint badge.12 // 5. A handler that fails is shown in the hint badge.
13 // 6. The lines a diff hides load in place (ermiteGaps; e and E).13 // 6. The lines a diff hides load in place (ermiteGaps; e and E).
14-// 7. The service worker and the "install app" button (ermiteInstall).14+// 7. The service worker (ermiteInstall).
15 (() => {15 (() => {
16 // Marks the page as scripted: what does nothing without scripts (the filters,16 // Marks the page as scripted: what does nothing without scripts (the filters,
17 // the "go to" and help buttons) is hidden until this class is set (see the17 // the "go to" and help buttons) is hidden until this class is set (see the
@@ -366,20 +366,12 @@
366 };366 };
367 })();367 })();
368 368
369- // The installed app. The service worker (/-/sw.js) is registered once the page369+ // The installed app: the service worker (/-/sw.js) is registered once the
370- // has loaded. Chrome offers the install by itself only as an icon in the370+ // page has loaded. With it and the manifest the browser offers the install
371- // address bar of a desktop (or a mini-bar on a phone): the page keeps the371+ // by itself (the address bar of a desktop, the menu or a banner on a phone);
372- // beforeinstallprompt event and shows its own button ([data-install] in the372+ // the page leaves that offer alone. Self-contained: jstest/install.test.mjs
373- // header), which asks the browser to prompt. The button stays hidden when the373+ // evaluates this block alone.
374- // app is already running installed or once it was installed. Self-contained:
375- // jstest/install.test.mjs evaluates this block alone.
376 window.ermiteInstall = (() => {374 window.ermiteInstall = (() => {
377- let deferred = null;
378- const buttons = () => $$('[data-install]');
379- const show = (on) => { for (const b of buttons()) b.hidden = !on; };
380- const installed = () =>
381- !!window.matchMedia?.('(display-mode: standalone), (display-mode: minimal-ui), (display-mode: window-controls-overlay)').matches ||
382- navigator.standalone === true;
383 const api = {375 const api = {
384 // register installs the service worker; a browser without one, or a376 // register installs the service worker; a browser without one, or a
385 // refusal, leaves the site working as before.377 // refusal, leaves the site working as before.
@@ -387,44 +379,7 @@
387 if (!navigator.serviceWorker) return Promise.resolve(null);379 if (!navigator.serviceWorker) return Promise.resolve(null);
388 return navigator.serviceWorker.register('/-/sw.js', { scope: '/' }).catch(() => null);380 return navigator.serviceWorker.register('/-/sw.js', { scope: '/' }).catch(() => null);
389 },381 },
390- // offer keeps the event the browser sent and shows the button.
391- offer(e) {
392- if (installed()) return;
393- e.preventDefault();
394- deferred = e;
395- show(true);
396- },
397- // prompt asks the browser to show its install dialog. The event works
398- // once: a new one comes if the visitor dismisses the dialog. The button
399- // stays while the dialog is up; once it hides, the focus it held moves
400- // to the search of the header rather than to the body.
401- async prompt() {
402- const e = deferred;
403- if (!e) return '';
404- deferred = null;
405- let outcome = '';
406- try {
407- await e.prompt();
408- outcome = (await e.userChoice).outcome;
409- } catch {
410- outcome = '';
411- }
412- const had = buttons().some((b) => b === document.activeElement);
413- show(false);
414- if (had) document.querySelector('[aria-controls="panel-cmd"]')?.focus?.();
415- return outcome;
416- },
417- done() {
418- deferred = null;
419- show(false);
420- },
421 };382 };
422- addEventListener('beforeinstallprompt', api.offer);
423- addEventListener('appinstalled', api.done);
424- addEventListener('click', (e) => {
425- // pending is the prompt the last click started (its outcome)
426- if (e.target.closest?.('[data-install]')) api.pending = api.prompt();
427- });
428 if (document.readyState === 'complete') api.register();383 if (document.readyState === 'complete') api.register();
429 else addEventListener('load', () => api.register(), { once: true });384 else addEventListener('load', () => api.register(), { once: true });
430 return api;385 return api;
M internal/app/testdata/web/blob-binary.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/blob-code.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/blob-empty.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/blob-image.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/blob-markdown.html +0 -4
@@ -69,10 +69,6 @@
6969 <span class="search-label">Go to…</span>
7070 <kbd aria-hidden="true">ctrl k</kbd>
7171 </button>
72-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
73-<span class="ico i-download" aria-hidden="true">
74-</span>
75-</button>
7672 </div>
7773 </div>
7874 <nav class="tabs" aria-label="Sections">
@@ -69,10 +69,6 @@
69 <span class="search-label">Go to…</span>69 <span class="search-label">Go to…</span>
70 <kbd aria-hidden="true">ctrl k</kbd>70 <kbd aria-hidden="true">ctrl k</kbd>
71 </button>71 </button>
72-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
73-<span class="ico i-download" aria-hidden="true">
74-</span>
75-</button>
76 </div>72 </div>
77 </div>73 </div>
78 <nav class="tabs" aria-label="Sections">74 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/blob-svg.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/blob-text.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/blob-too-large.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/blob-trojan.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commit-big.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commit-binary.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commit-merge.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commit-modify-split.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commit-modify.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commit-rename.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commit-root.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commit-trojan.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commits-page2.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commits-path.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commits-root.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/commits-small.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/empty.html +0 -4
@@ -51,10 +51,6 @@
5151 <span class="search-label">Go to…</span>
5252 <kbd aria-hidden="true">ctrl k</kbd>
5353 </button>
54-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
55-<span class="ico i-download" aria-hidden="true">
56-</span>
57-</button>
5854 </div>
5955 </div>
6056 </header>
@@ -51,10 +51,6 @@
51 <span class="search-label">Go to…</span>51 <span class="search-label">Go to…</span>
52 <kbd aria-hidden="true">ctrl k</kbd>52 <kbd aria-hidden="true">ctrl k</kbd>
53 </button>53 </button>
54-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
55-<span class="ico i-download" aria-hidden="true">
56-</span>
57-</button>
58 </div>54 </div>
59 </div>55 </div>
60 </header>56 </header>
M internal/app/testdata/web/home.html +0 -4
@@ -44,10 +44,6 @@
4444 <span class="search-label">Go to…</span>
4545 <kbd aria-hidden="true">ctrl k</kbd>
4646 </button>
47-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
48-<span class="ico i-download" aria-hidden="true">
49-</span>
50-</button>
5147 </div>
5248 </div>
5349 </header>
@@ -44,10 +44,6 @@
44 <span class="search-label">Go to…</span>44 <span class="search-label">Go to…</span>
45 <kbd aria-hidden="true">ctrl k</kbd>45 <kbd aria-hidden="true">ctrl k</kbd>
46 </button>46 </button>
47-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
48-<span class="ico i-download" aria-hidden="true">
49-</span>
50-</button>
51 </div>47 </div>
52 </div>48 </div>
53 </header>49 </header>
M internal/app/testdata/web/not-found-page.html +0 -4
@@ -51,10 +51,6 @@
5151 <span class="search-label">Go to…</span>
5252 <kbd aria-hidden="true">ctrl k</kbd>
5353 </button>
54-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
55-<span class="ico i-download" aria-hidden="true">
56-</span>
57-</button>
5854 </div>
5955 </div>
6056 </header>
@@ -51,10 +51,6 @@
51 <span class="search-label">Go to…</span>51 <span class="search-label">Go to…</span>
52 <kbd aria-hidden="true">ctrl k</kbd>52 <kbd aria-hidden="true">ctrl k</kbd>
53 </button>53 </button>
54-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
55-<span class="ico i-download" aria-hidden="true">
56-</span>
57-</button>
58 </div>54 </div>
59 </div>55 </div>
60 </header>56 </header>
M internal/app/testdata/web/not-found.html +0 -4
@@ -51,10 +51,6 @@
5151 <span class="search-label">Go to…</span>
5252 <kbd aria-hidden="true">ctrl k</kbd>
5353 </button>
54-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
55-<span class="ico i-download" aria-hidden="true">
56-</span>
57-</button>
5854 </div>
5955 </div>
6056 </header>
@@ -51,10 +51,6 @@
51 <span class="search-label">Go to…</span>51 <span class="search-label">Go to…</span>
52 <kbd aria-hidden="true">ctrl k</kbd>52 <kbd aria-hidden="true">ctrl k</kbd>
53 </button>53 </button>
54-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
55-<span class="ico i-download" aria-hidden="true">
56-</span>
57-</button>
58 </div>54 </div>
59 </div>55 </div>
60 </header>56 </header>
M internal/app/testdata/web/refs.html +0 -4
@@ -60,10 +60,6 @@
6060 <span class="search-label">Go to…</span>
6161 <kbd aria-hidden="true">ctrl k</kbd>
6262 </button>
63-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
64-<span class="ico i-download" aria-hidden="true">
65-</span>
66-</button>
6763 </div>
6864 </div>
6965 <nav class="tabs" aria-label="Sections">
@@ -60,10 +60,6 @@
60 <span class="search-label">Go to…</span>60 <span class="search-label">Go to…</span>
61 <kbd aria-hidden="true">ctrl k</kbd>61 <kbd aria-hidden="true">ctrl k</kbd>
62 </button>62 </button>
63-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
64-<span class="ico i-download" aria-hidden="true">
65-</span>
66-</button>
67 </div>63 </div>
68 </div>64 </div>
69 <nav class="tabs" aria-label="Sections">65 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/summary.html +1 -9
@@ -57,10 +57,6 @@
5757 <span class="search-label">Go to…</span>
5858 <kbd aria-hidden="true">ctrl k</kbd>
5959 </button>
60-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
61-<span class="ico i-download" aria-hidden="true">
62-</span>
63-</button>
6460 </div>
6561 </div>
6662 <nav class="tabs" aria-label="Sections">
@@ -153,11 +149,7 @@
153149 </dd>
154150 <dt>latest tag</dt>
155151 <dd class="latest-tag">
156-<a class="tag-name" href="/demo/tree/v0.2.0" title="add main.go">
157-<span class="ico i-tag" aria-hidden="true">
158-</span>v0.2.0</a>
159-<time class="dim" datetime="STAMP" title="STAMP">AGO</time>
160-<a class="all-tags" href="/demo/refs">2 tags</a>
152+<a class="tag-name" href="/demo/tree/v0.2.0" title="add main.go">v0.2.0</a> · <time datetime="STAMP" title="STAMP">AGO</time> · <a class="all-tags" href="/demo/refs">2 tags</a>
161153 </dd>
162154 </dl>
163155 </section>
@@ -57,10 +57,6 @@
57 <span class="search-label">Go to…</span>57 <span class="search-label">Go to…</span>
58 <kbd aria-hidden="true">ctrl k</kbd>58 <kbd aria-hidden="true">ctrl k</kbd>
59 </button>59 </button>
60-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
61-<span class="ico i-download" aria-hidden="true">
62-</span>
63-</button>
64 </div>60 </div>
65 </div>61 </div>
66 <nav class="tabs" aria-label="Sections">62 <nav class="tabs" aria-label="Sections">
@@ -153,11 +149,7 @@
153 </dd>149 </dd>
154 <dt>latest tag</dt>150 <dt>latest tag</dt>
155 <dd class="latest-tag">151 <dd class="latest-tag">
156-<a class="tag-name" href="/demo/tree/v0.2.0" title="add main.go">152+<a class="tag-name" href="/demo/tree/v0.2.0" title="add main.go">v0.2.0</a> · <time datetime="STAMP" title="STAMP">AGO</time> · <a class="all-tags" href="/demo/refs">2 tags</a>
157-<span class="ico i-tag" aria-hidden="true">
158-</span>v0.2.0</a>
159-<time class="dim" datetime="STAMP" title="STAMP">AGO</time>
160-<a class="all-tags" href="/demo/refs">2 tags</a>
161 </dd>153 </dd>
162 </dl>154 </dl>
163 </section>155 </section>
M internal/app/testdata/web/tree-nested.html +0 -4
@@ -69,10 +69,6 @@
6969 <span class="search-label">Go to…</span>
7070 <kbd aria-hidden="true">ctrl k</kbd>
7171 </button>
72-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
73-<span class="ico i-download" aria-hidden="true">
74-</span>
75-</button>
7672 </div>
7773 </div>
7874 <nav class="tabs" aria-label="Sections">
@@ -69,10 +69,6 @@
69 <span class="search-label">Go to…</span>69 <span class="search-label">Go to…</span>
70 <kbd aria-hidden="true">ctrl k</kbd>70 <kbd aria-hidden="true">ctrl k</kbd>
71 </button>71 </button>
72-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
73-<span class="ico i-download" aria-hidden="true">
74-</span>
75-</button>
76 </div>72 </div>
77 </div>73 </div>
78 <nav class="tabs" aria-label="Sections">74 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/tree-root.html +0 -4
@@ -63,10 +63,6 @@
6363 <span class="search-label">Go to…</span>
6464 <kbd aria-hidden="true">ctrl k</kbd>
6565 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
7066 </div>
7167 </div>
7268 <nav class="tabs" aria-label="Sections">
@@ -63,10 +63,6 @@
63 <span class="search-label">Go to…</span>63 <span class="search-label">Go to…</span>
64 <kbd aria-hidden="true">ctrl k</kbd>64 <kbd aria-hidden="true">ctrl k</kbd>
65 </button>65 </button>
66-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
67-<span class="ico i-download" aria-hidden="true">
68-</span>
69-</button>
70 </div>66 </div>
71 </div>67 </div>
72 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/tree-slash-ref.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/testdata/web/tree-subdir.html +0 -4
@@ -66,10 +66,6 @@
6666 <span class="search-label">Go to…</span>
6767 <kbd aria-hidden="true">ctrl k</kbd>
6868 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
7369 </div>
7470 </div>
7571 <nav class="tabs" aria-label="Sections">
@@ -66,10 +66,6 @@
66 <span class="search-label">Go to…</span>66 <span class="search-label">Go to…</span>
67 <kbd aria-hidden="true">ctrl k</kbd>67 <kbd aria-hidden="true">ctrl k</kbd>
68 </button>68 </button>
69-<button type="button" class="btn icon" data-install hidden aria-label="Install app" title="install ermite as an app">
70-<span class="ico i-download" aria-hidden="true">
71-</span>
72-</button>
73 </div>69 </div>
74 </div>70 </div>
75 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
M internal/app/web_mr_test.go +9 -3
@@ -98,7 +98,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
9898 // Open one from the compare page.
9999 res := b.form("/proj/merge-requests", token, url.Values{
100100 "source": {"feat/search"}, "target": {"main"}, "title": {"Add <b>search</b>"},
101- "description": {"Finds things.\r\nSee https://example.org/spec) and javascript:alert(1)"},
101+ "description": {"## Summary\r\n\r\nFinds **things** with `Find()`.\r\n\r\n- See https://example.org/spec) and javascript:alert(1)\r\n- [x](javascript:alert(1)) <script>alert(1)</script>"},
102102 })
103103 require.Equal(t, http.StatusSeeOther, res.status, res.body)
104104 assert.Equal(t, "/proj/merge-requests/1", res.hdr.Get("Location"))
@@ -137,9 +137,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
137137 assert.Contains(t, mr.body, "package search</td>")
138138 assert.Contains(t, mr.body, "no checks")
139139 assert.Contains(t, mr.body, "fast-forward, squash")
140- assert.Contains(t, mr.body, `<a href="https://example.org/spec" rel="noopener nofollow ugc">https://example.org/spec</a>)`, "the address is linked, not its closing bracket")
140+ // The description is Markdown, rendered as a README: no raw HTML, no
141+ // script URL; its heading ids keep apart from the ids of the page.
142+ assert.Contains(t, mr.body, `<article class="mr-desc prose" aria-label="description" hx-ignore>`)
143+ assert.Contains(t, mr.body, `<h2 id="mr-desc-summary">Summary</h2>`)
144+ assert.Contains(t, mr.body, `<strong>things</strong>`)
145+ assert.Contains(t, mr.body, `<code>Find()</code>`)
146+ assert.Contains(t, mr.body, `<a href="https://example.org/spec"`, "a bare address is linked")
141147 assert.NotContains(t, mr.body, `href="javascript:`)
142- assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept")
148+ assert.NotContains(t, mr.body, `<script>alert(1)</script>`)
143149 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")
144150 assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line")
145151 assert.NotContains(t, mr.body, `id="merge-menu"`)
@@ -98,7 +98,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
98 // Open one from the compare page.98 // Open one from the compare page.
99 res := b.form("/proj/merge-requests", token, url.Values{99 res := b.form("/proj/merge-requests", token, url.Values{
100 "source": {"feat/search"}, "target": {"main"}, "title": {"Add <b>search</b>"},100 "source": {"feat/search"}, "target": {"main"}, "title": {"Add <b>search</b>"},
101- "description": {"Finds things.\r\nSee https://example.org/spec) and javascript:alert(1)"},101+ "description": {"## Summary\r\n\r\nFinds **things** with `Find()`.\r\n\r\n- See https://example.org/spec) and javascript:alert(1)\r\n- [x](javascript:alert(1)) <script>alert(1)</script>"},
102 })102 })
103 require.Equal(t, http.StatusSeeOther, res.status, res.body)103 require.Equal(t, http.StatusSeeOther, res.status, res.body)
104 assert.Equal(t, "/proj/merge-requests/1", res.hdr.Get("Location"))104 assert.Equal(t, "/proj/merge-requests/1", res.hdr.Get("Location"))
@@ -137,9 +137,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
137 assert.Contains(t, mr.body, "package search</td>")137 assert.Contains(t, mr.body, "package search</td>")
138 assert.Contains(t, mr.body, "no checks")138 assert.Contains(t, mr.body, "no checks")
139 assert.Contains(t, mr.body, "fast-forward, squash")139 assert.Contains(t, mr.body, "fast-forward, squash")
140- assert.Contains(t, mr.body, `<a href="https://example.org/spec" rel="noopener nofollow ugc">https://example.org/spec</a>)`, "the address is linked, not its closing bracket")140+ // The description is Markdown, rendered as a README: no raw HTML, no
141+ // script URL; its heading ids keep apart from the ids of the page.
142+ assert.Contains(t, mr.body, `<article class="mr-desc prose" aria-label="description" hx-ignore>`)
143+ assert.Contains(t, mr.body, `<h2 id="mr-desc-summary">Summary</h2>`)
144+ assert.Contains(t, mr.body, `<strong>things</strong>`)
145+ assert.Contains(t, mr.body, `<code>Find()</code>`)
146+ assert.Contains(t, mr.body, `<a href="https://example.org/spec"`, "a bare address is linked")
141 assert.NotContains(t, mr.body, `href="javascript:`)147 assert.NotContains(t, mr.body, `href="javascript:`)
142- assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept")148+ assert.NotContains(t, mr.body, `<script>alert(1)</script>`)
143 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")149 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")
144 assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line")150 assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line")
145 assert.NotContains(t, mr.body, `id="merge-menu"`)151 assert.NotContains(t, mr.body, `id="merge-menu"`)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 43

↑↓ moveenter openesc close