clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat: name the sources, and run the pipeline from the bench

Dami committed eb5decc
The three places the collection draws from have lived as flags on a
crawler and labels in a ledger. Cartograph gives them a formal shape:
a Source declares its name, what components it contributes -- raster,
icons, locations, metadata; all three publish complete maps, differing
in quality rather than kind -- and how a fetch is spoken to
tools/crawl. The interface's contract writes down what the pipeline
already guarantees: sources are additive, composition merges by title
and slug with provenance, and re-fetching unchanged data is a no-op
because captures are content-addressed and stamps deterministic.

The sources page carries the operations panel: fetch a target, derive
pyramids, recompose bundles -- each running the repository's own tool
as a subprocess, its two streams flowing into the page as they speak.
Nothing runs on a schedule or at startup; an operation exists only
between a submitted form and the tool's exit, dies with an abandoned
page, refuses a foreign origin, and a target is validated down to its
characters so it can never be read as a flag.
commit
eb5deccda5ed3bfcbdf29e51bb7175ba19cd41e9
parent
a175057028c52b76187252d377549608b8df177c
author
Dami <dami.etoile@gmail.com>
10 files changed, +712 -6
Mcmd/cartograph/main.go34++++++-
Acmd/cartograph/ops.go93++++++++++++++++
Acmd/cartograph/ops_test.go161+++++++++++++++++++++++++++
Mcmd/cartograph/server.go84++++++++++++++-
Mcmd/cartograph/server_test.go16+++-
Acmd/cartograph/sources.go176++++++++++++++++++++++++++++++
Acmd/cartograph/sources_test.go77+++++++++++++
Acmd/cartograph/ui/app.js31+++++
Mcmd/cartograph/ui/layout.tmpl2+
Acmd/cartograph/ui/sources.tmpl44+++++++
M cmd/cartograph/main.go +33 -1
@@ -15,6 +15,8 @@ import (
1515 "net"1616 "net/http"1717 "os"18+ "path/filepath"19+ "strings"1820 1921 "github.com/FelineStateMachine/atlas/internal/bundle"2022 )
@@ -23,6 +25,10 @@ func main() {
2325 bundles := flag.String("bundles", "",2426 "registry directory holding .atlas bundles (default: the application's own library)")2527 addr := flag.String("addr", "127.0.0.1:6180", "address the dashboard listens on")28+ archive := flag.String("archive", "",29+ "fmg-archive root fetches fill and builds read (operations require it)")30+ repo := flag.String("repo", "",31+ "atlas repository whose tools operations run (default: found from the working directory)")2632 flag.Parse()2733 2834 if *bundles == "" {
@@ -33,8 +39,12 @@ func main() {
3339 }3440 *bundles = library3541 }42+ if *repo == "" {43+ *repo = findRepo()44+ }3645 37- server := newServer(&library{dir: *bundles})46+ server := newServer(&library{dir: *bundles},47+ &workshop{repo: *repo, archive: *archive, bundles: *bundles})3848 listener, err := net.Listen("tcp", *addr)3949 if err != nil {4050 fmt.Fprintln(os.Stderr, "cartograph:", err)
@@ -47,3 +57,25 @@ func main() {
4757 os.Exit(1)4858 }4959 }60+61+// findRepo walks up from the working directory looking for this module's own62+// go.mod, so `go run ./cmd/cartograph` from anywhere inside the checkout63+// needs no pointing. Anywhere else, -repo says where the tools live; the64+// dashboard's views work without one, and only operations demand it.65+func findRepo() string {66+ dir, err := os.Getwd()67+ if err != nil {68+ return ""69+ }70+ for {71+ data, err := os.ReadFile(filepath.Join(dir, "go.mod"))72+ if err == nil && strings.Contains(string(data), "module github.com/FelineStateMachine/atlas\n") {73+ return dir74+ }75+ parent := filepath.Dir(dir)76+ if parent == dir {77+ return ""78+ }79+ dir = parent80+ }81+}
A cmd/cartograph/ops.go +93 -0
@@ -0,0 +1,93 @@
1+package main2+3+import (4+ "context"5+ "fmt"6+ "io"7+ "net/http"8+ "os/exec"9+ "path/filepath"10+ "sync"11+)12+13+// The workbench runs the pipeline's own tools rather than reimplementing14+// them: a fetch is tools/crawl, pyramids are tools/tiles, composition is15+// tools/generate, each invoked exactly as a person at a terminal would and16+// each streamed back to the page as it speaks. Nothing runs on a schedule or17+// at startup; an operation exists only between a submitted form and the18+// subprocess's exit.19+20+// workshop holds where operations run: the repository whose tools are21+// invoked, the archive captures land in, and the registry composed bundles22+// install into. The lock keeps operations serial -- the tools are safe to23+// interleave in principle, but two crawls sharing one progress pane help24+// nobody.25+type workshop struct {26+ repo string27+ archive string28+ bundles string29+ busy sync.Mutex30+}31+32+// fetchArgv is the tools/crawl invocation for one source and target,33+// verbatim what a person would type in the repository.34+func fetchArgv(src Source, target, archive string) ([]string, error) {35+ selected, err := src.FetchArgs(target)36+ if err != nil {37+ return nil, err38+ }39+ argv := []string{"go", "run", "./tools/crawl", "-archive", archive}40+ return append(argv, selected...), nil41+}42+43+// tilesArgv derives every pyramid the archive holds into build/tiles, which44+// sits under the repository so the per-pyramid stamps persist and rebuilds45+// stay incremental.46+func tilesArgv(archive string) []string {47+ return []string{"go", "run", "./tools/tiles", "-source", archive, "-output", "build/tiles"}48+}49+50+// generateArgv composes bundles from the archive and the derived tiles into51+// the given registry directory. tools/generate is addressed by the directory52+// containing fmg-archive rather than the archive itself, so the archive path53+// must actually be one.54+func generateArgv(archive, bundles string) ([]string, error) {55+ if filepath.Base(archive) != "fmg-archive" {56+ return nil, fmt.Errorf(57+ "the archive %s is not an fmg-archive directory, and tools/generate finds captures under that name", archive)58+ }59+ return []string{60+ "go", "run", "./tools/generate",61+ "-source", filepath.Dir(archive),62+ "-tiles", "build/tiles/index.json",63+ "-bundles", bundles,64+ }, nil65+}66+67+// streamCommand runs argv in dir and writes everything it says, both68+// streams interleaved, to w as it arrives. The context is the request's: a69+// page abandoned mid-operation stops its subprocess, so nothing crawls on70+// with nobody watching.71+func streamCommand(ctx context.Context, w io.Writer, dir string, argv []string) error {72+ cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)73+ cmd.Dir = dir74+ out := flushWriter{w}75+ cmd.Stdout = out76+ cmd.Stderr = out77+ return cmd.Run()78+}79+80+// flushWriter pushes every write through to the client immediately, so a81+// long crawl reads as progress rather than silence. Writers that cannot82+// flush -- a buffer in a test -- just write.83+type flushWriter struct {84+ w io.Writer85+}86+87+func (f flushWriter) Write(p []byte) (int, error) {88+ n, err := f.w.Write(p)89+ if flusher, ok := f.w.(http.Flusher); ok {90+ flusher.Flush()91+ }92+ return n, err93+}
A cmd/cartograph/ops_test.go +161 -0
@@ -0,0 +1,161 @@
1+package main2+3+import (4+ "bytes"5+ "context"6+ "io"7+ "net/http"8+ "os/exec"9+ "path/filepath"10+ "reflect"11+ "runtime"12+ "strings"13+ "testing"14+)15+16+func TestOpArgv(t *testing.T) {17+ src, _ := sourceBySlug("ign-wiki")18+ argv, err := fetchArgv(src, "cyberpunk-2077/night-city", "/depot/gamemap/fmg-archive")19+ if err != nil {20+ t.Fatal(err)21+ }22+ want := []string{23+ "go", "run", "./tools/crawl",24+ "-archive", "/depot/gamemap/fmg-archive",25+ "-ign", "cyberpunk-2077/night-city",26+ }27+ if !reflect.DeepEqual(argv, want) {28+ t.Errorf("fetch argv %v, want %v", argv, want)29+ }30+ if _, err := fetchArgv(src, "not a target", "/depot/gamemap/fmg-archive"); err == nil {31+ t.Error("a bad target built an argv")32+ }33+34+ argv = tilesArgv("/depot/gamemap/fmg-archive")35+ want = []string{"go", "run", "./tools/tiles", "-source", "/depot/gamemap/fmg-archive", "-output", "build/tiles"}36+ if !reflect.DeepEqual(argv, want) {37+ t.Errorf("tiles argv %v, want %v", argv, want)38+ }39+40+ argv, err = generateArgv("/depot/gamemap/fmg-archive", "/library/bundles")41+ if err != nil {42+ t.Fatal(err)43+ }44+ want = []string{45+ "go", "run", "./tools/generate",46+ "-source", "/depot/gamemap",47+ "-tiles", "build/tiles/index.json",48+ "-bundles", "/library/bundles",49+ }50+ if !reflect.DeepEqual(argv, want) {51+ t.Errorf("generate argv %v, want %v", argv, want)52+ }53+ // tools/generate finds captures under a directory literally named54+ // fmg-archive; pointing it anywhere else must refuse, not guess.55+ if _, err := generateArgv("/depot/gamemap/archive", "/library/bundles"); err == nil {56+ t.Error("generate accepted an archive that is not an fmg-archive")57+ }58+}59+60+func TestOpsHandlerRefusals(t *testing.T) {61+ server, dir := testServer(t)62+ ts := newTestSite(t, server)63+ post := func(form string, header ...string) (int, string) {64+ t.Helper()65+ request, err := http.NewRequest("POST", ts.URL+"/ops/run", strings.NewReader(form))66+ if err != nil {67+ t.Fatal(err)68+ }69+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")70+ for at := 0; at+1 < len(header); at += 2 {71+ request.Header.Set(header[at], header[at+1])72+ }73+ response, err := ts.Client().Do(request)74+ if err != nil {75+ t.Fatal(err)76+ }77+ defer response.Body.Close()78+ body, _ := io.ReadAll(response.Body)79+ return response.StatusCode, string(body)80+ }81+82+ // Without a repository and an archive nothing may run, whatever is asked.83+ if status, _ := post("op=tiles"); status != 400 {84+ t.Errorf("op without a repo answered %d", status)85+ }86+ server.shop.repo = t.TempDir()87+ if status, _ := post("op=tiles"); status != 400 {88+ t.Errorf("op without an archive answered %d", status)89+ }90+91+ server.shop.archive = filepath.Join(dir, "fmg-archive")92+ if status, _ := post("op=vanish"); status != 400 {93+ t.Errorf("unknown op answered %d", status)94+ }95+ if status, _ := post("op=fetch&source=nowhere&target=x"); status != 400 {96+ t.Errorf("unknown source answered %d", status)97+ }98+ if status, _ := post("op=fetch&source=mapgenie&target=bad/target"); status != 400 {99+ t.Errorf("bad target answered %d", status)100+ }101+ // A cross-site POST carries a foreign Origin and must be turned away.102+ if status, _ := post("op=tiles", "Origin", "http://evil.example"); status != 403 {103+ t.Errorf("foreign origin answered %d", status)104+ }105+}106+107+func TestOpsHandlerStreams(t *testing.T) {108+ if _, err := exec.LookPath("go"); err != nil {109+ t.Skip("no go binary on PATH")110+ }111+ server, dir := testServer(t)112+ ts := newTestSite(t, server)113+ // The repo points at an empty directory, so the tool run fails at once --114+ // but its refusal must arrive through the page as streamed text, which115+ // is the whole wiring under test.116+ server.shop.repo = t.TempDir()117+ server.shop.archive = filepath.Join(dir, "fmg-archive")118+119+ response, err := ts.Client().Post(ts.URL+"/ops/run", "application/x-www-form-urlencoded",120+ strings.NewReader("op=generate"))121+ if err != nil {122+ t.Fatal(err)123+ }124+ defer response.Body.Close()125+ body, err := io.ReadAll(response.Body)126+ if err != nil {127+ t.Fatal(err)128+ }129+ text := string(body)130+ if response.StatusCode != 200 {131+ t.Fatalf("streamed op answered %d: %s", response.StatusCode, text)132+ }133+ if !strings.Contains(text, "go run ./tools/generate") {134+ t.Errorf("stream does not announce its command: %q", text)135+ }136+ if !strings.Contains(text, "cartograph: ") {137+ t.Errorf("stream does not carry the failure: %q", text)138+ }139+}140+141+func TestStreamCommand(t *testing.T) {142+ if _, err := exec.LookPath("go"); err != nil {143+ t.Skip("no go binary on PATH")144+ }145+ var out bytes.Buffer146+ if err := streamCommand(context.Background(), &out, t.TempDir(), []string{"go", "env", "GOOS"}); err != nil {147+ t.Fatal(err)148+ }149+ if got := strings.TrimSpace(out.String()); got != runtime.GOOS {150+ t.Errorf("streamed %q, want %q", got, runtime.GOOS)151+ }152+153+ out.Reset()154+ err := streamCommand(context.Background(), &out, t.TempDir(), []string{"go", "definitely-not-a-subcommand"})155+ if err == nil {156+ t.Fatal("a failing command reported no error")157+ }158+ if out.Len() == 0 {159+ t.Error("the failing command's own words were not streamed")160+ }161+}
M cmd/cartograph/server.go +80 -4
@@ -3,26 +3,30 @@ package main
33 import (44 "bytes"55 "embed"6+ "fmt"67 "html/template"78 "log/slog"89 "net/http"10+ "strings"911 )1012 1113 //go:embed ui1214 var uiFS embed.FS1315 14-// server is the dashboard: a handful of read-only views over the library.16+// server is the dashboard: read-only views over the library, and the17+// operations panel that runs the pipeline's tools on request.1518 type server struct {1619 library *library20+ shop *workshop1721 pages map[string]*template.Template1822 }1923 20-func newServer(lib *library) *server {24+func newServer(lib *library, shop *workshop) *server {2125 pages := make(map[string]*template.Template)22- for _, name := range []string{"collection", "game", "diff"} {26+ for _, name := range []string{"collection", "game", "diff", "sources"} {2327 pages[name] = template.Must(template.ParseFS(uiFS, "ui/layout.tmpl", "ui/"+name+".tmpl"))2428 }25- return &server{library: lib, pages: pages}29+ return &server{library: lib, shop: shop, pages: pages}2630 }2731 2832 func (s *server) routes() http.Handler {
@@ -30,7 +34,10 @@ func (s *server) routes() http.Handler {
3034 mux.HandleFunc("GET /{$}", s.handleCollection)3135 mux.HandleFunc("GET /game/{slug}", s.handleGame)3236 mux.HandleFunc("GET /game/{slug}/diff", s.handleDiff)37+ mux.HandleFunc("GET /sources", s.handleSources)38+ mux.HandleFunc("POST /ops/run", s.handleOps)3339 mux.HandleFunc("GET /static/style.css", staticFile("ui/style.css", "text/css; charset=utf-8"))40+ mux.HandleFunc("GET /static/app.js", staticFile("ui/app.js", "text/javascript; charset=utf-8"))3441 return mux3542 }3643
@@ -107,6 +114,75 @@ func (s *server) handleDiff(w http.ResponseWriter, r *http.Request) {
107114 http.Error(w, err.Error(), http.StatusInternalServerError)108115 }109116 117+func (s *server) handleSources(w http.ResponseWriter, r *http.Request) {118+ s.render(w, "sources", struct {119+ Sources []Source120+ Repo string121+ Archive string122+ Bundles string123+ }{Sources: sources, Repo: s.shop.repo, Archive: s.shop.archive, Bundles: s.shop.bundles})124+}125+126+// handleOps runs one pipeline tool and streams its voice back as plain text.127+// Every operation is explicit: it exists only because this request named it,128+// and it dies with the request if the page is abandoned.129+func (s *server) handleOps(w http.ResponseWriter, r *http.Request) {130+ // A browser sends Origin on any cross-site POST; refusing a foreign one131+ // keeps a stray web page from operating the workbench through the132+ // visitor's browser.133+ if origin := r.Header.Get("Origin"); origin != "" && origin != "http://"+r.Host {134+ http.Error(w, "operations are submitted from the dashboard alone", http.StatusForbidden)135+ return136+ }137+ if s.shop.repo == "" {138+ http.Error(w, "no atlas repository found: start cartograph with -repo", http.StatusBadRequest)139+ return140+ }141+ if s.shop.archive == "" {142+ http.Error(w, "operations need an archive: start cartograph with -archive", http.StatusBadRequest)143+ return144+ }145+146+ var argv []string147+ var err error148+ switch op := r.FormValue("op"); op {149+ case "fetch":150+ src, ok := sourceBySlug(r.FormValue("source"))151+ if !ok {152+ http.Error(w, "unknown source "+r.FormValue("source"), http.StatusBadRequest)153+ return154+ }155+ argv, err = fetchArgv(src, r.FormValue("target"), s.shop.archive)156+ case "tiles":157+ argv = tilesArgv(s.shop.archive)158+ case "generate":159+ argv, err = generateArgv(s.shop.archive, s.shop.bundles)160+ default:161+ http.Error(w, "unknown operation "+op, http.StatusBadRequest)162+ return163+ }164+ if err != nil {165+ http.Error(w, err.Error(), http.StatusBadRequest)166+ return167+ }168+169+ if !s.shop.busy.TryLock() {170+ http.Error(w, "an operation is already running", http.StatusConflict)171+ return172+ }173+ defer s.shop.busy.Unlock()174+175+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")176+ w.Header().Set("X-Content-Type-Options", "nosniff")177+ out := flushWriter{w}178+ fmt.Fprintf(out, "%s $ %s\n\n", s.shop.repo, strings.Join(argv, " "))179+ if err := streamCommand(r.Context(), w, s.shop.repo, argv); err != nil {180+ fmt.Fprintf(out, "\ncartograph: %v\n", err)181+ return182+ }183+ fmt.Fprintf(out, "\ndone\n")184+}185+110186 func staticFile(name, contentType string) http.HandlerFunc {111187 return func(w http.ResponseWriter, r *http.Request) {112188 data, err := uiFS.ReadFile(name)
M cmd/cartograph/server_test.go +15 -1
A cmd/cartograph/sources.go +176 -0
@@ -0,0 +1,176 @@
1+package main2+3+import (4+ "fmt"5+ "strings"6+)7+8+// Component is one kind of thing a source can contribute to a game's bundle.9+type Component string10+11+const (12+ // ComponentRaster is the tile pyramids a map is drawn from.13+ ComponentRaster Component = "raster"14+ // ComponentIcons is the marker artwork a legend draws.15+ ComponentIcons Component = "icons"16+ // ComponentLocations is the pins themselves: titles, coordinates, notes.17+ ComponentLocations Component = "locations"18+ // ComponentMetadata is the structure around the pins: categories, groups,19+ // zones, the map's own configuration.20+ ComponentMetadata Component = "metadata"21+)22+23+// ComponentSet is the components a source produces, in a fixed display24+// order.25+type ComponentSet []Component26+27+// Has reports whether the set carries the component.28+func (s ComponentSet) Has(c Component) bool {29+ for _, held := range s {30+ if held == c {31+ return true32+ }33+ }34+ return false35+}36+37+// Source is one place the collection draws from. A source declares its38+// identity, what it contributes, and how a fetch is spoken to tools/crawl;39+// everything downstream of the fetch -- translation, pyramids, composition40+// -- is the pipeline's and looks the same whichever source fed it.41+//42+// Sources are additive. Each contributes components to a game's .atlas, and43+// composition merges contributions by title and slug with full provenance in44+// the map payload's merged block; no source replaces another's work. The45+// additive contract extends to repetition: re-running a source that returns46+// unchanged data is a no-op end to end, because captures are content-47+// addressed in the archive, bundle stamps are deterministic functions of the48+// capture, and the merge ledger is derived from immutable observations --49+// the same data can only produce the build that already exists.50+type Source interface {51+ // Name is the source as a person knows it.52+ Name() string53+ // Slug is the source as merge ledgers and archives record it.54+ Slug() string55+ // Components is what this source produces, as a typed set.56+ Components() ComponentSet57+ // Description says in a sentence or two what the source is and any58+ // terms it imposes.59+ Description() string60+ // TargetHint says what shape a fetch target takes, for forms and errors.61+ TargetHint() string62+ // FetchArgs returns the tools/crawl arguments selecting this source and63+ // target, or an error naming what is wrong with the target. It builds64+ // argv and performs no fetch itself.65+ FetchArgs(target string) ([]string, error)66+}67+68+// crawlSource is a source whose fetch is one tools/crawl flag and a target.69+// All three registered sources take this shape; a source that needed its own70+// fetcher would implement Source itself.71+type crawlSource struct {72+ name string73+ slug string74+ description string75+ flag string76+ hint string77+ // pair marks a source addressed as two slugs joined by a slash rather78+ // than one.79+ pair bool80+ components ComponentSet81+}82+83+func (s crawlSource) Name() string { return s.name }84+func (s crawlSource) Slug() string { return s.slug }85+func (s crawlSource) Components() ComponentSet { return s.components }86+func (s crawlSource) Description() string { return s.description }87+func (s crawlSource) TargetHint() string { return s.hint }88+89+func (s crawlSource) FetchArgs(target string) ([]string, error) {90+ if err := validTarget(target, s.pair); err != nil {91+ return nil, fmt.Errorf("%s target %q: %w (expected %s)", s.name, target, err, s.hint)92+ }93+ return []string{s.flag, target}, nil94+}95+96+// validTarget admits targets that are safe as command arguments and as the97+// slugs the sources actually use: lowercase names, digits, separators, and98+// for pair-addressed sources exactly one slash. A leading dash is refused so99+// a target can never be read as a flag.100+func validTarget(target string, pair bool) error {101+ if target == "" {102+ return fmt.Errorf("empty")103+ }104+ if strings.HasPrefix(target, "-") {105+ return fmt.Errorf("starts with a dash")106+ }107+ slashes := 0108+ for _, r := range target {109+ switch {110+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':111+ case r == '/':112+ slashes++113+ default:114+ return fmt.Errorf("contains %q", r)115+ }116+ }117+ if pair && slashes != 1 {118+ return fmt.Errorf("needs exactly one slash")119+ }120+ if !pair && slashes != 0 {121+ return fmt.Errorf("takes no slash")122+ }123+ if strings.HasPrefix(target, "/") || strings.HasSuffix(target, "/") {124+ return fmt.Errorf("has an empty half")125+ }126+ return nil127+}128+129+// fullSet is every component: all three registered sources publish complete130+// maps, differing in quality per component rather than in kind.131+var fullSet = ComponentSet{ComponentRaster, ComponentIcons, ComponentLocations, ComponentMetadata}132+133+// sources is the registry, in the order pages list them. The names here are134+// the same names merge ledgers carry, so a ledger line and a plugin card135+// point at each other.136+var sources = []Source{137+ crawlSource{138+ name: "MapGenie",139+ slug: "mapgenie",140+ description: "Community-built interactive maps for hundreds of games: " +141+ "the collection's founding source, with deep pyramids and rich category trees.",142+ flag: "-game",143+ hint: "game slug, e.g. cyberpunk-2077",144+ components: fullSet,145+ },146+ crawlSource{147+ name: "IGN Wiki",148+ slug: "ign-wiki",149+ description: "IGN's native wikimaps only -- maps IGN draws itself, not embeds. " +150+ "Pins arrive named but largely unwritten; wiki links stay behind, since the reader runs offline.",151+ flag: "-ign",152+ hint: "objectSlug/mapSlug, e.g. cyberpunk-2077/night-city",153+ pair: true,154+ components: fullSet,155+ },156+ crawlSource{157+ name: "Piggyback",158+ slug: "piggyback",159+ description: "The official guide house -- Cyberpunk 2077's first-party map, with prose on nearly every pin. " +160+ "Its CDN answers only requests carrying a Referer, and the free tier ceilings pyramids at zoom 7.",161+ flag: "-piggyback",162+ hint: "gameSlug/mapSlug, e.g. cyberpunk-2077/night-city",163+ pair: true,164+ components: fullSet,165+ },166+}167+168+// sourceBySlug finds one registered source.169+func sourceBySlug(slug string) (Source, bool) {170+ for _, s := range sources {171+ if s.Slug() == slug {172+ return s, true173+ }174+ }175+ return nil, false176+}
A cmd/cartograph/sources_test.go +77 -0
@@ -0,0 +1,77 @@
1+package main2+3+import (4+ "reflect"5+ "testing"6+)7+8+func TestRegistry(t *testing.T) {9+ if len(sources) != 3 {10+ t.Fatalf("registry holds %d sources, want 3", len(sources))11+ }12+ for _, slug := range []string{"mapgenie", "ign-wiki", "piggyback"} {13+ src, ok := sourceBySlug(slug)14+ if !ok {15+ t.Fatalf("registry misses %s", slug)16+ }17+ // All three publish complete maps; they differ in quality per18+ // component, never in kind.19+ for _, component := range []Component{20+ ComponentRaster, ComponentIcons, ComponentLocations, ComponentMetadata,21+ } {22+ if !src.Components().Has(component) {23+ t.Errorf("%s misses component %s", slug, component)24+ }25+ }26+ if src.Name() == "" || src.Description() == "" || src.TargetHint() == "" {27+ t.Errorf("%s is missing a face: %q %q %q", slug, src.Name(), src.Description(), src.TargetHint())28+ }29+ }30+ if _, ok := sourceBySlug("nowhere"); ok {31+ t.Error("registry answered for a source it does not hold")32+ }33+}34+35+func TestFetchArgs(t *testing.T) {36+ good := []struct {37+ source string38+ target string39+ want []string40+ }{41+ {"mapgenie", "cyberpunk-2077", []string{"-game", "cyberpunk-2077"}},42+ {"ign-wiki", "cyberpunk-2077/night-city", []string{"-ign", "cyberpunk-2077/night-city"}},43+ {"piggyback", "cyberpunk-2077/night-city", []string{"-piggyback", "cyberpunk-2077/night-city"}},44+ }45+ for _, at := range good {46+ src, _ := sourceBySlug(at.source)47+ got, err := src.FetchArgs(at.target)48+ if err != nil {49+ t.Errorf("%s %q refused: %v", at.source, at.target, err)50+ continue51+ }52+ if !reflect.DeepEqual(got, at.want) {53+ t.Errorf("%s %q built %v, want %v", at.source, at.target, got, at.want)54+ }55+ }56+57+ bad := []struct {58+ source string59+ target string60+ }{61+ {"mapgenie", ""},62+ {"mapgenie", "cyberpunk-2077/night-city"}, // single-slug source takes no slash63+ {"mapgenie", "-list"}, // a target must never read as a flag64+ {"mapgenie", "night city"},65+ {"ign-wiki", "night-city"}, // pair source needs its slash66+ {"ign-wiki", "a/b/c"}, // exactly one67+ {"piggyback", "/night-city"}, // no empty half68+ {"piggyback", "night-city/"},69+ {"piggyback", "Night-City/x"}, // slugs are lowercase70+ }71+ for _, at := range bad {72+ src, _ := sourceBySlug(at.source)73+ if _, err := src.FetchArgs(at.target); err == nil {74+ t.Errorf("%s accepted %q", at.source, at.target)75+ }76+ }77+}
A cmd/cartograph/ui/app.js +31 -0
M cmd/cartograph/ui/layout.tmpl +2 -0
A cmd/cartograph/ui/sources.tmpl +44 -0
@@ -0,0 +1,44 @@
1+{{define "title"}}Cartograph · sources{{end}}2+{{define "content"}}3+<section class="page-head">4+ <h1>Sources</h1>5+ <p class="dim">Each source contributes components to a game's bundle; composition merges them by title and slug, with provenance. Re-fetching unchanged data is a no-op.</p>6+</section>7+8+<div class="columns">9+{{range .Sources}}10+<section class="card">11+ <h2>{{.Name}} <span class="dim">{{.Slug}}</span></h2>12+ <p>{{range .Components}}<span class="badge">{{.}}</span>{{end}}</p>13+ <p class="dim">{{.Description}}</p>14+ <form data-op action="/ops/run" method="post">15+ <input type="hidden" name="op" value="fetch">16+ <input type="hidden" name="source" value="{{.Slug}}">17+ <input name="target" placeholder="{{.TargetHint}}" required>18+ <button>Fetch</button>19+ </form>20+</section>21+{{end}}22+</div>23+24+<section class="card">25+ <h2>Pipeline</h2>26+ <p class="dim">27+ repo <b>{{if .Repo}}{{.Repo}}{{else}}not found — start with -repo{{end}}</b> ·28+ archive <b>{{if .Archive}}{{.Archive}}{{else}}not set — start with -archive{{end}}</b> ·29+ bundles <b>{{.Bundles}}</b>30+ </p>31+ <form data-op action="/ops/run" method="post">32+ <input type="hidden" name="op" value="tiles">33+ <button>Rebuild pyramids</button>34+ <span class="dim">tools/tiles derives every pyramid the archive holds into build/tiles, incrementally.</span>35+ </form>36+ <form data-op action="/ops/run" method="post">37+ <input type="hidden" name="op" value="generate">38+ <button>Recompose bundles</button>39+ <span class="dim">tools/generate merges sources and installs versioned builds into the bundles directory.</span>40+ </form>41+</section>42+43+<pre id="op-output" class="console"></pre>44+{{end}}