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:
parent
0991b791b1
commit
99fc63ddfd
28
ai/tools.go
Normal file
28
ai/tools.go
Normal 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
18
auth/claims.go
Normal 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
34
auth/context.go
Normal 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)
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
package auth
|
|
||||||
333
blocks/context.go
Normal file
333
blocks/context.go
Normal 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
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
package blocks
|
|
||||||
10
blocks/registry.go
Normal file
10
blocks/registry.go
Normal 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
106
blocks/types.go
Normal 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 · %[2]s keystrokes · %[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
47
content/content.go
Normal 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
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
package content
|
|
||||||
7
crypto/crypto.go
Normal file
7
crypto/crypto.go
Normal 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)
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
package crypto
|
|
||||||
@ -1 +0,0 @@
|
|||||||
package gating
|
|
||||||
45
gating/gating.go
Normal file
45
gating/gating.go
Normal 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
5
go.mod
@ -1,3 +1,8 @@
|
|||||||
module git.dev.alexdunmow.com/ninja/core
|
module git.dev.alexdunmow.com/ninja/core
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/a-h/templ v0.3.1001
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
)
|
||||||
|
|||||||
6
go.sum
Normal file
6
go.sum
Normal 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=
|
||||||
@ -1 +0,0 @@
|
|||||||
package rbac
|
|
||||||
44
rbac/role.go
Normal file
44
rbac/role.go
Normal 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
330
render/blocknote.go
Normal 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()
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
package render
|
|
||||||
@ -1 +0,0 @@
|
|||||||
package settings
|
|
||||||
44
settings/settings.go
Normal file
44
settings/settings.go
Normal 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
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
package templates
|
|
||||||
10
templates/registry.go
Normal file
10
templates/registry.go
Normal 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
59
templates/types.go
Normal 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
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
package video
|
|
||||||
69
video/embed.go
Normal file
69
video/embed.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user