jskelet 0.4.3 → 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 +5 -0
- package/docs/09-dev-araclari.md +12 -3
- package/docs/README.md +1 -1
- package/docs/en/09-dev-tools.md +13 -3
- package/docs/en/README.md +1 -1
- package/package.json +1 -1
- package/src/client/devtools/overlay.js +233 -18
- package/src/client/devtools/seo.js +628 -0
- package/src/server/dev/devtools.js +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -34,6 +34,11 @@ one is listed under a **Breaking** heading.
|
|
|
34
34
|
|
|
35
35
|
### Added
|
|
36
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.
|
|
37
42
|
- Marketing homepage ops storyboard: Redis L2, `/_jskelet/admin` panel mock and
|
|
38
43
|
Cloudflare purge flow, with tabbed visual scenes animated by the vanilla
|
|
39
44
|
`motion` API (Framer Motion’s non-React package) via an `ops-story` island.
|
package/docs/09-dev-araclari.md
CHANGED
|
@@ -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;
|
|
174
|
-
|
|
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ü |
|
package/docs/en/09-dev-tools.md
CHANGED
|
@@ -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
|
|
177
|
-
|
|
178
|
-
inside a shadow DOM and does not mix with
|
|
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.
|
|
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",
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dev overlay: sağ altta yüzen baloncuk;
|
|
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.
|
|
8
|
-
*
|
|
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
|
|
1744
|
-
const
|
|
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
|
|
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 === "
|
|
1802
|
-
?
|
|
1803
|
-
: activeTab === "
|
|
1804
|
-
?
|
|
1805
|
-
: activeTab === "
|
|
1806
|
-
?
|
|
1807
|
-
:
|
|
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"
|
|
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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ })[char],
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** @param {string} value */
|
|
626
|
+
function escapeAttr(value) {
|
|
627
|
+
return escapeHtml(value).replace(/'/g, "'");
|
|
628
|
+
}
|
|
@@ -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.
|