jskelet 0.1.1

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.
Files changed (72) hide show
  1. package/AGENTS.md +127 -0
  2. package/CHANGELOG.md +40 -0
  3. package/LICENSE +21 -0
  4. package/README.md +342 -0
  5. package/bin/jskelet.mjs +104 -0
  6. package/docs/01-baslangic.md +285 -0
  7. package/docs/02-mimari.md +287 -0
  8. package/docs/03-routing.md +437 -0
  9. package/docs/04-render-ve-sablonlar.md +490 -0
  10. package/docs/05-islands.md +429 -0
  11. package/docs/06-cache.md +409 -0
  12. package/docs/07-yapilandirma.md +673 -0
  13. package/docs/08-build.md +366 -0
  14. package/docs/09-dev-araclari.md +302 -0
  15. package/docs/10-dagitim.md +329 -0
  16. package/docs/11-tasima.md +352 -0
  17. package/docs/README.md +82 -0
  18. package/package.json +97 -0
  19. package/src/build/build.mjs +138 -0
  20. package/src/build/ensure-build.mjs +15 -0
  21. package/src/build/paths.mjs +118 -0
  22. package/src/build/resolve-peer.mjs +36 -0
  23. package/src/build/tasks/client.mjs +268 -0
  24. package/src/build/tasks/css.mjs +124 -0
  25. package/src/build/tasks/fonts.mjs +146 -0
  26. package/src/build/tasks/icons.mjs +224 -0
  27. package/src/build/tasks/images.mjs +244 -0
  28. package/src/build/tasks/precompress.mjs +78 -0
  29. package/src/client/devtools/overlay.js +1763 -0
  30. package/src/client/devtools/report.html +185 -0
  31. package/src/client/devtools/report.js +712 -0
  32. package/src/client/dom.js +95 -0
  33. package/src/client/index.js +26 -0
  34. package/src/client/registry.js +223 -0
  35. package/src/client/safe-image.js +91 -0
  36. package/src/client/store.js +36 -0
  37. package/src/config/defaults.js +102 -0
  38. package/src/config/index.js +433 -0
  39. package/src/config/pattern.js +107 -0
  40. package/src/dev-server.mjs +383 -0
  41. package/src/http/control-flow.js +56 -0
  42. package/src/http/request-cache.js +46 -0
  43. package/src/index.js +35 -0
  44. package/src/init.mjs +220 -0
  45. package/src/log.mjs +332 -0
  46. package/src/logo.png +0 -0
  47. package/src/runtime/alias-hooks.mjs +119 -0
  48. package/src/runtime/register.mjs +4 -0
  49. package/src/server/assets.js +119 -0
  50. package/src/server/create-app.js +167 -0
  51. package/src/server/dev/devtools.js +383 -0
  52. package/src/server/dev/report.js +351 -0
  53. package/src/server/head-hints.js +132 -0
  54. package/src/server/html-cache.js +166 -0
  55. package/src/server/metadata.js +102 -0
  56. package/src/server/middleware/compression.js +205 -0
  57. package/src/server/middleware/dev-gate.js +62 -0
  58. package/src/server/middleware/headers.js +37 -0
  59. package/src/server/middleware/redirects.js +32 -0
  60. package/src/server/middleware/static-precompressed.js +100 -0
  61. package/src/server/middleware/upstream-proxy.js +141 -0
  62. package/src/server/prewarm.js +283 -0
  63. package/src/server/render.js +356 -0
  64. package/src/server/router.js +121 -0
  65. package/src/server/status-page.js +164 -0
  66. package/src/server/upstream-tracking.js +51 -0
  67. package/src/start.mjs +7 -0
  68. package/src/templates/layout.ejs +44 -0
  69. package/src/version.mjs +17 -0
  70. package/src/views/components/loader.js +85 -0
  71. package/src/views/helpers/html.js +102 -0
  72. package/src/views/helpers/tags.js +193 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Island bundle'ı: esbuild, ESM, code splitting.
