package seo import ( "context" "net/http" "net/url" "path" "reflect" "regexp" "strings" "github.com/qor5/admin/l10n" h "github.com/theplant/htmlgo" "gorm.io/gorm" ) var ( GlobalSEO = "Global SEO" GlobalDB *gorm.DB DBContextKey contextKey = "DB" ) type ( contextKey string contextVariablesFunc func(interface{}, *Setting, *http.Request) string ) // Create a SeoCollection instance func NewCollection() *Collection { collection := &Collection{ settingModel: &QorSEOSetting{}, dbContextKey: DBContextKey, globalName: GlobalSEO, inherited: true, } collection.RegisterSEO(GlobalSEO).RegisterSettingVaribles(struct{ SiteName string }{}). RegisterContextVariables( "og:url", func(_ interface{}, _ *Setting, req *http.Request) string { return req.URL.String() }, ) return collection } // Collection will hold registered seo configures and global setting definition and other configures // @snippet_begin(SeoCollectionDefinition) type Collection struct { registeredSEO []*SEO globalName string //default name is GlobalSEO inherited bool //default is true. the order is model seo setting, system seo setting, global seo setting dbContextKey interface{} // get db from context settingModel interface{} // db model afterSave func(ctx context.Context, settingName string, locale string) error // hook called after saving } // @snippet_end // SEO represents a seo object for a page // @snippet_begin(SeoDefinition) type SEO struct { name string modelTyp reflect.Type contextVariables map[string]contextVariablesFunc // fetch context variables from request settingVariables interface{} // fetch setting variables from db } // @snippet_end // RegisterModel register a model to seo func (seo *SEO) SetModel(model interface{}) *SEO { seo.modelTyp = reflect.Indirect(reflect.ValueOf(model)).Type() return seo } // SetName set seo name func (seo *SEO) SetName(name string) *SEO { seo.name = name return seo } // RegisterContextVariables register context variables. the registered variables will be rendered to the page func (seo *SEO) RegisterContextVariables(key string, f contextVariablesFunc) *SEO { if seo.contextVariables == nil { seo.contextVariables = map[string]contextVariablesFunc{} } seo.contextVariables[key] = f return seo } // RegisterSettingVaribles register a setting variable func (seo *SEO) RegisterSettingVaribles(setting interface{}) *SEO { seo.settingVariables = setting return seo } func (collection *Collection) SetGlobalName(name string) *Collection { collection.globalName = name if globalSeo := collection.GetSEOByName(GlobalSEO); globalSeo != nil { globalSeo.SetName(name) } return collection } func (collection *Collection) NewSettingModelInstance() interface{} { return reflect.New(reflect.Indirect(reflect.ValueOf(collection.settingModel)).Type()).Interface() } func (collection *Collection) NewSettingModelSlice() interface{} { sliceType := reflect.SliceOf(reflect.PtrTo(reflect.Indirect(reflect.ValueOf(collection.settingModel)).Type())) slice := reflect.New(sliceType) slice.Elem().Set(reflect.MakeSlice(sliceType, 0, 0)) return slice.Interface() } // RegisterVariblesSetting register variables setting func (collection *Collection) SetInherited(b bool) *Collection { collection.inherited = b return collection } // RegisterVariblesSetting register variables setting func (collection *Collection) SetSettingModel(s interface{}) *Collection { collection.settingModel = s return collection } // RegisterDBContextKey register a key to get db from context func (collection *Collection) SetDBContextKey(key interface{}) *Collection { collection.dbContextKey = key return collection } // RegisterSEOByNames register mutiple seo by names func (collection *Collection) RegisterSEOByNames(names ...string) *Collection { for index := range names { collection.registeredSEO = append(collection.registeredSEO, &SEO{name: names[index]}) } return collection } // RegisterSEO register a seo func (collection *Collection) RegisterSEO(obj interface{}) (seo *SEO) { if name, ok := obj.(string); ok { seo = &SEO{name: name} } else { typ := reflect.Indirect(reflect.ValueOf(obj)).Type() seo = &SEO{name: typ.Name(), modelTyp: typ} } collection.registeredSEO = append(collection.registeredSEO, seo) return } // RegisterSEO remove a seo func (collection *Collection) RemoveSEO(obj interface{}) *Collection { var name string if n, ok := obj.(string); ok { name = n } else { name = reflect.Indirect(reflect.ValueOf(obj)).Type().Name() } for index, s := range collection.registeredSEO { if s.name == name { collection.registeredSEO = append(collection.registeredSEO[:index], collection.registeredSEO[index+1:]...) break } } return collection } // GetSEO get a Seo func (collection *Collection) GetSEO(obj interface{}) *SEO { if name, ok := obj.(string); ok { return collection.GetSEOByName(name) } else { return collection.GetSEOByModel(obj) } } // GetSEO get a Seo by name func (collection *Collection) GetSEOByName(name string) *SEO { for _, s := range collection.registeredSEO { if s.name == name { return s } } return nil } // GetSEOByModel get a seo by model func (collection *Collection) GetSEOByModel(model interface{}) *SEO { for _, s := range collection.registeredSEO { if reflect.Indirect(reflect.ValueOf(model)).Type() == s.modelTyp { return s } } return nil } // AfterSave set the hook called after saving func (collection *Collection) AfterSave(v func(ctx context.Context, settingName string, locale string) error) *Collection { collection.afterSave = v return collection } // RenderGlobal render global seo func (collection Collection) RenderGlobal(req *http.Request) h.HTMLComponent { return collection.Render(collection.globalName, req) } // Render render seo tags func (collection Collection) Render(obj interface{}, req *http.Request) h.HTMLComponent { var ( db = collection.getDBFromContext(req.Context()) sortedSEOs []*SEO sortedSeoNames []string sortedDBSettings []QorSEOSettingInterface sortedSettings []Setting setting Setting locale string ) // sort all SEOs globalSeo := collection.GetSEO(collection.globalName) if globalSeo == nil { return h.RawHTML("") } sortedSEOs = append(sortedSEOs, globalSeo) if name, ok := obj.(string); !ok || name != collection.globalName { if seo := collection.GetSEO(obj); seo != nil { sortedSeoNames = append(sortedSeoNames, seo.name) sortedSEOs = append(sortedSEOs, seo) } } sortedSeoNames = append(sortedSeoNames, globalSeo.name) if v, ok := obj.(l10n.L10nInterface); ok { locale = v.GetLocale() } // sort all QorSEOSettingInterface var settingModelSlice = collection.NewSettingModelSlice() if db.Find(settingModelSlice, "name in (?) AND locale_code = ?", sortedSeoNames, locale).Error != nil { return h.RawHTML("") } reflectVlaue := reflect.Indirect(reflect.ValueOf(settingModelSlice)) for _, name := range sortedSeoNames { for i := 0; i < reflectVlaue.Len(); i++ { if modelSetting, ok := reflectVlaue.Index(i).Interface().(QorSEOSettingInterface); ok && modelSetting.GetName() == name { sortedDBSettings = append(sortedDBSettings, modelSetting) } } } // sort all settings if _, ok := obj.(string); !ok { if value := reflect.Indirect(reflect.ValueOf(obj)); value.IsValid() && value.Kind() == reflect.Struct { for i := 0; i < value.NumField(); i++ { if value.Field(i).Type() == reflect.TypeOf(Setting{}) { if setting := value.Field(i).Interface().(Setting); setting.EnabledCustomize { sortedSettings = append(sortedSettings, setting) } break } } } } for _, s := range sortedDBSettings { sortedSettings = append(sortedSettings, s.GetSEOSetting()) } // get the final setting from sortedSettings for i, s := range sortedSettings { if !collection.inherited && i >= 1 { break } if s.Title != "" && setting.Title == "" { setting.Title = s.Title } if s.Description != "" && setting.Description == "" { setting.Description = s.Description } if s.Keywords != "" && setting.Keywords == "" { setting.Keywords = s.Keywords } if s.OpenGraphURL != "" && setting.OpenGraphURL == "" { setting.OpenGraphURL = s.OpenGraphURL } if s.OpenGraphType != "" && setting.OpenGraphType == "" { setting.OpenGraphType = s.OpenGraphType } if s.OpenGraphImageURL != "" && setting.OpenGraphImageURL == "" { setting.OpenGraphImageURL = s.OpenGraphImageURL } if s.OpenGraphImageFromMediaLibrary.URL("og") != "" && setting.OpenGraphImageURL == "" { setting.OpenGraphImageURL = s.OpenGraphImageFromMediaLibrary.URL("og") } if len(s.OpenGraphMetadata) > 0 && len(setting.OpenGraphMetadata) == 0 { setting.OpenGraphMetadata = s.OpenGraphMetadata } } if setting.OpenGraphURL != "" && !isAbsoluteURL(setting.OpenGraphURL) { var u url.URL u.Host = req.Host if req.URL.Scheme != "" { u.Scheme = req.URL.Scheme } else { u.Scheme = "http" } setting.OpenGraphURL = path.Join(u.String(), setting.OpenGraphURL) } // fetch all variables and tags from context var ( variables = map[string]string{} tags = map[string]string{} ) for _, s := range sortedDBSettings { for key, val := range s.GetVariables() { variables[key] = val } } for i, seo := range sortedSEOs { for key, f := range seo.contextVariables { value := f(obj, &setting, req) if strings.Contains(key, ":") && collection.inherited { tags[key] = value } else if strings.Contains(key, ":") && !collection.inherited && i == 0 { tags[key] = value } else { variables[key] = f(obj, &setting, req) } } } setting = replaceVariables(setting, variables) return setting.HTMLComponent(tags) } // GetDB get db from context func (collection Collection) getDBFromContext(ctx context.Context) *gorm.DB { if contextdb := ctx.Value(collection.dbContextKey); contextdb != nil { return contextdb.(*gorm.DB) } return GlobalDB } var regex = regexp.MustCompile("{{([a-zA-Z0-9]*)}}") func replaceVariables(setting Setting, values map[string]string) Setting { replace := func(str string) string { matches := regex.FindAllStringSubmatch(str, -1) for _, match := range matches { str = strings.Replace(str, match[0], values[match[1]], 1) } return str } setting.Title = replace(setting.Title) setting.Description = replace(setting.Description) setting.Keywords = replace(setting.Keywords) return setting } func isAbsoluteURL(str string) bool { if u, err := url.Parse(str); err == nil && u.IsAbs() { return true } return false } func ContextWithDB(ctx context.Context, db *gorm.DB) context.Context { return context.WithValue(ctx, DBContextKey, db) }