feat: WO-PS-002–008 SDK type packages

- rbac/: Role type, constants, HasPermission, RoleFromString
- blocks/: BlockFunc, BlockMeta, BlockContext, context accessors, BlockRegistry interface
- templates/: TemplateFunc, meta types, TemplateRegistry interface
- auth/: Claims, PublicClaims, context extractors
- content/: Content interface, AuthorProfile/PageInfo/PostInfo types
- settings/: Settings interface, map accessor helpers
- gating/: AccessRule, AccessResult, EvaluateAccess, Gating interface
- crypto/: Crypto interface (Encrypt/Decrypt)
- render/: BlockNoteToHTML standalone renderer
- video/: ParseEmbedURL, EmbedIframeURL
- ai/: ToolDefinition, ToolResult, ToolRegistry interface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-04-30 22:32:29 +08:00
parent 0991b791b1
commit 99fc63ddfd
28 changed files with 1195 additions and 11 deletions

View File

@ -1 +0,0 @@
package ai

28
ai/tools.go Normal file
View File

@ -0,0 +1,28 @@
package ai
import "context"
// ToolResult holds the output of a tool execution.
type ToolResult struct {
Content string `json:"content"`
Error string `json:"error,omitempty"`
}
// ToolHandler is the function signature for executing a tool.
type ToolHandler func(ctx context.Context, params map[string]any) (*ToolResult, error)
// ToolDefinition describes a registered tool.
type ToolDefinition struct {
Slug string
Name string
Description string
ParameterSchema map[string]any
Handler ToolHandler
}
// ToolRegistry is the interface for registering AI tools.
// Plugins use this to register their custom tools.
// The CMS provides the concrete implementation.
type ToolRegistry interface {
Register(tool *ToolDefinition)
}

18
auth/claims.go Normal file
View File

@ -0,0 +1,18 @@
package auth
import "github.com/google/uuid"
// Claims represents admin user JWT claims.
// Plugins receive these from context — they don't parse JWTs themselves.
type Claims struct {
UserID uuid.UUID
Email string
Role string
}
// PublicClaims represents public/community user JWT claims.
type PublicClaims struct {
UserID uuid.UUID
Email string
Username string
}

34
auth/context.go Normal file
View File

@ -0,0 +1,34 @@
package auth
import "context"
type contextKey string
const (
userContextKey contextKey = "user"
publicUserContextKey contextKey = "public_user"
)
// GetUserFromContext retrieves admin user claims from context.
func GetUserFromContext(ctx context.Context) (*Claims, bool) {
claims, ok := ctx.Value(userContextKey).(*Claims)
return claims, ok
}
// GetPublicUserFromContext retrieves public user claims from context.
func GetPublicUserFromContext(ctx context.Context) (*PublicClaims, bool) {
claims, ok := ctx.Value(publicUserContextKey).(*PublicClaims)
return claims, ok
}
// WithUser adds admin user claims to context.
// Used by CMS auth middleware — not typically called by plugins.
func WithUser(ctx context.Context, claims *Claims) context.Context {
return context.WithValue(ctx, userContextKey, claims)
}
// WithPublicUser adds public user claims to context.
// Used by CMS auth middleware — not typically called by plugins.
func WithPublicUser(ctx context.Context, claims *PublicClaims) context.Context {
return context.WithValue(ctx, publicUserContextKey, claims)
}

View File

@ -1 +0,0 @@
package auth

333
blocks/context.go Normal file
View File

