jskelet 0.6.14 → 0.6.16

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/CHANGELOG.md CHANGED
@@ -17,6 +17,16 @@ one is listed under a **Breaking** heading.
17
17
 
18
18
  ### Added
19
19
 
20
+ - When `build.assets.prefix` is set, the CSS build rewrites root-absolute
21
+ `url()` paths under `static.prefixes` (e.g. `/fonts/…`) to the CDN
22
+ prefix so they match the S3 key (`…/js/fonts/…`). Default upload
23
+ `include` is `["assets", "fonts"]`; prefix also forces `fonts` into
24
+ `include`. Font uploads run PutBucketCors with
25
+ `upload.corsOrigin` (default `"*"`, set `null` to skip).
26
+ - `asset(name, { origin: "document" })` skips `build.assets.prefix` and
27
+ returns the site-origin `/assets/…` path. `icon()` always resolves the
28
+ sprite this way so SVG `<use href>` keeps working when other assets are
29
+ on a CDN (the origin still serves `public/assets/`).
20
30
  - Dev overlay **API** tab (between Performance and Server): outbound
21
31
  `fetch` calls for the current page — browser and SSR — with host
22
32
  rollup, duration, status, and a detail pane on click (component,
@@ -629,13 +629,14 @@ değişmez** — asset'ler origin'den servis edilir.
629
629
 
630
630
  | Alan | Tip | Varsayılan | Anlamı |
631
631
  | --- | --- | --- | --- |
632
- | `assets.prefix` | `string` | `""` | CDN origin; `asset()` URL öneki (sonunda `/` yok). Yalnızca config. Manifest diskte göreli kalır. |
632
+ | `assets.prefix` | `string` | `""` | CDN origin; `asset()` URL öneki (sonunda `/` yok). Yalnızca config. Manifest diskte göreli kalır. `asset(name, { origin: "document" })` ve `icon()` sprite'ı öneksiz kalır. |
633
633
  | `assets.upload.enabled` | `boolean` | `false` | `true` veya config'te `bucket` doluysa açılır; açık `false` ezer. |
634
634
  | `assets.upload.bucket` | `string \| null` | `null` | **S3 bucket adı** (`static.ornek.com` gibi noktalı adlar geçerli; PutObject path-style kullanır) veya `bucket/key/prefix`. `https://…` şeması kabul edilmez — o `prefix`. |
635
635
  | `assets.upload.keyPrefix` | `string` | `""` | Nesne anahtarı öneki; bucket yolunda verilmişse o kazanır (`prefix` takma adı da kabul). |
636
636
  | `assets.upload.region` | `string` | `us-east-1` | Config; custom `endpoint` varken varsayılan `auto`. |
637
637
  | `assets.upload.endpoint` | `string \| null` | `null` | S3-uyumlu API (R2 / MinIO); config. |
638
- | `assets.upload.include` | `string[]` | `["assets"]` | `public/` altı dizinler (`assets`, isteğe `fonts`, …). |
638
+ | `assets.upload.include` | `string[]` | `["assets", "fonts"]` | `public/` altı dizinler. `assets.prefix` doluysa `fonts` yoksa eklenir. |
639
+ | `assets.upload.corsOrigin` | `string \| null` | `"*"` | Font upload'ta PutBucketCors `AllowedOrigin`; `null` kapatır (önceki CORS'un üzerine yazar). |
639
640
  | `assets.upload.cacheControl` | `string` | `public, max-age=31536000, immutable` | PutObject `Cache-Control`. |
640
641
 
641
642
  **Credential config'e yazılmaz.** Zincir: `AWS_ACCESS_KEY_ID` /
package/docs/08-build.md CHANGED
@@ -80,6 +80,10 @@ dosya adlarını okunur tutuyor. Hash'li olmaları sayesinde bu dosyalara
80
80
  - `asset(name)` manifest'te varsa hash'li URL'i, yoksa `/assets/<name>` döner.
81
81
  `build.assets.prefix` doluysa bu göreli yolun önüne CDN origin eklenir
82
82
  (manifest diskte göreli kalır).
83
+ - `asset(name, { origin: "document" })` prefix'i atlar; site origin'indeki
84
+ `/assets/…` yolunu döner. SVG `<use href>` çapraz origin sprite
85
+ yüklemediği için `icon()` sprite'ı her zaman böyle çözer — origin hâlâ
86
+ `public/assets/` servis eder.
83
87
  - `hasAsset(name)` manifest'te olup olmadığını söyler.
84
88
 
85
89
  ### CDN prefix ve S3 upload
@@ -87,11 +91,21 @@ dosya adlarını okunur tutuyor. Hash'li olmaları sayesinde bu dosyalara
87
91
  Varsayılan kapalıdır. Prefix / bucket / keyPrefix config'te; credential
88
92
  env → ECS task role → EC2 IMDS zincirinden. Açılınca:
89
93
 
