From 2b228fd8172f81eb9dfda6c6ca90c2747d91f642 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 21:41:22 -0700 Subject: [PATCH 1/2] Plugin pages render through page_content.html, not custom templates Plugins no longer need theme-specific templates. The scholar plugin now generates its article list as HTML and passes it via plugin_content, which page_content.html renders as raw HTML. Regular content pages continue to use Showdown markdown rendering. This means: - Any theme works with any page plugin automatically - No page_research.html needed in themes - Plugin controls its own HTML layout - Citation count and journal info now shown inline - All output properly HTML-escaped Removed: page_research.html and research.html from all themes. Updated: page_content.html in all themes to check for plugin_content. Closes #513 Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/scholar/scholar.go | 50 ++++++++++++++++----- themes/default/templates/page_content.html | 19 ++++---- themes/default/templates/page_research.html | 30 ------------- themes/default/templates/research.html | 25 ----------- themes/forest/templates/page_content.html | 19 ++++---- themes/forest/templates/page_research.html | 24 ---------- themes/minimal/templates/page_content.html | 19 ++++---- themes/minimal/templates/page_research.html | 24 ---------- 8 files changed, 72 insertions(+), 138 deletions(-) delete mode 100644 themes/default/templates/page_research.html delete mode 100644 themes/default/templates/research.html delete mode 100644 themes/forest/templates/page_research.html delete mode 100644 themes/minimal/templates/page_research.html diff --git a/plugins/scholar/scholar.go b/plugins/scholar/scholar.go index f88a5a1..6a828d8 100644 --- a/plugins/scholar/scholar.go +++ b/plugins/scholar/scholar.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "goblog/blog" + "html" "log" "sort" + "strconv" "sync" "time" @@ -118,8 +120,8 @@ func (p *ScholarPlugin) RenderPage(ctx *gplugin.HookContext, pageType string) (s settings := ctx.Settings scholarID := settings["scholar_id"] if scholarID == "" { - return "page_research.html", gin.H{ - "errors": "Google Scholar ID not configured. Set it in the Scholar plugin settings.", + return "page_content.html", gin.H{ + "plugin_content": `
Google Scholar ID not configured. Set it in the Scholar plugin settings.
`, } } @@ -132,18 +134,44 @@ func (p *ScholarPlugin) RenderPage(ctx *gplugin.HookContext, pageType string) (s p.ensureScholar(settings) articles, err := p.sch.QueryProfileWithMemoryCache(scholarID, limit) - data := gin.H{} - if err == nil { - sortArticlesByDateDesc(articles) - p.sch.SaveCache(settings["profile_cache"], settings["article_cache"]) - data["articles"] = articles - } else { + if err != nil { log.Printf("Scholar query failed: %v", err) - data["articles"] = make([]*scholarlib.Article, 0) - data["errors"] = err.Error() + return "page_content.html", gin.H{ + "plugin_content": `
` + html.EscapeString(err.Error()) + `
`, + } } - return "page_research.html", data + sortArticlesByDateDesc(articles) + p.sch.SaveCache(settings["profile_cache"], settings["article_cache"]) + + return "page_content.html", gin.H{ + "plugin_content": renderArticlesHTML(articles), + } +} + +// renderArticlesHTML generates the HTML for the articles list. +func renderArticlesHTML(articles []*scholarlib.Article) string { + out := "" + for _, a := range articles { + out += `
` + out += `
` + html.EscapeString(a.Title) + `
` + if a.Authors != "" { + out += `
` + html.EscapeString(a.Authors) + `
` + } + out += `
` + if a.Year > 0 { + out += strconv.Itoa(a.Year) + } + if a.Journal != "" { + out += ` · ` + html.EscapeString(a.Journal) + } + if a.NumCitations > 0 { + out += ` · ` + strconv.Itoa(a.NumCitations) + ` citations` + } + out += `
` + out += `
` + } + return out } func (p *ScholarPlugin) ScheduledJobs() []gplugin.ScheduledJob { diff --git a/themes/default/templates/page_content.html b/themes/default/templates/page_content.html index 2ffa0be..c92c667 100644 --- a/themes/default/templates/page_content.html +++ b/themes/default/templates/page_content.html @@ -21,15 +21,18 @@

{{ .page.Title }}

{{ if .is_admin }}

Edit this page

{{ end }} + {{ if .plugin_content }} +
{{ .plugin_content | rawHTML }}
+ {{ else }}
+ + + + {{ end }} - - - - {{ template "footer.html" .}} diff --git a/themes/default/templates/page_research.html b/themes/default/templates/page_research.html deleted file mode 100644 index f998526..0000000 --- a/themes/default/templates/page_research.html +++ /dev/null @@ -1,30 +0,0 @@ -{{ template "header.html" .}} - - {{ if .page.HasHero }} -
-   -
- {{ end }} - -
-

{{ .page.Title }}

- {{ if .is_admin }} -

Edit this page

- {{ end }} - {{ if .errors }} - - {{ end }} - {{range .articles}} -
-
- {{.Title}}
- {{.Authors}}
-
-
{{.Year}}
-
- {{end}} -
- -{{ template "footer.html" .}} diff --git a/themes/default/templates/research.html b/themes/default/templates/research.html deleted file mode 100644 index 0292b55..0000000 --- a/themes/default/templates/research.html +++ /dev/null @@ -1,25 +0,0 @@ -{{ template "header.html" .}} - -
-   -
- -
-

Research and Publications

- {{ if .errors }} - - {{ end }} - {{range .articles}} -
-
- {{.Title}}
- {{.Authors}}
-
-
{{.Year}}
-
- {{end}} -
- -{{ template "footer.html" .}} \ No newline at end of file diff --git a/themes/forest/templates/page_content.html b/themes/forest/templates/page_content.html index 453e7f5..a48c068 100644 --- a/themes/forest/templates/page_content.html +++ b/themes/forest/templates/page_content.html @@ -5,15 +5,18 @@

{{ .page.Title }}

{{ if .is_admin }}

Edit this page

{{ end }} + {{ if .plugin_content }} +
{{ .plugin_content | rawHTML }}
+ {{ else }}
+ + + + {{ end }} - - - - {{ template "footer.html" .}} diff --git a/themes/forest/templates/page_research.html b/themes/forest/templates/page_research.html deleted file mode 100644 index ca191f1..0000000 --- a/themes/forest/templates/page_research.html +++ /dev/null @@ -1,24 +0,0 @@ -{{ template "header.html" .}} - -
-

{{ .page.Title }}

- {{ if .is_admin }} -

Edit this page

- {{ end }} - {{ if .errors }} - - {{ end }} - {{range .articles}} -
-
- {{.Title}}
- {{.Authors}}
-
-
{{.Year}}
-
- {{end}} -
- -{{ template "footer.html" .}} diff --git a/themes/minimal/templates/page_content.html b/themes/minimal/templates/page_content.html index 33ca2e3..3b91e6b 100644 --- a/themes/minimal/templates/page_content.html +++ b/themes/minimal/templates/page_content.html @@ -5,15 +5,18 @@

{{ .page.Title }}

{{ if .is_admin }}

Edit this page

{{ end }} + {{ if .plugin_content }} +
{{ .plugin_content | rawHTML }}
+ {{ else }}
+ + + + {{ end }} - - - - {{ template "footer.html" .}} diff --git a/themes/minimal/templates/page_research.html b/themes/minimal/templates/page_research.html deleted file mode 100644 index 90e874d..0000000 --- a/themes/minimal/templates/page_research.html +++ /dev/null @@ -1,24 +0,0 @@ -{{ template "header.html" .}} - -
-

{{ .page.Title }}

- {{ if .is_admin }} -

Edit this page

- {{ end }} - {{ if .errors }} - - {{ end }} - {{range .articles}} -
-
- {{.Title}}
- {{.Authors}}
-
-
{{.Year}}
-
- {{end}} -
- -{{ template "footer.html" .}} From c81c85ca1ee77711c3ff47ef4cd8275e9c3c6f39 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 21:50:37 -0700 Subject: [PATCH 2/2] Address review feedback on plugin content rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use strings.Builder instead of string concatenation for O(n) HTML generation - Join meta fields (year, journal, citations) with separator to avoid leading middot - Validate URLs with safeHref — only allow http/https schemes - Return "No publications found" for empty article list - Use has_plugin_content boolean flag in templates instead of truthy string check - Add role="alert" to warning and error alert divs for accessibility - Add tests: empty articles, article rendering with all fields, XSS escaping, safeHref validation Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/scholar/scholar.go | 68 ++++++++++++----- plugins/scholar/scholar_test.go | 85 +++++++++++++++++++++- themes/default/templates/page_content.html | 2 +- themes/forest/templates/page_content.html | 2 +- themes/minimal/templates/page_content.html | 2 +- 5 files changed, 136 insertions(+), 23 deletions(-) diff --git a/plugins/scholar/scholar.go b/plugins/scholar/scholar.go index 6a828d8..e23f823 100644 --- a/plugins/scholar/scholar.go +++ b/plugins/scholar/scholar.go @@ -9,8 +9,10 @@ import ( "goblog/blog" "html" "log" + "net/url" "sort" "strconv" + "strings" "sync" "time" @@ -117,12 +119,13 @@ func (p *ScholarPlugin) RenderPage(ctx *gplugin.HookContext, pageType string) (s return "", nil } + data := gin.H{"has_plugin_content": true} + settings := ctx.Settings scholarID := settings["scholar_id"] if scholarID == "" { - return "page_content.html", gin.H{ - "plugin_content": `
Google Scholar ID not configured. Set it in the Scholar plugin settings.
`, - } + data["plugin_content"] = `` + return "page_content.html", data } limitStr := settings["article_limit"] @@ -136,42 +139,69 @@ func (p *ScholarPlugin) RenderPage(ctx *gplugin.HookContext, pageType string) (s articles, err := p.sch.QueryProfileWithMemoryCache(scholarID, limit) if err != nil { log.Printf("Scholar query failed: %v", err) - return "page_content.html", gin.H{ - "plugin_content": `
` + html.EscapeString(err.Error()) + `
`, - } + data["plugin_content"] = `` + return "page_content.html", data } sortArticlesByDateDesc(articles) p.sch.SaveCache(settings["profile_cache"], settings["article_cache"]) - return "page_content.html", gin.H{ - "plugin_content": renderArticlesHTML(articles), + data["plugin_content"] = renderArticlesHTML(articles) + return "page_content.html", data +} + +// safeHref returns the URL only if it uses http or https scheme, otherwise empty. +func safeHref(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + if u.Scheme != "http" && u.Scheme != "https" { + return "" } + return html.EscapeString(rawURL) } // renderArticlesHTML generates the HTML for the articles list. func renderArticlesHTML(articles []*scholarlib.Article) string { - out := "" + if len(articles) == 0 { + return `

