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