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
|
||||
|
||||
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