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 + + + {{end}} + + +{{if $p.Folders}} + +{{end}} + +{{if $p.Records}} +

Records{{if lt $p.Shown $p.Total}} (showing {{$p.Shown}} of {{$p.Total}}){{end}}

+
+ {{range $p.Records}}{{template "card" .}}{{end}} +
+{{end}} + +{{if and (not $p.Folders) (not $p.Records)}} +
+ {{template "icon" "database"}} +

Nothing cached here yet.{{if $p.Scheme}} Open a {{$p.Scheme}} record and it will appear in this tree.{{end}}

+ {{if $p.Examples}} +
+ {{range $p.Examples}}{{.}}{{end}} +
+ {{end}} + {{if $p.Searchable}}Search {{$p.Scheme}}{{end}} +
+{{end}} +{{end}} diff --git a/web/templates/pages/collection.html b/web/templates/pages/collection.html new file mode 100644 index 0000000..8bcbfbf --- /dev/null +++ b/web/templates/pages/collection.html @@ -0,0 +1,19 @@ +{{define "content"}} +{{$p := .Page}} +
+

Members

+

{{$p.URI}}

+
+ +{{if $p.Cards}} +
+ {{range $p.Cards}}{{template "card" .}}{{end}} +
+{{else}} +
+ {{template "icon" "folder"}} +

This resource has no members, or none are cached yet.

+ Open the record +
+{{end}} +{{end}} diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html new file mode 100644 index 0000000..6cae92d --- /dev/null +++ b/web/templates/pages/dashboard.html @@ -0,0 +1,38 @@ +{{define "content"}} +{{$p := .Page}} +
+

Every record is a URI.

+

ant is one front door over the tamnd site tools. Open a resource by its + URI, browse the local data tree, walk the link graph, or search a domain.

+
+ + {{template "icon" "database"}} + {{$p.Disk.Count}} + records cached + +
+ {{template "icon" "folder"}} + {{$p.Disk.Root}} +
+
+
+ +

Domains

+
+ {{range $p.Domains}} +
+
+ + {{.Scheme}} + {{range .Aliases}}{{.}}{{end}} +
+ {{if .Short}}

{{.Short}}

{{end}} + {{if .Examples}} +
+ {{range .Examples}}{{.}}{{end}} +
+ {{end}} +
+ {{end}} +
+{{end}} diff --git a/web/templates/pages/domain.html b/web/templates/pages/domain.html new file mode 100644 index 0000000..59f5d8d --- /dev/null +++ b/web/templates/pages/domain.html @@ -0,0 +1,44 @@ +{{define "content"}} +{{$p := .Page}}{{$d := $p.Info}} +
+
+ +
+

{{$d.Scheme}}

+
+ {{range $d.Aliases}}{{.}}{{end}} + {{if $d.Binary}}binary {{$d.Binary}}{{end}} +
+
+
+ +
+ +{{if $d.Short}}

{{$d.Short}}

{{end}} + +
+
+ {{if $p.Examples}} +
+
Try one
+
+ {{range $p.Examples}}{{.}}{{end}} +
+
+ {{end}} +
+ +
+{{end}} diff --git a/web/templates/pages/error.html b/web/templates/pages/error.html new file mode 100644 index 0000000..31819a4 --- /dev/null +++ b/web/templates/pages/error.html @@ -0,0 +1,16 @@ +{{define "content"}} +{{$p := .Page}} +
+
{{template "icon" "alert"}}{{$p.Status}}
+

{{$p.Title}}

+

{{$p.Kind}}

+ {{if $p.URI}}

{{$p.URI}}

{{end}} +

{{$p.Message}}

+ +
+ {{if $p.LiveURL}}{{template "icon" "external"}}Open the source{{end}} + {{range $p.Suggest}}{{.Label}}{{end}} + Home +
+
+{{end}} diff --git a/web/templates/pages/graph.html b/web/templates/pages/graph.html new file mode 100644 index 0000000..4e775a1 --- /dev/null +++ b/web/templates/pages/graph.html @@ -0,0 +1,35 @@ +{{define "content"}} +{{$p := .Page}} +
+
+

Graph

+

{{$p.URI}}

