on b83bb03 help sign in
14 files changed+31−632
since splitsnapshots
unifiedsplit
.gitignore−1 LICENSE−21 README.md+24−212 desk/app/shelf.hoon−27 desk/desk.docket-0−9 desk/lib/docket.hoon−223 desk/lib/shelf.hoon+5−18 desk/mar/docket-0.hoon−25 desk/mar/svg.hoon−12 desk/tests/lib/shelf.hoon+2−31 desk/web/favicon.svg−1 desk/web/shelf-icon.svg−1 flake.lock−27 flake.nix−24
.gitignoredeleted−1
@@ -1 +0,0 @@
1−/result
LICENSEdeleted−21
@@ -1,21 +0,0 @@
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+24−212
@@ -1,5 +1,3 @@
1−<img src="desk/web/shelf-icon.svg" width="96" height="96" alt="Shelf logo">
2−
31# %shelf
42
53A public web page that lists the apps your Urbit ship publishes, so anyone can
@@ -9,42 +7,21 @@ The page shows your ship's `%treaty` alliance: your own published desks plus
97any desks you republish from other ships. Each app has an Install button that
108opens it in the visitor's own Landscape, and the Dojo `|install` command as a
119fallback. The page follows the visitor's light or dark preference. It has no
12−JavaScript except for the Copy buttons. The same URL also returns the list as
13−JSON.
10+JavaScript except for the Copy buttons.
1411
1512%shelf answers on exactly one path, `/shelf`. Run it behind a reverse proxy
1613that serves it at the root of a dedicated domain (see
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 `/`.
14+[Serving it on its own domain](#serving-it-on-its-own-domain)). All of the
15+page's links point at `/`.
1916
2017## Requirements
2118
2219- Kernel `[%zuse 408]`.
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.
20+- `%landscape`, which provides `%treaty`. Fresh ships have it.
2621
2722## Install
2823
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:
24+From the repo root, with your ship running:
4825
4926```
5027~sampel-palnet:dojo> |new-desk %shelf
@@ -52,8 +29,7 @@ running:
5229```
5330
5431```sh
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
32+cp -R desk/* /path/to/pier/shelf/
5733```
5834
5935```
@@ -61,59 +37,17 @@ chmod -R u+w /path/to/pier/shelf # files from the Nix store are read-only
6137~sampel-palnet:dojo> |install our %shelf
6238```
6339
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− ```
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.
10643
10744## Update
10845
109−A %shelf installed from ~donmec-sallud gets its updates from that ship.
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.
11049
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.
50+%shelf is installed from your own ship, so it gets no over-the-air updates.
11751After a kernel upgrade, Clay holds it until `desk/sys.kelvin` lists the new
11852`%zuse` version; update that file (or pull a release that has) and commit.
11953`+vats %shelf` shows whether it is running or held.
@@ -131,11 +65,6 @@ before you can publish it.
13165 `:treaty|remove-desk ~other-ship %app`, and stop following with
13266 `:treaty|unally ~other-ship`.
13367
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−
13968A republished app appears once your ship has received its docket from the
14069publisher, which happens a few seconds after you ally them. Apps are sorted by
14170title. %shelf reads the alliance on every request, but browsers may show a
@@ -161,7 +90,7 @@ apps.example.com {
16190- `redir` sends every path except `/` back to `/`, so nothing else on your
16291 ship can be reached through this domain.
16392- `rewrite` replaces only the path, so the query string the page relies on
164− (`/?install=…`, `/?asset=…`) reaches the ship intact.
93+ (`/?install=…`) reaches the ship intact.
16594- `Forwarded` tells Eyre the visitor came over HTTPS. Eyre trusts it only
16695 from `127.0.0.1`, hence the IP instead of `localhost`. Without it, a ship
16796 that also serves its own TLS redirects every proxied request to its HTTPS
@@ -171,10 +100,6 @@ apps.example.com {
171100 another proxy, pass `Host` through (nginx: `proxy_set_header Host $host;`)
172101 and send `Forwarded` (or `X-Forwarded-Proto`) the same way.
173102- 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.
178103
179104This config was tested with Caddy 2.11 in front of a fake ship.
180105
@@ -217,15 +142,12 @@ curl -H 'Accept: application/json' https://apps.example.com/
217142- `image` is `null` when the docket has none, and `website` is `""`.
218143- `image` and `website` are the docket's values as they are. Dockets can come
219144 from other ships, and unlike the page, the JSON doesn't filter these, so
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.
145+ check for `http(s)` before using them.
224146- Entries are sorted like the page. Key order within an object is not fixed.
225147- JSON wins when the header ranks `application/json` above `text/html`. When
226148 the two rank equally, the more specific match wins, so
227149 `application/json, */*` gets JSON. A full tie, a bare `*/*` or a missing
228− header gets HTML. Repeated `Accept` lines count as one list.
150+ header gets HTML.
229151- The response has `Access-Control-Allow-Origin: *`, varies on `Accept` and
230152 `Origin`, and is `private, max-age=60`. It is private because Eyre attaches
231153 its session cookie to every response, and on your own logged-in requests
@@ -237,10 +159,10 @@ curl -H 'Accept: application/json' https://apps.example.com/
237159 URL (for example `sampel-palnet.tlon.network`) and shows the Dojo command.
2381602. The form is POSTed. %shelf checks the URL and keeps only its scheme, host
239161 and port. It remembers that origin in a cookie for a year, then redirects
240− to `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
162+ to
163+ `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
241164 Landscape's own "Get App" dialog. The visitor's ship fetches the app's
242− details directly from the publisher, and a visitor who isn't logged in
243− lands there after their ship's login page.
165+ details directly from the publisher.
2441663. On later visits, Install links straight there. The button reads
245167 `Install on ~sampel-palnet` when the URL's first label is a planet name
246168 (or a moon or comet), or any ship name on `arvo.network`, `tlon.network`
@@ -256,9 +178,8 @@ Guards:
256178 docket image URL pointing at your domain, can plant a URL in a visitor's
257179 cookie or bounce them elsewhere through your domain.
258180- Only apps actually on the shelf can be installed through the form.
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.
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.
262183
263184## Traffic
264185
@@ -285,13 +206,8 @@ after %shelf stops. Until you remove it, `/shelf` answers 503 at once.
2852061. Remove the site from your reverse proxy first. Otherwise visitors get that
286207 503 page, or, once the binding is gone, an endless redirect loop between
287208 Landscape's login page and `/`.
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`.
209+2. `|uninstall %shelf`. Your `head`/`body` HTML is kept, and installing again
210+ brings the page straight back.
2952113. To delete that state too: `|nuke %shelf, =desk &` (answer `y`).
2962124. Optionally, free the path. Only the binding's owner can remove it, so take
297213 it over from the dojo, then remove it:
@@ -304,116 +220,19 @@ after %shelf stops. Until you remove it, `/shelf` answers 503 at once.
304220 Installing %shelf again binds `/shelf` again. %shelf re-binds every time
305221 it loads, so it also restores the binding if someone removed it by hand.
306222
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−
399223## Development
400224
401225The desk is self-contained. `desk/` holds everything Clay needs:
402226
403227| Path | What |
404228|---|---|
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 |
406229| `app/shelf.hoon` | The agent: Eyre binding, routing, scries, responses |
407230| `lib/shelf.hoon` | Pure helpers and the page (Sail) |
408231| `sur/shelf.hoon`, `mar/shelf/action.hoon`, `gen/shelf/` | The `head`/`body` settings |
409232| `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=` |
411233| `tests/lib/shelf.hoon` | Unit tests, including the rendered page |
412234| `lib/sigil.hoon`, `lib/sigil/symbols.hoon` | Vendored [sigil renderer](https://github.com/Fang-/suite) (MIT, ~paldev; shapes from `@urbit/sigil-js`, MIT, Tlon) |
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)).
235+| other `lib/`, `mar/`, `sur/` files | Copied unmodified from `%base` (urbit/urbit `5a187fe`) and `%landscape` (`sur/treaty`, `sur/docket`; tloncorp/landscape `46c6ed8`) |
417236
418237Run the tests on a ship with the desk installed:
419238
@@ -435,10 +254,3 @@ Some details that are easy to get wrong:
435254- **Clay can refuse a commit silently.** It refuses commits that would stop a
436255 running agent from building, and logs nothing. After `|commit`, check that
437256 `.^(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,9 +7,6 @@
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−::
1310:: The owner can append raw HTML to <head> and <body>, for analytics:
1411:: :shelf|head '<script defer src="…"></script>'
1512:: :shelf|body '…'
@@ -18,8 +15,6 @@
1815/+ default-agent, dbug, server, *shelf
1916/* shelf-css %css /web/shelf/css
2017/* copy-js %js /web/copy/js
21−/* favicon %svg /web/favicon/svg
22−/* shelf-icon %svg /web/shelf-icon/svg
2318|%
2419+$ card card:agent:gall
2520+$ state-0
@@ -117,7 +112,6 @@
117112 ?. ?=(?(%'GET' %'HEAD') method.request.req)
118113 [[405 ~[['allow' 'GET, HEAD, POST']]] ~]
119114 =/ q=(map @t @t) (malt args.rl)
120− ?^ name=(~(get by q) 'asset') (asset u.name)
121115 ?: (~(has by q) 'forget') (forget hs secure)
122116 ?: (wants-json (header-all 'accept' hs)) listing-json
123117 (page q hs)
@@ -133,27 +127,6 @@
133127 ^- simple-payload:http
134128 [[code ~[['location' loc] ['cache-control' 'no-store']]] ~]
135129::
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−::
157130:: +forget: clear the visitor's saved ship
158131::
159132:: Only when the browser is navigating here, not when a docket's image
desk/desk.docket-0deleted−9
@@ -1,9 +0,0 @@
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.hoondeleted−223
@@ -1,223 +0,0 @@
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+5−18
@@ -230,18 +230,6 @@
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−::
245233:: +header: a request header, matching its name case-insensitively
246234::
247235++ header
@@ -551,11 +539,10 @@
551539 :~ ;meta(charset "utf-8");
552540 ;meta(name "viewport", content "width=device-width, initial-scale=1");
553541 ;meta(name "color-scheme", content "light dark");
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
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
557544 ::
558− ;link(rel "icon", type "image/svg+xml", href "?asset=favicon.svg");
545+ ;link(rel "icon", href "data:,");
559546 ;title: {me} · Apps
560547 ;style: {(trip css)}
561548 ==
@@ -626,7 +613,7 @@
626613 =/ pub=tape (scow %p ship.t)
627614 =/ cmd=tape (install-cmd ship.t desk.t)
628615 =/ is-open=? =(open `[ship.t desk.t])
629− =/ img=(unit @t) (biff image.d image-url)
616+ =/ img=(unit @t) (biff image.d web-url)
630617 =/ site=(unit @t) ?:(=('' website.d) ~ (web-url website.d))
631618 ;article.app(id (anchor ship.t desk.t))
632619 ;div.app-row
@@ -638,7 +625,7 @@
638625 ;div.app-name
639626 ;strong: {(trip title.d)}
640627 ;* ?: =(ship.t our) ~
641− :~ ;span.app-by: from {pub}
628+ :~ ;span.app-by: by {pub}
642629 ==
643630 ==
644631 ;div.app-desc
desk/mar/docket-0.hoondeleted−25
@@ -1,25 +0,0 @@
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.hoondeleted−12
@@ -1,12 +0,0 @@
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+2−31
@@ -1,7 +1,5 @@
11/- docket, treaty
22/+ *test, *shelf
3−/* icon %svg /web/shelf-icon/svg
4−/* dok %docket-0 /desk/docket-0
53|%
64:: fixtures for the page tests
75::
@@ -19,13 +17,6 @@
1917 ~ [0 4 2] 'https://github.com/bus/marginalia' 'MIT'
2018 ==
2119::
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−::
2920++ oddity
3021 ^- treaty:treaty
3122 :* ~zod %oddity da+~2026.1.1 0v3
@@ -61,8 +52,7 @@
6152 (expect !>((has doc "<title>~zod · Apps</title>")))
6253 (expect !>((has doc "<style>CSS</style>")))
6354 (expect !>((has doc "<main class=\"page\">")))
64− %- expect !>
65− (has doc "<link rel=\"icon\" type=\"image/svg+xml\" href=\"?asset=favicon.svg\" />")
55+ (expect !>((has doc "<link rel=\"icon\" href=\"data:,\" />")))
6656 (expect !>((has doc "<script>JS</script>")))
6757 ==
6858::
@@ -70,7 +60,7 @@
7060 =/ doc (render ~zod ~[harbor marginalia oddity] ~ | ~)
7161 ;: weld
7262 (expect !>((has doc "<span class=\"count\">3 apps</span>")))
73− (expect !>(=(1 (count doc "from ~bus"))))
63+ (expect !>(=(1 (count doc "by ~bus"))))
7464 (expect !>((has doc "<section class=\"apps\" aria-label=\"Published apps\">")))
7565 (expect !>((has doc "&lt;Odd &amp; &quot;Quoted&quot;&gt; App")))
7666 (expect !>((has doc "a &lt; b &amp; c &gt; &#39;d&#39;")))
@@ -90,10 +80,6 @@
9080 (expect !>((has doc "New to Urbit?\0a<a href=\"https://urbit.org\"")))
9181 ==
9282::
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−::
9783++ test-ui-one-app
9884 =/ doc (render ~zod ~[harbor] ~ | ~)
9985 (expect !>((has doc "<span class=\"count\">1 app</span>")))
@@ -207,13 +193,6 @@
207193 (expect-eq !>('') !>((clean-utf8 '')))
208194 ==
209195::
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−::
217196++ test-treaty-json
218197 =/ one (en:json:html (treaty-json harbor))
219198 =/ two (en:json:html (treaty-json marginalia))
@@ -303,14 +282,6 @@
303282 (expect-eq !>(`(unit @t)`~) !>((web-url '//evil.com/x')))
304283 (expect-eq !>(`(unit @t)`~) !>((web-url 'data:image/png;base64,AAAA')))
305284 (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)')))
314285 ==
315286::
316287++ test-header
desk/web/favicon.svgdeleted−1
@@ -1 +0,0 @@
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.svgdeleted−1
@@ -1 +0,0 @@
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.lockdeleted−27
@@ -1,27 +0,0 @@
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.nixdeleted−24
@@ -1,24 +0,0 @@
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−}