on master help sign in
14 files changed+632−31
since splitsnapshots
unifiedsplit
.gitignore+1 LICENSE+21 README.md+212−24 desk/app/shelf.hoon+27 desk/desk.docket-0+9 desk/lib/docket.hoon+223 desk/lib/shelf.hoon+18−5 desk/mar/docket-0.hoon+25 desk/mar/svg.hoon+12 desk/tests/lib/shelf.hoon+31−2 desk/web/favicon.svg+1 desk/web/shelf-icon.svg+1 flake.lock+27 flake.nix+24
.gitignorenew file+1
@@ -0,0 +1 @@
1+/result
LICENSEnew file+21
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2026 iko
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
README.md+212−24
@@ -1,3 +1,5 @@
1+<img src="desk/web/shelf-icon.svg" width="96" height="96" alt="Shelf logo">
2+
13# %shelf
24
35A public web page that lists the apps your Urbit ship publishes, so anyone can
@@ -7,21 +9,42 @@ The page shows your ship's `%treaty` alliance: your own published desks plus
79any desks you republish from other ships. Each app has an Install button that
810opens it in the visitor's own Landscape, and the Dojo `|install` command as a
911fallback. The page follows the visitor's light or dark preference. It has no
10−JavaScript except for the Copy buttons.
12+JavaScript except for the Copy buttons. The same URL also returns the list as
13+JSON.
1114
1215%shelf answers on exactly one path, `/shelf`. Run it behind a reverse proxy
1316that serves it at the root of a dedicated domain (see
14−[Serving it on its own domain](#serving-it-on-its-own-domain)). All of the
15−page's links point at `/`.
17+[Serving it on its own domain](#serving-it-on-its-own-domain)). The page's
18+links and its install form all point at `/`.
1619
1720## Requirements
1821
1922- Kernel `[%zuse 408]`.
20−- `%landscape`, which provides `%treaty`. Fresh ships have it.
23+- `%landscape`, which provides `%treaty` and `%docket`. Fresh ships have it.
24+- Nix, for the one-command build, if you install from source. Without it,
25+ copy `desk/` from a checkout.
2126
2227## Install
2328
24−From the repo root, with your ship running:
29+Install %shelf from ~donmec-sallud, which publishes it:
30+
31+```
32+~sampel-palnet:dojo> |install ~donmec-sallud %shelf
33+```
34+
35+%shelf then gets its updates from ~donmec-sallud.
36+[shelf.iko.soy](https://shelf.iko.soy) is %shelf running on that ship. It
37+lists all the apps ~donmec-sallud offers, %shelf among them.
38+
39+To install from source instead, build the desk:
40+
41+```sh
42+nix build github:iko-soy/shelf
43+```
44+
45+`result` is now the desk folder (see [Building with Nix](#building-with-nix)).
46+Without Nix, use `desk/` from a checkout in its place. Then, with your ship
47+running:
2548
2649```
2750~sampel-palnet:dojo> |new-desk %shelf
@@ -29,7 +52,8 @@ From the repo root, with your ship running:
2952```
3053
3154```sh
32−cp -R desk/* /path/to/pier/shelf/
55+cp -R result/* /path/to/pier/shelf/
56+chmod -R u+w /path/to/pier/shelf # files from the Nix store are read-only
3357```
3458
3559```
@@ -37,17 +61,59 @@ cp -R desk/* /path/to/pier/shelf/
3761~sampel-palnet:dojo> |install our %shelf
3862```
3963
40−The page is now at `https://<your-ship>/shelf`. It is meant to be opened
41−through your own domain, though: on the ship's address the page renders, but
42−its links lead to `/`, which belongs to Landscape.
64+Either way, once %shelf runs, the page is at `https://<your-ship>/shelf`, and
65+Landscape shows a Shelf tile that opens it. It is meant to be opened through
66+your own domain, though: on the ship's address the page renders, but its links
67+lead to `/`, which belongs to Landscape.
68+
69+## Building with Nix
70+
71+`flake.nix` has one output: the desk, `packages.<system>.desk`, which is also
72+the default package. Building it copies `desk/` into the Nix store; nothing is
73+compiled, because Clay compiles the Hoon on your ship when you commit the desk.
74+
75+| Command | Builds |
76+|---|---|
77+| `nix build github:iko-soy/shelf` | the latest commit on the default branch |
78+| `nix build github:iko-soy/shelf/<commit>` | a specific commit |
79+| `nix build` | your checkout, from the repo root |
80+
81+Each one leaves a `result` symlink to the desk in the store:
82+
83+```
84+result/
85+├── desk.bill desk.docket-0 sys.kelvin
86+├── app/ gen/ lib/ mar/ sur/ tests/
87+└── web/
88+```
89+
90+- Files in the store are read-only, so `chmod -R u+w` the mount after copying
91+ them in: the ship has to be able to write to its own mount.
92+- `nix flake check --all-systems` checks the flake on every system it
93+ supports: `x86_64-linux`, `aarch64-linux` and `aarch64-darwin`. Plain
94+ `nix flake check` checks only your own. Current nixpkgs no longer supports
95+ Intel Macs.
96+- nixpkgs is only used for `runCommandLocal`. `nix flake update` moves its
97+ pin in `flake.lock`.
98+- From another flake, for example a NixOS config that sets up a ship, the desk
99+ is:
100+
101+ ```nix
102+ inputs.shelf.url = "github:iko-soy/shelf";
103+ # …
104+ shelf.packages.${system}.desk
105+ ```
43106
44107## Update
45108
46−Copy `desk/*` into the mount again and run `|commit %shelf`. Then check that
47−`.^(cass:clay %cw /=shelf=)` moved: if the new version doesn't build, Clay
48−keeps the old one and logs nothing.
109+A %shelf installed from ~donmec-sallud gets its updates from that ship.
49110
50−%shelf is installed from your own ship, so it gets no over-the-air updates.
111+To update a source install, build and copy the desk into the mount again, as
112+above, and run `|commit %shelf`. Then check that `.^(cass:clay %cw /=shelf=)`
113+moved: if the new version doesn't build, Clay keeps the old one and logs
114+nothing.
115+
116+A source install comes from your own ship, so it gets no over-the-air updates.
51117After a kernel upgrade, Clay holds it until `desk/sys.kelvin` lists the new
52118`%zuse` version; update that file (or pull a release that has) and commit.
53119`+vats %shelf` shows whether it is running or held.
@@ -65,6 +131,11 @@ before you can publish it.
65131 `:treaty|remove-desk ~other-ship %app`, and stop following with
66132 `:treaty|unally ~other-ship`.
67133
134+%shelf has a docket of its own, so `:treaty|publish %shelf` lists it on its
135+own page, where others can install it. The docket carries the app icon
136+inline, as a URL-encoded `data:` URL, so the icon shows in Landscape and on
137+other ships' pages with nothing to host.
138+
68139A republished app appears once your ship has received its docket from the
69140publisher, which happens a few seconds after you ally them. Apps are sorted by
70141title. %shelf reads the alliance on every request, but browsers may show a
@@ -90,7 +161,7 @@ apps.example.com {
90161- `redir` sends every path except `/` back to `/`, so nothing else on your
91162 ship can be reached through this domain.
92163- `rewrite` replaces only the path, so the query string the page relies on
93− (`/?install=…`) reaches the ship intact.
164+ (`/?install=…`, `/?asset=…`) reaches the ship intact.
94165- `Forwarded` tells Eyre the visitor came over HTTPS. Eyre trusts it only
95166 from `127.0.0.1`, hence the IP instead of `localhost`. Without it, a ship
96167 that also serves its own TLS redirects every proxied request to its HTTPS
@@ -100,6 +171,10 @@ apps.example.com {
100171 another proxy, pass `Host` through (nginx: `proxy_set_header Host $host;`)
101172 and send `Forwarded` (or `X-Forwarded-Proto`) the same way.
102173- Your ship's own domain is unaffected: `/` still goes to Landscape.
174+- %shelf serves everything the page needs from the desk, at the same URL: the
175+ CSS and JavaScript inline, and the favicon at `/?asset=favicon.svg`. It
176+ also serves the app icon at `/?asset=shelf-icon.svg`, though the docket
177+ carries that inline. Nothing else needs to be reachable.
103178
104179This config was tested with Caddy 2.11 in front of a fake ship.
105180
@@ -142,12 +217,15 @@ curl -H 'Accept: application/json' https://apps.example.com/
142217- `image` is `null` when the docket has none, and `website` is `""`.
143218- `image` and `website` are the docket's values as they are. Dockets can come
144219 from other ships, and unlike the page, the JSON doesn't filter these, so
145− check for `http(s)` before using them.
220+ check them before using them. `image` may also be an inline
221+ `data:image/…` URL.
222+- Text that isn't valid UTF-8 comes out with U+FFFD in its place, so the
223+ listing is always valid JSON.
146224- Entries are sorted like the page. Key order within an object is not fixed.
147225- JSON wins when the header ranks `application/json` above `text/html`. When
148226 the two rank equally, the more specific match wins, so
149227 `application/json, */*` gets JSON. A full tie, a bare `*/*` or a missing
150− header gets HTML.
228+ header gets HTML. Repeated `Accept` lines count as one list.
151229- The response has `Access-Control-Allow-Origin: *`, varies on `Accept` and
152230 `Origin`, and is `private, max-age=60`. It is private because Eyre attaches
153231 its session cookie to every response, and on your own logged-in requests
@@ -159,10 +237,10 @@ curl -H 'Accept: application/json' https://apps.example.com/
159237 URL (for example `sampel-palnet.tlon.network`) and shows the Dojo command.
1602382. The form is POSTed. %shelf checks the URL and keeps only its scheme, host
161239 and port. It remembers that origin in a cookie for a year, then redirects
162− to
163− `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
240+ to `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
164241 Landscape's own "Get App" dialog. The visitor's ship fetches the app's
165− details directly from the publisher.
242+ details directly from the publisher, and a visitor who isn't logged in
243+ lands there after their ship's login page.
1662443. On later visits, Install links straight there. The button reads
167245 `Install on ~sampel-palnet` when the URL's first label is a planet name
168246 (or a moon or comet), or any ship name on `arvo.network`, `tlon.network`
@@ -178,8 +256,9 @@ Guards:
178256 docket image URL pointing at your domain, can plant a URL in a visitor's
179257 cookie or bounce them elsewhere through your domain.
180258- Only apps actually on the shelf can be installed through the form.
181−- On the page, image and website URLs from dockets are used only if they are
182− `http(s)`. Dockets can come from other ships.
259+- On the page, docket images are used only if they are `http(s)` or
260+ `data:image/…` URLs, and website links only if they are `http(s)`.
261+ Dockets can come from other ships.
183262
184263## Traffic
185264
@@ -206,8 +285,13 @@ after %shelf stops. Until you remove it, `/shelf` answers 503 at once.
2062851. Remove the site from your reverse proxy first. Otherwise visitors get that
207286 503 page, or, once the binding is gone, an endless redirect loop between
208287 Landscape's login page and `/`.
209−2. `|uninstall %shelf`. Your `head`/`body` HTML is kept, and installing again
210− brings the page straight back.
288+2. `:docket|uninstall %shelf`, or Uninstall from the tile's menu in
289+ Landscape. This removes the tile and suspends the agent; a plain
290+ `|uninstall %shelf` would leave a suspended tile behind. Your `head`/`body`
291+ HTML is kept, and `|install our %shelf` brings the page straight back.
292+ Uninstalling a %shelf installed from ~donmec-sallud also stops its updates.
293+ To turn them back on, run `|install ~donmec-sallud %shelf` after
294+ `|install our %shelf`.
2112953. To delete that state too: `|nuke %shelf, =desk &` (answer `y`).
2122964. Optionally, free the path. Only the binding's owner can remove it, so take
213297 it over from the dojo, then remove it:
@@ -220,19 +304,116 @@ after %shelf stops. Until you remove it, `/shelf` answers 503 at once.
220304 Installing %shelf again binds `/shelf` again. %shelf re-binds every time
221305 it loads, so it also restores the binding if someone removed it by hand.
222306
307+## Changes from the design handoffs
308+
309+%shelf follows two design handoffs: the page itself (layout, copy, behaviour,
310+JSON, a skeleton agent) and a later icon update. Rendered with the mock's sample
311+data, the page matches the design mock pixel for pixel in light and dark mode,
312+apart from the icons and names. Everything below differs from the handoffs,
313+and why.
314+
315+**Serving**
316+- **One path, `/shelf`, instead of `/`.** On most ships Landscape's `%docket`
317+ owns `/`, and Eyre doesn't refuse a second claim: it silently hands `/`
318+ over, which would take away the owner's login page. A dedicated domain
319+ mapped onto `/shelf` gives visitors a clean root URL instead.
320+- **One URL for everything.** Because only `/` reaches the ship through that
321+ domain, the CSS and JavaScript are inlined (not `/shelf.css`, `/copy.js`),
322+ the icons are at `/?asset=…` (not `/favicon.svg`, `/shelf-icon.svg`), the
323+ install form posts to `/` (not `/install`), and Change ship is `/?forget`
324+ (not `/forget`). Other paths are the proxy's job; the ship answers them
325+ with 404.
326+- **JSON by `Accept` header at the same URL, not `/apps.json`, and
327+ `private`.** The single URL rules out `/apps.json`, and `public` caching
328+ could hand an owner's login cookie to a shared cache.
329+- **HTML varies on `Cookie` as well as `Accept`.** Otherwise a browser shows
330+ its cached copy for up to a minute after Change ship or a save.
331+- **Bodies go out in 16 KiB pieces**, because a single large response gets
332+ quadratically slower on its way out of the ship.
333+
334+**Installing**
335+- **The install form is a POST, accepted only from the page itself, and only
336+ for apps on the shelf.** A GET could be triggered by a link or by another
337+ publisher's docket image, planting a phishing URL in visitors' cookies.
338+- **Deep link:** `<ship>/apps/landscape/search/direct/apps/~pub/desk`,
339+ Landscape's own "show install" route, tested end to end. The handoff's
340+ `…/leap/search/…` also works, but only through a redirect.
341+- **Saved ship URLs keep only scheme, host and port**, so a pasted
342+ `…/apps/groups` doesn't break the deep link. `localhost` and `127.*`
343+ default to `http://`, and ports must be 1–65535.
344+- **The cookie holds the URL as is (not URL-encoded), `HttpOnly`, and gets
345+ `Secure` when the visitor came over HTTPS**, as `Forwarded` or
346+ `X-Forwarded-Proto` says behind a proxy. The normalized URL only contains
347+ cookie-safe characters.
348+- **"Install on ~ship" needs a planet or longer name, or a known ship host.**
349+ The handoff's rule (any first DNS label that parses as an `@p`) turned
350+ `dev.example.com` into "Install on ~dev".
351+- **Returning visitors see "Installs open on ~ship. Change ship"** in the
352+ masthead, a muted link that clears the cookie. The handoff left this open.
353+
354+**Reading the alliance**
355+- **Own desks' treaties are built from Clay**, from each desk's
356+ `desk.docket-0`, as `%treaty` does. `%treaty`'s `/treaty/<ship>/<desk>`
357+ scry only covers other ships' desks and crashes for your own.
358+- **Only scries that can't fail.** The handoff wrapped scries in `mule`, but
359+ inside an agent `mule` can't catch a failed scry: the request just fails.
360+- **Visitors arrive under an Eyre guest identity**, so the owner check
361+ applies only to the settings pokes. Checking every request would 500 for
362+ all visitors.
363+- **Docket text from other ships is handled defensively:** images only
364+ `http(s)` or `data:image/…`, links only `http(s)`, invalid UTF-8 replaced
365+ in the JSON, and a title that starts with a control character still
366+ renders.
367+
368+**Page**
369+- **Header and rows share one grid (CSS subgrid)**, so columns stay aligned
370+ when Install labels get wider for a saved ship.
371+- **Narrow screens:** with a saved ship the stacked layout starts at 1040px
372+ instead of 860px, and the install button's text wraps; the URL field gets
373+ its own line; long words wrap instead of overflowing.
374+- **Keyboard focus inside a row tints it**, as the handoff's interaction table
375+ says (its CSS only did this on hover).
376+- **"1 app"**, not "1 apps".
377+- **Republished apps say "from ~publisher"**, not "by ~publisher".
378+- **The sigil is drawn in Hoon** by a vendored renderer, in `currentColor` on
379+ `var(--ink)` so it inverts with the theme, at sigil-js's 56px geometry.
380+ Moons and comets get the plain square, as in Landscape.
381+- **New: owner-set raw HTML in `<head>` and `<body>`**, for analytics.
382+
383+**Icons and docket** (the icon update)
384+- **The docket's `image` is the icon inline, as a URL-encoded `data:` URL**,
385+ instead of an absolute URL on the ship, which would need filling in per
386+ ship and would break wherever that ship can't be reached.
387+- **The tile opens `/shelf`** (`site+/shelf`, not `site+/`), and the docket's
388+ website is this repo.
389+- **The docket's description drops "with one-click install"**: first-time
390+ visitors enter their ship's URL before the Install button takes them there.
391+- **`mar/svg` is the upstream mark from `%base`**, not the handoff's new one.
392+- **Icons are served `private`** for the same login-cookie reason as the
393+ JSON.
394+
395+**Other**
396+- **Kernel `[%zuse 408]`**, the current release, replacing the handoff's
397+ placeholder 409.
398+
223399## Development
224400
225401The desk is self-contained. `desk/` holds everything Clay needs:
226402
227403| Path | What |
228404|---|---|
405+| `desk.docket-0` | What Landscape shows: title, description, color, icon, version, website, license. Its `image` is `web/shelf-icon.svg` URL-encoded; a test checks they match |
229406| `app/shelf.hoon` | The agent: Eyre binding, routing, scries, responses |
230407| `lib/shelf.hoon` | Pure helpers and the page (Sail) |
231408| `sur/shelf.hoon`, `mar/shelf/action.hoon`, `gen/shelf/` | The `head`/`body` settings |
232409| `web/shelf.css`, `web/copy.js` | Inlined into the page at build time with `/*` |
410+| `web/shelf-icon.svg`, `web/favicon.svg` | The app icon (also inlined in the docket) and the favicon, served at `?asset=` |
233411| `tests/lib/shelf.hoon` | Unit tests, including the rendered page |
234412| `lib/sigil.hoon`, `lib/sigil/symbols.hoon` | Vendored [sigil renderer](https://github.com/Fang-/suite) (MIT, ~paldev; shapes from `@urbit/sigil-js`, MIT, Tlon) |
235−| other `lib/`, `mar/`, `sur/` files | Copied unmodified from `%base` (urbit/urbit `5a187fe`) and `%landscape` (`sur/treaty`, `sur/docket`; tloncorp/landscape `46c6ed8`) |
413+| other `lib/`, `mar/`, `sur/` files | Copied unmodified from `%base` (urbit/urbit `5a187fe`, including `mar/svg`) and `%landscape` (`sur/treaty`, `sur/docket`, `lib/docket`, `mar/docket-0`; tloncorp/landscape `46c6ed8`) |
414+
415+Outside the desk, `flake.nix` and `flake.lock` define the Nix build (see
416+[Building with Nix](#building-with-nix)).
236417
237418Run the tests on a ship with the desk installed:
238419
@@ -254,3 +435,10 @@ Some details that are easy to get wrong:
254435- **Clay can refuse a commit silently.** It refuses commits that would stop a
255436 running agent from building, and logs nothing. After `|commit`, check that
256437 `.^(cass:clay %cw /=shelf=)` moved.
438+
439+## License
440+
441+MIT; see [LICENSE](LICENSE). The vendored files keep their own notices:
442+`lib/sigil.hoon` and `lib/sigil/symbols.hoon` are MIT (Fang; shapes from
443+`@urbit/sigil-js`, Tlon), and the files from `%base` and `%landscape` come
444+from urbit/urbit (MIT) and tloncorp/landscape.
desk/app/shelf.hoon+27
@@ -7,6 +7,9 @@
77:: Meant to sit behind a reverse proxy that maps a dedicated domain's
88:: / onto /shelf, so the page's links all point at /.
99::
10+:: It also serves its two icons from the desk, at ?asset=favicon.svg
11+:: and ?asset=shelf-icon.svg.
12+::
1013:: The owner can append raw HTML to <head> and <body>, for analytics:
1114:: :shelf|head '<script defer src="…"></script>'
1215:: :shelf|body '…'
@@ -15,6 +18,8 @@
1518/+ default-agent, dbug, server, *shelf
1619/* shelf-css %css /web/shelf/css
1720/* copy-js %js /web/copy/js
21+/* favicon %svg /web/favicon/svg
22+/* shelf-icon %svg /web/shelf-icon/svg
1823|%
1924+$ card card:agent:gall
2025+$ state-0
@@ -112,6 +117,7 @@
112117 ?. ?=(?(%'GET' %'HEAD') method.request.req)
113118 [[405 ~[['allow' 'GET, HEAD, POST']]] ~]
114119 =/ q=(map @t @t) (malt args.rl)
120+ ?^ name=(~(get by q) 'asset') (asset u.name)
115121 ?: (~(has by q) 'forget') (forget hs secure)
116122 ?: (wants-json (header-all 'accept' hs)) listing-json
117123 (page q hs)
@@ -127,6 +133,27 @@
127133 ^- simple-payload:http
128134 [[code ~[['location' loc] ['cache-control' 'no-store']]] ~]
129135::
136+:: +asset: one of the icons in the desk's /web
137+::
138+:: private, like the JSON: Eyre adds its session cookie to every
139+:: response, and for a logged-in owner that is their login token.
140+::
141+++ asset
142+ |= name=@t
143+ ^- simple-payload:http
144+ =/ file=(unit @)
145+ ?+ name ~
146+ %'favicon.svg' `favicon
147+ %'shelf-icon.svg' `shelf-icon
148+ ==
149+ ?~ file (plain 404 'Not found')
150+ :_ `(as-octs:mimes:html u.file)
151+ :- 200
152+ :~ ['content-type' 'image/svg+xml']
153+ ['cache-control' 'private, max-age=86400']
154+ ['x-content-type-options' 'nosniff']
155+ ==
156+::
130157:: +forget: clear the visitor's saved ship
131158::
132159:: Only when the browser is navigating here, not when a docket's image
desk/desk.docket-0new file+9
@@ -0,0 +1,9 @@
1+:~ title+'Shelf'
2+ info+'A public page of the apps this ship publishes.'
3+ color+0xe8.801f
4+ image+'data:image/svg+xml,%3Csvg%20height%3D%22120%22%20viewBox%3D%220%200%20120%20120%22%20width%3D%22120%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22m0%200h120v120h-120z%22%20fill%3D%22%23e8801f%22%2F%3E%3Cg%20fill%3D%22%23151514%22%3E%3Cpath%20d%3D%22m24%2052h20v20h-20z%22%2F%3E%3Cpath%20d%3D%22m50%2052h20v20h-20z%22%2F%3E%3Cpath%20d%3D%22m76%2040h20v20h-20z%22%2F%3E%3Cpath%20d%3D%22m24%2072h72v8h-72z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E'
5+ site+/shelf
6+ version+[0 1 0]
7+ website+'https://github.com/iko-soy/shelf'
8+ license+'MIT'
9+==
desk/lib/docket.hoonnew file+223
@@ -0,0 +1,223 @@
1+/- *docket
2+|%
3+::
4+++ mime
5+ |%
6+ +$ draft
7+ $: title=(unit @t)
8+ info=(unit @t)
9+ color=(unit @ux)
10+ glob-http=(unit [=url hash=@uvH])
11+ glob-ames=(unit [=ship hash=@uvH])
12+ base=(unit term)
13+ site=(unit path)
14+ image=(unit url)
15+ version=(unit version)
16+ website=(unit url)
17+ license=(unit cord)
18+ ==
19+ ::
20+ ++ finalize
21+ |= =draft
22+ ^- (unit docket)
23+ ?~ title.draft ~
24+ ?~ info.draft ~
25+ ?~ color.draft ~
26+ ?~ version.draft ~
27+ ?~ website.draft ~
28+ ?~ license.draft ~
29+ =/ href=(unit href)
30+ ?^ site.draft `[%site u.site.draft]
31+ ?~ base.draft ~
32+ ?^ glob-http.draft
33+ `[%glob u.base hash.u.glob-http %http url.u.glob-http]:draft
34+ ?~ glob-ames.draft
35+ ~
36+ `[%glob u.base hash.u.glob-ames %ames ship.u.glob-ames]:draft
37+ ?~ href ~
38+ =, draft
39+ :- ~
40+ :* %1
41+ u.title
42+ u.info
43+ u.color
44+ u.href
45+ image
46+ u.version
47+ u.website
48+ u.license
49+ ==
50+ ::
51+ ++ from-clauses
52+ =| =draft
53+ |= cls=(list clause)
54+ ^- (unit docket)
55+ =* loop $
56+ ?~ cls (finalize draft)
57+ =* clause i.cls
58+ =. draft
59+ ?- -.clause
60+ %title draft(title `title.clause)
61+ %info draft(info `info.clause)
62+ %color draft(color `color.clause)
63+ %glob-http draft(glob-http `[url hash]:clause)
64+ %glob-ames draft(glob-ames `[ship hash]:clause)
65+ %base draft(base `base.clause)
66+ %site draft(site `path.clause)
67+ %image draft(image `url.clause)
68+ %version draft(version `version.clause)
69+ %website draft(website `website.clause)
70+ %license draft(license `license.clause)
71+ ==
72+ loop(cls t.cls)
73+ ::
74+ ++ to-clauses
75+ |= d=docket
76+ ^- (list clause)
77+ %- zing
78+ :~ :~ title+title.d
79+ info+info.d
80+ color+color.d
81+ version+version.d
82+ website+website.d
83+ license+license.d
84+ ==
85+ ?~ image.d ~ ~[image+u.image.d]
86+ ?: ?=(%site -.href.d) ~[site+path.href.d]
87+ =/ ref=glob-reference glob-reference.href.d
88+ :~ base+base.href.d
89+ ?- -.location.ref
90+ %http [%glob-http url.location.ref hash.ref]
91+ %ames [%glob-ames ship.location.ref hash.ref]
92+ == == ==
93+ ::
94+ ++ spit-clause
95+ |= =clause
96+ ^- tape
97+ %+ weld " {(trip -.clause)}+"
98+ ?+ -.clause "'{(trip +.clause)}'"
99+ %color (scow %ux color.clause)
100+ %site (spud path.clause)
101+ ::
102+ %glob-http
103+ "['{(trip url.clause)}' {(scow %uv hash.clause)}]"
104+ ::
105+ %glob-ames
106+ "[{(scow %p ship.clause)} {(scow %uv hash.clause)}]"
107+ ::
108+ %version
109+ =, version.clause
110+ "[{(scow %ud major)} {(scow %ud minor)} {(scow %ud patch)}]"
111+ ==
112+ ::
113+ ++ spit-docket
114+ |= dock=docket
115+ ^- tape
116+ ;: welp
117+ ":~\0a"
118+ `tape`(zing (join "\0a" (turn (to-clauses dock) spit-clause)))
119+ "\0a=="
120+ ==
121+ --
122+::
123+++ enjs
124+ =, enjs:format
125+ |%
126+ ::
127+ ++ charge-update
128+ |= u=^charge-update
129+ ^- json
130+ %+ frond -.u
131+ ^- json
132+ ?- -.u
133+ %del-charge s+desk.u
134+ ::
135+ %initial
136+ %- pairs
137+ %+ turn ~(tap by initial.u)
138+ |=([=desk c=^charge] [desk (charge c)])
139+ ::
140+ %add-charge
141+ %- pairs
142+ :~ desk+s+desk.u
143+ charge+(charge charge.u)
144+ ==
145+ ==
146+ ::
147+ ++ num
148+ |= a=@u
149+ ^- ^tape
150+ =/ p=json (numb a)
151+ ?> ?=(%n -.p)
152+ (trip p.p)
153+ ::
154+ ++ version
155+ |= v=^version
156+ ^- json
157+ :- %s
158+ %- crip
159+ "{(num major.v)}.{(num minor.v)}.{(num patch.v)}"
160+ ::
161+ ++ merge
162+ |= [a=json b=json]
163+ ^- json
164+ ?> &(?=(%o -.a) ?=(%o -.b))
165+ [%o (~(uni by p.a) p.b)]
166+ ::
167+ ++ href
168+ |= h=^href
169+ %+ frond -.h
170+ ?- -.h
171+ %site s+(spat path.h)
172+ %glob
173+ %- pairs
174+ :~ base+s+base.h
175+ glob-reference+(glob-reference glob-reference.h)
176+ ==
177+ ==
178+ ::
179+ ++ glob-reference
180+ |= ref=^glob-reference
181+ %- pairs
182+ :~ hash+s+(scot %uv hash.ref)
183+ location+(glob-location location.ref)
184+ ==
185+ ::
186+ ++ glob-location
187+ |= loc=^glob-location
188+ ^- json
189+ %+ frond -.loc
190+ ?- -.loc
191+ %http s+url.loc
192+ %ames s+(scot %p ship.loc)
193+ ==
194+ ::
195+ ++ charge
196+ |= c=^charge
197+ %+ merge (docket docket.c)
198+ %- pairs
199+ :~ chad+(chad chad.c)
200+ ==
201+ ::
202+ ++ docket
203+ |= d=^docket
204+ ^- json
205+ %- pairs
206+ :~ title+s+title.d
207+ info+s+info.d
208+ color+s+(scot %ux color.d)
209+ href+(href href.d)
210+ image+?~(image.d ~ s+u.image.d)
211+ version+(version version.d)
212+ license+s+license.d
213+ website+s+website.d
214+ ==
215+ ::
216+ ++ chad
217+ |= c=^chad
218+ %+ frond -.c
219+ ?+ -.c ~
220+ %hung s+err.c
221+ ==
222+ --
223+--
desk/lib/shelf.hoon+18−5
@@ -230,6 +230,18 @@
230230 ?. |(=("https://" t) =("http://" (scag 7 t))) ~
231231 `url
232232::
233+:: +image-url: a URL fit for an <img src>: http(s), or an inline image
234+::
235+:: A docket may carry its icon as a data:image/ URL, so it shows
236+:: without a server. An <img> can't run script, whatever it loads.
237+::
238+++ image-url
239+ |= url=@t
240+ ^- (unit @t)
241+ ?^ (web-url url) `url
242+ ?. =("data:image/" (cass (trip (end [3 11] url)))) ~
243+ `url
244+::
233245:: +header: a request header, matching its name case-insensitively
234246::
235247++ header
@@ -539,10 +551,11 @@
539551 :~ ;meta(charset "utf-8");
540552 ;meta(name "viewport", content "width=device-width, initial-scale=1");
541553 ;meta(name "color-scheme", content "light dark");
542− :: no favicon, so browsers don't ask the proxy for /favicon.ico,
543− :: which it would answer with a second render of this page
554+ :: query-relative, so it resolves on the proxy's / and the
555+ :: ship's /shelf alike; an explicit icon also stops browsers
556+ :: asking the proxy for /favicon.ico, a second page render
544557 ::
545− ;link(rel "icon", href "data:,");
558+ ;link(rel "icon", type "image/svg+xml", href "?asset=favicon.svg");
546559 ;title: {me} · Apps
547560 ;style: {(trip css)}
548561 ==
@@ -613,7 +626,7 @@
613626 =/ pub=tape (scow %p ship.t)
614627 =/ cmd=tape (install-cmd ship.t desk.t)
615628 =/ is-open=? =(open `[ship.t desk.t])
616− =/ img=(unit @t) (biff image.d web-url)
629+ =/ img=(unit @t) (biff image.d image-url)
617630 =/ site=(unit @t) ?:(=('' website.d) ~ (web-url website.d))
618631 ;article.app(id (anchor ship.t desk.t))
619632 ;div.app-row
@@ -625,7 +638,7 @@
625638 ;div.app-name
626639 ;strong: {(trip title.d)}
627640 ;* ?: =(ship.t our) ~
628− :~ ;span.app-by: by {pub}
641+ :~ ;span.app-by: from {pub}
629642 ==
630643 ==
631644 ;div.app-desc
desk/mar/docket-0.hoonnew file+25
@@ -0,0 +1,25 @@
1+/+ dock=docket
2+|_ =docket:dock
3+++ grow
4+ |%
5+ ++ mime
6+ ^- ^mime
7+ [/text/x-docket (as-octt:mimes:html (spit-docket:mime:dock docket))]
8+ ++ noun docket
9+ ++ json (docket:enjs:dock docket)
10+ --
11+++ grab
12+ |%
13+ ::
14+ ++ mime
15+ |= [=mite len=@ud tex=@]
16+ ^- docket:dock
17+ %- need
18+ %- from-clauses:mime:dock
19+ !<((list clause:dock) (slap !>(~) (ream tex)))
20+
21+ ::
22+ ++ noun docket:dock
23+ --
24+++ grad %noun
25+--
desk/mar/svg.hoonnew file+12
@@ -0,0 +1,12 @@
1+|_ dat=@
2+++ grow
3+ |%
4+ ++ mime [/image/'svg+xml' (as-octs:mimes:html dat)]
5+ --
6+++ grab
7+ |%
8+ ++ mime |=([p=mite q=octs] q.q)
9+ ++ noun @
10+ --
11+++ grad %mime
12+--
desk/tests/lib/shelf.hoon+31−2
@@ -1,5 +1,7 @@
11/- docket, treaty
22/+ *test, *shelf
3+/* icon %svg /web/shelf-icon/svg
4+/* dok %docket-0 /desk/docket-0
35|%
46:: fixtures for the page tests
57::
@@ -17,6 +19,13 @@
1719 ~ [0 4 2] 'https://github.com/bus/marginalia' 'MIT'
1820 ==
1921::
22+++ shelfapp
23+ ^- treaty:treaty
24+ :* ~zod %shelf da+~2026.1.1 0v4
25+ %1 'Shelf' 'Its own icon.' 0xe8.801f [%site /shelf]
26+ `'data:image/svg+xml,%3Csvg%3E' [0 1 0] 'https://github.com/iko-soy/shelf' 'MIT'
27+ ==
28+::
2029++ oddity
2130 ^- treaty:treaty
2231 :* ~zod %oddity da+~2026.1.1 0v3
@@ -52,7 +61,8 @@
5261 (expect !>((has doc "<title>~zod · Apps</title>")))
5362 (expect !>((has doc "<style>CSS</style>")))
5463 (expect !>((has doc "<main class=\"page\">")))
55− (expect !>((has doc "<link rel=\"icon\" href=\"data:,\" />")))
64+ %- expect !>
65+ (has doc "<link rel=\"icon\" type=\"image/svg+xml\" href=\"?asset=favicon.svg\" />")
5666 (expect !>((has doc "<script>JS</script>")))
5767 ==
5868::
@@ -60,7 +70,7 @@
6070 =/ doc (render ~zod ~[harbor marginalia oddity] ~ | ~)
6171 ;: weld
6272 (expect !>((has doc "<span class=\"count\">3 apps</span>")))
63− (expect !>(=(1 (count doc "by ~bus"))))
73+ (expect !>(=(1 (count doc "from ~bus"))))
6474 (expect !>((has doc "<section class=\"apps\" aria-label=\"Published apps\">")))
6575 (expect !>((has doc "&lt;Odd &amp; &quot;Quoted&quot;&gt; App")))
6676 (expect !>((has doc "a &lt; b &amp; c &gt; &#39;d&#39;")))
@@ -80,6 +90,10 @@
8090 (expect !>((has doc "New to Urbit?\0a<a href=\"https://urbit.org\"")))
8191 ==
8292::
93+++ test-ui-inline-icon
94+ =/ doc (render ~zod ~[shelfapp] ~ | ~)
95+ (expect !>((has doc "<img src=\"data:image/svg+xml,%3Csvg%3E\" alt=\"\" loading=\"lazy\" />")))
96+::
8397++ test-ui-one-app
8498 =/ doc (render ~zod ~[harbor] ~ | ~)
8599 (expect !>((has doc "<span class=\"count\">1 app</span>")))
@@ -193,6 +207,13 @@
193207 (expect-eq !>('') !>((clean-utf8 '')))
194208 ==
195209::
210+:: the docket's inline icon is web/shelf-icon.svg, URL-encoded
211+::
212+++ test-docket-icon
213+ %+ expect-eq
214+ !>(`(unit @t)``(crip (weld "data:image/svg+xml," (en-urlt:html (trip icon)))))
215+ !>(image.docket.dok)
216+::
196217++ test-treaty-json
197218 =/ one (en:json:html (treaty-json harbor))
198219 =/ two (en:json:html (treaty-json marginalia))
@@ -282,6 +303,14 @@
282303 (expect-eq !>(`(unit @t)`~) !>((web-url '//evil.com/x')))
283304 (expect-eq !>(`(unit @t)`~) !>((web-url 'data:image/png;base64,AAAA')))
284305 (expect-eq !>(`(unit @t)`~) !>((web-url '')))
306+ (expect-eq !>(`(unit @t)``'data:image/png;base64,AAAA') !>((image-url 'data:image/png;base64,AAAA')))
307+ (expect-eq !>(`(unit @t)``'https://a.b/i.png') !>((image-url 'https://a.b/i.png')))
308+ %+ expect-eq !>(`(unit @t)``'data:image/svg+xml,%3Csvg%3E')
309+ !>((image-url 'data:image/svg+xml,%3Csvg%3E'))
310+ ::
311+ (expect-eq !>(`(unit @t)``'DATA:Image/png;base64,AA') !>((image-url 'DATA:Image/png;base64,AA')))
312+ (expect-eq !>(`(unit @t)`~) !>((image-url 'data:text/html,%3Cscript%3E')))
313+ (expect-eq !>(`(unit @t)`~) !>((image-url 'javascript:alert(1)')))
285314 ==
286315::
287316++ test-header
desk/web/favicon.svgnew file+1
@@ -0,0 +1 @@
1+<svg height="32" viewBox="0 0 120 120" width="32" xmlns="http://www.w3.org/2000/svg"><path d="m0 0h120v120h-120z" fill="#e8801f"/><g fill="#151514"><path d="m13.2 49.6h26v26h-26z"/><path d="m47 49.6h26v26h-26z"/><path d="m80.8 34h26v26h-26z"/><path d="m13.2 75.6h93.6v10.4h-93.6z"/></g></svg>
desk/web/shelf-icon.svgnew file+1
@@ -0,0 +1 @@
1+<svg height="120" viewBox="0 0 120 120" width="120" xmlns="http://www.w3.org/2000/svg"><path d="m0 0h120v120h-120z" fill="#e8801f"/><g fill="#151514"><path d="m24 52h20v20h-20z"/><path d="m50 52h20v20h-20z"/><path d="m76 40h20v20h-20z"/><path d="m24 72h72v8h-72z"/></g></svg>
flake.locknew file+27
@@ -0,0 +1,27 @@
1+{
2+ "nodes": {
3+ "nixpkgs": {
4+ "locked": {
5+ "lastModified": 1792210252,
6+ "narHash": "sha256-Mh0PUaIv4nFY+8wAGonaEh8iBzwsmdNlwjVHmFg1Biw=",
7+ "owner": "NixOS",
8+ "repo": "nixpkgs",
9+ "rev": "09063bd1f2b36fd9fd793c36dfade7bd40a93dfc",
10+ "type": "github"
11+ },
12+ "original": {
13+ "owner": "NixOS",
14+ "ref": "nixos-unstable",
15+ "repo": "nixpkgs",
16+ "type": "github"
17+ }
18+ },
19+ "root": {
20+ "inputs": {
21+ "nixpkgs": "nixpkgs"
22+ }
23+ }
24+ },
25+ "root": "root",
26+ "version": 7
27+}
flake.nixnew file+24
@@ -0,0 +1,24 @@
1+{
2+ description = "%shelf: a public web page listing the apps an Urbit ship publishes";
3+
4+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
5+
6+ outputs = { self, nixpkgs }:
7+ let
8+ systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
9+ forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
10+ in
11+ {
12+ # The desk: copy it into a mounted %shelf desk and |commit it.
13+ packages = forAllSystems (pkgs:
14+ let
15+ desk = pkgs.runCommandLocal "shelf-desk" { } ''
16+ cp -r ${./desk} $out
17+ '';
18+ in
19+ {
20+ inherit desk;
21+ default = desk;
22+ });
23+ };
24+}