// Package contentdata loads the structured SSR copy (glossary, static-page // SEO meta) from frontend/content/*.yaml — the Go port of content_loader.py. // Validated at load time so a malformed content file fails loudly at boot // rather than silently rendering a blank/broken page. // // The YAML files are shared data, consumed by the Python service before this // port and committed alongside it — they are not rewritten to JSON for the // rewrite, which is why this package carries the module's one non-stdlib // dependency (gopkg.in/yaml.v3; the files use block/folded scalars and // comments, well beyond anything worth hand-parsing). package contentdata import ( "fmt" "os" "path/filepath" "gopkg.in/yaml.v3" ) // GlossaryTerm is one glossary entry, in file order. Body is raw HTML copy; // its "{base}" placeholder is substituted by the page handler (the Python did // it per-request in _glossary_body), not here. type GlossaryTerm struct { Slug string `yaml:"slug"` Term string `yaml:"term"` Short string `yaml:"short"` Body string `yaml:"body"` } // Glossary preserves file order (the index page lists terms in the order the // YAML declares them — Python dicts kept insertion order) and offers slug // lookup for the per-term page. type Glossary struct { Terms []GlossaryTerm bySlug map[string]int } // Get returns the entry for slug, or ok=false (the per-term page's 404). func (g *Glossary) Get(slug string) (GlossaryTerm, bool) { i, ok := g.bySlug[slug] if !ok { return GlossaryTerm{}, false } return g.Terms[i], true } // Page is one static page's SEO title/description. type Page struct { Title string `yaml:"title"` Description string `yaml:"description"` } // LoadGlossary reads contentDir/glossary.yaml. It errors if any entry is // missing a required field, has the wrong shape, or repeats a slug — a bad // content edit fails loudly (at boot) rather than rendering a blank glossary // card. Same contract as content_loader.load_glossary. func LoadGlossary(contentDir string) (*Glossary, error) { path := filepath.Join(contentDir, "glossary.yaml") raw, err := os.ReadFile(path) if err != nil { return nil, err } var doc struct { Terms []map[string]string `yaml:"terms"` } if err := yaml.Unmarshal(raw, &doc); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } if len(doc.Terms) == 0 { return nil, fmt.Errorf("%s: 'terms' must be a non-empty list", path) } g := &Glossary{bySlug: make(map[string]int, len(doc.Terms))} for i, entry := range doc.Terms { slug := entry["slug"] if slug == "" { return nil, fmt.Errorf("%s: terms[%d] must be a mapping with a 'slug' key", path, i) } var missing []string for _, f := range []string{"term", "short", "body"} { if entry[f] == "" { missing = append(missing, f) } } if len(missing) > 0 { return nil, fmt.Errorf("%s: term '%s' is missing %v", path, slug, missing) } if _, dup := g.bySlug[slug]; dup { return nil, fmt.Errorf("%s: duplicate slug '%s'", path, slug) } g.bySlug[slug] = len(g.Terms) g.Terms = append(g.Terms, GlossaryTerm{ Slug: slug, Term: entry["term"], Short: entry["short"], Body: entry["body"], }) } return g, nil } // LoadPages reads contentDir/pages.yaml: page key -> {title, description}. // Same fail-loud contract as LoadGlossary. func LoadPages(contentDir string) (map[string]Page, error) { path := filepath.Join(contentDir, "pages.yaml") raw, err := os.ReadFile(path) if err != nil { return nil, err } var doc struct { Pages map[string]Page `yaml:"pages"` } if err := yaml.Unmarshal(raw, &doc); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } if len(doc.Pages) == 0 { return nil, fmt.Errorf("%s: 'pages' must be a non-empty mapping", path) } for key, p := range doc.Pages { var missing []string if p.Title == "" { missing = append(missing, "title") } if p.Description == "" { missing = append(missing, "description") } if len(missing) > 0 { return nil, fmt.Errorf("%s: page '%s' is missing %v", path, key, missing) } } return doc.Pages, nil }