jskelet 0.4.2 → 0.4.4

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
@@ -24,9 +24,24 @@ one is listed under a **Breaking** heading.
24
24
  query time and a default 24h lookback became `1d` plus network delay — Free
25
25
  zones reject anything wider than one day. Both ends are now pinned from the
26
26
  same clock (`datetime_leq` included).
27
+ - Missing `/assets/*` responses no longer keep the long-lived `immutable`
28
+ Cache-Control that `headersMiddleware` stamps for static prefixes. A deploy
29
+ race (prune-before-write) could 404 a hashed CSS URL for a moment; a CDN then
30
+ cached that HTML 404 for a year and browsers refused it as a stylesheet
31
+ (`MIME type 'text/html'`). Catch-all and `notFound` handlers now set
32
+ `Cache-Control: no-store`. CSS and sprite builds write the new file before
33
+ pruning older hashes so the same content hash never has a gap.
27
34
 
28
35
  ### Added
29
36
 
37
+ - Devtools SEO check: an **SEO** tab in the overlay lists document, heading,
38
+ image, link and social-tag issues with error/warning severity. Optional page
39
+ highlights draw red or yellow boxes around the offending elements; the label
40
+ shows the short title and a click opens the full explanation. Served only in
41
+ development as `/__jskelet/dev/seo.js` beside the overlay.
42
+ - Marketing homepage ops storyboard: Redis L2, `/_jskelet/admin` panel mock and
43
+ Cloudflare purge flow, with tabbed visual scenes animated by the vanilla
44
+ `motion` API (Framer Motion’s non-React package) via an `ops-story` island.
30
45
  - VS Code / Cursor extension skeleton under `extensions/vscode-jsk`: `.jsk`
31
46
  language id, TextMate highlighting (`{{ }}` / `{#if}` / `{#each}` /
32
47
  components), language config, and snippets. Install from that folder or
@@ -144,6 +159,14 @@ one is listed under a **Breaking** heading.
144
159
  island contract, path-based `invalidateHtmlCache` (replacing the outdated
145
160
  “no targeted invalidation” claim), Redis / admin / data-cache callouts, and
146
161
  bilingual doc links under `docs/` and `docs/en/`.
162
+ - Marketing compare live latency demo now measures two same-sized fragments
163
+ (cached vs `no-store`) with an explicit 80 ms simulated upstream inside the
164
+ shared producer — a hit skips that wait so the gap is visible even when RTT
165
+ dominates the wall clock. The island prints transferred bytes, Server-Timing
166
+ `produce` duration, and a View Source section contrasts `__NEXT_DATA__`
167
+ payload tax with plain JSkelet HTML. The measured-weight block also shows an
168
+ estimated Next.js App Router first-load breakdown beside this site’s real
169
+ gzip totals (clearly labelled estimate, not a build from this repo).
147
170
  - Duplicate component named exports (or the same PascalCase tag in two files)
148
171
  now **fail** at build and at server startup instead of warning and letting
149
172
  the second definition win. Overwriting `components/index.js` barrel exports
@@ -170,9 +170,9 @@ basılır:
170
170
  ```
171
171
 
172
172
  Overlay dosyası **build'e dâhil değildir.** Sunucu onu framework paketinden ham
173
- olarak servis eder; bu yüzden içinde bundler yoktur, tek dosya olarak çalışır ve
174
- prod çıktısına hiçbir şey eklemez. Tüm arayüz shadow DOM içinde durur, sayfanın
175
- CSS'i ile karışmaz.
173
+ olarak servis eder; bundler yoktur. Aynı dizindeki `seo.js` gibi kardeş
174
+ modüller native ESM import ile yüklenir ve prod çıktısına hiçbir şey eklenmez.
175
+ Tüm arayüz shadow DOM içinde durur, sayfanın CSS'i ile karışmaz.
176
176
 
177
177
  Gösterdikleri:
178
178
 
@@ -180,6 +180,14 @@ Gösterdikleri:
180
180
  (`img`/`script`/`link`), ve sunucudaki `console.error` / `console.warn`
181
181
  çıktıları. Sunucu tarafında `console` sarılır, böylece uyarılar terminalde
182
182
  kaybolmaz.
183
+ - **SEO:** açık sayfanın istemci tarafı taraması — title ve meta description
184
+ uzunluğu, `html lang`, viewport, canonical, robots/`noindex`, Open Graph ve
185
+ Twitter etiketleri, H1/outline, görsel `alt`, boş linkler ve JSON-LD parse
186
+ hataları. Bulgular panelde listelenir; **Highlight issues on the page**
187
+ açılınca sorunlu elemanların üzerine kırmızı (error) veya sarı (warning)
188
+ dikdörtgenler çizilir, kenarda kısa başlık durur. Etikete (ya da panel
189
+ satırına) tıklanınca ayrıntılı açıklama açılır. Tarama overlay'in yanındaki
190
+ `/seo.js` dosyasındadır; production paketine girmez.
183
191
  - **İstekler:** her HTML isteğinin metodu, yolu, durumu, süresi ve
184
192
  `X-JSkelet-Cache` değeri. Aynı satırlar terminale de basılır.
185
193
  - **Web Vitals:** TTFB, FCP, LCP, CLS, INP, DCL, load, uzun task sayısı ve
@@ -261,6 +269,7 @@ Rapor katmanı yalnızca development'ta yüklenir, üretim çıktısına hiç gi
261
269
  | Yol | Metot | İşi |
262
270
  | --- | --- | --- |
263
271
  | `/overlay.js` | GET | Overlay script'i |
272
+ | `/seo.js` | GET | SEO tarama + sayfa highlight yardımcısı (overlay import eder) |
264
273
  | `/logo.png` | GET | Overlay logosu |
265
274
  | `/ws` | GET (upgrade) | Canlı kanal: istatistikler, live reload ve CSS hot-swap olayları |
266
275
  | `/events` | GET | SSE: yalnızca WebSocket kurulamazsa kullanılan yedek olay akışı |
package/docs/README.md CHANGED
@@ -24,7 +24,7 @@ eşlenik tutuluyor; birini değiştiriyorsan diğerini de değiştir.
24
24
  | [06-cache.md](./06-cache.md) | `withHtmlCache`, `revalidate`, stale-while-revalidate, cache anahtarı, `X-JSkelet-Cache`, istek içi cache, degraded render, prewarm |
25
25
  | [07-yapilandirma.md](./07-yapilandirma.md) | `jskelet.config.mjs` tam referansı, `source` desen sözdizimi, ortam değişkenleri tablosu |
26
26
  | [08-build.md](./08-build.md) | Build hattı, manifest, hash'li varlıklar, CSS/Tailwind `@source`, fontlar, ikon sprite, görsel optimizasyonu, precompress |
27
- | [09-dev-araclari.md](./09-dev-araclari.md) | `jskelet dev` akışı, watch dizinleri, CSS hot-swap, devtools overlay (Alt+D), rapor sayfası, dev gate |
27
+ | [09-dev-araclari.md](./09-dev-araclari.md) | `jskelet dev` akışı, watch dizinleri, CSS hot-swap, devtools overlay (Alt+D, SEO highlight), rapor sayfası, dev gate |
28
28
  | [10-dagitim.md](./10-dagitim.md) | Prod build + start, ortam değişkenleri, Docker, ters proxy, sağlık kontrolü |
29
29
  | [11-tasima.md](./11-tasima.md) | Next.js'ten taşıma: karşılık tablosu ve adım adım plan |
30
30
  | [12-panel-ve-oturum.md](./12-panel-ve-oturum.md) | Kişiye özel sayfalar: `private: true`, imzalı cookie oturumu, CSRF, `fragment()`, parça takası ve form döngüsü |
@@ -173,9 +173,10 @@ by clicking the backdrop. It is only emitted by the layout when
173
173
  ```
