|
| 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 | +} |
0 commit comments