package classes import ( "context" "encoding/json" "strconv" "strings" "git.golem15.com/golem15/sm-seo-plugin/classes/schema" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // PhraseIn renders a phrase key in locale; the key itself when the // application has no translator. func (s *Service) PhraseIn(locale, key string, params map[string]string) string { tr := s.translator() if tr == nil { return key } return tr.GetIn(locale, key, params) } // SearchURL is the configured site search address, "" when unset. func (s *Service) SearchURL() string { if cfg := s.Config(); cfg != nil { return strings.TrimSpace(cfg.String(SearchURLConfigKey)) } return "" } // SameAsLines splits a sameAs list into its trimmed, non-empty lines. func SameAsLines(list string) []string { var out []string for _, line := range strings.Split(strings.ReplaceAll(list, "\r\n", "\n"), "\n") { if v := strings.TrimSpace(line); v != "" { out = append(out, v) } } return out } // PublisherOf is the settings' publisher in locale (D-45): its name is the // translated publisher name (empty: the site name, filled by the builder), // URLs absolute on the site, and the local business data only for an // organisation with the switch on. func (s *Service) PublisherOf(ctx context.Context, db *gorm.DB, settings models.Settings, locale string) schema.Publisher { site := s.SiteURL() p := schema.Publisher{ Type: schema.PublisherOrganization, Name: strings.TrimSpace(s.translatedSetting(ctx, db, &settings, "publisher_name", settings.PublisherName, locale)), Email: strings.TrimSpace(settings.PublisherEmail), Phone: strings.TrimSpace(settings.PublisherPhone), } if settings.PublisherType == models.PublisherPerson { p.Type = schema.PublisherPerson } if logo := strings.TrimSpace(settings.PublisherLogo); logo != "" { p.Logo = AbsoluteURL(site, logo) } if u := strings.TrimSpace(settings.PublisherURL); u != "" { p.URL = AbsoluteURL(site, u) } p.SameAs = SameAsLines(settings.PublisherSameAs) if settings.LocalEnabled && p.Type == schema.PublisherOrganization { l := &schema.Local{ Type: strings.TrimSpace(settings.LocalType), PriceRange: strings.TrimSpace(settings.LocalPriceRange), Street: strings.TrimSpace(settings.LocalStreet), PostalCode: strings.TrimSpace(settings.LocalPostalCode), City: strings.TrimSpace(settings.LocalCity), Region: strings.TrimSpace(settings.LocalRegion), Country: strings.TrimSpace(settings.LocalCountry), } if lat, err := strconv.ParseFloat(strings.TrimSpace(settings.LocalLatitude), 64); err == nil { if lng, err := strconv.ParseFloat(strings.TrimSpace(settings.LocalLongitude), 64); err == nil { l.Latitude, l.Longitude = &lat, &lng } } for _, h := range settings.Hours() { l.Hours = append(l.Hours, schema.Hours{Day: h.Day, Opens: h.Opens, Closes: h.Closes}) } p.Local = l } return p } // pageVariant is the registry kind of a registry page, "" for other types. func pageVariant(db *gorm.DB, key, id string) string { if key != PageTypeKey || id == "" { return "" } kinds, err := pageKinds(db, []string{id}) if err != nil { return "" } return kinds[id] } // structuredIn holds what resolveAt hands to the structured data. type structuredIn struct { key string id string rec *pact.ContentRecord entry models.Entry content pact.ContentLocale locale string path string url string home bool siteName string seo *SEO // schemaValue replaces the stored seo_schema value when set (the // admin preview of unsaved changes, the MCP graph tool). schemaValue *string // sources is the answer's value sources (the schema type's is set). sources map[string]string } // schemaRaw is the seo_schema value a graph is built with. func (in structuredIn) schemaRaw() string { if in.schemaValue != nil { return *in.schemaValue } return entryData(in.entry, DataSchema) } // graphInput composes the builder's input of a page. func (s *Service) graphInput(ctx context.Context, db *gorm.DB, settings models.Settings, in structuredIn, crumbs []schema.Crumb) schema.Input { site := s.SiteURL() name := strings.TrimSpace(in.content.Title) if name == "" && in.home { name = in.siteName } if name == "" && in.seo != nil { name = in.seo.Title } gi := schema.Input{ Site: site, URL: in.url, Lang: LangTag(in.locale), Home: in.home, Name: name, Headline: strings.TrimSpace(in.content.Title), Crumbs: crumbs, SiteName: in.siteName, SearchURL: s.SearchURL(), Publisher: s.PublisherOf(ctx, db, settings, in.locale), Words: in.content.Words, } if in.seo != nil { gi.Description = in.seo.Description } if value, err := schema.ParseValue(in.schemaRaw()); err == nil { gi.Type, gi.Service = value.Type, value.Fields if value.Type != "" && in.sources != nil { in.sources["schema"] = SourceRecord } } if in.key != "" { if typ, ok := s.Registry().ContentType(in.key); ok && typ.Content != nil { gi.Kind = typ.Content.Kind } gi.Variant = pageVariant(db, in.key, in.id) } seen := map[string]bool{} addImage := func(u string) { if u = strings.TrimSpace(u); u == "" { return } u = AbsoluteURL(site, u) if !seen[u] { seen[u] = true gi.Images = append(gi.Images, u) } } for _, img := range in.content.Images { if !img.Decorative { addImage(img.URL) } } addImage(in.content.Image) if len(gi.Images) == 0 && in.seo != nil { addImage(in.seo.OG.Image) } if rec := in.rec; rec != nil { gi.PublishedAt, gi.ModifiedAt = rec.PublishedAt, rec.LastMod for _, a := range rec.Authors { p := schema.Person{Name: a.Name} if a.Ref.Type != "" { if l, ok := s.describeLocale(ctx, a.Ref, in.locale); ok { p.URL = AbsoluteURL(site, l.URL) } } gi.Authors = append(gi.Authors, p) } for _, v := range in.content.Videos { gi.Videos = append(gi.Videos, schema.Video{Provider: v.Provider, ID: v.ID}) } } if len(crumbs) >= 3 { gi.Section = crumbs[len(crumbs)-2].Name } return gi } // describeLocale describes a referenced record in locale. func (s *Service) describeLocale(ctx context.Context, ref pact.ContentRef, locale string) (pact.ContentLocale, bool) { reg := s.Registry() if reg == nil { return pact.ContentLocale{}, false } rec, err := reg.Describe(ctx, ref.Type, ref.ID, s.UnprefixedLocale()) if err != nil || rec == nil || !rec.Published { return pact.ContentLocale{}, false } return localeOf(rec, locale) } // structuredData fills the structured keys of an ok answer: html_lang, // og:locale and its alternates, the hreflang alternates, the breadcrumb // trail and the JSON-LD graph (D-43, D-55 to D-57, D-86). func (s *Service) structuredData(ctx context.Context, db *gorm.DB, settings models.Settings, res *Result, in structuredIn) error { res.HTMLLang = LangTag(in.locale) if res.SEO != nil { res.SEO.OG.Locale = s.OGLocale(in.locale) res.SEO.OG.LocaleAlternates = s.OGLocaleAlternates(in.rec, in.locale) } res.Alternates = Alternates(s.SiteURL(), in.rec, s.DefaultLocale(ctx, db)) name := strings.TrimSpace(in.content.Title) if name == "" && res.SEO != nil { name = res.SEO.Title } res.Breadcrumbs = s.Trail(ctx, db, settings, in.rec, entryData(in.entry, DataPrimaryParent), in.locale, in.path, name) graph, merged, err := s.buildGraph(ctx, db, settings, in, res.Breadcrumbs) if err != nil { return err } res.JSONLD, res.Merged = graph, merged return nil } // buildGraph builds a page's graph with its custom JSON-LD merged in // (D-44); merged names the generated values the custom nodes replace. A // stored custom value that no longer decodes is left out. func (s *Service) buildGraph(ctx context.Context, db *gorm.DB, settings models.Settings, in structuredIn, crumbs []schema.Crumb) (map[string]any, []schema.Merged, error) { graph, err := schema.Build(ctx, s.graphInput(ctx, db, settings, in, crumbs)) if err != nil { return nil, nil, err } value, err := schema.ParseValue(in.schemaRaw()) if err != nil || value.Custom == "" { return graph, nil, nil } custom, err := schema.DecodeCustom(value.Custom) if err != nil || len(custom) == 0 { return graph, nil, nil } graph, merged := schema.Merge(graph, custom) return graph, merged, nil } // PublisherPreview is the view model of the settings' P8 partial: the // generated publisher node as indented JSON and its findings as lines, or // the all-good line. type PublisherPreview struct { JSON string Findings []string AllGood string } // FindingText renders a finding with the graph copy of the request locale. func (s *Service) FindingText(ctx context.Context, typ string, f schema.Finding) string { key := "golem15.seo::lang.graph.recommended" if f.Level == schema.LevelRequired { key = "golem15.seo::lang.graph.required" } return s.PhraseCtx(ctx, key, map[string]string{"property": f.Property, "type": typ}) } // PublisherPreview builds P8 from the stored settings in the default // locale (UI-SPEC C20). func (s *Service) PublisherPreview(ctx context.Context) (PublisherPreview, error) { db, err := s.DB(ctx) if err != nil { return PublisherPreview{}, err } settings, err := LoadSettings(db) if err != nil { return PublisherPreview{}, err } locale := s.DefaultLocale(ctx, db) node := schema.PublisherNode(s.SiteURL(), s.PublisherOf(ctx, db, settings, locale), s.SiteName(ctx, settings, locale)) out := PublisherPreview{AllGood: s.PhraseCtx(ctx, "golem15.seo::lang.graph.all_good", nil)} if node == nil { return out, nil } raw, err := json.MarshalIndent(node, "", " ") if err != nil { return PublisherPreview{}, err } out.JSON = string(raw) rep := schema.ValidateNode(node) for _, f := range rep.Findings { out.Findings = append(out.Findings, s.FindingText(ctx, rep.Type, f)) } return out, nil } // GraphAnswer is the structured data of one page (E9, the MCP graph tool): // the locale, the page's absolute URL (nil when it has none yet), the // graph and every node with its findings (D-46). type GraphAnswer struct { Locale string `json:"locale"` URL *string `json:"url"` Graph map[string]any `json:"graph"` Nodes []schema.Report `json:"nodes"` } // graphAnswer turns a resolve answer into a graph answer. func graphAnswer(res Result, locale string) GraphAnswer { out := GraphAnswer{Locale: locale, Nodes: []schema.Report{}} if res.Action != ActionOK || res.JSONLD == nil { return out } u := res.URL out.URL, out.Graph, out.Locale = &u, res.JSONLD, res.Locale out.Nodes = schema.Validate(res.JSONLD) return out } // GraphOfURL builds the graph of a public URL; schemaValue (nil: the // stored one) replaces the page's seo_schema value. A URL that is not an // ok page answers no graph. func (s *Service) GraphOfURL(ctx context.Context, rawURL string, schemaValue *string) (GraphAnswer, Result, error) { r := &Resolver{Service: s, schema: schemaValue} res, err := r.Resolve(ctx, rawURL) if err != nil { return GraphAnswer{}, Result{}, err } return graphAnswer(res, res.Locale), res, nil } // GraphOfRecord builds the graph of a record in locale at its own // address, published or not; schemaValue (nil: the stored one) replaces // its seo_schema value. A record without an address in locale answers no // URL and no graph. func (s *Service) GraphOfRecord(ctx context.Context, key, id, locale string, schemaValue *string) (GraphAnswer, Result, error) { r := &Resolver{Service: s, schema: schemaValue} res, ok, err := r.ResolveRecord(ctx, key, id, locale) if err != nil { return GraphAnswer{}, Result{}, err } if !ok { return GraphAnswer{Locale: locale, Nodes: []schema.Report{}}, res, nil } return graphAnswer(res, locale), res, nil } // GraphActionName is the SEO tab's graph preview widget action (E9). const GraphActionName = "seo_graph" // graphPayload is the E9 action payload. type graphPayload struct { Op string `json:"op"` Locale string `json:"locale"` } // RecordGraphAction is the E9 widget action of content type key (UI-SPEC // C18): op graph answers the graph of the saved record in locale with the // unsaved seo_schema value of the form, and its findings. func RecordGraphAction(svc func() *Service, key, label, permission string) pact.AdminAction { return pact.AdminAction{ Name: GraphActionName, Label: label, Permissions: []string{permission}, Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) { s := svc() if s == nil { return pact.AdminActionResult{}, ErrNotBooted } var p graphPayload if len(in.Payload) > 0 { if err := json.Unmarshal(in.Payload, &p); err != nil || (p.Op != "" && p.Op != "graph") { return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.api.value_invalid"}}} } } db, err := s.DB(ctx) if err != nil { return pact.AdminActionResult{}, err } locale := p.Locale if locale == "" { locale = s.DefaultLocale(ctx, db) } var override *string if raw, ok := in.Values[FieldPrefix+DataSchema]; ok { text, _ := raw.(string) override = &text } if in.RecordID == nil { return pact.AdminActionResult{Data: GraphAnswer{Locale: locale, Nodes: []schema.Report{}}}, nil } answer, _, err := s.GraphOfRecord(ctx, key, strconv.FormatUint(*in.RecordID, 10), locale, override) return pact.AdminActionResult{Data: answer}, err }, } } // SchemaMerges are the schema.merged save notices of a record (UI-SPEC // C17): one per generated node whose values its custom JSON-LD replaces, // in the default locale. func (s *Service) SchemaMerges(ctx context.Context, key, id string) ([]pact.FormFieldNotice, error) { db, err := s.DB(ctx) if err != nil { return nil, err } _, res, err := s.GraphOfRecord(ctx, key, id, s.DefaultLocale(ctx, db), nil) if err != nil { return nil, err } var out []pact.FormFieldNotice for _, m := range res.Merged { out = append(out, pact.FormFieldNotice{Message: "golem15.seo::lang.schema.merged", Params: map[string]string{"props": strings.Join(m.Props, ", "), "node": m.Type}}) } return out, nil } // CheckSchema refuses a structured data value the SEO tab of record id of // content type key would refuse, as a *ProblemsError on schema. func (s *Service) CheckSchema(ctx context.Context, kind, key, id, raw string) error { db, err := s.DB(ctx) if err != nil { return err } if _, _, err := schema.Normalize(kind, pageVariant(db, key, id), raw); err != nil { return &ProblemsError{Problems: map[string][]models.Problem{DataSchema: {SchemaProblem(kind, err)}}} } return nil }