blumenjs 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/audit.js +204 -0
- package/dist/cli/commands/bench.js +227 -0
- package/dist/cli/commands/export.js +241 -0
- package/dist/cli/commands/migrate.js +267 -0
- package/dist/cli/commands/test.js +118 -0
- package/dist/templates/app/shared/RouterContext.tsx +22 -5
- package/dist/templates/go-server/actions.go +147 -0
- package/dist/templates/go-server/redirects.go +203 -0
- package/dist/templates/go-server/ssg.go +230 -0
- package/dist/templates/node-ssr/server.ts +3 -2
- package/package.json +1 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"log"
|
|
6
|
+
"net/http"
|
|
7
|
+
"os"
|
|
8
|
+
"regexp"
|
|
9
|
+
"strings"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// ─── Redirects & Rewrites Engine ───────────────────────────────
|
|
13
|
+
// Declarative URL routing configured via blumen.routes.json.
|
|
14
|
+
// Processed at the Go layer for zero-overhead URL manipulation.
|
|
15
|
+
|
|
16
|
+
// RedirectRule defines a URL redirect.
|
|
17
|
+
type RedirectRule struct {
|
|
18
|
+
Source string `json:"source"` // Source path pattern (e.g. "/old", "/blog/:slug")
|
|
19
|
+
Destination string `json:"destination"` // Destination URL
|
|
20
|
+
Permanent bool `json:"permanent"` // 301 (permanent) or 307 (temporary)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// RewriteRule defines a URL rewrite (internal, transparent to browser).
|
|
24
|
+
type RewriteRule struct {
|
|
25
|
+
Source string `json:"source"` // Source path pattern
|
|
26
|
+
Destination string `json:"destination"` // Internal destination path
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// RoutesConfig holds all redirect and rewrite rules.
|
|
30
|
+
type RoutesConfig struct {
|
|
31
|
+
Redirects []RedirectRule `json:"redirects"`
|
|
32
|
+
Rewrites []RewriteRule `json:"rewrites"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// compiledRedirect is a pre-compiled redirect rule for fast matching.
|
|
36
|
+
type compiledRedirect struct {
|
|
37
|
+
pattern *regexp.Regexp
|
|
38
|
+
paramNames []string
|
|
39
|
+
destination string
|
|
40
|
+
permanent bool
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// compiledRewrite is a pre-compiled rewrite rule for fast matching.
|
|
44
|
+
type compiledRewrite struct {
|
|
45
|
+
pattern *regexp.Regexp
|
|
46
|
+
paramNames []string
|
|
47
|
+
destination string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// RoutesEngine processes redirects and rewrites.
|
|
51
|
+
type RoutesEngine struct {
|
|
52
|
+
redirects []compiledRedirect
|
|
53
|
+
rewrites []compiledRewrite
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// LoadRoutesConfig reads the routes config from blumen.routes.json.
|
|
57
|
+
func LoadRoutesConfig(path string) (*RoutesConfig, error) {
|
|
58
|
+
data, err := os.ReadFile(path)
|
|
59
|
+
if err != nil {
|
|
60
|
+
return nil, err
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var config RoutesConfig
|
|
64
|
+
if err := json.Unmarshal(data, &config); err != nil {
|
|
65
|
+
return nil, err
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return &config, nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// NewRoutesEngine compiles redirect and rewrite rules for fast matching.
|
|
72
|
+
func NewRoutesEngine(config *RoutesConfig) *RoutesEngine {
|
|
73
|
+
engine := &RoutesEngine{}
|
|
74
|
+
|
|
75
|
+
for _, r := range config.Redirects {
|
|
76
|
+
pattern, params := compileRoutePattern(r.Source)
|
|
77
|
+
engine.redirects = append(engine.redirects, compiledRedirect{
|
|
78
|
+
pattern: pattern,
|
|
79
|
+
paramNames: params,
|
|
80
|
+
destination: r.Destination,
|
|
81
|
+
permanent: r.Permanent,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for _, r := range config.Rewrites {
|
|
86
|
+
pattern, params := compileRoutePattern(r.Source)
|
|
87
|
+
engine.rewrites = append(engine.rewrites, compiledRewrite{
|
|
88
|
+
pattern: pattern,
|
|
89
|
+
paramNames: params,
|
|
90
|
+
destination: r.Destination,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return engine
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// compileRoutePattern converts a route pattern like "/blog/:slug" or "/old/*"
|
|
98
|
+
// into a compiled regex and extracts parameter names.
|
|
99
|
+
func compileRoutePattern(source string) (*regexp.Regexp, []string) {
|
|
100
|
+
var paramNames []string
|
|
101
|
+
regexStr := "^"
|
|
102
|
+
|
|
103
|
+
parts := strings.Split(source, "/")
|
|
104
|
+
for _, part := range parts {
|
|
105
|
+
if part == "" {
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
regexStr += "/"
|
|
110
|
+
|
|
111
|
+
if part == "*" {
|
|
112
|
+
// Wildcard: match everything
|
|
113
|
+
regexStr += "(.*)"
|
|
114
|
+
paramNames = append(paramNames, "*")
|
|
115
|
+
} else if strings.HasPrefix(part, ":") {
|
|
116
|
+
// Named parameter: match path segment
|
|
117
|
+
paramName := strings.TrimPrefix(part, ":")
|
|
118
|
+
paramNames = append(paramNames, paramName)
|
|
119
|
+
regexStr += "([^/]+)"
|
|
120
|
+
} else {
|
|
121
|
+
// Literal match
|
|
122
|
+
regexStr += regexp.QuoteMeta(part)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
regexStr += "$"
|
|
127
|
+
return regexp.MustCompile(regexStr), paramNames
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// interpolateDestination replaces :param placeholders in the destination
|
|
131
|
+
// with matched values from the source pattern.
|
|
132
|
+
func interpolateDestination(destination string, paramNames []string, matches []string) string {
|
|
133
|
+
result := destination
|
|
134
|
+
for i, name := range paramNames {
|
|
135
|
+
if i+1 < len(matches) {
|
|
136
|
+
if name == "*" {
|
|
137
|
+
result = strings.ReplaceAll(result, ":splat", matches[i+1])
|
|
138
|
+
} else {
|
|
139
|
+
result = strings.ReplaceAll(result, ":"+name, matches[i+1])
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return result
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// RedirectsMiddleware creates a middleware that processes redirects and rewrites.
|
|
147
|
+
func RedirectsMiddleware(engine *RoutesEngine) MiddlewareFunc {
|
|
148
|
+
return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
|
149
|
+
path := r.URL.Path
|
|
150
|
+
|
|
151
|
+
// Check redirects first (external, browser follows)
|
|
152
|
+
for _, redirect := range engine.redirects {
|
|
153
|
+
matches := redirect.pattern.FindStringSubmatch(path)
|
|
154
|
+
if matches != nil {
|
|
155
|
+
dest := interpolateDestination(redirect.destination, redirect.paramNames, matches)
|
|
156
|
+
status := http.StatusTemporaryRedirect // 307
|
|
157
|
+
if redirect.permanent {
|
|
158
|
+
status = http.StatusMovedPermanently // 301
|
|
159
|
+
}
|
|
160
|
+
http.Redirect(w, r, dest, status)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Check rewrites (internal, transparent to browser)
|
|
166
|
+
for _, rewrite := range engine.rewrites {
|
|
167
|
+
matches := rewrite.pattern.FindStringSubmatch(path)
|
|
168
|
+
if matches != nil {
|
|
169
|
+
dest := interpolateDestination(rewrite.destination, rewrite.paramNames, matches)
|
|
170
|
+
r.URL.Path = dest
|
|
171
|
+
next(w, r)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// No match — continue to handler
|
|
177
|
+
next(w, r)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// SetupRedirects loads the routes config and registers the middleware.
|
|
182
|
+
// Returns the number of rules loaded, or -1 if no config file exists.
|
|
183
|
+
func SetupRedirects(chain *MiddlewareChain, configPath string) int {
|
|
184
|
+
config, err := LoadRoutesConfig(configPath)
|
|
185
|
+
if err != nil {
|
|
186
|
+
if os.IsNotExist(err) {
|
|
187
|
+
return -1 // No config file — not an error
|
|
188
|
+
}
|
|
189
|
+
log.Printf("⚠ Failed to load routes config: %v", err)
|
|
190
|
+
return 0
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
total := len(config.Redirects) + len(config.Rewrites)
|
|
194
|
+
if total == 0 {
|
|
195
|
+
return 0
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
engine := NewRoutesEngine(config)
|
|
199
|
+
chain.Use(RedirectsMiddleware(engine))
|
|
200
|
+
|
|
201
|
+
log.Printf("🔀 Loaded %d redirect(s) and %d rewrite(s)", len(config.Redirects), len(config.Rewrites))
|
|
202
|
+
return total
|
|
203
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"io"
|
|
6
|
+
"log"
|
|
7
|
+
"net/http"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"sync"
|
|
12
|
+
"time"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// ─── Static Site Generation (SSG) Server ───────────────────────
|
|
16
|
+
// Serves pre-rendered HTML pages directly from disk.
|
|
17
|
+
// Supports Incremental Static Regeneration (ISR) — pages can be
|
|
18
|
+
// revalidated in the background on a configurable timer.
|
|
19
|
+
|
|
20
|
+
// SSGPage tracks a pre-rendered page with ISR metadata.
|
|
21
|
+
type SSGPage struct {
|
|
22
|
+
FilePath string
|
|
23
|
+
Route string
|
|
24
|
+
Revalidate int // TTL in seconds; 0 = never revalidate
|
|
25
|
+
LastRender time.Time // When the page was last rendered
|
|
26
|
+
IsStale bool // Set true when revalidation is in progress
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// SSGServer manages pre-rendered static pages with ISR.
|
|
30
|
+
type SSGServer struct {
|
|
31
|
+
pages map[string]*SSGPage
|
|
32
|
+
staticDir string
|
|
33
|
+
mu sync.RWMutex
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// SSGManifest matches the format written by ssg-prerender.ts
|
|
37
|
+
type SSGManifest struct {
|
|
38
|
+
Pages []SSGManifestPage `json:"pages"`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type SSGManifestPage struct {
|
|
42
|
+
Route string `json:"route"`
|
|
43
|
+
ComponentId string `json:"componentId"`
|
|
44
|
+
HasGetServerProps bool `json:"hasGetServerProps"`
|
|
45
|
+
HasGetStaticProps bool `json:"hasGetStaticProps"`
|
|
46
|
+
HasGetStaticPaths bool `json:"hasGetStaticPaths"`
|
|
47
|
+
Revalidate int `json:"revalidate"`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// NewSSGServer loads the SSG manifest and maps routes to HTML files.
|
|
51
|
+
func NewSSGServer(staticDir string) *SSGServer {
|
|
52
|
+
server := &SSGServer{
|
|
53
|
+
pages: make(map[string]*SSGPage),
|
|
54
|
+
staticDir: staticDir,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
manifestPath := filepath.Join(staticDir, "manifest.json")
|
|
58
|
+
data, err := os.ReadFile(manifestPath)
|
|
59
|
+
if err != nil {
|
|
60
|
+
return server // No manifest — return empty server
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Try the new format (SSGManifest with full metadata)
|
|
64
|
+
var manifest SSGManifest
|
|
65
|
+
if err := json.Unmarshal(data, &manifest); err == nil && len(manifest.Pages) > 0 {
|
|
66
|
+
for _, page := range manifest.Pages {
|
|
67
|
+
filePath := server.routeToFilePath(page.Route)
|
|
68
|
+
if _, err := os.Stat(filePath); err == nil {
|
|
69
|
+
server.pages[page.Route] = &SSGPage{
|
|
70
|
+
FilePath: filePath,
|
|
71
|
+
Route: page.Route,
|
|
72
|
+
Revalidate: page.Revalidate,
|
|
73
|
+
LastRender: time.Now(),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
// Fall back to the old format (simple string array)
|
|
79
|
+
var routes []string
|
|
80
|
+
if err := json.Unmarshal(data, &routes); err != nil {
|
|
81
|
+
log.Printf("⚠ SSG manifest parse error: %v", err)
|
|
82
|
+
return server
|
|
83
|
+
}
|
|
84
|
+
for _, route := range routes {
|
|
85
|
+
filePath := server.routeToFilePath(route)
|
|
86
|
+
if _, err := os.Stat(filePath); err == nil {
|
|
87
|
+
server.pages[route] = &SSGPage{
|
|
88
|
+
FilePath: filePath,
|
|
89
|
+
Route: route,
|
|
90
|
+
Revalidate: 0,
|
|
91
|
+
LastRender: time.Now(),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if len(server.pages) > 0 {
|
|
98
|
+
isrCount := 0
|
|
99
|
+
for _, p := range server.pages {
|
|
100
|
+
if p.Revalidate > 0 {
|
|
101
|
+
isrCount++
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
log.Printf("📄 SSG: %d pre-rendered page(s) loaded", len(server.pages))
|
|
105
|
+
if isrCount > 0 {
|
|
106
|
+
log.Printf("🔄 ISR: %d page(s) with revalidation enabled", isrCount)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return server
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func (s *SSGServer) routeToFilePath(route string) string {
|
|
114
|
+
if route == "/" {
|
|
115
|
+
return filepath.Join(s.staticDir, "index.html")
|
|
116
|
+
}
|
|
117
|
+
return filepath.Join(s.staticDir, strings.TrimPrefix(route, "/"), "index.html")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// HasPage checks if a route has a pre-rendered page.
|
|
121
|
+
func (s *SSGServer) HasPage(route string) bool {
|
|
122
|
+
s.mu.RLock()
|
|
123
|
+
defer s.mu.RUnlock()
|
|
124
|
+
_, ok := s.pages[route]
|
|
125
|
+
return ok
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ServePage serves a pre-rendered HTML page with ISR support.
|
|
129
|
+
// Returns true if the page was served, false if not found.
|
|
130
|
+
func (s *SSGServer) ServePage(w http.ResponseWriter, r *http.Request) bool {
|
|
131
|
+
s.mu.RLock()
|
|
132
|
+
page, ok := s.pages[r.URL.Path]
|
|
133
|
+
s.mu.RUnlock()
|
|
134
|
+
|
|
135
|
+
if !ok {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Read the pre-rendered HTML
|
|
140
|
+
html, err := os.ReadFile(page.FilePath)
|
|
141
|
+
if err != nil {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check if ISR revalidation is needed
|
|
146
|
+
if page.Revalidate > 0 {
|
|
147
|
+
age := time.Since(page.LastRender)
|
|
148
|
+
ttl := time.Duration(page.Revalidate) * time.Second
|
|
149
|
+
|
|
150
|
+
if age > ttl && !page.IsStale {
|
|
151
|
+
// Mark as stale and trigger background revalidation
|
|
152
|
+
s.mu.Lock()
|
|
153
|
+
page.IsStale = true
|
|
154
|
+
s.mu.Unlock()
|
|
155
|
+
|
|
156
|
+
go s.revalidatePage(page)
|
|
157
|
+
w.Header().Set("X-Blumen-Cache", "STALE")
|
|
158
|
+
} else {
|
|
159
|
+
w.Header().Set("X-Blumen-Cache", "SSG")
|
|
160
|
+
}
|
|
161
|
+
w.Header().Set("X-Blumen-Revalidate", string(rune(page.Revalidate)))
|
|
162
|
+
} else {
|
|
163
|
+
w.Header().Set("X-Blumen-Cache", "SSG")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
167
|
+
w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
|
|
168
|
+
w.WriteHeader(http.StatusOK)
|
|
169
|
+
w.Write(html)
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// revalidatePage re-renders a static page in the background via the Node SSR server.
|
|
174
|
+
func (s *SSGServer) revalidatePage(page *SSGPage) {
|
|
175
|
+
defer func() {
|
|
176
|
+
s.mu.Lock()
|
|
177
|
+
page.IsStale = false
|
|
178
|
+
s.mu.Unlock()
|
|
179
|
+
}()
|
|
180
|
+
|
|
181
|
+
log.Printf("🔄 ISR: Revalidating %s", page.Route)
|
|
182
|
+
|
|
183
|
+
// Call the Node SSR server to re-render
|
|
184
|
+
body := `{"path":"` + page.Route + `","query":{},"params":{}}`
|
|
185
|
+
resp, err := http.Post("http://localhost:4000/render", "application/json", strings.NewReader(body))
|
|
186
|
+
if err != nil {
|
|
187
|
+
log.Printf("⚠ ISR revalidation failed for %s: %v", page.Route, err)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
defer resp.Body.Close()
|
|
191
|
+
|
|
192
|
+
respBody, err := io.ReadAll(resp.Body)
|
|
193
|
+
if err != nil {
|
|
194
|
+
log.Printf("⚠ ISR revalidation read failed for %s: %v", page.Route, err)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
var ssrResp struct {
|
|
199
|
+
HTML string `json:"html"`
|
|
200
|
+
}
|
|
201
|
+
if err := json.Unmarshal(respBody, &ssrResp); err != nil || ssrResp.HTML == "" {
|
|
202
|
+
log.Printf("⚠ ISR revalidation parse failed for %s", page.Route)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Atomic write: write to temp file, then rename
|
|
207
|
+
tmpFile := page.FilePath + ".tmp"
|
|
208
|
+
if err := os.WriteFile(tmpFile, []byte(ssrResp.HTML), 0644); err != nil {
|
|
209
|
+
log.Printf("⚠ ISR write failed for %s: %v", page.Route, err)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
if err := os.Rename(tmpFile, page.FilePath); err != nil {
|
|
213
|
+
log.Printf("⚠ ISR rename failed for %s: %v", page.Route, err)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Update metadata
|
|
218
|
+
s.mu.Lock()
|
|
219
|
+
page.LastRender = time.Now()
|
|
220
|
+
s.mu.Unlock()
|
|
221
|
+
|
|
222
|
+
log.Printf("✅ ISR: Revalidated %s", page.Route)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// PageCount returns the number of pre-rendered pages.
|
|
226
|
+
func (s *SSGServer) PageCount() int {
|
|
227
|
+
s.mu.RLock()
|
|
228
|
+
defer s.mu.RUnlock()
|
|
229
|
+
return len(s.pages)
|
|
230
|
+
}
|
|
@@ -342,8 +342,9 @@ async function handleRender(
|
|
|
342
342
|
};
|
|
343
343
|
|
|
344
344
|
// Render React to HTML
|
|
345
|
-
//
|
|
346
|
-
//
|
|
345
|
+
// Must match the client's INITIAL hydration tree exactly:
|
|
346
|
+
// div.page-transition > App
|
|
347
|
+
// (Client adds Suspense/ErrorBoundary AFTER hydration via useEffect)
|
|
347
348
|
const appElement = React.createElement(
|
|
348
349
|
"div",
|
|
349
350
|
{ className: "page-transition page-transition-active" },
|