90
- 1. Production build `public/assets/` (ve `include` listesindeki diğer dizinler)
91
- altındaki dosyaları S3'e PutObject ile kopyalar.
92
- 2. Çalışma anında `asset()` / optimize görseller `build.assets.prefix` ile
93
- absolute CDN URL basar. Entry CDN'deyse esbuild chunk'ları göreli yolla aynı
94
- host'a gider.
94
+ 1. Production build `public/assets/` ve `public/fonts/` (varsayılan `include`)
95
+ altındaki dosyaları S3'e PutObject ile kopyalar. `assets.prefix` doluysa
96
+ `include`'da `fonts` yoksa otomatik eklenir.
97
+ 2. CSS derlemesi `static.prefixes` kök-mutlak `url()` yollarını
98
+ (`/fonts/…`, `/assets/…`) `prefix` ile yeniden yazar — aksi hâlde CDN'deki
99
+ stylesheet `url("/fonts/x")` yolunu host köküne çözer ve `keyPrefix`
100
+ kaybolur. `@font-face` CDN CSS içinde kalabilir; font ile CSS aynı host'ta
101
+ olduğu için ek CORS gerekmez.
102
+ 3. Font yüklendiğinde ve `upload.corsOrigin` doluysa (varsayılan `"*"`)
103
+ PutBucketCors bir GET/HEAD kuralı yazar — bucket'taki önceki CORS'un
104
+ **üzerine** yazar; kapatmak için `corsOrigin: null`. HTML'den çapraz
105
+ origin font için bu (veya CloudFront response header) gerekir.
106
+ 4. Çalışma anında `asset()` / optimize görseller `build.assets.prefix` ile
107
+ absolute CDN URL basar. `icon()` sprite'ı ve
108
+ `asset(name, { origin: "document" })` site origin'inde kalır.
95
109
 
96
110
  Bucket adı noktalı olabilir (`static.ornek.com`); `https://…` şeması
97
111
  `prefix`'e yazılır. Config sözlüğü: [07-yapilandirma.md](./07-yapilandirma.md#build).
@@ -292,6 +306,10 @@ Kullanımı stylesheet'te elle yazılır:
292
306
  `.woff2` uzantısı ve `/fonts/` öneki varsayılan `static` kurallarında olduğu için
293
307
  bu dosyalara otomatik olarak `immutable` cache yazılır.
294
308
 
309
+ `build.assets.prefix` açıksa build, CSS'teki `url("/fonts/…")` yollarını CDN
310
+ prefix'iyle yeniden yazar ve fontları S3'e de basar — layout'ta ayrı
311
+ `@font-face` tutmaya gerek kalmaz.
312
+
295
313
  ## İkon sprite
296
314
 
297
315
  **Yalnızca kaynakta kullanılan** ikonlar için bir `<symbol>` seti üretir. Tüm
@@ -457,7 +475,9 @@ karşılaşmaması.
457
475
  - **Bazı Tailwind sınıfları çalışmıyor.** `@source` direktifi eksik olan bir
458
476
  dizinde yazılmışlar.
459
477
  - **İkon boş görünüyor.** Sprite'ta o sembol yok; dev'de `[icon] missing from sprite`
460
- uyarısına bakın.
478
+ uyarısına bakın. CDN prefix açıkken sprite bilerek site origin'inde kalır
479
+ (`asset("sprite.svg", { origin: "document" })`); origin `public/assets/`
480
+ servis etmiyorsa `<use>` boş kalır.
461
481
  - **Island'lar hiç açılmıyor.** `main.js` manifest'te yok (entry dizini boş ya
462
482
  da build atlanmış) veya bir build hatası var.
463
483
  - **Dev'de sayfa aniden stilsiz kaldı.** Manifest ile diskteki dosya
@@ -642,13 +642,14 @@ changes** — assets stay on the origin.
642
642
 
643
643
  | Field | Type | Default | Meaning |
644
644
  | --- | --- | --- | --- |
645
- | `assets.prefix` | `string` | `""` | CDN origin; prefix for `asset()` URLs (no trailing `/`). Config only. The on-disk manifest stays relative. |
645
+ | `assets.prefix` | `string` | `""` | CDN origin; prefix for `asset()` URLs (no trailing `/`). Config only. The on-disk manifest stays relative. `asset(name, { origin: "document" })` and the `icon()` sprite stay unprefixed. |
646
646
  | `assets.upload.enabled` | `boolean` | `false` | Turns on when `true` or when `bucket` is set in config; explicit `false` wins. |
647
647
  | `assets.upload.bucket` | `string \| null` | `null` | **S3 bucket name** (dotted names like `static.example.com` are fine; PutObject uses path-style) or `bucket/key/prefix`. A `https://…` scheme is rejected — that belongs in `prefix`. |
648
648
  | `assets.upload.keyPrefix` | `string` | `""` | Object key prefix; a path on the bucket wins (`prefix` is accepted as an alias). |
649
649
  | `assets.upload.region` | `string` | `us-east-1` | Config; defaults to `auto` when `endpoint` is set. |
650
650
  | `assets.upload.endpoint` | `string \| null` | `null` | S3-compatible API (R2 / MinIO); config. |
651
- | `assets.upload.include` | `string[]` | `["assets"]` | Directories under `public/` (`assets`, optionally `fonts`, …). |
651
+ | `assets.upload.include` | `string[]` | `["assets", "fonts"]` | Directories under `public/`. When `assets.prefix` is set, `fonts` is added if missing. |
652
+ | `assets.upload.corsOrigin` | `string \| null` | `"*"` | PutBucketCors `AllowedOrigin` when fonts upload; `null` disables (replaces prior bucket CORS). |
652
653
  | `assets.upload.cacheControl` | `string` | `public, max-age=31536000, immutable` | PutObject `Cache-Control`. |
653
654
 
654
655
  **Credentials are not written in the config.** Chain: `AWS_ACCESS_KEY_ID` /
@@ -83,6 +83,10 @@ They are passed to templates automatically; in server code,
83
83
  - `asset(name)` returns the hashed URL if it is in the manifest, otherwise
