Files
sm-seo-plugin/classes/partials.go
Jakub Zych 17da132b00 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.
2026-11-04 14:33:10 +01:00

430 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package classes
import (
"context"
"fmt"
"net/url"
"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"
)
// The view models of the SEO partials. They hold text only (translated in
// the request locale), never a GORM model; html/template escapes every
// value.
// Callout is one P1 callout: a warning, or a plain note when Plain is
// set (the sitemap status).
type Callout struct {
Title string
Text string
Plain bool
}
// StatusView is the P1 seo_status view model.
type StatusView struct {
Callouts []Callout
}
// EffectiveRow is one P2 row: a property, its effective value and source.
type EffectiveRow struct {
Property string
Value string
Source string
Mono bool
}
// EffectiveLocale is the P2 table of one locale.
type EffectiveLocale struct {
Heading string
NoURL bool
NoURLText string
Rows []EffectiveRow
}
// EffectiveView is the P2 seo_effective view model.
type EffectiveView struct {
Staging bool
StagingText string
PropertyLabel string
ValueLabel string
SourceLabel string
Locales []EffectiveLocale
}
// PlaceholderRow is one P3 row.
type PlaceholderRow struct {
Code string
Meaning string
}
// PlaceholdersView is the P3 placeholders view model.
type PlaceholdersView struct {
Caption string
CodeLabel string
MeaningLabel string
Rows []PlaceholderRow
}
// PhraseCtx translates key with params in the request locale; without a
// translator it returns the key.
func (s *Service) PhraseCtx(ctx context.Context, key string, params map[string]string) string {
tr := s.translator()
if tr == nil {
return key
}
return tr.Get(ctx, key, params)
}
// effectiveProperties are the P2 rows in order: value name, label key,
// monospace.
var effectiveProperties = []struct {
name string
label string
mono bool
}{
{"title", "golem15.seo::lang.field.title", false},
{"description", "golem15.seo::lang.field.description", false},
{"canonical", "golem15.seo::lang.field.canonical", true},
{"robots", "golem15.seo::lang.field.robots", false},
{"og_title", "golem15.seo::lang.field.og_title", false},
{"og_description", "golem15.seo::lang.field.og_description", false},
{"og_image", "golem15.seo::lang.field.og_image", true},
}
// EffectiveRows turns a resolve answer into P2 rows.
func (s *Service) EffectiveRows(ctx context.Context, res Result) []EffectiveRow {
if res.SEO == nil {
return nil
}
values := map[string]string{
"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)+2)
for _, p := range effectiveProperties {
out = append(out, EffectiveRow{
Property: s.PhraseCtx(ctx, p.label, nil),
Value: values[p.name],
Source: s.PhraseCtx(ctx, "golem15.seo::lang.effective.source_"+res.Sources[p.name], nil),
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{
Staging: s.Staging(),
StagingText: s.PhraseCtx(ctx, "golem15.seo::lang.status.staging", nil),
PropertyLabel: s.PhraseCtx(ctx, "golem15.seo::lang.effective.property", nil),
ValueLabel: s.PhraseCtx(ctx, "golem15.seo::lang.effective.value", nil),
SourceLabel: s.PhraseCtx(ctx, "golem15.seo::lang.effective.source", nil),
}
}
// RecordEffective builds P2 for a record: one table per locale at the
// record's own address, or the no-address line.
func (s *Service) RecordEffective(ctx context.Context, key, id string) (EffectiveView, error) {
view := s.newEffectiveView(ctx)
db, err := s.DB(ctx)
if err != nil {
return view, err
}
described := s.recordLocales(ctx, key, id)
for _, locale := range s.Locales(ctx, db) {
l := described[locale]
heading := s.PhraseCtx(ctx, "golem15.seo::lang.effective.locale", map[string]string{"locale": strings.ToUpper(locale), "url": l.URL})
res, ok, err := s.Resolver().ResolveRecord(ctx, key, id, locale)
if err != nil {
return view, err
}
if !ok {
view.Locales = append(view.Locales, EffectiveLocale{Heading: strings.ToUpper(locale), NoURL: true, NoURLText: s.PhraseCtx(ctx, "golem15.seo::lang.effective.no_url", nil)})
continue
}
view.Locales = append(view.Locales, EffectiveLocale{Heading: heading, Rows: s.EffectiveRows(ctx, res)})
}
return view, nil
}
// ruleProperties returns the translated labels of the properties a rule
// sets.
func (s *Service) ruleProperties(ctx context.Context, r models.Rule) []string {
var out []string
for _, p := range models.RuleProperties {
if strings.TrimSpace(r.Value(p.Column)) != "" {
out = append(out, s.PhraseCtx(ctx, p.Label, nil))
}
}
return out
}
// RecordStatus builds P1 for a record: the URL rule that overrides part of
// its SEO tab (the first one found over its locales), and a canonical that
// points to a page served noindex (D-06).
func (s *Service) RecordStatus(ctx context.Context, key, id string) (StatusView, error) {
var view StatusView
db, err := s.DB(ctx)
if err != nil {
return view, err
}
for _, l := range s.recordLocales(ctx, key, id) {
if l.URL == "" {
continue
}
path, _, err := parseTarget(l.URL)
if err != nil {
continue
}
rules, err := matchingRules(db, path, rawQueryOf(l.URL))
if err != nil {
return view, err
}
if c, ok := s.ruleCallout(ctx, rules); ok {
view.Callouts = append(view.Callouts, c)
break
}
}
reg := s.Registry()
if reg != nil {
if typ, ok := reg.ContentType(key); ok && typ.Content != nil {
entries, err := LoadEntries(db, typ.Content.Model, id)
if err != nil {
return view, err
}
for _, e := range entries {
if c, ok := s.canonicalCallout(ctx, e.Canonical); ok {
view.Callouts = append(view.Callouts, c)
break
}
}
}
}
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
}
for _, x := range exclusions {
key, ok := excludedTexts[x.Reason]
if !ok {
continue
}
view.Callouts = append(view.Callouts, Callout{
Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.excluded_title", nil),
Text: s.PhraseCtx(ctx, key, map[string]string{"url": x.Target}),
Plain: true,
})
}
return view, nil
}
// excludedTexts are the P1 texts of the sitemap exclusion reasons (UI-SPEC
// C16).
var excludedTexts = map[string]string{
contract.ReasonNoindex: "golem15.seo::lang.status.excluded_noindex",
contract.ReasonCanonical: "golem15.seo::lang.status.excluded_canonical",
contract.ReasonRedirect: "golem15.seo::lang.status.excluded_redirect",
contract.ReasonType: "golem15.seo::lang.status.excluded_type",
}
// ruleCallout is the override callout of the first rule that sets a value.
func (s *Service) ruleCallout(ctx context.Context, rules []models.Rule) (Callout, bool) {
for _, r := range rules {
props := s.ruleProperties(ctx, r)
if len(props) == 0 {
continue
}
return Callout{
Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.rule_title", nil),
Text: s.PhraseCtx(ctx, "golem15.seo::lang.status.rule_text", map[string]string{"url": r.URL, "fields": strings.Join(props, ", ")}),
}, true
}
return Callout{}, false
}
// canonicalCallout warns about a canonical on this site (D-06): one that
// redirects (status.canonical_redirected, naming the final address), or
// one that resolves to a page served noindex (status.canonical_noindex).
// Outside production every page is noindex, so only the redirect warning
// applies there.
func (s *Service) canonicalCallout(ctx context.Context, canonical string) (Callout, bool) {
canonical = strings.TrimSpace(canonical)
if canonical == "" {
return Callout{}, false
}
target := canonical
if u, err := url.Parse(canonical); err == nil && u.IsAbs() {
if !sameSite(s.SiteURL(), u.Host) {
return Callout{}, false
}
target = u.String()
}
res, err := s.Resolver().Resolve(ctx, target)
if err != nil {
return Callout{}, false
}
if res.Action == ActionRedirect {
return Callout{Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.canonical_redirected", map[string]string{"url": canonical, "target": res.Location})}, true
}
if s.Staging() || res.SEO == nil || !strings.Contains(res.SEO.Robots, "noindex") {
return Callout{}, false
}
return Callout{Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.canonical_noindex", map[string]string{"url": canonical})}, true
}
// RuleStatus builds P1 for a URL rule: the canonical warning.
func (s *Service) RuleStatus(ctx context.Context, r models.Rule) StatusView {
var view StatusView
if c, ok := s.canonicalCallout(ctx, r.Canonical); ok {
view.Callouts = append(view.Callouts, c)
}
return view
}
// RuleEffective builds P2 for a URL rule: an exact rule is resolved at its
// address; a pattern rule shows its own values with the source URL rule.
func (s *Service) RuleEffective(ctx context.Context, r models.Rule) (EffectiveView, error) {
view := s.newEffectiveView(ctx)
if r.Match == models.MatchExact {
res, err := s.Resolver().Resolve(ctx, r.URL)
if err != nil {
return view, err
}
view.Locales = append(view.Locales, EffectiveLocale{Heading: r.URL, Rows: s.EffectiveRows(ctx, res)})
return view, nil
}
source := s.PhraseCtx(ctx, "golem15.seo::lang.effective.source_rule", nil)
var rows []EffectiveRow
for _, p := range effectiveProperties {
if v := strings.TrimSpace(r.Value(p.name)); v != "" {
rows = append(rows, EffectiveRow{Property: s.PhraseCtx(ctx, p.label, nil), Value: v, Source: source, Mono: p.mono})
}
}
view.Locales = append(view.Locales, EffectiveLocale{Heading: r.URL, Rows: rows})
return view, nil
}
// Placeholders builds P3.
func (s *Service) Placeholders(ctx context.Context) PlaceholdersView {
return PlaceholdersView{
Caption: s.PhraseCtx(ctx, "golem15.seo::lang.settings.placeholders", nil),
CodeLabel: s.PhraseCtx(ctx, "golem15.seo::lang.settings.placeholders_code", nil),
MeaningLabel: s.PhraseCtx(ctx, "golem15.seo::lang.settings.placeholders_meaning", nil),
Rows: []PlaceholderRow{
{Code: PlaceholderTitle, Meaning: s.PhraseCtx(ctx, "golem15.seo::lang.placeholder.title", nil)},
{Code: PlaceholderDescription, Meaning: s.PhraseCtx(ctx, "golem15.seo::lang.placeholder.description", nil)},
{Code: PlaceholderSite, Meaning: s.PhraseCtx(ctx, "golem15.seo::lang.placeholder.site", nil)},
},
}
}
// RecordKey returns the primary key of a GORM model as text.
func RecordKey(db *gorm.DB, record any) (string, error) {
if record == nil || db == nil {
return "", fmt.Errorf("golem15.seo: no record")
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(record); err != nil {
return "", err
}
if stmt.Schema == nil || stmt.Schema.PrioritizedPrimaryField == nil {
return "", fmt.Errorf("golem15.seo: %T has no primary key", record)
}
v, zero := stmt.Schema.PrioritizedPrimaryField.ValueOf(context.Background(), reflectValue(record))
if zero {
return "", fmt.Errorf("golem15.seo: %T has an empty primary key", record)
}
return fmt.Sprint(v), nil
}
// reflectValue is the addressable value of a model pointer.
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
}