| M | DESIGN.md | 5 | +- |
| M | site/ | 38 | +++- |
| M | site/ | 18 | +- |
| A | test/ | 246 | ++++++++++++++++++++++++++++++ |
| A | test/ | 24 | +++ |
| A | test/ | 28 | +++ |
| A | test/ | 24 | +++ |
| A | test/ | 22 | +++ |
| M | test/ | 10 | +- |
@@ -125,7 +125,10 @@ minute, with the reader's full local date and time on hover.
125125 Words for where you are, symbols for how you look at it.126126 127127 - Tabs say where you are (`ui.tabs`): the repo's sections, the shelves on128- an owner's page and `/topics/` (with an "all" tab).128+ an owner's page and `/topics/` (with an "all" tab). A tab shows one129+ panel: "all" is every repo in one list, newest first, not the shelves130+ stacked; a shelf's tab is that shelf's repos, and the panel's head131+ strip names what it shows ("all 31", "games 6").129132 - A checkbox turns one thing on or off (`ui.checkbox`): wrap lines,130133 viewed.131134 - An icon toggle is a checkbox drawn as a symbol (`ui.iconToggle`): a@@ -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) %}@@ -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');@@ -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));@@ -0,0 +1,24 @@
1+# An owner's page opens on "all": one panel of every repo, newest first,2+# whatever its shelf, so what changed lately is at the top, and no shelf3+# headings in it. Each shelf has a panel of its own after it, which ui.ts4+# (or, without JS, the address) shows instead when its tab is picked.5+. "$(dirname "$0")/../lib.sh"6+user dami7+for r in chess go notes blog; do8+ repo "dami/$r"9+ clone dami "dami/$r" "$T/w/$r"10+ tick 360011+ at "$T/w/$r" commit "first"12+ push_as dami "$T/w/$r" origin main13+done14+printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"15+printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"16+printf 'tools\n' >"$FORGE_ROOT/dami/notes.git/category"17+export FORGE_STRICT=118+build19+i=$FORGE_WWW/dami/index.html20+# The page as built: "all" first, then the shelves, fuller first.21+eq "$(grep -o '<section class="panel shelf" data-shelf="[a-z]*"' "$i" | sed 's/.*="//; s/"$//' | tr '\n' ' ')" " games tools other "22+has "$i" '<section class="panel shelf" data-shelf=""><header class="panel-head"><h2>all</h2><span class="n">4</span></header>'23+# As a reader sees it: one panel, newest first.24+eq "$(node --no-warnings "$T_HOME/test/page.ts" shelves "$i")" "all 4: blog notes go chess"@@ -0,0 +1,28 @@
1+# A link to one shelf (/dami/#games, from the home page's topics) opens the2+# owner's page on that shelf's tab; an address naming no shelf opens on3+# "all". Without JS the address picks the panel too (:target, in the4+# shelves macro's CSS).5+. "$(dirname "$0")/../lib.sh"6+user dami7+for r in chess go notes blog; do8+ repo "dami/$r"9+ clone dami "dami/$r" "$T/w/$r"10+ tick 360011+ at "$T/w/$r" commit "first"12+ push_as dami "$T/w/$r" origin main13+done14+printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"15+printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"16+printf 'tools\n' >"$FORGE_ROOT/dami/notes.git/category"17+export FORGE_STRICT=118+build19+i=$FORGE_WWW/dami/index.html20+page() { node --no-warnings "$T_HOME/test/page.ts" shelves "$i" "$@"; }21+has "$FORGE_WWW/index.html" '<a href="/dami/#games">games'22+eq "$(page '#games')" "games 2: go chess"23+eq "$(page '#tools')" "tools 1: notes"24+eq "$(page '#nothing')" "all 4: blog notes go chess"25+has "$i" '<section class="panel shelf" data-shelf="games" id="games">'26+css=$FORGE_WWW$(grep -o '/assets/[A-Za-z0-9_-]*\.css' "$i" | while read -r f; do grep -q shelves-js "$FORGE_WWW$f" && echo "$f"; done | head -n 1)27+has "$css" '\.shelves:not\(\.shelves-js\) > \.shelf\[data-shelf\]:not\(\[data-shelf=""\]\):not\(:target\) \{ display: none; \}'28+has "$css" '\.shelves:not\(\.shelves-js\):has\(> \.shelf:target\) > \.shelf\[data-shelf=""\] \{ display: none; \}'@@ -0,0 +1,24 @@
1+# On "all" and on a shelf's tab, the sort and the globe still apply: by2+# name is one list a to z, and the globe keeps only repos with a site, the3+# panel's head counting what it shows; a panel with none says so.4+. "$(dirname "$0")/../lib.sh"5+user dami6+for r in chess go notes blog; do7+ repo "dami/$r"8+ clone dami "dami/$r" "$T/w/$r"9+ tick 360010+ at "$T/w/$r" commit "first"11+ push_as dami "$T/w/$r" origin main12+done13+printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"14+printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"15+printf 'tools\n' >"$FORGE_ROOT/dami/notes.git/category"16+push_as dami "$T/w/notes" origin "$(site_commit "$T/w/notes" docs):refs/sites/docs"17+export FORGE_STRICT=118+build19+i=$FORGE_WWW/dami/index.html20+page() { node --no-warnings "$T_HOME/test/page.ts" shelves "$i" "$@"; }21+eq "$(page click '[data-sort="name"]')" "all 4: blog chess go notes"22+eq "$(page click '[data-shelves] a[data-shelf="games"]' click '[data-sort="name"]')" "games 2: chess go"23+eq "$(page click '[data-has-site]')" "all 1: notes"24+eq "$(page click '[data-has-site]' click '[data-shelves] a[data-shelf="games"]')" "empty: No repos here have a site."@@ -0,0 +1,22 @@
1+# A shelf's tab on an owner's page shows that shelf's repos alone, in one2+# panel named for it with its count, newest first; "all" brings back every3+# repo.4+. "$(dirname "$0")/../lib.sh"5+user dami6+for r in chess go notes blog; do7+ repo "dami/$r"8+ clone dami "dami/$r" "$T/w/$r"9+ tick 360010+ at "$T/w/$r" commit "first"11+ push_as dami "$T/w/$r" origin main12+done13+printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"14+printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"15+printf 'tools\n' >"$FORGE_ROOT/dami/notes.git/category"16+export FORGE_STRICT=117+build18+i=$FORGE_WWW/dami/index.html19+page() { node --no-warnings "$T_HOME/test/page.ts" shelves "$i" "$@"; }20+eq "$(page click '[data-shelves] a[data-shelf="games"]')" "games 2: go chess"21+eq "$(page click '[data-shelves] a[data-shelf="tools"]')" "tools 1: notes"22+eq "$(page click '[data-shelves] a[data-shelf="games"]' click '[data-shelves] a[data-shelf=""]')" "all 4: blog notes go chess"@@ -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>'