Files
sm-seo-plugin/classes/partials.go
Jakub Zych c4f0b4f94c feat: Site audit and Crawls screens with their MCP tools
- golem15.seo.issues (Site audit): current issues errors first, labels,
  summary and fix columns, filters, P7 issue_details, Check this address
  again, Ignore, Stop ignoring, bulk Ignore, ignored rows badged
- golem15.seo.crawls (Crawls): newest first, P6 crawl_summary with one
  status callout, staging and rendered-comparison lines, stats strip and
  per-kind table; Cancel; a running crawl refuses delete
- Start crawl toolbar action on both, refusing already_running and
  no_site_url; navigation Site audit (gauge) and Crawls (globe)
- routes and MCP tools start/cancel/list/get/delete crawls, list_issues
  with filters (open by default), recheck_url, ignore and unignore
- AdminOperations maps both controllers; toolbar actions get their own
  operation kind
- TestCrawlAdmin, TestCrawlTools, TestIssueKindLabels
2026-11-04 15:34:22 +01:00

538 lines
16 KiB
Go
Raw Permalink 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"
"strconv"
"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
}
// IssueParams turns an issue's details into the :name parameters of its
// issues.sum_<kind> phrase: scalars as text, lists by their length or
// joined (fields, also), with the aliases the Phase 32 kinds need (count
// from also or hops, url from canonical).
func IssueParams(details map[string]any) map[string]string {
out := map[string]string{}
for k, v := range details {
switch x := v.(type) {
case string:
out[k] = x
case float64:
out[k] = strconv.FormatFloat(x, 'f', -1, 64)
case int:
out[k] = strconv.Itoa(x)
case bool:
out[k] = strconv.FormatBool(x)
case []any:
parts := make([]string, 0, len(x))
for _, e := range x {
if s, ok := e.(string); ok {
parts = append(parts, s)
}
}
out[k] = strings.Join(parts, ", ")
case []string:
out[k] = strings.Join(x, ", ")
}
}
if _, ok := out["count"]; !ok {
if also, ok := details["also"].([]any); ok {
out["count"] = strconv.Itoa(len(also))
} else if hops, ok := out["hops"]; ok {
out["count"] = hops
}
}
if _, ok := out["url"]; !ok {
if c, ok := out["canonical"]; ok {
out["url"] = c
}
}
return out
}
// IssueSummary is the one-line Details cell of an issue (issues.sum_<kind>).
func (s *Service) IssueSummary(ctx context.Context, kind string, details map[string]any) string {
return s.PhraseCtx(ctx, "golem15.seo::lang.issues.sum_"+kind, IssueParams(details))
}
// IssueRow is one row of P7's "What was found" table.
type IssueRow struct {
Label string
Value string
Mono bool
}
// IssueLink is a page named in P7 (a linking page or a suggestion): its
// address and, when the page has an admin form, the admin path.
type IssueLink struct {
URL string
Admin string
Phrase string
}
// IssueDetailsView is the P7 issue_details view model (UI-SPEC C23).
type IssueDetailsView struct {
Rows []IssueRow
LinkedFrom []IssueLink
More string
Orphan bool
Suggestions []IssueLink
FixText string
PageURL string
RichResults bool
RichURL string
IgnoredNotes string
}
// CrawlStat is one item of P6's stats strip.
type CrawlStat struct {
Label string
Value string
}
// CrawlKindRow is one row of P6's per-kind table.
type CrawlKindRow struct {
Kind string
Label string
Severity string
Count int
Link string
}
// CrawlSummaryView is the P6 crawl_summary view model (UI-SPEC C24): one
// status callout (Tone "", warning or danger), the staging and rendered
// comparison lines, the stats strip and the per-kind table.
type CrawlSummaryView struct {
Tone string
Title string
Text string
Staging string
Render string
Stats []CrawlStat
Kinds []CrawlKindRow
NoIssues bool
HasCallout bool
}