174
174
 
175
175
  The overlay file is **not part of the build.** The server serves it raw from the
176
- framework package; that is why there is no bundler involved, it works as a
177
- single file, and it adds nothing to the production output. The entire UI lives
178
- inside a shadow DOM and does not mix with the page's CSS.
176
+ framework package; that is why there is no bundler involved. It loads sibling
177
+ modules such as `seo.js` with native ESM imports, and it adds nothing to the
178
+ production output. The entire UI lives inside a shadow DOM and does not mix with
179
+ the page's CSS.
179
180
 
180
181
  What it shows:
181
182
 
@@ -183,6 +184,14 @@ What it shows:
183
184
  (`img`/`script`/`link`), and the server's `console.error` / `console.warn`
184
185
  output. On the server side `console` is wrapped so warnings do not get lost in
185
186
  the terminal.
187
+ - **SEO:** a client-side scan of the current page — title and meta description
188
+ length, `html lang`, viewport, canonical, robots/`noindex`, Open Graph and
189
+ Twitter tags, H1/outline, image `alt`, empty links, and JSON-LD parse errors.
190
+ Findings appear in the panel; turning on **Highlight issues on the page**
191
+ draws red (error) or yellow (warning) boxes on the elements, with a short
192
+ title on the border. Clicking the label (or a row in the panel) opens the
193
+ full explanation. The scan lives in `/seo.js` next to the overlay and is not
194
+ part of the production bundle.
186
195
  - **Requests:** the method, path, status, duration and `X-JSkelet-Cache` value
187
196
  of every HTML request. The same lines are printed to the terminal too.
188
197
  - **Web Vitals:** TTFB, FCP, LCP, CLS, INP, DCL, load, long task count and
@@ -265,6 +274,7 @@ Under `brand.devBasePath` (default `/__jskelet/dev`):
265
274
  | Path | Method | Job |
266
275
  | --- | --- | --- |
267
276
  | `/overlay.js` | GET | The overlay script |
277
+ | `/seo.js` | GET | SEO scan + page highlight helper (imported by the overlay) |
268
278
  | `/logo.png` | GET | The overlay logo |
269
279
  | `/ws` | GET (upgrade) | Live channel: statistics, live reload and CSS hot-swap events |
270
280
  | `/events` | GET | SSE: the fallback event stream, used only when WebSocket cannot be established |
package/docs/en/README.md CHANGED
@@ -27,7 +27,7 @@ change one, change the other.
27
27
  | [06-caching.md](./06-caching.md) | `withHtmlCache`, `revalidate`, stale-while-revalidate, the cache key, `X-JSkelet-Cache`, in-request cache, degraded render, prewarm |
28
28
  | [07-configuration.md](./07-configuration.md) | Full `jskelet.config.mjs` reference, the `source` pattern syntax, environment variable table |
29
29
  | [08-build.md](./08-build.md) | The build pipeline, the manifest, hashed assets, CSS/Tailwind `@source`, fonts, icon sprite, image optimization, precompress |
30
- | [09-dev-tools.md](./09-dev-tools.md) | The `jskelet dev` flow, watch directories, CSS hot-swap, devtools overlay (Alt+D), the report page, the dev gate |
30
+ | [09-dev-tools.md](./09-dev-tools.md) | The `jskelet dev` flow, watch directories, CSS hot-swap, devtools overlay (Alt+D, SEO highlight), the report page, the dev gate |
31
31
  | [10-deployment.md](./10-deployment.md) | Prod build + start, environment variables, Docker, reverse proxy, health check |
32
32
  | [11-migration.md](./11-migration.md) | Migrating from Next.js: the equivalence table and a step-by-step plan |
