feat(server): add owner-tools injection and inline code theme CSS
- Add owner-tools serving and injection for blog owners - Inline code theme CSS in templates for soft reload support - Update import paths from github.com/writekitapp to writekit - Add optional session middleware for owner detection - Update platform index with improved UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
771ff7615a
commit
119e3b7a6d
11 changed files with 838 additions and 483 deletions
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/writekitapp/writekit/internal/auth"
|
"writekit/internal/auth"
|
||||||
"github.com/writekitapp/writekit/internal/tenant"
|
"writekit/internal/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) publicAPIRoutes() chi.Router {
|
func (s *Server) publicAPIRoutes() chi.Router {
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,13 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/writekitapp/writekit/internal/auth"
|
"writekit/internal/auth"
|
||||||
"github.com/writekitapp/writekit/internal/build/assets"
|
"writekit/internal/tenant/assets"
|
||||||
"github.com/writekitapp/writekit/internal/build/templates"
|
"writekit/internal/tenant/templates"
|
||||||
"github.com/writekitapp/writekit/internal/config"
|
"writekit/internal/config"
|
||||||
"github.com/writekitapp/writekit/internal/markdown"
|
"writekit/internal/markdown"
|
||||||
"github.com/writekitapp/writekit/internal/tenant"
|
"writekit/internal/tenant"
|
||||||
"github.com/writekitapp/writekit/studio"
|
"writekit/frontends/studio"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) serveBlog(w http.ResponseWriter, r *http.Request, subdomain string) {
|
func (s *Server) serveBlog(w http.ResponseWriter, r *http.Request, subdomain string) {
|
||||||
|
|
@ -59,12 +59,14 @@ func (s *Server) serveBlog(w http.ResponseWriter, r *http.Request, subdomain str
|
||||||
r = r.WithContext(ctx)
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
mux := chi.NewRouter()
|
mux := chi.NewRouter()
|
||||||
|
mux.Use(auth.OptionalSessionMiddleware(s.database))
|
||||||
|
|
||||||
mux.Get("/", s.blogHome)
|
mux.Get("/", s.blogHome)
|
||||||
mux.Get("/posts", s.blogList)
|
mux.Get("/posts", s.blogList)
|
||||||
mux.Get("/posts/{slug}", s.blogPost)
|
mux.Get("/posts/{slug}", s.blogPost)
|
||||||
|
|
||||||
mux.Handle("/static/*", http.StripPrefix("/static/", assets.Handler()))
|
mux.Handle("/static/*", http.StripPrefix("/static/", assets.Handler()))
|
||||||
|
mux.Handle("/@owner-tools/*", http.HandlerFunc(s.serveOwnerTools))
|
||||||
|
|
||||||
mux.Route("/api/studio", func(r chi.Router) {
|
mux.Route("/api/studio", func(r chi.Router) {
|
||||||
r.Use(demoAwareSessionMiddleware(s.database))
|
r.Use(demoAwareSessionMiddleware(s.database))
|
||||||
|
|
@ -638,8 +640,20 @@ func computeETag(data []byte) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) servePreRendered(w http.ResponseWriter, r *http.Request, html []byte, etag, cacheControl string) {
|
func (s *Server) servePreRendered(w http.ResponseWriter, r *http.Request, html []byte, etag, cacheControl string) {
|
||||||
|
tenantID, _ := r.Context().Value(tenantIDKey).(string)
|
||||||
|
modified := false
|
||||||
|
|
||||||
if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
|
if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
|
||||||
html = s.injectDemoBanner(html, demoInfo.ExpiresAt)
|
html = s.injectDemoBanner(html, demoInfo.ExpiresAt)
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.isOwner(r, tenantID) {
|
||||||
|
html = s.injectOwnerTools(html)
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if modified {
|
||||||
etag = computeETag(html)
|
etag = computeETag(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -701,3 +715,49 @@ func timeOrZero(t *time.Time) time.Time {
|
||||||
}
|
}
|
||||||
return *t
|
return *t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) isOwner(r *http.Request, tenantID string) bool {
|
||||||
|
if GetDemoInfo(r).IsDemo {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
userID := auth.GetUserID(r)
|
||||||
|
if userID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) injectOwnerTools(html []byte) []byte {
|
||||||
|
var script string
|
||||||
|
if s.ownerToolsURL != "" {
|
||||||
|
script = `<script type="module" src="/@owner-tools/src/main.tsx"></script>`
|
||||||
|
} else {
|
||||||
|
script = `<script src="/static/js/owner-tools.js"></script>`
|
||||||
|
}
|
||||||
|
return bytes.Replace(html, []byte("</body>"), []byte(script+"</body>"), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveOwnerTools(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ownerToolsURL == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := url.Parse(s.ownerToolsURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
proxy.Director = func(req *http.Request) {
|
||||||
|
req.URL.Scheme = target.Scheme
|
||||||
|
req.URL.Host = target.Host
|
||||||
|
req.Host = target.Host
|
||||||
|
}
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/writekitapp/writekit/internal/build/templates"
|
"writekit/internal/tenant/templates"
|
||||||
"github.com/writekitapp/writekit/internal/markdown"
|
"writekit/internal/markdown"
|
||||||
"github.com/writekitapp/writekit/internal/tenant"
|
"writekit/internal/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
type renderedPage struct {
|
type renderedPage struct {
|
||||||
|
|
@ -39,14 +39,16 @@ func (s *Server) rebuildSite(ctx context.Context, tenantID string, db *sql.DB, h
|
||||||
codeTheme := getSettingOr(settings, "code_theme", "github")
|
codeTheme := getSettingOr(settings, "code_theme", "github")
|
||||||
fontKey := getSettingOr(settings, "font", "system")
|
fontKey := getSettingOr(settings, "font", "system")
|
||||||
isDemo := getSettingOr(settings, "is_demo", "") == "true"
|
isDemo := getSettingOr(settings, "is_demo", "") == "true"
|
||||||
|
codeThemeCSS, _ := markdown.GenerateChromaCSS(codeTheme)
|
||||||
|
|
||||||
pageData := templates.PageData{
|
pageData := templates.PageData{
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
Year: time.Now().Year(),
|
Year: time.Now().Year(),
|
||||||
FontURL: templates.GetFontURL(fontKey),
|
FontURL: templates.GetFontURL(fontKey),
|
||||||
FontFamily: templates.GetFontFamily(fontKey),
|
FontFamily: templates.GetFontFamily(fontKey),
|
||||||
Settings: settingsToMap(settings),
|
Settings: settingsToMap(settings),
|
||||||
NoIndex: isDemo,
|
NoIndex: isDemo,
|
||||||
|
CodeThemeCSS: template.CSS(codeThemeCSS),
|
||||||
}
|
}
|
||||||
|
|
||||||
var pages []renderedPage
|
var pages []renderedPage
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/writekitapp/writekit/internal/auth"
|
"writekit/internal/auth"
|
||||||
"github.com/writekitapp/writekit/internal/db"
|
"writekit/internal/db"
|
||||||
"github.com/writekitapp/writekit/internal/tenant"
|
"writekit/internal/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey string
|
type ctxKey string
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/writekitapp/writekit/internal/auth"
|
"writekit/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*.html
|
//go:embed templates/*.html
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/writekitapp/writekit/internal/config"
|
"writekit/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type bucket struct {
|
type bucket struct {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/writekitapp/writekit/internal/tenant"
|
"writekit/internal/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) readerRoutes() chi.Router {
|
func (s *Server) readerRoutes() chi.Router {
|
||||||
|
|
|
||||||
|
|
@ -11,26 +11,27 @@ import (
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/writekitapp/writekit/internal/auth"
|
"writekit/internal/auth"
|
||||||
"github.com/writekitapp/writekit/internal/cloudflare"
|
"writekit/internal/cloudflare"
|
||||||
"github.com/writekitapp/writekit/internal/db"
|
"writekit/internal/db"
|
||||||
"github.com/writekitapp/writekit/internal/imaginary"
|
"writekit/internal/imaginary"
|
||||||
"github.com/writekitapp/writekit/internal/storage"
|
"writekit/internal/storage"
|
||||||
"github.com/writekitapp/writekit/internal/tenant"
|
"writekit/internal/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
router chi.Router
|
router chi.Router
|
||||||
database *db.DB
|
database *db.DB
|
||||||
tenantPool *tenant.Pool
|
tenantPool *tenant.Pool
|
||||||
tenantCache *tenant.Cache
|
tenantCache *tenant.Cache
|
||||||
storage storage.Client
|
storage storage.Client
|
||||||
imaginary *imaginary.Client
|
imaginary *imaginary.Client
|
||||||
cloudflare *cloudflare.Client
|
cloudflare *cloudflare.Client
|
||||||
rateLimiter *RateLimiter
|
rateLimiter *RateLimiter
|
||||||
domain string
|
domain string
|
||||||
jarvisURL string
|
jarvisURL string
|
||||||
stopCleanup chan struct{}
|
ownerToolsURL string
|
||||||
|
stopCleanup chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(database *db.DB, pool *tenant.Pool, cache *tenant.Cache, storageClient storage.Client) *Server {
|
func New(database *db.DB, pool *tenant.Pool, cache *tenant.Cache, storageClient storage.Client) *Server {
|
||||||
|
|
@ -44,6 +45,8 @@ func New(database *db.DB, pool *tenant.Pool, cache *tenant.Cache, storageClient
|
||||||
jarvisURL = "http://localhost:8090"
|
jarvisURL = "http://localhost:8090"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ownerToolsURL := os.Getenv("OWNER_TOOLS_URL")
|
||||||
|
|
||||||
var imgClient *imaginary.Client
|
var imgClient *imaginary.Client
|
||||||
if url := os.Getenv("IMAGINARY_URL"); url != "" {
|
if url := os.Getenv("IMAGINARY_URL"); url != "" {
|
||||||
imgClient = imaginary.New(url)
|
imgClient = imaginary.New(url)
|
||||||
|
|
@ -52,17 +55,18 @@ func New(database *db.DB, pool *tenant.Pool, cache *tenant.Cache, storageClient
|
||||||
cfClient := cloudflare.NewClient()
|
cfClient := cloudflare.NewClient()
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
router: chi.NewRouter(),
|
router: chi.NewRouter(),
|
||||||
database: database,
|
database: database,
|
||||||
tenantPool: pool,
|
tenantPool: pool,
|
||||||
tenantCache: cache,
|
tenantCache: cache,
|
||||||
storage: storageClient,
|
storage: storageClient,
|
||||||
imaginary: imgClient,
|
imaginary: imgClient,
|
||||||
cloudflare: cfClient,
|
cloudflare: cfClient,
|
||||||
rateLimiter: NewRateLimiter(),
|
rateLimiter: NewRateLimiter(),
|
||||||
domain: domain,
|
domain: domain,
|
||||||
jarvisURL: jarvisURL,
|
jarvisURL: jarvisURL,
|
||||||
stopCleanup: make(chan struct{}),
|
ownerToolsURL: ownerToolsURL,
|
||||||
|
stopCleanup: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
s.router.Use(middleware.Logger)
|
s.router.Use(middleware.Logger)
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/writekitapp/writekit/internal/config"
|
"writekit/internal/config"
|
||||||
"github.com/writekitapp/writekit/internal/db"
|
"writekit/internal/db"
|
||||||
"github.com/writekitapp/writekit/internal/imaginary"
|
"writekit/internal/imaginary"
|
||||||
"github.com/writekitapp/writekit/internal/markdown"
|
"writekit/internal/markdown"
|
||||||
"github.com/writekitapp/writekit/internal/tenant"
|
"writekit/internal/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
func renderContent(q *tenant.Queries, r *http.Request, content string) string {
|
func renderContent(q *tenant.Queries, r *http.Request, content string) string {
|
||||||
|
|
@ -52,6 +52,7 @@ func (s *Server) studioRoutes() chi.Router {
|
||||||
|
|
||||||
r.Get("/settings", s.getSettings)
|
r.Get("/settings", s.getSettings)
|
||||||
r.Put("/settings", s.updateSettings)
|
r.Put("/settings", s.updateSettings)
|
||||||
|
r.Get("/settings/schema", s.getSettingsSchema)
|
||||||
|
|
||||||
r.Get("/interaction-config", s.getStudioInteractionConfig)
|
r.Get("/interaction-config", s.getStudioInteractionConfig)
|
||||||
r.Put("/interaction-config", s.updateInteractionConfig)
|
r.Put("/interaction-config", s.updateInteractionConfig)
|
||||||
|
|
@ -93,9 +94,8 @@ func (s *Server) studioRoutes() chi.Router {
|
||||||
r.Get("/sdk", s.getSDK)
|
r.Get("/sdk", s.getSDK)
|
||||||
r.Get("/lsp", s.proxyLSP)
|
r.Get("/lsp", s.proxyLSP)
|
||||||
|
|
||||||
// Code themes
|
// Code theme CSS
|
||||||
r.Get("/code-theme.css", s.codeThemeCSS)
|
r.Get("/code-theme.css", s.codeThemeCSS)
|
||||||
r.Get("/code-themes", s.listCodeThemes)
|
|
||||||
|
|
||||||
// Plugin testing
|
// Plugin testing
|
||||||
r.Post("/plugins/test", s.testPlugin)
|
r.Post("/plugins/test", s.testPlugin)
|
||||||
|
|
@ -818,6 +818,89 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonResponse(w, http.StatusOK, map[string]bool{"success": true})
|
jsonResponse(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type settingOption struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingDefinition struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Options []settingOption `json:"options,omitempty"`
|
||||||
|
Default string `json:"default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getSettingsSchema(w http.ResponseWriter, r *http.Request) {
|
||||||
|
schema := []settingDefinition{
|
||||||
|
{
|
||||||
|
Key: "accent_color",
|
||||||
|
Type: "color",
|
||||||
|
Label: "Accent Color",
|
||||||
|
Default: "#10b981",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "font",
|
||||||
|
Type: "select",
|
||||||
|
Label: "Font",
|
||||||
|
Options: []settingOption{
|
||||||
|
{Value: "system", Label: "System Default"},
|
||||||
|
{Value: "inter", Label: "Inter"},
|
||||||
|
{Value: "georgia", Label: "Georgia"},
|
||||||
|
{Value: "merriweather", Label: "Merriweather"},
|
||||||
|
{Value: "source-serif", Label: "Source Serif"},
|
||||||
|
{Value: "jetbrains-mono", Label: "JetBrains Mono"},
|
||||||
|
},
|
||||||
|
Default: "system",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "code_theme",
|
||||||
|
Type: "select",
|
||||||
|
Label: "Code Theme",
|
||||||
|
Options: []settingOption{
|
||||||
|
{Value: "github", Label: "GitHub Light"},
|
||||||
|
{Value: "github-dark", Label: "GitHub Dark"},
|
||||||
|
{Value: "vs", Label: "VS Light"},
|
||||||
|
{Value: "xcode", Label: "Xcode Light"},
|
||||||
|
{Value: "xcode-dark", Label: "Xcode Dark"},
|
||||||
|
{Value: "solarized-light", Label: "Solarized Light"},
|
||||||
|
{Value: "solarized-dark", Label: "Solarized Dark"},
|
||||||
|
{Value: "gruvbox-light", Label: "Gruvbox Light"},
|
||||||
|
{Value: "gruvbox", Label: "Gruvbox Dark"},
|
||||||
|
{Value: "nord", Label: "Nord"},
|
||||||
|
{Value: "onedark", Label: "One Dark"},
|
||||||
|
{Value: "dracula", Label: "Dracula"},
|
||||||
|
{Value: "monokai", Label: "Monokai"},
|
||||||
|
},
|
||||||
|
Default: "github",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "layout",
|
||||||
|
Type: "select",
|
||||||
|
Label: "Layout",
|
||||||
|
Options: []settingOption{
|
||||||
|
{Value: "default", Label: "Classic"},
|
||||||
|
{Value: "minimal", Label: "Minimal"},
|
||||||
|
{Value: "magazine", Label: "Magazine"},
|
||||||
|
},
|
||||||
|
Default: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "compactness",
|
||||||
|
Type: "select",
|
||||||
|
Label: "Density",
|
||||||
|
Options: []settingOption{
|
||||||
|
{Value: "compact", Label: "Compact"},
|
||||||
|
{Value: "cozy", Label: "Cozy"},
|
||||||
|
{Value: "spacious", Label: "Spacious"},
|
||||||
|
},
|
||||||
|
Default: "cozy",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, schema)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) listAssets(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listAssets(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID := r.Context().Value(tenantIDKey).(string)
|
tenantID := r.Context().Value(tenantIDKey).(string)
|
||||||
|
|
||||||
|
|
@ -1789,11 +1872,6 @@ func (s *Server) codeThemeCSS(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Write([]byte(css))
|
w.Write([]byte(css))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) listCodeThemes(w http.ResponseWriter, r *http.Request) {
|
|
||||||
themes := markdown.ListThemes()
|
|
||||||
jsonResponse(w, http.StatusOK, themes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(slice []string, item string) bool {
|
func contains(slice []string, item string) bool {
|
||||||
for _, s := range slice {
|
for _, s := range slice {
|
||||||
if s == item {
|
if s == item {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/writekitapp/writekit/internal/tenant"
|
"writekit/internal/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) StartAnalyticsSync() {
|
func (s *Server) StartAnalyticsSync() {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue