diff --git a/ant/ant.go b/ant/ant.go index f2536b0..5abaa58 100644 --- a/ant/ant.go +++ b/ant/ant.go @@ -18,8 +18,10 @@ package ant import ( "context" + "encoding/json" "os" "path/filepath" + "sync" "time" "github.com/tamnd/any-cli/kit" @@ -32,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. @@ -49,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) } @@ -62,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 { @@ -82,6 +102,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 +172,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..a49e0a0 --- /dev/null +++ b/ant/cache.go @@ -0,0 +1,100 @@ +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 +} + +// 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) { + 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/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 22d47ff..ff391bc 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -6,37 +6,53 @@ 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 + } + // 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: 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 +72,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/docs/content/release-notes/_index.md b/docs/content/release-notes/_index.md index 155fb02..6fdc31e 100644 --- a/docs/content/release-notes/_index.md +++ b/docs/content/release-notes/_index.md @@ -11,5 +11,6 @@ packages (deb, rpm, apk), a multi-arch container image on GHCR, and entries for the package managers. Binaries are pure Go, so there is nothing to install alongside them. -No releases yet. Cut the first one with `git tag v0.1.0 && git push --tags`, -then add a page here. +For releases before `v0.2.0`, see the [GitHub releases +page](https://github.com/tamnd/ant/releases); each tag carries its full +generated changelog and artifacts. diff --git a/docs/content/release-notes/v0.2.0.md b/docs/content/release-notes/v0.2.0.md new file mode 100644 index 0000000..3e68a70 --- /dev/null +++ b/docs/content/release-notes/v0.2.0.md @@ -0,0 +1,98 @@ +--- +title: "v0.2.0" +linkTitle: "v0.2.0" +description: "The web console: browse the whole URI namespace in a browser, with no page that hangs." +weight: 10 +--- + +`v0.2.0` turns `ant serve` from a one-route JSON endpoint into a full **web +console**, adds **YouTube** as a domain, and makes every page fast: a cached +record renders from disk instantly, a slow fetch shows a self-reloading loading +screen instead of timing out, and the next click is often already warm. + +The machine-facing JSON API is preserved byte for byte under content +negotiation, so anything that scripts `ant serve` today keeps working. + +## The web console + +Open `http://localhost:7777` and you get a browser GUI over the entire `ant` +URI namespace, server-rendered in pure Go and styled to match shadcn/ui. No +Node, no build step, no CDN, no client framework, no API key. Everything is +embedded in the one binary. + +- **Dashboard** lists every registered domain as a card, with example URIs and a + count of what is already cached on disk. +- **Resource pages** render a record's envelope, its data as a readable + key/value table, its `@links` as clickable chips you can follow across sites, + its body as Markdown, and a raw-JSON disclosure. +- **Collections** list as cards; **links** and the **graph** render the + cross-site connections, with an interactive node diagram and a DOT download. +- **Browse** walks the on-disk data tree as plain folders, so you can see exactly + what `ant` already knows without typing a URI. +- **Search** gives every domain that supports it a free-text box, and each hit + opens its record. +- **Resolve** and **live URL** turn any id, URL, or URI into the canonical form + and back out to the source. + +The console is read-only and local-first. It holds no sessions, stores no +credentials, and changes nothing on the network. The on-disk data tree is the +only state, and the console reads it freely and writes it only through the +explicit export action. + +## YouTube as a domain + +`youtube://video/`, `youtube://channel/`, and friends now resolve, +dereference, and link like every other domain, so a graph walk can hop from a +record into YouTube and back. It is a blank-import driver like the rest: one line +wires it in. + +## Every page is fast, and nothing times out + +The old `serve` dereferenced synchronously with a request timeout, so a slow +upstream could block a page until it died with `context deadline exceeded`. That +is gone. The read path is now: + +- **Cache-first.** A record already on disk renders with no goroutine, no queue, + and no network. This is the common case once a URI has been seen, and it is + effectively instant. +- **A grace race on a miss.** A fetch that finishes quickly renders inline with + no spinner and no redirect. Only a genuinely slow fetch hands off to a loading + screen, which polls in the background and reloads itself the moment the data is + ready. With JavaScript off, a meta refresh does the same job. +- **Fetched once, shared.** Several tabs (or several viewers) of the same slow + URI share a single upstream fetch, and a finished result is kept briefly so a + reload reads memory. +- **The next click, prewarmed.** Viewing a record quietly warms the records it + links to, within strict bounds, so the likely next page is already cached. + +The result: a page renders the data, a loading screen, or a clean error, but it +never hangs and never times out. The only deliberate wait left is the export +button, which writes to disk on a clear bounded timeout. + +## Heads up: serve now binds to localhost by default + +A console that renders external content and runs locally should not listen on +every interface. `ant serve` now binds to `127.0.0.1:7777` by default. To expose +it on a LAN, pass it explicitly: + +``` +ant serve --addr 0.0.0.0:7777 +``` + +## Install and upgrade + +Every tagged version builds the same artifacts: archives for Linux, macOS, +Windows, and FreeBSD, Linux packages (deb, rpm, apk), a multi-arch container +image on GHCR, and package-manager entries. The binaries are pure Go, so there +is nothing to install alongside them. + +``` +go install github.com/tamnd/ant/cmd/ant@v0.2.0 +``` + +Or grab a binary from the [release +page](https://github.com/tamnd/ant/releases/tag/v0.2.0), or pull the image: + +``` +docker run --rm -p 7777:7777 ghcr.io/tamnd/ant:0.2.0 serve --addr 0.0.0.0:7777 +``` 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/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