jskelet 0.2.4 → 0.2.5
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/AGENTS.md +132 -132
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -21
- package/bin/jskelet.mjs +103 -103
- package/docs/01-baslangic.md +285 -285
- package/docs/02-mimari.md +287 -287
- package/docs/03-routing.md +480 -480
- package/docs/04-render-ve-sablonlar.md +490 -490
- package/docs/05-islands.md +482 -482
- package/docs/06-cache.md +1209 -1209
- package/docs/08-build.md +366 -366
- package/docs/09-dev-araclari.md +335 -335
- package/docs/10-dagitim.md +329 -329
- package/docs/12-panel-ve-oturum.md +384 -384
- package/docs/README.md +105 -105
- package/docs/en/01-getting-started.md +292 -292
- package/docs/en/02-architecture.md +305 -305
- package/docs/en/03-routing.md +497 -497
- package/docs/en/04-rendering.md +504 -504
- package/docs/en/05-islands.md +492 -492
- package/docs/en/06-caching.md +1239 -1239
- package/docs/en/07-configuration.md +986 -986
- package/docs/en/08-build.md +383 -383
- package/docs/en/09-dev-tools.md +342 -342
- package/docs/en/10-deployment.md +332 -332
- package/docs/en/11-migration.md +359 -359
- package/docs/en/12-dashboards-and-sessions.md +392 -392
- package/docs/en/README.md +112 -112
- package/package.json +102 -102
- package/src/build/ensure-build.mjs +15 -15
- package/src/build/paths.mjs +143 -143
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +268 -268
- package/src/build/tasks/css.mjs +124 -124
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +224 -224
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/client/cache-panel/i18n.js +670 -670
- package/src/client/cache-panel/login.html +74 -74
- package/src/client/cache-panel/panel.css +756 -756
- package/src/client/cache-panel/panel.html +308 -308
- package/src/client/cache-panel/panel.js +915 -915
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +725 -725
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +35 -35
- package/src/client/registry.js +297 -297
- package/src/client/safe-image.js +91 -91
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/config/pattern.js +107 -107
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies.js +257 -257
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +162 -162
- package/src/index.js +83 -83
- package/src/init.mjs +221 -221
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/assets.js +147 -147
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-panel.js +759 -759
- package/src/server/cloudflare.js +607 -595
- package/src/server/create-app.js +291 -291
- package/src/server/data-cache.js +462 -462
- package/src/server/dev/report.js +369 -369
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/html-cache.js +817 -817
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +62 -62
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/static-precompressed.js +100 -100
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/prewarm.js +601 -601
- package/src/server/redis.js +569 -569
- package/src/server/router.js +128 -128
- package/src/server/status-page.js +164 -164
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/start.mjs +7 -7
- package/src/templates/layout.ejs +44 -44
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +85 -85
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +245 -245
|
@@ -1,245 +1,245 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `next/link`, `next/image` ve `@phosphor-icons/react` karşılıkları.
|
|
3
|
-
* Hepsi HTML string döndürür; EJS içinden `<%- %>` ile basılır.
|
|
4
|
-
*/
|
|
5
|
-
import { attrs, esc, cn } from "./html.js";
|
|
6
|
-
import { asset, getSpriteIds, optimizedImage } from "../../server/assets.js";
|
|
7
|
-
import { getRequestContext, markTainted } from "../../http/request-context.js";
|
|
8
|
-
import { getSignedCookie, randomToken, setSignedCookie } from "../../http/cookies.js";
|
|
9
|
-
import { getConfig } from "../../config/index.js";
|
|
10
|
-
|
|
11
|
-
const isDev = process.env.NODE_ENV === "development";
|
|
12
|
-
|
|
13
|
-
/** Aynı eksik ikon için tek uyarı. */
|
|
14
|
-
const warnedIcons = new Set();
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* `SeoLink` karşılığı: title otomatik doldurulur.
|
|
18
|
-
* @param {{ href: string, text?: string, html?: string, class?: string,
|
|
19
|
-
* title?: string, ariaLabel?: string, target?: string, rel?: string,
|
|
20
|
-
* attrs?: Record<string, unknown> }} props
|
|
21
|
-
* @returns {string}
|
|
22
|
-
*/
|
|
23
|
-
export function link(props) {
|
|
24
|
-
const {
|
|
25
|
-
href,
|
|
26
|
-
text,
|
|
27
|
-
html,
|
|
28
|
-
class: className,
|
|
29
|
-
title,
|
|
30
|
-
ariaLabel,
|
|
31
|
-
target,
|
|
32
|
-
rel,
|
|
33
|
-
attrs: extra = {},
|
|
34
|
-
} = props;
|
|
35
|
-
|
|
36
|
-
const resolvedTitle = title ?? ariaLabel ?? text ?? href;
|
|
37
|
-
const isExternal = /^https?:\/\//.test(String(href ?? ""));
|
|
38
|
-
|
|
39
|
-
const attributes = attrs({
|
|
40
|
-
href,
|
|
41
|
-
class: className,
|
|
42
|
-
title: resolvedTitle,
|
|
43
|
-
"aria-label": ariaLabel,
|
|
44
|
-
target: target ?? (isExternal ? "_blank" : undefined),
|
|
45
|
-
rel: rel ?? (isExternal ? "noopener noreferrer" : undefined),
|
|
46
|
-
...extra,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
return `<a${attributes}>${html ?? esc(text ?? "")}</a>`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* `next/image` karşılığı. `public/` altındaki yerel görseller için build'de
|
|
54
|
-
* üretilen webp varyantları (`build/tasks/images.mjs`) otomatik olarak
|
|
55
|
-
* `srcset` + intrinsic `width`/`height` olarak eklenir; manifest'te olmayan
|
|
56
|
-
* ya da uzak görseller olduğu gibi basılır.
|
|
57
|
-
*
|
|
58
|
-
* `priority` LCP görselleri için `fetchpriority=high` + eager yükleme yapar.
|
|
59
|
-
* @param {{ src: string, alt: string, width?: number, height?: number,
|
|
60
|
-
* class?: string, sizes?: string, srcset?: string, priority?: boolean,
|
|
61
|
-
* fill?: boolean, loading?: 'lazy' | 'eager', unoptimized?: boolean,
|
|
62
|
-
* attrs?: Record<string, unknown> }} props
|
|
63
|
-
* @returns {string}
|
|
64
|
-
*/
|
|
65
|
-
export function image(props) {
|
|
66
|
-
const {
|
|
67
|
-
src,
|
|
68
|
-
alt,
|
|
69
|
-
width,
|
|
70
|
-
height,
|
|
71
|
-
class: className,
|
|
72
|
-
sizes,
|
|
73
|
-
srcset,
|
|
74
|
-
priority = false,
|
|
75
|
-
fill = false,
|
|
76
|
-
loading,
|
|
77
|
-
unoptimized = false,
|
|
78
|
-
attrs: extra = {},
|
|
79
|
-
} = props;
|
|
80
|
-
|
|
81
|
-
const optimized = unoptimized || srcset ? undefined : optimizedImage(src);
|
|
82
|
-
const largest = optimized?.variants.at(-1);
|
|
83
|
-
// Tek varyant üretilmişse (kaynak zaten küçükse) srcset/sizes gürültüden ibaret.
|
|
84
|
-
const responsive = optimized && optimized.variants.length > 1;
|
|
85
|
-
|
|
86
|
-
const attributes = attrs({
|
|
87
|
-
src: largest?.url ?? src,
|
|
88
|
-
alt: alt ?? "",
|
|
89
|
-
width: fill ? undefined : (width ?? optimized?.width),
|
|
90
|
-
height: fill ? undefined : (height ?? optimized?.height),
|
|
91
|
-
class: fill
|
|
92
|
-
? cn("absolute inset-0 h-full w-full object-cover", className)
|
|
93
|
-
: className,
|
|
94
|
-
sizes: responsive ? (sizes ?? defaultSizes(optimized)) : sizes,
|
|
95
|
-
srcset: responsive ? toSrcSet(optimized) : srcset,
|
|
96
|
-
loading: loading ?? (priority ? "eager" : "lazy"),
|
|
97
|
-
decoding: priority ? "sync" : "async",
|
|
98
|
-
fetchpriority: priority ? "high" : undefined,
|
|
99
|
-
...extra,
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
return `<img${attributes}>`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* @param {import("../../server/assets.js").OptimizedImage} optimized
|
|
107
|
-
* @returns {string}
|
|
108
|
-
*/
|
|
109
|
-
function toSrcSet(optimized) {
|
|
110
|
-
return optimized.variants
|
|
111
|
-
.map((variant) => `${variant.url} ${variant.width}w`)
|
|
112
|
-
.join(", ");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Çağıran `sizes` vermediyse makul bir varsayılan: görsel kendi intrinsic
|
|
117
|
-
* genişliğinden büyütülmez, dar ekranlarda viewport'u kaplar.
|
|
118
|
-
* @param {import("../../server/assets.js").OptimizedImage} optimized
|
|
119
|
-
* @returns {string}
|
|
120
|
-
*/
|
|
121
|
-
function defaultSizes(optimized) {
|
|
122
|
-
const max = optimized.variants.at(-1)?.width ?? optimized.width;
|
|
123
|
-
return `(max-width: ${max}px) 100vw, ${max}px`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* LCP görselleri için `<head>` preload etiketi.
|
|
128
|
-
* @param {{ href: string, imagesrcset?: string, imagesizes?: string }} props
|
|
129
|
-
* @returns {string}
|
|
130
|
-
*/
|
|
131
|
-
export function preloadImage({ href, imagesrcset, imagesizes }) {
|
|
132
|
-
return `<link${attrs({
|
|
133
|
-
rel: "preload",
|
|
134
|
-
as: "image",
|
|
135
|
-
href,
|
|
136
|
-
imagesrcset,
|
|
137
|
-
imagesizes,
|
|
138
|
-
fetchpriority: "high",
|
|
139
|
-
})}>`;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Phosphor ikonları — build zamanı üretilen SVG sprite'tan `<use>`.
|
|
144
|
-
* `weight` sprite id'sine dahildir (regular/bold/fill).
|
|
145
|
-
* @param {{ name: string, size?: number | string, weight?: string,
|
|
146
|
-
* class?: string, attrs?: Record<string, unknown> }} props
|
|
147
|
-
* @returns {string}
|
|
148
|
-
*/
|
|
149
|
-
export function icon(props) {
|
|
150
|
-
const {
|
|
151
|
-
name,
|
|
152
|
-
size = 24,
|
|
153
|
-
weight = "regular",
|
|
154
|
-
class: className,
|
|
155
|
-
attrs: extra = {},
|
|
156
|
-
} = props;
|
|
157
|
-
|
|
158
|
-
const spritePath = asset("sprite.svg");
|
|
159
|
-
const id = `${toKebab(name)}-${weight}`;
|
|
160
|
-
|
|
161
|
-
if (isDev && !warnedIcons.has(id)) {
|
|
162
|
-
const ids = getSpriteIds();
|
|
163
|
-
if (ids.size && !ids.has(id)) {
|
|
164
|
-
warnedIcons.add(id);
|
|
165
|
-
console.warn(
|
|
166
|
-
`[icon] missing from sprite: ${id} — write the name as a literal or add it to the build/tasks/icons.mjs scan.`,
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const attributes = attrs({
|
|
172
|
-
width: size,
|
|
173
|
-
height: size,
|
|
174
|
-
class: className,
|
|
175
|
-
"aria-hidden": "true",
|
|
176
|
-
focusable: "false",
|
|
177
|
-
fill: "currentColor",
|
|
178
|
-
viewBox: "0 0 256 256",
|
|
179
|
-
...extra,
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
return `<svg${attributes}><use href="${esc(spritePath)}#${esc(id)}"></use></svg>`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Formun içine CSRF token'ını gizli alan olarak basar.
|
|
187
|
-
*
|
|
188
|
-
* Token **burada** üretilir ve imzalı cookie olarak yazılır: bir sayfada
|
|
189
|
-
* token gerçekten gerekiyorsa o sayfa zaten kişiye özeldir. Bu yüzden çağrı
|
|
190
|
-
* render'ı işaretler (`tainted`) ve sayfa public HTML cache'ine giremez —
|
|
191
|
-
* aksi hâlde tüm ziyaretçiler cache'ten aynı token'ı alırdı ve çift gönderim
|
|
192
|
-
* kontrolü hiçbir şey doğrulamazdı.
|
|
193
|
-
*
|
|
194
|
-
* `security.csrf.token` kapalıysa boş string döner; şablon her koşulda
|
|
195
|
-
* render edilebilmeli, korumanın açık olması config'in kararı.
|
|
196
|
-
*
|
|
197
|
-
* @returns {string}
|
|
198
|
-
*/
|
|
199
|
-
export function csrfField() {
|
|
200
|
-
const context = getRequestContext();
|
|
201
|
-
if (!context?.res) return "";
|
|
202
|
-
|
|
203
|
-
const { csrf } = getConfig().security;
|
|
204
|
-
if (!csrf.token) return "";
|
|
205
|
-
|
|
206
|
-
if (!context.csrfToken) {
|
|
207
|
-
const existing = getSignedCookie(context.res.req, csrf.cookieName);
|
|
208
|
-
const token = existing ?? randomToken(24);
|
|
209
|
-
|
|
210
|
-
if (!existing) {
|
|
211
|
-
try {
|
|
212
|
-
setSignedCookie(context.res, csrf.cookieName, token, {
|
|
213
|
-
// Token'ı istemci cookie'den değil, basılan gizli alandan okur;
|
|
214
|
-
// HttpOnly kalması XSS durumunda bir katman daha demek.
|
|
215
|
-
httpOnly: true,
|
|
216
|
-
});
|
|
217
|
-
} catch (error) {
|
|
218
|
-
console.warn(
|
|
219
|
-
"[csrf] could not emit the token: no secret for signed cookies",
|
|
220
|
-
error instanceof Error ? error.message : error,
|
|
221
|
-
);
|
|
222
|
-
return "";
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
context.csrfToken = token;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
markTainted("csrfField()");
|
|
230
|
-
|
|
231
|
-
return `<input type="hidden" name="${esc(csrf.fieldName)}" value="${esc(context.csrfToken)}">`;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* `ArrowRightIcon` / `ArrowRight` → `arrow-right`
|
|
236
|
-
* @param {string} name
|
|
237
|
-
* @returns {string}
|
|
238
|
-
*/
|
|
239
|
-
export function toKebab(name) {
|
|
240
|
-
return String(name)
|
|
241
|
-
.replace(/Icon$/, "")
|
|
242
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
243
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
244
|
-
.toLowerCase();
|
|
245
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `next/link`, `next/image` ve `@phosphor-icons/react` karşılıkları.
|
|
3
|
+
* Hepsi HTML string döndürür; EJS içinden `<%- %>` ile basılır.
|
|
4
|
+
*/
|
|
5
|
+
import { attrs, esc, cn } from "./html.js";
|
|
6
|
+
import { asset, getSpriteIds, optimizedImage } from "../../server/assets.js";
|
|
7
|
+
import { getRequestContext, markTainted } from "../../http/request-context.js";
|
|
8
|
+
import { getSignedCookie, randomToken, setSignedCookie } from "../../http/cookies.js";
|
|
9
|
+
import { getConfig } from "../../config/index.js";
|
|
10
|
+
|
|
11
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
12
|
+
|
|
13
|
+
/** Aynı eksik ikon için tek uyarı. */
|
|
14
|
+
const warnedIcons = new Set();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `SeoLink` karşılığı: title otomatik doldurulur.
|
|
18
|
+
* @param {{ href: string, text?: string, html?: string, class?: string,
|
|
19
|
+
* title?: string, ariaLabel?: string, target?: string, rel?: string,
|
|
20
|
+
* attrs?: Record<string, unknown> }} props
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function link(props) {
|
|
24
|
+
const {
|
|
25
|
+
href,
|
|
26
|
+
text,
|
|
27
|
+
html,
|
|
28
|
+
class: className,
|
|
29
|
+
title,
|
|
30
|
+
ariaLabel,
|
|
31
|
+
target,
|
|
32
|
+
rel,
|
|
33
|
+
attrs: extra = {},
|
|
34
|
+
} = props;
|
|
35
|
+
|
|
36
|
+
const resolvedTitle = title ?? ariaLabel ?? text ?? href;
|
|
37
|
+
const isExternal = /^https?:\/\//.test(String(href ?? ""));
|
|
38
|
+
|
|
39
|
+
const attributes = attrs({
|
|
40
|
+
href,
|
|
41
|
+
class: className,
|
|
42
|
+
title: resolvedTitle,
|
|
43
|
+
"aria-label": ariaLabel,
|
|
44
|
+
target: target ?? (isExternal ? "_blank" : undefined),
|
|
45
|
+
rel: rel ?? (isExternal ? "noopener noreferrer" : undefined),
|
|
46
|
+
...extra,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return `<a${attributes}>${html ?? esc(text ?? "")}</a>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `next/image` karşılığı. `public/` altındaki yerel görseller için build'de
|
|
54
|
+
* üretilen webp varyantları (`build/tasks/images.mjs`) otomatik olarak
|
|
55
|
+
* `srcset` + intrinsic `width`/`height` olarak eklenir; manifest'te olmayan
|
|
56
|
+
* ya da uzak görseller olduğu gibi basılır.
|
|
57
|
+
*
|
|
58
|
+
* `priority` LCP görselleri için `fetchpriority=high` + eager yükleme yapar.
|
|
59
|
+
* @param {{ src: string, alt: string, width?: number, height?: number,
|
|
60
|
+
* class?: string, sizes?: string, srcset?: string, priority?: boolean,
|
|
61
|
+
* fill?: boolean, loading?: 'lazy' | 'eager', unoptimized?: boolean,
|
|
62
|
+
* attrs?: Record<string, unknown> }} props
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function image(props) {
|
|
66
|
+
const {
|
|
67
|
+
src,
|
|
68
|
+
alt,
|
|
69
|
+
width,
|
|
70
|
+
height,
|
|
71
|
+
class: className,
|
|
72
|
+
sizes,
|
|
73
|
+
srcset,
|
|
74
|
+
priority = false,
|
|
75
|
+
fill = false,
|
|
76
|
+
loading,
|
|
77
|
+
unoptimized = false,
|
|
78
|
+
attrs: extra = {},
|
|
79
|
+
} = props;
|
|
80
|
+
|
|
81
|
+
const optimized = unoptimized || srcset ? undefined : optimizedImage(src);
|
|
82
|
+
const largest = optimized?.variants.at(-1);
|
|
83
|
+
// Tek varyant üretilmişse (kaynak zaten küçükse) srcset/sizes gürültüden ibaret.
|
|
84
|
+
const responsive = optimized && optimized.variants.length > 1;
|
|
85
|
+
|
|
86
|
+
const attributes = attrs({
|
|
87
|
+
src: largest?.url ?? src,
|
|
88
|
+
alt: alt ?? "",
|
|
89
|
+
width: fill ? undefined : (width ?? optimized?.width),
|
|
90
|
+
height: fill ? undefined : (height ?? optimized?.height),
|
|
91
|
+
class: fill
|
|
92
|
+
? cn("absolute inset-0 h-full w-full object-cover", className)
|
|
93
|
+
: className,
|
|
94
|
+
sizes: responsive ? (sizes ?? defaultSizes(optimized)) : sizes,
|
|
95
|
+
srcset: responsive ? toSrcSet(optimized) : srcset,
|
|
96
|
+
loading: loading ?? (priority ? "eager" : "lazy"),
|
|
97
|
+
decoding: priority ? "sync" : "async",
|
|
98
|
+
fetchpriority: priority ? "high" : undefined,
|
|
99
|
+
...extra,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return `<img${attributes}>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {import("../../server/assets.js").OptimizedImage} optimized
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
function toSrcSet(optimized) {
|
|
110
|
+
return optimized.variants
|
|
111
|
+
.map((variant) => `${variant.url} ${variant.width}w`)
|
|
112
|
+
.join(", ");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Çağıran `sizes` vermediyse makul bir varsayılan: görsel kendi intrinsic
|
|
117
|
+
* genişliğinden büyütülmez, dar ekranlarda viewport'u kaplar.
|
|
118
|
+
* @param {import("../../server/assets.js").OptimizedImage} optimized
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
function defaultSizes(optimized) {
|
|
122
|
+
const max = optimized.variants.at(-1)?.width ?? optimized.width;
|
|
123
|
+
return `(max-width: ${max}px) 100vw, ${max}px`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* LCP görselleri için `<head>` preload etiketi.
|
|
128
|
+
* @param {{ href: string, imagesrcset?: string, imagesizes?: string }} props
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
export function preloadImage({ href, imagesrcset, imagesizes }) {
|
|
132
|
+
return `<link${attrs({
|
|
133
|
+
rel: "preload",
|
|
134
|
+
as: "image",
|
|
135
|
+
href,
|
|
136
|
+
imagesrcset,
|
|
137
|
+
imagesizes,
|
|
138
|
+
fetchpriority: "high",
|
|
139
|
+
})}>`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Phosphor ikonları — build zamanı üretilen SVG sprite'tan `<use>`.
|
|
144
|
+
* `weight` sprite id'sine dahildir (regular/bold/fill).
|
|
145
|
+
* @param {{ name: string, size?: number | string, weight?: string,
|
|
146
|
+
* class?: string, attrs?: Record<string, unknown> }} props
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
export function icon(props) {
|
|
150
|
+
const {
|
|
151
|
+
name,
|
|
152
|
+
size = 24,
|
|
153
|
+
weight = "regular",
|
|
154
|
+
class: className,
|
|
155
|
+
attrs: extra = {},
|
|
156
|
+
} = props;
|
|
157
|
+
|
|
158
|
+
const spritePath = asset("sprite.svg");
|
|
159
|
+
const id = `${toKebab(name)}-${weight}`;
|
|
160
|
+
|
|
161
|
+
if (isDev && !warnedIcons.has(id)) {
|
|
162
|
+
const ids = getSpriteIds();
|
|
163
|
+
if (ids.size && !ids.has(id)) {
|
|
164
|
+
warnedIcons.add(id);
|
|
165
|
+
console.warn(
|
|
166
|
+
`[icon] missing from sprite: ${id} — write the name as a literal or add it to the build/tasks/icons.mjs scan.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const attributes = attrs({
|
|
172
|
+
width: size,
|
|
173
|
+
height: size,
|
|
174
|
+
class: className,
|
|
175
|
+
"aria-hidden": "true",
|
|
176
|
+
focusable: "false",
|
|
177
|
+
fill: "currentColor",
|
|
178
|
+
viewBox: "0 0 256 256",
|
|
179
|
+
...extra,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return `<svg${attributes}><use href="${esc(spritePath)}#${esc(id)}"></use></svg>`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Formun içine CSRF token'ını gizli alan olarak basar.
|
|
187
|
+
*
|
|
188
|
+
* Token **burada** üretilir ve imzalı cookie olarak yazılır: bir sayfada
|
|
189
|
+
* token gerçekten gerekiyorsa o sayfa zaten kişiye özeldir. Bu yüzden çağrı
|
|
190
|
+
* render'ı işaretler (`tainted`) ve sayfa public HTML cache'ine giremez —
|
|
191
|
+
* aksi hâlde tüm ziyaretçiler cache'ten aynı token'ı alırdı ve çift gönderim
|
|
192
|
+
* kontrolü hiçbir şey doğrulamazdı.
|
|
193
|
+
*
|
|
194
|
+
* `security.csrf.token` kapalıysa boş string döner; şablon her koşulda
|
|
195
|
+
* render edilebilmeli, korumanın açık olması config'in kararı.
|
|
196
|
+
*
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
export function csrfField() {
|
|
200
|
+
const context = getRequestContext();
|
|
201
|
+
if (!context?.res) return "";
|
|
202
|
+
|
|
203
|
+
const { csrf } = getConfig().security;
|
|
204
|
+
if (!csrf.token) return "";
|
|
205
|
+
|
|
206
|
+
if (!context.csrfToken) {
|
|
207
|
+
const existing = getSignedCookie(context.res.req, csrf.cookieName);
|
|
208
|
+
const token = existing ?? randomToken(24);
|
|
209
|
+
|
|
210
|
+
if (!existing) {
|
|
211
|
+
try {
|
|
212
|
+
setSignedCookie(context.res, csrf.cookieName, token, {
|
|
213
|
+
// Token'ı istemci cookie'den değil, basılan gizli alandan okur;
|
|
214
|
+
// HttpOnly kalması XSS durumunda bir katman daha demek.
|
|
215
|
+
httpOnly: true,
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.warn(
|
|
219
|
+
"[csrf] could not emit the token: no secret for signed cookies",
|
|
220
|
+
error instanceof Error ? error.message : error,
|
|
221
|
+
);
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
context.csrfToken = token;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
markTainted("csrfField()");
|
|
230
|
+
|
|
231
|
+
return `<input type="hidden" name="${esc(csrf.fieldName)}" value="${esc(context.csrfToken)}">`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* `ArrowRightIcon` / `ArrowRight` → `arrow-right`
|
|
236
|
+
* @param {string} name
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
export function toKebab(name) {
|
|
240
|
+
return String(name)
|
|
241
|
+
.replace(/Icon$/, "")
|
|
242
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
243
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
244
|
+
.toLowerCase();
|
|
245
|
+
}
|