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