84
84
  `/assets/<name>`. When `build.assets.prefix` is set, that relative path is
85
85
  prefixed with the CDN origin (the on-disk manifest stays relative).
86
+ - `asset(name, { origin: "document" })` skips the prefix and returns the
87
+ site-origin `/assets/…` path. SVG `<use href>` cannot load a cross-origin
88
+ sprite, so `icon()` always resolves the sprite this way — the origin still
89
+ serves `public/assets/`.
86
90
  - `hasAsset(name)` tells you whether it is in the manifest.
87
91
 
88
92
  ### CDN prefix and S3 upload
@@ -90,11 +94,21 @@ They are passed to templates automatically; in server code,
90
94
  Off by default. Prefix / bucket / keyPrefix come from config; credentials
91
95
  resolve from env → ECS task role → EC2 IMDS. When enabled:
92
96
 
93
- 1. A production build PutObjects files under `public/assets/` (and any other
94
- directories in `include`) to S3.
95
- 2. At runtime `asset()` / optimised images emit absolute CDN URLs via
96
- `build.assets.prefix`. When the entry script is on the CDN, esbuild chunks
97
- resolve relatively on the same host.
97
+ 1. A production build PutObjects `public/assets/` and `public/fonts/`
98
+ (default `include`) to S3. When `assets.prefix` is set, `fonts` is added
99
+ to `include` if missing.
100
+ 2. The CSS step rewrites root-absolute `url()` paths under `static.prefixes`
101
+ (`/fonts/…`, `/assets/…`) with `prefix` — otherwise a stylesheet on the CDN
102
+ resolves `url("/fonts/x")` against the host root and drops `keyPrefix`.
103
+ `@font-face` can stay in the CDN CSS; font and CSS share a host, so no
104
+ extra CORS is needed for that path.
105
+ 3. When fonts are uploaded and `upload.corsOrigin` is set (default `"*"`),
106
+ PutBucketCors writes a GET/HEAD rule — it **replaces** the bucket's
107
+ previous CORS config; set `corsOrigin: null` to skip. Cross-origin fonts
108
+ from HTML still need this (or a CloudFront response-header policy).
109
+ 4. At runtime `asset()` / optimised images emit absolute CDN URLs via
110
+ `build.assets.prefix`. The `icon()` sprite and
111
+ `asset(name, { origin: "document" })` stay on the document origin.
98
112
 
99
113
  Bucket names may include dots (`static.example.com`); put `https://…` in
