blumenjs 0.1.6 → 0.2.0
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/blumen.js +258 -13
- package/dist/cli/commands/build.js +13 -4
- package/dist/cli/commands/create.js +1 -1
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/fonts.js +232 -0
- package/dist/cli/commands/start.js +1 -1
- package/dist/templates/app/client/entry.tsx +3 -1
- package/dist/templates/app/pages/templates/BlumenDashboard.tsx +99 -46
- package/dist/templates/app/shared/RouterContext.tsx +4 -1
- package/dist/templates/go-server/cache.go +147 -0
- package/dist/templates/go-server/image.go +200 -0
- package/dist/templates/go-server/main.go +208 -40
- package/dist/templates/node-ssr/server.ts +120 -8
- package/dist/templates/scripts/generate-routes.ts +43 -3
- package/package.json +7 -3
|
@@ -58,44 +58,53 @@ const DashboardPage: React.FC = () => {
|
|
|
58
58
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
|
59
59
|
@keyframes fu{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
|
60
60
|
.fd *{box-sizing:border-box;margin:0;padding:0;font-family:'Inter',sans-serif}
|
|
61
|
-
.fd{min-height:100vh;background:#f0f2f0;display:flex;color:#1a1a2e}
|
|
61
|
+
.fd{min-height:100vh;background:#f0f2f0;display:flex;color:#1a1a2e;width:100%}
|
|
62
62
|
|
|
63
63
|
/* Sidebar */
|
|
64
|
-
.sb{width:
|
|
64
|
+
.sb{width:220px;background:#fff;border-right:1px solid #e5e7eb;padding:16px 10px;display:flex;flex-direction:column;position:fixed;top:0;bottom:0;left:0;z-index:10}
|
|
65
65
|
.sb-brand{display:flex;align-items:center;gap:8px;padding:8px 10px;margin-bottom:20px}
|
|
66
66
|
.sb-logo{width:28px;height:28px;border-radius:7px;background:linear-gradient(135deg,#16a34a,#22c55e);display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:#fff;font-weight:800}
|
|
67
67
|
.sb-name{font-size:0.9rem;font-weight:800;letter-spacing:-0.01em}
|
|
68
68
|
.sb-nav{list-style:none;flex:1}
|
|
69
|
-
.sb-ni{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;font-size:0.78rem;font-weight:500;color:#6b7280;cursor:pointer;transition:all 0.15s;margin-bottom:1px;border:none;background:none;width:100%;text-align:left}
|
|
69
|
+
.sb-ni{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;font-size:0.78rem;font-weight:500;color:#6b7280;cursor:pointer;transition:all 0.15s;margin-bottom:1px;border:none;background:none;width:100%;text-align:left;font-family:inherit}
|
|
70
70
|
.sb-ni:hover{color:#16a34a;background:#f0fdf4}
|
|
71
71
|
.sb-ni.on{color:#fff;background:#16a34a;font-weight:600}
|
|
72
72
|
.sb-cta{margin-top:auto;padding:16px;border-radius:12px;background:linear-gradient(135deg,#0f3d1e,#14532d);color:#fff;text-align:center}
|
|
73
73
|
.sb-cta p{font-size:0.65rem;color:#86efac;margin:6px 0 10px;line-height:1.4}
|
|
74
|
-
.sb-cta-btn{padding:6px 16px;border-radius:6px;background:#22c55e;color:#fff;font-size:0.72rem;font-weight:600;border:none;cursor:pointer}
|
|
74
|
+
.sb-cta-btn{padding:6px 16px;border-radius:6px;background:#22c55e;color:#fff;font-size:0.72rem;font-weight:600;border:none;cursor:pointer;font-family:inherit}
|
|
75
|
+
|
|
76
|
+
/* Mobile header bar */
|
|
77
|
+
.mob-bar{display:none;position:sticky;top:0;z-index:20;background:#fff;border-bottom:1px solid #e5e7eb;padding:10px 16px;align-items:center;gap:10px}
|
|
78
|
+
.mob-bar-btn{width:36px;height:36px;border-radius:8px;background:#f0fdf4;border:1px solid #dcfce7;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:1.1rem}
|
|
79
|
+
.mob-bar h1{font-size:1rem;font-weight:800;flex:1}
|
|
80
|
+
.mob-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:50}
|
|
81
|
+
.mob-overlay.show{display:block}
|
|
82
|
+
.sb.mob-open{display:flex!important;z-index:60}
|
|
75
83
|
|
|
76
84
|
/* Layout */
|
|
77
|
-
.mn{margin-left:
|
|
78
|
-
.lp{padding:16px
|
|
79
|
-
.cp{padding:
|
|
80
|
-
.rp{padding:16px
|
|
85
|
+
.mn{margin-left:220px;display:grid;grid-template-columns:260px 1fr 280px;min-height:100vh;width:calc(100% - 220px)}
|
|
86
|
+
.lp{padding:16px 14px;background:#fff;border-right:1px solid #e8ebe8;overflow-y:auto}
|
|
87
|
+
.cp{padding:20px;min-width:0;overflow-x:hidden}
|
|
88
|
+
.rp{padding:16px 14px;background:#fff;border-left:1px solid #e8ebe8;overflow-y:auto}
|
|
81
89
|
|
|
82
90
|
/* Components */
|
|
83
|
-
.cd{background:#fff;border-radius:12px;border:1px solid #e8ebe8;padding:
|
|
84
|
-
.hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:
|
|
85
|
-
.hdr h1{font-size:1.
|
|
86
|
-
.
|
|
87
|
-
.
|
|
91
|
+
.cd{background:#fff;border-radius:12px;border:1px solid #e8ebe8;padding:16px;margin-bottom:14px;animation:fu 0.4s ease-out both}
|
|
92
|
+
.hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:10px}
|
|
93
|
+
.hdr h1{font-size:1.2rem;font-weight:800}
|
|
94
|
+
.hdr-right{display:flex;align-items:center;gap:10px}
|
|
95
|
+
.search{padding:7px 14px;border-radius:8px;border:1px solid #e5e7eb;background:#f9fafb;font-size:0.78rem;width:220px;font-family:inherit}
|
|
96
|
+
.uav{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#16a34a,#4ade80);display:flex;align-items:center;justify-content:center;font-size:0.65rem;font-weight:700;color:#fff;flex-shrink:0}
|
|
88
97
|
|
|
89
98
|
/* User Card */
|
|
90
|
-
.uc{background:linear-gradient(135deg,#14532d,#166534);border-radius:14px;padding:
|
|
99
|
+
.uc{background:linear-gradient(135deg,#14532d,#166534);border-radius:14px;padding:18px;color:#fff;margin-bottom:14px;position:relative;overflow:hidden}
|
|
91
100
|
.uc::after{content:'';position:absolute;top:-25px;right:-25px;width:100px;height:100px;border-radius:50%;background:rgba(255,255,255,0.05)}
|
|
92
|
-
.uc-n{font-size:0.
|
|
93
|
-
.uc-b{font-size:0.
|
|
94
|
-
.uc-a{font-size:1.
|
|
95
|
-
.uc-m{display:flex;gap:16px;margin-top:10px;font-size:0.
|
|
101
|
+
.uc-n{font-size:0.92rem;font-weight:700;margin:8px 0 12px}
|
|
102
|
+
.uc-b{font-size:0.68rem;color:#86efac}
|
|
103
|
+
.uc-a{font-size:1.4rem;font-weight:800;margin:2px 0}
|
|
104
|
+
.uc-m{display:flex;gap:16px;margin-top:10px;font-size:0.68rem;color:#86efac}
|
|
96
105
|
|
|
97
106
|
/* Quick Actions */
|
|
98
|
-
.qa{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:
|
|
107
|
+
.qa{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:14px}
|
|
99
108
|
.qa-i{text-align:center;padding:8px 2px;border-radius:8px;background:#f0fdf4;border:1px solid #dcfce7;cursor:pointer;transition:all 0.15s}
|
|
100
109
|
.qa-i:hover{background:#dcfce7;transform:translateY(-1px)}
|
|
101
110
|
.qa-ic{font-size:0.9rem;margin-bottom:2px}
|
|
@@ -114,36 +123,36 @@ const DashboardPage: React.FC = () => {
|
|
|
114
123
|
.sv-m{display:flex;justify-content:space-between;font-size:0.65rem;color:#9ca3af}
|
|
115
124
|
|
|
116
125
|
/* Stats */
|
|
117
|
-
.sts{display:grid;grid-template-columns:repeat(3,1fr);gap:
|
|
118
|
-
.st{background:#fff;border-radius:12px;border:1px solid #e8ebe8;padding:
|
|
126
|
+
.sts{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px}
|
|
127
|
+
.st{background:#fff;border-radius:12px;border:1px solid #e8ebe8;padding:16px;animation:fu 0.35s ease-out both}
|
|
119
128
|
.st:nth-child(2){animation-delay:0.05s}
|
|
120
129
|
.st:nth-child(3){animation-delay:0.1s}
|
|
121
130
|
.st-ic{width:32px;height:32px;border-radius:8px;background:#f0fdf4;display:flex;align-items:center;justify-content:center;font-size:0.8rem;margin-bottom:10px}
|
|
122
|
-
.st-v{font-size:1.
|
|
123
|
-
.st-l{font-size:0.
|
|
124
|
-
.st-c{font-size:0.
|
|
131
|
+
.st-v{font-size:1.4rem;font-weight:800;letter-spacing:-0.02em}
|
|
132
|
+
.st-l{font-size:0.75rem;color:#9ca3af;margin-top:2px}
|
|
133
|
+
.st-c{font-size:0.68rem;font-weight:600;padding:2px 6px;border-radius:4px;margin-top:6px;display:inline-block}
|
|
125
134
|
.st-c.up{background:#f0fdf4;color:#16a34a}
|
|
126
135
|
.st-c.dn{background:#fef2f2;color:#dc2626}
|
|
127
136
|
|
|
128
137
|
/* Chart */
|
|
129
|
-
.ch{display:flex;align-items:flex-end;gap:4px;height:
|
|
138
|
+
.ch{display:flex;align-items:flex-end;gap:4px;height:160px;padding-top:6px}
|
|
130
139
|
.ch-c{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px}
|
|
131
140
|
.ch-bw{flex:1;width:100%;display:flex;gap:2px;align-items:flex-end;justify-content:center}
|
|
132
141
|
.ch-b{border-radius:2px 2px 0 0;min-width:6px;transition:all 0.2s}
|
|
133
142
|
.ch-b:hover{filter:brightness(1.15)}
|
|
134
143
|
.ch-b.inc{background:#16a34a}
|
|
135
144
|
.ch-b.exp{background:#bbf7d0}
|
|
136
|
-
.ch-m{font-size:0.
|
|
137
|
-
.lg{display:flex;gap:12px;font-size:0.
|
|
138
|
-
.lg-d{width:
|
|
145
|
+
.ch-m{font-size:0.58rem;color:#9ca3af;margin-top:3px}
|
|
146
|
+
.lg{display:flex;gap:12px;font-size:0.7rem;color:#6b7280}
|
|
147
|
+
.lg-d{width:8px;height:8px;border-radius:2px;display:inline-block;margin-right:4px}
|
|
139
148
|
|
|
140
149
|
/* Table */
|
|
141
|
-
.tb{width:100%;border-collapse:collapse;font-size:0.
|
|
142
|
-
.tb th{text-align:left;padding:
|
|
143
|
-
.tb td{padding:
|
|
144
|
-
.tb-n{font-weight:600;color:#1a1a2e;font-size:0.
|
|
145
|
-
.tb-c{font-size:0.
|
|
146
|
-
.badge{padding:
|
|
150
|
+
.tb{width:100%;border-collapse:collapse;font-size:0.75rem}
|
|
151
|
+
.tb th{text-align:left;padding:8px 10px;color:#9ca3af;font-weight:500;font-size:0.68rem;border-bottom:1px solid #f3f4f6}
|
|
152
|
+
.tb td{padding:10px;border-bottom:1px solid #f9fafb;color:#374151}
|
|
153
|
+
.tb-n{font-weight:600;color:#1a1a2e;font-size:0.78rem}
|
|
154
|
+
.tb-c{font-size:0.68rem;color:#9ca3af}
|
|
155
|
+
.badge{padding:3px 8px;border-radius:5px;font-size:0.65rem;font-weight:600}
|
|
147
156
|
|
|
148
157
|
/* Donut */
|
|
149
158
|
.dn-w{display:flex;justify-content:center;margin:12px 0}
|
|
@@ -151,24 +160,61 @@ const DashboardPage: React.FC = () => {
|
|
|
151
160
|
/* Expense list */
|
|
152
161
|
.ex{display:flex;align-items:center;padding:5px 0}
|
|
153
162
|
.ex-d{width:7px;height:7px;border-radius:50%;margin-right:7px;flex-shrink:0}
|
|
154
|
-
.ex-p{font-size:0.
|
|
155
|
-
.ex-l{flex:1;font-size:0.
|
|
156
|
-
.ex-a{font-size:0.
|
|
163
|
+
.ex-p{font-size:0.68rem;color:#9ca3af;margin-right:10px;min-width:24px;font-weight:600}
|
|
164
|
+
.ex-l{flex:1;font-size:0.78rem;color:#374151}
|
|
165
|
+
.ex-a{font-size:0.8rem;font-weight:600;text-align:right}
|
|
157
166
|
|
|
158
167
|
/* Activity */
|
|
159
168
|
.ac{display:flex;gap:8px;padding:6px 0}
|
|
160
169
|
.ac-d{width:7px;height:7px;border-radius:50%;margin-top:5px;flex-shrink:0}
|
|
161
|
-
.ac-t{flex:1;font-size:0.
|
|
170
|
+
.ac-t{flex:1;font-size:0.75rem;color:#374151;line-height:1.4}
|
|
162
171
|
.ac-u{font-weight:600;color:#1a1a2e}
|
|
163
|
-
.ac-tm{font-size:0.
|
|
172
|
+
.ac-tm{font-size:0.65rem;color:#9ca3af;margin-top:1px}
|
|
173
|
+
|
|
174
|
+
.stl{font-size:0.85rem;font-weight:700;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center}
|
|
175
|
+
.stl span{font-size:0.68rem;color:#16a34a;font-weight:500;cursor:pointer}
|
|
176
|
+
.sub{font-size:0.68rem;color:#9ca3af}
|
|
177
|
+
.big{font-size:1.2rem;font-weight:800;margin-bottom:8px}
|
|
178
|
+
|
|
179
|
+
/* ── Responsive ── */
|
|
180
|
+
|
|
181
|
+
/* Large: hide right panel */
|
|
182
|
+
@media(max-width:1400px){
|
|
183
|
+
.mn{grid-template-columns:260px 1fr}
|
|
184
|
+
.rp{display:none}
|
|
185
|
+
}
|
|
164
186
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
.
|
|
168
|
-
.
|
|
187
|
+
/* Medium: hide left panel too, only center */
|
|
188
|
+
@media(max-width:1100px){
|
|
189
|
+
.mn{grid-template-columns:1fr}
|
|
190
|
+
.lp{display:none}
|
|
191
|
+
.sts{grid-template-columns:repeat(3,1fr)}
|
|
192
|
+
}
|
|
169
193
|
|
|
170
|
-
|
|
171
|
-
@media(max-width:768px){
|
|
194
|
+
/* Tablet: hide sidebar, show mobile header */
|
|
195
|
+
@media(max-width:768px){
|
|
196
|
+
.sb{display:none}
|
|
197
|
+
.mn{margin-left:0;width:100%}
|
|
198
|
+
.mob-bar{display:flex}
|
|
199
|
+
.cp{padding:16px}
|
|
200
|
+
.sts{grid-template-columns:repeat(2,1fr)}
|
|
201
|
+
.search{width:140px}
|
|
202
|
+
.hdr{flex-wrap:wrap}
|
|
203
|
+
.hdr h1{display:none}
|
|
204
|
+
.tb th:nth-child(4),.tb td:nth-child(4){display:none}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* Mobile: single column everything */
|
|
208
|
+
@media(max-width:480px){
|
|
209
|
+
.sts{grid-template-columns:1fr}
|
|
210
|
+
.cp{padding:12px}
|
|
211
|
+
.cd{padding:12px}
|
|
212
|
+
.hdr-right{flex-wrap:wrap}
|
|
213
|
+
.search{width:100%}
|
|
214
|
+
.st-v{font-size:1.1rem}
|
|
215
|
+
.ch{height:120px}
|
|
216
|
+
.tb th:nth-child(3),.tb td:nth-child(3){display:none}
|
|
217
|
+
}
|
|
172
218
|
`}} />
|
|
173
219
|
|
|
174
220
|
{/* Sidebar */}
|
|
@@ -191,6 +237,13 @@ const DashboardPage: React.FC = () => {
|
|
|
191
237
|
</div>
|
|
192
238
|
</aside>
|
|
193
239
|
|
|
240
|
+
{/* Mobile header bar */}
|
|
241
|
+
<div className="mob-bar">
|
|
242
|
+
<div className="mob-bar-btn" onClick={() => setActiveNav(activeNav)}>☰</div>
|
|
243
|
+
<h1>Dashboard</h1>
|
|
244
|
+
<div className="uav">AF</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
194
247
|
<div className="mn">
|
|
195
248
|
{/* Left Panel */}
|
|
196
249
|
<div className="lp">
|
|
@@ -233,7 +286,7 @@ const DashboardPage: React.FC = () => {
|
|
|
233
286
|
<div className="cp">
|
|
234
287
|
<div className="hdr">
|
|
235
288
|
<h1>Dashboard</h1>
|
|
236
|
-
<div
|
|
289
|
+
<div className="hdr-right">
|
|
237
290
|
<input className="search" placeholder="Search placeholder" />
|
|
238
291
|
<div className="uav">AF</div>
|
|
239
292
|
<span style={{ fontSize: "0.82rem", fontWeight: 600 }}>Andrew Forbist</span>
|
|
@@ -16,6 +16,7 @@ import React, {
|
|
|
16
16
|
useRef,
|
|
17
17
|
} from "react";
|
|
18
18
|
import { matchRoute, type RouteDef } from "./router";
|
|
19
|
+
import { BlumenErrorBoundary } from "./ErrorBoundary";
|
|
19
20
|
|
|
20
21
|
// ── Context types ──────────────────────────────────────────────
|
|
21
22
|
|
|
@@ -169,7 +170,9 @@ export function RouterProvider({
|
|
|
169
170
|
<div
|
|
170
171
|
className={`page-transition ${transitioning ? "page-transition-exit" : "page-transition-active"}`}
|
|
171
172
|
>
|
|
172
|
-
<
|
|
173
|
+
<BlumenErrorBoundary>
|
|
174
|
+
<App Component={PageComponent} pageProps={pageProps} />
|
|
175
|
+
</BlumenErrorBoundary>
|
|
173
176
|
</div>
|
|
174
177
|
</RouterContext.Provider>
|
|
175
178
|
);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
"sync"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// CacheEntry represents a single cached page response.
|
|
11
|
+
type CacheEntry struct {
|
|
12
|
+
HTML string
|
|
13
|
+
ETag string
|
|
14
|
+
CreatedAt time.Time
|
|
15
|
+
Revalidate int // TTL in seconds; 0 = cache forever until evicted
|
|
16
|
+
Stale bool
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// PageCache is a thread-safe in-memory LRU cache for rendered HTML pages.
|
|
20
|
+
// It uses sync.RWMutex for maximum concurrent read performance —
|
|
21
|
+
// thousands of goroutines can read simultaneously while writes are serialized.
|
|
22
|
+
type PageCache struct {
|
|
23
|
+
mu sync.RWMutex
|
|
24
|
+
entries map[string]*CacheEntry
|
|
25
|
+
order []string // LRU order: most recently used at end
|
|
26
|
+
maxSize int
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// NewPageCache creates a new cache with the given maximum number of entries.
|
|
30
|
+
func NewPageCache(maxSize int) *PageCache {
|
|
31
|
+
return &PageCache{
|
|
32
|
+
entries: make(map[string]*CacheEntry),
|
|
33
|
+
order: make([]string, 0, maxSize),
|
|
34
|
+
maxSize: maxSize,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Get retrieves a cached page. Returns the entry and whether it was found.
|
|
39
|
+
// Also returns whether the entry is stale (past its revalidate TTL).
|
|
40
|
+
func (c *PageCache) Get(key string) (entry *CacheEntry, found bool, stale bool) {
|
|
41
|
+
c.mu.RLock()
|
|
42
|
+
e, ok := c.entries[key]
|
|
43
|
+
c.mu.RUnlock()
|
|
44
|
+
|
|
45
|
+
if !ok {
|
|
46
|
+
return nil, false, false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check if the entry has expired
|
|
50
|
+
if e.Revalidate > 0 {
|
|
51
|
+
age := time.Since(e.CreatedAt).Seconds()
|
|
52
|
+
if age > float64(e.Revalidate) {
|
|
53
|
+
return e, true, true // Found but stale
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Move to end of LRU order (most recently used)
|
|
58
|
+
c.mu.Lock()
|
|
59
|
+
c.moveToEnd(key)
|
|
60
|
+
c.mu.Unlock()
|
|
61
|
+
|
|
62
|
+
return e, true, false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Set stores a page in the cache with the given revalidation TTL.
|
|
66
|
+
func (c *PageCache) Set(key string, html string, revalidate int) {
|
|
67
|
+
etag := generateETag(html)
|
|
68
|
+
|
|
69
|
+
c.mu.Lock()
|
|
70
|
+
defer c.mu.Unlock()
|
|
71
|
+
|
|
72
|
+
// If entry exists, update it
|
|
73
|
+
if _, exists := c.entries[key]; exists {
|
|
74
|
+
c.entries[key] = &CacheEntry{
|
|
75
|
+
HTML: html,
|
|
76
|
+
ETag: etag,
|
|
77
|
+
CreatedAt: time.Now(),
|
|
78
|
+
Revalidate: revalidate,
|
|
79
|
+
}
|
|
80
|
+
c.moveToEnd(key)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Evict LRU entry if at capacity
|
|
85
|
+
if len(c.entries) >= c.maxSize && len(c.order) > 0 {
|
|
86
|
+
oldest := c.order[0]
|
|
87
|
+
c.order = c.order[1:]
|
|
88
|
+
delete(c.entries, oldest)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Add new entry
|
|
92
|
+
c.entries[key] = &CacheEntry{
|
|
93
|
+
HTML: html,
|
|
94
|
+
ETag: etag,
|
|
95
|
+
CreatedAt: time.Now(),
|
|
96
|
+
Revalidate: revalidate,
|
|
97
|
+
}
|
|
98
|
+
c.order = append(c.order, key)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Invalidate removes a specific key from the cache.
|
|
102
|
+
func (c *PageCache) Invalidate(key string) {
|
|
103
|
+
c.mu.Lock()
|
|
104
|
+
defer c.mu.Unlock()
|
|
105
|
+
|
|
106
|
+
delete(c.entries, key)
|
|
107
|
+
for i, k := range c.order {
|
|
108
|
+
if k == key {
|
|
109
|
+
c.order = append(c.order[:i], c.order[i+1:]...)
|
|
110
|
+
break
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Clear removes all entries from the cache.
|
|
116
|
+
func (c *PageCache) Clear() {
|
|
117
|
+
c.mu.Lock()
|
|
118
|
+
defer c.mu.Unlock()
|
|
119
|
+
|
|
120
|
+
c.entries = make(map[string]*CacheEntry)
|
|
121
|
+
c.order = make([]string, 0, c.maxSize)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Size returns the current number of cached entries.
|
|
125
|
+
func (c *PageCache) Size() int {
|
|
126
|
+
c.mu.RLock()
|
|
127
|
+
defer c.mu.RUnlock()
|
|
128
|
+
return len(c.entries)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// moveToEnd moves a key to the end of the LRU order (most recently used).
|
|
132
|
+
// Must be called with the write lock held.
|
|
133
|
+
func (c *PageCache) moveToEnd(key string) {
|
|
134
|
+
for i, k := range c.order {
|
|
135
|
+
if k == key {
|
|
136
|
+
c.order = append(c.order[:i], c.order[i+1:]...)
|
|
137
|
+
c.order = append(c.order, key)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// generateETag creates an ETag from the HTML content.
|
|
144
|
+
func generateETag(content string) string {
|
|
145
|
+
hash := sha256.Sum256([]byte(content))
|
|
146
|
+
return `"` + hex.EncodeToString(hash[:8]) + `"`
|
|
147
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"fmt"
|
|
8
|
+
"image"
|
|
9
|
+
"image/jpeg"
|
|
10
|
+
"image/png"
|
|
11
|
+
"log"
|
|
12
|
+
"net/http"
|
|
13
|
+
"os"
|
|
14
|
+
"path/filepath"
|
|
15
|
+
"strconv"
|
|
16
|
+
"strings"
|
|
17
|
+
|
|
18
|
+
"golang.org/x/image/draw"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const (
|
|
22
|
+
imageCacheDir = ".blumen/image-cache"
|
|
23
|
+
maxImageWidth = 4096
|
|
24
|
+
defaultQuality = 80
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
func init() {
|
|
28
|
+
// Ensure cache directory exists
|
|
29
|
+
os.MkdirAll(imageCacheDir, 0755)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ImageHandler serves optimized images with on-the-fly resizing and caching.
|
|
33
|
+
// URL: /_blumen/image?src=/static/hero.jpg&w=800&q=80&blur=1
|
|
34
|
+
func ImageHandler() http.HandlerFunc {
|
|
35
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
36
|
+
if r.Method != http.MethodGet {
|
|
37
|
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
q := r.URL.Query()
|
|
42
|
+
src := q.Get("src")
|
|
43
|
+
widthStr := q.Get("w")
|
|
44
|
+
qualityStr := q.Get("q")
|
|
45
|
+
isBlur := q.Get("blur") == "1"
|
|
46
|
+
|
|
47
|
+
if src == "" {
|
|
48
|
+
http.Error(w, "Missing 'src' parameter", http.StatusBadRequest)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Parse width
|
|
53
|
+
targetWidth := 0
|
|
54
|
+
if widthStr != "" {
|
|
55
|
+
var err error
|
|
56
|
+
targetWidth, err = strconv.Atoi(widthStr)
|
|
57
|
+
if err != nil || targetWidth <= 0 || targetWidth > maxImageWidth {
|
|
58
|
+
http.Error(w, "Invalid 'w' parameter", http.StatusBadRequest)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Parse quality
|
|
64
|
+
quality := defaultQuality
|
|
65
|
+
if qualityStr != "" {
|
|
66
|
+
var err error
|
|
67
|
+
quality, err = strconv.Atoi(qualityStr)
|
|
68
|
+
if err != nil || quality < 1 || quality > 100 {
|
|
69
|
+
quality = defaultQuality
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Security: resolve the source path and ensure it's within the project
|
|
74
|
+
cleanSrc := strings.TrimPrefix(src, "/")
|
|
75
|
+
absPath, err := filepath.Abs(cleanSrc)
|
|
76
|
+
if err != nil {
|
|
77
|
+
http.Error(w, "Invalid path", http.StatusBadRequest)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Ensure path is within the project directory
|
|
82
|
+
cwd, _ := os.Getwd()
|
|
83
|
+
if !strings.HasPrefix(absPath, cwd) {
|
|
84
|
+
http.Error(w, "Access denied", http.StatusForbidden)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check if file exists
|
|
89
|
+
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
|
90
|
+
http.Error(w, "Image not found", http.StatusNotFound)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Generate cache key
|
|
95
|
+
cacheKey := generateCacheKey(src, targetWidth, quality, isBlur)
|
|
96
|
+
cachePath := filepath.Join(imageCacheDir, cacheKey+".jpg")
|
|
97
|
+
|
|
98
|
+
// Check disk cache
|
|
99
|
+
if cachedData, err := os.ReadFile(cachePath); err == nil {
|
|
100
|
+
serveCachedImage(w, cachedData)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Process the image
|
|
105
|
+
processedData, err := processImage(absPath, targetWidth, quality, isBlur)
|
|
106
|
+
if err != nil {
|
|
107
|
+
log.Printf("Image processing error: %v", err)
|
|
108
|
+
http.ServeFile(w, r, absPath)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Write to disk cache
|
|
113
|
+
os.WriteFile(cachePath, processedData, 0644)
|
|
114
|
+
|
|
115
|
+
// Serve the processed image
|
|
116
|
+
serveCachedImage(w, processedData)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func generateCacheKey(src string, width, quality int, blur bool) string {
|
|
121
|
+
data := fmt.Sprintf("%s-%d-%d-%v", src, width, quality, blur)
|
|
122
|
+
hash := sha256.Sum256([]byte(data))
|
|
123
|
+
return hex.EncodeToString(hash[:16])
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func serveCachedImage(w http.ResponseWriter, data []byte) {
|
|
127
|
+
w.Header().Set("Content-Type", "image/jpeg")
|
|
128
|
+
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
129
|
+
w.Header().Set("X-Blumen-Image", "optimized")
|
|
130
|
+
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
|
131
|
+
w.WriteHeader(http.StatusOK)
|
|
132
|
+
w.Write(data)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func processImage(filePath string, targetWidth, quality int, blur bool) ([]byte, error) {
|
|
136
|
+
file, err := os.Open(filePath)
|
|
137
|
+
if err != nil {
|
|
138
|
+
return nil, fmt.Errorf("open image: %w", err)
|
|
139
|
+
}
|
|
140
|
+
defer file.Close()
|
|
141
|
+
|
|
142
|
+
img, format, err := image.Decode(file)
|
|
143
|
+
if err != nil {
|
|
144
|
+
return nil, fmt.Errorf("decode image (%s): %w", format, err)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
bounds := img.Bounds()
|
|
148
|
+
origWidth := bounds.Dx()
|
|
149
|
+
origHeight := bounds.Dy()
|
|
150
|
+
|
|
151
|
+
// Calculate target dimensions maintaining aspect ratio
|
|
152
|
+
newWidth := origWidth
|
|
153
|
+
newHeight := origHeight
|
|
154
|
+
|
|
155
|
+
if targetWidth > 0 && targetWidth < origWidth {
|
|
156
|
+
ratio := float64(targetWidth) / float64(origWidth)
|
|
157
|
+
newWidth = targetWidth
|
|
158
|
+
newHeight = int(float64(origHeight) * ratio)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Resize using high-quality bilinear interpolation
|
|
162
|
+
resized := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
|
|
163
|
+
draw.BiLinear.Scale(resized, resized.Bounds(), img, bounds, draw.Over, nil)
|
|
164
|
+
|
|
165
|
+
// For blur placeholders, downscale aggressively
|
|
166
|
+
var finalImg image.Image = resized
|
|
167
|
+
if blur {
|
|
168
|
+
blurWidth := 20
|
|
169
|
+
if newWidth < blurWidth {
|
|
170
|
+
blurWidth = newWidth
|
|
171
|
+
}
|
|
172
|
+
blurHeight := int(float64(newHeight) * (float64(blurWidth) / float64(newWidth)))
|
|
173
|
+
if blurHeight < 1 {
|
|
174
|
+
blurHeight = 1
|
|
175
|
+
}
|
|
176
|
+
blurred := image.NewRGBA(image.Rect(0, 0, blurWidth, blurHeight))
|
|
177
|
+
draw.BiLinear.Scale(blurred, blurred.Bounds(), resized, resized.Bounds(), draw.Over, nil)
|
|
178
|
+
finalImg = blurred
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Encode to JPEG
|
|
182
|
+
var buf bytes.Buffer
|
|
183
|
+
|
|
184
|
+
jpegQuality := quality
|
|
185
|
+
if blur {
|
|
186
|
+
jpegQuality = 30
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
err = jpeg.Encode(&buf, finalImg, &jpeg.Options{Quality: jpegQuality})
|
|
190
|
+
if err != nil {
|
|
191
|
+
return nil, fmt.Errorf("encode jpeg: %w", err)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return buf.Bytes(), nil
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Ensure PNG decoder is registered
|
|
198
|
+
func init() {
|
|
199
|
+
_ = png.Decode
|
|
200
|
+
}
|