No publications found.

` + } + + var b strings.Builder for _, a := range articles { - out += `
` - out += `` + b.WriteString(`
`) + + // Title — link only if URL is safe + href := safeHref(a.ScholarURL) + if href != "" { + b.WriteString(``) + } else { + b.WriteString(`
` + html.EscapeString(a.Title) + `
`) + } + if a.Authors != "" { - out += `
` + html.EscapeString(a.Authors) + `
` + b.WriteString(`
` + html.EscapeString(a.Authors) + `
`) } - out += `
` + + // Meta line: year · journal · citations + var meta []string if a.Year > 0 { - out += strconv.Itoa(a.Year) + meta = append(meta, strconv.Itoa(a.Year)) } if a.Journal != "" { - out += ` · ` + html.EscapeString(a.Journal) + meta = append(meta, html.EscapeString(a.Journal)) } if a.NumCitations > 0 { - out += ` · ` + strconv.Itoa(a.NumCitations) + ` citations` + meta = append(meta, strconv.Itoa(a.NumCitations)+" citations") + } + if len(meta) > 0 { + b.WriteString(`
` + strings.Join(meta, " · ") + `
`) } - out += `
` - out += `
` + + b.WriteString(`
`) } - return out + return b.String() } func (p *ScholarPlugin) ScheduledJobs() []gplugin.ScheduledJob { diff --git a/plugins/scholar/scholar_test.go b/plugins/scholar/scholar_test.go index f3ca677..1b816bd 100644 --- a/plugins/scholar/scholar_test.go +++ b/plugins/scholar/scholar_test.go @@ -1,8 +1,10 @@ package scholar import ( - scholarlib "github.com/compscidr/scholar" + "strings" "testing" + + scholarlib "github.com/compscidr/scholar" ) func TestSortArticlesByDateDesc(t *testing.T) { @@ -23,3 +25,84 @@ func TestSortArticlesByDateDesc(t *testing.T) { } } } + +func TestRenderArticlesHTML_Empty(t *testing.T) { + result := renderArticlesHTML(nil) + if !strings.Contains(result, "No publications found") { + t.Errorf("expected 'No publications found' for empty list, got %q", result) + } +} + +func TestRenderArticlesHTML_WithArticles(t *testing.T) { + articles := []*scholarlib.Article{ + { + Title: "Test Paper", + Authors: "Alice, Bob", + ScholarURL: "https://scholar.google.com/test", + Year: 2024, + Journal: "Test Journal", + NumCitations: 42, + }, + } + result := renderArticlesHTML(articles) + + if !strings.Contains(result, "Test Paper") { + t.Error("expected title in output") + } + if !strings.Contains(result, "Alice, Bob") { + t.Error("expected authors in output") + } + if !strings.Contains(result, "2024") { + t.Error("expected year in output") + } + if !strings.Contains(result, "Test Journal") { + t.Error("expected journal in output") + } + if !strings.Contains(result, "42 citations") { + t.Error("expected citation count in output") + } + if !strings.Contains(result, `href="https://scholar.google.com/test"`) { + t.Error("expected scholar URL in href") + } +} + +func TestRenderArticlesHTML_XSSEscaping(t *testing.T) { + articles := []*scholarlib.Article{ + { + Title: ``, + Authors: `Bob "the hacker"`, + ScholarURL: "https://scholar.google.com/safe", + }, + } + result := renderArticlesHTML(articles) + + if strings.Contains(result, "