100
114
  `prefix`. Config reference: [07-configuration.md](./07-configuration.md#build).
@@ -300,6 +314,10 @@ Usage is written by hand in the stylesheet:
300
314
  Because the `.woff2` extension and the `/fonts/` prefix are in the default
301
315
  `static` rules, these files automatically get an `immutable` cache.
302
316
 
317
+ When `build.assets.prefix` is set, the build rewrites `url("/fonts/…")` in CSS
318
+ to the CDN prefix and uploads the font files too — you do not need a separate
319
+ `@font-face` block in the layout.
320
+
303
321
  ## Icon sprite
304
322
 
305
323
  Produces a `<symbol>` set for **only the icons actually used in the source**.
@@ -473,7 +491,9 @@ directly does not end up facing an unstyled page.
473
491
  - **Some Tailwind classes do not work.** They were written in a directory whose
474
492
  `@source` directive is missing.
475
493
  - **An icon looks empty.** That symbol is not in the sprite; in dev, look for the
476
- `[icon] missing from sprite` warning.
494
+ `[icon] missing from sprite` warning. With a CDN prefix the sprite stays on
495
+ the document origin on purpose (`asset("sprite.svg", { origin: "document" })`);
496
+ if the origin does not serve `public/assets/`, `<use>` stays blank.
477
497
  - **The islands never open.** `main.js` is not in the manifest (the entry
478
498
  directory is empty or the build was skipped) or there is a build error.
479
499
  - **The page suddenly went unstyled in dev.** The manifest and the file on disk
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.6.14",
3
+ "version": "0.6.16",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk SSR (optional EJS peer), vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,40 @@
1
+ /**
2
+ * CDN prefix varken CSS `url(/fonts/…)` yollarını upload path'iyle hizalar.
3
+ *
4
+ * CDN'deki `app.css` kök-mutlak `/fonts/x.woff2` görünce tarayıcı bunu
5
+ * `https://cdn.example.com/fonts/x` diye çözer — `keyPrefix` (`/js` vb.)
6
+ * kaybolur ve 403 gelir. Prefix + aynı göreli yol (`…/js/fonts/x`) doğru
7
+ * nesneye gider; font ile CSS aynı host'ta olduğu için `@font-face` CORS
8
+ * istemez. HTML'den CDN font yüklemek ayrı: bucket/CloudFront CORS gerekir.
9
+ */
10
+ import { applyAssetPrefix } from "../server/assets.js";
11
+
12
+ /**
13
+ * @param {string} css
14
+ * @param {{ prefix: string, prefixes?: string[] }} options
15
+ * @returns {string}
16
+ */
17
+ export function rewriteCssStaticUrls(css, { prefix, prefixes = [] }) {
18
+ if (!prefix || !css) return css;
19
+
20
+ const roots = [...prefixes]
21
+ .filter((entry) => typeof entry === "string" && entry.startsWith("/"))
22
+ .map((entry) => (entry.endsWith("/") ? entry : `${entry}/`))
23
+ .sort((a, b) => b.length - a.length);
24
+
25
+ if (!roots.length) return css;
26
+
27
+ return css.replace(
28
+ /url\(\s*(['"]?)(\/[^)"'\s]+)\1\s*\)/gi,
29
+ (match, quote, urlPath) => {
30
+ const pathOnly = urlPath.split(/[?#]/, 1)[0];
31
+ if (!roots.some((root) => pathOnly === root.slice(0, -1) || pathOnly.startsWith(root))) {
32
+ return match;
33
+ }
34
+
35
+ const rewritten = applyAssetPrefix(prefix, urlPath);
36
+ const q = quote || '"';
37
+ return `url(${q}${rewritten}${q})`;
38
+ },
39
+ );
40
+ }
@@ -16,12 +16,22 @@ import {
16
16
  resolveS3Credentials,
17
17
  s3CredentialsMissingReason,
18
18
  } from "../../server/logs/s3-credentials.js";
19
- import { putObject } from "../../server/logs/s3-put.js";
19
+ import { putBucketCors, putObject } from "../../server/logs/s3-put.js";
20
20
  import * as log from "../../log.mjs";
21
21
  import { paths } from "../paths.mjs";
22
22
 
23
23
  const CONCURRENCY = 8;
24
24
 
25
+ /** Font MIME — CDN'den HTML çapraz origin yüklerken CORS gerekir. */
26
+ const FONT_MIME = new Set([
27
+ "font/woff",
28
+ "font/woff2",
29
+ "font/ttf",
30
+ "font/otf",
31
+ "application/font-woff",
32
+ "application/font-woff2",
33
+ ]);
34
+
25
35
  /** @type {Record<string, string>} */
26
36
  const MIME = {
27
37
  ".js": "application/javascript; charset=utf-8",
@@ -116,6 +126,7 @@ export function assetsUploadSkipReason(config) {
116
126
  /**
117
127
  * @param {import('../../config/index.js').ResolvedConfig} config
118
128
  * @param {{ put?: typeof putObject,
129
+ * putCors?: typeof putBucketCors,
119
130
  * resolveCredentials?: typeof resolveS3Credentials }} [options]
120
131
  * @returns {Promise<void>}
121
132
  */
@@ -135,6 +146,7 @@ export async function uploadAssets(config, options = {}) {
135
146
  }
136
147
 
137
148
  const put = options.put ?? putObject;
149
+ const putCors = options.putCors ?? putBucketCors;
138
150
  // Normalize bazen atlanır (test / elle config); önek `/` ile bitsin.
139
151
  let keyPrefix = (upload.keyPrefix || "").replace(/^\/+/, "");
140
152
  if (keyPrefix && !keyPrefix.endsWith("/")) keyPrefix += "/";
@@ -162,6 +174,27 @@ export async function uploadAssets(config, options = {}) {
162
174
  return;
163
175
  }
164
176
 
177
+ const uploadsFonts = jobs.some((job) =>
178
+ FONT_MIME.has(contentTypeFor(job.file)),
179
+ );
180
+ if (uploadsFonts && upload.corsOrigin) {
181
+ try {
182
+ await putCors({
183
+ bucket: upload.bucket,
184
+ region: upload.region,
185
+ endpoint: upload.endpoint,
186
+ credentials,
187
+ allowedOrigin: upload.corsOrigin,
188
+ });
189
+ log.detail(`cors ${upload.corsOrigin}`);
190
+ } catch (error) {
191
+ const message = error instanceof Error ? error.message : String(error);
192
+ console.warn(
193
+ `[assets] PutBucketCors failed (${message}) — set CDN/bucket CORS manually for cross-origin fonts`,
194
+ );
195
+ }
196
+ }
197
+
165
198
  let uploaded = 0;
166
199
  let index = 0;
167
200
 
@@ -24,6 +24,7 @@
24
24
  import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { paths, writeAsset, writeManifest } from "../paths.mjs";
27
+ import { rewriteCssStaticUrls } from "../rewrite-css-urls.mjs";
27
28
  import { importFromApp, tryImportFromApp } from "../resolve-peer.mjs";
28
29
  import * as log from "../../log.mjs";
29
30
 
@@ -85,6 +86,23 @@ function listPageStyles(dir) {
85
86
  .map((name) => path.join(dir, name));
86
87
  }
87
88
 
89
+ /**
90
+ * `build.assets.prefix` doluysa `static.prefixes` kök-mutlak `url()` yollarını
91
+ * CDN'e çevirir (`/fonts/…` → `https://cdn…/js/fonts/…`).
92
+ *
93
+ * @param {string} css
94
+ * @param {import('../../config/index.js').ResolvedConfig} config
95
+ * @returns {string}
96
+ */
97
+ function applyCdnStaticUrls(css, config) {
98
+ const prefix = config.build?.assets?.prefix ?? "";
99
+ if (!prefix) return css;
100
+ return rewriteCssStaticUrls(css, {
101
+ prefix,
102
+ prefixes: config.static?.prefixes ?? ["/assets/", "/fonts/"],
103
+ });
104
+ }
105
+
88
106
  /**
89
107
  * @param {import('../../config/index.js').ResolvedConfig} config
90
108
  * @param {{ watch?: boolean }} [options]
@@ -101,7 +119,7 @@ export async function buildCss(config, { watch = false } = {}) {
101
119
  const urls = {};
102
120
  let bytes = 0;
103
121
 
104
- const appCss = await compile(input);
122
+ const appCss = applyCdnStaticUrls(await compile(input), config);
105
123
  const appUrl = writeAsset("app.css", appCss);
106
124
  urls["app.css"] = appUrl;
107
125
  bytes += Buffer.byteLength(appCss);
@@ -111,7 +129,7 @@ export async function buildCss(config, { watch = false } = {}) {
111
129
 
112
130
  for (const file of listPageStyles(pagesDir)) {
113
131
  const name = path.basename(file);
114
- const css = await compile(file);
132
+ const css = applyCdnStaticUrls(await compile(file), config);
115
133
  const url = writeAsset(name, css);
116
134
  urls[name] = url;
117
135
  bytes += Buffer.byteLength(css);
@@ -508,9 +508,19 @@ export const DEFAULT_BUILD = {
508
508
  keyPrefix: "",
509
509
  region: "us-east-1",
510
510
  endpoint: null,
511
- /** `public/` altı dizin adları. */
512
- include: /** @type {string[]} */ (["assets"]),
511
+ /**
512
+ * `public/` altı dizin adları. `fonts` varsayılan: CDN CSS'teki
513
+ * `/fonts/…` rewrite'ı upload nesnesiyle hizalansın.
514
+ */
515
+ include: /** @type {string[]} */ (["assets", "fonts"]),
513
516
  cacheControl: "public, max-age=31536000, immutable",
517
+ /**
518
+ * Upload sırasında PutBucketCors ile yazılan `AllowedOrigin`.
519
+ * `null` / `false` → CORS adımı atlanır. Bucket'taki önceki CORS
520
+ * kuralının üzerine yazar — paylaşımlı kovada bilinçli açın.
521
+ * CDN CSS + aynı host font için genelde gerekmez.
522
+ */
523
+ corsOrigin: /** @type {string | null} */ ("*"),
514
524
  },
515
525
  },
516
526
  };
@@ -184,6 +184,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
184
184
  * @property {string | null} endpoint
185
185
  * @property {string[]} include
186
186
  * @property {string} cacheControl
187
+ * @property {string | null} corsOrigin Font/CDN PutBucketCors `AllowedOrigin`; `null` kapatır.
187
188
  *
188
189
  * @typedef {object} BuildAssetsConfig
189
190
  * @property {string} prefix CDN origin (`build.assets.prefix`); sonunda `/` yok.
@@ -1264,11 +1265,25 @@ export function normalizeBuildAssets(raw) {
1264
1265
  }
1265
1266
  }
1266
1267
 
1268
+ // CDN CSS `/fonts/…` → prefix'li URL yazar; fontlar upload'ta yoksa 403.
1269
+ // Kullanıcı `include: ["assets"]` yazsa bile prefix varken `fonts` eklenir.
1270
+ if (prefix && !include.includes("fonts")) {
1271
+ include = [...include, "fonts"];
1272
+ }
1273
+
1267
1274
  const cacheControl =
1268
1275
  typeof uploadRaw.cacheControl === "string" && uploadRaw.cacheControl.trim()
1269
1276
  ? uploadRaw.cacheControl.trim()
1270
1277
  : DEFAULT_BUILD.assets.upload.cacheControl;
1271
1278
 
1279
+ /** @type {string | null} */
1280
+ let corsOrigin = DEFAULT_BUILD.assets.upload.corsOrigin;
1281
+ if (uploadRaw.corsOrigin === null || uploadRaw.corsOrigin === false) {
1282
+ corsOrigin = null;
1283
+ } else if (typeof uploadRaw.corsOrigin === "string") {
1284
+ corsOrigin = uploadRaw.corsOrigin.trim() || null;
1285
+ }
1286
+
1272
1287
  // Config'te bucket varsa `enabled` unutulmuş olsa bile aç. Açık `false` ezer.
1273
1288
  const enabled =
1274
1289
  uploadRaw.enabled === true ||
@@ -1284,6 +1299,7 @@ export function normalizeBuildAssets(raw) {
1284
1299
  endpoint,
1285
1300
  include,
1286
1301
  cacheControl,
1302
+ corsOrigin,
1287
1303
  },
1288
1304
  };
