@jjlmoya/utils-developer 1.15.0 → 1.16.0
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/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +4 -1
- package/src/index.ts +2 -0
- package/src/tests/locale_completeness.test.ts +2 -3
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/aspectRatio/component.astro +3 -1
- package/src/tool/colorConverter/component.astro +3 -3
- package/src/tool/llmCostCalculator/component.astro +1 -2
- package/src/tool/mobileMockupGenerator/component.astro +4 -5
- package/src/tool/placeholderGenerator/component.astro +2 -2
- package/src/tool/readabilityCalculator/component.astro +4 -4
- package/src/tool/serpPixelSimulator/bibliography.astro +7 -0
- package/src/tool/serpPixelSimulator/bibliography.ts +16 -0
- package/src/tool/serpPixelSimulator/component.astro +84 -0
- package/src/tool/serpPixelSimulator/controller.ts +323 -0
- package/src/tool/serpPixelSimulator/entry.ts +30 -0
- package/src/tool/serpPixelSimulator/i18n/de.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/en.ts +213 -0
- package/src/tool/serpPixelSimulator/i18n/es.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/fr.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/id.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/it.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/ja.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/ko.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/nl.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/pl.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/pt.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/ru.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/sv.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/tr.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/zh.ts +214 -0
- package/src/tool/serpPixelSimulator/index.ts +12 -0
- package/src/tool/serpPixelSimulator/logic.ts +18 -0
- package/src/tool/serpPixelSimulator/seo.astro +12 -0
- package/src/tool/serpPixelSimulator/serp-pixel-simulator.css +335 -0
- package/src/tool/serpPixelSimulator/ui.ts +33 -0
- package/src/tools.ts +2 -2
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { DESCRIPTION_LIMIT, getTitleLimit, ratioToPercent, type SerpDevice } from './logic';
|
|
2
|
+
import type { SerpPixelSimulatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
interface StoredState {
|
|
5
|
+
title?: string;
|
|
6
|
+
url?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
device?: SerpDevice;
|
|
9
|
+
favicon?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Elements {
|
|
13
|
+
titleInput: HTMLInputElement;
|
|
14
|
+
urlInput: HTMLInputElement;
|
|
15
|
+
fetchButton: HTMLButtonElement;
|
|
16
|
+
fetchMessage: HTMLElement;
|
|
17
|
+
descriptionInput: HTMLTextAreaElement;
|
|
18
|
+
desktopButton: HTMLButtonElement;
|
|
19
|
+
mobileButton: HTMLButtonElement;
|
|
20
|
+
serpCard: HTMLElement;
|
|
21
|
+
faviconFallback: HTMLElement;
|
|
22
|
+
faviconImage: HTMLImageElement;
|
|
23
|
+
previewTitle: HTMLElement;
|
|
24
|
+
previewUrl: HTMLElement;
|
|
25
|
+
previewDescription: HTMLElement;
|
|
26
|
+
titleMeter: HTMLElement;
|
|
27
|
+
descriptionMeter: HTMLElement;
|
|
28
|
+
titleFill: HTMLElement;
|
|
29
|
+
descriptionFill: HTMLElement;
|
|
30
|
+
titleCount: HTMLElement;
|
|
31
|
+
descriptionCount: HTMLElement;
|
|
32
|
+
titleStatus: HTMLElement;
|
|
33
|
+
descriptionStatus: HTMLElement;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface MeterParts {
|
|
37
|
+
meter: HTMLElement;
|
|
38
|
+
fill: HTMLElement;
|
|
39
|
+
count: HTMLElement;
|
|
40
|
+
status: HTMLElement;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FetchResult {
|
|
44
|
+
response: Response;
|
|
45
|
+
url: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const storageKey = 'serpPixelSimulator:lastState';
|
|
49
|
+
const titleFont = '20px Arial, Helvetica, sans-serif';
|
|
50
|
+
const descriptionFont = '14px Arial, Helvetica, sans-serif';
|
|
51
|
+
|
|
52
|
+
function getElement<T extends HTMLElement>(id: string): T {
|
|
53
|
+
return document.getElementById(id) as T;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collectElements(): Elements {
|
|
57
|
+
return {
|
|
58
|
+
titleInput: getElement<HTMLInputElement>('sps-title'),
|
|
59
|
+
urlInput: getElement<HTMLInputElement>('sps-url'),
|
|
60
|
+
fetchButton: getElement<HTMLButtonElement>('sps-fetch'),
|
|
61
|
+
fetchMessage: getElement('sps-fetch-message'),
|
|
62
|
+
descriptionInput: getElement<HTMLTextAreaElement>('sps-description'),
|
|
63
|
+
desktopButton: getElement<HTMLButtonElement>('sps-desktop'),
|
|
64
|
+
mobileButton: getElement<HTMLButtonElement>('sps-mobile'),
|
|
65
|
+
serpCard: getElement('sps-serp'),
|
|
66
|
+
faviconFallback: getElement('sps-favicon-fallback'),
|
|
67
|
+
faviconImage: getElement<HTMLImageElement>('sps-favicon-image'),
|
|
68
|
+
previewTitle: getElement('sps-preview-title'),
|
|
69
|
+
previewUrl: getElement('sps-preview-url'),
|
|
70
|
+
previewDescription: getElement('sps-preview-description'),
|
|
71
|
+
titleMeter: getElement('sps-title-meter'),
|
|
72
|
+
descriptionMeter: getElement('sps-description-meter'),
|
|
73
|
+
titleFill: getElement('sps-title-fill'),
|
|
74
|
+
descriptionFill: getElement('sps-description-fill'),
|
|
75
|
+
titleCount: getElement('sps-title-count'),
|
|
76
|
+
descriptionCount: getElement('sps-description-count'),
|
|
77
|
+
titleStatus: getElement('sps-title-status'),
|
|
78
|
+
descriptionStatus: getElement('sps-description-status'),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function measure(context: CanvasRenderingContext2D, text: string, font: string): number {
|
|
83
|
+
context.font = font;
|
|
84
|
+
return context.measureText(text).width;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function trimToPixels(context: CanvasRenderingContext2D, text: string, limit: number, font: string, suffix: string): string {
|
|
88
|
+
if (measure(context, text, font) <= limit) return text;
|
|
89
|
+
let low = 0;
|
|
90
|
+
let high = text.length;
|
|
91
|
+
while (low < high) {
|
|
92
|
+
const middle = Math.ceil((low + high) / 2);
|
|
93
|
+
const candidate = `${text.slice(0, middle).trimEnd()}${suffix}`;
|
|
94
|
+
if (measure(context, candidate, font) <= limit) low = middle;
|
|
95
|
+
else high = middle - 1;
|
|
96
|
+
}
|
|
97
|
+
return `${text.slice(0, low).trimEnd()}${suffix}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function displayUrl(rawUrl: string, fallbackUrl: string): string {
|
|
101
|
+
if (!rawUrl.trim()) return fallbackUrl;
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL(rawUrl);
|
|
104
|
+
return `${url.hostname}${url.pathname === '/' ? '' : url.pathname}`;
|
|
105
|
+
} catch {
|
|
106
|
+
return rawUrl.replace(/^https?:\/\//, '').replace(/\/$/, '') || fallbackUrl;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function setMessage(el: HTMLElement, message: string, tone: string): void {
|
|
111
|
+
el.textContent = message;
|
|
112
|
+
el.dataset.tone = tone;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizedUrl(rawUrl: string): string | null {
|
|
116
|
+
const trimmed = rawUrl.trim();
|
|
117
|
+
if (!trimmed) return null;
|
|
118
|
+
const value = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
119
|
+
try {
|
|
120
|
+
return new URL(value).toString();
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function candidateUrls(rawUrl: string): string[] {
|
|
127
|
+
const trimmed = rawUrl.trim();
|
|
128
|
+
if (!trimmed) return [];
|
|
129
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed)) {
|
|
130
|
+
const url = normalizedUrl(trimmed);
|
|
131
|
+
return url ? [url] : [];
|
|
132
|
+
}
|
|
133
|
+
return ['https://', 'http://'].flatMap((protocol) => {
|
|
134
|
+
try {
|
|
135
|
+
return [new URL(`${protocol}${trimmed}`).toString()];
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractMetaDescription(doc: Document): string {
|
|
143
|
+
const selectors = ['meta[name="description"]', 'meta[property="og:description"]', 'meta[name="twitter:description"]'];
|
|
144
|
+
for (const selector of selectors) {
|
|
145
|
+
const value = doc.querySelector(selector)?.getAttribute('content')?.trim();
|
|
146
|
+
if (value) return value;
|
|
147
|
+
}
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function extractFavicon(doc: Document, pageUrl: string): string {
|
|
152
|
+
const selectors = ['link[rel~="icon"][href]', 'link[rel="shortcut icon"][href]', 'link[rel="apple-touch-icon"][href]', 'link[rel="apple-touch-icon-precomposed"][href]'];
|
|
153
|
+
for (const selector of selectors) {
|
|
154
|
+
const href = doc.querySelector(selector)?.getAttribute('href')?.trim();
|
|
155
|
+
if (!href) continue;
|
|
156
|
+
try {
|
|
157
|
+
return new URL(href, pageUrl).toString();
|
|
158
|
+
} catch {
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return new URL('/favicon.ico', pageUrl).toString();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function stateFromElements(elements: Elements, device: SerpDevice): StoredState {
|
|
166
|
+
return {
|
|
167
|
+
title: elements.titleInput.value,
|
|
168
|
+
url: elements.urlInput.value,
|
|
169
|
+
description: elements.descriptionInput.value,
|
|
170
|
+
device,
|
|
171
|
+
favicon: elements.faviconImage.classList.contains('sps-hidden') ? '' : elements.faviconImage.src,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function saveState(elements: Elements, device: SerpDevice): void {
|
|
176
|
+
try {
|
|
177
|
+
localStorage.setItem(storageKey, JSON.stringify(stateFromElements(elements, device)));
|
|
178
|
+
} catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readState(): StoredState | null {
|
|
184
|
+
try {
|
|
185
|
+
const rawState = localStorage.getItem(storageKey);
|
|
186
|
+
return rawState ? (JSON.parse(rawState) as StoredState) : null;
|
|
187
|
+
} catch {
|
|
188
|
+
localStorage.removeItem(storageKey);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function setDevice(elements: Elements, device: SerpDevice): void {
|
|
194
|
+
elements.desktopButton.setAttribute('aria-pressed', String(device === 'desktop'));
|
|
195
|
+
elements.mobileButton.setAttribute('aria-pressed', String(device === 'mobile'));
|
|
196
|
+
elements.serpCard.classList.toggle('sps-mobile', device === 'mobile');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function setFavicon(elements: Elements, src: string): void {
|
|
200
|
+
elements.faviconImage.src = src;
|
|
201
|
+
elements.faviconImage.hidden = false;
|
|
202
|
+
elements.faviconImage.removeAttribute('hidden');
|
|
203
|
+
elements.faviconImage.classList.remove('sps-hidden');
|
|
204
|
+
elements.faviconFallback.hidden = false;
|
|
205
|
+
elements.faviconFallback.removeAttribute('hidden');
|
|
206
|
+
elements.faviconFallback.classList.add('sps-hidden');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function restoreState(elements: Elements): SerpDevice {
|
|
210
|
+
const state = readState();
|
|
211
|
+
if (!state) return 'desktop';
|
|
212
|
+
if (typeof state.title === 'string') elements.titleInput.value = state.title;
|
|
213
|
+
if (typeof state.url === 'string') elements.urlInput.value = state.url;
|
|
214
|
+
if (typeof state.description === 'string') elements.descriptionInput.value = state.description;
|
|
215
|
+
if (typeof state.favicon === 'string' && state.favicon) setFavicon(elements, state.favicon);
|
|
216
|
+
return state.device === 'mobile' ? 'mobile' : 'desktop';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function updateMeter(parts: MeterParts, width: number, limit: number, unit: string, okText: string, tooLongText: string): void {
|
|
220
|
+
const isOver = width > limit;
|
|
221
|
+
parts.meter.classList.toggle('sps-over', isOver);
|
|
222
|
+
parts.fill.style.width = `${ratioToPercent(width, limit)}%`;
|
|
223
|
+
parts.count.textContent = `${Math.round(width)} ${unit} / ${Math.round(limit)} ${unit}`;
|
|
224
|
+
parts.status.textContent = isOver ? tooLongText : okText;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function updatePreview(elements: Elements, context: CanvasRenderingContext2D, ui: SerpPixelSimulatorUI, device: SerpDevice): void {
|
|
228
|
+
const title = elements.titleInput.value.trim() || ui.emptyTitle;
|
|
229
|
+
const description = elements.descriptionInput.value.trim() || ui.emptyDescription;
|
|
230
|
+
const titleLimit = getTitleLimit(device);
|
|
231
|
+
elements.previewTitle.textContent = trimToPixels(context, title, titleLimit, titleFont, ui.ellipsis);
|
|
232
|
+
elements.previewDescription.textContent = trimToPixels(context, description, DESCRIPTION_LIMIT, descriptionFont, ui.ellipsis);
|
|
233
|
+
elements.previewUrl.textContent = displayUrl(elements.urlInput.value, ui.fallbackUrl);
|
|
234
|
+
updateMeter({ meter: elements.titleMeter, fill: elements.titleFill, count: elements.titleCount, status: elements.titleStatus }, measure(context, title, titleFont), titleLimit, ui.pixelUnit, ui.goodLabel, ui.tooLongLabel);
|
|
235
|
+
updateMeter({ meter: elements.descriptionMeter, fill: elements.descriptionFill, count: elements.descriptionCount, status: elements.descriptionStatus }, measure(context, description, descriptionFont), DESCRIPTION_LIMIT, ui.pixelUnit, ui.goodLabel, ui.tooLongLabel);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function fetchFirst(urls: string[]): Promise<FetchResult> {
|
|
239
|
+
let lastError: unknown = null;
|
|
240
|
+
for (const url of urls) {
|
|
241
|
+
try {
|
|
242
|
+
return { response: await fetch(url, { method: 'GET', mode: 'cors', credentials: 'omit' }), url };
|
|
243
|
+
} catch (error) {
|
|
244
|
+
lastError = error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
throw lastError instanceof Error ? lastError : new TypeError('Fetch failed');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function applyFetched(elements: Elements, doc: Document, url: string): boolean {
|
|
251
|
+
const title = doc.querySelector('title')?.textContent?.trim() ?? '';
|
|
252
|
+
const description = extractMetaDescription(doc);
|
|
253
|
+
if (!title && !description) return false;
|
|
254
|
+
if (title) elements.titleInput.value = title;
|
|
255
|
+
if (description) elements.descriptionInput.value = description;
|
|
256
|
+
setFavicon(elements, extractFavicon(doc, url));
|
|
257
|
+
elements.urlInput.value = url;
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function initSerpPixelSimulator(root: HTMLElement): void {
|
|
262
|
+
const ui = JSON.parse(root.dataset.i18n ?? '{}') as SerpPixelSimulatorUI;
|
|
263
|
+
const elements = collectElements();
|
|
264
|
+
const context = document.createElement('canvas').getContext('2d') as CanvasRenderingContext2D;
|
|
265
|
+
let device = restoreState(elements);
|
|
266
|
+
const render = () => updatePreview(elements, context, ui, device);
|
|
267
|
+
const persistAndRender = () => {
|
|
268
|
+
setMessage(elements.fetchMessage, '', 'neutral');
|
|
269
|
+
saveState(elements, device);
|
|
270
|
+
render();
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
setDevice(elements, device);
|
|
274
|
+
elements.titleInput.addEventListener('input', persistAndRender);
|
|
275
|
+
elements.urlInput.addEventListener('input', persistAndRender);
|
|
276
|
+
elements.descriptionInput.addEventListener('input', persistAndRender);
|
|
277
|
+
elements.desktopButton.addEventListener('click', () => {
|
|
278
|
+
device = 'desktop';
|
|
279
|
+
setDevice(elements, device);
|
|
280
|
+
persistAndRender();
|
|
281
|
+
});
|
|
282
|
+
elements.mobileButton.addEventListener('click', () => {
|
|
283
|
+
device = 'mobile';
|
|
284
|
+
setDevice(elements, device);
|
|
285
|
+
persistAndRender();
|
|
286
|
+
});
|
|
287
|
+
elements.faviconImage.addEventListener('error', () => {
|
|
288
|
+
elements.faviconImage.removeAttribute('src');
|
|
289
|
+
elements.faviconImage.classList.add('sps-hidden');
|
|
290
|
+
elements.faviconFallback.classList.remove('sps-hidden');
|
|
291
|
+
saveState(elements, device);
|
|
292
|
+
});
|
|
293
|
+
elements.fetchButton.addEventListener('click', () => {
|
|
294
|
+
void fetchMetadata(elements, ui, device, context);
|
|
295
|
+
});
|
|
296
|
+
render();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function fetchMetadata(elements: Elements, ui: SerpPixelSimulatorUI, device: SerpDevice, context: CanvasRenderingContext2D): Promise<void> {
|
|
300
|
+
const urls = candidateUrls(elements.urlInput.value);
|
|
301
|
+
if (urls.length === 0) {
|
|
302
|
+
setMessage(elements.fetchMessage, ui.fetchInvalidUrlError, 'error');
|
|
303
|
+
saveState(elements, device);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
elements.fetchButton.disabled = true;
|
|
307
|
+
elements.fetchButton.textContent = ui.fetchLoadingLabel;
|
|
308
|
+
setMessage(elements.fetchMessage, '', 'neutral');
|
|
309
|
+
try {
|
|
310
|
+
const result = await fetchFirst(urls);
|
|
311
|
+
if (!result.response.ok) throw new Error(`HTTP ${result.response.status}`);
|
|
312
|
+
const doc = new DOMParser().parseFromString(await result.response.text(), 'text/html');
|
|
313
|
+
if (!applyFetched(elements, doc, result.url)) setMessage(elements.fetchMessage, ui.fetchNoMetadataError, 'error');
|
|
314
|
+
else setMessage(elements.fetchMessage, ui.fetchSuccessLabel, 'success');
|
|
315
|
+
} catch (error) {
|
|
316
|
+
setMessage(elements.fetchMessage, error instanceof TypeError ? ui.fetchCorsError : ui.fetchGenericError, 'error');
|
|
317
|
+
} finally {
|
|
318
|
+
elements.fetchButton.disabled = false;
|
|
319
|
+
elements.fetchButton.textContent = ui.fetchButtonLabel;
|
|
320
|
+
saveState(elements, device);
|
|
321
|
+
updatePreview(elements, context, ui, device);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DeveloperToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
|
|
3
|
+
import type { SerpPixelSimulatorUI } from './ui';
|
|
4
|
+
|
|
5
|
+
export type SerpPixelSimulatorLocaleContent = ToolLocaleContent<SerpPixelSimulatorUI>;
|
|
6
|
+
|
|
7
|
+
export const serpPixelSimulator: DeveloperToolEntry<SerpPixelSimulatorUI> = {
|
|
8
|
+
id: 'serp-pixel-simulator',
|
|
9
|
+
icons: {
|
|
10
|
+
bg: 'mdi:google',
|
|
11
|
+
fg: 'mdi:ruler-square',
|
|
12
|
+
},
|
|
13
|
+
i18n: {
|
|
14
|
+
de: async () => (await import('./i18n/de')).content,
|
|
15
|
+
en: async () => (await import('./i18n/en')).content,
|
|
16
|
+
es: async () => (await import('./i18n/es')).content,
|
|
17
|
+
fr: async () => (await import('./i18n/fr')).content,
|
|
18
|
+
id: async () => (await import('./i18n/id')).content,
|
|
19
|
+
it: async () => (await import('./i18n/it')).content,
|
|
20
|
+
ja: async () => (await import('./i18n/ja')).content,
|
|
21
|
+
ko: async () => (await import('./i18n/ko')).content,
|
|
22
|
+
nl: async () => (await import('./i18n/nl')).content,
|
|
23
|
+
pl: async () => (await import('./i18n/pl')).content,
|
|
24
|
+
pt: async () => (await import('./i18n/pt')).content,
|
|
25
|
+
ru: async () => (await import('./i18n/ru')).content,
|
|
26
|
+
sv: async () => (await import('./i18n/sv')).content,
|
|
27
|
+
tr: async () => (await import('./i18n/tr')).content,
|
|
28
|
+
zh: async () => (await import('./i18n/zh')).content,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
import type { SerpPixelSimulatorUI } from '../ui';
|
|
4
|
+
import { bibliography } from '../bibliography';
|
|
5
|
+
|
|
6
|
+
const slug = 'serp-simulator-pixel-zaehler';
|
|
7
|
+
const title = 'SERP Simulator und SEO Pixelzähler';
|
|
8
|
+
const description = 'Vorschau von Google-ähnlichen Such-Snippets in Echtzeit, messen Sie die Breite von Titel und Meta-Beschreibung in Pixeln und sehen Sie genau, wo Ihr Text abgeschnitten wird.';
|
|
9
|
+
|
|
10
|
+
const howTo = [
|
|
11
|
+
{
|
|
12
|
+
name: 'Geben Sie den Title-Tag ein',
|
|
13
|
+
text: 'Tippen oder fügen Sie den Seitentitel ein, den Sie testen möchten. Die SERP-Vorschau und der Pixelmesser werden bei jedem Tastendruck aktualisiert.',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'Fügen Sie die sichtbare URL hinzu',
|
|
17
|
+
text: 'Verwenden Sie eine realistische Domain und einen Pfad, damit das Snippet dem Ergebnis ähnelt, das ein Suchender scannen würde.',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'Schreiben Sie die Meta-Beschreibung',
|
|
21
|
+
text: 'Fügen Sie den Beschreibungstext hinzu und beobachten Sie den Pixelbalken. Wenn er die empfohlene visuelle Breite überschreitet, kürzt die Vorschau ihn mit Auslassungspunkten.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'Wechseln Sie zwischen Desktop und Mobilgerät',
|
|
25
|
+
text: 'Vergleichen Sie die Titeldarstellung mit der Desktop- oder mobilen Kartenbreite, bevor Sie Metadaten veröffentlichen.',
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const faq = [
|
|
30
|
+
{
|
|
31
|
+
question: 'Warum Pixel statt Zeichen für SEO-Titel zählen?',
|
|
32
|
+
answer: 'Google-Suchergebniskarten sind durch die visuelle Breite begrenzt. Ein Titel mit vielen schmalen Buchstaben kann mehr Zeichen enthalten als ein Titel mit breiten Buchstaben, Großbuchstaben oder fett wirkenden Glyphen. Die Pixelmessung liefert eine genauere Vorschau des sichtbaren Ergebnisses.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
question: 'Garantiert dies genau, wie Google mein Snippet abschneidet?',
|
|
36
|
+
answer: 'Nein. Google kann Titel-Links und Snippets umschreiben, und die Darstellung kann je nach Suchanfrage, Gerät, Sprache und Experiment variieren. Das Tool ist als praktische visuelle Orientierungshilfe gedacht, um Metadaten zu schreiben, die weniger wahrscheinlich abgeschnitten werden.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
question: 'Welche Pixelgrenzen verwendet der Simulator?',
|
|
40
|
+
answer: 'Die Standard-Titelgrenze für Desktop beträgt 580 px, für Mobilgeräte 600 px und die Meta-Beschreibungsgrenze liegt bei 920 px. Dies sind Schreibziele, keine offiziellen Google-Grenzwerte.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
question: 'Warum fügt die Vorschau Auslassungspunkte hinzu?',
|
|
44
|
+
answer: 'Wenn der gemessene Text die verfügbare Pixelbreite überschreitet, kürzt der Simulator die Zeichenkette am letzten passenden Zeichen und hängt drei Punkte an, was dem praktischen Verhalten entspricht, das SEO-Teams benötigen, um Bedeutungsverlust zu erkennen.',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const ui: SerpPixelSimulatorUI = {
|
|
49
|
+
titleLabel: 'Title-Tag',
|
|
50
|
+
titlePlaceholder: 'GameBob | Indie-Entwicklungsstudio',
|
|
51
|
+
urlLabel: 'Sichtbare URL',
|
|
52
|
+
urlPlaceholder: 'https://www.gamebob.dev/de/',
|
|
53
|
+
descriptionLabel: 'Meta-Beschreibung',
|
|
54
|
+
descriptionPlaceholder: 'Entdecken Sie unsere Sammlung von Tools und Spielen, die Ihren digitalen Workflow und Ihre Unterhaltung bereichern.',
|
|
55
|
+
deviceLabel: 'Vorschaumodus',
|
|
56
|
+
desktopLabel: 'Desktop',
|
|
57
|
+
mobileLabel: 'Mobil',
|
|
58
|
+
titlePixelsLabel: 'Titelbreite',
|
|
59
|
+
descriptionPixelsLabel: 'Beschreibungsbreite',
|
|
60
|
+
charactersLabel: 'Zeichen',
|
|
61
|
+
previewLabel: 'Live-Vorschau im Google-Stil',
|
|
62
|
+
tooLongLabel: 'Zu breit',
|
|
63
|
+
goodLabel: 'Passt',
|
|
64
|
+
emptyTitle: 'Ihr Titel erscheint hier',
|
|
65
|
+
emptyDescription: 'Die Vorschau Ihrer Meta-Beschreibung erscheint hier, während Sie tippen.',
|
|
66
|
+
defaultTitle: 'GameBob | Indie-Entwicklungsstudio',
|
|
67
|
+
defaultUrl: 'https://www.gamebob.dev/de/',
|
|
68
|
+
defaultDescription: 'Entdecken Sie unsere Sammlung von Tools und Spielen, die Ihren digitalen Workflow und Ihre Unterhaltung bereichern.',
|
|
69
|
+
fallbackUrl: 'beispiel.de',
|
|
70
|
+
fallbackFaviconText: 'G',
|
|
71
|
+
pixelUnit: 'px',
|
|
72
|
+
ellipsis: '...',
|
|
73
|
+
fetchButtonLabel: 'Abrufen',
|
|
74
|
+
fetchLoadingLabel: 'Wird abgerufen...',
|
|
75
|
+
fetchSuccessLabel: 'Metadaten von der URL geladen.',
|
|
76
|
+
fetchCorsError: 'Der Browser konnte diese Seite nicht lesen. Sie könnte durch CORS, eine Weiterleitung, gemischten Inhalt oder eine Netzwerkregel blockiert sein. Sie können die Metadaten weiterhin manuell einfügen oder bearbeiten.',
|
|
77
|
+
fetchInvalidUrlError: 'Geben Sie eine gültige URL ein, bevor Sie Metadaten abrufen.',
|
|
78
|
+
fetchNoMetadataError: 'Die Seite wurde abgerufen, aber es wurde kein Titel oder keine Meta-Beschreibung gefunden.',
|
|
79
|
+
fetchGenericError: 'Metadaten konnten von dieser URL nicht abgerufen werden. Überprüfen Sie die Adresse oder füllen Sie die Felder manuell aus.',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
83
|
+
'@context': 'https://schema.org',
|
|
84
|
+
'@type': 'FAQPage',
|
|
85
|
+
mainEntity: faq.map((item) => ({
|
|
86
|
+
'@type': 'Question',
|
|
87
|
+
name: item.question,
|
|
88
|
+
acceptedAnswer: {
|
|
89
|
+
'@type': 'Answer',
|
|
90
|
+
text: item.answer,
|
|
91
|
+
},
|
|
92
|
+
})),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const howToSchema: WithContext<HowTo> = {
|
|
96
|
+
'@context': 'https://schema.org',
|
|
97
|
+
'@type': 'HowTo',
|
|
98
|
+
name: title,
|
|
99
|
+
description,
|
|
100
|
+
step: howTo.map((step, index) => ({
|
|
101
|
+
'@type': 'HowToStep',
|
|
102
|
+
position: index + 1,
|
|
103
|
+
name: step.name,
|
|
104
|
+
text: step.text,
|
|
105
|
+
})),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
109
|
+
'@context': 'https://schema.org',
|
|
110
|
+
'@type': 'SoftwareApplication',
|
|
111
|
+
name: title,
|
|
112
|
+
description,
|
|
113
|
+
applicationCategory: 'BusinessApplication',
|
|
114
|
+
operatingSystem: 'Any',
|
|
115
|
+
offers: {
|
|
116
|
+
'@type': 'Offer',
|
|
117
|
+
price: '0',
|
|
118
|
+
priceCurrency: 'EUR',
|
|
119
|
+
},
|
|
120
|
+
inLanguage: 'de',
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const content: ToolLocaleContent<SerpPixelSimulatorUI> = {
|
|
124
|
+
slug,
|
|
125
|
+
title,
|
|
126
|
+
description,
|
|
127
|
+
ui,
|
|
128
|
+
faqTitle: 'Häufig gestellte Fragen zum SERP-Simulator',
|
|
129
|
+
faq,
|
|
130
|
+
bibliographyTitle: 'Dokumentation zu Suchergebnissen',
|
|
131
|
+
bibliography,
|
|
132
|
+
howTo,
|
|
133
|
+
schemas: [appSchema, faqSchema, howToSchema],
|
|
134
|
+
seo: [
|
|
135
|
+
{
|
|
136
|
+
type: 'title',
|
|
137
|
+
text: 'Hören Sie auf zu raten, wie Ihr Google-Ergebnis aussehen wird',
|
|
138
|
+
level: 2,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
type: 'paragraph',
|
|
142
|
+
html: 'Ein Title-Tag kann in einer Tabelle perfekt aussehen und im Suchergebnis trotzdem versagen. Google reserviert keinen Platz nach Zeichenanzahl; es rendert Text innerhalb einer visuellen Karte. Das bedeutet, dass <strong>GameBob | Indie-Entwicklungsstudio</strong> und ein anderer Titel mit der gleichen Zeichenanzahl je nach Buchstaben, Groß-/Kleinschreibung, Zeichensetzung und Abständen sehr unterschiedliche Breiten einnehmen können.',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
type: 'tip',
|
|
146
|
+
title: 'Die Regel, die wirklich hilft',
|
|
147
|
+
html: 'Schreiben Sie das Snippet so, dass das wichtige Versprechen die Auslassungspunkte überlebt. Platzieren Sie Seitentyp, Suchabsicht und den stärksten Klickgrund vor der Pixelgrenze. Markennamen sind nützlich, sollten aber den Hauptnutzen nicht aus dem Blickfeld drängen.',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
type: 'title',
|
|
151
|
+
text: 'Was der Pixelzähler misst',
|
|
152
|
+
level: 3,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: 'table',
|
|
156
|
+
headers: ['Element', 'Was zählt', 'So nutzen Sie das Ergebnis'],
|
|
157
|
+
rows: [
|
|
158
|
+
['Title-Tag', 'Gerenderte Breite in Pixeln, nicht rohe Zeichenanzahl', 'Halten Sie das Hauptkeyword und das Klickversprechen vor der Kürzung sichtbar.'],
|
|
159
|
+
['Sichtbare URL', 'Visuelles Vertrauen und Themenklarheit', 'Verwenden Sie einen lesbaren Pfad, der das Ziel des Ergebnisses unterstreicht.'],
|
|
160
|
+
['Meta-Beschreibung', 'Ein breiterer Snippet-Bereich mit suchanfragenabhängigem Verhalten', 'Stellen Sie den Nutzen an den Anfang, da Google ihn kürzen oder umschreiben kann.'],
|
|
161
|
+
['Gerätemodus', 'Desktop- und Mobilkarten können sich unterschiedlich anfühlen', 'Überprüfen Sie beide, bevor Sie Metadaten für wichtige Seiten veröffentlichen.'],
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
type: 'title',
|
|
166
|
+
text: 'Warum Zeichenbegrenzungen eine schwache SEO-Gewohnheit sind',
|
|
167
|
+
level: 3,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
type: 'paragraph',
|
|
171
|
+
html: 'Der traditionelle Ratschlag wie "Titel unter 60 Zeichen halten" ist bequem, verschleiert aber das eigentliche Problem. Breite Buchstaben wie W und M, Großbuchstaben, Trennzeichen, Zahlen und lange Markennamen verbrauchen alle unterschiedlichen Platz. Die Pixelmessung macht den Kompromiss sofort sichtbar: Sie können sehen, ob eine Phrase ihren Platz verdient oder Raum von einer stärkeren Botschaft stiehlt.',
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
type: 'title',
|
|
175
|
+
text: 'Ein praktischer Workflow für bessere Snippets',
|
|
176
|
+
level: 3,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
type: 'list',
|
|
180
|
+
items: [
|
|
181
|
+
'<strong>Beginnen Sie mit der Absicht:</strong> beschreiben Sie, was der Nutzer bekommt, nicht nur, wie die Seite heißt.',
|
|
182
|
+
'<strong>Testen Sie den vollständigen Titel:</strong> fügen Sie ihn in den Simulator ein und beobachten Sie den Balken vor der Veröffentlichung.',
|
|
183
|
+
'<strong>Entfernen Sie schwache Wörter:</strong> wenn der Balken rot wird, entfernen Sie Füllwörter, bevor Sie wertvolle Begriffe kürzen.',
|
|
184
|
+
'<strong>Prüfen Sie die Auslassungspunkte:</strong> wenn die gekürzte Vorschau an Bedeutung verliert, schreiben Sie den Titel um, anstatt die Kürzung hinzunehmen.',
|
|
185
|
+
'<strong>Wiederholen Sie dies für die Beschreibung:</strong> stellen Sie sicher, dass der erste Satz das Wertversprechen für sich allein trägt.',
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
type: 'diagnostic',
|
|
190
|
+
variant: 'info',
|
|
191
|
+
title: 'Wenn der Balken rot wird',
|
|
192
|
+
html: 'Ein roter Balken ist keine Strafwarnung. Er bedeutet, dass der aktuelle Text breiter ist als das gewählte visuelle Ziel, sodass der Simulator ihn mit Punkten kürzt. Betrachten Sie dies als redaktionelles Signal: Entscheiden Sie, ob die versteckten Wörter verzichtbar sind oder ob das Snippet eine schärfere Struktur benötigt.',
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
type: 'title',
|
|
196
|
+
text: 'Grenzen, Umschreibungen und realistische Erwartungen',
|
|
197
|
+
level: 3,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
type: 'paragraph',
|
|
201
|
+
html: 'Kein Simulator kann das exakte Snippet garantieren, das Google anzeigen wird. Google kann Titel-Links umschreiben, Suchbegriffe fett formatieren, Seitentext anstelle der Meta-Beschreibung wählen oder verschiedene Snippets für verschiedene Suchanfragen anzeigen. Dieses Tool eignet sich am besten als schneller Schreib- und QA-Schritt: Es erkennt offensichtlichen visuellen Überlauf, bevor die Seite in die Produktion gelangt.',
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
type: 'summary',
|
|
205
|
+
title: 'Beste Nutzung dieses SERP Simulators',
|
|
206
|
+
items: [
|
|
207
|
+
'Nutzen Sie den Pixelbalken, um visuellen Überlauf vor der Veröffentlichung von Metadaten zu erkennen.',
|
|
208
|
+
'Halten Sie die Hauptsuchabsicht und das Klickversprechen vor jeder Auslassung sichtbar.',
|
|
209
|
+
'Rufen Sie Metadaten von URLs ab, die CORS erlauben, und bearbeiten Sie das Ergebnis bei Bedarf manuell.',
|
|
210
|
+
'Betrachten Sie die Vorschau als Schreibhilfe, denn Google kann Snippets pro Suchanfrage weiterhin umschreiben.',
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
};
|