diff --git a/ai/doc.go b/ai/doc.go deleted file mode 100644 index 3831891..0000000 --- a/ai/doc.go +++ /dev/null @@ -1 +0,0 @@ -package ai diff --git a/ai/tools.go b/ai/tools.go new file mode 100644 index 0000000..01da228 --- /dev/null +++ b/ai/tools.go @@ -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) +} diff --git a/auth/claims.go b/auth/claims.go new file mode 100644 index 0000000..5cf7be8 --- /dev/null +++ b/auth/claims.go @@ -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 +} diff --git a/auth/context.go b/auth/context.go new file mode 100644 index 0000000..62eb9ec --- /dev/null +++ b/auth/context.go @@ -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) +} diff --git a/auth/doc.go b/auth/doc.go deleted file mode 100644 index 8832b06..0000000 --- a/auth/doc.go +++ /dev/null @@ -1 +0,0 @@ -package auth diff --git a/blocks/context.go b/blocks/context.go new file mode 100644 index 0000000..1bc93ef --- /dev/null +++ b/blocks/context.go @@ -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 +} diff --git a/blocks/doc.go b/blocks/doc.go deleted file mode 100644 index 7d914f4..0000000 --- a/blocks/doc.go +++ /dev/null @@ -1 +0,0 @@ -package blocks diff --git a/blocks/registry.go b/blocks/registry.go new file mode 100644 index 0000000..c70e1b1 --- /dev/null +++ b/blocks/registry.go @@ -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 +} diff --git a/blocks/types.go b/blocks/types.go new file mode 100644 index 0000000..c2b98f2 --- /dev/null +++ b/blocks/types.go @@ -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(`
`+ + `
`+ + `
`+ + `
HP
`+ + `
`+ + `
Human Proof
`+ + `
%[1]d min active · %[2]s keystrokes · %[3]d sessions
`+ + `
`+ + ``+ + `
`+ + ``+ + ``, + 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 +} diff --git a/content/content.go b/content/content.go new file mode 100644 index 0000000..3cff802 --- /dev/null +++ b/content/content.go @@ -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 +} diff --git a/content/doc.go b/content/doc.go deleted file mode 100644 index 30f612d..0000000 --- a/content/doc.go +++ /dev/null @@ -1 +0,0 @@ -package content diff --git a/crypto/crypto.go b/crypto/crypto.go new file mode 100644 index 0000000..9afbf2a --- /dev/null +++ b/crypto/crypto.go @@ -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) +} diff --git a/crypto/doc.go b/crypto/doc.go deleted file mode 100644 index 5871506..0000000 --- a/crypto/doc.go +++ /dev/null @@ -1 +0,0 @@ -package crypto diff --git a/gating/doc.go b/gating/doc.go deleted file mode 100644 index fe030dc..0000000 --- a/gating/doc.go +++ /dev/null @@ -1 +0,0 @@ -package gating diff --git a/gating/gating.go b/gating/gating.go new file mode 100644 index 0000000..2e14465 --- /dev/null +++ b/gating/gating.go @@ -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 +} diff --git a/go.mod b/go.mod index 513e9bb..eb09f20 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5228531 --- /dev/null +++ b/go.sum @@ -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= diff --git a/rbac/doc.go b/rbac/doc.go deleted file mode 100644 index 0b31261..0000000 --- a/rbac/doc.go +++ /dev/null @@ -1 +0,0 @@ -package rbac diff --git a/rbac/role.go b/rbac/role.go new file mode 100644 index 0000000..8fbe5e9 --- /dev/null +++ b/rbac/role.go @@ -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 "" + } +} diff --git a/render/blocknote.go b/render/blocknote.go new file mode 100644 index 0000000..84f7652 --- /dev/null +++ b/render/blocknote.go @@ -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("
  • ") + sb.WriteString(renderInlineContent(content)) + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + sb.WriteString("
  • \n") + } + fmt.Fprintf(&sb, "\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("

    ") + sb.WriteString(renderInlineContent(content)) + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + sb.WriteString("

    \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, "", level) + sb.WriteString(renderInlineContent(content)) + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + fmt.Fprintf(&sb, "\n", level) + + case "quote": + sb.WriteString("
    ") + sb.WriteString(renderInlineContent(content)) + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + sb.WriteString("
    \n") + + case "codeBlock": + lang := "" + if l, ok := props["language"].(string); ok { + lang = l + } + fmt.Fprintf(&sb, `
    `, html.EscapeString(lang))
    +		sb.WriteString(renderInlineContent(content))
    +		sb.WriteString("
    \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(`
    `) + fmt.Fprintf(&sb, `%s`, html.EscapeString(url), html.EscapeString(alt)) + if caption != "" { + fmt.Fprintf(&sb, "
    %s
    ", html.EscapeString(caption)) + } + sb.WriteString("
    \n") + + case "table": + sb.WriteString(`
    `) + 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, "", cellTag) + } + } + } + } + sb.WriteString("") + renderTableRow(rows[0], true) + sb.WriteString("") + if len(rows) > 1 { + sb.WriteString("") + for _, row := range rows[1:] { + sb.WriteString("") + renderTableRow(row, false) + sb.WriteString("") + } + sb.WriteString("") + } + } + } + sb.WriteString("
    \n") + + case "references": + sb.WriteString("
    \n") + sb.WriteString("
    References
    \n") + sb.WriteString("
      \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, "
    1. %s
    2. \n", html.EscapeString(url), text) + } else { + fmt.Fprintf(&sb, "
    3. %s
    4. \n", text) + } + } + } + } + sb.WriteString("
    \n
    \n") + + default: + if len(content) > 0 || childrenHTML != "" { + sb.WriteString("
    ") + sb.WriteString(renderInlineContent(content)) + if childrenHTML != "" { + sb.WriteString(childrenHTML) + } + sb.WriteString("
    \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 = "" + rendered + "" + } + if italic, ok := styles["italic"].(bool); ok && italic { + rendered = "" + rendered + "" + } + if underline, ok := styles["underline"].(bool); ok && underline { + rendered = "" + rendered + "" + } + if strike, ok := styles["strike"].(bool); ok && strike { + rendered = "" + rendered + "" + } + if strike, ok := styles["strikethrough"].(bool); ok && strike { + rendered = "" + rendered + "" + } + if code, ok := styles["code"].(bool); ok && code { + rendered = "" + rendered + "" + } + } + sb.WriteString(rendered) + + case "link": + href, _ := itemMap["href"].(string) + linkContent := inlineContentFromRaw(itemMap["content"]) + if href == "" { + sb.WriteString(renderInlineContent(linkContent)) + break + } + fmt.Fprintf(&sb, ``, html.EscapeString(href)) + sb.WriteString(renderInlineContent(linkContent)) + sb.WriteString("") + + case "hardBreak": + sb.WriteString("
    ") + + default: + if text != "" { + sb.WriteString(html.EscapeString(text)) + break + } + if rawContent, ok := itemMap["content"]; ok { + sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent))) + } + } + } + return sb.String() +} diff --git a/render/doc.go b/render/doc.go deleted file mode 100644 index da4cad7..0000000 --- a/render/doc.go +++ /dev/null @@ -1 +0,0 @@ -package render diff --git a/settings/doc.go b/settings/doc.go deleted file mode 100644 index 0e66fdf..0000000 --- a/settings/doc.go +++ /dev/null @@ -1 +0,0 @@ -package settings diff --git a/settings/settings.go b/settings/settings.go new file mode 100644 index 0000000..f97e4ca --- /dev/null +++ b/settings/settings.go @@ -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 +} diff --git a/templates/doc.go b/templates/doc.go deleted file mode 100644 index dac8432..0000000 --- a/templates/doc.go +++ /dev/null @@ -1 +0,0 @@ -package templates diff --git a/templates/registry.go b/templates/registry.go new file mode 100644 index 0000000..fad1c55 --- /dev/null +++ b/templates/registry.go @@ -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) +} diff --git a/templates/types.go b/templates/types.go new file mode 100644 index 0000000..3e66e82 --- /dev/null +++ b/templates/types.go @@ -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 +} diff --git a/video/doc.go b/video/doc.go deleted file mode 100644 index 2c9ffe7..0000000 --- a/video/doc.go +++ /dev/null @@ -1 +0,0 @@ -package video diff --git a/video/embed.go b/video/embed.go new file mode 100644 index 0000000..2b157f3 --- /dev/null +++ b/video/embed.go @@ -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 "" + } +}