1289
1305
  }
@@ -9,6 +9,11 @@
9
9
  * `build.assets.prefix` doluysa HTML'e basılan URL'lere CDN origin eklenir;
10
10
  * manifest diskte göreli kalır. Dosya sistemi okumaları (`getSpriteIds`)
11
11
  * göreli yolu kullanır.
12
+ *
13
+ * Bazı varlıklar bilinçli olarak site origin'inde kalır: SVG `<use href>`
14
+ * çapraz origin sprite yüklemez. `asset(name, { origin: "document" })`
15
+ * prefix'i atlar; `icon()` sprite için bunu kullanır. Origin hâlâ
16
+ * `public/assets/` servis eder.
12
17
  */
13
18
  import fs from "node:fs";
14
19
  import path from "node:path";
@@ -17,7 +22,7 @@ import { getConfig } from "../config/index.js";
17
22
 
18
23
  const isDev = process.env.NODE_ENV === "development";
19
24
 
20
- /** @type {{ manifest: Record<string, string> } | null} */
25
+ /** @type {{ manifest: Record<string, string>, generated: string } | null} */
21
26
  let cached = null;
22
27
  let warned = false;
23
28
 
@@ -38,13 +43,15 @@ function load() {
38
43
  }
39
44
  }
40
45
 
41
- return { manifest };
46
+ return { manifest, generated };
42
47
  }
43
48
 
