feat: relevance-ordered search, suggest rows and journal:reindex

- Engine search asks Typesense for the first 250 candidates, re-gates them
  in SQL, counts the gated rows and pages over them in rank order; pages
  after the first are no longer empty. An explicit sort overrides the rank.
- The settings row gates engine search, so it works straight after boot.
- ?fields=suggest serves id, title, slug, a plain-text excerpt,
  featured_image_url and published_at.
- Content is indexed as plain text; Post declares SearchIndexSchema.
- journal:reindex [--fresh] syncs every post into the index.
This commit is contained in:
Jakub Zych
2026-10-30 17:59:57 +01:00
parent 5b38a3b20d
commit 08e3d82ecd
9 changed files with 768 additions and 18 deletions

View File

@@ -2,14 +2,19 @@ package models
import (
"context"
"html"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/beachcomber"
"gorm.io/gorm"
)
var _ beachcomber.Searchable = (*Post)(nil)
var (
_ beachcomber.Searchable = (*Post)(nil)
_ beachcomber.IndexSchemaProvider = (*Post)(nil)
)
// SearchableAs is the Typesense collection name (D-12).
func (Post) SearchableAs() string { return "golem15_journal_posts" }
@@ -75,6 +80,54 @@ func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]a
"tag_ids": tagIDs,
"title": p.Title,
"excerpt": p.Excerpt,
"content": p.Content,
"content": PlainText(p.searchContent()),
}, nil
}
// searchContent is the post body to index: the rendered content_html, or
// content when nothing has been rendered.
func (p *Post) searchContent() string {
if strings.TrimSpace(p.ContentHTML) != "" {
return p.ContentHTML
}
return p.Content
}
// SearchIndexSchema is the Typesense collection schema. It applies only when
// the collection is created; journal:reindex --fresh recreates it.
func (Post) SearchIndexSchema() map[string]any {
return map[string]any{
"fields": []map[string]any{
{"name": "id", "type": "string"},
{"name": "title", "type": "string"},
{"name": "excerpt", "type": "string"},
{"name": "content", "type": "string"},
{"name": "published", "type": "int64"},
{"name": "published_at", "type": "int64"},
{"name": "is_pinned", "type": "int64"},
{"name": "user_id", "type": "int64"},
{"name": "created_at", "type": "int64"},
{"name": "category_ids", "type": "int64[]", "facet": false},
{"name": "tag_ids", "type": "int64[]", "facet": false},
},
}
}
// PlainText drops HTML tags and unescapes entities, with whitespace runs
// collapsed to one space. A tag counts as a word break.
func PlainText(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
b.WriteRune(' ')
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(html.UnescapeString(b.String())), " ")
}

View File

@@ -0,0 +1,59 @@
package models
import (
"sort"
"strings"
"testing"
"time"
)
func TestSearchDocumentIsPlainText(t *testing.T) {
past := time.Now().Add(-time.Hour)
p := &Post{
ID: 7,
Title: "Kurki",
Excerpt: "Zajawka",
Content: "## Kurki",
ContentHTML: `<h2 class="x">Kurki</h2><p>rosną&nbsp;w <a href="/las">lesie</a> &amp; pachną</p>`,
Published: true,
PublishedAt: &past,
}
doc, err := p.ToSearchableArray(t.Context(), nil)
if err != nil {
t.Fatal(err)
}
content, _ := doc["content"].(string)
if strings.ContainsAny(content, "<>") || strings.Contains(content, "href") || strings.Contains(content, "&amp;") {
t.Fatalf("content keeps markup: %q", content)
}
if content != "Kurki rosną w lesie & pachną" {
t.Fatalf("content = %q", content)
}
p.ContentHTML = ""
doc, _ = p.ToSearchableArray(t.Context(), nil)
if doc["content"] != "## Kurki" {
t.Fatalf("content without content_html = %q", doc["content"])
}
}
// TestSearchSchemaMatchesDocument: the schema declares exactly the keys
// ToSearchableArray emits.
func TestSearchSchemaMatchesDocument(t *testing.T) {
doc, err := (&Post{ID: 1}).ToSearchableArray(t.Context(), nil)
if err != nil {
t.Fatal(err)
}
var docKeys, schemaKeys []string
for k := range doc {
docKeys = append(docKeys, k)
}
for _, f := range (Post{}).SearchIndexSchema()["fields"].([]map[string]any) {
schemaKeys = append(schemaKeys, f["name"].(string))
}
sort.Strings(docKeys)
sort.Strings(schemaKeys)
if strings.Join(docKeys, ",") != strings.Join(schemaKeys, ",") {
t.Fatalf("document keys %v, schema fields %v", docKeys, schemaKeys)
}
}