- E10 golem15-seo-links in the SEO tab after the graph preview (types with a body): inbound and outbound counts from the last finished crawl, the orphan warning, up to 10 pages to link to (Copy address) and 10 that could link here - classes/linkgraph.go builds the report from the crawl's pages and links; the crawler's orphan suggestions share NormalText - GET /_seo/api/v1/links and seo_get_link_suggestions; widget action seo_links mapped in AdminOperations - element bundle rebuilt
515 lines
17 KiB
Go
515 lines
17 KiB
Go
package seo
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
|
"git.golem15.com/golem15/sm-seo-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
)
|
|
|
|
// TabSEO is the key of the SEO tab added to every content type's form.
|
|
const TabSEO = "golem15.seo::lang.tab.seo"
|
|
|
|
// AdminActionExtensions adds the SEO tab (D-01, D-91) to the admin form of
|
|
// every content type registered in signpost with a content descriptor that
|
|
// names a controller. The content plugins import nothing from this plugin.
|
|
func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
|
|
reg := p.registry()
|
|
if reg == nil {
|
|
return nil
|
|
}
|
|
// The Pages form's address and name come first: the SEO tab of a
|
|
// registry page (added below, the registry being a content type) reads
|
|
// the address its save wrote.
|
|
out := []pact.AdminActionExtension{{Controller: controllers.PagesControllerID, Form: p.pagesForm()}}
|
|
types := reg.ContentTypes()
|
|
for _, typ := range types {
|
|
if typ.Content == nil || strings.TrimSpace(typ.Content.Controller) == "" {
|
|
continue
|
|
}
|
|
out = append(out, pact.AdminActionExtension{
|
|
Controller: typ.Content.Controller,
|
|
Form: p.seoTab(typ.Key, typ.Content.Kind),
|
|
})
|
|
}
|
|
if form := p.typeSettingsTabs(types); form != nil {
|
|
out = append(out, pact.AdminActionExtension{Controller: controllers.SettingsControllerID, Form: form})
|
|
}
|
|
out = append(out, pact.AdminActionExtension{Controller: controllers.RulesControllerID, Form: p.ruleHistory()})
|
|
out = append(out, pact.AdminActionExtension{Controller: controllers.RedirectsControllerID, Form: p.redirectChecks()})
|
|
out = append(out, pact.AdminActionExtension{Controller: controllers.AutolinksControllerID, Form: p.autolinkHistory()})
|
|
out = append(out, pact.AdminActionExtension{Controller: controllers.RobotsControllerID, Form: p.robotsForm()})
|
|
return out
|
|
}
|
|
|
|
// redirectChecks is a field-less extension on the plugin's own redirects
|
|
// controller (P-18). Its Save runs inside the save transaction with the
|
|
// row written: it simulates the rule in place, refuses a loop with
|
|
// redirects.loop on target (D-19, the save rolls back), answers a chain
|
|
// with redirects.chain_notice on target, and records the save in history.
|
|
func (p *Plugin) redirectChecks() *pact.AdminFormExtension {
|
|
return &pact.AdminFormExtension{
|
|
Permissions: []string{controllers.PermissionManage},
|
|
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
|
svc := p.Service()
|
|
if svc == nil {
|
|
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
|
|
}
|
|
r, ok := in.Record.(*models.Redirect)
|
|
if !ok || r == nil {
|
|
return pact.AdminFormSaveResult{}, nil
|
|
}
|
|
check, err := svc.CheckRedirect(ctx, in.DB, *r)
|
|
if err != nil {
|
|
return pact.AdminFormSaveResult{}, err
|
|
}
|
|
if check.Loop {
|
|
return pact.AdminFormSaveResult{}, pact.AdminFormErrors{"target": {svc.LoopMessage(ctx, check)}}
|
|
}
|
|
before, after := classes.ChangesValues(in.Changes, in.Created)
|
|
if err := svc.Record(ctx, in.DB, classes.Change{
|
|
Kind: models.SubjectRedirect,
|
|
SubjectID: in.ID,
|
|
What: svc.Phrase("golem15.seo::lang.history.what_redirect", map[string]string{"url": r.Source}),
|
|
Before: before,
|
|
After: after,
|
|
}); err != nil {
|
|
return pact.AdminFormSaveResult{}, err
|
|
}
|
|
if notices := classes.ChainNotice(check); len(notices) > 0 {
|
|
return pact.AdminFormSaveResult{Notices: map[string][]pact.FormFieldNotice{"target": notices}}, nil
|
|
}
|
|
return pact.AdminFormSaveResult{}, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ruleHistory is a field-less extension on the plugin's own rules
|
|
// controller: its Save sees the save's changes inside the save transaction
|
|
// and records them in history (deletes are recorded by the controller).
|
|
func (p *Plugin) ruleHistory() *pact.AdminFormExtension {
|
|
return &pact.AdminFormExtension{
|
|
Permissions: []string{controllers.PermissionManage},
|
|
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
|
svc := p.Service()
|
|
if svc == nil {
|
|
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
|
|
}
|
|
before, after := classes.ChangesValues(in.Changes, in.Created)
|
|
url := ""
|
|
if r, ok := in.Record.(*models.Rule); ok && r != nil {
|
|
url = r.URL
|
|
}
|
|
return pact.AdminFormSaveResult{}, svc.Record(ctx, in.DB, classes.Change{
|
|
Kind: models.SubjectRule,
|
|
SubjectID: in.ID,
|
|
What: svc.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": url}),
|
|
Before: before,
|
|
After: after,
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
// autolinkHistory is a field-less extension on the plugin's own Auto-links
|
|
// controller: its Save sees the save's changes inside the save
|
|
// transaction and records them in history, which purges the site's cached
|
|
// pages (deletes are recorded by the controller).
|
|
func (p *Plugin) autolinkHistory() *pact.AdminFormExtension {
|
|
return &pact.AdminFormExtension{
|
|
Permissions: []string{controllers.PermissionManage},
|
|
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
|
svc := p.Service()
|
|
if svc == nil {
|
|
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
|
|
}
|
|
a, ok := in.Record.(*models.Autolink)
|
|
if !ok || a == nil {
|
|
return pact.AdminFormSaveResult{}, nil
|
|
}
|
|
before, after := classes.ChangesValues(in.Changes, in.Created)
|
|
return pact.AdminFormSaveResult{}, svc.Record(ctx, in.DB, classes.Change{
|
|
Kind: models.SubjectAutolink,
|
|
SubjectID: in.ID,
|
|
What: svc.Phrase("golem15.seo::lang.history.what_autolink", map[string]string{"phrase": a.Phrase}),
|
|
Before: before,
|
|
After: after,
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *Plugin) registry() *signpost.Registry {
|
|
if p == nil || p.app == nil {
|
|
return nil
|
|
}
|
|
reg, _ := p.app.Lookup[*signpost.Registry]()
|
|
return reg
|
|
}
|
|
|
|
// seoTabYAML is the SEO tab's fields document (UI-SPEC C3): the tab placed
|
|
// after the owner's tabs (no anchor, P-17) and its fields in order. The
|
|
// names seo_robots and seo_og_image are anchors Phase 33 places fields
|
|
// after; they must not be renamed.
|
|
const seoTabYAML = `tabs:
|
|
- name: golem15.seo::lang.tab.seo
|
|
fields:
|
|
seo_status:
|
|
type: partial
|
|
path: seo_status
|
|
span: full
|
|
context: update
|
|
tab: golem15.seo::lang.tab.seo
|
|
seo_snippet:
|
|
label: golem15.seo::lang.field.snippet
|
|
type: widget
|
|
widget: golem15-seo-serp
|
|
action: seo_snippet
|
|
fill: [seo_title, seo_description, seo_keyphrase]
|
|
span: full
|
|
context: [create, update]
|
|
tab: golem15.seo::lang.tab.seo
|
|
seo_title:
|
|
label: golem15.seo::lang.field.title
|
|
comment: golem15.seo::lang.field.title_comment
|
|
type: mlseotext
|
|
measure: title
|
|
span: full
|
|
tab: golem15.seo::lang.tab.seo
|
|
seo_description:
|
|
label: golem15.seo::lang.field.description
|
|
comment: golem15.seo::lang.field.description_comment
|
|
type: mlseotext
|
|
measure: description
|
|
span: full
|
|
tab: golem15.seo::lang.tab.seo
|
|
seo_keyphrase:
|
|
label: golem15.seo::lang.field.keyphrase
|
|
comment: golem15.seo::lang.field.keyphrase_comment
|
|
type: mltext
|
|
span: left
|
|
tab: golem15.seo::lang.tab.seo
|
|
seo_canonical:
|
|
label: golem15.seo::lang.field.canonical
|
|
comment: golem15.seo::lang.field.canonical_comment
|
|
type: mltext
|
|
span: right
|
|
tab: golem15.seo::lang.tab.seo
|
|
seo_robots:
|
|
label: golem15.seo::lang.field.robots
|
|
comment: golem15.seo::lang.field.robots_comment
|
|
type: mlseorobots
|
|
scope: record
|
|
span: full
|
|
tab: golem15.seo::lang.tab.seo
|
|
seo_og_title:
|
|
label: golem15.seo::lang.field.og_title
|
|
comment: golem15.seo::lang.field.og_title_comment
|
|
type: mltext
|
|
span: full
|
|
tab: golem15.seo::lang.tab.seo
|
|
seo_og_description:
|
|
label: golem15.seo::lang.field.og_description
|
|
type: mltextarea
|
|
size: small
|
|
span: full
|
|
tab: golem15.seo::lang.tab.seo
|
|
seo_og_image:
|
|
label: golem15.seo::lang.field.og_image
|
|
comment: golem15.seo::lang.field.og_image_comment
|
|
type: seoimage
|
|
span: full
|
|
tab: golem15.seo::lang.tab.seo
|
|
seo_effective:
|
|
label: golem15.seo::lang.field.effective
|
|
type: partial
|
|
path: seo_effective
|
|
span: full
|
|
context: update
|
|
tab: golem15.seo::lang.tab.seo
|
|
`
|
|
|
|
// seoLinksYAML is E10, the internal links widget (UI-SPEC C16, C19).
|
|
const seoLinksYAML = ` seo_links:
|
|
label: golem15.seo::lang.field.links
|
|
type: widget
|
|
widget: golem15-seo-links
|
|
action: seo_links
|
|
span: full
|
|
context: update
|
|
tab: golem15.seo::lang.tab.seo
|
|
`
|
|
|
|
// hasBody reports whether records of a content type of kind have a body
|
|
// to add links to (UI-SPEC C16 row 14): collections and profiles do not,
|
|
// nor do the registry's pages, which the site renders itself.
|
|
func hasBody(key, kind string) bool {
|
|
if key == classes.PageTypeKey {
|
|
return false
|
|
}
|
|
return kind != pact.ContentKindCollection && kind != pact.ContentKindProfile
|
|
}
|
|
|
|
// 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), the structured data field
|
|
// (E8) and its preview (E9) after seo_og_image, then the internal links
|
|
// widget (E10) for types with a body.
|
|
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)
|
|
}
|
|
links := ""
|
|
if hasBody(key, kind) {
|
|
links = seoLinksYAML
|
|
}
|
|
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
|
|
`+links+` 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(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),
|
|
classes.RecordGraphAction(p.Service, key, "golem15.seo::lang.graph.action", controllers.PermissionManage),
|
|
classes.RecordLinksAction(p.Service, key, "golem15.seo::lang.field.links", controllers.PermissionManage),
|
|
},
|
|
PartialData: func(ctx context.Context, name string, record any) (any, error) {
|
|
return p.seoTabPartial(ctx, key, name, record)
|
|
},
|
|
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
|
|
return p.loadSEOTab(ctx, key, in)
|
|
},
|
|
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
|
return p.saveSEOTab(ctx, key, in)
|
|
},
|
|
}
|
|
}
|
|
|
|
// seoTabPartial serves P1 seo_status and P2 seo_effective of a record.
|
|
func (p *Plugin) seoTabPartial(ctx context.Context, key, name string, record any) (any, error) {
|
|
svc := p.Service()
|
|
if svc == nil {
|
|
return nil, classes.ErrNotBooted
|
|
}
|
|
db, err := svc.DB(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := classes.RecordKey(db, record)
|
|
if err != nil {
|
|
if name == "seo_effective" {
|
|
return classes.EffectiveView{}, nil
|
|
}
|
|
return classes.StatusView{}, nil
|
|
}
|
|
switch name {
|
|
case "seo_status":
|
|
return svc.RecordStatus(ctx, key, id)
|
|
case "seo_effective":
|
|
return svc.RecordEffective(ctx, key, id)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// contentType returns the registered content type key with a descriptor.
|
|
func (p *Plugin) contentType(key string) (pact.MenuItemType, error) {
|
|
reg := p.registry()
|
|
if reg == nil {
|
|
return pact.MenuItemType{}, fmt.Errorf("golem15.seo: menu item type registry is not published")
|
|
}
|
|
typ, ok := reg.ContentType(key)
|
|
if !ok {
|
|
return pact.MenuItemType{}, fmt.Errorf("golem15.seo: content type %q is not registered", key)
|
|
}
|
|
return typ, nil
|
|
}
|
|
|
|
// loadSEOTab returns the stored SEO tab values of a record, every field a
|
|
// map of locale to text.
|
|
func (p *Plugin) loadSEOTab(ctx context.Context, key string, in pact.AdminFormLoad) (map[string]any, error) {
|
|
svc := p.Service()
|
|
if svc == nil {
|
|
return nil, classes.ErrNotBooted
|
|
}
|
|
typ, err := p.contentType(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries, err := classes.LoadEntries(in.DB.WithContext(ctx), typ.Content.Model, in.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]any{}
|
|
for column, perLocale := range classes.RecordValuesOf(entries) {
|
|
if column == classes.SharedColumn {
|
|
out[classes.FieldPrefix+column] = classes.SharedValue(entries)
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
|
|
// saveSEOTab stores the submitted SEO tab values of a record inside the
|
|
// record's save transaction. On an update it first compares the record's
|
|
// addresses with its entries' URLs and creates the 301 of a changed
|
|
// address (AddressChanged), answered as the redirects.auto_created notice.
|
|
func (p *Plugin) saveSEOTab(ctx context.Context, key string, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
|
svc := p.Service()
|
|
if svc == nil {
|
|
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
|
|
}
|
|
typ, err := p.contentType(key)
|
|
if err != nil {
|
|
return pact.AdminFormSaveResult{}, err
|
|
}
|
|
values := classes.RecordValues{}
|
|
refusals := pact.AdminFormErrors{}
|
|
for _, column := range append(append([]string{}, classes.EntryColumns...), classes.DataColumns...) {
|
|
raw, ok := in.Values[classes.FieldPrefix+column]
|
|
if !ok {
|
|
continue
|
|
}
|
|
perLocale := localeMap(raw)
|
|
if column == "canonical" {
|
|
for _, v := range perLocale {
|
|
if v = strings.TrimSpace(v); v != "" && !classes.ValidURLRef(v) {
|
|
refusals[classes.FieldPrefix+column] = []string{svc.PhraseCtx(ctx, "lagoon::validation.url", map[string]string{
|
|
"attribute": svc.PhraseCtx(ctx, "golem15.seo::lang.field.canonical", nil),
|
|
})}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
values[column] = perLocale
|
|
}
|
|
if len(refusals) > 0 {
|
|
return pact.AdminFormSaveResult{}, refusals
|
|
}
|
|
// The address diff runs before the values are written, because the
|
|
// write refreshes the entries' URLs (D-18, P-20).
|
|
var moved []classes.AddressResult
|
|
if !in.Created {
|
|
changes, err := svc.AddressChanges(ctx, in.DB, typ, in.ID)
|
|
if err != nil {
|
|
return pact.AdminFormSaveResult{}, err
|
|
}
|
|
for _, change := range changes {
|
|
res, err := svc.AddressChanged(ctx, in.DB, change)
|
|
if err != nil {
|
|
return pact.AdminFormSaveResult{}, err
|
|
}
|
|
if res.Redirect != nil {
|
|
moved = append(moved, res)
|
|
}
|
|
}
|
|
}
|
|
if _, _, err := svc.SaveRecord(ctx, in.DB, classes.RecordWrite{Type: typ, ID: in.ID, Values: values}); err != nil {
|
|
return pact.AdminFormSaveResult{}, err
|
|
}
|
|
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
|
|
// field's map, or a plain string under the empty locale key.
|
|
func localeMap(raw any) map[string]string {
|
|
switch v := raw.(type) {
|
|
case map[string]string:
|
|
return v
|
|
case map[string]any:
|
|
out := make(map[string]string, len(v))
|
|
for locale, value := range v {
|
|
if s, ok := value.(string); ok {
|
|
out[locale] = s
|
|
}
|
|
}
|
|
return out
|
|
case string:
|
|
return map[string]string{"": v}
|
|
}
|
|
return map[string]string{}
|
|
}
|
|
|
|
// injected reports whether the SEO tab of content type key is shown: false
|
|
// only when its settings row turns it off. It answers true when it cannot
|
|
// decide, so a database error never hides stored values.
|
|
func (p *Plugin) injected(ctx context.Context, key string) bool {
|
|
svc := p.Service()
|
|
if svc == nil {
|
|
return true
|
|
}
|
|
db, err := svc.DB(ctx)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
row, err := classes.LoadTypeSetting(db, key)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return row.Inject
|
|
}
|