44
49
  function state() {
45
50
  // Dev'de her istekte yeniden okunur (watch build hash'leri değiştirir);
46
- // prod'da bir kez.
47
- if (isDev || !cached) cached = load();
51
+ // prod'da bir kez. `generated` yolu değiştiyse (test / başka kök) de
52
+ // yenilenir — aksi hâlde eski manifest takılı kalır.
53
+ const generated = getConfig().dirs.generated;
54
+ if (isDev || !cached || cached.generated !== generated) cached = load();
48
55
  return cached;
49
56
  }
50
57
 
@@ -82,11 +89,22 @@ function assetPath(name) {
82
89
  }
83
90
 
84
91
  /**
92
+ * @typedef {{ origin?: "cdn" | "document" }} AssetOptions
93
+ */
94
+
95
+ /**
96
+ * Manifest'teki hash'li (veya yedek) URL.
97
+ *
85
98
  * @param {string} name Örn. "app.css", "main.js", "sprite.svg"
99
+ * @param {AssetOptions} [options]
100
+ * `origin: "document"` → `build.assets.prefix` uygulanmaz; site
101
+ * origin'indeki `/assets/…` yolu döner (varsayılan `"cdn"`).
86
102
  * @returns {string}
87
103
  */
88
- export function asset(name) {
89
- return applyAssetPrefix(assetPrefix(), assetPath(name));
104
+ export function asset(name, options = {}) {
105
+ const relative = assetPath(name);
106
+ if (options.origin === "document") return relative;
107
+ return applyAssetPrefix(assetPrefix(), relative);
90
108
  }
91
109
 
92
110
  /**
@@ -14,6 +14,9 @@ import crypto from "node:crypto";
14
14
  * @typedef {{ bucket: string, key: string, body: string | Buffer,
15
15
  * region: string, endpoint?: string | null, credentials: AwsCredentials,
16
16
  * contentType?: string, cacheControl?: string | null, now?: Date }} PutObjectInput
17
+ *
18
+ * @typedef {{ bucket: string, region: string, endpoint?: string | null,
19
+ * credentials: AwsCredentials, allowedOrigin: string, now?: Date }} PutBucketCorsInput
17
20
  */
18
21
 
19
22
  /**
@@ -131,6 +134,24 @@ export function useS3PathStyle(bucket, endpoint) {
131
134
  return bucket.includes(".");
132
135
  }
133
136
 
137
+ /**
138
+ * @param {{ bucket: string, region: string, endpoint?: string | null }} input
139
+ * @returns {{ host: string, base: string, pathStyle: boolean }}
140
+ */
141
+ function s3EndpointParts(input) {
142
+ const pathStyle = useS3PathStyle(input.bucket, input.endpoint);
143
+ if (input.endpoint) {
144
+ const base = String(input.endpoint).replace(/\/+$/, "");
145
+ return { host: new URL(input.endpoint).host, base, pathStyle: true };
146
+ }
147
+ if (pathStyle) {
148
+ const host = `s3.${input.region}.amazonaws.com`;
149
+ return { host, base: `https://${host}`, pathStyle: true };
150
+ }
151
+ const host = `${input.bucket}.s3.${input.region}.amazonaws.com`;
152
+ return { host, base: `https://${host}`, pathStyle: false };
153
+ }
154
+
134
155
  /**
135
156
  * PutObject isteğini imzalar; ağ çağrısı yapmaz — test edilebilir olsun diye.
136
157
  *
@@ -145,22 +166,7 @@ export function buildSignedPutObject(input) {
145
166
  const payloadHash = sha256Hex(body);
146
167
 
147
168
  const keyPath = encodeS3Path(input.key.replace(/^\/+/, ""));
148
- const pathStyle = useS3PathStyle(input.bucket, input.endpoint);
149
-
150
- /** @type {string} */
151
- let host;
152
- /** @type {string} */
153
- let base;
154
- if (input.endpoint) {
155
- base = String(input.endpoint).replace(/\/+$/, "");
156
- host = new URL(input.endpoint).host;
157
- } else if (pathStyle) {
158
- host = `s3.${input.region}.amazonaws.com`;
159
- base = `https://${host}`;
160
- } else {
161
- host = `${input.bucket}.s3.${input.region}.amazonaws.com`;
162
- base = `https://${host}`;
163
- }
169
+ const { host, base, pathStyle } = s3EndpointParts(input);
164
170
 
165
171
  const canonicalUri = pathStyle
166
172
  ? `/${encodeS3Path(input.bucket)}/${keyPath}`
@@ -219,6 +225,132 @@ export function buildSignedPutObject(input) {
219
225
  return { url, headers, body };
220
226
  }
221
227
 
228
+ /**
229
+ * PutBucketCors gövdesi — tek kural, GET/HEAD.
230
+ *
231
+ * @param {string} allowedOrigin
232
+ * @returns {string}
233
+ */
234
+ export function corsConfigurationXml(allowedOrigin) {
235
+ const origin = String(allowedOrigin)
236
+ .replace(/&/g, "&amp;")
237
+ .replace(/</g, "&lt;")
238
+ .replace(/>/g, "&gt;")
239
+ .replace(/"/g, "&quot;");
240
+
241
+ return (
242
+ `<?xml version="1.0" encoding="UTF-8"?>` +
243
+ `<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">` +
244
+ `<CORSRule>` +
245
+ `<AllowedOrigin>${origin}</AllowedOrigin>` +
246
+ `<AllowedMethod>GET</AllowedMethod>` +
247
+ `<AllowedMethod>HEAD</AllowedMethod>` +
248
+ `<AllowedHeader>*</AllowedHeader>` +
249
+ `<ExposeHeader>ETag</ExposeHeader>` +
250
+ `<ExposeHeader>Content-Type</ExposeHeader>` +
251
+ `<MaxAgeSeconds>86400</MaxAgeSeconds>` +
252
+ `</CORSRule>` +
253
+ `</CORSConfiguration>`
254
+ );
255
+ }
256
+
257
+ /**
258
+ * PutBucketCors isteğini imzalar (`PUT /?cors` veya path-style `/bucket?cors`).
259
+ *
260
+ * @param {PutBucketCorsInput} input
261
+ * @returns {{ url: string, headers: Record<string, string>, body: Buffer }}
262
+ */
263
+ export function buildSignedPutBucketCors(input) {
264
+ const body = Buffer.from(corsConfigurationXml(input.allowedOrigin), "utf8");
265
+ const now = input.now ?? new Date();
266
+ const { amzDate, dateStamp } = formatAmzDates(now);
267
+ const payloadHash = sha256Hex(body);
268
+ const { host, base, pathStyle } = s3EndpointParts(input);
269
+
270
+ // AWS: cors alt kaynağının canonical query'si `cors=` (boş değer).
271
+ const canonicalQuerystring = "cors=";
272
+ const canonicalUri = pathStyle ? `/${encodeS3Path(input.bucket)}` : "/";
273
+
274
+ /** @type {Record<string, string>} */
275
+ const headers = {
276
+ host,
277
+ "content-type": "application/xml",
278
+ "content-length": String(body.length),
279
+ "x-amz-content-sha256": payloadHash,
280
+ "x-amz-date": amzDate,
281
+ };
282
+
283
+ if (input.credentials.sessionToken) {
284
+ headers["x-amz-security-token"] = input.credentials.sessionToken;
285
+ }
286
+
287
+ const { canonicalRequest, signedHeaders } = buildCanonicalRequest({
288
+ method: "PUT",
289
+ canonicalUri,
290
+ canonicalQuerystring,
291
+ headers,
292
+ payloadHash,
293
+ });
294
+
295
+ const stringToSign = buildStringToSign({
296
+ amzDate,
297
+ dateStamp,
298
+ region: input.region,
299
+ service: "s3",
300
+ canonicalRequest,
301
+ });
302
+
303
+ const signingKey = deriveSigningKey(
304
+ input.credentials.secretAccessKey,
305
+ dateStamp,
306
+ input.region,
307
+ "s3",
308
+ );
309
+ const signature = hmac("sha256", signingKey, stringToSign).toString("hex");
310
+ const credentialScope = `${dateStamp}/${input.region}/s3/aws4_request`;
311
+
312
+ headers.authorization =
313
+ `AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${credentialScope}, ` +
314
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
315
+
316
+ const url = pathStyle
317
+ ? `${base}/${encodeS3Path(input.bucket)}?cors`
318
+ : `${base}/?cors`;
319
+
320
+ return { url, headers, body };
321
+ }
322
+
323
+ /**
324
+ * Bucket CORS yazar. Mevcut kuralların **üzerine** yazar; paylaşımlı
325
+ * kovada bilinçli kullanılmalı. İzin yoksa çağıran uyarır, upload sürer.
326
+ *
327
+ * @param {PutBucketCorsInput} input
328
+ * @returns {Promise<void>}
329
+ */
330
+ export async function putBucketCors(input) {
331
+ const signed = buildSignedPutBucketCors(input);
332
+ /** @type {Response} */
333
+ let response;
334
+ try {
335
+ response = await fetch(signed.url, {
336
+ method: "PUT",
337
+ headers: signed.headers,
338
+ body: signed.body,
339
+ });
340
+ } catch (error) {
341
+ const detail =
342
+ error instanceof Error ? error.message : String(error);
343
+ throw new Error(`S3 PutBucketCors fetch failed (${signed.url}): ${detail}`);
344
+ }
345
+
346
+ if (!response.ok) {
347
+ const text = await response.text().catch(() => "");
348
+ throw new Error(
349
+ `S3 PutBucketCors ${response.status}: ${text.slice(0, 200) || response.statusText}`,
350
+ );
351
+ }
352
+ }
353
+
222
354
  /**
223
355
  * @param {PutObjectInput} input
224
356
  * @returns {Promise<void>}
@@ -195,6 +195,11 @@ export function preloadImage({ href, imagesrcset, imagesizes }) {
195
195
  /**
196
196
  * Phosphor ikonları — build zamanı üretilen SVG sprite'tan `<use>`.
197
197
  * `weight` sprite id'sine dahildir (regular/bold/fill).
198
+ *
199
+ * Sprite URL'si her zaman site origin'indedir (`asset(..., { origin:
200
+ * "document" })`). `<use href>` çapraz origin SVG yüklemez; CDN prefix
201
+ * açıkken bile `public/assets/sprite.<hash>.svg` aynı host'tan gelir.
202
+ *
198
203
  * @param {{ name: string, size?: number | string, weight?: string,
199
204
  * class?: string, attrs?: Record<string, unknown> }} props
200
205
  * @returns {string}
@@ -208,7 +213,7 @@ export function icon(props) {
208
213
  attrs: extra = {},
209
214
  } = props;
210
215
 
211
- const spritePath = asset("sprite.svg");
216
+ const spritePath = asset("sprite.svg", { origin: "document" });
212
217
  const id = `${toKebab(name)}-${weight}`;
213
218
 
214
219
  if (isDev && !warnedIcons.has(id)) {
@@ -458,9 +458,19 @@ export declare const DEFAULT_BUILD: {
458
458
  keyPrefix: string;
459
459
  region: string;
460
460
  endpoint: null;
461
- /** `public/` altı dizin adları. */
461
+ /**
462
+ * `public/` altı dizin adları. `fonts` varsayılan: CDN CSS'teki
463
+ * `/fonts/…` rewrite'ı upload nesnesiyle hizalansın.
464
+ */
462
465
  include: string[];
463
466
  cacheControl: string;
467
+ /**
468
+ * Upload sırasında PutBucketCors ile yazılan `AllowedOrigin`.
469
+ * `null` / `false` → CORS adımı atlanır. Bucket'taki önceki CORS
470
+ * kuralının üzerine yazar — paylaşımlı kovada bilinçli açın.
471
+ * CDN CSS + aynı host font için genelde gerekmez.
472
+ */
473
+ corsOrigin: string | null;
464
474
  };
465
475
  };
