- seo_schema (seoschema, E8) with the C17 server rules: invalid JSON with line and column, non-objects, 16 KB, depth 8, a literal <script and a type outside the kind's list refused; the value re-serialised - a custom node with a generated @id merges into it (schema.merged save notice), other nodes are appended to the graph - seo_primary_parent (seoparent) for article types; both values stored in the entry data on every locale row - the seo_graph widget action (E9 data), P1 primary_gone and the P2 schema type and breadcrumb trail rows - GET /_seo/api/v1/graph and MCP seo_get_graph; seo_set_url_seo and apply_batch accept primary_parent and schema The graph tool lands with the validation: the operations parity test binds the seo_graph widget action to its tool.
443 lines
14 KiB
Go
443 lines
14 KiB
Go
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
|
|
}
|