jskelet 0.6.1 → 0.6.3

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