@ -0,0 +1,333 @@
package blocks
import (
"context"
"net/http"
"time"
"github.com/google/uuid"
)
type templateKeyContextKey struct{}
func WithTemplateKey(ctx context.Context, templateKey string) context.Context {
return context.WithValue(ctx, templateKeyContextKey{}, templateKey)
}
func GetTemplateKey(ctx context.Context) string {
if v, ok := ctx.Value(templateKeyContextKey{}).(string); ok {
return v
}
return ""
}
type queriesContextKey struct{}
func WithQueries(ctx context.Context, queries any) context.Context {
return context.WithValue(ctx, queriesContextKey{}, queries)
}
func GetQueries(ctx context.Context) any {
return ctx.Value(queriesContextKey{})
}
type currentPageContextKey struct{}
func WithCurrentPage(ctx context.Context, page any) context.Context {
return context.WithValue(ctx, currentPageContextKey{}, page)
}
func GetCurrentPage(ctx context.Context) any {
return ctx.Value(currentPageContextKey{})
}
type currentBlogPostContextKey struct{}
func WithCurrentBlogPost(ctx context.Context, post any) context.Context {
return context.WithValue(ctx, currentBlogPostContextKey{}, post)
}
func GetCurrentBlogPost(ctx context.Context) any {
return ctx.Value(currentBlogPostContextKey{})
}
type currentAuthorContextKey struct{}
func WithCurrentAuthor(ctx context.Context, author any) context.Context {
return context.WithValue(ctx, currentAuthorContextKey{}, author)
}
func GetCurrentAuthor(ctx context.Context) any {
return ctx.Value(currentAuthorContextKey{})
}
type currentCategoryContextKey struct{}
func WithCurrentCategory(ctx context.Context, category any) context.Context {
return context.WithValue(ctx, currentCategoryContextKey{}, category)
}
func GetCurrentCategory(ctx context.Context) any {
return ctx.Value(currentCategoryContextKey{})
}
type requestedPathContextKey struct{}
func WithRequestedPath(ctx context.Context, path string) context.Context {
return context.WithValue(ctx, requestedPathContextKey{}, path)
}
func GetRequestedPath(ctx context.Context) string {
if v, ok := ctx.Value(requestedPathContextKey{}).(string); ok {
return v
}
return ""
}
type injectedSlotsContextKey struct{}
func WithInjectedSlots(ctx context.Context, slots map[string]string) context.Context {
return context.WithValue(ctx, injectedSlotsContextKey{}, slots)
}
func GetInjectedSlotContent(ctx context.Context, slotName string) string {
if slots, ok := ctx.Value(injectedSlotsContextKey{}).(map[string]string); ok {
return slots[slotName]
}
return ""
}
func GetInjectedSlots(ctx context.Context) map[string]string {
if slots, ok := ctx.Value(injectedSlotsContextKey{}).(map[string]string); ok {
return slots
}
return nil
}
type masterPageContextKey struct{}
func WithMasterPage(ctx context.Context, masterPage any) context.Context {
return context.WithValue(ctx, masterPageContextKey{}, masterPage)
}
func GetMasterPage(ctx context.Context) any {
return ctx.Value(masterPageContextKey{})
}
func IsMasterPageContext(ctx context.Context) bool {
return ctx.Value(masterPageContextKey{}) != nil
}
type requestContextKey struct{}
func WithRequest(ctx context.Context, r *http.Request) context.Context {
return context.WithValue(ctx, requestContextKey{}, r)
}
func GetRequest(ctx context.Context) *http.Request {
if r, ok := ctx.Value(requestContextKey{}).(*http.Request); ok {
return r
}
return nil
}
type isEditorContextKey struct{}
func WithIsEditor(ctx context.Context, isEditor bool) context.Context {
return context.WithValue(ctx, isEditorContextKey{}, isEditor)
}
func IsEditor(ctx context.Context) bool {
if v, ok := ctx.Value(isEditorContextKey{}).(bool); ok {
return v
}
return false
}
type expectedSlotsContextKey struct{}
func WithExpectedSlots(ctx context.Context, slots []string) context.Context {
return context.WithValue(ctx, expectedSlotsContextKey{}, slots)
}
func GetExpectedSlots(ctx context.Context) []string {
if v, ok := ctx.Value(expectedSlotsContextKey{}).([]string); ok {
return v
}
return nil
}
type blockIDContextKey struct{}
func WithBlockID(ctx context.Context, id uuid.UUID) context.Context {
return context.WithValue(ctx, blockIDContextKey{}, id)
}
func GetBlockID(ctx context.Context) uuid.UUID {
if v, ok := ctx.Value(blockIDContextKey{}).(uuid.UUID); ok {
return v
}
return uuid.Nil
}
type currentPageIDContextKey struct{}
func WithCurrentPageID(ctx context.Context, id uuid.UUID) context.Context {
return context.WithValue(ctx, currentPageIDContextKey{}, id)
}
func GetCurrentPageID(ctx context.Context) uuid.UUID {
if v, ok := ctx.Value(currentPageIDContextKey{}).(uuid.UUID); ok {
return v
}
return uuid.Nil
}
type slotRendererContextKey struct{}
func WithSlotRenderer(ctx context.Context, r SlotRenderer) context.Context {
return context.WithValue(ctx, slotRendererContextKey{}, r)
}
func GetSlotRenderer(ctx context.Context) SlotRenderer {
if r, ok := ctx.Value(slotRendererContextKey{}).(SlotRenderer); ok {
return r
}
return nil
}
type humanProofBannerContextKey struct{}
func WithHumanProofBanner(ctx context.Context, data *HumanProofBannerData) context.Context {
return context.WithValue(ctx, humanProofBannerContextKey{}, data)
}
func GetHumanProofBanner(ctx context.Context) *HumanProofBannerData {
if v, ok := ctx.Value(humanProofBannerContextKey{}).(*HumanProofBannerData); ok {
return v
}
return nil
}
type embedResolverContextKey struct{}
func WithEmbedResolver(ctx context.Context, resolver any) context.Context {
return context.WithValue(ctx, embedResolverContextKey{}, resolver)
}
func GetEmbedResolver(ctx context.Context) any {
return ctx.Value(embedResolverContextKey{})
}
// RenderSlot renders children for the current container block.
func RenderSlot(ctx context.Context) string {
blockID := GetBlockID(ctx)
if blockID == uuid.Nil {
return ""
}
renderer := GetSlotRenderer(ctx)
if renderer == nil {
return ""
}
return renderer.RenderContainerSlot(ctx, blockID)
}
// BlockContext provides contextual information available to all block templates.
type BlockContext struct {
URL string `pongo2:"url"`
Path string `pongo2:"path"`
Slug string `pongo2:"slug"`
PageID string `pongo2:"pageId"`
PageTitle string `pongo2:"pageTitle"`
TemplateKey string `pongo2:"templateKey"`
IsEditor bool `pongo2:"isEditor"`
Timestamp int64 `pongo2:"timestamp"`
IsLoggedIn bool `pongo2:"isLoggedIn"`
UserID string `pongo2:"userId"`
UserEmail string `pongo2:"userEmail"`
UserRole string `pongo2:"userRole"`
Method string `pongo2:"method"`
Host string `pongo2:"host"`
Query map[string]string `pongo2:"query"`
Referrer string `pongo2:"referrer"`
UserAgent string `pongo2:"userAgent"`
IP string `pongo2:"ip"`
Cookies map[string]string `pongo2:"cookies"`
Headers map[string]string `pongo2:"headers"`
Country string `pongo2:"country"`
City string `pongo2:"city"`
Timezone string `pongo2:"timezone"`
Now time.Time `pongo2:"now"`
CurrentAuthor map[string]any `pongo2:"currentAuthor"`
CurrentPost map[string]any `pongo2:"currentPost"`
CurrentCategory map[string]any `pongo2:"currentCategory"`
Site map[string]any `pongo2:"site"`
IsPublicLoggedIn bool `pongo2:"isPublicLoggedIn"`
PublicUserID string `pongo2:"publicUserId"`
PublicUsername string `pongo2:"publicUsername"`
PublicDisplayName string `pongo2:"publicDisplayName"`
PublicEmailVerified bool `pongo2:"publicEmailVerified"`
DetailRowID string `pongo2:"detailRowId"`
DetailTableID string `pongo2:"detailTableId"`
DetailRowData map[string]any `pongo2:"detailRowData"`
BlogIndexURL string `pongo2:"blogIndexUrl"`
CategoryPageURL string `pongo2:"categoryPageUrl"`
}
type blockContextKey struct{}
func WithBlockContext(ctx context.Context, blockCtx *BlockContext) context.Context {
return context.WithValue(ctx, blockContextKey{}, blockCtx)
}
func GetBlockContext(ctx context.Context) *BlockContext {
if bc, ok := ctx.Value(blockContextKey{}).(*BlockContext); ok {
return bc
}
return nil
}
// ToMap converts the BlockContext to a map for pongo2 template injection.
func (bc *BlockContext) ToMap() map[string]any {
return map[string]any{
"url": bc.URL, "path": bc.Path, "slug": bc.Slug,
"pageId": bc.PageID, "pageTitle": bc.PageTitle,
"templateKey": bc.TemplateKey, "isEditor": bc.IsEditor,
"timestamp": bc.Timestamp, "now": bc.Now,
"isLoggedIn": bc.IsLoggedIn, "userId": bc.UserID,
"userEmail": bc.UserEmail, "userRole": bc.UserRole,
"method": bc.Method, "host": bc.Host, "query": bc.Query,
"referrer": bc.Referrer, "userAgent": bc.UserAgent,
"ip": bc.IP, "cookies": bc.Cookies, "headers": bc.Headers,
"country": bc.Country, "city": bc.City, "timezone": bc.Timezone,
"currentAuthor": bc.CurrentAuthor, "currentPost": bc.CurrentPost,
"currentCategory": bc.CurrentCategory, "site": bc.Site,
"isPublicLoggedIn": bc.IsPublicLoggedIn,
"publicUserId": bc.PublicUserID, "publicUsername": bc.PublicUsername,
"publicDisplayName": bc.PublicDisplayName,
"publicEmailVerified": bc.PublicEmailVerified,
"detailRowId": bc.DetailRowID, "detailTableId": bc.DetailTableID,
"detailRowData": bc.DetailRowData,
"blogIndexUrl": bc.BlogIndexURL, "categoryPageUrl": bc.CategoryPageURL,
}
}
// DetailRowInfo holds the resolved data table row for detail pages.
type DetailRowInfo struct {
TableID string
RowID string
Data map[string]any
}
type detailRowKey struct{}
func WithDetailRow(ctx context.Context, tableID, rowID string, data map[string]any) context.Context {
return context.WithValue(ctx, detailRowKey{}, &DetailRowInfo{TableID: tableID, RowID: rowID, Data: data})
}
func GetDetailRow(ctx context.Context) *DetailRowInfo {
if v, ok := ctx.Value(detailRowKey{}).(*DetailRowInfo); ok {
return v
}
return nil
}