466
476
  };
@@ -240,6 +240,10 @@ export type BuildAssetsUploadConfig = {
240
240
  endpoint: string | null;
241
241
  include: string[];
242
242
  cacheControl: string;
243
+ /**
244
+ * Font/CDN PutBucketCors `AllowedOrigin`; `null` kapatır.
245
+ */
246
+ corsOrigin: string | null;
243
247
  };
244
248
  export type BuildAssetsConfig = {
245
249
  /**
@@ -6,11 +6,22 @@
6
6
  * @returns {string}
7
7
  */
8
8
  export declare function applyAssetPrefix(prefix: string, url: string): string;
9
+ export type AssetOptions = {
10
+ origin?: "cdn" | "document";
11
+ };
12
+ /**
13
+ * @typedef {{ origin?: "cdn" | "document" }} AssetOptions
14
+ */
9
15
  /**
16
+ * Manifest'teki hash'li (veya yedek) URL.
17
+ *
10
18
  * @param {string} name Örn. "app.css", "main.js", "sprite.svg"
19
+ * @param {AssetOptions} [options]
20
+ * `origin: "document"` → `build.assets.prefix` uygulanmaz; site
21
+ * origin'indeki `/assets/…` yolu döner (varsayılan `"cdn"`).
11
22
  * @returns {string}
12
23
  */
13
- export declare function asset(name: string): string;
24
+ export declare function asset(name: string, options?: AssetOptions): string;
14
25
  /**
15
26
  * @param {string} name
16
27
  * @returns {boolean}
@@ -14,6 +14,14 @@ export type PutObjectInput = {
14
14
  cacheControl?: string | null;
15
15
  now?: Date;
16
16
  };
17
+ export type PutBucketCorsInput = {
18
+ bucket: string;
19
+ region: string;
20
+ endpoint?: string | null;
21
+ credentials: AwsCredentials;
22
+ allowedOrigin: string;
23
+ now?: Date;
24
+ };
17
25
  /**
18
26
  * @param {string | Buffer} data
19
27
  * @returns {string}
@@ -91,6 +99,32 @@ export declare function buildSignedPutObject(input: PutObjectInput): {
91
99
  headers: Record<string, string>;
92
100
  body: Buffer;
93
101
  };
102
+ /**
103
+ * PutBucketCors gövdesi — tek kural, GET/HEAD.
104
+ *
105
+ * @param {string} allowedOrigin
106
+ * @returns {string}
107
+ */
108
+ export declare function corsConfigurationXml(allowedOrigin: string): string;
109
+ /**
110
+ * PutBucketCors isteğini imzalar (`PUT /?cors` veya path-style `/bucket?cors`).
111
+ *
112
+ * @param {PutBucketCorsInput} input
113
+ * @returns {{ url: string, headers: Record<string, string>, body: Buffer }}
114
+ */
115
+ export declare function buildSignedPutBucketCors(input: PutBucketCorsInput): {
116
+ url: string;
117
+ headers: Record<string, string>;
118
+ body: Buffer;
119
+ };
120
+ /**
121
+ * Bucket CORS yazar. Mevcut kuralların **üzerine** yazar; paylaşımlı
122
+ * kovada bilinçli kullanılmalı. İzin yoksa çağıran uyarır, upload sürer.
123
+ *
124
+ * @param {PutBucketCorsInput} input
125
+ * @returns {Promise<void>}
126
+ */
127
+ export declare function putBucketCors(input: PutBucketCorsInput): Promise<void>;
94
128
  /**
95
129
  * @param {PutObjectInput} input
96
130
  * @returns {Promise<void>}
@@ -60,6 +60,11 @@ export declare function preloadImage({ href, imagesrcset, imagesizes }: {
60
60
  /**
61
61
  * Phosphor ikonları — build zamanı üretilen SVG sprite'tan `<use>`.
62
62
  * `weight` sprite id'sine dahildir (regular/bold/fill).
63
+ *
64
+ * Sprite URL'si her zaman site origin'indedir (`asset(..., { origin:
65
+ * "document" })`). `<use href>` çapraz origin SVG yüklemez; CDN prefix
66
+ * açıkken bile `public/assets/sprite.<hash>.svg` aynı host'tan gelir.
67
+ *
63
68
  * @param {{ name: string, size?: number | string, weight?: string,
64
69
  * class?: string, attrs?: Record<string, unknown> }} props
65
70
  * @returns {string}