feat: graph validation and custom JSON-LD
- 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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 <type>:<id>.
|
||||
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"}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
285
classes/schema/custom.go
Normal file
285
classes/schema/custom.go
Normal file
@@ -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 <script in any letter case.
|
||||
var scriptTag = regexp.MustCompile(`(?i)<\s*/?\s*script`)
|
||||
|
||||
// currencyPattern is an ISO 4217 code.
|
||||
var currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`)
|
||||
|
||||
// Normalize checks a submitted value for kind and returns it in its
|
||||
// stored form: the type checked against the kind's list, Service fields
|
||||
// kept only for Service (price and currency checked), the custom JSON-LD
|
||||
// decoded with the C17 caps and re-serialised, never echoed as typed. The
|
||||
// empty value stays "".
|
||||
func Normalize(kind, variant, raw string) (string, Value, error) {
|
||||
v, err := ParseValue(raw)
|
||||
if err != nil {
|
||||
return "", Value{}, err
|
||||
}
|
||||
v.Type = strings.TrimSpace(v.Type)
|
||||
if !Allowed(kind, v.Type) {
|
||||
return "", Value{}, &Error{Code: CodeTypeUnknown}
|
||||
}
|
||||
if EffectiveType(kind, variant, v.Type) == TypeService {
|
||||
f := v.Fields
|
||||
f.ServiceType, f.AreaServed = strings.TrimSpace(f.ServiceType), strings.TrimSpace(f.AreaServed)
|
||||
f.Price, f.Currency = strings.TrimSpace(f.Price), strings.ToUpper(strings.TrimSpace(f.Currency))
|
||||
if f.Price != "" {
|
||||
if NormalizePrice(f.Price) == "" {
|
||||
return "", Value{}, &Error{Code: CodeFieldInvalid, Field: "price"}
|
||||
}
|
||||
f.Price = NormalizePrice(f.Price)
|
||||
}
|
||||
if f.Currency != "" && !currencyPattern.MatchString(f.Currency) {
|
||||
return "", Value{}, &Error{Code: CodeFieldInvalid, Field: "currency"}
|
||||
}
|
||||
v.Fields = f
|
||||
} else {
|
||||
v.Fields = ServiceFields{}
|
||||
}
|
||||
v.Custom = strings.TrimSpace(v.Custom)
|
||||
if v.Custom != "" {
|
||||
nodes, err := DecodeCustom(v.Custom)
|
||||
if err != nil {
|
||||
return "", Value{}, err
|
||||
}
|
||||
var out any = nodes
|
||||
if len(nodes) == 1 {
|
||||
out = nodes[0]
|
||||
}
|
||||
pretty, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return "", Value{}, &Error{Code: CodeInvalidJSON, Detail: err.Error()}
|
||||
}
|
||||
v.Custom = string(pretty)
|
||||
}
|
||||
if v.Type == "" && v.Custom == "" && v.Fields == (ServiceFields{}) {
|
||||
return "", v, nil
|
||||
}
|
||||
stored, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", Value{}, err
|
||||
}
|
||||
return string(stored), v, nil
|
||||
}
|
||||
|
||||
// position turns a byte offset of text into its line and column.
|
||||
func position(text string, offset int64) (int, int) {
|
||||
if offset > int64(len(text)) {
|
||||
offset = int64(len(text))
|
||||
}
|
||||
line, col := 1, 1
|
||||
for _, r := range text[:offset] {
|
||||
if r == '\n' {
|
||||
line, col = line+1, 1
|
||||
} else {
|
||||
col++
|
||||
}
|
||||
}
|
||||
return line, col
|
||||
}
|
||||
|
||||
// depth is the nesting depth of a decoded JSON value (an object or a list
|
||||
// counts one level).
|
||||
func depth(v any) int {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
d := 0
|
||||
for _, c := range x {
|
||||
d = max(d, depth(c))
|
||||
}
|
||||
return d + 1
|
||||
case []any:
|
||||
d := 0
|
||||
for _, c := range x {
|
||||
d = max(d, depth(c))
|
||||
}
|
||||
return d + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// DecodeCustom decodes custom JSON-LD (UI-SPEC C17): an object or a list
|
||||
// of objects, at most 16 KB and 8 levels deep, without a literal <script.
|
||||
// Invalid JSON is refused with the parser's line and column.
|
||||
func DecodeCustom(text string) ([]map[string]any, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if len(text) > MaxCustomBytes {
|
||||
return nil, &Error{Code: CodeTooLarge}
|
||||
}
|
||||
if scriptTag.MatchString(text) {
|
||||
return nil, &Error{Code: CodeScriptTag}
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(text))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
var syn *json.SyntaxError
|
||||
offset := dec.InputOffset()
|
||||
if errors.As(err, &syn) {
|
||||
offset = syn.Offset
|
||||
}
|
||||
line, col := position(text, offset)
|
||||
msg := err.Error()
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
|
||||
msg = "unexpected end of JSON input"
|
||||
}
|
||||
return nil, &Error{Code: CodeInvalidJSON, Detail: fmt.Sprintf("line %d, column %d: %s", line, col, msg)}
|
||||
}
|
||||
if dec.More() {
|
||||
line, col := position(text, dec.InputOffset())
|
||||
return nil, &Error{Code: CodeInvalidJSON, Detail: fmt.Sprintf("line %d, column %d: unexpected data after the JSON value", line, col)}
|
||||
}
|
||||
if depth(v) > MaxCustomDepth {
|
||||
return nil, &Error{Code: CodeTooDeep}
|
||||
}
|
||||
var out []map[string]any
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
out = []map[string]any{x}
|
||||
case []any:
|
||||
if len(x) == 0 {
|
||||
return nil, &Error{Code: CodeNotObject}
|
||||
}
|
||||
for _, item := range x {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, &Error{Code: CodeNotObject}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
default:
|
||||
return nil, &Error{Code: CodeNotObject}
|
||||
}
|
||||
for _, n := range out {
|
||||
delete(n, "@context")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Merged is one generated node a custom node was merged into, with the
|
||||
// generated properties the custom values replace.
|
||||
type Merged struct {
|
||||
ID string
|
||||
Type string
|
||||
Props []string
|
||||
}
|
||||
|
||||
// Merge adds custom nodes to a graph (UI-SPEC C17): a node whose @id is a
|
||||
// generated node's is merged into it, custom values first; any other node
|
||||
// is appended. It answers the merges that replaced generated values.
|
||||
func Merge(graph map[string]any, custom []map[string]any) (map[string]any, []Merged) {
|
||||
nodes := Nodes(graph)
|
||||
byID := map[string]map[string]any{}
|
||||
for _, n := range nodes {
|
||||
if id, _ := n["@id"].(string); id != "" {
|
||||
byID[id] = n
|
||||
}
|
||||
}
|
||||
var merged []Merged
|
||||
for _, c := range custom {
|
||||
id, _ := c["@id"].(string)
|
||||
target, ok := byID[id]
|
||||
if id == "" || !ok {
|
||||
nodes = append(nodes, c)
|
||||
if id != "" {
|
||||
byID[id] = c
|
||||
}
|
||||
continue
|
||||
}
|
||||
var props []string
|
||||
for k, v := range c {
|
||||
if k == "@id" {
|
||||
continue
|
||||
}
|
||||
if old, had := target[k]; had && !sameJSON(old, v) {
|
||||
props = append(props, k)
|
||||
}
|
||||
target[k] = v
|
||||
}
|
||||
if len(props) > 0 {
|
||||
sort.Strings(props)
|
||||
merged = append(merged, Merged{ID: id, Type: NodeType(target), Props: props})
|
||||
}
|
||||
}
|
||||
list := make([]any, len(nodes))
|
||||
for i, n := range nodes {
|
||||
list[i] = n
|
||||
}
|
||||
out := map[string]any{"@context": Context, "@graph": list}
|
||||
return out, merged
|
||||
}
|
||||
|
||||
// sameJSON reports whether two values encode alike.
|
||||
func sameJSON(a, b any) bool {
|
||||
x, err1 := json.Marshal(a)
|
||||
y, err2 := json.Marshal(b)
|
||||
return err1 == nil && err2 == nil && bytes.Equal(x, y)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -111,6 +112,19 @@ type structuredIn struct {
|
||||
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.
|
||||
@@ -139,6 +153,12 @@ func (s *Service) graphInput(ctx context.Context, db *gorm.DB, settings models.S
|
||||
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
|
||||
@@ -214,14 +234,34 @@ func (s *Service) structuredData(ctx context.Context, db *gorm.DB, settings mode
|
||||
name = res.SEO.Title
|
||||
}
|
||||
res.Breadcrumbs = s.Trail(ctx, db, settings, in.rec, entryData(in.entry, DataPrimaryParent), in.locale, in.path, name)
|
||||
graph, err := schema.Build(ctx, s.graphInput(ctx, db, settings, in, res.Breadcrumbs))
|
||||
graph, merged, err := s.buildGraph(ctx, db, settings, in, res.Breadcrumbs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.JSONLD = graph
|
||||
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.
|
||||
@@ -268,3 +308,135 @@ func (s *Service) PublisherPreview(ctx context.Context) (PublisherPreview, error
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
48
controllers/api/graph.go
Normal file
48
controllers/api/graph.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
)
|
||||
|
||||
// GetGraphInput is the query of GET /_seo/api/v1/graph.
|
||||
type GetGraphInput struct {
|
||||
URL string `json:"url" query:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"`
|
||||
Schema *string `json:"schema,omitempty" query:"schema" jsonschema:"an unsaved structured data value (the seo_set_url_seo schema string) to preview instead of the stored one; omitted: the stored value"`
|
||||
}
|
||||
|
||||
// Graph serves GET /_seo/api/v1/graph?url=&schema= (D-46, D-10): the
|
||||
// JSON-LD @graph the resolve answer of the URL carries, built with the
|
||||
// given unsaved schema value when sent, and every node with its missing
|
||||
// required and recommended properties (rules false for a type Google has
|
||||
// no rules for). It answers {locale, url, graph, nodes}; url and graph are
|
||||
// null for an address that is not an ok page. 422 for a URL that is not a
|
||||
// path or http(s) URL, or for a schema value the SEO tab would refuse.
|
||||
func Graph(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
q := r.URL.Query()
|
||||
var override *string
|
||||
if q.Has("schema") {
|
||||
v := q.Get("schema")
|
||||
override = &v
|
||||
}
|
||||
answer, res, err := svc.GraphOfURL(ctx, q.Get("url"), override)
|
||||
if errors.Is(err, classes.ErrBadURL) {
|
||||
return 0, nil, Fail(http.StatusUnprocessableEntity, err.Error())
|
||||
}
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if override != nil && res.TypeKey != "" {
|
||||
if typ, ok := svc.Registry().ContentType(res.TypeKey); ok && typ.Content != nil {
|
||||
if err := svc.CheckSchema(ctx, typ.Content.Kind, res.TypeKey, res.RecordID, *override); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return http.StatusOK, answer, nil
|
||||
})
|
||||
}
|
||||
@@ -20,6 +20,8 @@ type SEOValues struct {
|
||||
OGTitle *string `json:"og_title,omitempty" jsonschema:"the social title"`
|
||||
OGDescription *string `json:"og_description,omitempty" jsonschema:"the social description"`
|
||||
OGImage *string `json:"og_image,omitempty" jsonschema:"the social image: a path or an absolute URL"`
|
||||
PrimaryParent *string `json:"primary_parent,omitempty" jsonschema:"records only: the primary category of the breadcrumb trail and the article section, one of the record's parents as <type>:<id> (seo_get_url_seo and the graph tool name them); empty for the first parent. Shared by every locale"`
|
||||
Schema *string `json:"schema,omitempty" jsonschema:"records only: the structured data as a JSON string {\"type\": schema type or empty for the default, \"fields\": {serviceType, areaServed, price, currency} for Service, \"custom\": custom JSON-LD text (an object or a list, at most 16 KB, no script tag; a node with a generated @id is merged into it)}; empty resets it. Shared by every locale"`
|
||||
}
|
||||
|
||||
// Map returns the values that are set, keyed by column.
|
||||
@@ -29,6 +31,7 @@ func (v SEOValues) Map() map[string]string {
|
||||
"title": v.Title, "description": v.Description, "keyphrase": v.Keyphrase,
|
||||
"canonical": v.Canonical, "robots": v.Robots, "og_title": v.OGTitle,
|
||||
"og_description": v.OGDescription, "og_image": v.OGImage,
|
||||
"primary_parent": v.PrimaryParent, "schema": v.Schema,
|
||||
} {
|
||||
if p != nil {
|
||||
out[column] = *p
|
||||
|
||||
@@ -3,6 +3,7 @@ package seo
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
@@ -34,7 +35,7 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||
}
|
||||
out = append(out, pact.AdminActionExtension{
|
||||
Controller: typ.Content.Controller,
|
||||
Form: p.seoTab(typ.Key, typ.Label),
|
||||
Form: p.seoTab(typ.Key, typ.Content.Kind),
|
||||
})
|
||||
}
|
||||
if form := p.typeSettingsTabs(types); form != nil {
|
||||
@@ -205,16 +206,60 @@ fields:
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
`
|
||||
|
||||
// seoTab is the form extension of content type key.
|
||||
func (p *Plugin) seoTab(key, label string) *pact.AdminFormExtension {
|
||||
// seoTabFor is the SEO tab's fields document of a content type (UI-SPEC
|
||||
// C16): seoTabYAML with the primary category after seo_robots for article
|
||||
// types (whose records have parents, P33-23), and the structured data
|
||||
// field (E8) and its preview (E9) after seo_og_image.
|
||||
func seoTabFor(key, kind string) string {
|
||||
doc := seoTabYAML
|
||||
quoted := strconv.Quote(key)
|
||||
if kind == pact.ContentKindArticle {
|
||||
doc = strings.Replace(doc, " seo_og_title:\n", ` seo_primary_parent:
|
||||
label: golem15.seo::lang.field.primary_category
|
||||
comment: golem15.seo::lang.field.primary_category_comment
|
||||
type: seoparent
|
||||
contentType: `+quoted+`
|
||||
span: full
|
||||
context: [create, update]
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_og_title:
|
||||
`, 1)
|
||||
}
|
||||
return strings.Replace(doc, " seo_effective:\n", ` seo_schema:
|
||||
label: golem15.seo::lang.field.schema
|
||||
comment: golem15.seo::lang.field.schema_comment
|
||||
type: seoschema
|
||||
kind: `+strconv.Quote(kind)+`
|
||||
contentType: `+quoted+`
|
||||
span: full
|
||||
context: [create, update]
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_graph:
|
||||
label: golem15.seo::lang.field.graph
|
||||
type: widget
|
||||
widget: golem15-seo-graph
|
||||
action: seo_graph
|
||||
fill: [seo_schema]
|
||||
span: full
|
||||
context: update
|
||||
tab: golem15.seo::lang.tab.seo
|
||||
seo_effective:
|
||||
`, 1)
|
||||
}
|
||||
|
||||
// seoTab is the form extension of content type key of kind.
|
||||
func (p *Plugin) seoTab(key, kind string) *pact.AdminFormExtension {
|
||||
return &pact.AdminFormExtension{
|
||||
FieldsYAML: []byte(seoTabYAML),
|
||||
FieldsYAML: []byte(seoTabFor(key, kind)),
|
||||
Permissions: []string{controllers.PermissionManage},
|
||||
Visible: func(ctx context.Context) bool {
|
||||
return p.injected(ctx, key)
|
||||
},
|
||||
Dir: "extension",
|
||||
Actions: []pact.AdminAction{classes.RecordSnippetAction(p.Service, key, "golem15.seo::lang.snippet.action", controllers.PermissionManage)},
|
||||
Dir: "extension",
|
||||
Actions: []pact.AdminAction{
|
||||
classes.RecordSnippetAction(p.Service, key, "golem15.seo::lang.snippet.action", controllers.PermissionManage),
|
||||
classes.RecordGraphAction(p.Service, key, "golem15.seo::lang.graph.action", controllers.PermissionManage),
|
||||
},
|
||||
PartialData: func(ctx context.Context, name string, record any) (any, error) {
|
||||
return p.seoTabPartial(ctx, key, name, record)
|
||||
},
|
||||
@@ -289,6 +334,13 @@ func (p *Plugin) loadSEOTab(ctx context.Context, key string, in pact.AdminFormLo
|
||||
}
|
||||
out[classes.FieldPrefix+column] = perLocale
|
||||
}
|
||||
out[classes.FieldPrefix+classes.DataSchema] = classes.SharedData(entries, classes.DataSchema)
|
||||
if typ.Content.Kind == pact.ContentKindArticle {
|
||||
out[classes.FieldPrefix+classes.DataPrimaryParent] = map[string]any{
|
||||
"selected": classes.SharedData(entries, classes.DataPrimaryParent),
|
||||
"options": svc.ParentOptions(ctx, key, in.ID, ""),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -307,7 +359,7 @@ func (p *Plugin) saveSEOTab(ctx context.Context, key string, in pact.AdminFormSa
|
||||
}
|
||||
values := classes.RecordValues{}
|
||||
refusals := pact.AdminFormErrors{}
|
||||
for _, column := range classes.EntryColumns {
|
||||
for _, column := range append(append([]string{}, classes.EntryColumns...), classes.DataColumns...) {
|
||||
raw, ok := in.Values[classes.FieldPrefix+column]
|
||||
if !ok {
|
||||
continue
|
||||
@@ -349,7 +401,21 @@ func (p *Plugin) saveSEOTab(ctx context.Context, key string, in pact.AdminFormSa
|
||||
if _, _, err := svc.SaveRecord(ctx, in.DB, classes.RecordWrite{Type: typ, ID: in.ID, Values: values}); err != nil {
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
}
|
||||
return pact.AdminFormSaveResult{Notices: classes.AddressNotices(typ, in.Changes, moved, "seo_status")}, nil
|
||||
notices := classes.AddressNotices(typ, in.Changes, moved, "seo_status")
|
||||
if _, sent := values[classes.DataSchema]; sent {
|
||||
merged, err := svc.SchemaMerges(ctx, key, in.ID)
|
||||
if err != nil {
|
||||
return pact.AdminFormSaveResult{}, err
|
||||
}
|
||||
if len(merged) > 0 {
|
||||
if notices == nil {
|
||||
notices = map[string][]pact.FormFieldNotice{}
|
||||
}
|
||||
field := classes.FieldPrefix + classes.DataSchema
|
||||
notices[field] = append(notices[field], merged...)
|
||||
}
|
||||
}
|
||||
return pact.AdminFormSaveResult{Notices: notices}, nil
|
||||
}
|
||||
|
||||
// localeMap reads a submitted value as locale -> text: a translatable
|
||||
|
||||
@@ -36,6 +36,12 @@ field:
|
||||
og_title: Social title
|
||||
og_description: Social description
|
||||
og_image: Social image
|
||||
primary_category: "Primary category (breadcrumb trail)"
|
||||
primary_category_comment: "Sets the breadcrumb trail and the article section. Default: the first category."
|
||||
primary_category_default: "Default (first category)"
|
||||
schema: "Structured data"
|
||||
schema_comment: "The schema.org type of this page in its JSON-LD graph. Shared by all languages."
|
||||
graph: "Structured data preview"
|
||||
robots:
|
||||
llms_enabled: Serve llms.txt
|
||||
llms_enabled_comment: Lists the site's indexable pages from the sitemaps at /llms.txt, for AI tools that read it.
|
||||
@@ -73,6 +79,7 @@ status:
|
||||
canonical_noindex: The canonical URL “:url” is set to noindex, so search engines may ignore both pages.
|
||||
canonical_redirected: The canonical URL “:url” redirects to “:target”. Point the canonical to the final address.
|
||||
staging: On this environment every page is served with noindex and robots.txt blocks everything (non-production lock).
|
||||
primary_gone: "The primary category “:name” is no longer assigned, so the first category is used. Choose another one below."
|
||||
settings:
|
||||
placeholders: Placeholders
|
||||
placeholders_code: Placeholder
|
||||
@@ -317,6 +324,8 @@ effective:
|
||||
source_content: Content
|
||||
source_default: Site default
|
||||
no_url: This language has no address yet. Fill in its slug and save.
|
||||
schema: "Schema type"
|
||||
trail: "Breadcrumb trail"
|
||||
placeholder:
|
||||
title: The content's title
|
||||
description: The content's own description or excerpt
|
||||
@@ -422,6 +431,7 @@ api:
|
||||
change_not_found: The record this change names does not exist.
|
||||
op_unknown: "Unknown operation “:op”."
|
||||
conflict_noindex_published: "“:url” is a published page listed in the sitemaps; noindex removes it from search results."
|
||||
record_only: "Only the SEO of a record has this value."
|
||||
transfer:
|
||||
title: Import and export
|
||||
delimiter: CSV delimiter
|
||||
@@ -497,3 +507,16 @@ graph:
|
||||
recommended: "Missing recommended property “:property” (:type)"
|
||||
no_rules: ":type has no rich-result rules at Google; it is checked as valid schema.org only."
|
||||
all_good: "No problems: every node has its required and recommended properties."
|
||||
schema:
|
||||
invalid_json: "The custom JSON-LD is not valid JSON: :error"
|
||||
not_object: "The custom JSON-LD must be an object or a list of objects."
|
||||
too_large: "The custom JSON-LD may be at most 16 KB."
|
||||
too_deep: "The custom JSON-LD is nested more than 8 levels deep."
|
||||
script_tag: "Remove the script tag and paste only the JSON."
|
||||
type_unknown: "This schema type is not available for :type."
|
||||
field_invalid: ":field: :message"
|
||||
field_price: "Price"
|
||||
field_currency: "Currency"
|
||||
price_invalid: "Enter a price such as 150 or 149.99."
|
||||
currency_invalid: "Use a three-letter currency code, e.g. PLN."
|
||||
merged: "Saved. Your custom JSON-LD replaces :props in the generated :node node."
|
||||
|
||||
@@ -36,6 +36,12 @@ field:
|
||||
og_title: Tytuł w mediach społecznościowych
|
||||
og_description: Opis w mediach społecznościowych
|
||||
og_image: Obraz w mediach społecznościowych
|
||||
primary_category: "Kategoria główna (ścieżka nawigacji)"
|
||||
primary_category_comment: "Ustala ścieżkę nawigacji i sekcję artykułu. Domyślnie: pierwsza kategoria."
|
||||
primary_category_default: "Domyślna (pierwsza kategoria)"
|
||||
schema: "Dane strukturalne"
|
||||
schema_comment: "Typ schema.org tej strony w jej grafie JSON-LD. Wspólny dla wszystkich wersji językowych."
|
||||
graph: "Podgląd danych strukturalnych"
|
||||
robots:
|
||||
llms_enabled: Udostępniaj llms.txt
|
||||
llms_enabled_comment: Wypisuje indeksowalne strony witryny z map witryny pod /llms.txt, dla narzędzi AI, które go czytają.
|
||||
@@ -75,6 +81,7 @@ status:
|
||||
canonical_noindex: Adres kanoniczny „:url” ma ustawione noindex, więc wyszukiwarki mogą zignorować obie strony.
|
||||
canonical_redirected: Adres kanoniczny „:url” przekierowuje na „:target”. Ustaw adres kanoniczny na adres docelowy.
|
||||
staging: W tym środowisku każda strona ma noindex, a robots.txt blokuje wszystko (blokada środowiska nieprodukcyjnego).
|
||||
primary_gone: "Kategoria główna „:name” nie jest już przypisana, więc używana jest pierwsza kategoria. Wybierz inną poniżej."
|
||||
settings:
|
||||
placeholders: Znaczniki
|
||||
placeholders_code: Znacznik
|
||||
@@ -323,6 +330,8 @@ effective:
|
||||
source_content: Treść
|
||||
source_default: Domyślne dla witryny
|
||||
no_url: Ta wersja językowa nie ma jeszcze adresu. Uzupełnij jej slug i zapisz.
|
||||
schema: "Typ schema"
|
||||
trail: "Ścieżka nawigacji"
|
||||
placeholder:
|
||||
title: Tytuł treści
|
||||
description: Własny opis lub zajawka treści
|
||||
@@ -432,6 +441,7 @@ api:
|
||||
change_not_found: Rekord wskazany w tej zmianie nie istnieje.
|
||||
op_unknown: "Nieznana operacja „:op”."
|
||||
conflict_noindex_published: "„:url” to opublikowana strona z map witryny; noindex usunie ją z wyników wyszukiwania."
|
||||
record_only: "Tę wartość ma tylko SEO rekordu."
|
||||
transfer:
|
||||
title: Import i eksport
|
||||
delimiter: Separator CSV
|
||||
@@ -511,3 +521,16 @@ graph:
|
||||
recommended: "Brak zalecanej właściwości „:property” (:type)"
|
||||
no_rules: ":type nie ma reguł wyników z elementami rozszerzonymi w Google; sprawdzana jest tylko poprawność schema.org."
|
||||
all_good: "Brak problemów: każdy węzeł ma wymagane i zalecane właściwości."
|
||||
schema:
|
||||
invalid_json: "Własny JSON-LD nie jest poprawnym JSON-em: :error"
|
||||
not_object: "Własny JSON-LD musi być obiektem lub listą obiektów."
|
||||
too_large: "Własny JSON-LD może mieć najwyżej 16 KB."
|
||||
too_deep: "Własny JSON-LD jest zagnieżdżony głębiej niż 8 poziomów."
|
||||
script_tag: "Usuń znacznik script i wklej sam JSON."
|
||||
type_unknown: "Ten typ schema jest niedostępny dla: :type."
|
||||
field_invalid: ":field: :message"
|
||||
field_price: "Cena"
|
||||
field_currency: "Waluta"
|
||||
price_invalid: "Wpisz cenę, np. 150 lub 149,99."
|
||||
currency_invalid: "Użyj trzyliterowego kodu waluty, np. PLN."
|
||||
merged: "Zapisano. Twój JSON-LD zastępuje :props w wygenerowanym węźle :node."
|
||||
|
||||
11
mcp.go
11
mcp.go
@@ -48,6 +48,17 @@ func (p *Plugin) MCP() pact.MCP {
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
|
||||
{
|
||||
Name: "get_graph",
|
||||
Title: "Get the structured data of a URL",
|
||||
Description: "Shows the JSON-LD @graph a page addressed by URL emits (the nodes the CMS generates, the custom JSON-LD merged in) and every node's missing required and recommended properties per Google's rules; rules false marks a type Google has no rich-result rules for. With schema, previews an unsaved structured data value. Fix through seo_set_url_seo (schema, primary_parent) or seo_update_settings (publisher, local business).",
|
||||
Guard: mcpGuard,
|
||||
Method: http.MethodGet,
|
||||
Path: "/_seo/api/v1/graph",
|
||||
Input: apicontrollers.GetGraphInput{},
|
||||
Permissions: []string{mcpPermission},
|
||||
},
|
||||
|
||||
// URL rules (D-02).
|
||||
{
|
||||
Name: "list_rules",
|
||||
|
||||
@@ -51,6 +51,7 @@ var AdminOperations = []AdminOperation{
|
||||
{ContentController, OpShow, "get_url_seo"},
|
||||
{ContentController, OpExtensionSave, "set_url_seo"},
|
||||
{ContentController, OpWidgetAction + ":seo_snippet", "get_url_seo"},
|
||||
{ContentController, OpWidgetAction + ":seo_graph", "get_graph"},
|
||||
// The social image is set by URL through MCP (uploads are no tool).
|
||||
{ContentController, OpUpload, "set_url_seo"},
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
api := "throttle:" + BucketAPI
|
||||
g.Get("/seo", apicontrollers.GetSEO(p.Service), api)
|
||||
g.Put("/seo", apicontrollers.SetSEO(p.Service), api)
|
||||
g.Get("/graph", apicontrollers.Graph(p.Service), api)
|
||||
g.Get("/rules", apicontrollers.ListRules(p.Service), api)
|
||||
g.Post("/rules", apicontrollers.CreateRule(p.Service), api)
|
||||
g.Get("/rules/{id}", apicontrollers.ShowRule(p.Service), api)
|
||||
|
||||
@@ -370,7 +370,7 @@ func TestSEOTab(t *testing.T) {
|
||||
env := bootEnv(t)
|
||||
token := env.admin(t, true)
|
||||
|
||||
want := "seo_status seo_snippet seo_title seo_description seo_keyphrase seo_canonical seo_robots seo_og_title seo_og_description seo_og_image seo_effective"
|
||||
want := "seo_status seo_snippet seo_title seo_description seo_keyphrase seo_canonical seo_robots seo_og_title seo_og_description seo_og_image seo_schema seo_graph seo_effective"
|
||||
fields := env.formSchema(t, token, "acme/pages/pages")
|
||||
var seoFields []string
|
||||
for _, f := range fields {
|
||||
|
||||
@@ -598,3 +598,264 @@ func TestPagedAlternates(t *testing.T) {
|
||||
t.Fatalf("paged alternates = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// schemaJSON encodes a seo_schema value.
|
||||
func schemaJSON(t *testing.T, typ string, fields map[string]string, custom string) string {
|
||||
t.Helper()
|
||||
return asJSON(t, map[string]any{"type": typ, "fields": fields, "custom": custom})
|
||||
}
|
||||
|
||||
// TestCustomJSONLD covers E8's server rules (UI-SPEC C17, D-44, T-33-24):
|
||||
// every refusal answers 422 with its schema.* copy, a custom node with a
|
||||
// generated @id merges into it with the schema.merged notice, a new node
|
||||
// is appended, and the stored value is re-serialised.
|
||||
func TestCustomJSONLD(t *testing.T) {
|
||||
env := bootBlog(t)
|
||||
token := env.admin(t, true)
|
||||
id := env.createPage(t, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true})
|
||||
findField(t, env.formSchema(t, token, "acme/pages/pages"), "seo_schema")
|
||||
findField(t, env.formSchema(t, token, "acme/pages/pages"), "seo_graph")
|
||||
form := adminAPI("/acme/pages/pages/" + id)
|
||||
save := func(value string) *httptest.ResponseRecorder {
|
||||
return env.call(t, http.MethodPut, form, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true, "seo_schema": value})
|
||||
}
|
||||
deep := strings.Repeat(`{"a":`, 9) + "1" + strings.Repeat("}", 9)
|
||||
for _, c := range []struct{ name, value, message string }{
|
||||
{"invalid", schemaJSON(t, "", nil, `{"@type": "Thing",`), "The custom JSON-LD is not valid JSON: line 1, column"},
|
||||
{"not an object", schemaJSON(t, "", nil, `42`), "must be an object or a list of objects"},
|
||||
{"list of scalars", schemaJSON(t, "", nil, `[1, 2]`), "must be an object or a list of objects"},
|
||||
{"too large", schemaJSON(t, "", nil, `{"description": "`+strings.Repeat("x", 17000)+`"}`), "may be at most 16 KB"},
|
||||
{"too deep", schemaJSON(t, "", nil, deep), "nested more than 8 levels deep"},
|
||||
{"script", schemaJSON(t, "", nil, `{"name": "</script><script>alert(1)</script>"}`), "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<b"}, {"@id": "` + page + `#faq", "@type": "FAQPage", "name": "FAQ"}]`
|
||||
rec := save(schemaJSON(t, "AboutPage", nil, custom))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var merged string
|
||||
for _, n := range saveNotices(t, rec.Body.Bytes()) {
|
||||
if n.Field == "seo_schema" {
|
||||
merged = n.Message
|
||||
}
|
||||
}
|
||||
if merged != "Saved. Your custom JSON-LD replaces about, name in the generated AboutPage node." {
|
||||
t.Fatalf("merge notice = %q (%s)", merged, rec.Body.String())
|
||||
}
|
||||
nodes := graphNodes(t, env.resolve(t, "/pages/about"))
|
||||
if p := nodes["AboutPage"]; p == nil || p["name"] != "Custom name" || p["about"] != "a<b" || idOf(p["isPartOf"]) != siteURL+"/#website" {
|
||||
t.Fatalf("merged page node = %v", nodes)
|
||||
}
|
||||
if faq := nodes["FAQPage"]; faq == nil || faq["@id"] != page+"#faq" {
|
||||
t.Fatalf("appended node = %v", nodes)
|
||||
}
|
||||
var stored string
|
||||
if err := env.db.Raw(`SELECT data->>'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<b") || !strings.Contains(stored, `"type":"AboutPage"`) {
|
||||
t.Fatalf("stored value = %s, want re-serialised JSON with < escaped", stored)
|
||||
}
|
||||
var pl string
|
||||
if err := env.db.Raw(`SELECT COALESCE(data->>'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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user