33
33
  | [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md) | Per-visitor pages: `private: true`, signed cookie sessions, CSRF, `fragment()`, swapping regions and the form loop |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk (or EJS) SSR, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -131,13 +131,23 @@ export function patchManifest(key, url) {
131
131
  /**
132
132
  * Eski hash'li çıktıları temizler.
133
133
  *
134
+ * Yeni dosya **önce** yazılmalı, prune sonra gelmeli: aynı içerik aynı hash'i
135
+ * üretir ve önce silmek `/assets/app.<hash>.css` için kısa bir 404 penceresi
136
+ * açar. CDN o 404'ü `immutable` ile saklarsa (eski headersMiddleware
137
+ * davranışı) tarayıcı bir yıl boyunca stilsiz kalır.
138
+ *
134
139
  * @param {string[]} prefixes
140
+ * @param {{ keep?: string[] }} [options] Korunacak dosya adları (ör. yeni
141
+ * yazılan `app.<hash>.css`); `.br` / `.gz` sonekleri de eşleşir.
135
142
  */
136
- export function pruneAssets(prefixes) {
143
+ export function pruneAssets(prefixes, { keep = [] } = {}) {
137
144
  if (!fs.existsSync(paths.assets)) return;
145
+
138
146
  for (const file of fs.readdirSync(paths.assets)) {
139
- if (prefixes.some((prefix) => file.startsWith(prefix))) {
140
- fs.rmSync(path.join(paths.assets, file), { force: true });
147
+ if (!prefixes.some((prefix) => file.startsWith(prefix))) continue;
148
+ if (keep.some((name) => file === name || file.startsWith(`${name}.`))) {
149
+ continue;
141
150
  }
151
+ fs.rmSync(path.join(paths.assets, file), { force: true });
142
152
  }
143
153
  }
@@ -64,9 +64,11 @@ export async function buildCss(config, { watch = false } = {}) {
64
64
 
65
65
  const run = async () => {
66
66
  const started = Date.now();
67
- pruneAssets(["app."]);
68
67
  const css = await compile(input);
68
+ // Önce yaz, sonra eski hash'leri sil — aynı hash'e düşen içerikte 404
69
+ // penceresi olmasın (CDN immutable zehirlenmesi).
69
70
  const url = writeAsset("app.css", css);
71
+ pruneAssets(["app."], { keep: [path.basename(url)] });
70
72
  return { url, bytes: Buffer.byteLength(css), elapsed: Date.now() - started };
71
73
  };
72
74
 
@@ -192,8 +192,6 @@ export async function buildIconSprite(config) {
192
192
  config.icons?.scan ?? ["views", "client", "routes", "lib", "features", "shared"]
193
193
  ).map((dir) => path.resolve(config.root, dir));
194
194
 
195
- pruneAssets(["sprite."]);
196
-
197
195
  const used = [...scanUsedIcons(scanDirs)].sort();
198
196
  const symbols = [];
199
197
  const missing = [];
@@ -214,6 +212,7 @@ export async function buildIconSprite(config) {
214
212
 
215
213
  const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols.join("")}</svg>`;
216
214
  const url = writeAsset("sprite.svg", sprite);
215
+ pruneAssets(["sprite."], { keep: [path.basename(url)] });
217
216
 
218
217
  log.detail(`${symbols.length} symbols`);
219
218
  if (missing.length) {
@@ -1,15 +1,17 @@
1
1
  /**
2
- * Dev overlay: sağ altta yüzen baloncuk; hataları ve performans
3
- * istatistiklerini gösterir.
2
+ * Dev overlay: sağ altta yüzen baloncuk; hataları, performans
3
+ * istatistiklerini ve SEO taramasını gösterir.
4
4
  *
5
5
  * Bu dosya **build'e dahil değildir**. `npm run dev` sırasında sunucu onu
6
6
  * `/__jskelet/dev/overlay.js` altından ham olarak servis eder, layout da
7
- * script etiketini yalnızca development'ta basar. Bu yüzden burada bundler
8
- * yok: import yapmadan, tek dosya olarak çalışır.
7
+ * script etiketini yalnızca development'ta basar. Bundler yok: yalnızca aynı
8
+ * dizindeki `seo.js` gibi native ESM import'ları kullanılır.
9
9
  *
10
10
  * Tüm arayüz shadow DOM içinde durur; sayfanın CSS'i ile karışmaz.
11
11
  */
12
12
 
13
+ import { scanSeo, SeoHighlighter } from "./seo.js";
14
+
13
15
  const BASE = "/__jskelet/dev";
14
16
  /** Soket kurulamadığında düşülen yedek yoklama sıklığı. */
15
17
  const POLL_MS = 2000;
@@ -40,6 +42,19 @@ let open = false;
40
42
  /** Açık bırakılan yığın izleri; panel her poll'da yeniden çizildiği için gerekir. */
41
43
  let expanded = new Set();
42
44
 
45
+ /** Sayfa üzeri SEO dikdörtgenleri; panel kapalıyken de açık kalabilir. */
46
+ let seoHighlight = false;
47
+ /** Panel listesinde açılan sorun açıklaması. */
48
+ let seoExpandedId = null;
49
+ /** @type {import('./seo.js').SeoIssue[]} */
50
+ let seoIssues = [];
51
+ const seoLayer = new SeoHighlighter();
52
+ seoLayer.onSelect = (id) => {
53
+ seoExpandedId = id;
54
+ lastBody = "";
55
+ render();
56
+ };
57
+
43
58
  /**
44
59
  * Sunucu `node --watch` ile yeniden başladığında ya da sayfa tazelendiğinde
45
60
  * panelin kapanmaması için görünüm durumu ve tarayıcı hata günlüğü sekme
@@ -60,6 +75,8 @@ function saveState() {
60
75
  open,
61
76
  activeTab,
62
77
  aboutSection,
78
+ seoHighlight,
79
+ seoExpandedId,
63
80
  expanded: [...expanded],
64
81
  errors: clientErrors.slice(0, 30),
65
82
  }),
@@ -77,6 +94,8 @@ function loadState() {
77
94
  open = Boolean(saved.open);
78
95
  activeTab = saved.activeTab ?? "errors";
79
96
  aboutSection = saved.aboutSection ?? "framework";
97
+ seoHighlight = Boolean(saved.seoHighlight);
98
+ seoExpandedId = saved.seoExpandedId ?? null;
80
99
  expanded = new Set(saved.expanded ?? []);
81
100
  clientErrors.push(...(saved.errors ?? []));
82
101
  nextId = Math.max(nextId, ...clientErrors.map((item) => item.id + 1));
@@ -826,12 +845,43 @@ h4:first-child { margin-top: 0; }
826
845
  padding: 9px 11px; margin-bottom: 8px;
827
846
  }
828
847
  .item.warn { border-inline-start-color: var(--mid); }
848
+ .item.selected { border-color: rgba(255,255,255,.22); background: var(--surface-hover); }
829
849
  .item .meta { color: var(--muted); font-size: 10.5px; display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
830
850
  .tag { border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; font-size: 9.5px; }
831
851
  .msg { word-break: break-word; color: var(--text); }
832
852
  .item pre { margin: 8px 0 0; padding: 8px 10px; background: rgba(0,0,0,.4); border-radius: 8px; white-space: pre-wrap; word-break: break-word; color: #b8c0cb; font-size: 11px; font-family: ui-monospace, SFMono-Regular, monospace; max-height: 150px; overflow: auto; }
853
+ .item .detail-text { margin: 8px 0 0; color: #b8c0cb; font-size: 12px; line-height: 1.55; }
833
854
  .link { background: none; border: 0; padding: 0; margin-top: 6px; color: var(--muted); font-size: 11px; font-weight: 600; cursor: pointer; }
834
855
  .link:hover { color: var(--text); }
856
+ .item.clickable { cursor: pointer; }
857
+ .item.clickable:hover { border-color: var(--line); }
858
+
859
+ .toggle-row {
860
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
861
+ margin: 12px 0 4px; padding: 10px 12px; border-radius: 12px;
862
+ background: var(--surface); border: 1px solid var(--line);
863
+ }
864
+ .toggle-row span { color: #c6ced9; font-size: 12px; }
865
+ .toggle {
866
+ position: relative; width: 36px; height: 20px; border-radius: 999px; border: 0;
867
+ background: rgba(255,255,255,.12); cursor: pointer; flex: none;
868
+ transition: background .15s ease;
869
+ }
870
+ .toggle.on { background: rgba(52,211,153,.45); }
871
+ .toggle i {
872
+ position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
873
+ background: #fff; transition: transform .15s ease;
874
+ }
875
+ .toggle.on i { transform: translateX(16px); }
876
+
877
+ .seo-chip {
878
+ display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
879
+ padding: 5px 11px; border-radius: 999px; border: 1px solid var(--line);
880
+ background: rgba(12,14,18,.92); color: #cfd6de; font-size: 11.5px; font-weight: 600;
881
+ box-shadow: 0 8px 24px rgba(0,0,0,.4); cursor: pointer;
882
+ }
883
+ .seo-chip.bad { color: #ffd7de; border-color: rgba(251,113,133,.4); }
884
+ .seo-chip.warn { color: #ffeec2; border-color: rgba(251,191,36,.4); }
835
885
 
836
886
  .copy {
837
887
  display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
@@ -891,6 +941,7 @@ let shadow = null;
891
941
  */
892
942
  const TAB_ICONS = {
893
943
  errors: `<svg viewBox="0 0 16 16"><path d="M8 5.5v4M8 11.5h.01M8 1.8 1.8 13.2h12.4L8 1.8Z"/></svg>`,
944
+ seo: `<svg viewBox="0 0 16 16"><circle cx="7.2" cy="7.2" r="4.4"/><path d="M10.4 10.4 13.6 13.6M5.2 7.2h4M7.2 5.2v4"/></svg>`,
894
945
  perf: `<svg viewBox="0 0 16 16"><path d="M2 13V8m4 5V3m4 10V6m4 7v-3"/></svg>`,
895
946
  server: `<svg viewBox="0 0 16 16"><path d="M2.5 3.5h11v3h-11zM2.5 9.5h11v3h-11zM5 5h.01M5 11h.01"/></svg>`,
896
947
  warm: `<svg viewBox="0 0 16 16"><path d="M8 14c2.5 0 4.2-1.7 4.2-3.9C12.2 6.5 8 2 8 2S3.8 6.5 3.8 10.1C3.8 12.3 5.5 14 8 14Z"/></svg>`,
@@ -916,6 +967,7 @@ const SKELETON = `
916
967
  </div>
917
968
  <div class="rail">
918
969
  <button class="tab" data-tab="errors">${TAB_ICONS.errors} Errors<span class="chip" data-part="chip" hidden></span></button>
970
+ <button class="tab" data-tab="seo">${TAB_ICONS.seo} SEO<span class="chip" data-part="seo-chip" hidden></span></button>
919
971
  <button class="tab" data-tab="perf">${TAB_ICONS.perf} Performance</button>
920
972
  <button class="tab" data-tab="server">${TAB_ICONS.server} Server<span class="chip neutral" data-part="version-chip" hidden>update</span></button>
921
973
  <button class="tab" data-tab="warm">${TAB_ICONS.warm} Prewarming<span class="chip neutral" data-part="warm-chip" hidden></span></button>
@@ -931,6 +983,7 @@ const SKELETON = `
931
983
  </div>
932
984
  </div>
933
985
  <div class="dock">
986
+ <button type="button" class="seo-chip" data-part="seo-dock" data-action="seo-focus" hidden></button>
934
987
  <span class="warm" data-part="warm" hidden>
935
988
  <span class="ring"></span>
936
989
  <span data-part="warm-text"></span>
@@ -1151,6 +1204,86 @@ function errorsTab() {
1151
1204
  `;
1152
1205
  }
1153
1206
 
1207
+ /**
1208
+ * DOM'u yeniden tarar; highlight açıksa katmanı da günceller.
1209
+ * @param {{ select?: string | null }} [opts]
1210
+ */
1211
+ function refreshSeo(opts = {}) {
1212
+ seoIssues = scanSeo(document);
1213
+ if (seoHighlight) {
1214
+ seoLayer.show(seoIssues, { select: opts.select ?? seoExpandedId });
1215
+ } else if (seoLayer.active) {
1216
+ seoLayer.hide();
1217
+ }
1218
+ }
1219
+
1220
+ function seoTab() {
1221
+ refreshSeo();
1222
+
1223
+ const errors = seoIssues.filter((item) => item.severity === "error").length;
1224
+ const warns = seoIssues.length - errors;
1225
+ const tone = errors ? "bad" : warns ? "mid" : "good";
1226
+
1227
+ clipboard.set(
1228
+ "seo",
1229
+ seoIssues
1230
+ .map(
1231
+ (item) =>
1232
+ `[${item.severity}] ${item.category} · ${item.title}\n${item.detail}`,
1233
+ )
1234
+ .join("\n\n") || "No SEO issues on this page.",
1235
+ );
1236
+
1237
+ const groups = new Map();
1238
+ for (const issue of seoIssues) {
1239
+ const list = groups.get(issue.category) ?? [];
1240
+ list.push(issue);
1241
+ groups.set(issue.category, list);
1242
+ }
1243
+
1244
+ const body = seoIssues.length
1245
+ ? [...groups.entries()]
1246
+ .map(
1247
+ ([category, list]) => `
1248
+ <h4>${escapeHtml(category)}</h4>
1249
+ ${list
1250
+ .map((issue) => {
1251
+ const openIssue = seoExpandedId === issue.id;
1252
+ return `<div class="item clickable ${issue.severity === "warn" ? "warn" : ""} ${openIssue ? "selected" : ""}"
1253
+ data-action="seo-issue" data-id="${escapeHtml(issue.id)}">
1254
+ <div class="meta">
1255
+ <span class="tag">${escapeHtml(issue.severity)}</span>
1256
+ <span class="spacer"></span>
1257
+ <span>${openIssue ? "▾ details" : "▸ details"}</span>
1258
+ </div>
1259
+ <div class="msg">${escapeHtml(issue.title)}</div>
1260
+ ${openIssue ? `<p class="detail-text">${escapeHtml(issue.detail)}</p>` : ""}
1261
+ </div>`;
1262
+ })
1263
+ .join("")}`,
1264
+ )
1265
+ .join("")
1266
+ : `<div class="empty">No SEO issues on this page.</div>`;
1267
+
1268
+ return `
1269
+ ${lede(
1270
+ tone,
1271
+ errors || warns
1272
+ ? `This page has <strong>${errors} SEO errors</strong> and <strong>${warns} warnings</strong>. Turn on page highlights to see red/yellow boxes on the offending elements; click a label for the full explanation.`
1273
+ : "Document title, description, headings, images and social tags look fine on this page.",
1274
+ )}
1275
+ <div class="toggle-row">
1276
+ <span>Highlight issues on the page</span>
1277
+ <button type="button" class="toggle ${seoHighlight ? "on" : ""}" data-action="seo-toggle"
1278
+ aria-pressed="${seoHighlight}" title="Draw boxes around SEO issues">
1279
+ <i></i>
1280
+ </button>
1281
+ </div>
1282
+ <h4>Findings<span class="head-action">${copyButton("seo", "copy all")}<button class="btn mini" data-action="seo-rescan" style="margin-inline-start:6px">${REFRESH_ICON} Rescan</button></span></h4>
1283
+ ${body}
1284
+ `;
1285
+ }
1286
+
1154
1287
  function perfTab() {
1155
1288
  const resources = resourceSummary();
1156
1289
  const islands = islandSummary();
@@ -1740,23 +1873,38 @@ function paint() {
1740
1873
  const warnCount =
1741
1874
  clientErrors.length + (serverStats?.errors ?? []).length - errorCount;
1742
1875
 
1743
- const tone = errorCount ? "bad" : warnCount ? "warn" : "";
1744
- const badgeCount = errorCount || warnCount;
1876
+ const seoErrors = seoIssues.filter((item) => item.severity === "error").length;
1877
+ const seoWarns = seoIssues.length - seoErrors;
1878
+
1879
+ const tone = errorCount || seoErrors ? "bad" : warnCount || seoWarns ? "warn" : "";
1880
+ const badgeCount = errorCount || warnCount || seoErrors || seoWarns;
1745
1881
  const lcpLabel = metrics.lcp ? `${Math.round(metrics.lcp)} ms` : "measuring";
1746
1882
 
1747
1883
  const bubble = root.querySelector(".bubble");
1748
1884
  bubble.className = `bubble ${tone}`;
1749
1885
  bubble.title = `JSkelet dev tools — LCP ${lcpLabel}${
1750
- badgeCount ? `, ${errorCount} errors, ${warnCount} warnings` : ""
1886
+ badgeCount
1887
+ ? `, ${errorCount} errors, ${warnCount} warnings, ${seoIssues.length} SEO`
1888
+ : ""
1751
1889
  }`;
1752
1890
 
1753
1891
  const badge = root.querySelector(".badge");
1754
1892
  badge.hidden = !badgeCount;
1755
- badge.className = `badge ${errorCount ? "" : "warn"}`;
1893
+ badge.className = `badge ${errorCount || seoErrors ? "" : "warn"}`;
1756
1894
  badge.textContent = badgeCount > 99 ? "99+" : String(badgeCount);
1757
1895
 
1758
1896
  paintPrewarm(root);
1759
1897
 
1898
+ const seoDock = root.querySelector("[data-part='seo-dock']");
1899
+ if (seoHighlight && seoIssues.length) {
1900
+ seoDock.hidden = false;
1901
+ seoDock.className = `seo-chip ${seoErrors ? "bad" : "warn"}`;
1902
+ seoDock.textContent = `${seoIssues.length} SEO`;
1903
+ seoDock.title = "Open SEO tab · page highlights are on";
1904
+ } else {
1905
+ seoDock.hidden = true;
1906
+ }
1907
+
1760
1908
  const backdrop = root.querySelector(".backdrop");
1761
1909
  backdrop.hidden = !open;
1762
1910
  if (!open) return;
@@ -1765,14 +1913,19 @@ function paint() {
1765
1913
  pill.className = `pill ${offline ? "warn" : tone}`;
1766
1914
  pill.querySelector("[data-part='pill']").textContent = offline
1767
1915
  ? "server restarting…"
1768
- : errorCount || warnCount
1769
- ? `${errorCount} errors · ${warnCount} warnings`
1916
+ : errorCount || warnCount || seoIssues.length
1917
+ ? `${errorCount} errors · ${warnCount} warnings · ${seoIssues.length} SEO`
1770
1918
  : "all good";
1771
1919
 
1772
1920
  const chip = root.querySelector("[data-part='chip']");
1773
1921
  chip.hidden = !errorCount;
1774
1922
  chip.textContent = String(errorCount);
1775
1923
 
1924
+ const seoChip = root.querySelector("[data-part='seo-chip']");
1925
+ seoChip.hidden = !seoIssues.length;
1926
+ seoChip.className = `chip ${seoErrors ? "" : "neutral"}`;
1927
+ seoChip.textContent = String(seoIssues.length);
1928
+
1776
1929
  const version = serverStats?.version;
1777
1930
  paintVersion(root, version);
1778
1931
  const versionChip = root.querySelector("[data-part='version-chip']");
@@ -1798,13 +1951,15 @@ function paint() {
1798
1951
  const html =
1799
1952
  activeTab === "errors"
1800
1953
  ? errorsTab()
1801
- : activeTab === "perf"
1802
- ? perfTab()
1803
- : activeTab === "about"
1804
- ? aboutTab()
1805
- : activeTab === "warm"
1806
- ? warmTab()
1807
- : serverTab();
1954
+ : activeTab === "seo"
1955
+ ? seoTab()
1956
+ : activeTab === "perf"
1957
+ ? perfTab()
1958
+ : activeTab === "about"
1959
+ ? aboutTab()
1960
+ : activeTab === "warm"
1961
+ ? warmTab()
1962
+ : serverTab();
1808
1963
 
1809
1964
  if (html !== lastBody) {
1810
1965
  const body = root.querySelector(".body");
@@ -1921,6 +2076,45 @@ function bind(root) {
1921
2076
  return;
1922
2077
  }
1923
2078
 
2079
+ if (target.dataset.action === "seo-toggle") {
2080
+ seoHighlight = !seoHighlight;
2081
+ if (!seoHighlight) seoExpandedId = null;
2082
+ refreshSeo();
2083
+ saveState();
2084
+ lastBody = "";
2085
+ render();
2086
+ return;
2087
+ }
2088
+
2089
+ if (target.dataset.action === "seo-rescan") {
2090
+ refreshSeo();
2091
+ lastBody = "";
2092
+ render();
2093
+ return;
2094
+ }
2095
+
2096
+ if (target.dataset.action === "seo-focus") {
2097
+ activeTab = "seo";
2098
+ setOpen(true);
2099
+ return;
2100
+ }
2101
+
2102
+ if (target.dataset.action === "seo-issue") {
2103
+ const id = target.dataset.id;
2104
+ seoExpandedId = seoExpandedId === id ? null : id;
2105
+ if (seoExpandedId) {
2106
+ seoHighlight = true;
2107
+ refreshSeo({ select: seoExpandedId });
2108
+ seoLayer.select(seoExpandedId);
2109
+ } else if (seoHighlight) {
2110
+ seoLayer.select(null);
2111
+ }
2112
+ saveState();
2113
+ lastBody = "";
2114
+ render();
2115
+ return;
2116
+ }
2117
+
1924
2118
  if (target.dataset.action === "copy") {
1925
2119
  copy(target.dataset.key);
1926
2120
  return;
@@ -1936,6 +2130,7 @@ function start() {
1936
2130
  captureMetrics();
1937
2131
  captureFetch();
1938
2132
  bind(ensureRoot());
2133
+ refreshSeo();
1939
2134
  render();
1940
2135
 
1941
2136
  // Panel kapalıyken de rozet güncel kalsın diye kanal her zaman açılır;
@@ -1949,13 +2144,33 @@ function start() {
1949
2144
  });
1950
2145
  window.addEventListener("pagehide", () => sendPageReport(true));
1951
2146
 
2147
+ // Island'lar ve lazy img'ler geç gelebilir; highlight açıksa bir kez daha tara.
2148
+ setTimeout(() => {
2149
+ if (!seoHighlight && activeTab !== "seo") return;
2150
+ refreshSeo();
2151
+ lastBody = "";
2152
+ render();
2153
+ }, 2500);
2154
+
1952
2155
  window.addEventListener("keydown", (event) => {
1953
2156
  if (event.altKey && event.code === "KeyD") {
1954
2157
  setOpen(!open);
1955
2158
  return;
1956
2159
  }
1957
2160
 
1958
- if (event.key === "Escape" && open) setOpen(false);
2161
+ if (event.key === "Escape") {
2162
+ if (open) {
2163
+ setOpen(false);
2164
+ return;
2165
+ }
2166
+ if (seoExpandedId) {
2167
+ seoExpandedId = null;
2168
+ if (seoHighlight) seoLayer.select(null);
2169
+ saveState();
2170
+ lastBody = "";
2171
+ render();
2172
+ }
2173
+ }
1959
2174
  });
1960
2175
  }
1961
2176
 
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Dev SEO taraması: sayfanın DOM'unu okur, sorun listesi üretir ve isteğe
3
+ * bağlı olarak elemanları kırmızı/sarı dikdörtgenlerle işaretler.
4
+ *
5
+ * Overlay ile aynı şekilde build'e girmez; `/__jskelet/dev/seo.js` altında
6
+ * ham servis edilir. Kurallar bilinçli olarak küçük tutulur — Lighthouse
7
+ * yerine geliştirme sırasında kaçırılan meta/başlık/alt boşluklarını yakalar.
8
+ */
9
+
10
+ /**
11
+ * @typedef {'error' | 'warn'} SeoSeverity
12
+ *
13
+ * @typedef {{
14
+ * id: string,
15
+ * severity: SeoSeverity,
16
+ * title: string,
17
+ * detail: string,
18
+ * category: string,
19
+ * element?: Element | null,
20
+ * }} SeoIssue
21
+ */
22
+
23
+ /** @param {string} text */
24
+ export function trimText(text) {
25
+ return String(text ?? "").replace(/\s+/g, " ").trim();
26
+ }
27
+
28
+ /**
29
+ * Başlık uzunluğu. Google genelde ~50–60 karakterde keser.
30
+ * @param {string} title
31
+ * @returns {{ severity: SeoSeverity, title: string, detail: string } | null}
32
+ */
33
+ export function gradeTitleLength(title) {
34
+ const value = trimText(title);
35
+ if (!value) {
36
+ return {
37
+ severity: "error",
38
+ title: "Missing document title",
39
+ detail:
40
+ "Every page needs a unique <title>. Search results and browser tabs use it; without one the URL is shown instead.",
41
+ };
42
+ }
43
+ if (value.length < 10) {
44
+ return {
45
+ severity: "warn",
46
+ title: "Title is very short",
47
+ detail: `The title is ${value.length} characters. Aim for roughly 30–60 so the result snippet is informative.`,
48
+ };
49
+ }
50
+ if (value.length > 60) {
51
+ return {
52
+ severity: "warn",
53
+ title: "Title may be truncated",
54
+ detail: `The title is ${value.length} characters. Search engines often cut around 60; put the distinctive words first.`,
55
+ };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * @param {string | null | undefined} description
62
+ * @returns {{ severity: SeoSeverity, title: string, detail: string } | null}
63
+ */
64
+ export function gradeDescriptionLength(description) {
65
+ const value = trimText(description ?? "");
66
+ if (!value) {
67
+ return {
68
+ severity: "error",
69
+ title: "Missing meta description",
70
+ detail:
71
+ "Add <meta name=\"description\">. It does not rank directly, but it is the default snippet under the title in search results.",
72
+ };
73
+ }
74
+ if (value.length < 50) {
75
+ return {
76
+ severity: "warn",
77
+ title: "Description is short",
78
+ detail: `The description is ${value.length} characters. Around 120–160 characters usually fills the snippet without trailing off.`,
79
+ };
80
+ }
81
+ if (value.length > 160) {
82
+ return {
83
+ severity: "warn",
84
+ title: "Description may be truncated",
85
+ detail: `The description is ${value.length} characters. Keep the important clause in the first ~155 characters.`,
86
+ };
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * @param {string | null} alt
93
+ * @param {boolean} hasAttr
94
+ * @returns {{ severity: SeoSeverity, title: string, detail: string } | null}
95
+ */
96
+ export function gradeImageAlt(alt, hasAttr) {
97
+ if (!hasAttr) {
98
+ return {
99
+ severity: "error",
100
+ title: "Image missing alt",
101
+ detail:
102
+ "Add an alt attribute. Describe the image for screen readers and image search; use alt=\"\" only when the image is purely decorative.",
103
+ };
104
+ }
105
+ const value = trimText(alt ?? "");
106
+ // Boş alt dekoratif kabul edilir; uzun "image of …" gürültüsü uyarıdır.
107
+ if (value.length > 125) {
108
+ return {
109
+ severity: "warn",
110
+ title: "Alt text is very long",
111
+ detail: `Alt is ${value.length} characters. Keep it to a short phrase; the surrounding copy should carry the rest.`,
112
+ };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * @param {number[]} levels heading seviyeleri sırayla (1–6)
119
+ * @returns {{ severity: SeoSeverity, title: string, detail: string } | null}
120
+ */
121
+ export function gradeHeadingSkip(levels) {
122
+ if (!levels.length) return null;
123
+ let previous = levels[0];
124
+ for (let i = 1; i < levels.length; i++) {
125
+ const level = levels[i];
126
+ if (level > previous + 1) {
127
+ return {
128
+ severity: "warn",
129
+ title: "Heading level skipped",
130
+ detail: `Found an h${level} right after an h${previous}. Keep the outline sequential (h1 → h2 → h3) so assistive tech and outline tools can follow the structure.`,
131
+ };
132
+ }
133
+ previous = level;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * @param {Document} doc
140
+ * @param {string} name
141
+ * @returns {string | null}
142
+ */
143
+ function metaContent(doc, name) {
144
+ const node = doc.querySelector(`meta[name="${name}" i]`);
145
+ return node?.getAttribute("content") ?? null;
146
+ }
147
+
148
+ /**
149
+ * @param {Document} doc
150
+ * @param {string} property
151
+ * @returns {string | null}
152
+ */
153
+ function ogContent(doc, property) {
154
+ const node = doc.querySelector(`meta[property="${property}" i]`);
155
+ return node?.getAttribute("content") ?? null;
156
+ }
157
+
158
+ /**
159
+ * @param {Element | null | undefined} element
160
+ * @returns {string}
161
+ */
162
+ function describeElement(element) {
163
+ if (!element) return "document";
164
+ const tag = element.tagName.toLowerCase();
165
+ const id = element.id ? `#${element.id}` : "";
166
+ const cls = element.classList?.[0] ? `.${element.classList[0]}` : "";
167
+ return `<${tag}${id}${cls}>`;
168
+ }
169
+
170
+ /**
171
+ * Sayfadaki SEO sorunlarını tarar. `element` referansı highlight katmanı için
172
+ * tutulur; JSON'a serileştirilmez.
173
+ *
174
+ * @param {Document} [doc]
175
+ * @returns {SeoIssue[]}
176
+ */
177
+ export function scanSeo(doc = document) {
178
+ /** @type {SeoIssue[]} */
179
+ const issues = [];
180
+ let seq = 0;
181
+ /** @param {Omit<SeoIssue, 'id'>} issue */
182
+ const push = (issue) => {
183
+ issues.push({ ...issue, id: `seo-${++seq}` });
184
+ };
185
+
186
+ const titleEl = doc.querySelector("title");
187
+ const titleGrade = gradeTitleLength(titleEl?.textContent ?? "");
188
+ if (titleGrade) {
189
+ push({ ...titleGrade, category: "document", element: titleEl ?? doc.documentElement });
190
+ }
191
+
192
+ const descEl = doc.querySelector('meta[name="description" i]');
193
+ const descGrade = gradeDescriptionLength(descEl?.getAttribute("content"));
194
+ if (descGrade) {
195
+ push({
196
+ ...descGrade,
197
+ category: "document",
198
+ element: descEl ?? doc.head ?? doc.documentElement,
199
+ });
200
+ }
201
+
202
+ const html = doc.documentElement;
203
+ if (!html?.getAttribute("lang")?.trim()) {
204
+ push({
205
+ severity: "warn",
206
+ title: "Missing html lang",
207
+ detail:
208
+ "Set <html lang=\"…\"> to the page language. Screen readers and translation tools use it; search engines treat it as a language signal.",
209
+ category: "document",
210
+ element: html,
211
+ });
212
+ }
213
+
214
+ if (!doc.querySelector('meta[name="viewport" i]')) {
215
+ push({
216
+ severity: "error",
217
+ title: "Missing viewport meta",
218
+ detail:
219
+ "Mobile Googlebot expects a viewport tag. Without it the page may be treated as non-mobile-friendly.",
220
+ category: "document",
221
+ element: doc.head ?? html,
222
+ });
223
+ }
224
+
225
+ const canonicals = [...doc.querySelectorAll('link[rel="canonical" i]')];
226
+ if (!canonicals.length) {
227
+ push({
228
+ severity: "warn",
229
+ title: "Missing canonical URL",
230
+ detail:
231
+ "Add <link rel=\"canonical\"> so duplicate URLs (tracking params, trailing slash variants) consolidate to one preferred address.",
232
+ category: "document",
233
+ element: doc.head ?? html,
234
+ });
235
+ } else if (canonicals.length > 1) {
236
+ push({
237
+ severity: "error",
238
+ title: "Multiple canonical links",
239
+ detail: `Found ${canonicals.length} canonical tags. Only one should appear per page; extras confuse crawlers.`,
240
+ category: "document",
241
+ element: canonicals[0],
242
+ });
243
+ } else {
244
+ const href = canonicals[0].getAttribute("href")?.trim() ?? "";
245
+ if (!href) {
246
+ push({
247
+ severity: "error",
248
+ title: "Empty canonical href",
249
+ detail: "The canonical link has no href. Point it at the preferred absolute or site-relative URL for this page.",
250
+ category: "document",
251
+ element: canonicals[0],
252
+ });
253
+ }
254
+ }
255
+
256
+ const robots = trimText(metaContent(doc, "robots") ?? "").toLowerCase();
257
+ if (/\bnoindex\b/.test(robots)) {
258
+ push({
259
+ severity: "warn",
260
+ title: "Page is marked noindex",
261
+ detail:
262
+ "robots contains noindex, so search engines should not list this URL. Confirm that is intentional for this environment/page.",
263
+ category: "document",
264
+ element: doc.querySelector('meta[name="robots" i]') ?? doc.head ?? html,
265
+ });
266
+ }
267
+
268
+ for (const prop of ["og:title", "og:description", "og:image"]) {
269
+ if (!trimText(ogContent(doc, prop) ?? "")) {
270
+ push({
271
+ severity: "warn",
272
+ title: `Missing ${prop}`,
273
+ detail: `Open Graph ${prop} is empty. Social previews and some crawlers fall back to weaker signals without it.`,
274
+ category: "social",
275
+ element: doc.querySelector(`meta[property="${prop}" i]`) ?? doc.head ?? html,
276
+ });
277
+ }
278
+ }
279
+
280
+ if (!trimText(metaContent(doc, "twitter:card") ?? "")) {
281
+ push({
282
+ severity: "warn",
283
+ title: "Missing twitter:card",
284
+ detail:
285
+ "Without twitter:card, X/Twitter may pick a poor preview layout. summary or summary_large_image are the usual choices.",
286
+ category: "social",
287
+ element: doc.head ?? html,
288
+ });
289
+ }
290
+
291
+ const headings = [...doc.querySelectorAll("h1, h2, h3, h4, h5, h6")].filter(
292
+ (node) => !node.closest("#jskelet-devtools, #jskelet-seo-layer"),
293
+ );
294
+ const h1s = headings.filter((node) => node.tagName === "H1");
295
+ if (!h1s.length) {
296
+ push({
297
+ severity: "error",
298
+ title: "Missing H1",
299
+ detail:
300
+ "Add a single top-level heading that names the page topic. It anchors the outline and is a strong on-page signal.",
301
+ category: "structure",
302
+ element: doc.body ?? html,
303
+ });
304
+ } else if (h1s.length > 1) {
305
+ push({
306
+ severity: "warn",
307
+ title: "Multiple H1 headings",
308
+ detail: `Found ${h1s.length} H1 elements. Prefer one primary H1; demote section titles to H2+.`,
309
+ category: "structure",
310
+ element: h1s[1],
311
+ });
312
+ }
313
+
314
+ const levels = headings.map((node) => Number(node.tagName.slice(1)));
315
+ const skip = gradeHeadingSkip(levels);
316
+ if (skip) {
317
+ const offender = headings.find((node, index) => {
318
+ if (index === 0) return false;
319
+ return Number(node.tagName.slice(1)) > levels[index - 1] + 1;
320
+ });
321
+ push({ ...skip, category: "structure", element: offender ?? headings[0] });
322
+ }
323
+
324
+ const images = [...doc.querySelectorAll("img")].filter(
325
+ (node) => !node.closest("#jskelet-devtools, #jskelet-seo-layer"),
326
+ );
327
+ for (const img of images) {
328
+ const grade = gradeImageAlt(img.getAttribute("alt"), img.hasAttribute("alt"));
329
+ if (!grade) continue;
330
+ const src = img.getAttribute("src") || img.getAttribute("data-src") || describeElement(img);
331
+ push({
332
+ ...grade,
333
+ title: grade.title,
334
+ detail: `${grade.detail} (${src})`,
335
+ category: "media",
336
+ element: img,
337
+ });
338
+ }
339
+
340
+ const links = [...doc.querySelectorAll("a[href]")].filter(
341
+ (node) => !node.closest("#jskelet-devtools, #jskelet-seo-layer"),
342
+ );
343
+ for (const link of links) {
344
+ const href = (link.getAttribute("href") ?? "").trim();
345
+ if (!href || href === "#") {
346
+ push({
347
+ severity: "warn",
348
+ title: "Empty or placeholder link",
349
+ detail:
350
+ "This anchor has href=\"#\" or an empty href. Give it a real destination, or use a button if it only triggers a script.",
351
+ category: "links",
352
+ element: link,
353
+ });
354
+ continue;
355
+ }
356
+
357
+ const name = trimText(
358
+ link.getAttribute("aria-label") ||
359
+ link.getAttribute("title") ||
360
+ link.textContent ||
361
+ "",
362
+ );
363
+ const hasImgAlt = [...link.querySelectorAll("img")].some((img) =>
364
+ trimText(img.getAttribute("alt") ?? ""),
365
+ );
366
+ if (!name && !hasImgAlt) {
367
+ push({
368
+ severity: "warn",
369
+ title: "Link has no accessible name",
370
+ detail:
371
+ "The link has no text, aria-label, title, or image alt. Add a short label so users and crawlers know where it goes.",
372
+ category: "links",
373
+ element: link,
374
+ });
375
+ }
376
+ }
377
+
378
+ for (const script of doc.querySelectorAll('script[type="application/ld+json"]')) {
379
+ const raw = script.textContent?.trim() ?? "";
380
+ if (!raw) {
381
+ push({
382
+ severity: "warn",
383
+ title: "Empty JSON-LD block",
384
+ detail: "A structured-data script is present but empty. Remove it or fill in valid JSON-LD.",
385
+ category: "structured",
386
+ element: script,
387
+ });
388
+ continue;
389
+ }
390
+ try {
391
+ JSON.parse(raw);
392
+ } catch (error) {
393
+ push({
394
+ severity: "error",
395
+ title: "Invalid JSON-LD",
396
+ detail: `Structured data failed to parse: ${error instanceof Error ? error.message : String(error)}`,
397
+ category: "structured",
398
+ element: script,
399
+ });
400
+ }
401
+ }
402
+
403
+ const rank = { error: 0, warn: 1 };
404
+ issues.sort(
405
+ (a, b) =>
406
+ rank[a.severity] - rank[b.severity] || a.category.localeCompare(b.category) || a.title.localeCompare(b.title),
407
+ );
408
+ return issues;
409
+ }
410
+
411
+ /* -------------------------------------------------------------- highlight */
412
+
413
+ const LAYER_CSS = `
414
+ :host { all: initial; }
415
+ * { box-sizing: border-box; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
416
+ .layer { position: fixed; inset: 0; pointer-events: none; z-index: 2147483646; }
417
+ .box {
418
+ position: fixed; pointer-events: none;
419
+ border: 2px solid var(--tone);
420
+ background: color-mix(in srgb, var(--tone) 8%, transparent);
421
+ border-radius: 4px;
422
+ }
423
+ .box.error { --tone: #fb7185; }
424
+ .box.warn { --tone: #fbbf24; }
425
+ .box.active {
426
+ background: color-mix(in srgb, var(--tone) 18%, transparent);
427
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone) 55%, transparent);
428
+ }
429
+ .label {
430
+ position: fixed; pointer-events: auto; cursor: pointer;
431
+ max-width: min(280px, 70vw);
432
+ padding: 3px 8px; border-radius: 4px 4px 0 0;
433
+ font-size: 11px; font-weight: 700; line-height: 1.3;
434
+ color: #1a1010; background: var(--tone);
435
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
436
+ box-shadow: 0 4px 14px rgba(0,0,0,.28);
437
+ transform: translateY(-100%);
438
+ }
439
+ .label.warn { color: #2a1d00; }
440
+ .detail {
441
+ position: fixed; pointer-events: auto; z-index: 1;
442
+ width: min(340px, calc(100vw - 24px));
443
+ padding: 12px 14px; border-radius: 10px;
444
+ background: #10131a; color: #eef1f5; border: 1px solid rgba(255,255,255,.12);
445
+ box-shadow: 0 18px 40px rgba(0,0,0,.45);
446
+ font-size: 12.5px; line-height: 1.55;
447
+ }
448
+ .detail .head {
449
+ display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
450
+ font-size: 12px; font-weight: 700;
451
+ }
452
+ .detail .sev {
453
+ font-size: 9.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
454
+ border-radius: 999px; padding: 1px 7px;
455
+ }
456
+ .detail .sev.error { background: rgba(251,113,133,.2); color: #fb7185; }
457
+ .detail .sev.warn { background: rgba(251,191,36,.18); color: #fbbf24; }
458
+ .detail .cat { color: #8b95a3; font-size: 10.5px; margin-left: auto; text-transform: uppercase; letter-spacing: .05em; }
459
+ .detail p { margin: 0; color: #c6ced9; }
460
+ .detail .close {
461
+ position: absolute; top: 8px; right: 8px; border: 0; background: transparent;
462
+ color: #8b95a3; cursor: pointer; font-size: 14px; line-height: 1; padding: 4px;
463
+ }
464
+ .detail .close:hover { color: #fff; }
465
+ `;
466
+
467
+ /**
468
+ * Sayfa üzeri SEO işaretleri. Shadow DOM; sayfa CSS'i karışmaz.
469
+ */
470
+ export class SeoHighlighter {
471
+ constructor() {
472
+ /** @type {SeoIssue[]} */
473
+ this.issues = [];
474
+ /** @type {string | null} */
475
+ this.openId = null;
476
+ /** @type {((id: string | null) => void) | null} */
477
+ this.onSelect = null;
478
+ /** @type {HTMLElement | null} */
479
+ this.host = null;
480
+ /** @type {ShadowRoot | null} */
481
+ this.shadow = null;
482
+ /** @type {(() => void) | null} */
483
+ this._onViewport = null;
484
+ }
485
+
486
+ /** @returns {boolean} */
487
+ get active() {
488
+ return Boolean(this.host);
489
+ }
490
+
491
+ /**
492
+ * @param {SeoIssue[]} issues
493
+ * @param {{ select?: string | null }} [opts]
494
+ */
495
+ show(issues, opts = {}) {
496
+ this.issues = issues.filter((issue) => issue.element);
497
+ if (opts.select !== undefined) this.openId = opts.select;
498
+ this.#ensure();
499
+ this.#paint();
500
+ this.#bindViewport();
501
+ }
502
+
503
+ hide() {
504
+ this.#unbindViewport();
505
+ this.host?.remove();
506
+ this.host = null;
507
+ this.shadow = null;
508
+ this.openId = null;
509
+ }
510
+
511
+ /** @param {string | null} id */
512
+ select(id) {
513
+ this.openId = id;
514
+ const issue = this.issues.find((item) => item.id === id);
515
+ issue?.element?.scrollIntoView({ block: "center", behavior: "smooth" });
516
+ this.#paint();
517
+ this.onSelect?.(id);
518
+ }
519
+
520
+ #ensure() {
521
+ if (this.host) return;
522
+ const host = document.createElement("div");
523
+ host.id = "jskelet-seo-layer";
524
+ host.setAttribute("data-jskelet", "seo-layer");
525
+ document.documentElement.append(host);
526
+ const shadow = host.attachShadow({ mode: "open" });
527
+ const style = document.createElement("style");
528
+ style.textContent = LAYER_CSS;
529
+ const root = document.createElement("div");
530
+ root.className = "layer";
531
+ shadow.append(style, root);
532
+ root.addEventListener("click", (event) => {
533
+ const target = /** @type {HTMLElement} */ (event.target).closest("[data-seo-id], [data-action]");
534
+ if (!target) return;
535
+ event.preventDefault();
536
+ event.stopPropagation();
537
+ if (target.dataset.action === "close") {
538
+ this.openId = null;
539
+ this.#paint();
540
+ this.onSelect?.(null);
541
+ return;
542
+ }
543
+ const id = target.dataset.seoId;
544
+ if (!id) return;
545
+ this.openId = this.openId === id ? null : id;
546
+ this.#paint();
547
+ this.onSelect?.(this.openId);
548
+ });
549
+ this.host = host;
550
+ this.shadow = shadow;
551
+ }
552
+
553
+ #bindViewport() {
554
+ if (this._onViewport) return;
555
+ this._onViewport = () => this.#paint();
556
+ window.addEventListener("scroll", this._onViewport, true);
557
+ window.addEventListener("resize", this._onViewport);
558
+ }
559
+
560
+ #unbindViewport() {
561
+ if (!this._onViewport) return;
562
+ window.removeEventListener("scroll", this._onViewport, true);
563
+ window.removeEventListener("resize", this._onViewport);
564
+ this._onViewport = null;
565
+ }
566
+
567
+ #paint() {
568
+ const root = this.shadow?.querySelector(".layer");
569
+ if (!root) return;
570
+
571
+ const parts = [];
572
+ for (const issue of this.issues) {
573
+ const el = issue.element;
574
+ if (!el || !el.isConnected) continue;
575
+ const rect = el.getBoundingClientRect();
576
+ if (rect.width < 1 && rect.height < 1) continue;
577
+
578
+ const top = Math.max(0, rect.top);
579
+ const left = Math.max(0, rect.left);
580
+ const width = Math.min(rect.width, window.innerWidth - left);
581
+ const height = Math.min(rect.height, window.innerHeight - top);
582
+ if (width < 2 || height < 2) continue;
583
+
584
+ const active = this.openId === issue.id ? " active" : "";
585
+ parts.push(`
586
+ <div class="box ${issue.severity}${active}"
587
+ style="top:${top}px;left:${left}px;width:${width}px;height:${height}px"></div>
588
+ <button type="button" class="label ${issue.severity}" data-seo-id="${escapeAttr(issue.id)}"
589
+ style="top:${top}px;left:${left}px" title="${escapeAttr(issue.title)}">${escapeHtml(issue.title)}</button>
590
+ `);
591
+ }
592
+
593
+ if (this.openId) {
594
+ const issue = this.issues.find((item) => item.id === this.openId);
595
+ const el = issue?.element;
596
+ if (issue && el?.isConnected) {
597
+ const rect = el.getBoundingClientRect();
598
+ const detailTop = Math.min(window.innerHeight - 160, Math.max(8, rect.bottom + 8));
599
+ const detailLeft = Math.min(window.innerWidth - 360, Math.max(12, rect.left));
600
+ parts.push(`
601
+ <div class="detail" style="top:${detailTop}px;left:${detailLeft}px" role="dialog">
602
+ <button type="button" class="close" data-action="close" aria-label="Close">✕</button>
603
+ <div class="head">
604
+ <span class="sev ${issue.severity}">${issue.severity}</span>
605
+ <span>${escapeHtml(issue.title)}</span>
606
+ <span class="cat">${escapeHtml(issue.category)}</span>
607
+ </div>
608
+ <p>${escapeHtml(issue.detail)}</p>
609
+ </div>
610
+ `);
611
+ }
612
+ }
613
+
614
+ root.innerHTML = parts.join("");
615
+ }
616
+ }
617
+
618
+ /** @param {string} value */
619
+ function escapeHtml(value) {
620
+ return String(value).replace(/[&<>"]/g, (char) =>
621
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char],
622
+ );
623
+ }
624
+
625
+ /** @param {string} value */
626
+ function escapeAttr(value) {
627
+ return escapeHtml(value).replace(/'/g, "&#39;");
628
+ }
@@ -148,7 +148,10 @@ export async function createApp(options = {}) {
148
148
 
149
149
  app.use(async (req, res, next) => {
150
150
  try {
151
- res.status(404).type("html").send(await renderNotFound());
151
+ // headersMiddleware `/assets/*` için immutable basmış olabilir; eksik bir
152
+ // hash'li dosyanın 404'ü CDN'de bir yıl zehirlenmesin.
153
+ res.status(404).setHeader("Cache-Control", "no-store");
154
+ res.type("html").send(await renderNotFound());
152
155
  } catch (error) {
153
156
  next(error);
154
157
  }
@@ -168,7 +171,8 @@ export async function createApp(options = {}) {
168
171
  }
169
172
 
170
173
  if (isNotFoundError(error)) {
171
- res.status(404).type("html").send(await renderNotFound());
174
+ res.status(404).setHeader("Cache-Control", "no-store");
175
+ res.type("html").send(await renderNotFound());
172
176
  return;
173
177
  }
174
178
 
@@ -339,6 +339,13 @@ function router() {
339
339
  fs.createReadStream(OVERLAY_FILE).pipe(res);
340
340
  });
341
341
 
342
+ // Overlay'in SEO taraması; native ESM import ile yüklenir.
343
+ api.get("/seo.js", (req, res) => {
344
+ res.type("application/javascript");
345
+ res.setHeader("Cache-Control", "no-store");
346
+ fs.createReadStream(path.join(DEVTOOLS_DIR, "seo.js")).pipe(res);
347
+ });
348
+
342
349
  api.get("/logo.png", (req, res) => {
343
350
  res.type("image/png");
344
351
  // Logo geliştirme sırasında değişmiyor; her gezinmede yeniden indirmesin.