+
+
+ + + + {{template "icon" "download"}}DOT +
+
+ +
+ + +
+ +

Nodes

+
+ {{range $p.Payload.Nodes}} + + + {{.Label}} + {{.Type}} + + {{end}} +
+{{end}} diff --git a/web/templates/pages/links.html b/web/templates/pages/links.html new file mode 100644 index 0000000..3875838 --- /dev/null +++ b/web/templates/pages/links.html @@ -0,0 +1,22 @@ +{{define "content"}} +{{$p := .Page}} +
+

Links

+

{{$p.URI}}

+
+ +{{if $p.Groups}} +
+ {{range $p.Groups}} + + {{end}} +
+{{else}} +
{{template "icon" "link"}}

This record links to nothing.

+{{end}} +{{end}} diff --git a/web/templates/pages/locate.html b/web/templates/pages/locate.html new file mode 100644 index 0000000..3c113d9 --- /dev/null +++ b/web/templates/pages/locate.html @@ -0,0 +1,19 @@ +{{define "content"}} +{{$p := .Page}} +
+

Live URL

+

{{$p.URI}}

+
+ +{{if $p.Err}} + +{{else}} + +{{end}} +{{end}} diff --git a/web/templates/pages/notfound.html b/web/templates/pages/notfound.html new file mode 100644 index 0000000..25c7b48 --- /dev/null +++ b/web/templates/pages/notfound.html @@ -0,0 +1,11 @@ +{{define "content"}} +
+
404
+

Nothing here

+

That page is not part of the console.

+ +
+{{end}} diff --git a/web/templates/pages/resolve.html b/web/templates/pages/resolve.html new file mode 100644 index 0000000..19f8187 --- /dev/null +++ b/web/templates/pages/resolve.html @@ -0,0 +1,36 @@ +{{define "content"}} +{{$p := .Page}} +
+

Resolve

+

Turn a URI, a site link, or a bare id into a canonical resource URI.

+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +{{if $p.Err}}{{end}} + +{{if $p.Resolved}} +
+
Resolved
+ {{$p.Resolved}} + {{if $p.LiveURL}}{{end}} +
+{{end}} +{{end}} diff --git a/web/templates/pages/resource.html b/web/templates/pages/resource.html new file mode 100644 index 0000000..76d2cef --- /dev/null +++ b/web/templates/pages/resource.html @@ -0,0 +1,83 @@ +{{define "content"}} +{{$p := .Page}} +{{template "crumbs" $p.Crumbs}} + +
+
+ +
+

{{$p.ID}}

+
+ {{$p.Type}} + {{if $p.FromCache}}cached + {{else}}live{{end}} + {{if $p.Fetched}}fetched {{relTime $p.Fetched}}{{end}} +
+
+
+ +
+ +{{if $p.Exported}}{{end}} + +
+
+ {{if $p.HasBody}} +
{{body $p.Body}}
+ {{end}} + +
+
Fields
+ {{if $p.Fields}} +
+ {{range $p.Fields}} +
{{humanize .Key}}
+
{{value .Key .Val}}
+ {{end}} +
+ {{else}}

No fields.

{{end}} +
+ +
+ Raw JSON +
{{$p.RawJSON}}
+
+
+ + +
+{{end}} diff --git a/web/templates/pages/search.html b/web/templates/pages/search.html new file mode 100644 index 0000000..63127ea --- /dev/null +++ b/web/templates/pages/search.html @@ -0,0 +1,48 @@ +{{define "content"}} +{{$p := .Page}} +
+

Search

+

Run a domain's free-text search. Results open cache-first.

+
+ + + +{{if not $p.Schemes}} + +{{end}} + +{{if $p.Err}}{{end}} + +{{if $p.Searched}} + {{if $p.Cards}} +

{{len $p.Cards}} result(s) for “{{$p.Query}}” on {{$p.Scheme}}

+
+ {{range $p.Cards}}{{template "card" .}}{{end}} +
+ {{else}} +
+ {{template "icon" "search"}} +

No results for “{{$p.Query}}” on {{$p.Scheme}}.