View File

@ -1 +0,0 @@
package blocks

10
blocks/registry.go Normal file
View File

@ -0,0 +1,10 @@
package blocks
import "io/fs"
// BlockRegistry is the interface that plugins use to register blocks.
type BlockRegistry interface {
Register(meta BlockMeta, fn BlockFunc)
RegisterTemplateOverride(templateKey, blockKey string, fn BlockFunc)
LoadSchemasFromFS(fsys fs.FS) error
}

106
blocks/types.go Normal file
View File

@ -0,0 +1,106 @@
package blocks
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
)
// BlockFunc renders a block given its content data.
type BlockFunc func(ctx context.Context, content map[string]any) string
// BlockCategory represents categories for organizing blocks in the palette.
type BlockCategory string
const (
CategoryContent BlockCategory = "content"
CategoryLayout BlockCategory = "layout"
CategoryNavigation BlockCategory = "navigation"
CategoryBlog BlockCategory = "blog"
CategoryTheme BlockCategory = "theme"
)
// BlockMeta provides metadata about a block type for admin UI and validation.
type BlockMeta struct {
Key string
Title string
Description string
Category BlockCategory
HasInternalSlot bool
Source string
Hidden bool
EditorJS string
}
// BlockNode represents a block with its content and children for rendering.
type BlockNode struct {
ID uuid.UUID
BlockKey string
Title string
Content map[string]any
HtmlContent string
Children []*BlockNode
SortOrder int
}
// SlotRenderer is the interface for rendering container slots.
type SlotRenderer interface {
RenderContainerSlot(ctx context.Context, containerID uuid.UUID) string
}
// HumanProofBannerData holds data for the public human proof banner.
type HumanProofBannerData struct {
ActiveTimeMinutes int
KeystrokeCount int
SessionCount int
PostSlug string
}
// RenderHumanProofBanner renders the public-facing banner HTML.
func RenderHumanProofBanner(hp *HumanProofBannerData) string {
return fmt.Sprintf(`<div id="hp-banner-%[4]s" data-human-proof-banner class="rounded-lg overflow-hidden my-6">`+
`<div class="flex items-center justify-between py-3 px-5 bg-muted border border-border rounded-lg">`+
`<div class="flex items-center gap-3">`+
`<div class="w-8 h-8 bg-primary text-primary-foreground rounded-md flex items-center justify-center text-xs font-bold">HP</div>`+
`<div>`+
`<div class="font-semibold text-sm text-foreground">Human Proof</div>`+
`<div class="text-xs text-muted-foreground">%[1]d min active &middot; %[2]s keystrokes &middot; %[3]d sessions</div>`+
`</div></div>`+
`<button class="px-4 py-1.5 bg-primary text-primary-foreground rounded-md text-sm font-medium cursor-pointer hover:bg-primary/90 border-0" data-action="watch">Watch this post being written</button>`+
`</div></div>`+
`<script src="/assets/human-proof-player.js" defer></script>`+
`<script>document.addEventListener("DOMContentLoaded",function(){if(window.HumanProof){window.HumanProof.init({postSlug:%[4]q,bannerId:"hp-banner-%[4]s",contentId:"hp-content-%[4]s"})}});</script>`,
hp.ActiveTimeMinutes,
formatThousands(hp.KeystrokeCount),
hp.SessionCount,
hp.PostSlug,
)
}
func formatThousands(n int) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
}
s := fmt.Sprintf("%d", n)
var result []byte
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result = append(result, ',')
}
result = append(result, byte(c))
}
return string(result)
}
// ResolveMediaPath converts a media: prefixed path to a /media/ URL path.
func ResolveMediaPath(path string) string {
if after, ok := strings.CutPrefix(path, "media:"); ok {
return "/media/" + after
}
if _, err := uuid.Parse(path); err == nil {
return "/media/" + path
}
return path
}