3
+ *
4
+ * `client/entries/*.js` içindeki her dosya bir entry'dir. `main.js` her sayfada
5
+ * yüklenen ortak island bootstrap'ıdır; ek entry'ler yalnızca onları isteyen
6
+ * sayfalarda (`controller` → `entries: ["chart.js"]`) yüklenir.
7
+ *
8
+ * Hedef tarayıcılar `package.json` → `browserslist` yerine burada sabit: ESM +
9
+ * dinamik import + `IntersectionObserver` island modelinin zaten alt sınırı,
10
+ * daha eskisine transpile etmek çıktıyı büyütüp hiçbir ziyaretçi kazandırmıyor.
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import process from "node:process";
15
+ import * as esbuild from "esbuild";
16
+ import { paths, patchManifest, pruneAssets } from "../paths.mjs";
17
+ import * as log from "../../log.mjs";
18
+
19
+ /**
20
+ * `@/` alias'ını proje köküne çözer — Node tarafındaki `alias-hooks.mjs` ile
21
+ * aynı davranış, böylece `lib/` altındaki modüller hem sunucuda hem
22
+ * tarayıcıda aynı import stilini kullanabilir.
23
+ *
24
+ * @param {string} root
25
+ * @returns {esbuild.Plugin}
26
+ */
27
+ function aliasPlugin(root) {
28
+ return {
29
+ name: "jskelet-alias",
30
+ setup(build) {
31
+ build.onResolve({ filter: /^@\// }, (args) => ({
32
+ path: resolveWithExtension(path.join(root, args.path.slice(2))),
33
+ }));
34
+ },
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Tarayıcıda `process` yoktur; sunucuyla paylaşılan modüller yine de
40
+ * `process.env` okur. `clientEnv` ile bildirilen anahtarlar build zamanında
41
+ * gömülür — Next'teki `NEXT_PUBLIC_*` ile aynı sözleşme, ama hangi anahtarın
42
+ * herkese açık olduğu isimden değil config'ten belli.
43
+ *
44
+ * `process.env`in tamamı tek nesne olarak define edilir: listede olmayan bir
45
+ * anahtar okunduğunda çökme yerine `undefined` döner.
46
+ *
47
+ * @param {string[]} keys
48
+ * @returns {Record<string, string>}
49
+ */
50
+ function publicEnv(keys) {
51
+ /** @type {Record<string, string>} */
52
+ const env = { NODE_ENV: process.env.NODE_ENV ?? "production" };
53
+ for (const key of keys) {
54
+ const value = process.env[key];
55
+ if (value != null) env[key] = value;
56
+ }
57
+ return env;
58
+ }
59
+
60
+ /**
61
+ * @param {string} base
62
+ * @returns {string}
63
+ */
64
+ function resolveWithExtension(base) {
65
+ const candidates = [
66
+ base,
67
+ `${base}.js`,
68
+ `${base}.mjs`,
69
+ `${base}.json`,
70
+ path.join(base, "index.js"),
71
+ ];
72
+ for (const candidate of candidates) {
73
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
74
+ }
75
+ return base;
76
+ }
77
+
78
+ /**
79
+ * @param {import('../../config/index.js').ResolvedConfig} config
80
+ * @param {{ watch?: boolean }} [options]
81
+ * @returns {Promise<Record<string, string>>}
82
+ */
83
+ export async function buildClient(config, { watch = false } = {}) {
84
+ const entryDir = path.join(config.dirs.client, "entries");
85
+ const outDir = path.join(paths.assets, "js");
86
+
87
+ if (!fs.existsSync(entryDir)) {
88
+ log.detail("entry yok, atlandı");
89
+ return {};
90
+ }
91
+
92
+ const entryPoints = fs
93
+ .readdirSync(entryDir)
94
+ .filter((file) => file.endsWith(".js"))
95
+ .map((file) => path.join(entryDir, file));
96
+
97
+ if (!entryPoints.length) {
98
+ log.detail("entry yok, atlandı");
99
+ return {};
100
+ }
101
+
102
+ fs.rmSync(outDir, { recursive: true, force: true });
103
+ pruneAssets(["js/"]);
104
+
105
+ const entryRoot = path
106
+ .relative(config.root, entryDir)
107
+ .split(path.sep)
108
+ .join("/");
109
+
110
+ /** @type {esbuild.BuildOptions} */
111
+ const options = {
112
+ entryPoints,
113
+ outdir: outDir,
114
+ absWorkingDir: config.root,
115
+ bundle: true,
116
+ splitting: true,
117
+ format: "esm",
118
+ target: ["chrome111", "edge111", "firefox111", "safari16.4"],
119
+ minify: true,
120
+ sourcemap: true,
121
+ metafile: true,
122
+ entryNames: "[name].[hash]",
123
+ chunkNames: "chunks/[name].[hash]",
124
+ legalComments: "none",
125
+ plugins: [aliasPlugin(config.root)],
126
+ define: {
127
+ "process.env": JSON.stringify(publicEnv(config.clientEnv)),
128
+ },
129
+ };
130
+
131
+ if (watch) {
132
+ const context = await esbuild.context({
133
+ ...options,
134
+ plugins: [...options.plugins, rebuildReporter(config, entryRoot)],
135
+ });
136
+ const result = await context.rebuild();
137
+ await context.watch();
138
+ writeMetafile(result.metafile);
139
+ const manifest = toManifest(result.metafile, config, entryRoot);
140
+ const entries = Object.keys(manifest).length;
141
+ log.detail(`${entries} ${entries === 1 ? "entry" : "entries"}`);
142
+ return manifest;
143
+ }
144
+
145
+ const result = await esbuild.build(options);
146
+ writeMetafile(result.metafile);
147
+ const manifest = toManifest(result.metafile, config, entryRoot);
148
+ const bytes = Object.values(result.metafile.outputs).reduce(
149
+ (sum, output) => sum + output.bytes,
150
+ 0,
151
+ );
152
+ const entries = Object.keys(manifest).length;
153
+ log.detail(`${entries} ${entries === 1 ? "entry" : "entries"}, ${log.size(bytes)}`);
154
+ return manifest;
155
+ }
156
+
157
+ /**
158
+ * Metafile diske yazılır: dev panelindeki chunk analizi giriş/çıkış
159
+ * kırılımını buradan okur. Çalışma zamanı bu dosyaya bağımlı değildir.
160
+ *
161
+ * @param {esbuild.Metafile} [metafile]
162
+ */
163
+ function writeMetafile(metafile) {
164
+ if (!metafile) return;
165
+ try {
166
+ fs.mkdirSync(paths.generated, { recursive: true });
167
+ fs.writeFileSync(
168
+ path.join(paths.generated, "metafile.json"),
169
+ JSON.stringify(metafile),
170
+ );
171
+ } catch {
172
+ // Analiz verisi en iyi çaba; build'i düşürmez.
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Watch turlarını canlı olarak bildirir; ilk build zaten adım satırında görünür.
178
+ *
179
+ * @param {import('../../config/index.js').ResolvedConfig} config
180
+ * @param {string} entryRoot
181
+ * @returns {esbuild.Plugin}
182
+ */
183
+ function rebuildReporter(config, entryRoot) {
184
+ // İlk `rebuild()` ve `watch()`'un kendi açılış turu adım satırında zaten
185
+ // görünüyor; canlı bildirim gerçek değişikliklerle başlar.
186
+ let skip = 2;
187
+ let started = 0;
188
+
189
+ return {
190
+ name: "jskelet-rebuild-log",
191
+ setup(build) {
192
+ build.onStart(() => {
193
+ started = Date.now();
194
+ });
195
+
196
+ build.onEnd((result) => {
197
+ if (result.metafile) {
198
+ writeMetafile(result.metafile);
199
+ // Entry adları hash'li: yeni hash manifest'e yazılmazsa HTML eski
200
+ // (artık var olmayan) bundle'ı ister ve island'ların hiçbiri açılmaz.
201
+ for (const [key, url] of Object.entries(
202
+ toManifest(result.metafile, config, entryRoot),
203
+ )) {
204
+ patchManifest(key, url);
205
+ }
206
+ }
207
+
208
+ if (skip > 0) {
209
+ skip -= 1;
210
+ return;
211
+ }
212
+
213
+ if (result.errors.length) {
214
+ const [first] = result.errors;
215
+ log.errorBox({
216
+ title: "Client Build Error",
217
+ name: "BuildError",
218
+ message: first.text,
219
+ lines: [
220
+ first.location
221
+ ? `${first.location.file}:${first.location.line}:${first.location.column}`
222
+ : "",
223
+ result.errors.length > 1
224
+ ? `+${result.errors.length - 1} more errors`
225
+ : "",
226
+ ].filter(Boolean),
227
+ });
228
+ return;
229
+ }
230
+
231
+ log.event({
232
+ scope: "client",
233
+ message: "rebuilt",
234
+ time: Date.now() - started,
235
+ });
236
+ });
237
+ },
238
+ };
239
+ }
240
+
241
+ /**
242
+ * esbuild metafile → { "main.js": "/assets/js/main.abc.js" }
243
+ *
244
+ * Dinamik import'lar da `entryPoint` taşır; yalnızca gerçek entry'ler
245
+ * manifest'e girer, yoksa her island ayrı bir manifest anahtarı olurdu.
246
+ *
247
+ * @param {esbuild.Metafile} metafile
248
+ * @param {import('../../config/index.js').ResolvedConfig} config
249
+ * @param {string} entryRoot
250
+ * @returns {Record<string, string>}
251
+ */
252
+ function toManifest(metafile, config, entryRoot) {
253
+ /** @type {Record<string, string>} */
254
+ const manifest = {};
255
+
256
+ for (const [outputPath, output] of Object.entries(metafile.outputs)) {
257
+ if (!output.entryPoint?.startsWith(entryRoot)) continue;
258
+
259
+ const name = `${path.basename(output.entryPoint, ".js")}.js`;
260
+ const absolute = path.resolve(config.root, outputPath);
261
+ manifest[name] = `/${path
262
+ .relative(config.dirs.public, absolute)
263
+ .split(path.sep)
264
+ .join("/")}`;
265
+ }
266
+
267
+ return manifest;
268
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tailwind v4 → tek statik stylesheet.
3
+ *
4
+ * Ayrı bir "critical CSS" üretilmez. Ölçümde inline kritik CSS ilk ekranı tam
5
+ * kapsamadığı için sheet gelince sayfa yeniden akıyordu (bir liste sayfasında
6
+ * CLS 0.307) ve aynı ~27 KB her HTML yanıtında tekrar ediyordu. Sıkıştırılmış
7
+ * tek sheet'i render-blocking bırakmak hem daha hızlı hem CLS'siz; ikinci
8
+ * ziyarette zaten immutable önbellekten geliyor.
9
+ *
10
+ * Tailwind'in sınıf taraması `globals.css` içindeki `@source` direktiflerine
11
+ * bağlıdır. Otomatik tespit yalnızca stylesheet'in bulunduğu dizini tarar,
12
+ * bu yüzden şablonlarda geçen varyantlar (`data-[active=false]:…`) sessizce
13
+ * düşer. Yeni bir üst dizin eklediğinizde `@source` satırını da ekleyin.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { patchManifest, pruneAssets, writeAsset } from "../paths.mjs";
18
+ import { importFromApp, tryImportFromApp } from "../resolve-peer.mjs";
19
+ import * as log from "../../log.mjs";
20
+
21
+ /**
22
+ * PostCSS boru hattı bir kez kurulur: Tailwind'in kendi önbelleği plugin
23
+ * örneğinde yaşıyor, her derlemede yeniden oluşturmak watch turlarını
24
+ * belirgin şekilde yavaşlatıyor.
25
+ *
26
+ * @param {string} root
27
+ * @returns {Promise<(inputPath: string) => Promise<string>>}
28
+ */
29
+ async function createCompiler(root) {
30
+ const [{ default: postcss }, { default: tailwindcss }] = await Promise.all([
31
+ importFromApp(root, "postcss"),
32
+ importFromApp(root, "@tailwindcss/postcss"),
33
+ ]);
34
+
35
+ // lightningcss opsiyonel: yoksa Tailwind'in kendi çıktısı kullanılır,
36
+ // yalnızca birkaç kB daha büyük olur.
37
+ const lightning = await tryImportFromApp(root, "lightningcss");
38
+ const processor = postcss([tailwindcss()]);
39
+
40
+ return async (inputPath) => {
41
+ const css = fs.readFileSync(inputPath, "utf8");
42
+ const result = await processor.process(css, { from: inputPath, to: inputPath });
43
+
44
+ if (!lightning) return result.css;
45
+
46
+ const minified = lightning.transform({
47
+ filename: path.basename(inputPath),
48
+ code: Buffer.from(result.css),
49
+ minify: true,
50
+ });
51
+
52
+ return minified.code.toString("utf8");
53
+ };
54
+ }
55
+
56
+ /**
57
+ * @param {import('../../config/index.js').ResolvedConfig} config
58
+ * @param {{ watch?: boolean }} [options]
59
+ * @returns {Promise<Record<string, string>>}
60
+ */
61
+ export async function buildCss(config, { watch = false } = {}) {
62
+ const input = config.dirs.styles;
63
+ const compile = await createCompiler(config.root);
64
+
65
+ const run = async () => {
66
+ const started = Date.now();
67
+ pruneAssets(["app."]);
68
+ const css = await compile(input);
69
+ const url = writeAsset("app.css", css);
70
+ return { url, bytes: Buffer.byteLength(css), elapsed: Date.now() - started };
71
+ };
72
+
73
+ const first = await run();
74
+ log.detail(log.size(first.bytes));
75
+
76
+ if (watch) {
77
+ watchCssSources(config, async () => {
78
+ try {
79
+ const result = await run();
80
+ // Yeni hash manifest'e yazılmazsa HTML silinmiş dosyayı ister.
81
+ patchManifest("app.css", result.url);
82
+ log.event({
83
+ scope: "css",
84
+ message: "rebuilt",
85
+ note: log.size(result.bytes),
86
+ time: result.elapsed,
87
+ });
88
+ } catch (error) {
89
+ log.event({
90
+ symbol: log.symbols.fail,
91
+ scope: "css",
92
+ message: "failed",
93
+ note: error.message,
94
+ });
95
+ }
96
+ });
97
+ }
98
+
99
+ return { "app.css": first.url };
100
+ }
101
+
102
+ /**
103
+ * Şablon ve island dosyaları da izlenir: Tailwind sınıfları oradan geliyor,
104
+ * yalnızca `styles/` izlemek yeni bir utility yazıldığında rebuild etmez.
105
+ *
106
+ * @param {import('../../config/index.js').ResolvedConfig} config
107
+ * @param {() => void} onChange
108
+ */
109
+ function watchCssSources(config, onChange) {
110
+ const targets = [
111
+ path.dirname(config.dirs.styles),
112
+ config.dirs.views,
113
+ config.dirs.client,
114
+ ];
115
+
116
+ let timer = null;
117
+ for (const target of targets) {
118
+ if (!fs.existsSync(target)) continue;
119
+ fs.watch(target, { recursive: true }, () => {
120
+ clearTimeout(timer);
121
+ timer = setTimeout(onChange, 120);
122
+ });
123
+ }
124
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `next/font/google` yerine self-host font dosyaları.
3
+ *
4
+ * Dosyalar `public/fonts/` altında **sabit isimlerle** durur (hash yok), çünkü
5
+ * `@font-face` içindeki `url()` yolları elle yazılıyor; hash'lemek her build'de
6
+ * stylesheet'i de değiştirmek zorunda bırakırdı.
7
+ *
8
+ * Dosya yoksa bir kez Google Fonts'tan indirilir ve **commit edilmesi
9
+ * beklenir**: build'in ağa bağımlı olması CI'da kırılgan. İndirme başarısız
10
+ * olursa uyarı basılır ve sayfa sistem font yığınına düşer — build durmaz.
11
+ *
12
+ * Yapılandırma:
13
+ * fonts: [{ family: "Inter", weights: [400, 700] }]
14
+ * Çıktı: `public/fonts/inter-400.woff2`, manifest anahtarı aynı dosya adı.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import * as log from "../../log.mjs";
19
+
20
+ /** woff2 döndürmesi için modern tarayıcı UA'sı gerekir. */
21
+ const UA =
22
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
23
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36";
24
+
25
+ /**
26
+ * @param {string} family
27
+ * @returns {string}
28
+ */
29
+ function toSlug(family) {
30
+ return family.trim().toLowerCase().replace(/\s+/g, "-");
31
+ }
32
+
33
+ /**
34
+ * @param {string} family
35
+ * @param {number[]} weights
36
+ * @returns {string}
37
+ */
38
+ function cssUrl(family, weights) {
39
+ const name = family.trim().replace(/\s+/g, "+");
40
+ const wght = [...weights].sort((a, b) => a - b).join(";");
41
+ return `https://fonts.googleapis.com/css2?family=${name}:wght@${wght}&display=swap`;
42
+ }
43
+
44
+ /**
45
+ * @param {import('../../config/index.js').ResolvedConfig} config
46
+ * @returns {Promise<Record<string, string>>}
47
+ */
48
+ export async function copyFonts(config) {
49
+ const fontsDir = config.dirs.fonts;
50
+ fs.mkdirSync(fontsDir, { recursive: true });
51
+
52
+ /** @type {Record<string, string>} */
53
+ const manifest = {};
54
+ let expected = 0;
55
+
56
+ for (const family of config.fonts) {
57
+ const slug = family.slug ?? toSlug(family.family);
58
+ const weights = family.weights ?? [400];
59
+ expected += weights.length;
60
+
61
+ /** @type {number[]} */
62
+ const missing = [];
63
+
64
+ for (const weight of weights) {
65
+ const fileName = `${slug}-${weight}.woff2`;
66
+ if (fs.existsSync(path.join(fontsDir, fileName))) {
67
+ manifest[fileName] = `/fonts/${fileName}`;
68
+ continue;
69
+ }
70
+ missing.push(weight);
71
+ }
72
+
73
+ if (!missing.length) continue;
74
+
75
+ const downloaded = await downloadFromGoogle(
76
+ cssUrl(family.family, missing),
77
+ missing,
78
+ );
79
+
80
+ for (const [weight, buffer] of downloaded) {
81
+ const fileName = `${slug}-${weight}.woff2`;
82
+ fs.writeFileSync(path.join(fontsDir, fileName), buffer);
83
+ manifest[fileName] = `/fonts/${fileName}`;
84
+ log.detail(`${fileName} downloaded (${log.size(buffer.byteLength)})`);
85
+ }
86
+
87
+ const stillMissing = missing.filter(
88
+ (weight) => !manifest[`${slug}-${weight}.woff2`],
89
+ );
90
+ if (stillMissing.length) {
91
+ log.warn(
92
+ `${family.family} ${stillMissing.join(", ")} indirilemedi — sistem font yığınına düşülecek.`,
93
+ );
94
+ }
95
+ }
96
+
97
+ log.summary(`${Object.keys(manifest).length}/${expected} weights`);
98
+ return manifest;
99
+ }
100
+
101
+ /**
102
+ * Google'ın döndürdüğü CSS'te her ağırlık ve her subset için ayrı bir
103
+ * `@font-face` bloğu var. Latin subset'i (`U+0000-00FF`) seçilir: diğerleri
104
+ * çoğu site için ölü ağırlık ve `unicode-range` olmadan hepsini indirmek
105
+ * font boyutunu katlar.
106
+ *
107
+ * @param {string} url
108
+ * @param {number[]} weights
109
+ * @returns {Promise<[number, Buffer][]>}
110
+ */
111
+ async function downloadFromGoogle(url, weights) {
112
+ /** @type {[number, Buffer][]} */
113
+ const out = [];
114
+
115
+ let css;
116
+ try {
117
+ const response = await fetch(url, { headers: { "User-Agent": UA } });
118
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
119
+ css = await response.text();
120
+ } catch (error) {
121
+ log.warn(`Google Fonts CSS alınamadı (${error.message})`);
122
+ return out;
123
+ }
124
+
125
+ const blocks = css.split("@font-face").slice(1);
126
+
127
+ for (const weight of weights) {
128
+ const block = blocks.find(
129
+ (candidate) =>
130
+ candidate.includes(`font-weight: ${weight}`) &&
131
+ candidate.includes("U+0000-00FF"),
132
+ );
133
+ const fileUrl = block?.match(/url\((https:[^)]+\.woff2)\)/)?.[1];
134
+ if (!fileUrl) continue;
135
+
136
+ try {
137
+ const response = await fetch(fileUrl, { headers: { "User-Agent": UA } });
138
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
139
+ out.push([weight, Buffer.from(await response.arrayBuffer())]);
140
+ } catch (error) {
141
+ log.warn(`${weight} indirilemedi (${error.message})`);
142
+ }
143
+ }
144
+
145
+ return out;
146
+ }