clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

An owner's "all" is one list of every repo, newest first

cat committed 9bf849c
commit
9bf849c7c2becd5b19a9b70b113d37d1e9a1d428
parent
a3445fcbe17d2f9bce5a917b1a348b144c80d485
author
cat <admin@dev.site>
ci
✓ passed in 149s, signed by cat
kind
feat risk score 2.0 of 3, Jev's estimate
review
#25 An owner's all tab: one list, newest first
ci attestation
forge-attestation 1
type ci
commit 9bf849c7c2becd5b19a9b70b113d37d1e9a1d428
tree 2680f7064b845653cdbc1ae5d0da2e67f3773d9b
script .forge/ci 91d640bd19a231e82d21cc0a10d907253d9b0f41
result pass
exit 0
started 2026-10-19T05:23:29Z
duration 149s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:ee3bccf588580db2b03b932e8c90e3dbb2b5ac9a22f5819726476e631e4d84ca 289 lines
|pushed wip/i18n
|098c671 running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|098c671 pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|5f1534d running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|5f1534d fail in 0s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|4f9f44a running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|4f9f44a pass in 0s, signed
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|warning: You appear to have cloned an empty repository.
|site home: deployed 75166d4 -> /sites/ana/dotfiles/home/
|created ana/notes
|remote: review #1: /ana/notes/reviews/1/
|pushed ana/todo
|landing #1 A todo list: ana/todo -> main
|833666e running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|833666e pass in 0s, signed
|pushed main
|0c7c701 running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|0c7c701 pass in 0s, signed
|remote: review #7: /dami/greet/reviews/7/
|pushed dami/regions
|created dami/forge
|warning: You appear to have cloned an empty repository.
|site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/
|site guide: deployed 9f61b36 -> /sites/dami/forge/guide/
|(node:1527) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/dotfiles in 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.cHi2Z0/wt/demo/root/.forge/www
|(node:1739) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/notes in 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.cHi2Z0/wt/demo/root/.forge/www
|(node:1967) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/forge in 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.cHi2Z0/wt/demo/root/.forge/www
|(node:2228) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/greet in 5s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.cHi2Z0/wt/demo/root/.forge/www
|(node:3157) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built - in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.cHi2Z0/wt/demo/root/.forge/www
|235 of 235 pages render the same without 11ty
|guide: 8 files, links ok
|ok

Signature: good, cat

