Skip to content

Commit 94a2dbe

Browse files
authored
feat(nav): rich title-first directory index
2 parents 71b1c99 + 1d946ce commit 94a2dbe

9 files changed

Lines changed: 233 additions & 11 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package web
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"strings"
7+
8+
"github.com/latebit-io/demarkus-library/internal/core/domain"
9+
"golang.org/x/net/html"
10+
"golang.org/x/net/html/atom"
11+
)
12+
13+
// The rich directory index (ADR 0006 §5): a raw listing is a bare ls of
14+
// filenames; this enriches each document row with what the catalog knows — the
15+
// title (primary), the *.md filename (mono secondary), a status badge, and an
16+
// orphan tag — so there is one name per document across the index and the map,
17+
// killing the filename↔title split. Subdirectory rows are left untouched. It
18+
// runs after rewriteLinks (hrefs are /w/ doc routes, decodable here) and before
19+
// previewize/trailize.
20+
21+
// richIndex enriches a rendered listing fragment with catalog metadata for the
22+
// world. Best-effort: a catalog read failure (or an unreadable world) leaves
23+
// the listing as a plain ls — the index degrades, never errors.
24+
func (h *ReadingHandler) richIndex(ctx context.Context, world, fragment string) string {
25+
entries, err := h.reading.NameIndex(ctx, "world", world)
26+
if err != nil || len(entries) == 0 {
27+
return fragment
28+
}
29+
byPath := make(map[string]domain.IndexEntry, len(entries))
30+
for _, e := range entries {
31+
byPath[e.Path] = e
32+
}
33+
return indexify(fragment, byPath)
34+
}
35+
36+
func indexify(fragment string, byPath map[string]domain.IndexEntry) string {
37+
ctxNode := &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body}
38+
nodes, err := html.ParseFragment(strings.NewReader(fragment), ctxNode)
39+
if err != nil {
40+
return fragment
41+
}
42+
for _, n := range nodes {
43+
indexifyNode(n, byPath)
44+
}
45+
var buf bytes.Buffer
46+
for _, n := range nodes {
47+
if err := html.Render(&buf, n); err != nil {
48+
return fragment
49+
}
50+
}
51+
return buf.String()
52+
}
53+
54+
func indexifyNode(n *html.Node, byPath map[string]domain.IndexEntry) {
55+
// Recurse first, capturing the next sibling before any insertion mutates the
56+
// tree (the inserts reparent siblings, changing n.NextSibling).
57+
for c := n.FirstChild; c != nil; {
58+
next := c.NextSibling
59+
indexifyNode(c, byPath)
60+
c = next
61+
}
62+
if n.Type != html.ElementNode || n.DataAtom != atom.A {
63+
return
64+
}
65+
var href string
66+
for _, a := range n.Attr {
67+
if a.Key == "href" {
68+
href = a.Val
69+
}
70+
}
71+
addr, _, ok := paneAddrFromRoute(href)
72+
if !ok || addr.Kind != paneDoc || strings.HasSuffix(addr.Value, "/") {
73+
return // a subdirectory row or a non-document link — leave it as is
74+
}
75+
e, ok := byPath[addr.Value]
76+
if !ok {
77+
return // not in the catalog (e.g. an untitled file) — leave the filename
78+
}
79+
// The title becomes the row's primary text; the filename, status, and orphan
80+
// tag follow it (mono secondary + badges) — door affordances over a bare ls.
81+
setNodeText(n, e.Title)
82+
anchor := insertAfter(n, spanNode("idx-file", baseFile(addr.Value)))
83+
if e.Status != "" {
84+
anchor = insertAfter(anchor, spanNode("status status-"+e.Status, e.Status))
85+
}
86+
if e.Orphan {
87+
insertAfter(anchor, spanNode("idx-orphan", "orphan"))
88+
}
89+
}
90+
91+
// baseFile is a path's final segment (the *.md filename).
92+
func baseFile(path string) string {
93+
return path[strings.LastIndex(path, "/")+1:]
94+
}
95+
96+
// setNodeText replaces a node's children with a single text node.
97+
func setNodeText(n *html.Node, text string) {
98+
for c := n.FirstChild; c != nil; {
99+
next := c.NextSibling
100+
n.RemoveChild(c)
101+
c = next
102+
}
103+
n.AppendChild(&html.Node{Type: html.TextNode, Data: text})
104+
}
105+
106+
// spanNode builds <span class="…">text</span>.
107+
func spanNode(class, text string) *html.Node {
108+
s := &html.Node{
109+
Type: html.ElementNode, Data: "span", DataAtom: atom.Span,
110+
Attr: []html.Attribute{{Key: "class", Val: class}},
111+
}
112+
s.AppendChild(&html.Node{Type: html.TextNode, Data: text})
113+
return s
114+
}
115+
116+
// insertAfter inserts node immediately after ref among its parent's children,
117+
// returning node so inserts can chain.
118+
func insertAfter(ref, node *html.Node) *html.Node {
119+
p := ref.Parent
120+
if p == nil {
121+
return ref
122+
}
123+
if ref.NextSibling == nil {
124+
p.AppendChild(node)
125+
} else {
126+
p.InsertBefore(node, ref.NextSibling)
127+
}
128+
return node
129+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package web
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/latebit-io/demarkus-library/internal/core/domain"
8+
)
9+
10+
func TestIndexifyEnrichesDocRows(t *testing.T) {
11+
// A rendered listing (post-rewriteLinks): a document file + a subdirectory.
12+
frag := `<ul>` +
13+
`<li><a href="/w/team-a/d/plans/mission.md">mission.md</a></li>` +
14+
`<li><a href="/w/team-a/d/plans/sub/">sub/</a></li>` +
15+
`</ul>`
16+
entries := map[string]domain.IndexEntry{
17+
"/plans/mission.md": {Title: "The Mission", Path: "/plans/mission.md", World: "team-a", Status: "accepted", Orphan: true},
18+
}
19+
out := indexify(frag, entries)
20+
21+
if !strings.Contains(out, `>The Mission</a>`) {
22+
t.Errorf("row should lead with the title: %s", out)
23+
}
24+
if !strings.Contains(out, `class="idx-file">mission.md`) {
25+
t.Errorf("filename should be mono secondary: %s", out)
26+
}
27+
if !strings.Contains(out, `status status-accepted`) {
28+
t.Errorf("status badge missing: %s", out)
29+
}
30+
if !strings.Contains(out, `class="idx-orphan"`) {
31+
t.Errorf("orphan tag missing: %s", out)
32+
}
33+
// Subdirectory rows are left untouched (not documents).
34+
if !strings.Contains(out, `>sub/</a>`) {
35+
t.Errorf("subdirectory row should be untouched: %s", out)
36+
}
37+
}

