diff --git a/classes/breadcrumbs.go b/classes/breadcrumbs.go index 5560e79..a43071e 100644 --- a/classes/breadcrumbs.go +++ b/classes/breadcrumbs.go @@ -160,3 +160,39 @@ func (s *Service) Trail(ctx context.Context, db *gorm.DB, settings models.Settin } return append(out, schema.Crumb{Name: name, URL: site + path, Path: path}) } + +// ParentOption is one choice of the primary category field (P33-23). +type ParentOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// ParentOptions lists a record's parents as primary category choices, in +// descriptor order, labelled with their title in locale (the default +// locale when ""). +func (s *Service) ParentOptions(ctx context.Context, key, id, locale string) []ParentOption { + out := []ParentOption{} + reg := s.Registry() + if reg == nil || key == "" || id == "" { + return out + } + rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale()) + if err != nil || rec == nil { + return out + } + if locale == "" { + if db, err := s.DB(ctx); err == nil { + locale = s.DefaultLocale(ctx, db) + } + } + for _, ref := range rec.Parents { + label := ParentValue(ref) + if parent, err := reg.Describe(ctx, ref.Type, ref.ID, s.UnprefixedLocale()); err == nil && parent != nil { + if l := contentLocale(parent, locale); strings.TrimSpace(l.Title) != "" { + label = l.Title + } + } + out = append(out, ParentOption{Value: ParentValue(ref), Label: label}) + } + return out +} diff --git a/classes/entries.go b/classes/entries.go index b1f43d6..1d6cc25 100644 --- a/classes/entries.go +++ b/classes/entries.go @@ -22,6 +22,30 @@ var EntryColumns = []string{ // every locale row (Q-5): the social image. const SharedColumn = "og_image" +// DataColumns are the SEO tab values stored in an entry's data (P33-23): +// the primary parent and the structured data value. Like the social image +// they are one per record, written on every locale row. +var DataColumns = []string{DataPrimaryParent, DataSchema} + +// sharedColumns are every value shared by the locale rows of a record. +var sharedColumns = append([]string{SharedColumn}, DataColumns...) + +// SharedData returns a record's shared data value: the first non-empty +// value in locale order. +func SharedData(entries map[string]models.Entry, key string) string { + locales := make([]string, 0, len(entries)) + for locale := range entries { + locales = append(locales, locale) + } + sort.Strings(locales) + for _, locale := range locales { + if v := entryData(entries[locale], key); v != "" { + return v + } + } + return "" +} + // SharedValue returns a record's shared social image: the first non-empty // value in locale order. func SharedValue(entries map[string]models.Entry) string { @@ -61,6 +85,8 @@ func entryValue(e models.Entry, column string) string { return e.OGDescription case "og_image": return e.OGImage + case DataPrimaryParent, DataSchema: + return entryData(e, column) } return "" } @@ -85,6 +111,17 @@ func setEntryValue(e *models.Entry, column, value string) { e.OGDescription = value case "og_image": e.OGImage = value + case DataPrimaryParent, DataSchema: + data := models.JSONMap{} + for k, v := range e.Data { + data[k] = v + } + if value == "" { + delete(data, column) + } else { + data[column] = value + } + e.Data = data } } @@ -152,7 +189,11 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) ( } described := s.recordLocales(ctx, in.Type.Key, in.ID) values := in.Values - if shared, ok := values[SharedColumn]; ok { + for _, column := range sharedColumns { + shared, ok := values[column] + if !ok { + continue + } // One value for every locale of the record (Q-5). v := "" for _, text := range shared { @@ -166,10 +207,10 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) ( all[locale] = v } copied := RecordValues{} - for column, perLocale := range values { - copied[column] = perLocale + for c, perLocale := range values { + copied[c] = perLocale } - copied[SharedColumn] = all + copied[column] = all values = copied } touched := map[string]models.Entry{} diff --git a/classes/field_types.go b/classes/field_types.go index bdc56c1..ed7d247 100644 --- a/classes/field_types.go +++ b/classes/field_types.go @@ -2,10 +2,13 @@ package classes import ( "context" + "errors" "fmt" "net/url" "strings" + "git.golem15.com/golem15/sm-seo-plugin/classes/schema" + "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" ) @@ -18,6 +21,9 @@ const ( ElementImage = "golem15-seo-image" ElementRobotsTxt = "golem15-seo-robotstxt" ElementRobotsTester = "golem15-seo-robotstester" + ElementSchema = "golem15-seo-schema" + ElementGraph = "golem15-seo-graph" + ElementParent = "golem15-seo-parent" ) // The element bundle every SEO field type loads. @@ -73,6 +79,126 @@ func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldTyp Scripts: elementScripts, Styles: elementStyles, Decode: decodeRobotsTxt, }, + { + Name: "seoschema", Element: ElementSchema, + Scripts: elementScripts, Styles: elementStyles, + Options: []string{"kind", "contentType"}, CheckOptions: checkKind, + Decode: decodeSchema(app), + }, + { + Name: "seoparent", Element: ElementParent, + Scripts: elementScripts, Styles: elementStyles, + Options: []string{"contentType"}, + Decode: decodeParent(app), + }, + } +} + +// checkKind accepts the content kinds of pact.ContentDescriptor. +func checkKind(options map[string]any) error { + if raw, ok := options["kind"]; ok { + switch s, _ := raw.(string); s { + case "", schema.KindArticle, schema.KindCollection, schema.KindProfile, schema.KindPage: + default: + return fmt.Errorf("kind must be article, collection, profile or page, got %v", raw) + } + } + return nil +} + +// optionString reads a string option. +func optionString(options map[string]any, key string) string { + s, _ := options[key].(string) + return s +} + +// SchemaRefusal renders a refused seo_schema value with the schema.* copy +// in the request locale (UI-SPEC C17). +func (s *Service) SchemaRefusal(ctx context.Context, kind string, err error) string { + var se *schema.Error + if !errors.As(err, &se) { + return s.PhraseCtx(ctx, "golem15.seo::lang.schema.invalid_json", map[string]string{"error": err.Error()}) + } + switch se.Code { + case schema.CodeInvalidJSON: + return s.PhraseCtx(ctx, "golem15.seo::lang.schema.invalid_json", map[string]string{"error": se.Detail}) + case schema.CodeTypeUnknown: + return s.PhraseCtx(ctx, "golem15.seo::lang.schema.type_unknown", map[string]string{"type": kind}) + case schema.CodeFieldInvalid: + label, message := "golem15.seo::lang.schema.field_price", "golem15.seo::lang.schema.price_invalid" + if se.Field == "currency" { + label, message = "golem15.seo::lang.schema.field_currency", "golem15.seo::lang.schema.currency_invalid" + } + return s.PhraseCtx(ctx, "golem15.seo::lang.schema.field_invalid", map[string]string{ + "field": s.PhraseCtx(ctx, label, nil), + "message": s.PhraseCtx(ctx, message, nil), + }) + } + keys := map[string]string{ + schema.CodeNotObject: "golem15.seo::lang.schema.not_object", + schema.CodeTooLarge: "golem15.seo::lang.schema.too_large", + schema.CodeTooDeep: "golem15.seo::lang.schema.too_deep", + schema.CodeScriptTag: "golem15.seo::lang.schema.script_tag", + } + return s.PhraseCtx(ctx, keys[se.Code], nil) +} + +// SchemaVariant is the registry kind of a record of contentType, "" for +// other types. +func SchemaVariant(record any) string { + if p, ok := record.(*models.Page); ok && p != nil { + return p.Kind + } + return "" +} + +// decodeSchema checks and normalises the seoschema value (E8): a JSON +// string or "" (UI-SPEC C17); every refusal answers the schema.* copy. +func decodeSchema(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) { + return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + raw, ok := stringValue(in.Value) + if !ok { + return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.schema.not_object"}} + } + kind := optionString(in.Options, "kind") + stored, _, err := schema.Normalize(kind, SchemaVariant(in.Record), raw) + if err != nil { + svc, serr := NewService(app()) + if serr != nil { + return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{err.Error()}} + } + return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{svc.SchemaRefusal(ctx, kind, err)}} + } + return pact.FormFieldDecoded{Value: stored}, nil + } +} + +// decodeParent accepts "" (the first parent) or one of the record's +// parents as :. +func decodeParent(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) { + return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + raw, ok := stringValue(in.Value) + raw = strings.TrimSpace(raw) + if !ok { + return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.api.value_invalid"}} + } + if raw == "" { + return pact.FormFieldDecoded{Value: ""}, nil + } + svc, err := NewService(app()) + if err != nil { + return pact.FormFieldDecoded{}, err + } + id := "" + if in.Record != nil && in.DB != nil { + id, _ = RecordKey(in.DB, in.Record) + } + for _, opt := range svc.ParentOptions(ctx, optionString(in.Options, "contentType"), id, "") { + if opt.Value == raw { + return pact.FormFieldDecoded{Value: raw}, nil + } + } + return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.api.value_invalid"}} } } @@ -165,3 +291,29 @@ func ValidURLRef(v string) bool { u, err := url.Parse(v) return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" } + +// SchemaProblem is a refused seo_schema value as an API problem. +func SchemaProblem(kind string, err error) models.Problem { + var se *schema.Error + if !errors.As(err, &se) { + return models.Problem{Key: "golem15.seo::lang.schema.invalid_json", Params: map[string]string{"error": err.Error()}} + } + switch se.Code { + case schema.CodeInvalidJSON: + return models.Problem{Key: "golem15.seo::lang.schema.invalid_json", Params: map[string]string{"error": se.Detail}} + case schema.CodeTypeUnknown: + return models.Problem{Key: "golem15.seo::lang.schema.type_unknown", Params: map[string]string{"type": kind}} + case schema.CodeFieldInvalid: + if se.Field == "currency" { + return models.Problem{Key: "golem15.seo::lang.schema.currency_invalid"} + } + return models.Problem{Key: "golem15.seo::lang.schema.price_invalid"} + case schema.CodeNotObject: + return models.Problem{Key: "golem15.seo::lang.schema.not_object"} + case schema.CodeTooLarge: + return models.Problem{Key: "golem15.seo::lang.schema.too_large"} + case schema.CodeTooDeep: + return models.Problem{Key: "golem15.seo::lang.schema.too_deep"} + } + return models.Problem{Key: "golem15.seo::lang.schema.script_tag"} +} diff --git a/classes/history.go b/classes/history.go index 87f7dbc..e1621e6 100644 --- a/classes/history.go +++ b/classes/history.go @@ -342,6 +342,11 @@ func entryValues(e models.Entry) map[string]any { for _, column := range EntryColumns { out[column] = entryValue(e, column) } + for _, column := range DataColumns { + if v := entryData(e, column); v != "" { + out[column] = v + } + } return out } diff --git a/classes/owner.go b/classes/owner.go index cf91165..23c79e0 100644 --- a/classes/owner.go +++ b/classes/owner.go @@ -5,6 +5,7 @@ import ( "net/url" "strings" + "git.golem15.com/golem15/sm-seo-plugin/classes/schema" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" @@ -126,6 +127,9 @@ func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (U for _, column := range EntryColumns { known[column] = true } + for _, column := range DataColumns { + known[column] = true + } for column, raw := range in.Values { v := strings.TrimSpace(raw) switch { @@ -148,6 +152,27 @@ func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (U case column == "og_image" && !ValidImageURL(v): problems[column] = append(problems[column], models.Problem{Key: errImageURL}) continue + case (column == DataPrimaryParent || column == DataSchema) && owner.WrittenTo != WrittenToRecord: + if v != "" { + problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.record_only"}) + } + continue + case column == DataPrimaryParent && v != "": + found := false + for _, opt := range s.ParentOptions(ctx, typ.Key, owner.ID, "") { + found = found || opt.Value == v + } + if !found { + problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"}) + continue + } + case column == DataSchema: + stored, _, err := schema.Normalize(typ.Content.Kind, pageVariant(tx, typ.Key, owner.ID), v) + if err != nil { + problems[column] = append(problems[column], SchemaProblem(typ.Content.Kind, err)) + continue + } + v = stored } values[column] = v } @@ -181,6 +206,9 @@ func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (U for _, column := range EntryColumns { out[column] = EntryValue(e, column) } + for _, column := range DataColumns { + out[column] = EntryValue(e, column) + } return URLSEOResult{Owner: owner, Values: out}, nil } rule := models.Rule{URL: owner.URL, Match: models.MatchExact} diff --git a/classes/partials.go b/classes/partials.go index 8f84158..78f486e 100644 --- a/classes/partials.go +++ b/classes/partials.go @@ -7,6 +7,7 @@ import ( "reflect" "strings" + "git.golem15.com/golem15/sm-seo-plugin/classes/schema" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/sm-sitemap-plugin/contract" "gorm.io/gorm" @@ -104,7 +105,7 @@ func (s *Service) EffectiveRows(ctx context.Context, res Result) []EffectiveRow "title": res.SEO.Title, "description": res.SEO.Description, "canonical": res.SEO.Canonical, "robots": res.SEO.Robots, "og_title": res.SEO.OG.Title, "og_description": res.SEO.OG.Description, "og_image": res.SEO.OG.Image, } - out := make([]EffectiveRow, 0, len(effectiveProperties)) + out := make([]EffectiveRow, 0, len(effectiveProperties)+2) for _, p := range effectiveProperties { out = append(out, EffectiveRow{ Property: s.PhraseCtx(ctx, p.label, nil), @@ -113,9 +114,47 @@ func (s *Service) EffectiveRows(ctx context.Context, res Result) []EffectiveRow Mono: p.mono, }) } + // Structured data (UI-SPEC C16): the page's schema type and its + // breadcrumb trail. + if res.JSONLD != nil { + typ, source := effectiveSchemaType(res), SourceDefault + if res.Sources["schema"] != "" { + source = res.Sources["schema"] + } + out = append(out, EffectiveRow{ + Property: s.PhraseCtx(ctx, "golem15.seo::lang.effective.schema", nil), + Value: typ, + Source: s.PhraseCtx(ctx, "golem15.seo::lang.effective.source_"+source, nil), + }) + names := make([]string, 0, len(res.Breadcrumbs)) + for _, c := range res.Breadcrumbs { + names = append(names, c.Name) + } + out = append(out, EffectiveRow{ + Property: s.PhraseCtx(ctx, "golem15.seo::lang.effective.trail", nil), + Value: strings.Join(names, " › "), + Source: s.PhraseCtx(ctx, "golem15.seo::lang.effective.source_"+SourceContent, nil), + }) + } return out } +// effectiveSchemaType is the page's main schema type: its article or +// service node, else its page node. +func effectiveSchemaType(res Result) string { + page := "" + for _, n := range schema.Nodes(res.JSONLD) { + id, _ := n["@id"].(string) + switch id { + case res.URL + "#article", res.URL + "#service": + return schema.NodeType(n) + case res.URL + "#webpage": + page = schema.NodeType(n) + } + } + return page +} + // newEffectiveView starts a P2 view with its labels and the staging note. func (s *Service) newEffectiveView(ctx context.Context) EffectiveView { return EffectiveView{ @@ -205,6 +244,9 @@ func (s *Service) RecordStatus(ctx context.Context, key, id string) (StatusView, } } } + if c, ok := s.primaryGoneCallout(ctx, db, key, id); ok { + view.Callouts = append(view.Callouts, c) + } exclusions, err := s.RecordSitemapExclusions(ctx, key, id) if err != nil { return view, err @@ -346,3 +388,42 @@ func RecordKey(db *gorm.DB, record any) (string, error) { func reflectValue(record any) reflect.Value { return reflect.Indirect(reflect.ValueOf(record)) } + +// primaryGoneCallout warns that a record's stored primary category is no +// longer one of its parents, so the first one is used (UI-SPEC C16). +func (s *Service) primaryGoneCallout(ctx context.Context, db *gorm.DB, key, id string) (Callout, bool) { + reg := s.Registry() + if reg == nil { + return Callout{}, false + } + typ, ok := reg.ContentType(key) + if !ok || typ.Content == nil { + return Callout{}, false + } + entries, err := LoadEntries(db, typ.Content.Model, id) + if err != nil { + return Callout{}, false + } + stored := SharedData(entries, DataPrimaryParent) + if stored == "" { + return Callout{}, false + } + rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale()) + if err != nil || rec == nil { + return Callout{}, false + } + if _, _, gone := PrimaryParent(rec, stored); !gone { + return Callout{}, false + } + name := stored + if pt, pid, ok := strings.Cut(stored, ":"); ok { + if parent, err := reg.Describe(ctx, pt, pid, s.UnprefixedLocale()); err == nil && parent != nil { + if l := contentLocale(parent, s.DefaultLocale(ctx, db)); strings.TrimSpace(l.Title) != "" { + name = l.Title + } + } + } + return Callout{ + Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.primary_gone", map[string]string{"name": name}), + }, true +} diff --git a/classes/resolver.go b/classes/resolver.go index 4025fbb..74a50c0 100644 --- a/classes/resolver.go +++ b/classes/resolver.go @@ -83,6 +83,8 @@ type Result struct { // TypeKey and RecordID name the record behind the URL, "" when none. TypeKey string `json:"-"` RecordID string `json:"-"` + // Merged names the generated values custom JSON-LD replaced. + Merged []schema.Merged `json:"-"` } // SEO is the head metadata of a resolved page. @@ -157,6 +159,9 @@ type Resolver struct { // non-production lock (D-22) is not applied. The sitemap filter decides // on these, so staging lists the same addresses as production. production bool + // schema replaces the stored seo_schema value of the page when set + // (the graph preview of unsaved changes and the MCP graph tool). + schema *string } // Resolver returns the service's resolver. @@ -799,6 +804,7 @@ func (r *Resolver) resolveAt(ctx context.Context, db *gorm.DB, settings models.S if err := svc.structuredData(ctx, db, settings, &res, structuredIn{ key: key, id: res.RecordID, rec: rec, entry: entry, content: content, locale: locale, path: path, url: self, home: home, siteName: svc.SiteName(ctx, settings, locale), seo: seo, + schemaValue: r.schema, sources: src, }); err != nil { return Result{}, err } diff --git a/classes/schema/custom.go b/classes/schema/custom.go new file mode 100644 index 0000000..9531c22 --- /dev/null +++ b/classes/schema/custom.go @@ -0,0 +1,285 @@ +package schema + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "sort" + "strings" +) + +// The caps of custom JSON-LD (UI-SPEC C17, T-33-24). +const ( + MaxCustomBytes = 16 * 1024 + MaxCustomDepth = 8 +) + +// The codes of a refused value; each is a phrase key under schema. +const ( + CodeInvalidJSON = "invalid_json" + CodeNotObject = "not_object" + CodeTooLarge = "too_large" + CodeTooDeep = "too_deep" + CodeScriptTag = "script_tag" + CodeTypeUnknown = "type_unknown" + CodeFieldInvalid = "field_invalid" +) + +// Error is a refused structured data value: a code, and for invalid JSON +// the parser's message with its line and column; for a field the field +// name. +type Error struct { + Code string + Detail string + Field string +} + +func (e *Error) Error() string { + if e.Detail != "" { + return "schema: " + e.Code + ": " + e.Detail + } + return "schema: " + e.Code +} + +// Value is the stored seo_schema value (UI-SPEC C17): the editor's type +// ("" for the kind's default), the Service fields and the custom JSON-LD. +type Value struct { + Type string `json:"type"` + Fields ServiceFields `json:"fields"` + Custom string `json:"custom"` +} + +// ParseValue reads a stored or submitted value; "" is the zero value. +func ParseValue(raw string) (Value, error) { + var v Value + if strings.TrimSpace(raw) == "" { + return v, nil + } + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return Value{}, &Error{Code: CodeInvalidJSON, Detail: err.Error()} + } + return v, nil +} + +// scriptTag finds a literal "}`), "Remove the script tag"}, + {"type", schemaJSON(t, "BlogPosting", nil, ""), "This schema type is not available"}, + {"price", schemaJSON(t, "Service", map[string]string{"price": "abc"}, ""), "Enter a price such as 150"}, + {"currency", schemaJSON(t, "Service", map[string]string{"currency": "ZLOTY"}, ""), "three-letter currency code"}, + } { + t.Run(c.name, func(t *testing.T) { refusal(t, save(c.value), "seo_schema", c.message) }) + } + + page := siteURL + "/pages/about" + custom := `[{"@id": "` + page + `#webpage", "name": "Custom name", "about": "a>'schema' FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? AND locale = 'en'`, pagesModel, id).Scan(&stored).Error; err != nil { + t.Fatal(err) + } + if !strings.Contains(stored, `\u003c`) || strings.Contains(stored, "a>'schema', '') FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? AND locale = 'pl'`, pagesModel, id).Scan(&pl).Error; err != nil || pl != stored { + t.Fatalf("pl row schema = %q err=%v, want the shared value", pl, err) + } + // The SEO tab loads the stored value; an empty value resets it. + if show := env.expect(t, http.StatusOK, http.MethodGet, form, token, nil).Body.String(); !strings.Contains(show, `"seo_schema":"{`) { + t.Fatalf("show lacks seo_schema: %s", show) + } + env.expect(t, http.StatusOK, http.MethodPut, form, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true, "seo_schema": ""}) + if nodes := graphNodes(t, env.resolve(t, "/pages/about")); nodes["WebPage"] == nil || nodes["FAQPage"] != nil { + t.Fatalf("reset graph = %v", nodes) + } +} + +// TestGraphPerKind covers the node of every kind (D-43, D-94): a +// collection, a profile, a registry offer with Service fields, a registry +// contact page, and the home page's SearchAction. +func TestGraphPerKind(t *testing.T) { + env := bootBlog(t, "SUMMER_GOLEM15__SEO__SEARCH_URL=/szukaj?q={search_term_string}") + token := env.admin(t, true) + + nodes := graphNodes(t, env.resolve(t, "/blog/category/recipes")) + if p := nodes["CollectionPage"]; p == nil || idOf(p["mainEntity"]) != siteURL+"/blog/category/recipes#itemlist" { + t.Fatalf("collection = %v", nodes) + } + nodes = graphNodes(t, env.resolve(t, "/blog/author/jan")) + if p, person := nodes["ProfilePage"], nodes["Person"]; p == nil || person == nil || idOf(p["mainEntity"]) != siteURL+"/blog/author/jan#profile" || person["@id"] != siteURL+"/blog/author/jan#profile" || person["name"] != "Jan Kowalski" { + t.Fatalf("profile = %v", nodes) + } + + api := "/_seo/api/v1/pages" + env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"url": "/oferta", "title": "Warsztaty", "kind": "offer"}) + env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"url": "/kontakt", "title": "Kontakt", "kind": "page"}) + set := func(url, value string) { + t.Helper() + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": url, "values": map[string]any{"schema": value}}) + } + set("/oferta", schemaJSON(t, "", map[string]string{"serviceType": "Warsztaty grzybowe", "areaServed": "Warszawa, Kraków", "price": "149,99", "currency": "pln"}, "")) + nodes = graphNodes(t, env.resolve(t, "/oferta")) + svc := nodes["Service"] + if svc == nil || idOf(svc["provider"]) != siteURL+"/#organization" || asJSON(t, svc["areaServed"]) != `["Warszawa","Kraków"]` || svc["serviceType"] != "Warsztaty grzybowe" { + t.Fatalf("service = %v", nodes) + } + if got := asJSON(t, svc["offers"]); got != `{"@type":"Offer","price":"149.99","priceCurrency":"PLN","url":"https://www.example.test/oferta"}` { + t.Fatalf("offer = %s", got) + } + set("/kontakt", schemaJSON(t, "ContactPage", nil, "")) + nodes = graphNodes(t, env.resolve(t, "/kontakt")) + if p := nodes["ContactPage"]; p == nil || idOf(p["mainEntity"]) != siteURL+"/#organization" { + t.Fatalf("contact = %v", nodes) + } + // The API refuses what the SEO tab refuses; a rule-only URL takes no + // structured data. + rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/kontakt", "values": map[string]any{"schema": schemaJSON(t, "NewsArticle", nil, "")}}) + refusal(t, rec, "schema", "This schema type is not available") + + home := graphNodes(t, env.resolve(t, "/")) + action, _ := home["WebSite"]["potentialAction"].(map[string]any) + if action["@type"] != "SearchAction" || asJSON(t, action["target"]) != `{"@type":"EntryPoint","urlTemplate":"https://www.example.test/szukaj?q={search_term_string}"}` { + t.Fatalf("SearchAction = %v", home["WebSite"]) + } + for _, n := range []string{"Product", "Offer"} { + if home[n] != nil { + t.Fatalf("e-commerce node %s", n) + } + } +} + +// TestGraphTool covers GET /_seo/api/v1/graph (seo_get_graph, D-46, +// D-10, T-33-27): the graph and findings, the unsaved schema preview, the +// permission, and primary_parent through seo_set_url_seo and apply_batch. +func TestGraphTool(t *testing.T) { + env := bootBlog(t) + token := env.admin(t, true) + other := env.admin(t, false) + target := "/_seo/api/v1/graph?url=" + urlQueryEscape("/blog/first-post") + env.expect(t, http.StatusForbidden, http.MethodGet, target, other, nil) + env.expect(t, http.StatusUnauthorized, http.MethodGet, target, "", nil) + + var answer struct { + Locale string `json:"locale"` + URL *string `json:"url"` + Graph map[string]any `json:"graph"` + Nodes []struct { + ID string `json:"id"` + Type string `json:"type"` + Rules bool `json:"rules"` + Findings []struct { + Level string `json:"level"` + Property string `json:"property"` + } `json:"findings"` + } `json:"nodes"` + } + body := env.expect(t, http.StatusOK, http.MethodGet, target, token, nil).Body.Bytes() + if err := json.Unmarshal(body, &answer); err != nil || answer.Locale != "en" || answer.URL == nil || *answer.URL != siteURL+"/blog/first-post" || answer.Graph["@context"] != "https://schema.org" { + t.Fatalf("graph answer = %s err=%v", body, err) + } + rules := map[string]bool{} + findings := map[string]string{} + for _, n := range answer.Nodes { + rules[n.Type] = n.Rules + for _, f := range n.Findings { + findings[n.Type+"."+f.Property] = f.Level + } + } + if !rules["BlogPosting"] || rules["WebPage"] || !rules["VideoObject"] || !rules["BreadcrumbList"] { + t.Fatalf("rules = %v", rules) + } + if findings["Organization.logo"] != "recommended" || len(findings) != 1 { + t.Fatalf("findings = %v (%s)", findings, body) + } + + // The unsaved schema preview. + preview := target + "&schema=" + urlQueryEscape(schemaJSON(t, "NewsArticle", nil, "")) + if b := env.expect(t, http.StatusOK, http.MethodGet, preview, token, nil).Body.String(); !strings.Contains(b, `"type":"NewsArticle"`) { + t.Fatalf("preview = %s", b) + } + refusal(t, env.call(t, http.MethodGet, target+"&schema="+urlQueryEscape(schemaJSON(t, "ContactPage", nil, "")), token, nil), "schema", "This schema type is not available") + if b := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/graph?url=%2Fnowhere", token, nil).Body.String(); !jsonContains(b, `"url":null`) { + t.Fatalf("missing page = %s", b) + } + + // The validator on its own: missing image, uploadDate and address. + resetBlog() + blogMu.Lock() + blogRecords[postTypeKey][0].PublishedAt = nil + blogRecords[postTypeKey][0].Locales[0].Images = nil + blogRecords[postTypeKey][0].Locales[0].Image = "" + blogMu.Unlock() + body = env.expect(t, http.StatusOK, http.MethodGet, target, token, nil).Body.Bytes() + _ = json.Unmarshal(body, &answer) + findings = map[string]string{} + for _, n := range answer.Nodes { + for _, f := range n.Findings { + findings[n.Type+"."+f.Property] = f.Level + } + } + if findings["BlogPosting.image"] != "recommended" || findings["VideoObject.uploadDate"] != "required" || findings["BlogPosting.datePublished"] != "recommended" { + t.Fatalf("findings = %v", findings) + } + resetBlog() + + // primary_parent through seo_set_url_seo (every locale row) and + // apply_batch with dry_run. + rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/blog/first-post", "values": map[string]any{"primary_parent": "acme-section:9"}}) + refusal(t, rec, "primary_parent", "") + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/blog/first-post", "values": map[string]any{"primary_parent": "acme-section:2"}}) + var rows int64 + if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE model_type = ? AND record_id = '1' AND data->>'primary_parent' = 'acme-section:2'`, postModel).Scan(&rows).Error; err != nil || rows != 2 { + t.Fatalf("rows with primary_parent = %d err=%v, want both locales", rows, err) + } + if got := crumbNames(env.resolve(t, "/pl/blog/pierwszy-wpis")); got != "Strona główna > Pierwszy wpis" { + t.Fatalf("pl trail = %s (Mushrooms has no pl page)", got) + } + batch := map[string]any{"dry_run": true, "changes": []map[string]any{{"op": "set_seo", "url": "/blog/first-post", "values": map[string]any{"primary_parent": "acme-section:1"}}}} + env.expect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", token, batch) + if got := crumbNames(env.resolve(t, "/blog/first-post")); got != "Home > Mushrooms > First post" { + t.Fatalf("trail after a dry run = %s", got) + } + batch["dry_run"] = false + env.expect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", token, batch) + if got := crumbNames(env.resolve(t, "/blog/first-post")); got != "Home > Recipes > First post" { + t.Fatalf("trail after the batch = %s", got) + } + + // P1: a stored primary parent the record no longer has. + blogMu.Lock() + blogRecords[postTypeKey][0].Parents = []pact.ContentRef{{Type: categoryTypeKey, ID: "2"}} + blogMu.Unlock() + defer resetBlog() + status, err := env.seoPlugin(t).Service().RecordStatus(t.Context(), postTypeKey, "1") + if err != nil { + t.Fatal(err) + } + found := false + for _, c := range status.Callouts { + found = found || strings.Contains(c.Title, "The primary category “Recipes” is no longer assigned") + } + if !found { + t.Fatalf("P1 callouts = %v", status.Callouts) + } + if got := crumbNames(env.resolve(t, "/blog/first-post")); got != "Home > Mushrooms > First post" { + t.Fatalf("trail with a gone primary = %s", got) + } + eff, err := env.seoPlugin(t).Service().RecordEffective(t.Context(), postTypeKey, "1") + if err != nil { + t.Fatal(err) + } + rows2 := map[string]string{} + for _, r := range eff.Locales[0].Rows { + rows2[r.Property] = r.Value + } + if rows2["Schema type"] != "BlogPosting" || rows2["Breadcrumb trail"] != "Home › Mushrooms › First post" { + t.Fatalf("P2 rows = %v", rows2) + } +}