Implement plugin system with analytics, social icons, and scholar plugins#520
Implement plugin system with analytics, social icons, and scholar plugins#520
Conversation
Adds a plugin architecture that allows extending goblog without forking. Plugins can inject HTML into templates, provide data to templates, register scheduled background jobs, and define their own settings in the admin UI. Plugin interface (plugin/plugin.go): - Name, DisplayName, Version for identity - Settings() for plugin-specific configuration - ScheduledJobs() for periodic background tasks - TemplateHead/TemplateFooter for HTML injection - TemplateData for template data enrichment - BasePlugin provides no-op defaults Registry (plugin/registry.go): - Register compiled-in plugins - Init seeds plugin settings, calls OnInit - StartScheduledJobs launches background goroutines - InjectTemplateData enriches template data before render - GetAllSettings for admin UI - Gin middleware stores registry on context Dynamic loading (plugin/loader.go): - Yaegi Go interpreter loads .go files from plugins/dynamic/ - Dynamic plugins implement the same Plugin interface - No recompilation needed for dynamic plugins Integration: - Blog.Render() helper wraps c.HTML with plugin data injection - All 32 c.HTML calls in blog.go replaced with b.Render - Plugin middleware added to router in goblog.go - Template injection points in all theme headers/footers - Plugin settings section on admin settings page Proof of concept: Google Analytics plugin (plugins/analytics/) - Configurable measurement ID and enable/disable toggle - Injects GA script into <head> when enabled Closes #480 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces a plugin system for GoBlog that supports both compiled-in plugins and dynamically loaded plugins (via Yaegi), and integrates plugin-provided settings + template injections into the existing admin UI and rendering pipeline.
Changes:
- Adds a
pluginpackage (plugin interface, registry, Yaegi loader, middleware) and a proof-of-concept Google Analytics plugin. - Wraps HTML rendering via
Blog.Render()to injectplugin_head_html,plugin_footer_html, andpluginstemplate data across pages. - Extends admin settings UI/JS to display and update plugin settings.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| www/js/admin-script.js | Adds updatePluginSettings() AJAX submission for plugin settings. |
| themes/minimal/templates/header.html | Adds plugin_head_html injection point. |
| themes/minimal/templates/footer.html | Adds plugin_footer_html injection point. |
| themes/forest/templates/header.html | Adds plugin_head_html injection point. |
| themes/forest/templates/footer.html | Adds plugin_footer_html injection point. |
| themes/default/templates/header.html | Adds plugin_head_html injection point. |
| themes/default/templates/footer.html | Adds plugin_footer_html injection point. |
| themes/default/templates/admin_settings.html | Adds plugin settings section to admin settings page. |
| plugins/analytics/analytics.go | Adds compiled-in Google Analytics plugin. |
| plugin/plugin.go | Defines plugin interface, hook context, settings/jobs types, and BasePlugin defaults. |
| plugin/registry.go | Implements plugin registry, template injection, settings grouping, scheduled jobs, and Gin middleware. |
| plugin/loader.go | Implements dynamic plugin loading via Yaegi. |
| plugin/symbols.go | Exports plugin types to Yaegi interpreter. |
| plugin/registry_test.go | Adds tests for registry basics, seeding, template injection, and settings grouping. |
| goblog.go | Wires plugin registry, dynamic loading, init, job start, and middleware into server startup. |
| blog/blog.go | Adds Blog.Render() helper and migrates handlers to use it. |
| admin/admin.go | Injects plugin settings groups into admin settings page template data. |
| go.mod / go.sum | Adds Yaegi dependency. |
Comments suppressed due to low confidence (1)
blog/blog.go:1026
b.Renderis being called with a non-template string ("Error loading .env file: ...") astemplateName. Gin will treat this as a template filename and fail to render, breaking the login page when both.envandlocal.envare missing/unreadable. Use an actual template (e.g.error.html) and pass the error message via the data map (e.g.description/error).
b.Render(c, http.StatusInternalServerError, "Error loading .env file: "+err.Error(), gin.H{
"logged_in": b.auth.IsLoggedIn(c),
"is_admin": b.auth.IsAdmin(c),
"version": b.Version,
"title": "Login Configuration Error",
"recent": b.GetLatest(),
"admin_page": false,
"settings": b.GetSettings(),
"nav_pages": b.GetNavPages(),
})
return
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| router.Use(CORS()) | ||
| router.Use(gplugin.Middleware(registry)) |
| go func(p Plugin, job ScheduledJob) { | ||
| ticker := time.NewTicker(job.Interval) | ||
| defer ticker.Stop() | ||
| for { | ||
| select { | ||
| case <-ticker.C: | ||
| settings := r.getPluginSettings(p.Name()) | ||
| if err := job.Run(r.db, settings); err != nil { | ||
| log.Printf("Plugin %s job %s error: %v", p.Name(), job.Name, err) | ||
| } | ||
| case <-r.stopCh: | ||
| return | ||
| } | ||
| } | ||
| }(p, job) |
plugin/loader.go
Outdated
| // using the Yaegi Go interpreter. Each file must define a function: | ||
| // | ||
| // func NewPlugin() plugin.Plugin | ||
| // |
| <form id="plugin-settings-form"> | ||
| {{ range .plugin_settings }} | ||
| <h4>{{ .DisplayName }}</h4> | ||
| {{ $pluginName := .PluginName }} | ||
| {{ $values := .CurrentValues }} | ||
| {{ range .Settings }} | ||
| <label for="{{ $pluginName }}.{{ .Key }}" class="form-label">{{ .Label }}</label> | ||
| {{ if .Description }}<small class="text-muted d-block mb-1">{{ .Description }}</small>{{ end }} | ||
| {{ if eq .Type "textarea" }} | ||
| <textarea id="{{ $pluginName }}.{{ .Key }}" name="{{ $pluginName }}.{{ .Key }}" class="form-control mb-2" rows="3">{{ index $values .Key }}</textarea> | ||
| {{ else }} | ||
| <input type="text" id="{{ $pluginName }}.{{ .Key }}" name="{{ $pluginName }}.{{ .Key }}" value="{{ index $values .Key }}" class="form-control mb-2"> |
| var Symbols = map[string]map[string]reflect.Value{ | ||
| "goblog/plugin/plugin": { | ||
| "BasePlugin": reflect.ValueOf((*BasePlugin)(nil)), | ||
| "HookContext": reflect.ValueOf((*HookContext)(nil)), | ||
| "SettingDefinition": reflect.ValueOf((*SettingDefinition)(nil)), | ||
| "ScheduledJob": reflect.ValueOf((*ScheduledJob)(nil)), | ||
| }, |
| p, ok := v.Interface().(Plugin) | ||
| if !ok { | ||
| return nil, err | ||
| } |
www/js/admin-script.js
Outdated
| $("#plugin-settings-form :input").each(function() { | ||
| var key = this.name; | ||
| var type = this.tagName === "TEXTAREA" ? "textarea" : "text"; | ||
| var value = this.value; | ||
| if (this.type === "submit" || !key) return; | ||
| settings.push({"key": key, "value": value, "type": type}); |
| func loadPlugin(path string) (Plugin, error) { | ||
| src, err := os.ReadFile(path) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| i := interp.New(interp.Options{}) | ||
| if err := i.Use(stdlib.Symbols); err != nil { | ||
| return nil, err | ||
| } | ||
| // Export the plugin package symbols so dynamic plugins can use them | ||
| if err := i.Use(Symbols); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| _, err = i.Eval(string(src)) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| v, err := i.Eval("NewPlugin()") | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| p, ok := v.Interface().(Plugin) | ||
| if !ok { | ||
| return nil, err | ||
| } | ||
|
|
||
| return p, nil | ||
| } |
plugin/registry_test.go
Outdated
| db, _ := gorm.Open(sqlite.Open(":memory:")) | ||
| db.AutoMigrate(&blog.Setting{}) | ||
|
|
Theme switching: - Reload page after theme change so new templates take effect immediately instead of showing stale "Settings updated" message Admin settings layout: - Separate site settings into a "General" card - Each plugin gets its own card with header showing display name - Plugin save buttons are per-plugin, not one global button - updatePluginSettings now scopes to the closest form Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Plugin cards are collapsed by default, expanded only when enabled - Enabled setting rendered as a Bootstrap switch checkbox in the card header instead of a text input - Toggling the checkbox immediately saves the enabled state and expands/collapses the settings card - The "enabled" field is excluded from the settings form body since it lives in the card header - Save button includes the enabled state along with other settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Card header is clickable to expand/collapse regardless of enabled state - Cards start collapsed by default so the page is clean - Enable/disable toggle only saves the setting, doesn't control collapse - stopPropagation on the switch prevents clicking it from also toggling the collapse Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Plugin settings were stored in the main settings table with namespaced keys (analytics.enabled, analytics.tracking_id), which caused them to appear in the global settings form. - Add PluginSetting model with composite PK (plugin_name, key) - Auto-migrate plugin_settings table in Registry.Init() - Add UpdateSetting method on Registry - Add PATCH /api/v1/plugin-settings endpoint for saving plugin settings - Update JS to use the new endpoint for both toggle and form save - Remove blog.Setting dependency from plugin package Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove any dot-namespaced keys (e.g. analytics.enabled) from the settings table that were seeded before plugin settings moved to their own plugin_settings table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move social URL settings out of the core settings table and hardcoded
footer templates into a socialicons plugin.
Plugin (plugins/socialicons/):
- Defines settings for 10 social platforms (GitHub, LinkedIn, X, etc.)
- TemplateFooter renders icon links with Font Awesome
- TemplateData provides structured links list for themes that want it
- Enabled by default
Migration:
- Moves existing social URL values from settings to plugin_settings
- Removes the social URL keys from the main settings table
- Sets socialicons.enabled to true for existing installs
Footer templates:
- Removed hardcoded social icon blocks from all three themes
- Social icons now rendered via {{ .plugin_footer_html }}
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the scholar integration out of the Blog struct and into a standalone plugin. The plugin defines a "research" dynamic page type that renders when enabled, and disappears from the site when disabled. Plugin interface additions: - Pages() — plugins can register dynamic page types - RenderPage() — plugins handle rendering their own pages - Registry.GetPagePlugin/RenderPluginPage/GetNavItems for page routing - Registry.IsPluginEnabled for checking enabled state Scholar plugin (plugins/scholar/): - Owns the scholar.Scholar instance internally - Configurable: scholar_id, article_limit, cache file paths - Scheduled job: daily cache refresh in background - Renders page_research.html with articles data Blog changes: - Removed scholar field from Blog struct - Removed Blog.New scholar parameter - Removed Research handler and sortArticlesByDateDesc - DynamicPage default case now delegates to plugin registry - Moved sort test to plugin package Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- OnInit ensures a research page exists in the pages table with default title, slug, nav settings. Users can customize these via the admin page editor. - Migrates ScholarID from legacy page field to plugin settings for backward compatibility with existing installs. - When the scholar plugin is disabled, visiting the research page shows a "Page Not Available" 404 instead of an empty content page. - Added HasPageType to registry to distinguish "plugin disabled" from "no plugin for this page type". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove PageTypeResearch constant (now plugin-defined) - Mark ScholarID on Page as deprecated (kept for migration compat) - Add PageFilter to Blog struct, applied in GetNavPages to filter out pages owned by disabled plugins - Add IsPageTypeEnabled to registry for checking if the plugin that owns a page type is enabled - Wire up the filter in goblog.go after registry init - Update seed defaults and tests to use string literal "research" When the scholar plugin is disabled: - Research page disappears from navigation - Visiting /research shows "Page Not Available" 404 When re-enabled, everything comes back automatically. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Admin pages list (/admin/pages): - Rows for pages with disabled plugins highlighted in yellow - "plugin disabled" badge shown next to the page type - Enabled column shows "Plugin disabled" instead of Yes/No Admin page editor (/admin/pages/:id): - Warning banner at top when the page's plugin is disabled, with link to Settings to enable it - Removed scholar-specific Google Scholar ID field (now in plugin settings) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a first-class plugin system for goblog (compiled-in and dynamically loaded via Yaegi), integrates plugin-provided data/HTML into template rendering, and adds admin UI + migrations to support plugin settings and plugin-owned pages.
Changes:
- Add plugin framework (registry, settings table, middleware, Yaegi dynamic loader) plus initial plugins (Analytics, Social Icons, Scholar).
- Route all template rendering through
Blog.Render()so plugins can inject template data and head/footer HTML. - Update admin UI + migrations: plugin settings management endpoint/UI, social URL settings migrated into
plugin_settings, and admin page warnings when plugin-owned page types are disabled.
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| www/js/admin-script.js | Adds plugin settings AJAX updates and a theme-change-triggered reload after saving settings. |
| tools/migrate.go | Migrates social URL settings into plugin settings and adds plugin-settings cleanup logic. |
| themes/minimal/templates/header.html | Adds plugin_head_html injection point. |
| themes/minimal/templates/footer.html | Replaces hardcoded social icons with plugin_footer_html. |
| themes/minimal/templates/admin_settings.html | Adds plugin settings UI groups with enable toggle and collapsible sections. |
| themes/minimal/templates/admin_pages.html | Highlights pages whose page type belongs to a disabled plugin. |
| themes/minimal/templates/admin_edit_page.html | Shows a warning when editing a page whose type is provided by a disabled plugin; removes scholar-specific fields. |
| themes/forest/templates/header.html | Adds plugin_head_html injection point. |
| themes/forest/templates/footer.html | Replaces hardcoded social icons with plugin_footer_html. |
| themes/forest/templates/admin_settings.html | Adds plugin settings UI groups with enable toggle and collapsible sections. |
| themes/forest/templates/admin_pages.html | Highlights pages whose page type belongs to a disabled plugin. |
| themes/forest/templates/admin_edit_page.html | Shows a warning when editing a page whose type is provided by a disabled plugin; removes scholar-specific fields. |
| themes/default/templates/header.html | Adds plugin_head_html injection point. |
| themes/default/templates/footer.html | Adds plugin_footer_html injection (currently duplicated). |
| themes/default/templates/admin_settings.html | Adds plugin settings UI groups with enable toggle and collapsible sections. |
| themes/default/templates/admin_pages.html | Highlights pages whose page type belongs to a disabled plugin. |
| themes/default/templates/admin_edit_page.html | Shows a warning when editing a page whose type is provided by a disabled plugin; removes scholar-specific fields. |
| plugins/socialicons/socialicons.go | New Social Icons plugin that injects footer HTML + structured template data. |
| plugins/scholar/scholar_test.go | Moves/renames test package/imports to match plugin location. |
| plugins/scholar/scholar.go | New Scholar plugin implementing a plugin-owned “research” page + scheduled cache refresh job. |
| plugins/analytics/analytics.go | New Google Analytics plugin injecting tracking script into page head when enabled. |
| plugin/symbols.go | Exposes plugin types to Yaegi (dynamic plugin support). |
| plugin/setting.go | Adds PluginSetting model for plugin settings persistence. |
| plugin/registry_test.go | Adds tests for registry basics, setting seeding, and template injection. |
| plugin/registry.go | Adds plugin registry, settings seeding, template injection, scheduled jobs, page ownership, and middleware. |
| plugin/plugin.go | Defines plugin interface, hook context, scheduled jobs, page definitions, and BasePlugin. |
| plugin/loader.go | Implements Yaegi-based dynamic plugin loading from .go files. |
| goblog.go | Wires registry into app startup, registers compiled-in plugins, adds middleware + plugin settings API route, and filters nav pages via plugins. |
| go.mod | Adds Yaegi dependency. |
| go.sum | Adds Yaegi checksums. |
| blog/page.go | Updates PageType comments and deprecates ScholarID field usage. |
| blog/blog_test.go | Updates tests for new blog.New(...) signature and research page type usage. |
| blog/blog.go | Removes built-in scholar handling, introduces Blog.Render() wrapper, and routes render calls through it; adds plugin page rendering fallback. |
| admin/admin_test.go | Updates tests for new blog.New(...) signature. |
| admin/admin.go | Adds plugin settings retrieval for templates, disabled-plugin-page warnings, and new plugin settings PATCH handler. |
Comments suppressed due to low confidence (1)
blog/blog.go:996
Login()passes an error message string as the template name toRender, which will make Gin try to render a template named like"Error loading .env file: ...". This should render a real template (e.g.error.html) and pass the error message via the template data.
b.Render(c, http.StatusInternalServerError, "Error loading .env file: "+err.Error(), gin.H{
"logged_in": b.auth.IsLoggedIn(c),
"is_admin": b.auth.IsAdmin(c),
"version": b.Version,
"title": "Login Configuration Error",
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if url == "" { | ||
| continue | ||
| } | ||
| html += `<a href="` + url + `" target="_blank" rel="noopener noreferrer" title="` + s.label + `" style="margin: 0 6px; color: inherit;"><i class="` + s.icon + ` fa-1x"></i></a>` | ||
| } |
| result := db.Where("page_type = ?", "research").First(&page) | ||
| if result.Error != nil { | ||
| // No research page exists — create the default | ||
| page = blog.Page{ | ||
| Title: "Research", |
plugin/registry.go
Outdated
| func (r *Registry) Plugins() []Plugin { | ||
| r.mu.RLock() | ||
| defer r.mu.RUnlock() | ||
| return r.plugins |
goblog.go
Outdated
| registry := gplugin.NewRegistry(db) | ||
| registry.Register(analytics.New()) | ||
| registry.Register(socialicons.New()) | ||
| registry.Register(scholarplugin.New()) | ||
| gplugin.LoadDynamicPlugins(registry, "plugins/dynamic") |
tools/migrate.go
Outdated
| func cleanupPluginSettingsFromMainTable(db *gorm.DB) { | ||
| result := db.Exec("DELETE FROM settings WHERE key LIKE '%.%'") | ||
| if result.Error != nil { |
| p, ok := v.Interface().(Plugin) | ||
| if !ok { | ||
| return nil, err | ||
| } |
plugin/plugin.go
Outdated
| ) | ||
|
|
||
| // SettingDefinition describes a single setting that a plugin requires. | ||
| // Settings are stored in the blog's Setting table namespaced as "pluginname.key". |
| enabled := ctx.Settings["enabled"] | ||
| if trackingID == "" || enabled != "true" { | ||
| return "" | ||
| } | ||
| return `<script async src="https://www.googletagmanager.com/gtag/js?id=` + trackingID + `"></script> |
| <div id="footer" class="text-center"> | ||
| <div class="footer"> | ||
| {{ if .settings.github_url.Value }} | ||
| <a href="{{ .settings.github_url.Value }}" target="_blank" title="{{ .settings.site_title.Value }} on Github"><i class="fab fa-github fa-1x"></i></a> | ||
| {{ end }} | ||
| {{ if .settings.linkedin_url.Value }} | ||
| <a href="{{ .settings.linkedin_url.Value }}" target="_blank" title="{{ .settings.site_title.Value }} on LinkedIn"><i class="fab fa-linkedin fa-1x"></i></a> | ||
| {{ end }} | ||
| {{ if .settings.x_url.Value }} | ||
| <a href="{{ .settings.x_url.Value }}" target="_blank" title="{{ .settings.site_title.Value }} on X"><i class="fab fa-x fa-1x"></i></a> | ||
| {{ end }} | ||
| {{ if .settings.keybase_url.Value }} | ||
| <a href="{{ .settings.keybase_url.Value }}" target="_blank" title="{{ .settings.site_title.Value }} on Keybase"><i class="fab fa-keybase fa-1x"></i></a> | ||
| {{ end }} | ||
| {{ if .settings.instagram_url.Value }} | ||
| <a href="https://www.instagram.com/compscidr/" target="_blank" title="Jason Ernst on Instagram"><i class="fab fa-instagram fa-1x"></i></a> | ||
| {{ end }} | ||
| {{ if .settings.facebook_url.Value }} | ||
| <a href="{{ .settings.facebook_url.Value }}" target="_blank" title="Jason Ernst on Facebook"><i class="fab fa-facebook fa-1x"></i></a> | ||
| {{ end }} | ||
| {{ if .settings.strava_url.Value }} | ||
| <a href="{{ .settings.strava_url.Value }}" target="_blank" title="Jason Ernst on Strava"><i class="fab fa-strava fa-1x"></i></a> | ||
| {{ end }} | ||
| {{ if .settings.spotify_url.Value }} | ||
| <a href="{{ .settings.spotify_url.Value }}" target="_blank" title="Jason Ernst on Spotify"><i class="fab fa-spotify fa-1x"></i></a> | ||
| {{ end }} | ||
| {{ if .settings.xbox_url.Value }} | ||
| <a href="{{ .settings.xbox_url.Value }}" target="_blank" title="Jason Ernst on Xbox"><i class="fab fa-xbox fa-1x"></i></a> | ||
| {{ end }} | ||
| {{ if .settings.steam_url.Value }} | ||
| <a href="{{ .settings.steam_url.Value }}" target="_blank" title="Jason Ernst on Steam"><i class="fab fa-steam fa-1x"></i></a> | ||
| {{ end }} | ||
| </div> <!-- /footer --> | ||
| {{ with .plugin_footer_html }}{{ . | rawHTML }}{{ end }} | ||
| <div class="footer"></div> | ||
| <div class="spacer version">Powered by <a href="https://github.com/compscidr/goblog" target="goblog {{ .version }}">goblog {{ .version }}</a></div> |
| {{ with index .settings "custom_footer_code" }}{{ if .Value }} | ||
| {{ .Value | rawHTML }} | ||
| {{ end }}{{ end }} | ||
| {{ with .plugin_footer_html }}{{ . | rawHTML }}{{ end }} |
Security: - Analytics: validate tracking ID with regex (alphanumeric + hyphens only) - Social icons: escape URLs and labels with html.EscapeString - Dynamic plugin loading now opt-in via ENABLE_DYNAMIC_PLUGINS env var Concurrency: - Scholar: use sync.Once for ensureScholar instead of bare nil check - Scheduled jobs: copy r.db under RLock before using in goroutine - Registry.Plugins() returns a copy of the internal slice Robustness: - Nil DB guards in getPluginSettings and InjectTemplateData - Scholar OnInit: check for gorm.ErrRecordNotFound specifically - Scholar OnInit: check db.Create error - Dynamic loader: return descriptive error on type assertion failure - Dynamic loader: document package main requirement - Yaegi symbols: fix import path key, export Plugin interface - Plugin registry stored on goblog struct, UpdateDb/Init/StartScheduledJobs called when wizard establishes DB connection Admin UI: - Fix duplicate plugin_footer_html injection in default footer - Theme reload only fires when theme value actually changed - Checkbox reverts on failed plugin toggle save - SettingDefinition comment updated to match plugin_settings table Migration: - Narrow cleanup to known plugin prefixes instead of all dot-namespaced keys - Registry test errors properly checked Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Adds a hybrid plugin system supporting compiled-in Go plugins and dynamically loaded plugins via the Yaegi interpreter. Includes three production plugins that replace previously hardcoded features.
Plugin Architecture
Interface — plugins implement only what they need via
BasePluginembedding:Settings()— plugin-specific config shown in admin UI, stored in dedicatedplugin_settingstableScheduledJobs()— periodic background tasksTemplateHead()/TemplateFooter()— inject HTML into pagesTemplateData()— inject data accessible in templates as{{ .plugins.name.key }}Pages()— register dynamic page typesRenderPage()— handle rendering plugin-owned pagesTwo loading modes:
main().gofiles intoplugins/dynamic/, loaded at startup via Yaegi — no recompilation neededAdmin Settings UI:
Included Plugins
Google Analytics (
plugins/analytics/)<head>when enabledSocial Icons (
plugins/socialicons/)TemplateFootersettingstable toplugin_settingsGoogle Scholar (
plugins/scholar/)ScholarIDfrom legacy page field to plugin settingsIntegration Changes
Blog.Render()helper wrapsc.HTML()with plugin data injection (all 32 calls migrated)plugin_head_html/plugin_footer_htmlinjection points in all theme headers/footersPageFilteron Blog filters nav pages — hides pages owned by disabled pluginsDynamicPagedefault case delegates to plugin registry, shows 404 for disabled plugin pagesFile Structure
Closes #480
Test plan
🤖 Generated with Claude Code