+
+ {{end}} +{{end}} +{{end}} diff --git a/web/templates/partials/card.html b/web/templates/partials/card.html new file mode 100644 index 0000000..d5806d1 --- /dev/null +++ b/web/templates/partials/card.html @@ -0,0 +1,14 @@ +{{define "card"}} + + {{if .Thumb}}
{{end}} +
+
+ + {{if .Type}}{{.Type}}{{end}} +
+
{{.Title}}
+ {{if .Snippet}}
{{.Snippet}}
{{end}} +
{{.URI}}
+
+
+{{end}} diff --git a/web/templates/partials/crumbs.html b/web/templates/partials/crumbs.html new file mode 100644 index 0000000..979b124 --- /dev/null +++ b/web/templates/partials/crumbs.html @@ -0,0 +1,9 @@ +{{define "crumbs"}} + +{{end}} diff --git a/web/templates/partials/footer.html b/web/templates/partials/footer.html new file mode 100644 index 0000000..1c0ed73 --- /dev/null +++ b/web/templates/partials/footer.html @@ -0,0 +1,9 @@ +{{define "footer"}} + +{{end}} diff --git a/web/templates/partials/icon.html b/web/templates/partials/icon.html new file mode 100644 index 0000000..557779c --- /dev/null +++ b/web/templates/partials/icon.html @@ -0,0 +1,22 @@ +{{define "icon"}}{{$n := .}}{{end}} diff --git a/web/templates/partials/sidebar.html b/web/templates/partials/sidebar.html new file mode 100644 index 0000000..883580c --- /dev/null +++ b/web/templates/partials/sidebar.html @@ -0,0 +1,43 @@ +{{define "sidebar"}} + +{{end}} diff --git a/web/templates/partials/topbar.html b/web/templates/partials/topbar.html new file mode 100644 index 0000000..91b15b0 --- /dev/null +++ b/web/templates/partials/topbar.html @@ -0,0 +1,28 @@ +{{define "topbar"}} +
+ + + + + + + +
+{{end}} From 53f7607b5c826e788dbe32627f2386c38316db57 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:02:25 +0700 Subject: [PATCH 2/6] serve: drop the unused ctxNonce helper The render path reads the request nonce inline; the helper was dead and the linter flagged it (and its lone context import). --- web/render.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/web/render.go b/web/render.go index 8c3e45c..322c8a6 100644 --- a/web/render.go +++ b/web/render.go @@ -2,7 +2,6 @@ package web import ( "bytes" - "context" "encoding/json" "fmt" "hash/fnv" @@ -692,9 +691,3 @@ func graphToPayload(root string, g *ant.Graph) graphPayload { } return p } - -// ctxValue is a tiny helper to read the request nonce in tests. -func ctxNonce(ctx context.Context) string { - n, _ := ctx.Value(nonceKey{}).(string) - return n -} From 98852267463a6c6636c6dae27c8fcedf1e161437 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:22:40 +0700 Subject: [PATCH 3/6] serve: don't walk the whole data root, and index listings in memory The dashboard and browse-root were listing the entire data root to count and group records. $HOME/data is shared with many other tools, so that walk crossed hundreds of thousands of unrelated files: the dashboard took ~7s and browse-root ~15s on a real tree, and it was wrong too, surfacing other tools' files as ant records. Scope both pages to ant's own domains: the dashboard sums per-domain counts and browse-root lists the registered domains, never LL(""). Back LL with an in-memory index. The first call for a prefix walks the domain's subtree; the result is held in memory, and every cache-write folds the new URI into the matching listings, so a record fetched mid-session shows up in browse with no re-walk. serve warms the index for each domain in the background at startup, off the request path. Measured on the real tree: dashboard 7438ms -> ~1ms, browse 14993ms -> ~1ms; every page well under 25ms. With a synthetic 50k-record domain the dashboard and browse-root stay ~0.5ms (cached counts) and the deepest grouping is ~11ms. Tests: the engine index is cached and write-through (a file written behind the Engine is not seen; one written through it appears at once); the dashboard and browse-root never list the whole root. --- ant/ant.go | 21 +++++++++++- ant/cache_test.go | 84 +++++++++++++++++++++++++++++++++++++++++++++ ant/export.go | 60 ++++++++++++++++++++++++++++++-- cli/serve.go | 3 ++ web/console_test.go | 54 +++++++++++++++++++++++++++++ web/pages.go | 70 ++++++++++++++++++++++--------------- 6 files changed, 261 insertions(+), 31 deletions(-) create mode 100644 ant/cache_test.go diff --git a/ant/ant.go b/ant/ant.go index a6e1b71..5abaa58 100644 --- a/ant/ant.go +++ b/ant/ant.go @@ -21,6 +21,7 @@ import ( "encoding/json" "os" "path/filepath" + "sync" "time" "github.com/tamnd/any-cli/kit" @@ -33,6 +34,13 @@ type Engine struct { host *kit.Host root string // the data tree root ($HOME/data, ANT_DATA-overridable) now func() time.Time // the fetch clock, injectable so tests are deterministic + + // llMu guards llCache, the in-memory index of materialized URIs keyed by the + // listing prefix. A directory walk runs once per prefix; every cache-write + // folds the new URI into the matching listings, so repeat reads (the web + // console's dashboard and browse pages) never re-walk the tree. See LL. + llMu sync.RWMutex + llCache map[string][]string } // Option customizes an Engine at New. @@ -50,7 +58,7 @@ func New(opts ...Option) (*Engine, error) { if err != nil { return nil, err } - e := &Engine{host: h, now: time.Now} + e := &Engine{host: h, now: time.Now, llCache: map[string][]string{}} for _, o := range opts { o(e) } @@ -63,6 +71,17 @@ func New(opts ...Option) (*Engine, error) { // Root returns the data tree root the Engine writes under. func (e *Engine) Root() string { return e.root } +// WarmIndex pre-populates the in-memory LL index for every registered domain, so +// the first browse or dashboard request is served from memory rather than paying +// for a cold filesystem walk. It walks only ant's own domain subtrees, never the +// whole shared data root. A long-lived process (ant serve) calls this once in the +// background at startup; it is a no-op to call again. +func (e *Engine) WarmIndex() { + for _, scheme := range e.host.Domains() { + _, _ = e.LL(scheme + "://") + } +} + // Domains returns the registered domains the Engine can address, sorted by // scheme. It is the analogue of sql.Drivers and backs `ant domains`. func (e *Engine) Domains() []DomainInfo { diff --git a/ant/cache_test.go b/ant/cache_test.go new file mode 100644 index 0000000..9b5e317 --- /dev/null +++ b/ant/cache_test.go @@ -0,0 +1,84 @@ +package ant_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/tamnd/ant/ant" + "github.com/tamnd/any-cli/kit" +) + +// TestLLIndexIsCachedAndWriteThrough proves the in-memory listing index: a repeat +// LL is served from memory (so a file written behind the Engine's back is not +// seen), while a record written through the Engine appears at once (write-through +// keeps the index warm without a re-walk). This is what keeps the web console's +// dashboard and browse pages fast as the data tree grows. +func TestLLIndexIsCachedAndWriteThrough(t *testing.T) { + e, root := newEngine(t) + ctx := context.Background() + + // Seed the cache entry for the prefix with an initial walk (empty tree). + if got, err := e.LL("fake://"); err != nil || len(got) != 0 { + t.Fatalf("initial LL = %v, %v; want empty", got, err) + } + + // Export a record through the Engine: write-through must fold it into the + // already-cached listing. + u, _ := kit.ParseURI("fake://book/b1") + if _, err := e.Export(ctx, u, 0, false); err != nil { + t.Fatal(err) + } + if got, err := e.LL("fake://"); err != nil || !has(got, "fake://book/b1") { + t.Fatalf("after Export, LL = %v, %v; want it to contain b1", got, err) + } + + // Write a record file directly to disk, behind the Engine's back. The cache is + // authoritative for the session, so LL must NOT pick it up. + stray := filepath.Join(root, "fake", "book", "stray.json") + if err := os.WriteFile(stray, []byte(`{"@id":"fake://book/stray"}`), 0o644); err != nil { + t.Fatal(err) + } + if got, _ := e.LL("fake://"); has(got, "fake://book/stray") { + t.Errorf("LL saw a file written behind the Engine's back: %v", got) + } + + // A fresh Engine on the same root walks from scratch and does see the stray, + // proving the file was really on disk and the cache (not a missing write) hid it. + e2, err := ant.New(ant.WithRoot(root)) + if err != nil { + t.Fatal(err) + } + if got, _ := e2.LL("fake://"); !has(got, "fake://book/stray") { + t.Errorf("fresh Engine missed the on-disk stray: %v", got) + } +} + +// TestDereferenceWriteBackIndexes proves a cache-first Dereference miss writes the +// record back and folds it into the listing index, so it shows up in browse with +// no re-walk. +func TestDereferenceWriteBackIndexes(t *testing.T) { + e, _ := newEngine(t) + ctx := context.Background() + + if got, _ := e.LL("fake://"); len(got) != 0 { + t.Fatalf("expected empty start, got %v", got) + } + u, _ := kit.ParseURI("fake://book/b9") + if _, err := e.Dereference(ctx, u, false); err != nil { + t.Fatal(err) + } + if got, _ := e.LL("fake://"); !has(got, "fake://book/b9") { + t.Errorf("Dereference did not index the written record: %v", got) + } +} + +func has(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} diff --git a/ant/export.go b/ant/export.go index 589ee6c..a3e6128 100644 --- a/ant/export.go +++ b/ant/export.go @@ -96,6 +96,12 @@ func (e *Engine) writeEnvelope(u kit.URI, env kit.Envelope, asMarkdown bool) ([] if err := os.WriteFile(jsonPath, append(blob, '\n'), 0o644); err != nil { return nil, err } + // Keep the in-memory LL index consistent with what just hit disk, so a record + // fetched mid-session shows up in browse without a re-walk. Derive the URI from + // the path so it matches the exact string form LL stores. + if uri, ok := e.pathToURI(jsonPath); ok { + e.indexAdd(uri) + } written := []string{jsonPath} if asMarkdown { @@ -132,9 +138,37 @@ func (e *Engine) Import(path string) (map[string]any, error) { } // LL lists the record URIs already materialized on disk under a URI prefix. An -// empty prefix lists the whole tree. It is pure filesystem work, so it is fast -// and offline. +// empty prefix lists the whole tree. The first call for a prefix walks the +// filesystem; the result is held in an in-memory index, and every later +// cache-write folds the new URI into the matching listings (indexAdd), so a +// repeat call returns from memory without touching disk. This is what keeps the +// web console's dashboard and browse pages fast as the data tree grows. A +// long-lived process (ant serve) is the only writer of its own tree, so the +// index stays consistent for the session; an external write lands on the next +// restart. func (e *Engine) LL(prefix string) ([]string, error) { + key := canonPrefix(prefix) + e.llMu.RLock() + if uris, ok := e.llCache[key]; ok { + e.llMu.RUnlock() + return uris, nil + } + e.llMu.RUnlock() + + uris, err := e.scanLL(prefix) + if err != nil { + return nil, err + } + e.llMu.Lock() + e.llCache[key] = uris + e.llMu.Unlock() + return uris, nil +} + +// scanLL is the filesystem walk behind LL: it lists every .json record under the +// prefix's data path. It is pure filesystem work, offline, and runs once per +// prefix before the in-memory index serves the rest. +func (e *Engine) scanLL(prefix string) ([]string, error) { sub := prefixDir(prefix) dir := filepath.Join(e.root, filepath.FromSlash(sub)) @@ -172,6 +206,28 @@ func (e *Engine) LL(prefix string) ([]string, error) { return out, nil } +// indexAdd folds a freshly written URI into every cached LL listing it belongs +// to, keeping the in-memory index warm and consistent after a cache-write +// without re-walking the tree. It mirrors LL's own prefix filter so the index +// matches what a fresh walk would return. +func (e *Engine) indexAdd(uri string) { + e.llMu.Lock() + defer e.llMu.Unlock() + for key, uris := range e.llCache { + if key != "" && !strings.HasPrefix(uri, canonPrefix(key)) { + continue + } + i := sort.SearchStrings(uris, uri) + if i < len(uris) && uris[i] == uri { + continue // already indexed + } + uris = append(uris, "") + copy(uris[i+1:], uris[i:]) + uris[i] = uri + e.llCache[key] = uris + } +} + // pathToURI reverses dataFile: it turns an on-disk record path back into its // canonical URI string. It returns false for a path outside the data root. func (e *Engine) pathToURI(p string) (string, bool) { diff --git a/cli/serve.go b/cli/serve.go index fe53cd3..ff391bc 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -40,6 +40,9 @@ Then open http://localhost:7777/ in a browser, or: if err != nil { return err } + // Warm the in-memory listing index off the request path, so the first + // dashboard or browse click is served from memory, not a cold walk. + go e.WarmIndex() srv := &http.Server{ Addr: addr, Handler: console.Handler(), diff --git a/web/console_test.go b/web/console_test.go index e35f5fe..bedcdee 100644 --- a/web/console_test.go +++ b/web/console_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "github.com/tamnd/ant/ant" @@ -182,6 +183,59 @@ func TestJSONNegotiation(t *testing.T) { } } +// recordingDeref records the prefixes passed to LL, so a test can assert which +// listings a page asks for. +type recordingDeref struct { + fakeDeref + mu sync.Mutex + llCalls []string +} + +func (d *recordingDeref) LL(prefix string) ([]string, error) { + d.mu.Lock() + d.llCalls = append(d.llCalls, prefix) + d.mu.Unlock() + return d.fakeDeref.LL(prefix) +} + +func (d *recordingDeref) calls() []string { + d.mu.Lock() + defer d.mu.Unlock() + return append([]string(nil), d.llCalls...) +} + +// TestNoWholeTreeWalk guards the performance regression that made the dashboard +// and browse-root take seconds: they must never list the whole shared data root +// (LL with an empty prefix), only ant's own per-domain subtrees. +func TestNoWholeTreeWalk(t *testing.T) { + for _, path := range []string{"/", "/browse"} { + t.Run(path, func(t *testing.T) { + rec := &recordingDeref{} + c, err := New(rec, Build{Version: "test"}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Accept", "text/html") + c.Handler().ServeHTTP(httptest.NewRecorder(), req) + + calls := rec.calls() + sawDomain := false + for _, p := range calls { + if p == "" { + t.Errorf("%s walked the whole data root (LL(%q)); calls=%v", path, p, calls) + } + if p == "demo://" { + sawDomain = true + } + } + if !sawDomain { + t.Errorf("%s never listed the demo domain; calls=%v", path, calls) + } + }) + } +} + // TestAboutVersion is a quick assertion that build info reaches the page. func TestAboutVersion(t *testing.T) { h := newTestConsole(t).Handler() diff --git a/web/pages.go b/web/pages.go index fc0971c..9638fe6 100644 --- a/web/pages.go +++ b/web/pages.go @@ -3,6 +3,7 @@ package web import ( "context" "net/http" + "sort" "strconv" "strings" "time" @@ -49,7 +50,12 @@ func (c *Console) home(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"service": "ant", "domains": c.e.Domains()}) return } + // Count only ant's own domains, never the whole shared data root: $HOME/data + // is home to many other tools' trees, so a whole-tree walk is both slow and + // wrong (it would surface their files as ant records). Per-domain listings are + // bounded and the in-memory index keeps them cheap. var cards []domainCard + disk := diskSummary{Root: c.e.Root()} for _, d := range c.e.Domains() { cards = append(cards, domainCard{ Scheme: d.Scheme, @@ -60,10 +66,9 @@ func (c *Console) home(w http.ResponseWriter, r *http.Request) { 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) + if uris, err := c.e.LL(d.Scheme + "://"); 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}) @@ -406,45 +411,54 @@ 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. + // Root: list the registered domains as folders, scoped to ant's own data and + // never the whole shared root. Walking $HOME/data wholesale is both slow (it + // holds many other tools' trees) and wrong (it would surface their files as ant + // records). Each folder's count and the JSON listing come from per-domain + // listings, which the in-memory index keeps cheap. if depth == 0 { + bv := browseView{Root: c.e.Root(), Prefix: "", Crumbs: crumbsForPrefix(segs)} + var all []string for _, d := range c.e.Domains() { - count := 0 - if cu, e := c.e.LL(d.Scheme + "://"); e == nil { - count = len(cu) + cu, e := c.e.LL(d.Scheme + "://") + if e == nil { + all = append(all, cu...) } + bv.Total += len(cu) bv.Folders = append(bv.Folders, browseFolder{ Name: d.Scheme, Href: browseHref(d.Scheme + "://"), - Count: count, + Count: len(cu), Accent: accent(d.Scheme), }) } + if wantsJSON(r) { + sort.Strings(all) + writeJSON(w, http.StatusOK, all) + return + } c.render(w, r, http.StatusOK, "browse", "Browse the data tree", "browse", bv) return } + 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)} + bv.Scheme = segs[0] + bv.Accent = accent(segs[0]) + bv.Searchable = c.e.Searchable(segs[0]) + bv.Examples = exampleURIs(segs[0]) + // 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{} From db4e7304d37885d12e87ce915831d906ed6ad788 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:54:55 +0700 Subject: [PATCH 4/6] web: never block on a slow fetch, prefetch the next click The resource page called Dereference synchronously with a 30s request timeout, so a slow upstream (x://status/... was the reported case) blocked the request until the deadline tripped and the page died with a raw "context deadline exceeded". Move the network off the request's critical path so a page renders the data, a loading screen, or a clean error, but never hangs and never times out. Add a small job manager (web/jobs.go): background fetches deduplicated by key so N viewers of one slow URI cost a single upstream call, results retained for a few minutes so a reload reads memory, workers on a background context so navigating away does not abort a fetch others want. Reads resolve cache-first: a materialized record renders from disk with no goroutine and no network (Engine.Lookup, the read-only half of Dereference). On a miss, a browser waits a short grace window and either renders inline (fast) or hands off to a self-reloading loading screen (slow) that polls /status and reloads when ready; JS-off falls back to a meta refresh. Scripts still wait for the data and get a structured 202 pending instead of a hang. Warm the next click: viewing a record fire-and-forgets a bounded prefetch of the records it links to, so the likely next navigation is a cache hit. Prefetch is capped per page and globally and never queues ahead of an interactive click. The only synchronous, timeout-bounded path left is the deliberate export button. Tests cover dedup, error capture, deadline-leaves-it-running, prefetch bounding, and the cold loading-then-status path; race-clean. --- ant/cache.go | 8 + web/assets/poll.js | 36 +++ web/assets/styles.css | 13 + web/console.go | 8 +- web/console_test.go | 5 + web/jobs.go | 181 ++++++++++++++ web/jobs_test.go | 198 +++++++++++++++ web/pages.go | 410 +++++++++++++++++++++++++------ web/render.go | 33 +++ web/templates/base.html | 1 + web/templates/pages/loading.html | 19 ++ 11 files changed, 835 insertions(+), 77 deletions(-) create mode 100644 web/assets/poll.js create mode 100644 web/jobs.go create mode 100644 web/jobs_test.go create mode 100644 web/templates/pages/loading.html diff --git a/ant/cache.go b/ant/cache.go index d3ad11c..a49e0a0 100644 --- a/ant/cache.go +++ b/ant/cache.go @@ -57,6 +57,14 @@ func (e *Engine) Cached(u kit.URI) bool { return err == nil } +// Lookup returns a record from the on-disk cache without ever touching the +// network, so the web console can render a cached page instantly and route only a +// miss to a background fetch. ok is false on a miss (absent or unreadable). It is +// the read-only half of Dereference: same cache read, no write-back, no fetch. +func (e *Engine) Lookup(u kit.URI) (Fetched, bool) { + return e.readCache(u) +} + // 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) { diff --git a/web/assets/poll.js b/web/assets/poll.js new file mode 100644 index 0000000..3da4f37 --- /dev/null +++ b/web/assets/poll.js @@ -0,0 +1,36 @@ +// poll.js drives the loading screen. While a record, collection, or graph is +// being fetched in the background, it asks the status endpoint named in +// #fetch[data-status] every second and reloads the page the instant the work is +// ready (or failed, to surface the error). Loaded only on the loading page +// (8000_ant_serve §24). With JavaScript off, the page's