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.
- package/AGENTS.md +136 -136
- package/CHANGELOG.md +628 -620
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -310
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +667 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1467 -1443
- package/docs/07-yapilandirma.md +1208 -1197
- package/docs/08-build.md +429 -429
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +348 -338
- package/docs/12-panel-ve-oturum.md +479 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -329
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +675 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1476 -1453
- package/docs/en/07-configuration.md +1229 -1219
- package/docs/en/08-build.md +447 -447
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +351 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +489 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +541 -534
- package/src/config/index.js +1500 -1469
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +232 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -70
- package/src/server/cache-control.js +45 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -553
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -233
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1196
- package/src/server/image-optimizer.js +500 -500
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -193
- package/src/server/logs/pipeline.js +165 -165
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -121
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +369 -356
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1082
- package/src/server/redis.js +588 -588
- package/src/server/render.js +910 -910
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +6 -0
- package/types/config/index.d.ts +6 -0
- package/types/server/cache-control.d.ts +28 -0
- 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
|
-
({ "&": "&", "<": "<", ">": ">", '"': """ })[char],
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/** @param {string} value */
|
|
626
|
-
function escapeAttr(value) {
|
|
627
|
-
return escapeHtml(value).replace(/'/g, "'");
|
|
628
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Dev SEO taraması: sayfanın DOM'unu okur, sorun listesi üretir ve isteğe
|
|
3
|
+
* bağlı olarak elemanları kırmızı/sarı dikdörtgenlerle işaretler.
|
|
4
|
+
*
|
|
5
|
+
* Overlay ile aynı şekilde build'e girmez; `/__jskelet/dev/seo.js` altında
|
|
6
|
+
* ham servis edilir. Kurallar bilinçli olarak küçük tutulur — Lighthouse
|
|
7
|
+
* yerine geliştirme sırasında kaçırılan meta/başlık/alt boşluklarını yakalar.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {'error' | 'warn'} SeoSeverity
|
|
12
|
+
*
|
|
13
|
+
* @typedef {{
|
|
14
|
+
* id: string,
|
|
15
|
+
* severity: SeoSeverity,
|
|
16
|
+
* title: string,
|
|
17
|
+
* detail: string,
|
|
18
|
+
* category: string,
|
|
19
|
+
* element?: Element | null,
|
|
20
|
+
* }} SeoIssue
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** @param {string} text */
|
|
24
|
+
export function trimText(text) {
|
|
25
|
+
return String(text ?? "").replace(/\s+/g, " ").trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Başlık uzunluğu. Google genelde ~50–60 karakterde keser.
|
|
30
|
+
* @param {string} title
|
|
31
|
+
* @returns {{ severity: SeoSeverity, title: string, detail: string } | null}
|
|
32
|
+
*/
|
|
33
|
+
export function gradeTitleLength(title) {
|
|
34
|
+
const value = trimText(title);
|
|
35
|
+
if (!value) {
|
|
36
|
+
return {
|
|
37
|
+
severity: "error",
|
|
38
|
+
title: "Missing document title",
|
|
39
|
+
detail:
|
|
40
|
+
"Every page needs a unique <title>. Search results and browser tabs use it; without one the URL is shown instead.",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (value.length < 10) {
|
|
44
|
+
return {
|
|
45
|
+
severity: "warn",
|
|
46
|
+
title: "Title is very short",
|
|
47
|
+
detail: `The title is ${value.length} characters. Aim for roughly 30–60 so the result snippet is informative.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (value.length > 60) {
|
|
51
|
+
return {
|
|
52
|
+
severity: "warn",
|
|
53
|
+
title: "Title may be truncated",
|
|
54
|
+
detail: `The title is ${value.length} characters. Search engines often cut around 60; put the distinctive words first.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string | null | undefined} description
|
|
62
|
+
* @returns {{ severity: SeoSeverity, title: string, detail: string } | null}
|
|
63
|
+
*/
|
|
64
|
+
export function gradeDescriptionLength(description) {
|
|
65
|
+
const value = trimText(description ?? "");
|
|
66
|
+
if (!value) {
|
|
67
|
+
return {
|
|
68
|
+
severity: "error",
|
|
69
|
+
title: "Missing meta description",
|
|
70
|
+
detail:
|
|
71
|
+
"Add <meta name=\"description\">. It does not rank directly, but it is the default snippet under the title in search results.",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (value.length < 50) {
|
|
75
|
+
return {
|
|
76
|
+
severity: "warn",
|
|
77
|
+
title: "Description is short",
|
|
78
|
+
detail: `The description is ${value.length} characters. Around 120–160 characters usually fills the snippet without trailing off.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (value.length > 160) {
|
|
82
|
+
return {
|
|
83
|
+
severity: "warn",
|
|
84
|
+
title: "Description may be truncated",
|
|
85
|
+
detail: `The description is ${value.length} characters. Keep the important clause in the first ~155 characters.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string | null} alt
|
|
93
|
+
* @param {boolean} hasAttr
|
|
94
|
+
* @returns {{ severity: SeoSeverity, title: string, detail: string } | null}
|
|
95
|
+
*/
|
|
96
|
+
export function gradeImageAlt(alt, hasAttr) {
|
|
97
|
+
if (!hasAttr) {
|
|
98
|
+
return {
|
|
99
|
+
severity: "error",
|
|
100
|
+
title: "Image missing alt",
|
|
101
|
+
detail:
|
|
102
|
+
"Add an alt attribute. Describe the image for screen readers and image search; use alt=\"\" only when the image is purely decorative.",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const value = trimText(alt ?? "");
|
|
106
|
+
// Boş alt dekoratif kabul edilir; uzun "image of …" gürültüsü uyarıdır.
|
|
107
|
+
if (value.length > 125) {
|
|
108
|
+
return {
|
|
109
|
+
severity: "warn",
|
|
110
|
+
title: "Alt text is very long",
|
|
111
|
+
detail: `Alt is ${value.length} characters. Keep it to a short phrase; the surrounding copy should carry the rest.`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {number[]} levels heading seviyeleri sırayla (1–6)
|
|
119
|
+
* @returns {{ severity: SeoSeverity, title: string, detail: string } | null}
|
|
120
|
+
*/
|
|
121
|
+
export function gradeHeadingSkip(levels) {
|
|
122
|
+
if (!levels.length) return null;
|
|
123
|
+
let previous = levels[0];
|
|
124
|
+
for (let i = 1; i < levels.length; i++) {
|
|
125
|
+
const level = levels[i];
|
|
126
|
+
if (level > previous + 1) {
|
|
127
|
+
return {
|
|
128
|
+
severity: "warn",
|
|
129
|
+
title: "Heading level skipped",
|
|
130
|
+
detail: `Found an h${level} right after an h${previous}. Keep the outline sequential (h1 → h2 → h3) so assistive tech and outline tools can follow the structure.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
previous = level;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {Document} doc
|
|
140
|
+
* @param {string} name
|
|
141
|
+
* @returns {string | null}
|
|
142
|
+
*/
|
|
143
|
+
function metaContent(doc, name) {
|
|
144
|
+
const node = doc.querySelector(`meta[name="${name}" i]`);
|
|
145
|
+
return node?.getAttribute("content") ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {Document} doc
|
|
150
|
+
* @param {string} property
|
|
151
|
+
* @returns {string | null}
|
|
152
|
+
*/
|
|
153
|
+
function ogContent(doc, property) {
|
|
154
|
+
const node = doc.querySelector(`meta[property="${property}" i]`);
|
|
155
|
+
return node?.getAttribute("content") ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {Element | null | undefined} element
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
function describeElement(element) {
|
|
163
|
+
if (!element) return "document";
|
|
164
|
+
const tag = element.tagName.toLowerCase();
|
|
165
|
+
const id = element.id ? `#${element.id}` : "";
|
|
166
|
+
const cls = element.classList?.[0] ? `.${element.classList[0]}` : "";
|
|
167
|
+
return `<${tag}${id}${cls}>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Sayfadaki SEO sorunlarını tarar. `element` referansı highlight katmanı için
|
|
172
|
+
* tutulur; JSON'a serileştirilmez.
|
|
173
|
+
*
|
|
174
|
+
* @param {Document} [doc]
|
|
175
|
+
* @returns {SeoIssue[]}
|
|
176
|
+
*/
|
|
177
|
+
export function scanSeo(doc = document) {
|
|
178
|
+
/** @type {SeoIssue[]} */
|
|
179
|
+
const issues = [];
|
|
180
|
+
let seq = 0;
|
|
181
|
+
/** @param {Omit<SeoIssue, 'id'>} issue */
|
|
182
|
+
const push = (issue) => {
|
|
183
|
+
issues.push({ ...issue, id: `seo-${++seq}` });
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const titleEl = doc.querySelector("title");
|
|
187
|
+
const titleGrade = gradeTitleLength(titleEl?.textContent ?? "");
|
|
188
|
+
if (titleGrade) {
|
|
189
|
+
push({ ...titleGrade, category: "document", element: titleEl ?? doc.documentElement });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const descEl = doc.querySelector('meta[name="description" i]');
|
|
193
|
+
const descGrade = gradeDescriptionLength(descEl?.getAttribute("content"));
|
|
194
|
+
if (descGrade) {
|
|
195
|
+
push({
|
|
196
|
+
...descGrade,
|
|
197
|
+
category: "document",
|
|
198
|
+
element: descEl ?? doc.head ?? doc.documentElement,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const html = doc.documentElement;
|
|
203
|
+
if (!html?.getAttribute("lang")?.trim()) {
|
|
204
|
+
push({
|
|
205
|
+
severity: "warn",
|
|
206
|
+
title: "Missing html lang",
|
|
207
|
+
detail:
|
|
208
|
+
"Set <html lang=\"…\"> to the page language. Screen readers and translation tools use it; search engines treat it as a language signal.",
|
|
209
|
+
category: "document",
|
|
210
|
+
element: html,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!doc.querySelector('meta[name="viewport" i]')) {
|
|
215
|
+
push({
|
|
216
|
+
severity: "error",
|
|
217
|
+
title: "Missing viewport meta",
|
|
218
|
+
detail:
|
|
219
|
+
"Mobile Googlebot expects a viewport tag. Without it the page may be treated as non-mobile-friendly.",
|
|
220
|
+
category: "document",
|
|
221
|
+
element: doc.head ?? html,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const canonicals = [...doc.querySelectorAll('link[rel="canonical" i]')];
|
|
226
|
+
if (!canonicals.length) {
|
|
227
|
+
push({
|
|
228
|
+
severity: "warn",
|
|
229
|
+
title: "Missing canonical URL",
|
|
230
|
+
detail:
|
|
231
|
+
"Add <link rel=\"canonical\"> so duplicate URLs (tracking params, trailing slash variants) consolidate to one preferred address.",
|
|
232
|
+
category: "document",
|
|
233
|
+
element: doc.head ?? html,
|
|
234
|
+
});
|
|
235
|
+
} else if (canonicals.length > 1) {
|
|
236
|
+
push({
|
|
237
|
+
severity: "error",
|
|
238
|
+
title: "Multiple canonical links",
|
|
239
|
+
detail: `Found ${canonicals.length} canonical tags. Only one should appear per page; extras confuse crawlers.`,
|
|
240
|
+
category: "document",
|
|
241
|
+
element: canonicals[0],
|
|
242
|
+
});
|
|
243
|
+
} else {
|
|
244
|
+
const href = canonicals[0].getAttribute("href")?.trim() ?? "";
|
|
245
|
+
if (!href) {
|
|
246
|
+
push({
|
|
247
|
+
severity: "error",
|
|
248
|
+
title: "Empty canonical href",
|
|
249
|
+
detail: "The canonical link has no href. Point it at the preferred absolute or site-relative URL for this page.",
|
|
250
|
+
category: "document",
|
|
251
|
+
element: canonicals[0],
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const robots = trimText(metaContent(doc, "robots") ?? "").toLowerCase();
|
|
257
|
+
if (/\bnoindex\b/.test(robots)) {
|
|
258
|
+
push({
|
|
259
|
+
severity: "warn",
|
|
260
|
+
title: "Page is marked noindex",
|
|
261
|
+
detail:
|
|
262
|
+
"robots contains noindex, so search engines should not list this URL. Confirm that is intentional for this environment/page.",
|
|
263
|
+
category: "document",
|
|
264
|
+
element: doc.querySelector('meta[name="robots" i]') ?? doc.head ?? html,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const prop of ["og:title", "og:description", "og:image"]) {
|
|
269
|
+
if (!trimText(ogContent(doc, prop) ?? "")) {
|
|
270
|
+
push({
|
|
271
|
+
severity: "warn",
|
|
272
|
+
title: `Missing ${prop}`,
|
|
273
|
+
detail: `Open Graph ${prop} is empty. Social previews and some crawlers fall back to weaker signals without it.`,
|
|
274
|
+
category: "social",
|
|
275
|
+
element: doc.querySelector(`meta[property="${prop}" i]`) ?? doc.head ?? html,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!trimText(metaContent(doc, "twitter:card") ?? "")) {
|
|
281
|
+
push({
|
|
282
|
+
severity: "warn",
|
|
283
|
+
title: "Missing twitter:card",
|
|
284
|
+
detail:
|
|
285
|
+
"Without twitter:card, X/Twitter may pick a poor preview layout. summary or summary_large_image are the usual choices.",
|
|
286
|
+
category: "social",
|
|
287
|
+
element: doc.head ?? html,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const headings = [...doc.querySelectorAll("h1, h2, h3, h4, h5, h6")].filter(
|
|
292
|
+
(node) => !node.closest("#jskelet-devtools, #jskelet-seo-layer"),
|
|
293
|
+
);
|
|
294
|
+
const h1s = headings.filter((node) => node.tagName === "H1");
|
|
295
|
+
if (!h1s.length) {
|
|
296
|
+
push({
|
|
297
|
+
severity: "error",
|
|
298
|
+
title: "Missing H1",
|
|
299
|
+
detail:
|
|
300
|
+
"Add a single top-level heading that names the page topic. It anchors the outline and is a strong on-page signal.",
|
|
301
|
+
category: "structure",
|
|
302
|
+
element: doc.body ?? html,
|
|
303
|
+
});
|
|
304
|
+
} else if (h1s.length > 1) {
|
|
305
|
+
push({
|
|
306
|
+
severity: "warn",
|
|
307
|
+
title: "Multiple H1 headings",
|
|
308
|
+
detail: `Found ${h1s.length} H1 elements. Prefer one primary H1; demote section titles to H2+.`,
|
|
309
|
+
category: "structure",
|
|
310
|
+
element: h1s[1],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const levels = headings.map((node) => Number(node.tagName.slice(1)));
|
|
315
|
+
const skip = gradeHeadingSkip(levels);
|
|
316
|
+
if (skip) {
|
|
317
|
+
const offender = headings.find((node, index) => {
|
|
318
|
+
if (index === 0) return false;
|
|
319
|
+
return Number(node.tagName.slice(1)) > levels[index - 1] + 1;
|
|
320
|
+
});
|
|
321
|
+
push({ ...skip, category: "structure", element: offender ?? headings[0] });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const images = [...doc.querySelectorAll("img")].filter(
|
|
325
|
+
(node) => !node.closest("#jskelet-devtools, #jskelet-seo-layer"),
|
|
326
|
+
);
|
|
327
|
+
for (const img of images) {
|
|
328
|
+
const grade = gradeImageAlt(img.getAttribute("alt"), img.hasAttribute("alt"));
|
|
329
|
+
if (!grade) continue;
|
|
330
|
+
const src = img.getAttribute("src") || img.getAttribute("data-src") || describeElement(img);
|
|
331
|
+
push({
|
|
332
|
+
...grade,
|
|
333
|
+
title: grade.title,
|
|
334
|
+
detail: `${grade.detail} (${src})`,
|
|
335
|
+
category: "media",
|
|
336
|
+
element: img,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const links = [...doc.querySelectorAll("a[href]")].filter(
|
|
341
|
+
(node) => !node.closest("#jskelet-devtools, #jskelet-seo-layer"),
|
|
342
|
+
);
|
|
343
|
+
for (const link of links) {
|
|
344
|
+
const href = (link.getAttribute("href") ?? "").trim();
|
|
345
|
+
if (!href || href === "#") {
|
|
346
|
+
push({
|
|
347
|
+
severity: "warn",
|
|
348
|
+
title: "Empty or placeholder link",
|
|
349
|
+
detail:
|
|
350
|
+
"This anchor has href=\"#\" or an empty href. Give it a real destination, or use a button if it only triggers a script.",
|
|
351
|
+
category: "links",
|
|
352
|
+
element: link,
|
|
353
|
+
});
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const name = trimText(
|
|
358
|
+
link.getAttribute("aria-label") ||
|
|
359
|
+
link.getAttribute("title") ||
|
|
360
|
+
link.textContent ||
|
|
361
|
+
"",
|
|
362
|
+
);
|
|
363
|
+
const hasImgAlt = [...link.querySelectorAll("img")].some((img) =>
|
|
364
|
+
trimText(img.getAttribute("alt") ?? ""),
|
|
365
|
+
);
|
|
366
|
+
if (!name && !hasImgAlt) {
|
|
367
|
+
push({
|
|
368
|
+
severity: "warn",
|
|
369
|
+
title: "Link has no accessible name",
|
|
370
|
+
detail:
|
|
371
|
+
"The link has no text, aria-label, title, or image alt. Add a short label so users and crawlers know where it goes.",
|
|
372
|
+
category: "links",
|
|
373
|
+
element: link,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const script of doc.querySelectorAll('script[type="application/ld+json"]')) {
|
|
379
|
+
const raw = script.textContent?.trim() ?? "";
|
|
380
|
+
if (!raw) {
|
|
381
|
+
push({
|
|
382
|
+
severity: "warn",
|
|
383
|
+
title: "Empty JSON-LD block",
|
|
384
|
+
detail: "A structured-data script is present but empty. Remove it or fill in valid JSON-LD.",
|
|
385
|
+
category: "structured",
|
|
386
|
+
element: script,
|
|
387
|
+
});
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
JSON.parse(raw);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
push({
|
|
394
|
+
severity: "error",
|
|
395
|
+
title: "Invalid JSON-LD",
|
|
396
|
+
detail: `Structured data failed to parse: ${error instanceof Error ? error.message : String(error)}`,
|
|
397
|
+
category: "structured",
|
|
398
|
+
element: script,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const rank = { error: 0, warn: 1 };
|
|
404
|
+
issues.sort(
|
|
405
|
+
(a, b) =>
|
|
406
|
+
rank[a.severity] - rank[b.severity] || a.category.localeCompare(b.category) || a.title.localeCompare(b.title),
|
|
407
|
+
);
|
|
408
|
+
return issues;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/* -------------------------------------------------------------- highlight */
|
|
412
|
+
|
|
413
|
+
const LAYER_CSS = `
|
|
414
|
+
:host { all: initial; }
|
|
415
|
+
* { box-sizing: border-box; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
|
416
|
+
.layer { position: fixed; inset: 0; pointer-events: none; z-index: 2147483646; }
|
|
417
|
+
.box {
|
|
418
|
+
position: fixed; pointer-events: none;
|
|
419
|
+
border: 2px solid var(--tone);
|
|
420
|
+
background: color-mix(in srgb, var(--tone) 8%, transparent);
|
|
421
|
+
border-radius: 4px;
|
|
422
|
+
}
|
|
423
|
+
.box.error { --tone: #fb7185; }
|
|
424
|
+
.box.warn { --tone: #fbbf24; }
|
|
425
|
+
.box.active {
|
|
426
|
+
background: color-mix(in srgb, var(--tone) 18%, transparent);
|
|
427
|
+
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone) 55%, transparent);
|
|
428
|
+
}
|
|
429
|
+
.label {
|
|
430
|
+
position: fixed; pointer-events: auto; cursor: pointer;
|
|
431
|
+
max-width: min(280px, 70vw);
|
|
432
|
+
padding: 3px 8px; border-radius: 4px 4px 0 0;
|
|
433
|
+
font-size: 11px; font-weight: 700; line-height: 1.3;
|
|
434
|
+
color: #1a1010; background: var(--tone);
|
|
435
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
436
|
+
box-shadow: 0 4px 14px rgba(0,0,0,.28);
|
|
437
|
+
transform: translateY(-100%);
|
|
438
|
+
}
|
|
439
|
+
.label.warn { color: #2a1d00; }
|
|
440
|
+
.detail {
|
|
441
|
+
position: fixed; pointer-events: auto; z-index: 1;
|
|
442
|
+
width: min(340px, calc(100vw - 24px));
|
|
443
|
+
padding: 12px 14px; border-radius: 10px;
|
|
444
|
+
background: #10131a; color: #eef1f5; border: 1px solid rgba(255,255,255,.12);
|
|
445
|
+
box-shadow: 0 18px 40px rgba(0,0,0,.45);
|
|
446
|
+
font-size: 12.5px; line-height: 1.55;
|
|
447
|
+
}
|
|
448
|
+
.detail .head {
|
|
449
|
+
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
|
|
450
|
+
font-size: 12px; font-weight: 700;
|
|
451
|
+
}
|
|
452
|
+
.detail .sev {
|
|
453
|
+
font-size: 9.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
|
454
|
+
border-radius: 999px; padding: 1px 7px;
|
|
455
|
+
}
|
|
456
|
+
.detail .sev.error { background: rgba(251,113,133,.2); color: #fb7185; }
|
|
457
|
+
.detail .sev.warn { background: rgba(251,191,36,.18); color: #fbbf24; }
|
|
458
|
+
.detail .cat { color: #8b95a3; font-size: 10.5px; margin-left: auto; text-transform: uppercase; letter-spacing: .05em; }
|
|
459
|
+
.detail p { margin: 0; color: #c6ced9; }
|
|
460
|
+
.detail .close {
|
|
461
|
+
position: absolute; top: 8px; right: 8px; border: 0; background: transparent;
|
|
462
|
+
color: #8b95a3; cursor: pointer; font-size: 14px; line-height: 1; padding: 4px;
|
|
463
|
+
}
|
|
464
|
+
.detail .close:hover { color: #fff; }
|
|
465
|
+
`;
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Sayfa üzeri SEO işaretleri. Shadow DOM; sayfa CSS'i karışmaz.
|
|
469
|
+
*/
|
|
470
|
+
export class SeoHighlighter {
|
|
471
|
+
constructor() {
|
|
472
|
+
/** @type {SeoIssue[]} */
|
|
473
|
+
this.issues = [];
|
|
474
|
+
/** @type {string | null} */
|
|
475
|
+
this.openId = null;
|
|
476
|
+
/** @type {((id: string | null) => void) | null} */
|
|
477
|
+
this.onSelect = null;
|
|
478
|
+
/** @type {HTMLElement | null} */
|
|
479
|
+
this.host = null;
|
|
480
|
+
/** @type {ShadowRoot | null} */
|
|
481
|
+
this.shadow = null;
|
|
482
|
+
/** @type {(() => void) | null} */
|
|
483
|
+
this._onViewport = null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** @returns {boolean} */
|
|
487
|
+
get active() {
|
|
488
|
+
return Boolean(this.host);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* @param {SeoIssue[]} issues
|
|
493
|
+
* @param {{ select?: string | null }} [opts]
|
|
494
|
+
*/
|
|
495
|
+
show(issues, opts = {}) {
|
|
496
|
+
this.issues = issues.filter((issue) => issue.element);
|
|
497
|
+
if (opts.select !== undefined) this.openId = opts.select;
|
|
498
|
+
this.#ensure();
|
|
499
|
+
this.#paint();
|
|
500
|
+
this.#bindViewport();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
hide() {
|
|
504
|
+
this.#unbindViewport();
|
|
505
|
+
this.host?.remove();
|
|
506
|
+
this.host = null;
|
|
507
|
+
this.shadow = null;
|
|
508
|
+
this.openId = null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** @param {string | null} id */
|
|
512
|
+
select(id) {
|
|
513
|
+
this.openId = id;
|
|
514
|
+
const issue = this.issues.find((item) => item.id === id);
|
|
515
|
+
issue?.element?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
516
|
+
this.#paint();
|
|
517
|
+
this.onSelect?.(id);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#ensure() {
|
|
521
|
+
if (this.host) return;
|
|
522
|
+
const host = document.createElement("div");
|
|
523
|
+
host.id = "jskelet-seo-layer";
|
|
524
|
+
host.setAttribute("data-jskelet", "seo-layer");
|
|
525
|
+
document.documentElement.append(host);
|
|
526
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
527
|
+
const style = document.createElement("style");
|
|
528
|
+
style.textContent = LAYER_CSS;
|
|
529
|
+
const root = document.createElement("div");
|
|
530
|
+
root.className = "layer";
|
|
531
|
+
shadow.append(style, root);
|
|
532
|
+
root.addEventListener("click", (event) => {
|
|
533
|
+
const target = /** @type {HTMLElement} */ (event.target).closest("[data-seo-id], [data-action]");
|
|
534
|
+
if (!target) return;
|
|
535
|
+
event.preventDefault();
|
|
536
|
+
event.stopPropagation();
|
|
537
|
+
if (target.dataset.action === "close") {
|
|
538
|
+
this.openId = null;
|
|
539
|
+
this.#paint();
|
|
540
|
+
this.onSelect?.(null);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const id = target.dataset.seoId;
|
|
544
|
+
if (!id) return;
|
|
545
|
+
this.openId = this.openId === id ? null : id;
|
|
546
|
+
this.#paint();
|
|
547
|
+
this.onSelect?.(this.openId);
|
|
548
|
+
});
|
|
549
|
+
this.host = host;
|
|
550
|
+
this.shadow = shadow;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
#bindViewport() {
|
|
554
|
+
if (this._onViewport) return;
|
|
555
|
+
this._onViewport = () => this.#paint();
|
|
556
|
+
window.addEventListener("scroll", this._onViewport, true);
|
|
557
|
+
window.addEventListener("resize", this._onViewport);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#unbindViewport() {
|
|
561
|
+
if (!this._onViewport) return;
|
|
562
|
+
window.removeEventListener("scroll", this._onViewport, true);
|
|
563
|
+
window.removeEventListener("resize", this._onViewport);
|
|
564
|
+
this._onViewport = null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
#paint() {
|
|
568
|
+
const root = this.shadow?.querySelector(".layer");
|
|
569
|
+
if (!root) return;
|
|
570
|
+
|
|
571
|
+
const parts = [];
|
|
572
|
+
for (const issue of this.issues) {
|
|
573
|
+
const el = issue.element;
|
|
574
|
+
if (!el || !el.isConnected) continue;
|
|
575
|
+
const rect = el.getBoundingClientRect();
|
|
576
|
+
if (rect.width < 1 && rect.height < 1) continue;
|
|
577
|
+
|
|
578
|
+
const top = Math.max(0, rect.top);
|
|
579
|
+
const left = Math.max(0, rect.left);
|
|
580
|
+
const width = Math.min(rect.width, window.innerWidth - left);
|
|
581
|
+
const height = Math.min(rect.height, window.innerHeight - top);
|
|
582
|
+
if (width < 2 || height < 2) continue;
|
|
583
|
+
|
|
584
|
+
const active = this.openId === issue.id ? " active" : "";
|
|
585
|
+
parts.push(`
|
|
586
|
+
<div class="box ${issue.severity}${active}"
|
|
587
|
+
style="top:${top}px;left:${left}px;width:${width}px;height:${height}px"></div>
|
|
588
|
+
<button type="button" class="label ${issue.severity}" data-seo-id="${escapeAttr(issue.id)}"
|
|
589
|
+
style="top:${top}px;left:${left}px" title="${escapeAttr(issue.title)}">${escapeHtml(issue.title)}</button>
|
|
590
|
+
`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (this.openId) {
|
|
594
|
+
const issue = this.issues.find((item) => item.id === this.openId);
|
|
595
|
+
const el = issue?.element;
|
|
596
|
+
if (issue && el?.isConnected) {
|
|
597
|
+
const rect = el.getBoundingClientRect();
|
|
598
|
+
const detailTop = Math.min(window.innerHeight - 160, Math.max(8, rect.bottom + 8));
|
|
599
|
+
const detailLeft = Math.min(window.innerWidth - 360, Math.max(12, rect.left));
|
|
600
|
+
parts.push(`
|
|
601
|
+
<div class="detail" style="top:${detailTop}px;left:${detailLeft}px" role="dialog">
|
|
602
|
+
<button type="button" class="close" data-action="close" aria-label="Close">✕</button>
|
|
603
|
+
<div class="head">
|
|
604
|
+
<span class="sev ${issue.severity}">${issue.severity}</span>
|
|
605
|
+
<span>${escapeHtml(issue.title)}</span>
|
|
606
|
+
<span class="cat">${escapeHtml(issue.category)}</span>
|
|
607
|
+
</div>
|
|
608
|
+
<p>${escapeHtml(issue.detail)}</p>
|
|
609
|
+
</div>
|
|
610
|
+
`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
root.innerHTML = parts.join("");
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** @param {string} value */
|
|
619
|
+
function escapeHtml(value) {
|
|
620
|
+
return String(value).replace(/[&<>"]/g, (char) =>
|
|
621
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ })[char],
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** @param {string} value */
|
|
626
|
+
function escapeAttr(value) {
|
|
627
|
+
return escapeHtml(value).replace(/'/g, "'");
|
|
628
|
+
}
|