47
content/content.go Normal file
View File

@ -0,0 +1,47 @@
package content
import (
"context"
"github.com/google/uuid"
)
// AuthorProfile is a simplified author representation for plugins.
type AuthorProfile struct {
ID uuid.UUID
Name string
Slug string
Bio string
AvatarURL string
Website string
SocialLinks map[string]string
}
// PageInfo is a simplified page representation for plugins.
type PageInfo struct {
ID uuid.UUID
Slug string
Title string
}
// PostInfo is a simplified post representation for plugins.
type PostInfo struct {
ID uuid.UUID
Slug string
Title string
Excerpt string
FeaturedImageURL string
AuthorID uuid.UUID
}
// Content provides content access for plugins.
// The CMS implements this interface and wires it into ServiceDeps.
type Content interface {
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
GetPage(ctx context.Context, slug string) (*PageInfo, error)
GetPost(ctx context.Context, slug string) (*PostInfo, error)
Slugify(text string) string
BlockNoteToHTML(doc any) string
GenerateExcerpt(html string, maxLen int) string
StripHTML(s string) string
}

View File

@ -1 +0,0 @@
package content

7
crypto/crypto.go Normal file
View File

@ -0,0 +1,7 @@
package crypto
// Crypto provides encryption/decryption for plugins.
type Crypto interface {
EncryptSecret(plaintext string) (string, error)
DecryptSecret(ciphertext string) (string, error)
}

