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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user