1package main23import (4 "fmt"5 "strings"6)78// Component is one kind of thing a source can contribute to a game's bundle.9type Component string1011const (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)2223// ComponentSet is the components a source produces, in a fixed display24// order.25type ComponentSet []Component2627// Has reports whether the set carries the component.28func (s ComponentSet) Has(c Component) bool {29 for _, held := range s {30 if held == c {31 return true32 }33 }34 return false35}3637// 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.50type 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}6768// 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.71type 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}8283func (s crawlSource) Name() string { return s.name }84func (s crawlSource) Slug() string { return s.slug }85func (s crawlSource) Components() ComponentSet { return s.components }86func (s crawlSource) Description() string { return s.description }87func (s crawlSource) TargetHint() string { return s.hint }8889func (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}9596// 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.100func 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}128129// fullSet is every component: all three registered sources publish complete130// maps, differing in quality per component rather than in kind.131var fullSet = ComponentSet{ComponentRaster, ComponentIcons, ComponentLocations, ComponentMetadata}132133// 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.136var 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's Cyberpunk 2077 map, with prose on nearly every pin. " +160 "A deliberately game-specific importer: each Piggyback map projects through a transformation " +161 "buried in its own scripts, and only Cyberpunk's is verified -- other games are refused. " +162 "Its CDN answers only requests carrying a Referer, and the free tier ceilings pyramids at zoom 7.",163 flag: "-piggyback",164 hint: "cyberpunk-2077/night-city (the one supported game)",165 pair: true,166 components: fullSet,167 },168 crawlSource{169 name: "NASA Trek",170 slug: "nasa-trek",171 description: "Planetary maps, proving the format was never about games: NASA Trek's " +172 "global mosaics for the raster, the IAU Gazetteer of Planetary Nomenclature for " +173 "the pins -- every named feature with its place, size, and the origin of its name. " +174 "All public domain. A curated importer: each body names one verified global " +175 "mosaic, and only Mars is curated so far. No icon artwork; the viewer's fallback " +176 "glyphs stand in.",177 flag: "-trek",178 hint: "body, e.g. mars (the one curated body)",179 components: ComponentSet{180 ComponentRaster, ComponentLocations, ComponentMetadata,181 },182 },183 crawlSource{184 name: "ArcGIS Open Data",185 slug: "arcgis-hub",186 description: "A city's ArcGIS Hub open-data site, read in the format's own words: the " +187 "city is the volume, each crawl day registers a dated world, and the picker reads " +188 "as the city's version history. Zoning, annexations and wetlands ride the zones, " +189 "named places ride the pins, and the one lens is a basemap rendered offline from " +190 "the city's own streets and trails. A curated importer: each city names verified " +191 "datasets and a verified window, and only Bend, Oregon is curated so far. " +192 "Standard-library icons; no source artwork.",193 flag: "-arcgis",194 hint: "city, e.g. bend-or (the one curated city)",195 components: ComponentSet{196 ComponentRaster, ComponentLocations, ComponentMetadata,197 },198 },199}200201// sourceBySlug finds one registered source.202func sourceBySlug(slug string) (Source, bool) {203 for _, s := range sources {204 if s.Slug() == slug {205 return s, true206 }207 }208 return nil, false209}