internal/adapter/inbound/web/reading_handlers.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ func (h *ReadingHandler) present(c *echo.Context, doc domain.Document, err error
197197
if opts.catalog {
198198
content = linkifyCatalogPaths(content, opts.world)
199199
}
200+
if domain.IsListingPath(opts.path) {
201+
// Rich index (ADR 0006 §5): enrich the bare ls with catalog title/status/
202+
// orphan. Runs while hrefs are still /w/ doc routes.
203+
content = h.richIndex(c.Request().Context(), opts.world, content)
204+
}
200205
content = previewize(content)
201206
if opts.doc {
202207
// Feed the observed-links map (R3): only real documents are edge

internal/adapter/inbound/web/reading_handlers_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,9 @@ func TestDocMarginOmitsStatusAxisTag(t *testing.T) {
349349
func TestBrowseRendersWithoutMargin(t *testing.T) {
350350
svc := &fakeReading{doc: domain.Document{Title: "Index of /plans/", Path: "/plans/", HTML: "<ul></ul>"}}
351351
body := get(readingApp(t, svc), "/w/soul.demarkus.io/d/plans/").Body.String()
352-
if svc.called != "Browse" {
353-
t.Fatalf("routed to %s, want Browse", svc.called)
352+
// Listing routes through Browse (then NameIndex enriches the rich index).
353+
if len(svc.calls) == 0 || !strings.HasPrefix(svc.calls[0], "Browse") {
354+
t.Fatalf("routed to %v, want Browse first", svc.calls)
354355
}
355356
if strings.Contains(body, `class="doc-meta"`) {
356357
t.Errorf("listing must not render the margin metadata block")

internal/adapter/inbound/web/templates/page.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,13 @@
345345
.world-card.federated { border-style: dashed; }
346346
.world-card.federated .world-chev { margin-left: .4rem; }
347347
.world-card.gone { opacity: .55; }
348+
/* Rich directory index (ADR 0006 §5): listing rows lead with the title; the
349+
*.md filename is mono secondary, with a status badge and orphan tag. */
350+
.idx-file { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
351+
font-size: .76rem; color: var(--muted); margin-left: .5rem; }
352+
.idx-orphan { font-family: system-ui, sans-serif; font-size: .62rem;
353+
text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
354+
border: 1px solid var(--faint); border-radius: 4px; padding: 0 .3rem; margin-left: .4rem; }
348355

349356
/* ── Trail dock (ADR 0006 §2): bottom orientation strip. Fixed; <details>
350357
gives zero-JS minimize. System-ui sans — an instrument, not prose. ── */

internal/adapter/inbound/web/trail_handlers.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ func (h *ReadingHandler) Trail(c *echo.Context) error {
130130
if i == t.Reader {
131131
readerDoc, readerAddr, haveReaderDoc = doc, addr, true
132132
}
133-
vm.Panes[i] = h.paneView(t, i, addr, doc, authed, false)
133+
vm.Panes[i] = h.paneView(ctx, t, i, addr, doc, authed, false)
134134
}
135135
}
136136

@@ -149,7 +149,7 @@ func (h *ReadingHandler) Trail(c *echo.Context) error {
149149
// overlay (reader=true); ✕/backdrop/Esc close to trailURL(t), which keeps
150150
// the original focus.
151151
if t.Reader >= 0 && haveReaderDoc {
152-
rp := h.paneView(t, t.Reader, readerAddr, readerDoc, authed, true)
152+
rp := h.paneView(ctx, t, t.Reader, readerAddr, readerDoc, authed, true)
153153
vm.Reader = &rp
154154
vm.CloseURL = trailURL(t)
155155
}
@@ -220,7 +220,7 @@ func (h *ReadingHandler) readPane(ctx context.Context, addr paneAddr, live bool)
220220
// focused doc + margin, but body and backlink hrefs persist the overlay
221221
// (reader-mode links), edges are not re-recorded (the canvas build already
222222
// did), and the pane carries no "open reader" affordance (it is already open).
223-
func (h *ReadingHandler) paneView(t trail, i int, addr paneAddr, doc domain.Document, authed, reader bool) paneVM {
223+
func (h *ReadingHandler) paneView(ctx context.Context, t trail, i int, addr paneAddr, doc domain.Document, authed, reader bool) paneVM {
224224
focused := i == t.Focus
225225

226226
mode := "spine"
@@ -266,6 +266,12 @@ func (h *ReadingHandler) paneView(t trail, i int, addr paneAddr, doc domain.Docu
266266
if addr.Kind == paneTag {
267267
content = linkifyCatalogPaths(content, addr.World)
268268
}
269+
if focused && addr.Kind == paneDoc && domain.IsListingPath(addr.Value) {
270+
// Rich index (ADR 0006 §5): enrich the focused listing pane's bare ls
271+
// before its links are trailized. Focused-only keeps the per-click read
272+
// budget (an unfocused listing stays a plain ls — it's context).
273+
content = h.richIndex(ctx, addr.World, content)
274+
}
269275
// previewize runs on /w/ hrefs (it derives each card's source from them),
270276
// then trailizeLinks rewrites those hrefs to post-click trail URLs — in the
271277
// overlay (reader=true) those become reader-persisting URLs.

internal/core/domain/document.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ type IndexEntry struct {
251251
Path string
252252
World string
253253
Status string
254+
Orphan bool // zero reference edges (ADR 0006 §0.2); the rich index tags it
254255
}
255256

256257
// Document is a rendered, display-ready document. HTML is already sanitized; the

internal/core/service/palette.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,20 @@ const nameIndexMaxPerWorld = 1000
2727
// whole index. Cancellation/timeout always propagates — a terminated request
2828
// must not render a half-index.
2929
func (s *ReadingService) NameIndex(ctx context.Context, scope, world string) ([]domain.IndexEntry, error) {
30+
// The durable topology sources the orphan flag (ADR 0006 §0.2). Read once;
31+
// when no hub is configured it's empty and free, so the per-keystroke palette
32+
// pays nothing — only orphan-aware callers on hub systems incur the join.
33+
topo := s.readHub(ctx, s.hub)
34+
var host2name map[string]string
35+
if len(topo.nodes) > 0 {
36+
var err error
37+
if host2name, err = s.host2name(ctx); err != nil {
38+
return nil, err // outbound failure propagates; the web layer decides
39+
}
40+
}
41+
3042
if scope != "universe" {
31-
return s.worldNameIndex(ctx, world)
43+
return s.worldNameIndex(ctx, world, worldOrphans(world, host2name, topo))
3244
}
3345

3446
var worlds []string
@@ -46,7 +58,7 @@ func (s *ReadingService) NameIndex(ctx context.Context, scope, world string) ([]
4658

4759
var out []domain.IndexEntry
4860
for _, w := range worlds {
49-
entries, err := s.worldNameIndex(ctx, w)
61+
entries, err := s.worldNameIndex(ctx, w, worldOrphans(w, host2name, topo))
5062
if err != nil {
5163
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
5264
return nil, err
@@ -58,10 +70,11 @@ func (s *ReadingService) NameIndex(ctx context.Context, scope, world string) ([]
5870
return out, nil
5971
}
6072

61-
// worldNameIndex returns one world's catalog as palette entries. A read failure
62-
// returns the error (the caller decides whether to degrade or propagate); a
63-
// canceled/timed-out context always propagates.
64-
func (s *ReadingService) worldNameIndex(ctx context.Context, world string) ([]domain.IndexEntry, error) {
73+
// worldNameIndex returns one world's catalog as index entries, tagging each with
74+
// orphan membership (orphans is the world's reference-orphan path set, possibly
75+
// nil). A read failure returns the error (the caller decides whether to degrade
76+
// or propagate); a canceled/timed-out context always propagates.
77+
func (s *ReadingService) worldNameIndex(ctx context.Context, world string, orphans map[string]bool) ([]domain.IndexEntry, error) {
6578
raw, err := s.world.Lookup(ctx, world, "/", "*", "", nameIndexMaxPerWorld)
6679
if err != nil {
6780
return nil, err
@@ -74,6 +87,7 @@ func (s *ReadingService) worldNameIndex(ctx context.Context, world string) ([]do
7487
Path: d.Path,
7588
World: world,
7689
Status: d.Status,
90+
Orphan: orphans[d.Path],
7791
})
7892
}
7993
return out, nil

internal/core/service/worldmap.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,28 @@ func intraWorldEdges(world string, host2name map[string]string, all []domain.Edg
257257
return out
258258
}
259259

260+
// host2name maps each authorized world's dial host to its world name — the join
261+
// key for hub-graph refs (keyed by host) against world names. A world-list read
262+
// failure propagates (outbound-port errors are never swallowed in the core); the
263+
// caller decides whether to degrade or surface it.
264+
func (s *ReadingService) host2name(ctx context.Context) (map[string]string, error) {
265+
worlds, err := s.world.Worlds(ctx)
266+
if err != nil {
267+
return nil, err
268+
}
269+
m := make(map[string]string, len(worlds))
270+
for _, w := range worlds {
271+
addr := w.Address
272+
if addr == "" {
273+
addr = w.URL
274+
}
275+
if h := hostOf(addr); h != "" {
276+
m[h] = w.Name
277+
}
278+
}
279+
return m, nil
280+
}
281+
260282
// worldMember reports whether a topology ref's world resolves to world — either
261283
// it already is the world name (observed map) or its host joins to it (hub
262284
// graph, via host2name).

0 commit comments

Comments
 (0)