View File

@ -1 +0,0 @@
package crypto

View File

@ -1 +0,0 @@
package gating

45
gating/gating.go Normal file
View File

@ -0,0 +1,45 @@
package gating
import (
"context"
"github.com/google/uuid"
)
// AccessRule defines content access requirements.
type AccessRule struct {
MinTierLevel int
OverrideTierID string
TeaserMode string // "hard", "soft", "none"
TeaserPercent int
}
// AccessResult is the result of evaluating access for a user.
type AccessResult struct {
HasAccess bool
TeaserMode string
TeaserPercent int
RequiredLevel int
}
// EvaluateAccess checks whether a user's tier level grants access based on the rule.
func EvaluateAccess(userTierLevel int, rule *AccessRule) AccessResult {
if rule == nil {
return AccessResult{HasAccess: true}
}
if userTierLevel >= rule.MinTierLevel {
return AccessResult{HasAccess: true}
}
return AccessResult{
HasAccess: false,
TeaserMode: rule.TeaserMode,
TeaserPercent: rule.TeaserPercent,
RequiredLevel: rule.MinTierLevel,
}
}
// Gating provides gating access for plugins.
type Gating interface {
GetSubscriberTierLevel(ctx context.Context, userID uuid.UUID) (int, error)
EvaluateAccess(userTierLevel int, rule *AccessRule) AccessResult
}

5
go.mod
View File

@ -1,3 +1,8 @@
module git.dev.alexdunmow.com/ninja/core
go 1.26
require (
github.com/a-h/templ v0.3.1001
github.com/google/uuid v1.6.0
)

6
go.sum Normal file
View File

@ -0,0 +1,6 @@
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

View File

@ -1 +0,0 @@
package rbac

44
rbac/role.go Normal file
View File

@ -0,0 +1,44 @@
package rbac
type Role string
const (
RoleViewer Role = "viewer"
RoleAdmin Role = "admin"
RoleSuperadmin Role = "superadmin"
RolePublic Role = "public"
)
var RoleHierarchy = map[Role]int{
RoleViewer: 0,
RoleAdmin: 1,
RoleSuperadmin: 2,
}
func HasPermission(userRole Role, requiredRole Role) bool {
if requiredRole == "" {
return true
}
userLevel, ok := RoleHierarchy[userRole]
if !ok {
return false
}
requiredLevel, ok := RoleHierarchy[requiredRole]
if !ok {
return false
}
return userLevel >= requiredLevel
}
func RoleFromString(s string) Role {
switch s {
case "viewer":
return RoleViewer
case "admin":
return RoleAdmin
case "superadmin":
return RoleSuperadmin
default:
return ""
}
}

330
render/blocknote.go Normal file
View File

