diff --git a/internal/server/api.go b/internal/server/api.go index 59ce9a1..588d93d 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -6,8 +6,8 @@ import ( "strings" "github.com/go-chi/chi/v5" - "github.com/writekitapp/writekit/internal/auth" - "github.com/writekitapp/writekit/internal/tenant" + "writekit/internal/auth" + "writekit/internal/tenant" ) func (s *Server) publicAPIRoutes() chi.Router { diff --git a/internal/server/blog.go b/internal/server/blog.go index da86848..a0e1d02 100644 --- a/internal/server/blog.go +++ b/internal/server/blog.go @@ -16,13 +16,13 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/writekitapp/writekit/internal/auth" - "github.com/writekitapp/writekit/internal/build/assets" - "github.com/writekitapp/writekit/internal/build/templates" - "github.com/writekitapp/writekit/internal/config" - "github.com/writekitapp/writekit/internal/markdown" - "github.com/writekitapp/writekit/internal/tenant" - "github.com/writekitapp/writekit/studio" + "writekit/internal/auth" + "writekit/internal/tenant/assets" + "writekit/internal/tenant/templates" + "writekit/internal/config" + "writekit/internal/markdown" + "writekit/internal/tenant" + "writekit/frontends/studio" ) 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) mux := chi.NewRouter() + mux.Use(auth.OptionalSessionMiddleware(s.database)) mux.Get("/", s.blogHome) mux.Get("/posts", s.blogList) mux.Get("/posts/{slug}", s.blogPost) mux.Handle("/static/*", http.StripPrefix("/static/", assets.Handler())) + mux.Handle("/@owner-tools/*", http.HandlerFunc(s.serveOwnerTools)) mux.Route("/api/studio", func(r chi.Router) { 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) { + tenantID, _ := r.Context().Value(tenantIDKey).(string) + modified := false + if demoInfo := GetDemoInfo(r); demoInfo.IsDemo { html = s.injectDemoBanner(html, demoInfo.ExpiresAt) + modified = true + } + + if s.isOwner(r, tenantID) { + html = s.injectOwnerTools(html) + modified = true + } + + if modified { etag = computeETag(html) } @@ -701,3 +715,49 @@ func timeOrZero(t *time.Time) time.Time { } 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 = `` + } else { + script = `` + } + return bytes.Replace(html, []byte(""), []byte(script+""), 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) +} diff --git a/internal/server/build.go b/internal/server/build.go index ed1c846..a7d47a5 100644 --- a/internal/server/build.go +++ b/internal/server/build.go @@ -8,9 +8,9 @@ import ( "sync" "time" - "github.com/writekitapp/writekit/internal/build/templates" - "github.com/writekitapp/writekit/internal/markdown" - "github.com/writekitapp/writekit/internal/tenant" + "writekit/internal/tenant/templates" + "writekit/internal/markdown" + "writekit/internal/tenant" ) 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") fontKey := getSettingOr(settings, "font", "system") isDemo := getSettingOr(settings, "is_demo", "") == "true" + codeThemeCSS, _ := markdown.GenerateChromaCSS(codeTheme) pageData := templates.PageData{ - SiteName: siteName, - Year: time.Now().Year(), - FontURL: templates.GetFontURL(fontKey), - FontFamily: templates.GetFontFamily(fontKey), - Settings: settingsToMap(settings), - NoIndex: isDemo, + SiteName: siteName, + Year: time.Now().Year(), + FontURL: templates.GetFontURL(fontKey), + FontFamily: templates.GetFontFamily(fontKey), + Settings: settingsToMap(settings), + NoIndex: isDemo, + CodeThemeCSS: template.CSS(codeThemeCSS), } var pages []renderedPage diff --git a/internal/server/demo.go b/internal/server/demo.go index 8a4d855..18b7766 100644 --- a/internal/server/demo.go +++ b/internal/server/demo.go @@ -8,9 +8,9 @@ import ( "strconv" "time" - "github.com/writekitapp/writekit/internal/auth" - "github.com/writekitapp/writekit/internal/db" - "github.com/writekitapp/writekit/internal/tenant" + "writekit/internal/auth" + "writekit/internal/db" + "writekit/internal/tenant" ) type ctxKey string diff --git a/internal/server/platform.go b/internal/server/platform.go index 78597d8..3766721 100644 --- a/internal/server/platform.go +++ b/internal/server/platform.go @@ -11,7 +11,7 @@ import ( "regexp" "strings" - "github.com/writekitapp/writekit/internal/auth" + "writekit/internal/auth" ) //go:embed templates/*.html diff --git a/internal/server/ratelimit.go b/internal/server/ratelimit.go index f58e6f8..da095a7 100644 --- a/internal/server/ratelimit.go +++ b/internal/server/ratelimit.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "github.com/writekitapp/writekit/internal/config" + "writekit/internal/config" ) type bucket struct { diff --git a/internal/server/reader.go b/internal/server/reader.go index 270579a..251e143 100644 --- a/internal/server/reader.go +++ b/internal/server/reader.go @@ -13,7 +13,7 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/writekitapp/writekit/internal/tenant" + "writekit/internal/tenant" ) func (s *Server) readerRoutes() chi.Router { diff --git a/internal/server/server.go b/internal/server/server.go index d35d1e6..9545773 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,26 +11,27 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/writekitapp/writekit/internal/auth" - "github.com/writekitapp/writekit/internal/cloudflare" - "github.com/writekitapp/writekit/internal/db" - "github.com/writekitapp/writekit/internal/imaginary" - "github.com/writekitapp/writekit/internal/storage" - "github.com/writekitapp/writekit/internal/tenant" + "writekit/internal/auth" + "writekit/internal/cloudflare" + "writekit/internal/db" + "writekit/internal/imaginary" + "writekit/internal/storage" + "writekit/internal/tenant" ) type Server struct { - router chi.Router - database *db.DB - tenantPool *tenant.Pool - tenantCache *tenant.Cache - storage storage.Client - imaginary *imaginary.Client - cloudflare *cloudflare.Client - rateLimiter *RateLimiter - domain string - jarvisURL string - stopCleanup chan struct{} + router chi.Router + database *db.DB + tenantPool *tenant.Pool + tenantCache *tenant.Cache + storage storage.Client + imaginary *imaginary.Client + cloudflare *cloudflare.Client + rateLimiter *RateLimiter + domain string + jarvisURL string + ownerToolsURL string + stopCleanup chan struct{} } 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" } + ownerToolsURL := os.Getenv("OWNER_TOOLS_URL") + var imgClient *imaginary.Client if url := os.Getenv("IMAGINARY_URL"); url != "" { imgClient = imaginary.New(url) @@ -52,17 +55,18 @@ func New(database *db.DB, pool *tenant.Pool, cache *tenant.Cache, storageClient cfClient := cloudflare.NewClient() s := &Server{ - router: chi.NewRouter(), - database: database, - tenantPool: pool, - tenantCache: cache, - storage: storageClient, - imaginary: imgClient, - cloudflare: cfClient, - rateLimiter: NewRateLimiter(), - domain: domain, - jarvisURL: jarvisURL, - stopCleanup: make(chan struct{}), + router: chi.NewRouter(), + database: database, + tenantPool: pool, + tenantCache: cache, + storage: storageClient, + imaginary: imgClient, + cloudflare: cfClient, + rateLimiter: NewRateLimiter(), + domain: domain, + jarvisURL: jarvisURL, + ownerToolsURL: ownerToolsURL, + stopCleanup: make(chan struct{}), } s.router.Use(middleware.Logger) diff --git a/internal/server/studio.go b/internal/server/studio.go index 3ff11e2..89a05fc 100644 --- a/internal/server/studio.go +++ b/internal/server/studio.go @@ -15,11 +15,11 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/gorilla/websocket" - "github.com/writekitapp/writekit/internal/config" - "github.com/writekitapp/writekit/internal/db" - "github.com/writekitapp/writekit/internal/imaginary" - "github.com/writekitapp/writekit/internal/markdown" - "github.com/writekitapp/writekit/internal/tenant" + "writekit/internal/config" + "writekit/internal/db" + "writekit/internal/imaginary" + "writekit/internal/markdown" + "writekit/internal/tenant" ) 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.Put("/settings", s.updateSettings) + r.Get("/settings/schema", s.getSettingsSchema) r.Get("/interaction-config", s.getStudioInteractionConfig) r.Put("/interaction-config", s.updateInteractionConfig) @@ -93,9 +94,8 @@ func (s *Server) studioRoutes() chi.Router { r.Get("/sdk", s.getSDK) r.Get("/lsp", s.proxyLSP) - // Code themes + // Code theme CSS r.Get("/code-theme.css", s.codeThemeCSS) - r.Get("/code-themes", s.listCodeThemes) // Plugin testing 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}) } +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) { tenantID := r.Context().Value(tenantIDKey).(string) @@ -1789,11 +1872,6 @@ func (s *Server) codeThemeCSS(w http.ResponseWriter, r *http.Request) { 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 { for _, s := range slice { if s == item { diff --git a/internal/server/sync.go b/internal/server/sync.go index 78e6418..e750d56 100644 --- a/internal/server/sync.go +++ b/internal/server/sync.go @@ -5,7 +5,7 @@ import ( "log" "time" - "github.com/writekitapp/writekit/internal/tenant" + "writekit/internal/tenant" ) func (s *Server) StartAnalyticsSync() { diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html index 9250a17..5a06591 100644 --- a/internal/server/templates/index.html +++ b/internal/server/templates/index.html @@ -1,417 +1,604 @@ - - - - WriteKit — Full Blogging Platform. Lightweight. Yours. - - - - - - - - -
- -
-
- -

Blog Hosting for Developers / 2025

-

Your Words,
Your Platform

-

Spin up a beautiful, fast blog in seconds. SQLite-powered, markdown-native, infinitely customizable.

-
- -

{{DEMO_MINUTES}} minute demo. Create a real blog instead.

+ + + + WriteKit — Blogging Platform for Developers + + + + + + + + +
+
-
- -
-

We built WriteKit because blogging platforms got complicated.

-

Ghost is heavy. Hashnode is bloated. Medium doesn't care about developers. Hugo outputs static sites — great, until you want comments, logins and analytics without bolting on five services.

WriteKit is a fully featured platform for developers. Comments, reactions, search, analytics, monetization, API — everything works out of the box. Deploy in seconds, own your data forever.

+ + + + + -
-
-
-

01

Comments & Reactions

Threaded comments and emoji reactions. No Disqus, no third-party scripts.

-

02

Full-text Search

SQLite FTS5 powers instant search. Fast, local, no external service.

-

03

Privacy-first Analytics

Views, referrers, browsers — no cookies, no tracking pixels.

-

04

REST API

Full programmatic access. Create posts, manage content, build integrations.

-

05

Markdown Native

Write how you already write. YAML frontmatter, syntax highlighting.

-

06

Custom Domains

Your domain or *.writekit.dev. SSL included automatically.

-

07

Own Your Data

Export anytime. JSON, Markdown, full backup. No lock-in ever.

-

08

One-click Deploy

No DevOps required. One button, your instance is live.

-
-
-
-
-

Ready to start writing?

-

Deploy your blog in seconds. No credit card required.

-
- Create Your Blog - + +
+
+ +

Blog Hosting for Developers / 2025

+

Your Words,
Your Platform

+

+ Spin up a beautiful, fast blog in seconds. + SQLite-powered, markdown-native, + infinitely customizable. +

+
+ +

+ {{DEMO_MINUTES}} minute demo. + Create a real blog + instead. +

-
-
-
-
-
-
-
- 1 / 2 -

What's your name?

-

We'll use this to personalize your blog.

+ +
+ +
+

Blogging got complicated.

+

+ Ghost is heavy. Hashnode is bloated. Medium doesn't care about + developers. Hugo outputs static sites — great, until you want + comments, logins and analytics without bolting on five + services.

WriteKit is a blogging platform for + productive developers. Everything works out of the box. Own your + data from day one. +

+
+
+
+
+
+

01

+

Comments & Reactions

+

+ Threaded comments and emoji reactions. No Disqus, no third-party + scripts. +

- - -
-
- 2 / 2 -

Pick a color

-

Choose an accent color for your blog.

-
-
- - - - - - -
-
+
+
+

Ready to start writing?

+

Deploy your blog in seconds. No credit card required.

+
+ Create Your Blog +
-
-

Launching your demo...

-
-
Creating database...
-
Configuring settings...
-
Adding welcome post...
-
Ready!
+
+
+
+
+
+
+ 1 / 2 +

What's your name?

+

We'll use this to personalize your blog.

+
+ +
-
-
-
-
-
-
Redirecting you now...
+
+
+ 2 / 2 +

Pick a color

+

Choose an accent color for your blog.

+
+
+ + + + + + +
+ +
+
+
+

Launching your demo...

+
+
+
+ Creating database... +
+
+ Configuring settings... +
+
+ Adding welcome post... +
+
+ Ready! +
+
+
+
+
+
+ + + +
+
+
Redirecting you now...
+
-
-
-
- - + const render = (time) => { + time *= 0.001; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.bindVertexArray(vao); + gl.uniform2f(u.resolution, canvas.width, canvas.height); + gl.uniform1f(u.time, time); + gl.uniform1f(u.pixelSize, settings.pixelSize); + gl.uniform1f(u.gridSize, settings.gridSize); + gl.uniform1f(u.speed, settings.speed); + gl.uniform1f(u.colorShift, settings.colorShift); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + requestAnimationFrame(render); + }; + requestAnimationFrame(render); + })(); + +