9 files changed, +393 -22
MDESIGN.md5+-
Msite/_includes/macros.njk38+++-
Msite/assets/ui.ts18+-
Atest/page.ts246++++++++++++++++++++++++++++++
Atest/scenarios/shelves-all.sh24+++
Atest/scenarios/shelves-fragment.sh28+++
Atest/scenarios/shelves-sort.sh24+++
Atest/scenarios/shelves-tab.sh22+++
Mtest/scenarios/shelves.sh10+-
M DESIGN.md +4 -1
M site/_includes/macros.njk +27 -11
@@ -138,12 +138,14 @@
138138 </table>139139 {% endmacro %}140140 141-{# Owner pages and /topics/: repos as rows on shelves by kind. Tabs above142- them name the shelves, "all" first: without JS they jump to a shelf,143- with it (ui.ts) one shows that shelf alone. Beside them, how to look at144- the list, which needs JS and shows with it: the globe keeps only repos145- with a site, and the sort is newest first or by name. keys.ts walks the146- rows with j and k. #}141+{# Owner pages and /topics/: repos as rows, one panel at a time. Tabs142+ above name the shelves (a repo's kind), "all" first: "all" is every repo143+ in one panel, newest first, so what changed lately is at the top; a144+ shelf's tab shows its repos alone. Without JS the page shows "all", or145+ the shelf its address names (/cat/#games, :target); with it (ui.ts) the146+ tabs pick the panel. Beside them, how to look at the list, which needs147+ JS and shows with it: the globe keeps only repos with a site, and the148+ sort is newest first or by name. keys.ts walks the rows with j and k. #}147149 {% macro shelves(repos, owners = false) %}148150 {% css %}149151 .shelf-bar { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6); align-items: center; margin: 0 0 var(--space-4); border-bottom: var(--hairline) solid var(--border); }
@@ -153,6 +155,10 @@
153155 .shelf-tools { display: flex; gap: var(--space-2); align-items: center; padding: var(--space-1) 0; }154156 .shelf-tools[hidden] { display: none; }155157 .shelf { scroll-margin-top: var(--space-3); }158+/* Without JS: "all", unless the address names a shelf. ui.ts marks the159+ page with shelves-js and picks the panel itself. */160+.shelves:not(.shelves-js) > .shelf[data-shelf]:not([data-shelf=""]):not(:target) { display: none; }161+.shelves:not(.shelves-js):has(> .shelf:target) > .shelf[data-shelf=""] { display: none; }156162 /* Repos as rows in their shelf's panel: name, what it is, then its sites,157163 reviews and last push. The rows share the panel's columns (subgrid), so158164 names, descriptions and times line up; the time ends each row, as wide
@@ -182,6 +188,7 @@
182188 183189 {% endcss %}184190 {% set list = repos | shelves %}191+<div class="shelves">185192 <div class="shelf-bar{% if list.length < 2 %} plain{% endif %}" id="shelves" data-shelves>186193 {% if list.length > 1 %}187194 {% set tabs = [{href: '#shelves', text: 'all', n: repos.length, cur: true, attrs: 'data-shelf=""'}] %}
@@ -194,11 +201,23 @@
194201 {{ ui.segmented('sort', [{icon: 'clock', label: 'newest first', pressed: true, attrs: 'data-sort="date"'}, {icon: 'az', label: 'by name, a to z', attrs: 'data-sort="name"'}]) }}195202 </span>196203 </div>204+{% if list.length > 1 %}205+{% set head %}<h2>all</h2><span class="n">{{ repos.length }}</span>{% endset %}206+{% call ui.panel(head, 'shelf', 'data-shelf=""') %}{{ cards(repos | sort(true, true, 'lastCommit'), owners) }}{% endcall %}207+{% endif %}197208 {% for sh in list %}198209 {% set head %}<h2>{{ sh.title }}</h2><span class="n">{{ sh.repos.length }}</span>{% endset %}199-{% call ui.panel(head, 'shelf', 'data-shelf="' + sh.id + '" id="' + sh.id + '"') %}210+{# A lone shelf is all there is: it shows whatever the address says. #}211+{% call ui.panel(head, 'shelf', 'data-shelf="' + (sh.id if list.length > 1 else '') + '" id="' + sh.id + '"') %}{{ cards(sh.repos, owners) }}{% endcall %}212+{% endfor %}213+<div class="panel shelves-empty" hidden><p class="empty">No repos here have a site.</p></div>214+</div>215+{% endmacro %}216+217+{# A shelf's repos as rows: name, what it is, its sites, reviews and last push. #}218+{% macro cards(repos, owners) %}200219 <ul class="rows cards nav-cards">201-{% for s in sh.repos %}220+{% for s in repos %}202221 <li class="card" data-name="{{ s.name | lower }}" data-date="{{ s.lastCommit }}"{% if s.counts.sites %} data-site{% endif %}>203222 <div class="card-top"><a class="card-name" href="{{ s.urls.home.href }}">{% if owners %}<span class="desc">{{ s.owner }} / </span>{% endif %}{{ s.name }}</a></div>204223 {% if s.description %}<p class="card-desc">{{ s.description }}</p>{% endif %}
@@ -206,9 +225,6 @@
206225 </li>207226 {% endfor %}208227 </ul>209-{% endcall %}210-{% endfor %}211-<div class="panel shelves-empty" hidden><p class="empty">No repos here have a site.</p></div>212228 {% endmacro %}213229 214230 {% macro sitesLink(s) %}
M site/assets/ui.ts +12 -6
@@ -82,14 +82,16 @@
8282 }8383 8484 // The owner pages' and /topics/' shelves (shelves in macros.njk). Without JS85- // the tabs jump to a shelf; here one shows that shelf alone ("all" shows86- // every one), the globe hides repos without a site, and the sort is87- // newest first or by name. The choices outlive live.ts swapping in a88- // rebuilt page.85+ // the page shows "all", or the shelf its address names; here the tabs pick86+ // the panel: "all" (every repo in one) or one shelf's. The globe hides87+ // repos without a site, the sort is newest first or by name, and the88+ // panel's head counts what it shows. The choices outlive live.ts swapping89+ // in a rebuilt page.8990 const pick = { shelf: '', site: false, az: false };9091 const shelves = (): void => {9192 const bar = document.querySelector<HTMLElement>('[data-shelves]');9293 if (!bar) return;94+ bar.closest('.shelves')?.classList.add('shelves-js');9395 for (const b of bar.querySelectorAll<HTMLElement>('[hidden]')) b.hidden = false;9496 for (const t of bar.querySelectorAll<HTMLElement>('a[data-shelf]')) {9597 const cur = t.dataset.shelf === pick.shelf;
@@ -99,15 +101,19 @@
99101 }100102 bar.querySelector('[data-has-site]')?.setAttribute('aria-pressed', String(pick.site));101103 for (const b of bar.querySelectorAll<HTMLElement>('[data-sort]')) b.setAttribute('aria-pressed', String((b.dataset.sort === 'name') === pick.az));104+ const panels = [...document.querySelectorAll<HTMLElement>('section.shelf')];105+ const on = panels.find((sec) => sec.dataset.shelf === pick.shelf) ?? panels[0];102106 let shown = 0;103- for (const sec of document.querySelectorAll<HTMLElement>('section.shelf')) {107+ for (const sec of panels) {104108 const ul = sec.querySelector('ul')!;105109 const cards = [...ul.children] as HTMLElement[];106110 cards.sort((a, b) => (pick.az ? a.dataset.name!.localeCompare(b.dataset.name!) : b.dataset.date!.localeCompare(a.dataset.date!)));107111 ul.append(...cards);108112 let n = 0;109113 for (const c of cards) n += Number(!(c.hidden = pick.site && !c.hasAttribute('data-site')));110- sec.hidden = !n || (!!pick.shelf && sec.dataset.shelf !== pick.shelf);114+ const count = sec.querySelector('.panel-head .n');115+ if (count) count.textContent = String(n);116+ sec.hidden = sec !== on || !n;111117 if (!sec.hidden) shown++;112118 }113119 const empty = document.querySelector<HTMLElement>('.shelves-empty');
A test/page.ts +246 -0
@@ -0,0 +1,246 @@
1+// A built page with the browser's own site/assets/ui.ts run on it, in Node:2+// what a reader with JS sees on an owner page or /topics/. The DOM is3+// htmlparser2's tree with the little ui.ts uses: attributes, classes,4+// hidden, text, moving children, and selectors made of tags, classes and5+// attributes, descendants and lists.6+//7+// node test/page.ts shelves <page.html> [#fragment] [click <selector>]...8+//9+// After loading the page at the fragment and clicking each selector in10+// turn, prints the shelf panels that show, a line each:11+// `<head strip>: <repo> <repo>...`, the repos as their rows show them.12+import fs from 'node:fs';13+import vm from 'node:vm';14+import path from 'node:path';15+import { fileURLToPath } from 'node:url';16+import { stripTypeScriptTypes } from 'node:module';17+import { parseDocument } from 'htmlparser2';18+19+interface Raw {20+ type: string;21+ name?: string;22+ data?: string;23+ attribs?: Record<string, string>;24+ children?: Raw[];25+ parent?: Raw | null;26+}27+28+// One compound selector: a tag, classes, attributes ([a] or [a="v"]).29+interface Compound {30+ tag: string | null;31+ classes: string[];32+ attrs: [string, string | null][];33+}34+35+const compound = (s: string): Compound => {36+ const m = /^([a-z][a-z0-9]*)?((?:\.[\w-]+|\[[\w-]+(?:="[^"]*")?\])*)$/i.exec(s);37+ if (!m) throw new Error(`test/page.ts: can't read the selector ${s}`);38+ const c: Compound = { tag: m[1]?.toLowerCase() ?? null, classes: [], attrs: [] };39+ for (const p of m[2].match(/\.[\w-]+|\[[^\]]+\]/g) ?? []) {40+ if (p[0] === '.') c.classes.push(p.slice(1));41+ else {42+ const a = /^\[([\w-]+)(?:="([^"]*)")?\]$/.exec(p)!;43+ c.attrs.push([a[1], a[2] ?? null]);44+ }45+ }46+ return c;47+};48+49+const els = (r: Raw): Raw[] => (r.children ?? []).filter((c) => c.type === 'tag' || c.type === 'script' || c.type === 'style');50+51+const fits = (r: Raw, c: Compound): boolean => {52+ if (r.type !== 'tag' && r.type !== 'script' && r.type !== 'style') return false;53+ const at = r.attribs ?? {};54+ if (c.tag && r.name !== c.tag) return false;55+ const cls = (at.class ?? '').split(/\s+/);56+ if (!c.classes.every((k) => cls.includes(k))) return false;57+ return c.attrs.every(([k, v]) => k in at && (v == null || at[k] === v));58+};59+60+// A list of descendant chains, matched right to left.61+const matches = (r: Raw, selector: string): boolean =>62+ selector.split(',').some((one) => {63+ const chain = one.trim().split(/\s+/).map(compound);64+ if (!fits(r, chain[chain.length - 1])) return false;65+ let i = chain.length - 2;66+ for (let up = r.parent; up && i >= 0; up = up.parent) if (fits(up, chain[i])) i--;67+ return i < 0;68+ });69+70+const all = (root: Raw, selector: string): Raw[] => {71+ const out: Raw[] = [];72+ const walk = (r: Raw): void => {73+ for (const c of els(r)) {74+ if (matches(c, selector)) out.push(c);75+ walk(c);76+ }77+ };78+ walk(root);79+ return out;80+};81+82+const text = (r: Raw): string => (r.type === 'text' ? r.data ?? '' : (r.children ?? []).map(text).join(''));83+84+const wrapped = new WeakMap<Raw, El>();85+const el = (r: Raw): El => {86+ let e = wrapped.get(r);87+ if (!e) wrapped.set(r, (e = new El(r)));88+ return e;89+};90+91+class El {92+ raw: Raw;93+ scrollLeft = 0;94+ constructor(raw: Raw) {95+ this.raw = raw;96+ }97+ get attrs(): Record<string, string> {98+ return (this.raw.attribs ??= {});99+ }100+ get children(): El[] {101+ return els(this.raw).map(el);102+ }103+ getAttribute(k: string): string | null {104+ return this.attrs[k] ?? null;105+ }106+ setAttribute(k: string, v: string): void {107+ this.attrs[k] = String(v);108+ }109+ removeAttribute(k: string): void {110+ delete this.attrs[k];111+ }112+ hasAttribute(k: string): boolean {113+ return k in this.attrs;114+ }115+ get hidden(): boolean {116+ return this.hasAttribute('hidden');117+ }118+ set hidden(on: boolean) {119+ if (on) this.setAttribute('hidden', '');120+ else this.removeAttribute('hidden');121+ }122+ get title(): string {123+ return this.attrs.title ?? '';124+ }125+ set title(v: string) {126+ this.attrs.title = v;127+ }128+ get dateTime(): string {129+ return this.attrs.datetime ?? '';130+ }131+ get textContent(): string {132+ return text(this.raw);133+ }134+ set textContent(v: string) {135+ this.raw.children = [{ type: 'text', data: v, parent: this.raw }];136+ }137+ get dataset(): Record<string, string | undefined> {138+ const attrs = this.attrs;139+ const name = (k: string): string => 'data-' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());140+ return new Proxy({}, { get: (_, k) => attrs[name(String(k))], has: (_, k) => name(String(k)) in attrs });141+ }142+ get classList() {143+ const get = (): string[] => (this.attrs.class ?? '').split(/\s+/).filter(Boolean);144+ const set = (l: string[]): void => void (this.attrs.class = l.join(' '));145+ return {146+ contains: (c: string) => get().includes(c),147+ add: (c: string) => set([...new Set([...get(), c])]),148+ remove: (c: string) => set(get().filter((x) => x !== c)),149+ toggle: (c: string, on?: boolean) => {150+ const now = on ?? !get().includes(c);151+ set(now ? [...new Set([...get(), c])] : get().filter((x) => x !== c));152+ return now;153+ },154+ };155+ }156+ append(...kids: El[]): void {157+ for (const k of kids) {158+ const from = k.raw.parent;159+ if (from?.children) from.children = from.children.filter((c) => c !== k.raw);160+ k.raw.parent = this.raw;161+ (this.raw.children ??= []).push(k.raw);162+ }163+ }164+ matches(s: string): boolean {165+ return matches(this.raw, s);166+ }167+ closest(s: string): El | null {168+ for (let r: Raw | null | undefined = this.raw; r; r = r.parent) if (r.type === 'tag' && matches(r, s)) return el(r);169+ return null;170+ }171+ querySelectorAll(s: string): El[] {172+ return all(this.raw, s).map(el);173+ }174+ querySelector(s: string): El | null {175+ return this.querySelectorAll(s)[0] ?? null;176+ }177+ getBoundingClientRect() {178+ return { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };179+ }180+ getClientRects(): unknown[] {181+ return [];182+ }183+ addEventListener(): void {}184+}185+186+// The page as ui.ts sees it, at a fragment; click() as a reader would.187+function load(file: string, hash: string) {188+ const root = parseDocument(fs.readFileSync(file, 'utf8')) as unknown as Raw;189+ const handlers: Record<string, ((e: object) => void)[]> = {};190+ const html = all(root, 'html')[0] ?? root;191+ const doc = Object.assign(el(html), {192+ body: el(all(root, 'body')[0] ?? html),193+ addEventListener: (type: string, f: (e: object) => void) => (handlers[type] ??= []).push(f),194+ dispatchEvent: (e: { type: string }) => {195+ for (const f of handlers[e.type] ?? []) f(e);196+ return true;197+ },198+ });199+ const noop = (): void => {};200+ const page = vm.createContext({201+ document: doc,202+ location: { hash },203+ localStorage: { getItem: () => null, setItem: noop },204+ matchMedia: () => ({ matches: false }),205+ setInterval: noop,206+ setTimeout: noop,207+ navigator: {},208+ CSS: { escape: (s: string) => s.replace(/["\\]/g, '\\$&') },209+ Event: class {210+ type: string;211+ constructor(type: string) {212+ this.type = type;213+ }214+ },215+ KeyboardEvent: class {},216+ });217+ const here = path.dirname(fileURLToPath(import.meta.url));218+ vm.runInContext(stripTypeScriptTypes(fs.readFileSync(path.join(here, '../site/assets/ui.ts'), 'utf8')), page);219+ const click = (selector: string): void => {220+ const target = doc.querySelector(selector);221+ if (!target) throw new Error(`test/page.ts: nothing on the page matches ${selector}`);222+ for (const f of handlers.click ?? []) f({ type: 'click', target, preventDefault: noop });223+ };224+ return { doc, click };225+}226+227+const shown = (e: El): boolean => !e.closest('[hidden]');228+const words = (s: string): string => s.replace(/\s+/g, ' ').trim();229+230+const [mode, file, ...rest] = process.argv.slice(2);231+if (mode !== 'shelves' || !file) {232+ console.error('usage: node test/page.ts shelves <page.html> [#fragment] [click <selector>]...');233+ process.exit(2);234+}235+const hash = rest[0]?.startsWith('#') ? rest.shift()! : '';236+const { doc, click } = load(file, hash);237+for (let i = 0; i < rest.length; i += 2) {238+ if (rest[i] !== 'click' || !rest[i + 1]) throw new Error(`test/page.ts: expected click <selector>, got ${rest.slice(i).join(' ')}`);239+ click(rest[i + 1]);240+}241+for (const sec of doc.querySelectorAll('section.shelf').filter(shown)) {242+ const names = sec.querySelectorAll('.card').filter(shown).map((c) => words(c.querySelector('.card-name')!.textContent).replace(' / ', '/'));243+ const head = sec.querySelector('.panel-head')!.children.map((c) => words(c.textContent)).join(' ');244+ console.log(`${head}: ${names.join(' ')}`);245+}246+if (doc.querySelector('.shelves-empty') && shown(doc.querySelector('.shelves-empty')!)) console.log('empty: ' + words(doc.querySelector('.shelves-empty')!.textContent));
A test/scenarios/shelves-all.sh +24 -0
A test/scenarios/shelves-fragment.sh +28 -0
A test/scenarios/shelves-sort.sh +24 -0
A test/scenarios/shelves-tab.sh +22 -0
M test/scenarios/shelves.sh +6 -4
@@ -20,13 +20,15 @@ build
2020 i=$FORGE_WWW/topics/index.html2121 has "$i" '<section class="panel shelf" data-shelf="games" id="games">'2222 has "$i" '<section class="panel shelf" data-shelf="other" id="other">'23-# games (two repos) before other (one); go was pushed last, so it leads.24-eq "$(grep -o '<section class="panel shelf" data-shelf="[a-z]*"' "$i" | sed 's/.*="//; s/"//' | tr '\n' ' ')" "games other "25-eq "$(grep -o 'class="card-name" href="/[a-z]*/[a-z]*/"' "$i" | sed 's/.*href="//; s/"//' | tr '\n' ' ')" "/dami/go/ /dami/chess/ /ana/notes/ "23+# "all" (data-shelf ""), then games (two repos) before other (one); ana's24+# notes was pushed last, so it leads "all", and go leads games.25+eq "$(grep -o '<section class="panel shelf" data-shelf="[a-z]*"' "$i" | sed 's/.*="//; s/"//' | tr '\n' ' ')" " games other "26+eq "$(grep -o 'class="card-name" href="/[a-z]*/[a-z]*/"' "$i" | sed 's/.*href="//; s/"//' | tr '\n' ' ')" "/ana/notes/ /dami/go/ /dami/chess/ /dami/go/ /dami/chess/ /ana/notes/ "2627 # Several owners: each card names its owner.2728 has "$i" '<span class="desc">dami / </span>go</a>'2829 lacks "$i" 'secret'29-has "$FORGE_WWW/dami/index.html" '<section class="panel shelf" data-shelf="games" id="games">'30+# One shelf is all there is: no "all" panel, and the shelf stands for it.31+has "$FORGE_WWW/dami/index.html" '<section class="panel shelf" data-shelf="" id="games">'3032 lacks "$FORGE_WWW/dami/index.html" '<span class="desc">dami / </span>'3133 # One owner's only shelf is "other": titled repositories.3234 has "$FORGE_WWW/ana/index.html" '<h2>repositories</h2>'