@ -0,0 +1,330 @@
package render
import (
"encoding/json"
"fmt"
"html"
"strings"
)
// BlockNoteToHTML converts a BlockNote document (map with "blocks" key) to HTML.
func BlockNoteToHTML(doc map[string]any) string {
blocks := blocksFromRaw(doc["blocks"])
if len(blocks) == 0 {
return ""
}
return renderBlocks(blocks)
}
func renderBlocks(blocks []any) string {
var sb strings.Builder
var currentListType string
var listItems []map[string]any
flushList := func() {
if len(listItems) == 0 {
return
}
tag := "ul"
listStyle := "list-disc"
if currentListType == "numberedListItem" {
tag = "ol"
listStyle = "list-decimal"
}
fmt.Fprintf(&sb, "<%s class=\"my-4 pl-6 space-y-2 %s\">\n", tag, listStyle)
for _, item := range listItems {
content := inlineContentFromRaw(item["content"])
childrenHTML := renderChildren(item["children"])
sb.WriteString("<li>")
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</li>\n")
}
fmt.Fprintf(&sb, "</%s>\n", tag)
listItems = nil
currentListType = ""
}
for _, block := range blocks {
blockMap, ok := block.(map[string]any)
if !ok {
continue
}
blockType, _ := blockMap["type"].(string)
if blockType == "bulletListItem" || blockType == "numberedListItem" {
if currentListType != "" && currentListType != blockType {
flushList()
}
currentListType = blockType
listItems = append(listItems, blockMap)
continue
}
flushList()
sb.WriteString(renderBlock(blockMap))
}
flushList()
return sb.String()
}
func inlineContentFromRaw(raw any) []any {
switch v := raw.(type) {
case []any:
return v
case []map[string]any:
items := make([]any, 0, len(v))
for _, item := range v {
items = append(items, item)
}
return items
case string:
if v == "" {
return nil
}
return []any{map[string]any{"type": "text", "text": v}}
default:
return nil
}
}
func blocksFromRaw(raw any) []any {
switch v := raw.(type) {
case []any:
return v
case []map[string]any:
items := make([]any, 0, len(v))
for _, item := range v {
items = append(items, item)
}
return items
default:
return nil
}
}
func renderBlock(block map[string]any) string {
blockType, _ := block["type"].(string)
props, _ := block["props"].(map[string]any)
content := inlineContentFromRaw(block["content"])
childrenHTML := renderChildren(block["children"])
var sb strings.Builder
switch blockType {
case "paragraph":
sb.WriteString("<p class=\"my-4\">")
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</p>\n")
case "heading":
level := 2
if l, ok := props["level"].(float64); ok {
level = int(l)
} else if l, ok := props["level"].(int); ok {
level = l
}
if level < 1 {
level = 1
}
if level > 6 {
level = 6
}
fmt.Fprintf(&sb, "<h%d class=\"mt-8 mb-4 font-bold\">", level)
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
fmt.Fprintf(&sb, "</h%d>\n", level)
case "quote":
sb.WriteString("<blockquote class=\"my-4 border-l-4 border-border pl-4 text-muted-foreground\">")
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</blockquote>\n")
case "codeBlock":
lang := ""
if l, ok := props["language"].(string); ok {
lang = l
}
fmt.Fprintf(&sb, `<pre class="my-4"><code class="language-%s">`, html.EscapeString(lang))
sb.WriteString(renderInlineContent(content))
sb.WriteString("</code></pre>\n")
case "image":
url := ""
caption := ""
alt := ""
if u, ok := props["url"].(string); ok {
url = u
}
if c, ok := props["caption"].(string); ok {
caption = c
}
if a, ok := props["alt"].(string); ok {
alt = a
}
if alt == "" {
alt = caption
}
sb.WriteString(`<figure class="my-6">`)
fmt.Fprintf(&sb, `<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
if caption != "" {
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
}
sb.WriteString("</figure>\n")
case "table":
sb.WriteString(`<div class="overflow-x-auto my-6"><table class="min-w-full border-collapse border border-border">`)
if tableContent, ok := block["content"].(map[string]any); ok {
if rows, ok := tableContent["rows"].([]any); ok && len(rows) > 0 {
renderTableRow := func(row any, isHeader bool) {
if rowMap, ok := row.(map[string]any); ok {
if cells, ok := rowMap["cells"].([]any); ok {
for _, cell := range cells {
cellTag := "td"
cellClass := "px-4 py-2 text-sm"
if isHeader {
cellTag = "th"
cellClass = "px-4 py-3 text-left text-sm font-semibold"
}
fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass)
sb.WriteString(renderInlineContent(inlineContentFromRaw(cell)))
fmt.Fprintf(&sb, "</%s>", cellTag)
}
}
}
}
sb.WriteString("<thead><tr class=\"bg-muted\">")
renderTableRow(rows[0], true)
sb.WriteString("</tr></thead>")
if len(rows) > 1 {
sb.WriteString("<tbody>")
for _, row := range rows[1:] {
sb.WriteString("<tr class=\"border-b border-border\">")
renderTableRow(row, false)
sb.WriteString("</tr>")
}
sb.WriteString("</tbody>")
}
}
}
sb.WriteString("</table></div>\n")
case "references":
sb.WriteString("<div class=\"bn-references\">\n")
sb.WriteString("<div class=\"bn-references-label\">References</div>\n")
sb.WriteString("<ol>\n")
if itemsJSON, ok := props["items"].(string); ok && itemsJSON != "" {
var items []map[string]string
if err := json.Unmarshal([]byte(itemsJSON), &items); err == nil {
for _, item := range items {
text := html.EscapeString(item["text"])
url := item["url"]
if text == "" {
continue
}
if url != "" {
fmt.Fprintf(&sb, "<li><a href=\"%s\">%s</a></li>\n", html.EscapeString(url), text)
} else {
fmt.Fprintf(&sb, "<li>%s</li>\n", text)
}
}
}
}
sb.WriteString("</ol>\n</div>\n")
default:
if len(content) > 0 || childrenHTML != "" {
sb.WriteString("<div>")
sb.WriteString(renderInlineContent(content))
if childrenHTML != "" {
sb.WriteString(childrenHTML)
}
sb.WriteString("</div>\n")
}
}
return sb.String()
}
func renderChildren(children any) string {
blocks := blocksFromRaw(children)
if len(blocks) == 0 {
return ""
}
return renderBlocks(blocks)
}
func renderInlineContent(content []any) string {
var sb strings.Builder
for _, item := range content {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
itemType, _ := itemMap["type"].(string)
text, _ := itemMap["text"].(string)
styles, _ := itemMap["styles"].(map[string]any)
switch itemType {
case "text":
rendered := html.EscapeString(text)
if styles != nil {
if bold, ok := styles["bold"].(bool); ok && bold {
rendered = "<strong>" + rendered + "</strong>"
}
if italic, ok := styles["italic"].(bool); ok && italic {
rendered = "<em>" + rendered + "</em>"
}
if underline, ok := styles["underline"].(bool); ok && underline {
rendered = "<u>" + rendered + "</u>"
}
if strike, ok := styles["strike"].(bool); ok && strike {
rendered = "<s>" + rendered + "</s>"
}
if strike, ok := styles["strikethrough"].(bool); ok && strike {
rendered = "<s>" + rendered + "</s>"
}
if code, ok := styles["code"].(bool); ok && code {
rendered = "<code>" + rendered + "</code>"
}
}
sb.WriteString(rendered)
case "link":
href, _ := itemMap["href"].(string)
linkContent := inlineContentFromRaw(itemMap["content"])
if href == "" {
sb.WriteString(renderInlineContent(linkContent))
break
}
fmt.Fprintf(&sb, `<a href="%s">`, html.EscapeString(href))
sb.WriteString(renderInlineContent(linkContent))
sb.WriteString("</a>")
case "hardBreak":
sb.WriteString("<br />")
default:
if text != "" {
sb.WriteString(html.EscapeString(text))
break
}
if rawContent, ok := itemMap["content"]; ok {
sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent)))
}
}
}
return sb.String()
}

View File

@ -1 +0,0 @@
package render

View File

@ -1 +0,0 @@
package settings

44
settings/settings.go Normal file
View File

@ -0,0 +1,44 @@
package settings
import "context"
// Settings provides settings access for plugins.
type Settings interface {
GetSiteSettings(ctx context.Context) (map[string]any, error)
GetPluginSettings(ctx context.Context, pluginName string) (map[string]any, error)
}
// GetStringOr returns a string value from a map, or defaultVal if not found/wrong type.
func GetStringOr(m map[string]any, key, defaultVal string) string {
if v, ok := m[key].(string); ok {
return v
}
return defaultVal
}
// GetBoolOr returns a bool value from a map, or defaultVal if not found/wrong type.
func GetBoolOr(m map[string]any, key string, defaultVal bool) bool {
if v, ok := m[key].(bool); ok {
return v
}
return defaultVal
}
// GetIntOr returns an int value from a map, handling both int and float64 (JSON numbers).
func GetIntOr(m map[string]any, key string, defaultVal int) int {
if v, ok := m[key].(float64); ok {
return int(v)
}
if v, ok := m[key].(int); ok {
return v
}
return defaultVal
}
// GetFloat64Or returns a float64 value from a map, or defaultVal if not found/wrong type.
func GetFloat64Or(m map[string]any, key string, defaultVal float64) float64 {
if v, ok := m[key].(float64); ok {
return v
}
return defaultVal
}

View File

@ -1 +0,0 @@
package templates

10
templates/registry.go Normal file
View File

@ -0,0 +1,10 @@
package templates
// TemplateRegistry is the interface plugins use to register templates.
// The CMS provides the concrete implementation.
type TemplateRegistry interface {
Register(key string, fn TemplateFunc) error
RegisterSystemTemplate(meta SystemTemplateMeta)
RegisterPageTemplate(systemKey string, meta PageTemplateMeta, fn TemplateFunc) error
RegisterEmailWrapper(systemKey string, fn EmailWrapperFunc)
}

59
templates/types.go Normal file
View File

@ -0,0 +1,59 @@
package templates
import (
"context"
"github.com/a-h/templ"
)
// TemplateFunc is the signature for template functions loaded from plugins.
type TemplateFunc func(ctx context.Context, doc map[string]any) templ.Component
// PageTemplateMeta provides metadata about a page template within a system template.
type PageTemplateMeta struct {
Key string
Title string
Description string
Slots []string
}
// SystemTemplateMeta provides metadata about a system template (theme).
type SystemTemplateMeta struct {
Key string
Title string
Description string
}
// EmailWrapperFunc wraps body content in a branded email template.
type EmailWrapperFunc func(body string, ctx EmailContext) string
// EmailContext provides data for email wrapper rendering.
type EmailContext struct {
Colors EmailColors
SiteSettings EmailSiteData
UnsubscribeURL string
PreviewText string
}
// EmailColors contains theme colors converted to hex for email inlining.
type EmailColors struct {
Primary string
PrimaryForeground string
Secondary string
SecondaryForeground string
Background string
Foreground string
Muted string
MutedForeground string
Border string
Card string
CardForeground string
}
// EmailSiteData contains site information for email rendering.
type EmailSiteData struct {
SiteName string
LogoURL string
SiteURL string
SupportEmail string
}

View File

@ -1 +0,0 @@
package video

69
video/embed.go Normal file
View File

@ -0,0 +1,69 @@
package video
import (
"fmt"
"net/url"
"regexp"
"strings"
)
// EmbedInfo holds parsed video embed information.
type EmbedInfo struct {
Provider string
VideoID string
URL string
}
var (
youtubeRegexps = []*regexp.Regexp{
regexp.MustCompile(`(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})`),
}
vimeoRegexp = regexp.MustCompile(`vimeo\.com/(\d+)`)
loomRegexp = regexp.MustCompile(`loom\.com/share/([a-zA-Z0-9]+)`)
)
// ParseEmbedURL parses a video URL and returns embed information.
func ParseEmbedURL(rawURL string) (*EmbedInfo, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
host := strings.ToLower(parsed.Host)
if strings.Contains(host, "youtube.com") || strings.Contains(host, "youtu.be") {
for _, re := range youtubeRegexps {
if matches := re.FindStringSubmatch(rawURL); len(matches) > 1 {
return &EmbedInfo{Provider: "youtube", VideoID: matches[1], URL: rawURL}, nil
}
}
}
if strings.Contains(host, "vimeo.com") {
if matches := vimeoRegexp.FindStringSubmatch(rawURL); len(matches) > 1 {
return &EmbedInfo{Provider: "vimeo", VideoID: matches[1], URL: rawURL}, nil
}
}
if strings.Contains(host, "loom.com") {
if matches := loomRegexp.FindStringSubmatch(rawURL); len(matches) > 1 {
return &EmbedInfo{Provider: "loom", VideoID: matches[1], URL: rawURL}, nil
}
}
return nil, fmt.Errorf("unsupported video provider for URL: %s", rawURL)
}
// EmbedIframeURL returns the iframe embed URL for a video provider and ID.
func EmbedIframeURL(provider, videoID string) string {
switch provider {
case "youtube":
return "https://www.youtube.com/embed/" + videoID
case "vimeo":
return "https://player.vimeo.com/video/" + videoID
case "loom":
return "https://www.loom.com/embed/" + videoID
default:
return ""
}
}