jskelet 0.6.3 → 0.6.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.
Files changed (153) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +628 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +667 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +348 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +675 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +351 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +369 -356
  134. package/src/server/port-guard.js +255 -255
  135. package/src/server/prewarm.js +1082 -1082
  136. package/src/server/redis.js +588 -588
  137. package/src/server/render.js +910 -910
  138. package/src/server/router.js +157 -157
  139. package/src/server/status-page.js +265 -265
  140. package/src/server/upstream-limiter.js +376 -376
  141. package/src/server/upstream-tracking.js +166 -166
  142. package/src/shared/cookie-domain.js +66 -66
  143. package/src/start.mjs +22 -22
  144. package/src/templates/layout.ejs +30 -30
  145. package/src/templates/layout.jsk +30 -30
  146. package/src/version.mjs +31 -31
  147. package/src/views/components/loader.js +101 -101
  148. package/src/views/helpers/html.js +102 -102
  149. package/src/views/helpers/tags.js +375 -375
  150. package/types/config/defaults.d.ts +6 -0
  151. package/types/config/index.d.ts +6 -0
  152. package/types/server/cache-control.d.ts +28 -0
  153. package/types/server/og-image.d.ts +5 -0
@@ -1,628 +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
- }
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
+ }