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 ` // 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), and the structured data // field (E8) and its preview (E9) after seo_og_image. 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) } 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 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), }, 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 }