@ozsarman/clarityjs 0.6.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/README.md +178 -0
- package/package.json +168 -0
- package/src/analyze.js +534 -0
- package/src/async-state.js +555 -0
- package/src/bundle-runtime.js +35 -0
- package/src/clarity-bundle.js +332 -0
- package/src/clarity-test.js +622 -0
- package/src/cli.js +453 -0
- package/src/codegen.js +1934 -0
- package/src/dev-server.js +362 -0
- package/src/devtools.js +765 -0
- package/src/edge.js +606 -0
- package/src/error-overlay.js +535 -0
- package/src/file-conventions.js +472 -0
- package/src/font.js +513 -0
- package/src/game-loop.js +106 -0
- package/src/head.js +393 -0
- package/src/hydrate.js +292 -0
- package/src/i18n.js +403 -0
- package/src/image.js +352 -0
- package/src/index.js +193 -0
- package/src/islands.js +284 -0
- package/src/isr.js +306 -0
- package/src/layout.js +342 -0
- package/src/lexer.js +572 -0
- package/src/linter.js +547 -0
- package/src/pages-router.js +229 -0
- package/src/parser.js +1108 -0
- package/src/router.js +732 -0
- package/src/runtime.js +1465 -0
- package/src/scoped-css.js +641 -0
- package/src/server-actions.js +439 -0
- package/src/server-data.js +225 -0
- package/src/sourcemap.js +130 -0
- package/src/ssg.js +310 -0
- package/src/ssr.js +621 -0
- package/src/store.js +276 -0
- package/src/transitions.js +438 -0
- package/src/ts-plugin.js +613 -0
- package/src/typegen.js +240 -0
- package/src/vite-plugin.js +447 -0
- package/types/index.d.ts +366 -0
package/src/islands.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clarity.js — Islands Architecture
|
|
3
|
+
*
|
|
4
|
+
* Statik HTML sayfa üzerinde seçici hidrasyon (selective hydration).
|
|
5
|
+
* Astro Islands / React Server Components benzeri model.
|
|
6
|
+
*
|
|
7
|
+
* Çalışma prensibi:
|
|
8
|
+
* 1. Tüm sayfa sunucuda statik HTML olarak render edilir
|
|
9
|
+
* 2. Sadece `island:client` direktifi olan bileşenler client JS yükler
|
|
10
|
+
* 3. Diğer bileşenler sıfır JavaScript gönderir
|
|
11
|
+
*
|
|
12
|
+
* ── Kullanım (.clarity içinde) ────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* component App() {
|
|
15
|
+
* render {
|
|
16
|
+
* <div>
|
|
17
|
+
* <StaticHeader /> // Server-only — sıfır JS
|
|
18
|
+
* <Counter island:client /> // İnteraktif adacık — JS yüklenir
|
|
19
|
+
* <StaticFooter /> // Server-only — sıfır JS
|
|
20
|
+
*
|
|
21
|
+
* // Yükleme stratejisi seçimi:
|
|
22
|
+
* <HeavyWidget island:client="visible" /> // Görünür olunca
|
|
23
|
+
* <Analytics island:client="idle" /> // Tarayıcı boştayken
|
|
24
|
+
* <Modal island:client="load" /> // Sayfa yüklenir yüklenmez
|
|
25
|
+
* <Tooltip island:client="hover" /> // Hover'da
|
|
26
|
+
* </div>
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* ── Programatik API ───────────────────────────────────────────────────────────
|
|
31
|
+
*
|
|
32
|
+
* import { createIsland, hydrateIslands } from '@ozsarman/clarityjs/islands';
|
|
33
|
+
*
|
|
34
|
+
* // Server: adacık HTML'i oluştur + hidrasyon şeridi göm
|
|
35
|
+
* const html = createIsland(Counter, { initialCount: 0 }, { strategy: 'visible' });
|
|
36
|
+
*
|
|
37
|
+
* // Client: sayfadaki tüm adacıkları bul ve hidrate et
|
|
38
|
+
* hydrateIslands();
|
|
39
|
+
*
|
|
40
|
+
* Author: Claude (Anthropic) + Özdemir Sarman
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { renderToString } from './ssr.js';
|
|
44
|
+
|
|
45
|
+
// ─── Adacık stratejileri ──────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {'load' | 'idle' | 'visible' | 'hover' | 'none'} IslandStrategy
|
|
49
|
+
*
|
|
50
|
+
* load – sayfa yüklenince hemen hidrate et (varsayılan)
|
|
51
|
+
* idle – requestIdleCallback ile boş anında
|
|
52
|
+
* visible – IntersectionObserver ile görünür olunca
|
|
53
|
+
* hover – fareyle üzerine gelince
|
|
54
|
+
* none – hiçbir zaman (sadece statik HTML)
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
// ─── Server-side: adacık oluştur ──────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Bir bileşeni sunucuda render et + client hidrasyon bilgisini göm.
|
|
61
|
+
*
|
|
62
|
+
* @param {Function} ComponentFn – Clarity component fonksiyonu
|
|
63
|
+
* @param {object} [props={}] – bileşen prop'ları
|
|
64
|
+
* @param {object} [opts={}]
|
|
65
|
+
* @param {IslandStrategy} [opts.strategy='load'] – hidrasyon stratejisi
|
|
66
|
+
* @param {string} [opts.id] – benzersiz adacık kimliği (otomatik üretilir)
|
|
67
|
+
* @param {string} [opts.import] – client bundle'da bileşenin import yolu
|
|
68
|
+
* @returns {string} HTML string (hidrasyon metadata'sı gömülü)
|
|
69
|
+
*/
|
|
70
|
+
export function createIsland(ComponentFn, props = {}, opts = {}) {
|
|
71
|
+
const {
|
|
72
|
+
strategy = 'load',
|
|
73
|
+
id = `island-${Math.random().toString(36).slice(2, 8)}`,
|
|
74
|
+
importPath = null,
|
|
75
|
+
} = opts;
|
|
76
|
+
|
|
77
|
+
// Bileşeni sunucuda render et
|
|
78
|
+
const innerHTML = renderToString(ComponentFn, { props });
|
|
79
|
+
|
|
80
|
+
// Hidrasyon verisi — JSON olarak data attribute'a gömülür
|
|
81
|
+
const islandData = JSON.stringify({
|
|
82
|
+
component: ComponentFn.name || ComponentFn.displayName || 'Unknown',
|
|
83
|
+
props,
|
|
84
|
+
strategy,
|
|
85
|
+
importPath,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return `<clarity-island
|
|
89
|
+
id="${id}"
|
|
90
|
+
data-strategy="${strategy}"
|
|
91
|
+
data-island='${_escapeAttr(islandData)}'
|
|
92
|
+
style="display:contents"
|
|
93
|
+
>${innerHTML}</clarity-island>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Client-side: adacıkları hidrate et ──────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sayfadaki tüm `<clarity-island>` elementlerini bulup hidrate eder.
|
|
100
|
+
* Client entry point'te çağrılır.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} [componentMap={}] – bileşen adı → ComponentFn eşlemesi
|
|
103
|
+
* (dinamik import desteklenmiyorsa)
|
|
104
|
+
*/
|
|
105
|
+
export function hydrateIslands(componentMap = {}) {
|
|
106
|
+
if (typeof document === 'undefined') return; // SSR ortamda çalışmasın
|
|
107
|
+
|
|
108
|
+
const islands = document.querySelectorAll('clarity-island');
|
|
109
|
+
if (islands.length === 0) return;
|
|
110
|
+
|
|
111
|
+
console.log(`[clarity/islands] ${islands.length} adacık bulundu`);
|
|
112
|
+
|
|
113
|
+
for (const el of islands) {
|
|
114
|
+
_scheduleHydration(el, componentMap);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _scheduleHydration(el, componentMap) {
|
|
119
|
+
const strategy = el.dataset.strategy || 'load';
|
|
120
|
+
const data = _parseIslandData(el);
|
|
121
|
+
if (!data) return;
|
|
122
|
+
|
|
123
|
+
switch (strategy) {
|
|
124
|
+
case 'load':
|
|
125
|
+
_hydrateElement(el, data, componentMap);
|
|
126
|
+
break;
|
|
127
|
+
|
|
128
|
+
case 'idle':
|
|
129
|
+
if ('requestIdleCallback' in window) {
|
|
130
|
+
requestIdleCallback(() => _hydrateElement(el, data, componentMap));
|
|
131
|
+
} else {
|
|
132
|
+
setTimeout(() => _hydrateElement(el, data, componentMap), 200);
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
|
|
136
|
+
case 'visible': {
|
|
137
|
+
const observer = new IntersectionObserver((entries) => {
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (entry.isIntersecting) {
|
|
140
|
+
observer.unobserve(el);
|
|
141
|
+
_hydrateElement(el, data, componentMap);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}, { rootMargin: '50px' });
|
|
145
|
+
observer.observe(el);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case 'hover':
|
|
150
|
+
el.addEventListener('mouseenter', () => _hydrateElement(el, data, componentMap), { once: true });
|
|
151
|
+
el.addEventListener('touchstart', () => _hydrateElement(el, data, componentMap), { once: true, passive: true });
|
|
152
|
+
break;
|
|
153
|
+
|
|
154
|
+
case 'none':
|
|
155
|
+
// Statik — hidrasyonsuz
|
|
156
|
+
break;
|
|
157
|
+
|
|
158
|
+
default:
|
|
159
|
+
_hydrateElement(el, data, componentMap);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function _hydrateElement(el, data, componentMap) {
|
|
164
|
+
if (el.dataset.hydrated) return;
|
|
165
|
+
el.dataset.hydrated = 'true';
|
|
166
|
+
|
|
167
|
+
let ComponentFn = componentMap[data.component];
|
|
168
|
+
|
|
169
|
+
// Dinamik import denemeleri
|
|
170
|
+
if (!ComponentFn && data.importPath) {
|
|
171
|
+
try {
|
|
172
|
+
const mod = await import(data.importPath);
|
|
173
|
+
ComponentFn = mod[data.component] ?? mod.default;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error(`[clarity/islands] import hatası (${data.importPath}): ${err.message}`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!ComponentFn) {
|
|
181
|
+
console.warn(`[clarity/islands] Bileşen bulunamadı: ${data.component}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const { hydrateRoot } = await import('./hydrate.js');
|
|
187
|
+
hydrateRoot(ComponentFn, el, data.props);
|
|
188
|
+
console.log(`[clarity/islands] ✅ ${data.component} (${data.strategy})`);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(`[clarity/islands] hidrasyon hatası (${data.component}): ${err.message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _parseIslandData(el) {
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(el.dataset.island || '{}');
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── defineServerComponent ────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Bir bileşeni sunucu bileşeni olarak işaretle.
|
|
206
|
+
* Bu bileşenler client bundle'a dahil edilmez.
|
|
207
|
+
*
|
|
208
|
+
* @param {Function} ComponentFn
|
|
209
|
+
* @returns {Function} Aynı fonksiyon + __clarity_server__ = true işareti
|
|
210
|
+
*/
|
|
211
|
+
export function defineServerComponent(ComponentFn) {
|
|
212
|
+
ComponentFn.__clarity_server_only__ = true;
|
|
213
|
+
return ComponentFn;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Bir bileşen sunucu bileşeni mi?
|
|
218
|
+
*/
|
|
219
|
+
export function isServerComponent(ComponentFn) {
|
|
220
|
+
return !!ComponentFn.__clarity_server_only__;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Vite plugin: islands dönüşümü ───────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Vite transform hook'u — `island:client` direktifini işler.
|
|
227
|
+
* clarityPlugin() içinde otomatik kullanılır.
|
|
228
|
+
*
|
|
229
|
+
* `island:client` direktifi olan JSX elementini:
|
|
230
|
+
* <Counter island:client="visible" initialCount={0} />
|
|
231
|
+
*
|
|
232
|
+
* Buna dönüştürür (server):
|
|
233
|
+
* createIsland(Counter, { initialCount: 0 }, { strategy: 'visible' })
|
|
234
|
+
*
|
|
235
|
+
* @internal
|
|
236
|
+
*/
|
|
237
|
+
export function transformIslandDirective(code, id) {
|
|
238
|
+
// island:client direktifini tara — basit regex tabanlı dönüşüm
|
|
239
|
+
// CodeGenerator'dan geçtikten sonra üretilen JS'e uygulanır
|
|
240
|
+
return code.replace(
|
|
241
|
+
/(\w+)\(\{\s*([^}]*)"island:client"\s*:\s*"([^"]*)"([^}]*)\}\)/g,
|
|
242
|
+
(match, fnName, beforeProps, strategy, afterProps) => {
|
|
243
|
+
const props = (beforeProps + afterProps).replace(/,\s*,/g, ',').trim().replace(/^,|,$/g, '');
|
|
244
|
+
return `createIsland(${fnName}, {${props}}, { strategy: '${strategy || 'load'}' })`;
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── SSR helper: adacıkları HTML'e göm ───────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* SSR sırasında bir sayfanın adacıklarını render et.
|
|
253
|
+
* renderToDocument() ile birlikte kullanım içindir.
|
|
254
|
+
*
|
|
255
|
+
* @param {Function} PageComponent
|
|
256
|
+
* @param {object} props
|
|
257
|
+
* @param {object} islandDefs – { ComponentName: { ComponentFn, strategy } }
|
|
258
|
+
* @returns {string} HTML
|
|
259
|
+
*/
|
|
260
|
+
export function renderPageWithIslands(PageComponent, props = {}, islandDefs = {}) {
|
|
261
|
+
// Server ortamında adacıkları statik HTML + hidrasyon metadata'sı ile render et
|
|
262
|
+
// global'e adacık factory'leri kaydet — ssr.js render sırasında bunları kullanır
|
|
263
|
+
if (typeof globalThis !== 'undefined') {
|
|
264
|
+
globalThis.__clarity_islands__ = islandDefs;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
return renderToString(PageComponent, { props });
|
|
269
|
+
} finally {
|
|
270
|
+
if (typeof globalThis !== 'undefined') {
|
|
271
|
+
delete globalThis.__clarity_islands__;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Util ─────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function _escapeAttr(str) {
|
|
279
|
+
return String(str)
|
|
280
|
+
.replace(/&/g, '&')
|
|
281
|
+
.replace(/'/g, ''')
|
|
282
|
+
.replace(/</g, '<')
|
|
283
|
+
.replace(/>/g, '>');
|
|
284
|
+
}
|
package/src/isr.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clarity.js — Incremental Static Regeneration (ISR)
|
|
3
|
+
*
|
|
4
|
+
* Belirli aralıklarla veya istek üzerine statik sayfaları yeniler.
|
|
5
|
+
* Next.js revalidate / Nuxt routeRules ISR eşdeğeri.
|
|
6
|
+
*
|
|
7
|
+
* ── Kullanım (getStaticProps içinde) ─────────────────────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* // pages/blog/[slug].js
|
|
10
|
+
* export async function getStaticProps({ params }) {
|
|
11
|
+
* const post = await db.posts.findBySlug(params.slug);
|
|
12
|
+
* return {
|
|
13
|
+
* props: { post },
|
|
14
|
+
* revalidate: 60, // 60 saniyede bir arka planda yeniden üret
|
|
15
|
+
* };
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* ── On-demand revalidation ────────────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* // pages/api/revalidate.js
|
|
21
|
+
* import { revalidatePath, revalidateTag } from '@ozsarman/clarityjs/isr';
|
|
22
|
+
*
|
|
23
|
+
* export async function POST(req, res) {
|
|
24
|
+
* const { path, secret } = req.body;
|
|
25
|
+
* if (secret !== process.env.REVALIDATE_SECRET) return res.status(401).json({ error: 'Unauthorized' });
|
|
26
|
+
*
|
|
27
|
+
* await revalidatePath(path, { pagesDir: './pages', outDir: './dist' });
|
|
28
|
+
* res.json({ revalidated: true });
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* ── Express middleware ────────────────────────────────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* import { createISRMiddleware } from '@ozsarman/clarityjs/isr';
|
|
34
|
+
*
|
|
35
|
+
* const isr = createISRMiddleware({ outDir: './dist', pagesDir: './pages' });
|
|
36
|
+
* app.use(isr); // Her istekte: bayat mı? → arka planda yenile → statik dosyayı sun
|
|
37
|
+
*
|
|
38
|
+
* Author: Claude (Anthropic) + Özdemir Sarman
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
42
|
+
import { existsSync } from 'node:fs';
|
|
43
|
+
import { join, dirname, resolve } from 'node:path';
|
|
44
|
+
import { pathToFileURL } from 'node:url';
|
|
45
|
+
|
|
46
|
+
// ─── ISR Metadata Store ───────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const ISR_META_FILE = '_clarity_isr.json';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ISR metadata'yı diskten yükle.
|
|
52
|
+
* @param {string} outDir
|
|
53
|
+
* @returns {Promise<object>}
|
|
54
|
+
*/
|
|
55
|
+
async function _loadMeta(outDir) {
|
|
56
|
+
const metaPath = join(resolve(outDir), ISR_META_FILE);
|
|
57
|
+
if (!existsSync(metaPath)) return {};
|
|
58
|
+
try {
|
|
59
|
+
const raw = await readFile(metaPath, 'utf8');
|
|
60
|
+
return JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ISR metadata'yı diske kaydet.
|
|
68
|
+
*/
|
|
69
|
+
async function _saveMeta(outDir, data) {
|
|
70
|
+
const metaPath = join(resolve(outDir), ISR_META_FILE);
|
|
71
|
+
await writeFile(metaPath, JSON.stringify(data, null, 2), 'utf8');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── On-demand revalidation ───────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Belirli bir yolu anında yeniden üret.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} path – yenilenecek route (örn: '/blog/my-post')
|
|
80
|
+
* @param {object} opts
|
|
81
|
+
* @param {string} opts.pagesDir
|
|
82
|
+
* @param {string} opts.outDir
|
|
83
|
+
* @param {string} [opts.baseUrl]
|
|
84
|
+
* @param {string} [opts.clientScript]
|
|
85
|
+
* @param {string} [opts.title]
|
|
86
|
+
* @returns {Promise<boolean>} – başarıyla yenilendiyse true
|
|
87
|
+
*/
|
|
88
|
+
export async function revalidatePath(path, opts = {}) {
|
|
89
|
+
const { pagesDir = 'pages', outDir = 'dist', baseUrl = '', clientScript = null, title = '' } = opts;
|
|
90
|
+
|
|
91
|
+
const meta = await _loadMeta(outDir);
|
|
92
|
+
const entry = meta[path];
|
|
93
|
+
|
|
94
|
+
if (!entry) {
|
|
95
|
+
console.warn(`[clarity/isr] revalidatePath: '${path}' ISR metadata bulunamadı`);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const success = await _regeneratePage(path, entry, { outDir, baseUrl, clientScript, title });
|
|
100
|
+
|
|
101
|
+
if (success) {
|
|
102
|
+
meta[path] = {
|
|
103
|
+
...entry,
|
|
104
|
+
generatedAt: Date.now(),
|
|
105
|
+
expiresAt: Date.now() + entry.revalidate * 1000,
|
|
106
|
+
};
|
|
107
|
+
await _saveMeta(outDir, meta);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return success;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Tag'e göre birden fazla yolu yenile.
|
|
115
|
+
* getStaticProps'ta `tags: ['posts']` ile etiketlenmiş sayfaları yeniler.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} tag
|
|
118
|
+
* @param {object} opts
|
|
119
|
+
*/
|
|
120
|
+
export async function revalidateTag(tag, opts = {}) {
|
|
121
|
+
const { outDir = 'dist' } = opts;
|
|
122
|
+
const meta = await _loadMeta(outDir);
|
|
123
|
+
const paths = Object.entries(meta)
|
|
124
|
+
.filter(([, entry]) => Array.isArray(entry.tags) && entry.tags.includes(tag))
|
|
125
|
+
.map(([p]) => p);
|
|
126
|
+
|
|
127
|
+
console.log(`[clarity/isr] Tag '${tag}' için ${paths.length} sayfa yenileniyor...`);
|
|
128
|
+
const results = await Promise.all(paths.map(p => revalidatePath(p, opts)));
|
|
129
|
+
return results.every(Boolean);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Express ISR Middleware ───────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Express middleware factory — stale-while-revalidate pattern.
|
|
136
|
+
*
|
|
137
|
+
* Çalışma prensibi:
|
|
138
|
+
* 1. İstek gelir
|
|
139
|
+
* 2. Statik HTML dosyası var mı? → Varsa hemen sun
|
|
140
|
+
* 3. Dosya bayat mı (expiresAt geçti)? → Arka planda yenile (kullanıcıyı beklettirme)
|
|
141
|
+
* 4. Statik dosya yoksa → SSR ile üret, hem sun hem kaydet
|
|
142
|
+
*
|
|
143
|
+
* @param {object} opts
|
|
144
|
+
* @param {string} opts.outDir
|
|
145
|
+
* @param {string} opts.pagesDir
|
|
146
|
+
* @param {string} [opts.baseUrl]
|
|
147
|
+
* @param {string} [opts.clientScript]
|
|
148
|
+
* @param {string} [opts.title]
|
|
149
|
+
* @param {boolean} [opts.verbose]
|
|
150
|
+
* @returns {Function} Express middleware
|
|
151
|
+
*/
|
|
152
|
+
export function createISRMiddleware(opts = {}) {
|
|
153
|
+
const {
|
|
154
|
+
outDir = 'dist',
|
|
155
|
+
pagesDir = 'pages',
|
|
156
|
+
baseUrl = '',
|
|
157
|
+
clientScript = null,
|
|
158
|
+
title = '',
|
|
159
|
+
verbose = false,
|
|
160
|
+
} = opts;
|
|
161
|
+
|
|
162
|
+
const log = (...args) => verbose && console.log('[clarity/isr]', ...args);
|
|
163
|
+
|
|
164
|
+
// Arka planda yenileme kuyrukta mı? Duplicate önlemek için
|
|
165
|
+
const _revalidating = new Set();
|
|
166
|
+
|
|
167
|
+
return async function isrMiddleware(req, res, next) {
|
|
168
|
+
// Sadece GET isteklerini yönet
|
|
169
|
+
if (req.method !== 'GET') return next();
|
|
170
|
+
|
|
171
|
+
const urlPath = req.path === '/' ? '/' : req.path.replace(/\/$/, '');
|
|
172
|
+
const outDirAbs = resolve(outDir);
|
|
173
|
+
|
|
174
|
+
// Statik HTML dosyasını ara
|
|
175
|
+
const htmlFile = urlPath === '/'
|
|
176
|
+
? join(outDirAbs, 'index.html')
|
|
177
|
+
: join(outDirAbs, urlPath.slice(1), 'index.html');
|
|
178
|
+
|
|
179
|
+
const fileExists = existsSync(htmlFile);
|
|
180
|
+
|
|
181
|
+
if (fileExists) {
|
|
182
|
+
// Dosya var — hemen sun
|
|
183
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
184
|
+
res.setHeader('X-Clarity-SSG', 'hit');
|
|
185
|
+
|
|
186
|
+
// Bayatlık kontrolü — arka planda yenile
|
|
187
|
+
const meta = await _loadMeta(outDir);
|
|
188
|
+
const entry = meta[urlPath];
|
|
189
|
+
if (entry && Date.now() > entry.expiresAt && !_revalidating.has(urlPath)) {
|
|
190
|
+
_revalidating.add(urlPath);
|
|
191
|
+
log(`stale → arka planda yenileniyor: ${urlPath}`);
|
|
192
|
+
_regeneratePage(urlPath, entry, { outDir, baseUrl, clientScript, title })
|
|
193
|
+
.then(async (ok) => {
|
|
194
|
+
if (ok) {
|
|
195
|
+
meta[urlPath] = {
|
|
196
|
+
...entry,
|
|
197
|
+
generatedAt: Date.now(),
|
|
198
|
+
expiresAt: Date.now() + entry.revalidate * 1000,
|
|
199
|
+
};
|
|
200
|
+
await _saveMeta(outDir, meta);
|
|
201
|
+
log(`✅ arka planda yenilendi: ${urlPath}`);
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
.finally(() => _revalidating.delete(urlPath));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return res.sendFile(htmlFile);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Dosya yok — SSR ile üret ve kaydet
|
|
211
|
+
log(`miss → SSR ile üretiliyor: ${urlPath}`);
|
|
212
|
+
try {
|
|
213
|
+
const { renderToDocument } = await import('./ssr.js');
|
|
214
|
+
const { FileRouter } = await import('../packages/clarity-ssr/file-router.js');
|
|
215
|
+
|
|
216
|
+
const router = new FileRouter(resolve(pagesDir));
|
|
217
|
+
await router.scan();
|
|
218
|
+
const match = router.match(urlPath);
|
|
219
|
+
|
|
220
|
+
if (!match) return next();
|
|
221
|
+
|
|
222
|
+
const { route, params } = match;
|
|
223
|
+
let pageProps = params;
|
|
224
|
+
|
|
225
|
+
if (route.loader) {
|
|
226
|
+
const loaderData = await route.loader({ params, query: req.query, req });
|
|
227
|
+
pageProps = { ...params, loaderData };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const html = renderToDocument(route.component, {
|
|
231
|
+
props: pageProps, title: route.meta?.title ?? title, clientScript,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await mkdir(dirname(htmlFile), { recursive: true });
|
|
235
|
+
await writeFile(htmlFile, html, 'utf8');
|
|
236
|
+
|
|
237
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
238
|
+
res.setHeader('X-Clarity-SSG', 'generated');
|
|
239
|
+
res.send(html);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error(`[clarity/isr] SSR hata: ${err.message}`);
|
|
242
|
+
next(err);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Periyodik revalidation scheduler ────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Tüm bayat ISR sayfalarını periyodik olarak yenile.
|
|
251
|
+
* Sunucu başlatılırken bir kez çağrıl.
|
|
252
|
+
*
|
|
253
|
+
* @param {object} opts
|
|
254
|
+
* @param {string} opts.outDir
|
|
255
|
+
* @param {number} [opts.interval=30000] – kontrol aralığı (ms)
|
|
256
|
+
* @returns {{ stop: Function }}
|
|
257
|
+
*/
|
|
258
|
+
export function startISRScheduler(opts = {}) {
|
|
259
|
+
const { outDir = 'dist', interval = 30_000, ...rest } = opts;
|
|
260
|
+
|
|
261
|
+
const timer = setInterval(async () => {
|
|
262
|
+
const meta = await _loadMeta(outDir);
|
|
263
|
+
const stale = Object.entries(meta).filter(([, e]) => Date.now() > e.expiresAt);
|
|
264
|
+
|
|
265
|
+
if (stale.length === 0) return;
|
|
266
|
+
console.log(`[clarity/isr] ${stale.length} bayat sayfa yenileniyor...`);
|
|
267
|
+
|
|
268
|
+
for (const [path, entry] of stale) {
|
|
269
|
+
await revalidatePath(path, { outDir, ...rest });
|
|
270
|
+
}
|
|
271
|
+
}, interval);
|
|
272
|
+
|
|
273
|
+
return { stop: () => clearInterval(timer) };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Internal: single page regen ─────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
async function _regeneratePage(routePath, entry, { outDir, baseUrl, clientScript, title }) {
|
|
279
|
+
try {
|
|
280
|
+
const { renderToDocument } = await import('./ssr.js');
|
|
281
|
+
const pageFile = resolve(entry.file);
|
|
282
|
+
const mod = await import(pathToFileURL(pageFile).href + `?t=${Date.now()}`);
|
|
283
|
+
|
|
284
|
+
let pageProps = entry.params ?? {};
|
|
285
|
+
if (typeof mod.getStaticProps === 'function') {
|
|
286
|
+
const result = await mod.getStaticProps({ params: entry.params ?? {} });
|
|
287
|
+
if (result?.notFound) return false;
|
|
288
|
+
pageProps = { ...pageProps, ...(result?.props ?? {}) };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const html = renderToDocument(mod.default, {
|
|
292
|
+
props: pageProps, title: mod.meta?.title ?? title, clientScript,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const htmlFile = routePath === '/'
|
|
296
|
+
? join(resolve(outDir), 'index.html')
|
|
297
|
+
: join(resolve(outDir), routePath.slice(1), 'index.html');
|
|
298
|
+
|
|
299
|
+
await mkdir(dirname(htmlFile), { recursive: true });
|
|
300
|
+
await writeFile(htmlFile, html, 'utf8');
|
|
301
|
+
return true;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.error(`[clarity/isr] regen hata (${routePath}): ${err.message}`);
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|