From 779ea712704ad99143dea1bd9c8d412b5af6132a Mon Sep 17 00:00:00 2001
From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com>
Date: Sun, 14 Jun 2026 17:58:49 +0700
Subject: [PATCH 1/6] serve: a web console that browses the URI data tree
ant serve now answers a browser as well as a curl. The same routes a
script reads as JSON render as a shadcn-styled console when the request
asks for HTML, so there is one URL surface, not two.
The console is cache-first. A resource view reads the record already
materialized under the data tree and only fetches over the network on a
miss or when refresh is set; a live fetch is written back (JSON always,
Markdown when the record has a body) so the next read is offline. A
"cached" or "live" badge says which path served, and a refresh control
pulls a fresh copy.
Browsing follows the on-disk tree like directories: the root lists every
registered domain, a scheme lists its record types, and a type lists its
records, with breadcrumbs back up. Domains that expose a search op get a
search box; a hit that is a preview shape is resolved back to its
canonical URI so it is one click from its record.
The web package serves from go:embed (templates and assets, no Node), SSR
through html/template, Markdown through goldmark, and sets a per-response
CSP nonce with no secrets in the page. Every page and the JSON
negotiation are covered by web/console_test.go against a network-free
fake, and serve_test.go covers the wiring.
Requires any-cli v0.3.3 for Host.Search/Host.Searchable.
---
ant/ant.go | 81 ++++
ant/cache.go | 92 ++++
cli/serve.go | 130 +-----
cli/serve_test.go | 53 ++-
go.mod | 4 +-
go.sum | 6 +-
web/assets/ant.svg | 10 +
web/assets/app.js | 47 ++
web/assets/favicon.svg | 11 +
web/assets/graph.js | 107 +++++
web/assets/styles.css | 424 +++++++++++++++++
web/console.go | 161 +++++++
web/console_test.go | 195 ++++++++
web/embed.go | 11 +
web/negotiate.go | 126 +++++
web/pages.go | 688 +++++++++++++++++++++++++++
web/render.go | 700 ++++++++++++++++++++++++++++
web/templates/base.html | 38 ++
web/templates/pages/about.html | 48 ++
web/templates/pages/browse.html | 51 ++
web/templates/pages/collection.html | 19 +
web/templates/pages/dashboard.html | 38 ++
web/templates/pages/domain.html | 44 ++
web/templates/pages/error.html | 16 +
web/templates/pages/graph.html | 35 ++
web/templates/pages/links.html | 22 +
web/templates/pages/locate.html | 19 +
web/templates/pages/notfound.html | 11 +
web/templates/pages/resolve.html | 36 ++
web/templates/pages/resource.html | 83 ++++
web/templates/pages/search.html | 48 ++
web/templates/partials/card.html | 14 +
web/templates/partials/crumbs.html | 9 +
web/templates/partials/footer.html | 9 +
web/templates/partials/icon.html | 22 +
web/templates/partials/sidebar.html | 43 ++
web/templates/partials/topbar.html | 28 ++
37 files changed, 3346 insertions(+), 133 deletions(-)
create mode 100644 ant/cache.go
create mode 100644 web/assets/ant.svg
create mode 100644 web/assets/app.js
create mode 100644 web/assets/favicon.svg
create mode 100644 web/assets/graph.js
create mode 100644 web/assets/styles.css
create mode 100644 web/console.go
create mode 100644 web/console_test.go
create mode 100644 web/embed.go
create mode 100644 web/negotiate.go
create mode 100644 web/pages.go
create mode 100644 web/render.go
create mode 100644 web/templates/base.html
create mode 100644 web/templates/pages/about.html
create mode 100644 web/templates/pages/browse.html
create mode 100644 web/templates/pages/collection.html
create mode 100644 web/templates/pages/dashboard.html
create mode 100644 web/templates/pages/domain.html
create mode 100644 web/templates/pages/error.html
create mode 100644 web/templates/pages/graph.html
create mode 100644 web/templates/pages/links.html
create mode 100644 web/templates/pages/locate.html
create mode 100644 web/templates/pages/notfound.html
create mode 100644 web/templates/pages/resolve.html
create mode 100644 web/templates/pages/resource.html
create mode 100644 web/templates/pages/search.html
create mode 100644 web/templates/partials/card.html
create mode 100644 web/templates/partials/crumbs.html
create mode 100644 web/templates/partials/footer.html
create mode 100644 web/templates/partials/icon.html
create mode 100644 web/templates/partials/sidebar.html
create mode 100644 web/templates/partials/topbar.html
diff --git a/ant/ant.go b/ant/ant.go
index f2536b0..a6e1b71 100644
--- a/ant/ant.go
+++ b/ant/ant.go
@@ -18,6 +18,7 @@ package ant
import (
"context"
+ "encoding/json"
"os"
"path/filepath"
"time"
@@ -82,6 +83,24 @@ func (e *Engine) Domains() []DomainInfo {
return out
}
+// Domain returns the descriptor of a single registered domain by scheme or
+// alias, the lookup the web console uses to render one domain's detail page.
+func (e *Engine) Domain(scheme string) (DomainInfo, bool) {
+ info, ok := e.host.Domain(scheme)
+ if !ok {
+ return DomainInfo{}, false
+ }
+ return DomainInfo{
+ Scheme: info.Scheme,
+ Aliases: info.Aliases,
+ Hosts: info.Hosts,
+ Binary: info.Identity.Binary,
+ Short: info.Identity.Short,
+ Site: info.Identity.Site,
+ Repo: info.Identity.Repo,
+ }, true
+}
+
// DomainInfo is one registered domain, as `ant domains` prints it.
type DomainInfo struct {
Scheme string `json:"scheme"`
@@ -134,6 +153,68 @@ func (e *Engine) List(ctx context.Context, u kit.URI, limit int) ([]kit.Envelope
return out, nil
}
+// Searchable reports whether a domain (by scheme or alias) supports free-text
+// search, so the web console can decide to show a search box for it.
+func (e *Engine) Searchable(scheme string) bool { return e.host.Searchable(scheme) }
+
+// Search runs a domain's free-text search and returns the hits as envelopes. A
+// hit that is URI-addressable carries its canonical @id, so it links straight to
+// get; one that is not still surfaces, wrapped with the scheme as @type and no
+// @id. limit caps the result (0 means the op's own default). Search hits are
+// previews and are not written to the data tree; dereferencing one caches it.
+func (e *Engine) Search(ctx context.Context, scheme, query string, limit int) ([]kit.Envelope, error) {
+ recs, err := e.host.Search(ctx, scheme, query, limit)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]kit.Envelope, 0, len(recs))
+ for _, rec := range recs {
+ env, err := e.host.Wrap(rec, e.now())
+ if err != nil {
+ env = kit.Envelope{Type: scheme, Data: rec}
+ }
+ // A search hit often is not itself a mintable resource (it is a preview
+ // shape, not the record type), so Wrap leaves @id empty. When the hit
+ // carries a site URL, resolve it back to the canonical URI so the result is
+ // still one click from its record.
+ if env.ID == "" {
+ if u, ok := e.uriFromHit(scheme, rec); ok {
+ env.ID = u.String()
+ env.Type = u.Scheme + "/" + u.Authority
+ }
+ }
+ out = append(out, env)
+ }
+ return out, nil
+}
+
+// uriFromHit recovers a canonical URI from a search hit that did not mint one, by
+// resolving a URL-bearing field (url/link/href) through the domain. It is how a
+// preview-shaped result becomes dereferenceable.
+func (e *Engine) uriFromHit(scheme string, rec any) (kit.URI, bool) {
+ blob, err := json.Marshal(rec)
+ if err != nil {
+ return kit.URI{}, false
+ }
+ var fields map[string]any
+ if err := json.Unmarshal(blob, &fields); err != nil {
+ return kit.URI{}, false
+ }
+ for _, key := range []string{"url", "link", "href", "permalink"} {
+ s, ok := fields[key].(string)
+ if !ok || s == "" {
+ continue
+ }
+ if u, err := e.Resolve(s, ""); err == nil {
+ return u, true
+ }
+ if u, err := e.Resolve(s, scheme); err == nil {
+ return u, true
+ }
+ }
+ return kit.URI{}, false
+}
+
// Links fetches a URI's record and returns its outbound graph edges as URIs.
func (e *Engine) Links(ctx context.Context, u kit.URI) ([]kit.URI, error) {
rec, err := e.host.Get(ctx, u)
diff --git a/ant/cache.go b/ant/cache.go
new file mode 100644
index 0000000..d3ad11c
--- /dev/null
+++ b/ant/cache.go
@@ -0,0 +1,92 @@
+package ant
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "strings"
+
+ "github.com/tamnd/any-cli/kit"
+)
+
+// Fetched is a dereferenced record with the provenance the web console needs:
+// whether it came from the on-disk cache or a live fetch, its long-text body
+// when it has one, and the canonical envelope JSON, so a renderer can show the
+// record's fields in their declared order (a map would lose it).
+type Fetched struct {
+ Env kit.Envelope
+ Raw json.RawMessage // the indented envelope JSON, as written to disk
+ Body string
+ HasBody bool
+ FromCache bool
+}
+
+// Dereference resolves a URI cache-first: it returns the record already
+// materialized under the data tree when one is present, and only fetches from
+// the network on a cache miss or when refresh forces it. A live fetch is written
+// back to the tree (JSON always, plus Markdown when the record has a body) so the
+// next read is offline. This is the read path the web console drives, so browsing
+// never re-fetches what ant already holds, and the refresh switch is the explicit
+// way to pull a fresh copy.
+func (e *Engine) Dereference(ctx context.Context, u kit.URI, refresh bool) (Fetched, error) {
+ if !refresh {
+ if f, ok := e.readCache(u); ok {
+ return f, nil
+ }
+ }
+ env, err := e.Get(ctx, u)
+ if err != nil {
+ return Fetched{}, err
+ }
+ body, hasBody := e.host.Body(env.Data)
+ // Write the record back so the next read is a cache hit. A write failure must
+ // not fail the read: the record is already in hand, and a read-only data dir
+ // should still serve.
+ _, _ = e.writeEnvelope(u, env, hasBody)
+ raw, err := json.MarshalIndent(env, "", " ")
+ if err != nil {
+ return Fetched{}, err
+ }
+ return Fetched{Env: env, Raw: raw, Body: body, HasBody: hasBody, FromCache: false}, nil
+}
+
+// Cached reports whether a URI's record is already materialized on disk, so a
+// caller can show a cache badge or a refresh affordance without reading the file.
+func (e *Engine) Cached(u kit.URI) bool {
+ _, err := os.Stat(e.dataFile(u, "json"))
+ return err == nil
+}
+
+// readCache reads a materialized record from the data tree, returning false on
+// any miss (absent or unreadable) so the caller falls through to a live fetch.
+func (e *Engine) readCache(u kit.URI) (Fetched, bool) {
+ blob, err := os.ReadFile(e.dataFile(u, "json"))
+ if err != nil {
+ return Fetched{}, false
+ }
+ var env kit.Envelope
+ if err := json.Unmarshal(blob, &env); err != nil {
+ return Fetched{}, false
+ }
+ f := Fetched{Env: env, Raw: blob, FromCache: true}
+ if body, ok := readBodyFile(e.dataFile(u, "md")); ok {
+ f.Body, f.HasBody = body, true
+ }
+ return f, true
+}
+
+// readBodyFile reads an exported Markdown body, stripping the JSON front-matter
+// block writeEnvelope writes between the leading "---" fences.
+func readBodyFile(path string) (string, bool) {
+ blob, err := os.ReadFile(path)
+ if err != nil {
+ return "", false
+ }
+ s := string(blob)
+ if strings.HasPrefix(s, "---\n") {
+ if i := strings.Index(s[4:], "\n---\n"); i >= 0 {
+ s = s[4+i+len("\n---\n"):]
+ }
+ }
+ return strings.TrimLeft(s, "\n"), true
+}
diff --git a/cli/serve.go b/cli/serve.go
index 22d47ff..fe53cd3 100644
--- a/cli/serve.go
+++ b/cli/serve.go
@@ -6,37 +6,50 @@ import (
"fmt"
"net"
"net/http"
- "strconv"
- "strings"
"time"
"github.com/spf13/cobra"
- "github.com/tamnd/ant/ant"
- "github.com/tamnd/any-cli/kit"
+ "github.com/tamnd/ant/web"
)
func newServeCmd() *cobra.Command {
var addr string
cmd := &cobra.Command{
Use: "serve",
- Short: "Dereference server: HTTP GET on a URI returns the record",
- Args: cobra.NoArgs,
+ Short: "Web console + dereference server over the URI namespace",
+ Long: `serve runs the ant web console: a browser GUI over the whole resource-URI
+namespace, server-rendered and styled like shadcn/ui. The same URLs answer with
+JSON for scripts under content negotiation (or the /api/ prefix), so the GET-a-URI
+contract ant serve has always offered is preserved.
+
+ ant serve
+ ant serve --addr :8080
+
+Then open http://localhost:7777/ in a browser, or:
+
+ curl http://localhost:7777/api/resolve?input=https://x.com/nasa
+ curl -H 'Accept: application/json' http://localhost:7777/x://status/20`,
+ Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
e, err := engineFrom()
if err != nil {
return err
}
+ console, err := web.New(e, web.Build{Version: Version, Commit: Commit, Date: Date})
+ if err != nil {
+ return err
+ }
srv := &http.Server{
Addr: addr,
- Handler: dereferenceMux(e),
+ Handler: console.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
- if _, err := fmt.Fprintf(c.OutOrStdout(), "ant serve listening on %s\n", ln.Addr()); err != nil {
+ if _, err := fmt.Fprintf(c.OutOrStdout(), "ant serve listening on http://%s\n", ln.Addr()); err != nil {
return err
}
@@ -56,104 +69,3 @@ func newServeCmd() *cobra.Command {
cmd.Flags().StringVar(&addr, "addr", ":7777", "listen address")
return cmd
}
-
-// dereferenceMux turns the URI namespace into dereferenceable linked data: a raw
-// URI path returns its record, and the query endpoints cover resolve/ls/links/url.
-//
-// It routes by hand rather than through http.ServeMux on purpose. A resource URI
-// in the path carries a "//" (GET /x://status/20), and ServeMux's path cleaning
-// would collapse that to "/" and 301-redirect before the handler ran. Dispatching
-// on the first path segment leaves the rest of the path untouched.
-func dereferenceMux(e *ant.Engine) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- raw := strings.TrimPrefix(r.URL.Path, "/")
- switch firstSegment(raw) {
- case "healthz":
- _, _ = w.Write([]byte("ok\n"))
- case "resolve":
- u, err := e.Resolve(r.URL.Query().Get("input"), r.URL.Query().Get("on"))
- if err != nil {
- httpErr(w, http.StatusBadRequest, err)
- return
- }
- httpJSON(w, map[string]string{"uri": u.String()})
- case "url":
- u, err := kit.ParseURI(r.URL.Query().Get("uri"))
- if err != nil {
- httpErr(w, http.StatusBadRequest, err)
- return
- }
- loc, err := e.URL(u)
- if err != nil {
- httpErr(w, http.StatusBadRequest, err)
- return
- }
- httpJSON(w, map[string]string{"url": loc})
- case "ls":
- u, err := kit.ParseURI(r.URL.Query().Get("uri"))
- if err != nil {
- httpErr(w, http.StatusBadRequest, err)
- return
- }
- limit, _ := strconv.Atoi(r.URL.Query().Get("n"))
- envs, err := e.List(r.Context(), u, limit)
- if err != nil {
- httpErr(w, http.StatusBadGateway, err)
- return
- }
- httpJSON(w, envs)
- case "links":
- u, err := kit.ParseURI(r.URL.Query().Get("uri"))
- if err != nil {
- httpErr(w, http.StatusBadRequest, err)
- return
- }
- links, err := e.Links(r.Context(), u)
- if err != nil {
- httpErr(w, http.StatusBadGateway, err)
- return
- }
- out := make([]string, 0, len(links))
- for _, lu := range links {
- out = append(out, lu.String())
- }
- httpJSON(w, out)
- case "":
- httpJSON(w, map[string]any{"service": "ant", "domains": e.Domains()})
- default:
- // A raw URI in the path (GET /goodreads://book/2767052).
- u, err := kit.ParseURI(raw)
- if err != nil {
- httpErr(w, http.StatusBadRequest, err)
- return
- }
- env, err := e.Get(r.Context(), u)
- if err != nil {
- httpErr(w, http.StatusBadGateway, err)
- return
- }
- httpJSON(w, env)
- }
- })
-}
-
-// firstSegment returns the path up to the first "/", used to pick the named
-// endpoint. A resource URI like "x://status/20" has first segment "x:", so it
-// falls through to the dereference catch-all rather than a named route.
-func firstSegment(path string) string {
- if i := strings.IndexByte(path, '/'); i >= 0 {
- return path[:i]
- }
- return path
-}
-
-func httpJSON(w http.ResponseWriter, v any) {
- w.Header().Set("Content-Type", "application/json")
- _ = writeJSON(w, v)
-}
-
-func httpErr(w http.ResponseWriter, code int, err error) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(code)
- _ = writeJSON(w, map[string]string{"error": err.Error()})
-}
diff --git a/cli/serve_test.go b/cli/serve_test.go
index dfff6e0..391aee4 100644
--- a/cli/serve_test.go
+++ b/cli/serve_test.go
@@ -7,22 +7,29 @@ import (
"testing"
"github.com/tamnd/ant/ant"
+ "github.com/tamnd/ant/web"
)
// The drivers are blank-imported by root.go, so the cli test binary has the
-// goodreads, x, and wikipedia domains registered. That is enough to exercise the
-// router's offline paths without touching the network.
-func newTestEngine(t *testing.T) *ant.Engine {
+// goodreads, x, wikipedia and youtube domains registered. That is enough to
+// exercise the console's offline paths without touching the network.
+func newTestHandler(t *testing.T) http.Handler {
t.Helper()
e, err := ant.New(ant.WithRoot(t.TempDir()))
if err != nil {
t.Fatal(err)
}
- return e
+ console, err := web.New(e, web.Build{Version: "test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return console.Handler()
}
+// A request without an Accept: text/html header negotiates to JSON, so the
+// console keeps answering scripts the way ant serve always has.
func TestServeNamedEndpoints(t *testing.T) {
- h := dereferenceMux(newTestEngine(t))
+ h := newTestHandler(t)
cases := []struct {
path, want string
@@ -30,6 +37,7 @@ func TestServeNamedEndpoints(t *testing.T) {
{"/healthz", "ok"},
{"/resolve?input=https://x.com/nasa", `"x://user/nasa"`},
{"/url?uri=x://user/nasa", `"https://x.com/nasa"`},
+ {"/api/resolve?input=https://x.com/nasa", `"x://user/nasa"`},
}
for _, c := range cases {
rec := httptest.NewRecorder()
@@ -44,31 +52,34 @@ func TestServeNamedEndpoints(t *testing.T) {
}
}
+// A browser (Accept: text/html) gets the GUI, not JSON, on the same URL.
+func TestServeNegotiatesHTML(t *testing.T) {
+ h := newTestHandler(t)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("Accept", "text/html")
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("/ code %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
+ t.Errorf("/ content-type %q, want text/html", ct)
+ }
+ if !strings.Contains(rec.Body.String(), "") {
+ t.Error("/ did not render the HTML shell")
+ }
+}
+
// The regression this guards: a resource URI in the path carries a "//", and an
// http.ServeMux would 301-redirect it (collapsing the slashes) before any handler
// ran. The hand-rolled router must instead reach the dereference handler. We can
// assert that offline because the only failure left is the network fetch, which
// is a 502 gateway error, never a 301.
func TestServeRawURIPathIsNotRedirected(t *testing.T) {
- h := dereferenceMux(newTestEngine(t))
+ h := newTestHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x://status/20", nil))
if rec.Code == http.StatusMovedPermanently || rec.Code == http.StatusPermanentRedirect {
t.Fatalf("raw URI path was redirected (code %d); the // was collapsed", rec.Code)
}
}
-
-func TestFirstSegment(t *testing.T) {
- cases := map[string]string{
- "healthz": "healthz",
- "resolve": "resolve",
- "x://status/20": "x:",
- "goodreads://book/1": "goodreads:",
- "": "",
- }
- for in, want := range cases {
- if got := firstSegment(in); got != want {
- t.Errorf("firstSegment(%q) = %q, want %q", in, got, want)
- }
- }
-}
diff --git a/go.mod b/go.mod
index d92633d..49b0486 100644
--- a/go.mod
+++ b/go.mod
@@ -5,11 +5,13 @@ go 1.26.4
require (
github.com/charmbracelet/fang v1.0.0
github.com/spf13/cobra v1.10.2
- github.com/tamnd/any-cli v0.2.0
+ github.com/tamnd/any-cli v0.3.3
github.com/tamnd/goodread-cli v0.2.0
github.com/tamnd/x-cli v0.2.0
)
+require github.com/yuin/goldmark v1.8.2
+
require (
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
diff --git a/go.sum b/go.sum
index d2a4e45..e8b19b0 100644
--- a/go.sum
+++ b/go.sum
@@ -85,8 +85,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/tamnd/any-cli v0.2.0 h1:0m4a4ssG9fd6a/n9/5BnNO2WnB3Emt4OwFdY4y7WTEU=
-github.com/tamnd/any-cli v0.2.0/go.mod h1:eX/Ak1Ccn1eTBmkFouKtEzg9TG375tUu8zpIrr0GZF8=
+github.com/tamnd/any-cli v0.3.3 h1:1DVkafJsDi7EfFVJUvcHXIh/CTUAnzAI/MCwqhzX0JQ=
+github.com/tamnd/any-cli v0.3.3/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk=
github.com/tamnd/goodread-cli v0.2.0 h1:gtizbjAdZqGvmrMwcug/y9o39OODX5EHW0AkmULBMdU=
github.com/tamnd/goodread-cli v0.2.0/go.mod h1:QVmIwTAuIrQD15JJKDahdrEwSJi/36cq4fRtxPlwxOk=
github.com/tamnd/wikipedia-cli v0.1.0 h1:LOLYAnH2LOIl6fr2ES1gJSo0sLDTMfvhcMiZZ+eA/yg=
@@ -98,6 +98,8 @@ github.com/tamnd/ytb-cli v0.2.0/go.mod h1:/DoozwStkw9OMRksN12EOfPRP+66ODZMKg7/CD
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
diff --git a/web/assets/ant.svg b/web/assets/ant.svg
new file mode 100644
index 0000000..3f48db8
--- /dev/null
+++ b/web/assets/ant.svg
@@ -0,0 +1,10 @@
+
diff --git a/web/assets/app.js b/web/assets/app.js
new file mode 100644
index 0000000..e50da5b
--- /dev/null
+++ b/web/assets/app.js
@@ -0,0 +1,47 @@
+// ant console behavior: theme toggle, the mobile nav drawer, and select
+// auto-submit. Progressive enhancement only — every page works without it
+// (8000_ant_serve §3, §6.3). No dependencies.
+(function () {
+ "use strict";
+
+ // Theme toggle, persisted in localStorage (the inline head script applies it
+ // before first paint to avoid a flash).
+ var root = document.documentElement;
+ document.querySelectorAll("[data-theme-toggle]").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ var next = root.dataset.theme === "dark" ? "light" : "dark";
+ root.dataset.theme = next;
+ try { localStorage.setItem("ant-theme", next); } catch (e) {}
+ });
+ });
+
+ // Mobile navigation drawer.
+ document.querySelectorAll("[data-menu-toggle]").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ document.body.classList.toggle("menu-open");
+ });
+ });
+ document.addEventListener("click", function (e) {
+ if (!document.body.classList.contains("menu-open")) return;
+ var sidebar = document.querySelector(".sidebar");
+ var toggle = e.target.closest("[data-menu-toggle]");
+ if (toggle) return;
+ if (sidebar && !sidebar.contains(e.target)) document.body.classList.remove("menu-open");
+ });
+
+ // Selects that submit their form on change (graph depth, etc).
+ document.querySelectorAll("select[data-autosubmit]").forEach(function (sel) {
+ sel.addEventListener("change", function () {
+ if (sel.form) sel.form.submit();
+ });
+ });
+
+ // "/" focuses the omni bar, like a command palette shortcut.
+ document.addEventListener("keydown", function (e) {
+ if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
+ var tag = (e.target.tagName || "").toLowerCase();
+ if (tag === "input" || tag === "select" || tag === "textarea") return;
+ var omni = document.querySelector(".omni-input");
+ if (omni) { e.preventDefault(); omni.focus(); }
+ });
+})();
diff --git a/web/assets/favicon.svg b/web/assets/favicon.svg
new file mode 100644
index 0000000..7b27192
--- /dev/null
+++ b/web/assets/favicon.svg
@@ -0,0 +1,11 @@
+
diff --git a/web/assets/graph.js b/web/assets/graph.js
new file mode 100644
index 0000000..3d0159d
--- /dev/null
+++ b/web/assets/graph.js
@@ -0,0 +1,107 @@
+// graph.js renders the link graph the /graph page embeds as JSON in
+// #graph[data-payload]. It is a tiny self-contained force layout drawn to SVG;
+// nodes link back to their record. Loaded only on the graph page
+// (8000_ant_serve §10). No dependencies.
+(function () {
+ "use strict";
+ var host = document.getElementById("graph");
+ if (!host) return;
+
+ var data;
+ try { data = JSON.parse(host.dataset.payload || "{}"); } catch (e) { return; }
+ var nodes = (data.nodes || []).map(function (n) { return Object.assign({}, n); });
+ var edges = data.edges || [];
+ if (!nodes.length) { host.innerHTML = '
No nodes to draw.
'; return; }
+
+ var W = host.clientWidth || 720, H = host.clientHeight || 460;
+ var byURI = {};
+ nodes.forEach(function (n, i) {
+ byURI[n.uri] = n;
+ // Seed on a circle so the layout opens up rather than collapsing.
+ var a = (i / nodes.length) * Math.PI * 2;
+ n.x = W / 2 + Math.cos(a) * Math.min(W, H) * 0.3;
+ n.y = H / 2 + Math.sin(a) * Math.min(W, H) * 0.3;
+ n.vx = 0; n.vy = 0;
+ n.root = n.uri === data.root;
+ });
+ var links = edges.filter(function (e) { return byURI[e.from] && byURI[e.to]; })
+ .map(function (e) { return { s: byURI[e.from], t: byURI[e.to] }; });
+
+ // Force-directed layout: Coulomb repulsion, Hooke springs on edges, and a mild
+ // pull to center. A fixed iteration count keeps it cheap and deterministic.
+ var K = 130;
+ for (var iter = 0; iter < 320; iter++) {
+ for (var i = 0; i < nodes.length; i++) {
+ var a = nodes[i];
+ for (var j = i + 1; j < nodes.length; j++) {
+ var b = nodes[j];
+ var dx = a.x - b.x, dy = a.y - b.y;
+ var d2 = dx * dx + dy * dy || 0.01;
+ var d = Math.sqrt(d2);
+ var rep = (K * K) / d2;
+ var fx = (dx / d) * rep, fy = (dy / d) * rep;
+ a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
+ }
+ a.vx += (W / 2 - a.x) * 0.01;
+ a.vy += (H / 2 - a.y) * 0.01;
+ }
+ links.forEach(function (l) {
+ var dx = l.t.x - l.s.x, dy = l.t.y - l.s.y;
+ var d = Math.sqrt(dx * dx + dy * dy) || 0.01;
+ var f = (d - K) * 0.04;
+ var fx = (dx / d) * f, fy = (dy / d) * f;
+ l.s.vx += fx; l.s.vy += fy; l.t.vx -= fx; l.t.vy -= fy;
+ });
+ var damp = 0.85;
+ nodes.forEach(function (n) {
+ n.x += n.vx * 0.04; n.y += n.vy * 0.04;
+ n.vx *= damp; n.vy *= damp;
+ n.x = Math.max(24, Math.min(W - 24, n.x));
+ n.y = Math.max(24, Math.min(H - 24, n.y));
+ });
+ }
+
+ var svgNS = "http://www.w3.org/2000/svg";
+ var svg = document.createElementNS(svgNS, "svg");
+ svg.setAttribute("viewBox", "0 0 " + W + " " + H);
+
+ links.forEach(function (l) {
+ var line = document.createElementNS(svgNS, "line");
+ line.setAttribute("x1", l.s.x); line.setAttribute("y1", l.s.y);
+ line.setAttribute("x2", l.t.x); line.setAttribute("y2", l.t.y);
+ line.setAttribute("stroke", "currentColor");
+ line.setAttribute("stroke-opacity", "0.18");
+ line.setAttribute("stroke-width", "1.4");
+ svg.appendChild(line);
+ });
+
+ nodes.forEach(function (n) {
+ var g = document.createElementNS(svgNS, "a");
+ g.setAttributeNS("http://www.w3.org/1999/xlink", "href", "/view?uri=" + encodeURIComponent(n.uri));
+ g.setAttribute("href", "/view?uri=" + encodeURIComponent(n.uri));
+
+ var c = document.createElementNS(svgNS, "circle");
+ c.setAttribute("cx", n.x); c.setAttribute("cy", n.y);
+ c.setAttribute("r", n.root ? 9 : 6);
+ c.setAttribute("fill", n.accent || "#888");
+ c.setAttribute("stroke", "hsl(var(--bg))");
+ c.setAttribute("stroke-width", "2");
+ g.appendChild(c);
+
+ var t = document.createElementNS(svgNS, "text");
+ t.setAttribute("x", n.x + 10); t.setAttribute("y", n.y + 4);
+ t.setAttribute("font-size", n.root ? "13" : "12");
+ t.setAttribute("fill", "currentColor");
+ t.textContent = (n.label || n.uri).slice(0, 28);
+ g.appendChild(t);
+
+ var title = document.createElementNS(svgNS, "title");
+ title.textContent = n.uri;
+ g.appendChild(title);
+
+ svg.appendChild(g);
+ });
+
+ host.textContent = "";
+ host.appendChild(svg);
+})();
diff --git a/web/assets/styles.css b/web/assets/styles.css
new file mode 100644
index 0000000..59757c9
--- /dev/null
+++ b/web/assets/styles.css
@@ -0,0 +1,424 @@
+/* ant console — a hand-written stylesheet in the shadcn/ui idiom: HSL design
+ tokens, a light/dark theme switched by [data-theme], soft cards, subtle rings
+ and a restrained accent. No framework, no build step (8000_ant_serve §3). */
+
+/* ---- tokens --------------------------------------------------------------- */
+:root {
+ --bg: 0 0% 100%;
+ --fg: 240 10% 9%;
+ --muted: 240 4% 46%;
+ --card: 0 0% 100%;
+ --card-fg: 240 10% 9%;
+ --border: 240 6% 90%;
+ --input: 240 6% 90%;
+ --ring: 240 5% 65%;
+ --accent-bg: 240 5% 96%;
+ --accent-fg: 240 6% 20%;
+ --primary: 240 6% 10%;
+ --primary-fg: 0 0% 98%;
+ --danger: 0 72% 51%;
+ --ok: 142 71% 40%;
+ --warn: 38 92% 45%;
+ --radius: 12px;
+ --radius-sm: 8px;
+ --shadow: 0 1px 2px hsl(240 6% 10% / .06), 0 8px 24px -12px hsl(240 6% 10% / .14);
+ --shadow-sm: 0 1px 2px hsl(240 6% 10% / .07);
+ --sidebar-w: 248px;
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+}
+[data-theme="dark"] {
+ --bg: 240 10% 6%;
+ --fg: 0 0% 96%;
+ --muted: 240 5% 60%;
+ --card: 240 9% 9%;
+ --card-fg: 0 0% 96%;
+ --border: 240 5% 17%;
+ --input: 240 5% 18%;
+ --ring: 240 5% 40%;
+ --accent-bg: 240 5% 15%;
+ --accent-fg: 0 0% 92%;
+ --primary: 0 0% 96%;
+ --primary-fg: 240 10% 9%;
+ --danger: 0 72% 58%;
+ --ok: 142 64% 50%;
+ --warn: 38 92% 56%;
+ --shadow: 0 1px 2px hsl(0 0% 0% / .3), 0 8px 30px -12px hsl(0 0% 0% / .55);
+ --shadow-sm: 0 1px 2px hsl(0 0% 0% / .35);
+}
+
+/* ---- reset ---------------------------------------------------------------- */
+*, *::before, *::after { box-sizing: border-box; }
+* { margin: 0; }
+html { -webkit-text-size-adjust: 100%; }
+body {
+ font-family: var(--sans);
+ background: hsl(var(--bg));
+ color: hsl(var(--fg));
+ line-height: 1.55;
+ font-size: 15px;
+ -webkit-font-smoothing: antialiased;
+}
+a { color: inherit; text-decoration: none; }
+img { max-width: 100%; display: block; }
+button, input, select, textarea { font: inherit; color: inherit; }
+code { font-family: var(--mono); font-size: .86em; }
+svg.icon { width: 18px; height: 18px; flex: none; }
+
+::selection { background: hsl(var(--fg) / .14); }
+:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; border-radius: 4px; }
+
+.skip {
+ position: absolute; left: -999px; top: 8px; z-index: 50;
+ background: hsl(var(--card)); padding: 8px 14px; border-radius: var(--radius-sm);
+ box-shadow: var(--shadow);
+}
+.skip:focus { left: 8px; }
+
+.muted { color: hsl(var(--muted)); }
+
+/* ---- layout --------------------------------------------------------------- */
+.layout { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
+.content { display: flex; flex-direction: column; min-width: 0; }
+.main { flex: 1; padding: 28px 36px 48px; max-width: 1180px; width: 100%; }
+
+/* ---- sidebar -------------------------------------------------------------- */
+.sidebar {
+ position: sticky; top: 0; height: 100vh; overflow-y: auto;
+ border-right: 1px solid hsl(var(--border));
+ background: hsl(var(--bg));
+ padding: 18px 14px; display: flex; flex-direction: column; gap: 18px;
+}
+.brand { display: flex; align-items: center; gap: 10px; padding: 6px 8px; }
+.brand-mark { border-radius: 8px; }
+.brand-name { font-weight: 700; font-size: 19px; letter-spacing: -.02em; }
+.nav { display: flex; flex-direction: column; gap: 2px; }
+.nav-link {
+ display: flex; align-items: center; gap: 10px;
+ padding: 8px 10px; border-radius: var(--radius-sm);
+ color: hsl(var(--muted)); font-weight: 500; font-size: 14px;
+ transition: background .12s, color .12s;
+}
+.nav-link:hover { background: hsl(var(--accent-bg)); color: hsl(var(--fg)); }
+.nav-link.is-active { background: hsl(var(--accent-bg)); color: hsl(var(--fg)); font-weight: 600; }
+.nav-link .icon { width: 17px; height: 17px; }
+.nav-section-title {
+ text-transform: uppercase; letter-spacing: .08em; font-size: 11px;
+ font-weight: 600; color: hsl(var(--muted)); padding: 0 10px 6px;
+}
+.nav-domain { justify-content: flex-start; }
+.nav-domain-name { flex: 1; }
+.nav-flag { color: hsl(var(--muted)); display: inline-flex; }
+.nav-flag .icon { width: 13px; height: 13px; }
+.sidebar-foot { margin-top: auto; padding: 8px 10px; font-size: 12px; }
+
+.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
+.dot-lg { width: 13px; height: 13px; }
+
+/* ---- topbar --------------------------------------------------------------- */
+.topbar {
+ position: sticky; top: 0; z-index: 20;
+ display: flex; align-items: center; gap: 12px;
+ padding: 12px 36px; border-bottom: 1px solid hsl(var(--border));
+ background: hsl(var(--bg) / .85); backdrop-filter: blur(8px);
+}
+.omni {
+ flex: 1; display: flex; align-items: center; gap: 8px;
+ background: hsl(var(--card)); border: 1px solid hsl(var(--input));
+ border-radius: 10px; padding: 0 8px 0 12px; max-width: 720px;
+ transition: border-color .12s, box-shadow .12s;
+}
+.omni:focus-within { border-color: hsl(var(--ring)); box-shadow: 0 0 0 3px hsl(var(--ring) / .18); }
+.omni-icon { color: hsl(var(--muted)); display: inline-flex; }
+.omni-input { flex: 1; border: 0; background: transparent; padding: 9px 4px; outline: none; min-width: 0; }
+.omni-go {
+ border: 0; background: hsl(var(--primary)); color: hsl(var(--primary-fg));
+ padding: 6px 14px; border-radius: 8px; font-weight: 600; cursor: pointer;
+}
+.omni-go:hover { opacity: .9; }
+
+.findbox {
+ display: flex; align-items: center; gap: 6px;
+ background: hsl(var(--card)); border: 1px solid hsl(var(--input));
+ border-radius: 10px; padding: 0 6px 0 10px;
+}
+.findbox:focus-within { border-color: hsl(var(--ring)); }
+.findbox-icon { color: hsl(var(--muted)); display: inline-flex; }
+.findbox-input { border: 0; background: transparent; padding: 8px 2px; outline: none; width: 130px; }
+.findbox-on { border: 0; background: transparent; outline: none; cursor: pointer; padding: 6px 2px; color: hsl(var(--muted)); }
+
+.icon-btn {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 38px; height: 38px; border-radius: 9px; cursor: pointer;
+ background: transparent; border: 1px solid transparent; color: hsl(var(--muted));
+}
+.icon-btn:hover { background: hsl(var(--accent-bg)); color: hsl(var(--fg)); }
+.menu-toggle { display: none; }
+.theme-toggle .theme-moon { display: none; }
+[data-theme="dark"] .theme-toggle .theme-sun { display: none; }
+[data-theme="dark"] .theme-toggle .theme-moon { display: inline-flex; }
+
+/* ---- footer --------------------------------------------------------------- */
+.foot {
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
+ padding: 18px 36px; border-top: 1px solid hsl(var(--border));
+ color: hsl(var(--muted)); font-size: 13px;
+}
+.foot-link:hover { color: hsl(var(--fg)); }
+.foot-dot { opacity: .5; }
+
+/* ---- typography ----------------------------------------------------------- */
+.page-head { margin-bottom: 22px; }
+.page-head-row { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
+.page-title { font-size: 26px; font-weight: 700; letter-spacing: -.02em; }
+.page-sub { color: hsl(var(--muted)); margin-top: 4px; }
+.section-title { font-size: 14px; font-weight: 600; color: hsl(var(--muted)); text-transform: uppercase; letter-spacing: .06em; margin: 28px 0 14px; }
+.lead { font-size: 17px; color: hsl(var(--muted)); margin: 6px 0 18px; max-width: 70ch; }
+
+/* ---- cards ---------------------------------------------------------------- */
+.card {
+ background: hsl(var(--card)); color: hsl(var(--card-fg));
+ border: 1px solid hsl(var(--border)); border-radius: var(--radius);
+ padding: 18px; box-shadow: var(--shadow-sm);
+}
+.card-title { font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: hsl(var(--muted)); margin-bottom: 12px; }
+.grid { display: grid; gap: 14px; }
+.grid-domains { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
+.grid-cards { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
+
+/* ---- hero ----------------------------------------------------------------- */
+.hero {
+ border: 1px solid hsl(var(--border)); border-radius: 16px;
+ padding: 34px; margin-bottom: 8px;
+ background:
+ radial-gradient(900px 300px at 12% -20%, hsl(var(--accent-bg)), transparent),
+ hsl(var(--card));
+}
+.hero-title { font-size: 32px; font-weight: 800; letter-spacing: -.03em; }
+.hero-sub { color: hsl(var(--muted)); margin-top: 10px; max-width: 64ch; }
+.hero-stats { display: flex; gap: 14px; margin-top: 22px; flex-wrap: wrap; }
+.stat {
+ display: inline-flex; align-items: center; gap: 10px;
+ border: 1px solid hsl(var(--border)); border-radius: var(--radius);
+ padding: 12px 16px; background: hsl(var(--bg));
+}
+.stat .icon { color: hsl(var(--muted)); }
+.stat-num { font-size: 20px; font-weight: 700; }
+.stat-label { color: hsl(var(--muted)); font-size: 13px; }
+.stat-path .stat-label { font-family: var(--mono); font-size: 12px; }
+
+/* ---- domain cards --------------------------------------------------------- */
+.domain-card { display: flex; flex-direction: column; gap: 10px; transition: border-color .12s, box-shadow .12s; }
+.domain-card:hover { border-color: hsl(var(--ring)); box-shadow: var(--shadow); }
+.domain-card-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.domain-card-name { font-weight: 700; font-size: 17px; }
+.domain-card-name:hover { text-decoration: underline; }
+.domain-card-short { color: hsl(var(--muted)); font-size: 14px; }
+.domain-card-examples { display: flex; flex-wrap: wrap; gap: 6px; }
+
+/* ---- badges & chips ------------------------------------------------------- */
+.badge {
+ display: inline-flex; align-items: center; gap: 4px;
+ font-size: 11.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px;
+ background: hsl(var(--accent-bg)); color: hsl(var(--accent-fg));
+ border: 1px solid hsl(var(--border)); white-space: nowrap;
+}
+.badge-soft { font-weight: 500; }
+.badge-cache { color: hsl(var(--muted)); }
+.badge-live { background: hsl(var(--ok) / .14); color: hsl(var(--ok)); border-color: hsl(var(--ok) / .3); }
+.chip {
+ display: inline-flex; align-items: center; gap: 5px; max-width: 100%;
+ font-size: 13px; padding: 4px 10px; border-radius: 8px;
+ background: hsl(var(--accent-bg)); border: 1px solid hsl(var(--border));
+ color: hsl(var(--fg)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ transition: border-color .12s, background .12s;
+}
+.chip:hover { border-color: hsl(var(--ring)); }
+.chip-scheme { font-weight: 700; color: hsl(var(--muted)); }
+.chip-lg { font-size: 15px; padding: 8px 14px; }
+.chips { display: flex; flex-wrap: wrap; gap: 6px; }
+
+/* ---- record cards --------------------------------------------------------- */
+.record-card { display: flex; flex-direction: column; overflow: hidden; padding: 0; transition: border-color .12s, box-shadow .12s, transform .12s; }
+.record-card:hover { border-color: hsl(var(--ring)); box-shadow: var(--shadow); transform: translateY(-1px); }
+.record-thumb { aspect-ratio: 16 / 9; overflow: hidden; background: hsl(var(--accent-bg)); }
+.record-thumb img { width: 100%; height: 100%; object-fit: cover; }
+.record-body { padding: 14px; display: flex; flex-direction: column; gap: 6px; }
+.record-head { display: flex; align-items: center; gap: 8px; }
+.record-title { font-weight: 600; line-height: 1.35; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.record-snippet { color: hsl(var(--muted)); font-size: 13px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.record-uri { font-family: var(--mono); font-size: 11.5px; color: hsl(var(--muted)); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.mini-card { display: flex; align-items: center; gap: 8px; padding: 12px 14px; transition: border-color .12s; }
+.mini-card:hover { border-color: hsl(var(--ring)); }
+.mini-card-label { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* ---- resource page -------------------------------------------------------- */
+.res-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin: 14px 0 22px; }
+.res-head-main { display: flex; gap: 12px; align-items: flex-start; }
+.res-title { font-size: 24px; font-weight: 700; letter-spacing: -.02em; word-break: break-word; }
+.res-meta { display: flex; align-items: center; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
+.res-fetched { font-size: 13px; }
+.res-actions { display: flex; gap: 8px; flex-wrap: wrap; }
+.res-grid { display: grid; grid-template-columns: 1fr 300px; gap: 18px; align-items: start; }
+.res-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
+.res-side { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 78px; }
+
+/* ---- key/value lists ------------------------------------------------------ */
+.kv { display: grid; grid-template-columns: minmax(120px, 200px) 1fr; gap: 2px 18px; }
+.kv-tight { grid-template-columns: 100px 1fr; gap: 6px 12px; }
+.kv-key { color: hsl(var(--muted)); font-size: 13px; padding: 7px 0; border-top: 1px solid hsl(var(--border)); }
+.kv-val { padding: 7px 0; border-top: 1px solid hsl(var(--border)); min-width: 0; word-break: break-word; }
+.kv > .kv-key:first-of-type, .kv > .kv-val:nth-of-type(1) { border-top: 0; }
+.kv-tight .kv-key, .kv-tight .kv-val { border: 0; padding: 2px 0; }
+.kv-nested { grid-template-columns: minmax(100px, 160px) 1fr; gap: 0 12px; margin: 2px 0; }
+.value-text { white-space: pre-wrap; }
+.value-text.clamp { max-height: 16em; overflow: auto; display: block; }
+.vlist { list-style: none; display: flex; flex-direction: column; gap: 4px; }
+.thumb { max-width: 160px; border-radius: 8px; border: 1px solid hsl(var(--border)); }
+.ext { color: hsl(212 90% 48%); word-break: break-all; }
+[data-theme="dark"] .ext { color: hsl(212 90% 66%); }
+.ext:hover { text-decoration: underline; }
+.ext-lg { font-size: 16px; }
+.link { display: inline-flex; align-items: center; gap: 6px; }
+.link:hover { text-decoration: underline; }
+
+.link-group { padding: 10px 0; border-top: 1px solid hsl(var(--border)); }
+.link-group:first-child { border-top: 0; padding-top: 0; }
+.link-group-field { font-size: 12px; color: hsl(var(--muted)); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .04em; }
+
+/* ---- prose (markdown body) ------------------------------------------------ */
+.prose { line-height: 1.7; }
+.prose > * + * { margin-top: .9em; }
+.prose h1, .prose h2, .prose h3 { font-weight: 700; line-height: 1.3; margin-top: 1.4em; }
+.prose h1 { font-size: 1.5em; } .prose h2 { font-size: 1.3em; } .prose h3 { font-size: 1.1em; }
+.prose a { color: hsl(212 90% 48%); text-decoration: underline; }
+[data-theme="dark"] .prose a { color: hsl(212 90% 66%); }
+.prose ul, .prose ol { padding-left: 1.4em; }
+.prose blockquote { border-left: 3px solid hsl(var(--border)); padding-left: 1em; color: hsl(var(--muted)); }
+.prose pre { background: hsl(var(--accent-bg)); padding: 14px; border-radius: var(--radius-sm); overflow: auto; }
+.prose code { background: hsl(var(--accent-bg)); padding: .12em .4em; border-radius: 5px; }
+.prose pre code { background: none; padding: 0; }
+.prose img { border-radius: var(--radius-sm); margin: .6em 0; }
+.prose table { border-collapse: collapse; width: 100%; }
+.prose th, .prose td { border: 1px solid hsl(var(--border)); padding: 6px 10px; text-align: left; }
+
+/* ---- raw json / code ------------------------------------------------------ */
+.raw summary { cursor: pointer; font-weight: 600; font-size: 13px; color: hsl(var(--muted)); }
+.code {
+ font-family: var(--mono); font-size: 12.5px; line-height: 1.6;
+ background: hsl(var(--accent-bg)); padding: 14px; border-radius: var(--radius-sm);
+ overflow: auto; margin-top: 12px; max-height: 520px;
+}
+
+/* ---- buttons -------------------------------------------------------------- */
+.btn {
+ display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
+ padding: 8px 14px; border-radius: 9px; font-weight: 600; font-size: 14px;
+ background: hsl(var(--primary)); color: hsl(var(--primary-fg)); border: 1px solid transparent;
+ transition: opacity .12s, background .12s, border-color .12s;
+}
+.btn:hover { opacity: .9; }
+.btn .icon { width: 16px; height: 16px; }
+.btn-primary { background: hsl(var(--primary)); color: hsl(var(--primary-fg)); }
+.btn-ghost { background: hsl(var(--card)); color: hsl(var(--fg)); border-color: hsl(var(--border)); }
+.btn-ghost:hover { background: hsl(var(--accent-bg)); opacity: 1; border-color: hsl(var(--ring)); }
+.inline { display: inline; }
+
+/* ---- forms ---------------------------------------------------------------- */
+.form { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; }
+.field { display: flex; flex-direction: column; gap: 6px; }
+.field-grow { flex: 1; min-width: 240px; }
+.field-narrow { width: 92px; }
+.field label { font-size: 12px; font-weight: 600; color: hsl(var(--muted)); }
+.field input, .field select {
+ border: 1px solid hsl(var(--input)); background: hsl(var(--bg));
+ border-radius: 9px; padding: 9px 11px; outline: none; transition: border-color .12s, box-shadow .12s;
+}
+.field input:focus, .field select:focus { border-color: hsl(var(--ring)); box-shadow: 0 0 0 3px hsl(var(--ring) / .18); }
+.field-action { align-self: flex-end; }
+.search-form { align-items: flex-end; }
+.result { display: flex; flex-direction: column; gap: 12px; }
+.result-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+
+/* ---- banners -------------------------------------------------------------- */
+.banner { padding: 11px 14px; border-radius: var(--radius-sm); margin: 14px 0; font-size: 14px; border: 1px solid; }
+.banner-ok { background: hsl(var(--ok) / .12); border-color: hsl(var(--ok) / .35); }
+.banner-err { background: hsl(var(--danger) / .1); border-color: hsl(var(--danger) / .35); color: hsl(var(--danger)); }
+.banner-warn { background: hsl(var(--warn) / .12); border-color: hsl(var(--warn) / .35); }
+
+/* ---- breadcrumbs ---------------------------------------------------------- */
+.crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 13.5px; margin-bottom: 10px; }
+.crumb { color: hsl(var(--muted)); padding: 2px 6px; border-radius: 6px; }
+.crumb:hover { background: hsl(var(--accent-bg)); color: hsl(var(--fg)); }
+.crumb.is-last { color: hsl(var(--fg)); font-weight: 600; }
+.crumb-sep { color: hsl(var(--muted)); opacity: .6; }
+
+/* ---- folders (browse) ----------------------------------------------------- */
+.folders { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
+.folder {
+ display: flex; align-items: center; gap: 10px;
+ border: 1px solid hsl(var(--border)); border-radius: var(--radius);
+ padding: 13px 14px; background: hsl(var(--card)); transition: border-color .12s, box-shadow .12s, transform .12s;
+}
+.folder:hover { border-color: hsl(var(--ring)); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
+.folder-icon { display: inline-flex; }
+.folder-name { flex: 1; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.folder-count { font-size: 12px; color: hsl(var(--muted)); background: hsl(var(--accent-bg)); padding: 1px 8px; border-radius: 999px; }
+.folder-chevron { color: hsl(var(--muted)); display: inline-flex; }
+.folder-chevron .icon { width: 16px; height: 16px; }
+
+.findbox-inline { padding: 2px 8px; }
+
+/* ---- tables --------------------------------------------------------------- */
+.table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.table th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: hsl(var(--muted)); padding: 8px 10px; border-bottom: 1px solid hsl(var(--border)); }
+.table td { padding: 9px 10px; border-bottom: 1px solid hsl(var(--border)); }
+.table tr:last-child td { border-bottom: 0; }
+
+/* ---- graph ---------------------------------------------------------------- */
+.graph-card { padding: 0; overflow: hidden; }
+.graph-canvas { width: 100%; height: 460px; display: block; }
+.graph-canvas svg { width: 100%; height: 100%; }
+.graph-noscript { padding: 24px; }
+.depth-form { display: flex; align-items: center; gap: 8px; }
+.depth-form label { font-size: 13px; color: hsl(var(--muted)); }
+.depth-form select { border: 1px solid hsl(var(--input)); background: hsl(var(--bg)); border-radius: 8px; padding: 6px 8px; }
+
+/* ---- empty / state -------------------------------------------------------- */
+.empty {
+ display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;
+ padding: 56px 24px; color: hsl(var(--muted));
+ border: 1px dashed hsl(var(--border)); border-radius: var(--radius);
+}
+.empty .icon { width: 34px; height: 34px; opacity: .6; }
+.empty-examples { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
+
+.state { text-align: center; padding: 72px 24px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
+.state-code { font-size: 44px; font-weight: 800; letter-spacing: -.03em; display: inline-flex; align-items: center; gap: 10px; }
+.state-code .icon { width: 40px; height: 40px; }
+.state-title { font-size: 24px; font-weight: 700; }
+.state-uri { font-family: var(--mono); }
+.state-msg { color: hsl(var(--muted)); max-width: 60ch; }
+.state-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 8px; }
+
+/* ---- responsive ----------------------------------------------------------- */
+@media (max-width: 900px) {
+ .layout { grid-template-columns: 1fr; }
+ .sidebar {
+ position: fixed; left: 0; top: 0; z-index: 40; width: 280px;
+ transform: translateX(-100%); transition: transform .2s ease; box-shadow: var(--shadow);
+ }
+ body.menu-open .sidebar { transform: translateX(0); }
+ body.menu-open::after { content: ""; position: fixed; inset: 0; z-index: 30; background: hsl(0 0% 0% / .4); }
+ .menu-toggle { display: inline-flex; }
+ .topbar { padding: 12px 16px; }
+ .main { padding: 20px 16px 40px; }
+ .foot { padding: 16px; }
+ .res-grid { grid-template-columns: 1fr; }
+ .res-side { position: static; }
+ .findbox { display: none; }
+}
+@media (max-width: 560px) {
+ .omni-input::placeholder { color: transparent; }
+}
diff --git a/web/console.go b/web/console.go
new file mode 100644
index 0000000..3bed61b
--- /dev/null
+++ b/web/console.go
@@ -0,0 +1,161 @@
+// Package web is the ant web console: a browser GUI over the whole resource-URI
+// namespace, server-rendered in pure Go and styled to match shadcn/ui, with the
+// machine-facing JSON API preserved under content negotiation. It is the human
+// surface that sits beside the CLI and the MCP server (8000_ant_serve).
+//
+// The console adds no data capability of its own; every page is a thin rendering
+// of an ant.Engine method. It depends only on the Deref interface, so it is
+// testable against a fake and could later be mounted by another host.
+package web
+
+import (
+ "context"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "strings"
+
+ "github.com/tamnd/ant/ant"
+ "github.com/tamnd/any-cli/kit"
+)
+
+// Deref is the slice of *ant.Engine the console renders. Keeping it an interface
+// makes the coupling explicit and lets the route tests run against a fake with
+// no network (8000_ant_serve §6.2, §17).
+type Deref interface {
+ Domains() []ant.DomainInfo
+ Domain(scheme string) (ant.DomainInfo, bool)
+ Resolve(input, on string) (kit.URI, error)
+ URL(u kit.URI) (string, error)
+ Get(ctx context.Context, u kit.URI) (kit.Envelope, error)
+ Dereference(ctx context.Context, u kit.URI, refresh bool) (ant.Fetched, error)
+ Cached(u kit.URI) bool
+ BodyOf(env kit.Envelope) (string, bool)
+ List(ctx context.Context, u kit.URI, n int) ([]kit.Envelope, error)
+ Searchable(scheme string) bool
+ Search(ctx context.Context, scheme, query string, n int) ([]kit.Envelope, error)
+ Links(ctx context.Context, u kit.URI) ([]kit.URI, error)
+ Walk(ctx context.Context, u kit.URI, depth int) (*ant.Graph, error)
+ Export(ctx context.Context, u kit.URI, follow int, md bool) (*ant.ExportReport, error)
+ LL(prefix string) ([]string, error)
+ Root() string
+}
+
+// Build is the binary's release identity, surfaced on the About page and used to
+// cache-bust the embedded assets.
+type Build struct {
+ Version string
+ Commit string
+ Date string
+}
+
+// Console renders the web surface over a Deref.
+type Console struct {
+ e Deref
+ build Build
+ tpl map[string]*template.Template // page name -> base+partials+page
+ assets http.Handler // static file server over the embedded FS
+}
+
+// pages are the page templates under templates/pages; each is parsed together
+// with the shell and the partials into its own set (8000_ant_serve §6.3).
+var pages = []string{
+ "dashboard", "resource", "collection", "search", "links", "resolve",
+ "locate", "graph", "browse", "domain", "about", "error", "notfound",
+}
+
+// New parses every template against the embedded FS and returns a ready Console.
+// Parsing once at construction means no per-request template work.
+func New(e Deref, b Build) (*Console, error) {
+ c := &Console{e: e, build: b, tpl: map[string]*template.Template{}}
+ for _, name := range pages {
+ t, err := template.New("base.html").Funcs(c.funcs()).ParseFS(files,
+ "templates/base.html",
+ "templates/partials/*.html",
+ "templates/pages/"+name+".html",
+ )
+ if err != nil {
+ return nil, err
+ }
+ c.tpl[name] = t
+ }
+ sub, err := fs.Sub(files, "assets")
+ if err != nil {
+ return nil, err
+ }
+ c.assets = http.StripPrefix("/assets/", cacheForever(http.FileServer(http.FS(sub))))
+ return c, nil
+}
+
+// Handler returns the console's HTTP handler. It routes by the first path
+// segment rather than through http.ServeMux on purpose: a resource URI in the
+// path carries a "//" (GET /x://status/20) and ServeMux's path cleaning would
+// 301-redirect it before any handler ran. Dispatching on the first segment leaves
+// the raw path untouched (the regression serve_test.go guards).
+func (c *Console) Handler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ nonce := newNonce()
+ secureHeaders(w, nonce)
+ r = r.WithContext(context.WithValue(r.Context(), nonceKey{}, nonce))
+ c.route(w, r)
+ })
+}
+
+// route dispatches a request by its first path segment. It is split out of
+// Handler so the /api facade can rewrite a request to force JSON and re-enter the
+// same routing without duplicating it.
+func (c *Console) route(w http.ResponseWriter, r *http.Request) {
+ raw := trimLeadingSlash(r.URL.Path)
+ switch seg := firstSegment(raw); seg {
+ case "":
+ c.home(w, r)
+ case "assets":
+ c.assets.ServeHTTP(w, r)
+ case "healthz":
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte("ok\n"))
+ case "api":
+ c.api(w, r)
+ case "view":
+ c.resource(w, r, r.URL.Query().Get("uri"))
+ case "ls":
+ c.collection(w, r)
+ case "search":
+ c.search(w, r)
+ case "links":
+ c.linksPage(w, r)
+ case "resolve":
+ c.resolve(w, r)
+ case "url":
+ c.locate(w, r)
+ case "graph":
+ c.graph(w, r)
+ case "browse":
+ c.browse(w, r)
+ case "domain":
+ c.domainPage(w, r)
+ case "about":
+ c.about(w, r)
+ case "export":
+ c.export(w, r)
+ default:
+ if isSchemeSegment(seg) {
+ c.resource(w, r, raw) // raw-URI dereference: /goodreads://book/1
+ return
+ }
+ c.notFound(w, r)
+ }
+}
+
+// api is the explicit JSON facade: GET /api/ serves the same data as
+// / but always as JSON, for scripts that would rather not send an Accept
+// header. It strips the /api prefix, pins format=json, and re-enters the router;
+// wantsJSON then returns true for every downstream handler.
+func (c *Console) api(w http.ResponseWriter, r *http.Request) {
+ r2 := r.Clone(r.Context())
+ r2.URL.Path = "/" + strings.TrimPrefix(trimLeadingSlash(r.URL.Path), "api/")
+ q := r2.URL.Query()
+ q.Set("format", "json")
+ r2.URL.RawQuery = q.Encode()
+ c.route(w, r2)
+}
diff --git a/web/console_test.go b/web/console_test.go
new file mode 100644
index 0000000..e35f5fe
--- /dev/null
+++ b/web/console_test.go
@@ -0,0 +1,195 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/tamnd/ant/ant"
+ "github.com/tamnd/any-cli/kit"
+)
+
+// fakeDeref is a network-free stand-in for *ant.Engine, so the console's every
+// page can be rendered and asserted in a unit test (8000_ant_serve §17).
+type fakeDeref struct{}
+
+func mustURI(s string) kit.URI {
+ u, err := kit.ParseURI(s)
+ if err != nil {
+ panic(err)
+ }
+ return u
+}
+
+func (fakeDeref) env(u kit.URI) kit.Envelope {
+ return kit.Envelope{
+ ID: u.String(),
+ Type: "demo/" + u.Authority,
+ Fetched: "2026-06-14T08:00:00Z",
+ Links: map[string][]string{"maker_id": {"demo://maker/m1"}},
+ Data: map[string]any{
+ "id": u.ID(),
+ "name": "Widget " + u.ID(),
+ "description": "a demo record",
+ "maker_id": "demo://maker/m1",
+ },
+ }
+}
+
+func (fakeDeref) Domains() []ant.DomainInfo {
+ return []ant.DomainInfo{{
+ Scheme: "demo", Aliases: []string{"dm"}, Hosts: []string{"demo.example"},
+ Binary: "demo", Short: "A demo domain", Site: "https://demo.example",
+ Repo: "https://example.com/demo",
+ }}
+}
+func (f fakeDeref) Domain(s string) (ant.DomainInfo, bool) {
+ for _, d := range f.Domains() {
+ if d.Scheme == s {
+ return d, true
+ }
+ }
+ return ant.DomainInfo{}, false
+}
+func (fakeDeref) Resolve(input, on string) (kit.URI, error) { return mustURI("demo://widget/42"), nil }
+func (fakeDeref) URL(u kit.URI) (string, error) { return "https://demo.example/" + u.ID(), nil }
+func (f fakeDeref) Get(_ context.Context, u kit.URI) (kit.Envelope, error) {
+ return f.env(u), nil
+}
+func (f fakeDeref) Dereference(_ context.Context, u kit.URI, refresh bool) (ant.Fetched, error) {
+ env := f.env(u)
+ raw, _ := json.MarshalIndent(env, "", " ")
+ return ant.Fetched{Env: env, Raw: raw, Body: "# Body\n\nHello from the body.", HasBody: true, FromCache: !refresh}, nil
+}
+func (fakeDeref) Cached(kit.URI) bool { return true }
+func (fakeDeref) BodyOf(kit.Envelope) (string, bool) { return "body", true }
+func (f fakeDeref) List(_ context.Context, u kit.URI, n int) ([]kit.Envelope, error) {
+ return []kit.Envelope{f.env(mustURI("demo://widget/1")), f.env(mustURI("demo://widget/2"))}, nil
+}
+func (fakeDeref) Searchable(s string) bool { return s == "demo" }
+func (f fakeDeref) Search(_ context.Context, scheme, q string, n int) ([]kit.Envelope, error) {
+ return []kit.Envelope{f.env(mustURI("demo://widget/7"))}, nil
+}
+func (fakeDeref) Links(_ context.Context, u kit.URI) ([]kit.URI, error) {
+ return []kit.URI{mustURI("demo://maker/m1")}, nil
+}
+func (fakeDeref) Walk(_ context.Context, u kit.URI, depth int) (*ant.Graph, error) {
+ return &ant.Graph{
+ Nodes: []ant.GraphNode{{URI: "demo://widget/42", Type: "demo/widget"}, {URI: "demo://maker/m1", Type: "demo/maker"}},
+ Edges: []ant.GraphEdge{{From: "demo://widget/42", To: "demo://maker/m1"}},
+ }, nil
+}
+func (fakeDeref) Export(_ context.Context, u kit.URI, follow int, md bool) (*ant.ExportReport, error) {
+ return &ant.ExportReport{Root: u.String(), Written: []string{u.String()}}, nil
+}
+func (fakeDeref) LL(prefix string) ([]string, error) {
+ return []string{"demo://widget/42", "demo://widget/1", "demo://maker/m1"}, nil
+}
+func (fakeDeref) Root() string { return "/tmp/data" }
+
+func newTestConsole(t *testing.T) *Console {
+ t.Helper()
+ c, err := New(fakeDeref{}, Build{Version: "test", Commit: "abc1234", Date: "2026-06-14"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return c
+}
+
+// TestEveryPageRenders walks every HTML route and asserts each renders the shell
+// with a 200 and the page-specific marker, so a template break is caught here
+// rather than in the browser.
+func TestEveryPageRenders(t *testing.T) {
+ h := newTestConsole(t).Handler()
+
+ cases := []struct {
+ name, path, want string
+ status int
+ }{
+ {"dashboard", "/", "Every record is a URI", 200},
+ {"resource", "/view?uri=demo://widget/42", "Fields", 200},
+ {"resource-raw", "/demo://widget/42", "Fields", 200},
+ {"collection", "/ls?uri=demo://widget/42", "Members", 200},
+ {"search-empty", "/search", "Run a domain", 200},
+ {"search-results", "/search?on=demo&q=gears", "result", 200},
+ {"links", "/links?uri=demo://widget/42", "Links", 200},
+ {"resolve", "/resolve", "Resolve", 200},
+ {"locate", "/url?uri=demo://widget/42", "Live URL", 200},
+ {"graph", "/graph?uri=demo://widget/42", "Graph", 200},
+ {"browse-root", "/browse", "Data tree", 200},
+ {"browse-scheme", "/browse?prefix=demo://", "demo", 200},
+ {"browse-authority", "/browse?prefix=demo://widget", "Records", 200},
+ {"domain", "/domain?scheme=demo", "demo", 200},
+ {"about", "/about", "About ant", 200},
+ {"error", "/view?uri=not%20a%20uri", "valid URI", 400},
+ {"notfound", "/no-such-page", "Nothing here", 404},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, c.path, nil)
+ req.Header.Set("Accept", "text/html")
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != c.status {
+ t.Fatalf("%s: code %d, want %d\n%s", c.path, rec.Code, c.status, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "") {
+ t.Errorf("%s: missing HTML shell", c.path)
+ }
+ if !strings.Contains(body, c.want) {
+ t.Errorf("%s: body does not contain %q", c.path, c.want)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
+ t.Errorf("%s: content-type %q", c.path, ct)
+ }
+ if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "nonce-") {
+ t.Errorf("%s: missing CSP nonce", c.path)
+ }
+ })
+ }
+}
+
+// TestJSONNegotiation asserts the same routes answer JSON without an HTML Accept,
+// and that the /api prefix forces it.
+func TestJSONNegotiation(t *testing.T) {
+ h := newTestConsole(t).Handler()
+ cases := []struct{ path, want string }{
+ {"/resolve?input=x", `"uri"`},
+ {"/url?uri=demo://widget/42", `"url"`},
+ {"/view?uri=demo://widget/42", `"@id"`},
+ {"/api/about", `"Version"`},
+ {"/search?on=demo&q=gears", `"@id"`},
+ {"/browse?prefix=demo://", `demo://widget/42`},
+ }
+ for _, c := range cases {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, c.path, nil))
+ if rec.Code != http.StatusOK {
+ t.Errorf("%s: code %d", c.path, rec.Code)
+ continue
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
+ t.Errorf("%s: content-type %q, want json", c.path, ct)
+ }
+ if !strings.Contains(rec.Body.String(), c.want) {
+ t.Errorf("%s: body %q lacks %q", c.path, rec.Body.String(), c.want)
+ }
+ }
+}
+
+// TestAboutVersion is a quick assertion that build info reaches the page.
+func TestAboutVersion(t *testing.T) {
+ h := newTestConsole(t).Handler()
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/about", nil)
+ req.Header.Set("Accept", "text/html")
+ h.ServeHTTP(rec, req)
+ if !strings.Contains(rec.Body.String(), "abc1234") {
+ t.Error("about page missing commit")
+ }
+}
diff --git a/web/embed.go b/web/embed.go
new file mode 100644
index 0000000..8c815e3
--- /dev/null
+++ b/web/embed.go
@@ -0,0 +1,11 @@
+package web
+
+import "embed"
+
+// files holds the whole console: the html/template sources under templates/ and
+// the static CSS/JS/SVG under assets/. Embedding them keeps ant a single static
+// binary — there is no asset directory to ship next to it and nothing to fetch
+// at runtime (8000_ant_serve §2, WC1).
+//
+//go:embed templates assets
+var files embed.FS
diff --git a/web/negotiate.go b/web/negotiate.go
new file mode 100644
index 0000000..3780968
--- /dev/null
+++ b/web/negotiate.go
@@ -0,0 +1,126 @@
+package web
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ "github.com/tamnd/any-cli/kit/errs"
+)
+
+// nonceKey is the request-context key under which the per-response CSP nonce is
+// stashed, so the inline theme-init script can carry it (8000_ant_serve §13).
+type nonceKey struct{}
+
+// wantsJSON decides the representation for a negotiated route. A browser sends
+// Accept: text/html and gets the GUI; everything else (curl, the test harness,
+// other programs) gets the JSON that ant serve has always returned. The /api
+// prefix and ?format= are explicit overrides (8000_ant_serve §5).
+func wantsJSON(r *http.Request) bool {
+ if strings.HasPrefix(trimLeadingSlash(r.URL.Path), "api/") {
+ return true
+ }
+ switch r.URL.Query().Get("format") {
+ case "json":
+ return true
+ case "html":
+ return false
+ }
+ return !strings.Contains(r.Header.Get("Accept"), "text/html")
+}
+
+// statusFor maps an engine error to an HTTP status using the shared kit error
+// taxonomy, so the console reports the same distinctions the CLI exit codes do
+// (8000_ant_serve §5.1).
+func statusFor(err error) int {
+ switch errs.KindOf(err) {
+ case errs.KindUsage:
+ return http.StatusBadRequest
+ case errs.KindNoResults, errs.KindNotFound:
+ return http.StatusNotFound
+ case errs.KindNeedAuth, errs.KindRateLimited, errs.KindNetwork, errs.KindUnsupported:
+ return http.StatusBadGateway
+ default:
+ return http.StatusBadGateway
+ }
+}
+
+// writeJSON marshals v as indented JSON with the right content type and status.
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ _ = enc.Encode(v)
+}
+
+// writeJSONErr writes the {"error": ...} body the JSON API has always used.
+func writeJSONErr(w http.ResponseWriter, err error) {
+ writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})
+}
+
+// secureHeaders sets the console's security headers on every response. The only
+// cross-origin load allowed is an (record thumbnails); the sole inline
+// script is the theme init, which carries this response's nonce
+// (8000_ant_serve §13).
+func secureHeaders(w http.ResponseWriter, nonce string) {
+ w.Header().Set("Content-Security-Policy",
+ "default-src 'none'; base-uri 'none'; form-action 'self'; "+
+ "img-src 'self' https: data:; style-src 'self'; "+
+ "script-src 'self' 'nonce-"+nonce+"'; connect-src 'self'; font-src 'self'")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
+}
+
+// cacheForever marks the embedded assets immutable; their URLs carry the build
+// commit as a ?v= cache-buster so a new release invalidates them.
+func cacheForever(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ h.ServeHTTP(w, r)
+ })
+}
+
+// newNonce returns a fresh base64 CSP nonce.
+func newNonce() string {
+ var b [16]byte
+ _, _ = rand.Read(b[:])
+ return base64.StdEncoding.EncodeToString(b[:])
+}
+
+// trimLeadingSlash drops a single leading slash for first-segment routing.
+func trimLeadingSlash(p string) string { return strings.TrimPrefix(p, "/") }
+
+// firstSegment returns the path up to the first "/". A resource URI like
+// "x://status/20" has first segment "x:", so it falls through to the resource
+// handler rather than a named route.
+func firstSegment(path string) string {
+ if i := strings.IndexByte(path, '/'); i >= 0 {
+ return path[:i]
+ }
+ return path
+}
+
+// isSchemeSegment reports whether a first segment is a URI scheme (ends in ':'
+// with a valid scheme token), e.g. "x:" or "goodreads:".
+func isSchemeSegment(seg string) bool {
+ s, ok := strings.CutSuffix(seg, ":")
+ if !ok || s == "" {
+ return false
+ }
+ for i, r := range s {
+ isLower := r >= 'a' && r <= 'z'
+ isDigit := r >= '0' && r <= '9'
+ if i == 0 && !isLower {
+ return false
+ }
+ if !isLower && !isDigit {
+ return false
+ }
+ }
+ return true
+}
diff --git a/web/pages.go b/web/pages.go
new file mode 100644
index 0000000..fc0971c
--- /dev/null
+++ b/web/pages.go
@@ -0,0 +1,688 @@
+package web
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/tamnd/ant/ant"
+ "github.com/tamnd/any-cli/kit"
+ "github.com/tamnd/any-cli/kit/errs"
+)
+
+// upstreamTimeout caps how long a single page will wait on a site fetch before
+// it surfaces a 502 "upstream timed out" state (8000_ant_serve §6.5).
+const upstreamTimeout = 30 * time.Second
+
+// reqCtx derives a timeout-bounded context from the request so a hung upstream
+// cannot pin a browser tab, while a client disconnect still cancels the fetch.
+func reqCtx(r *http.Request) (context.Context, context.CancelFunc) {
+ return context.WithTimeout(r.Context(), upstreamTimeout)
+}
+
+// --- dashboard / home -------------------------------------------------------
+
+type dashView struct {
+ Domains []domainCard
+ Disk diskSummary
+}
+
+type domainCard struct {
+ Scheme string
+ Short string
+ Repo string
+ Accent string
+ Aliases []string
+ Hosts []string
+ Examples []string
+}
+
+type diskSummary struct {
+ Root string
+ Count int
+}
+
+func (c *Console) home(w http.ResponseWriter, r *http.Request) {
+ if wantsJSON(r) {
+ writeJSON(w, http.StatusOK, map[string]any{"service": "ant", "domains": c.e.Domains()})
+ return
+ }
+ var cards []domainCard
+ for _, d := range c.e.Domains() {
+ cards = append(cards, domainCard{
+ Scheme: d.Scheme,
+ Short: d.Short,
+ Repo: d.Repo,
+ Accent: accent(d.Scheme),
+ Aliases: d.Aliases,
+ Hosts: d.Hosts,
+ Examples: exampleURIs(d.Scheme),
+ })
+ }
+ disk := diskSummary{Root: c.e.Root()}
+ if uris, err := c.e.LL(""); err == nil {
+ disk.Count = len(uris)
+ }
+ c.render(w, r, http.StatusOK, "dashboard", "ant — every record is a URI", "home",
+ dashView{Domains: cards, Disk: disk})
+}
+
+// --- resource (get) ---------------------------------------------------------
+
+type resourceView struct {
+ URI string
+ Scheme string
+ Authority string
+ ID string
+ Accent string
+ Type string
+ Fetched string
+ Fields orderedObj
+ Links []linkGroup
+ HasBody bool
+ Body string // rendered by the body func in the template
+ LiveURL string
+ RawJSON string
+ Crumbs []crumb
+ FromCache bool
+ Exported bool
+ RefreshURL string
+}
+
+func (c *Console) resource(w http.ResponseWriter, r *http.Request, raw string) {
+ u, err := kit.ParseURI(raw)
+ if err != nil {
+ c.fail(w, r, errs.Usage("%s", err.Error()), raw)
+ return
+ }
+ // Cache-first: read the record from the data tree when it is there, and only
+ // fetch from the site on a miss or when ?refresh=1 forces a fresh copy. A live
+ // fetch is written back, so the next view is offline (8000_ant_serve §23).
+ refresh := r.URL.Query().Get("refresh") == "1"
+ ctx, cancel := reqCtx(r)
+ defer cancel()
+ f, err := c.e.Dereference(ctx, u, refresh)
+ if err != nil {
+ c.fail(w, r, err, u.String())
+ return
+ }
+ if wantsJSON(r) {
+ writeJSON(w, http.StatusOK, f.Env)
+ return
+ }
+ liveURL, _ := c.e.URL(u)
+ rv := resourceView{
+ URI: f.Env.ID,
+ Scheme: u.Scheme,
+ Authority: u.Authority,
+ ID: u.ID(),
+ Accent: accent(u.Scheme),
+ Type: f.Env.Type,
+ Fetched: f.Env.Fetched,
+ Fields: orderedDataFromRaw(f.Raw),
+ Links: linkGroupsOf(f.Env.Links),
+ HasBody: f.HasBody,
+ Body: f.Body,
+ LiveURL: liveURL,
+ RawJSON: string(f.Raw),
+ Crumbs: crumbsFor(u),
+ FromCache: f.FromCache,
+ Exported: r.URL.Query().Get("exported") == "1",
+ RefreshURL: viewHref(u.String()) + "&refresh=1",
+ }
+ c.render(w, r, http.StatusOK, "resource", f.Env.ID, "", rv)
+}
+
+// --- collection (ls) --------------------------------------------------------
+
+type collectionView struct {
+ URI string
+ N int
+ Accent string
+ Cards []recordCard
+}
+
+type recordCard struct {
+ URI string
+ Title string
+ Snippet string
+ Thumb string
+ Type string
+ Accent string
+}
+
+func (c *Console) collection(w http.ResponseWriter, r *http.Request) {
+ raw := r.URL.Query().Get("uri")
+ u, err := kit.ParseURI(raw)
+ if err != nil {
+ c.fail(w, r, errs.Usage("%s", err.Error()), raw)
+ return
+ }
+ n, _ := strconv.Atoi(r.URL.Query().Get("n"))
+ ctx, cancel := reqCtx(r)
+ defer cancel()
+ envs, err := c.e.List(ctx, u, n)
+ if err != nil {
+ c.fail(w, r, err, u.String())
+ return
+ }
+ if wantsJSON(r) {
+ writeJSON(w, http.StatusOK, envs)
+ return
+ }
+ cv := collectionView{URI: u.String(), N: n, Accent: accent(u.Scheme)}
+ for _, env := range envs {
+ cv.Cards = append(cv.Cards, cardFromEnv(env))
+ }
+ c.render(w, r, http.StatusOK, "collection", "Members of "+u.String(), "", cv)
+}
+
+// cardFromEnv projects an envelope into a list card: a title, a one-line
+// snippet, and a thumbnail, chosen from whichever common fields the record has.
+func cardFromEnv(env kit.Envelope) recordCard {
+ fields := orderedData(env.Data)
+ get := func(keys ...string) string {
+ for _, k := range keys {
+ for _, kv := range fields {
+ if kv.Key == k {
+ if s, ok := kv.Val.(string); ok && s != "" {
+ return s
+ }
+ }
+ }
+ }
+ return ""
+ }
+ u, _ := kit.ParseURI(env.ID)
+ title := get("title", "name", "headline", "handle", "username", "text")
+ if title == "" {
+ title = u.ID()
+ }
+ return recordCard{
+ URI: env.ID,
+ Title: title,
+ Snippet: truncate(get("description", "extract", "summary", "bio", "text"), 160),
+ Thumb: get("thumbnail", "image", "cover", "avatar", "photo"),
+ Type: env.Type,
+ Accent: accent(u.Scheme),
+ }
+}
+
+// --- links ------------------------------------------------------------------
+
+type linksView struct {
+ URI string
+ Accent string
+ Groups []linkGroup
+}
+
+func (c *Console) linksPage(w http.ResponseWriter, r *http.Request) {
+ raw := r.URL.Query().Get("uri")
+ u, err := kit.ParseURI(raw)
+ if err != nil {
+ c.fail(w, r, errs.Usage("%s", err.Error()), raw)
+ return
+ }
+ ctx, cancel := reqCtx(r)
+ defer cancel()
+ if wantsJSON(r) {
+ links, err := c.e.Links(ctx, u)
+ if err != nil {
+ writeJSONErr(w, err)
+ return
+ }
+ out := make([]string, 0, len(links))
+ for _, lu := range links {
+ out = append(out, lu.String())
+ }
+ writeJSON(w, http.StatusOK, out)
+ return
+ }
+ env, err := c.e.Get(ctx, u)
+ if err != nil {
+ c.fail(w, r, err, u.String())
+ return
+ }
+ c.render(w, r, http.StatusOK, "links", "Links of "+u.String(), "",
+ linksView{URI: u.String(), Accent: accent(u.Scheme), Groups: linkGroupsOf(env.Links)})
+}
+
+// --- resolve ----------------------------------------------------------------
+
+type resolveView struct {
+ Input string
+ On string
+ Schemes []string
+ Resolved string
+ LiveURL string
+ Err string
+}
+
+func (c *Console) resolve(w http.ResponseWriter, r *http.Request) {
+ input := r.URL.Query().Get("input")
+ on := r.URL.Query().Get("on")
+ if wantsJSON(r) {
+ u, err := c.e.Resolve(input, on)
+ if err != nil {
+ writeJSONErr(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"uri": u.String()})
+ return
+ }
+ rv := resolveView{Input: input, On: on, Schemes: c.schemes()}
+ if input == "" {
+ c.render(w, r, http.StatusOK, "resolve", "Resolve", "resolve", rv)
+ return
+ }
+ u, err := c.e.Resolve(input, on)
+ if err != nil {
+ rv.Err = err.Error()
+ c.render(w, r, http.StatusBadRequest, "resolve", "Resolve", "resolve", rv)
+ return
+ }
+ // An unambiguous input (already a URI, or a URL a domain claims) forwards
+ // straight to the record; a bare id disambiguated by --on shows the result so
+ // the human sees what their id became (8000_ant_serve §11).
+ if on == "" {
+ http.Redirect(w, r, viewHref(u.String()), http.StatusSeeOther)
+ return
+ }
+ rv.Resolved = u.String()
+ rv.LiveURL, _ = c.e.URL(u)
+ c.render(w, r, http.StatusOK, "resolve", "Resolved", "resolve", rv)
+}
+
+// --- locate (url) -----------------------------------------------------------
+
+type locateView struct {
+ URI string
+ LiveURL string
+ Err string
+}
+
+func (c *Console) locate(w http.ResponseWriter, r *http.Request) {
+ raw := r.URL.Query().Get("uri")
+ u, err := kit.ParseURI(raw)
+ if err != nil {
+ c.fail(w, r, errs.Usage("%s", err.Error()), raw)
+ return
+ }
+ loc, err := c.e.URL(u)
+ if wantsJSON(r) {
+ if err != nil {
+ writeJSONErr(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"url": loc})
+ return
+ }
+ lv := locateView{URI: u.String(), LiveURL: loc}
+ if err != nil {
+ lv.Err = err.Error()
+ }
+ c.render(w, r, http.StatusOK, "locate", "Live URL", "", lv)
+}
+
+// --- graph ------------------------------------------------------------------
+
+type graphView struct {
+ URI string
+ Depth int
+ Payload graphPayload
+ JSON string
+}
+
+func (c *Console) graph(w http.ResponseWriter, r *http.Request) {
+ raw := r.URL.Query().Get("uri")
+ u, err := kit.ParseURI(raw)
+ if err != nil {
+ c.fail(w, r, errs.Usage("%s", err.Error()), raw)
+ return
+ }
+ depth := 1
+ if d, e := strconv.Atoi(r.URL.Query().Get("depth")); e == nil && d >= 0 {
+ depth = d
+ }
+ if depth > 3 {
+ depth = 3
+ }
+ ctx, cancel := reqCtx(r)
+ defer cancel()
+ g, err := c.e.Walk(ctx, u, depth)
+ if err != nil {
+ c.fail(w, r, err, u.String())
+ return
+ }
+ payload := graphToPayload(u.String(), g)
+ if r.URL.Query().Get("format") == "dot" {
+ w.Header().Set("Content-Type", "text/vnd.graphviz; charset=utf-8")
+ _, _ = w.Write([]byte(g.Dot()))
+ return
+ }
+ if wantsJSON(r) {
+ writeJSON(w, http.StatusOK, payload)
+ return
+ }
+ c.render(w, r, http.StatusOK, "graph", "Graph of "+u.String(), "graph",
+ graphView{URI: u.String(), Depth: depth, Payload: payload, JSON: prettyJSON(payload)})
+}
+
+// --- browse (the data tree as directories) ----------------------------------
+
+// browseLeafCap bounds how many record cards a single authority folder renders,
+// so a large cache cannot make one page unbounded. Truncation is reported.
+const browseLeafCap = 300
+
+type browseView struct {
+ Root string
+ Prefix string // canonical prefix string for this node ("" at root)
+ Crumbs []crumb
+ Scheme string // the scheme in context, "" at root
+ Accent string
+ Folders []browseFolder
+ Records []recordCard
+ Searchable bool
+ Examples []string // try-me URIs offered when a folder has no cache yet
+ Total int // records cached under this node
+ Shown int // records rendered as cards (<= Total, capped)
+}
+
+type browseFolder struct {
+ Name string
+ Href string
+ Count int
+ Accent string
+}
+
+// browse renders the on-disk data tree as a directory listing: the root lists
+// every registered domain as a folder, a domain lists the record types it has
+// cached, and a type lists its records as cards that open the cached view. It is
+// pure filesystem work over LL, so it never touches the network; the search box
+// and example URIs are the bridges out to a live fetch (8000_ant_serve §22).
+func (c *Console) browse(w http.ResponseWriter, r *http.Request) {
+ prefix := r.URL.Query().Get("prefix")
+ segs := splitPrefix(prefix)
+ canon := joinPrefix(segs)
+
+ uris, err := c.e.LL(canon)
+ if err != nil {
+ c.fail(w, r, err, prefix)
+ return
+ }
+ if wantsJSON(r) {
+ writeJSON(w, http.StatusOK, uris)
+ return
+ }
+
+ bv := browseView{Root: c.e.Root(), Prefix: canon, Crumbs: crumbsForPrefix(segs), Total: len(uris)}
+ depth := len(segs)
+ if depth >= 1 {
+ bv.Scheme = segs[0]
+ bv.Accent = accent(segs[0])
+ bv.Searchable = c.e.Searchable(segs[0])
+ bv.Examples = exampleURIs(segs[0])
+ }
+
+ // Root: every registered domain is a folder, even with an empty cache, so a
+ // fresh install is still navigable. Each carries its cached record count.
+ if depth == 0 {
+ for _, d := range c.e.Domains() {
+ count := 0
+ if cu, e := c.e.LL(d.Scheme + "://"); e == nil {
+ count = len(cu)
+ }
+ bv.Folders = append(bv.Folders, browseFolder{
+ Name: d.Scheme,
+ Href: browseHref(d.Scheme + "://"),
+ Count: count,
+ Accent: accent(d.Scheme),
+ })
+ }
+ c.render(w, r, http.StatusOK, "browse", "Browse the data tree", "browse", bv)
+ return
+ }
+
+ // Deeper: group the cached URIs by their segment at this depth. A child with
+ // more segments below it is a folder; one that terminates here is a record.
+ folderCount := map[string]int{}
+ var folderOrder []string
+ var leaves []string
+ for _, uri := range uris {
+ us := uriSegs(uri)
+ if len(us) <= depth || !segsHasPrefix(us, segs) {
+ continue
+ }
+ child := us[depth]
+ if len(us) == depth+1 {
+ leaves = append(leaves, uri)
+ continue
+ }
+ if _, seen := folderCount[child]; !seen {
+ folderOrder = append(folderOrder, child)
+ }
+ folderCount[child]++
+ }
+ for _, name := range folderOrder {
+ child := joinPrefix(append(append([]string{}, segs...), name))
+ bv.Folders = append(bv.Folders, browseFolder{
+ Name: name,
+ Href: browseHref(child),
+ Count: folderCount[name],
+ Accent: accent(segs[0]),
+ })
+ }
+ for i, uri := range leaves {
+ if i >= browseLeafCap {
+ break
+ }
+ bv.Records = append(bv.Records, c.cardFromCache(uri))
+ }
+ bv.Shown = len(bv.Records)
+ c.render(w, r, http.StatusOK, "browse", "Browse "+canon, "browse", bv)
+}
+
+// cardFromCache builds a list card for a record already on disk. It reads the
+// cache only (the URI came from LL, so the fetch never reaches the network), and
+// falls back to a bare-URI card if the file is unreadable.
+func (c *Console) cardFromCache(uri string) recordCard {
+ u, err := kit.ParseURI(uri)
+ if err != nil {
+ return recordCard{URI: uri, Title: uri}
+ }
+ f, err := c.e.Dereference(context.Background(), u, false)
+ if err != nil {
+ return recordCard{URI: uri, Title: u.ID(), Accent: accent(u.Scheme)}
+ }
+ return cardFromEnv(f.Env)
+}
+
+// --- search -----------------------------------------------------------------
+
+type searchView struct {
+ Scheme string
+ Accent string
+ Query string
+ N int
+ Schemes []string // searchable schemes, for the selector
+ Cards []recordCard
+ Searched bool
+ Err string
+}
+
+// search runs a domain's free-text search and renders the hits as cards that open
+// each result's record (cache-first). The box is shown for every domain that
+// registered a search op; a domain without one is reported, not hidden, so the UI
+// stays honest (8000_ant_serve §22.1).
+func (c *Console) search(w http.ResponseWriter, r *http.Request) {
+ scheme := r.URL.Query().Get("on")
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+ n, _ := strconv.Atoi(r.URL.Query().Get("n"))
+
+ if wantsJSON(r) {
+ if scheme == "" || query == "" {
+ writeJSONErr(w, errs.Usage("search needs ?on=&q="))
+ return
+ }
+ ctx, cancel := reqCtx(r)
+ defer cancel()
+ envs, err := c.e.Search(ctx, scheme, query, n)
+ if err != nil {
+ writeJSONErr(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, envs)
+ return
+ }
+
+ sv := searchView{Scheme: scheme, Accent: accent(scheme), Query: query, N: n, Schemes: c.searchSchemes()}
+ if scheme == "" || query == "" {
+ c.render(w, r, http.StatusOK, "search", "Search", "search", sv)
+ return
+ }
+ if !c.e.Searchable(scheme) {
+ sv.Err = scheme + " does not support search"
+ c.render(w, r, http.StatusBadRequest, "search", "Search", "search", sv)
+ return
+ }
+ ctx, cancel := reqCtx(r)
+ defer cancel()
+ envs, err := c.e.Search(ctx, scheme, query, n)
+ if err != nil {
+ sv.Err = err.Error()
+ c.render(w, r, statusFor(err), "search", "Search", "search", sv)
+ return
+ }
+ sv.Searched = true
+ for _, env := range envs {
+ sv.Cards = append(sv.Cards, cardFromEnv(env))
+ }
+ c.render(w, r, http.StatusOK, "search", "Search: "+query, "search", sv)
+}
+
+// searchSchemes is the set of registered schemes that support search, for the
+// search form's domain selector.
+func (c *Console) searchSchemes() []string {
+ var out []string
+ for _, d := range c.e.Domains() {
+ if c.e.Searchable(d.Scheme) {
+ out = append(out, d.Scheme)
+ }
+ }
+ return out
+}
+
+// --- domain -----------------------------------------------------------------
+
+type domainView struct {
+ Info ant.DomainInfo
+ Accent string
+ Examples []string
+}
+
+func (c *Console) domainPage(w http.ResponseWriter, r *http.Request) {
+ scheme := r.URL.Query().Get("scheme")
+ info, ok := c.e.Domain(scheme)
+ if !ok {
+ c.fail(w, r, errs.NotFound("no domain %q", scheme), "")
+ return
+ }
+ if wantsJSON(r) {
+ writeJSON(w, http.StatusOK, info)
+ return
+ }
+ c.render(w, r, http.StatusOK, "domain", info.Scheme, "",
+ domainView{Info: info, Accent: accent(info.Scheme), Examples: exampleURIs(info.Scheme)})
+}
+
+// --- about ------------------------------------------------------------------
+
+type aboutView struct {
+ Version string
+ Commit string
+ Date string
+ Domains []ant.DomainInfo
+}
+
+func (c *Console) about(w http.ResponseWriter, r *http.Request) {
+ av := aboutView{Version: c.build.Version, Commit: c.build.Commit, Date: c.build.Date, Domains: c.e.Domains()}
+ if wantsJSON(r) {
+ writeJSON(w, http.StatusOK, av)
+ return
+ }
+ c.render(w, r, http.StatusOK, "about", "About ant", "about", av)
+}
+
+// --- export (the one POST) --------------------------------------------------
+
+func (c *Console) export(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.Header().Set("Allow", "POST")
+ c.fail(w, r, errs.Usage("export requires POST"), "")
+ return
+ }
+ if !sameOrigin(r) {
+ c.fail(w, r, errs.Usage("cross-origin export refused"), "")
+ return
+ }
+ _ = r.ParseForm()
+ raw := r.FormValue("uri")
+ u, err := kit.ParseURI(raw)
+ if err != nil {
+ c.fail(w, r, errs.Usage("%s", err.Error()), raw)
+ return
+ }
+ follow, _ := strconv.Atoi(r.FormValue("follow"))
+ md := r.FormValue("md") == "on" || r.FormValue("md") == "true"
+ ctx, cancel := reqCtx(r)
+ defer cancel()
+ rep, err := c.e.Export(ctx, u, follow, md)
+ if err != nil {
+ c.fail(w, r, err, u.String())
+ return
+ }
+ if wantsJSON(r) {
+ writeJSON(w, http.StatusOK, rep)
+ return
+ }
+ http.Redirect(w, r, viewHref(u.String())+"&exported=1", http.StatusSeeOther)
+}
+
+// --- not found --------------------------------------------------------------
+
+func (c *Console) notFound(w http.ResponseWriter, r *http.Request) {
+ if wantsJSON(r) {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+ return
+ }
+ c.render(w, r, http.StatusNotFound, "notfound", "Not found", "", nil)
+}
+
+// fail renders an error as JSON or the styled error page per negotiation.
+func (c *Console) fail(w http.ResponseWriter, r *http.Request, err error, uri string) {
+ if wantsJSON(r) {
+ writeJSONErr(w, err)
+ return
+ }
+ c.renderError(w, r, err, uri)
+}
+
+// schemes lists the canonical schemes for the resolve form's