@jjlmoya/utils-developer 1.15.0 → 1.17.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.
Files changed (63) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +7 -1
  4. package/src/index.ts +4 -0
  5. package/src/tests/locale_completeness.test.ts +2 -3
  6. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  7. package/src/tests/tool_validation.test.ts +2 -2
  8. package/src/tool/aspectRatio/component.astro +3 -1
  9. package/src/tool/colorConverter/component.astro +3 -3
  10. package/src/tool/jwtDecoder/bibliography.astro +14 -0
  11. package/src/tool/jwtDecoder/bibliography.ts +12 -0
  12. package/src/tool/jwtDecoder/component.astro +209 -0
  13. package/src/tool/jwtDecoder/entry.ts +30 -0
  14. package/src/tool/jwtDecoder/i18n/de.ts +248 -0
  15. package/src/tool/jwtDecoder/i18n/en.ts +247 -0
  16. package/src/tool/jwtDecoder/i18n/es.ts +248 -0
  17. package/src/tool/jwtDecoder/i18n/fr.ts +248 -0
  18. package/src/tool/jwtDecoder/i18n/id.ts +248 -0
  19. package/src/tool/jwtDecoder/i18n/it.ts +248 -0
  20. package/src/tool/jwtDecoder/i18n/ja.ts +248 -0
  21. package/src/tool/jwtDecoder/i18n/ko.ts +248 -0
  22. package/src/tool/jwtDecoder/i18n/nl.ts +247 -0
  23. package/src/tool/jwtDecoder/i18n/pl.ts +247 -0
  24. package/src/tool/jwtDecoder/i18n/pt.ts +247 -0
  25. package/src/tool/jwtDecoder/i18n/ru.ts +248 -0
  26. package/src/tool/jwtDecoder/i18n/sv.ts +248 -0
  27. package/src/tool/jwtDecoder/i18n/tr.ts +248 -0
  28. package/src/tool/jwtDecoder/i18n/zh.ts +248 -0
  29. package/src/tool/jwtDecoder/index.ts +11 -0
  30. package/src/tool/jwtDecoder/jwt-decoder-parser-and-claims-inspector.css +358 -0
  31. package/src/tool/jwtDecoder/logic.ts +96 -0
  32. package/src/tool/jwtDecoder/seo.astro +15 -0
  33. package/src/tool/jwtDecoder/ui.ts +38 -0
  34. package/src/tool/llmCostCalculator/component.astro +1 -2
  35. package/src/tool/mobileMockupGenerator/component.astro +4 -5
  36. package/src/tool/placeholderGenerator/component.astro +2 -2
  37. package/src/tool/readabilityCalculator/component.astro +4 -4
  38. package/src/tool/serpPixelSimulator/bibliography.astro +14 -0
  39. package/src/tool/serpPixelSimulator/bibliography.ts +16 -0
  40. package/src/tool/serpPixelSimulator/component.astro +84 -0
  41. package/src/tool/serpPixelSimulator/controller.ts +323 -0
  42. package/src/tool/serpPixelSimulator/entry.ts +30 -0
  43. package/src/tool/serpPixelSimulator/i18n/de.ts +214 -0
  44. package/src/tool/serpPixelSimulator/i18n/en.ts +213 -0
  45. package/src/tool/serpPixelSimulator/i18n/es.ts +214 -0
  46. package/src/tool/serpPixelSimulator/i18n/fr.ts +214 -0
  47. package/src/tool/serpPixelSimulator/i18n/id.ts +214 -0
  48. package/src/tool/serpPixelSimulator/i18n/it.ts +214 -0
  49. package/src/tool/serpPixelSimulator/i18n/ja.ts +214 -0
  50. package/src/tool/serpPixelSimulator/i18n/ko.ts +214 -0
  51. package/src/tool/serpPixelSimulator/i18n/nl.ts +214 -0
  52. package/src/tool/serpPixelSimulator/i18n/pl.ts +214 -0
  53. package/src/tool/serpPixelSimulator/i18n/pt.ts +214 -0
  54. package/src/tool/serpPixelSimulator/i18n/ru.ts +214 -0
  55. package/src/tool/serpPixelSimulator/i18n/sv.ts +214 -0
  56. package/src/tool/serpPixelSimulator/i18n/tr.ts +214 -0
  57. package/src/tool/serpPixelSimulator/i18n/zh.ts +214 -0
  58. package/src/tool/serpPixelSimulator/index.ts +8 -0
  59. package/src/tool/serpPixelSimulator/logic.ts +18 -0
  60. package/src/tool/serpPixelSimulator/seo.astro +15 -0
  61. package/src/tool/serpPixelSimulator/serp-pixel-simulator.css +335 -0
  62. package/src/tool/serpPixelSimulator/ui.ts +33 -0
  63. package/src/tools.ts +3 -2
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { jwtDecoder } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const content = await jwtDecoder.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,38 @@
1
+ export interface JwtDecoderUI extends Record<string, string> {
2
+ tokenLabel: string;
3
+ tokenPlaceholder: string;
4
+ sampleButton: string;
5
+ clearButton: string;
6
+ statusWaiting: string;
7
+ statusValid: string;
8
+ statusInvalid: string;
9
+ statusExpired: string;
10
+ statusUnsigned: string;
11
+ headerTitle: string;
12
+ payloadTitle: string;
13
+ signatureTitle: string;
14
+ claimsTitle: string;
15
+ copyHeader: string;
16
+ copyPayload: string;
17
+ copySignature: string;
18
+ copyAll: string;
19
+ copiedLabel: string;
20
+ invalidTokenTitle: string;
21
+ invalidTokenBody: string;
22
+ invalidSegmentError: string;
23
+ invalidDecodeError: string;
24
+ emptyJson: string;
25
+ signaturePresent: string;
26
+ signatureMissing: string;
27
+ algorithmLabel: string;
28
+ typeLabel: string;
29
+ issuerLabel: string;
30
+ subjectLabel: string;
31
+ audienceLabel: string;
32
+ issuedAtLabel: string;
33
+ notBeforeLabel: string;
34
+ expiresAtLabel: string;
35
+ claimMissing: string;
36
+ privacyNote: string;
37
+ sampleToken: string;
38
+ }
@@ -59,7 +59,7 @@ const t = (ui ?? {}) as LlmCostCalculatorUI;
59
59
  step="100"
60
60
  value="1000"
61
61
  />
62
- <input class="llm-req-num" type="number" id="llm-requests-num" value="1000" min="1" />
62
+ <input class="llm-req-num" type="number" id="llm-requests-num" value="1000" min="1" aria-label={t.labelRequests} />
63
63
  </div>
64
64
  </div>
65
65
  </div>
@@ -232,4 +232,3 @@ const t = (ui ?? {}) as LlmCostCalculatorUI;
232
232
 
233
233
  init();
234
234
  </script>
235
-
@@ -90,16 +90,16 @@ const t = (ui ?? {}) as MobileMockupGeneratorUI;
90
90
  </div>
91
91
  <div id="mmg-presets" class="mmg-presets-row"></div>
92
92
  <div class="mmg-gradient-inputs">
93
- <input type="color" id="mmg-grad-start" value="#6366f1" class="mmg-color-picker" />
93
+ <input type="color" id="mmg-grad-start" value="#6366f1" class="mmg-color-picker" aria-label={t.labelBackground} />
94
94
  <Icon name="mdi:arrow-right" class="mmg-arrow-icon" />
95
- <input type="color" id="mmg-grad-end" value="#a855f7" class="mmg-color-picker" />
95
+ <input type="color" id="mmg-grad-end" value="#a855f7" class="mmg-color-picker" aria-label={t.labelBackground} />
96
96
  </div>
97
97
  <div class="mmg-angle-section">
98
98
  <div class="mmg-angle-row">
99
99
  <span>{t.labelAngle}</span>
100
100
  <span id="mmg-angle-val">135&deg;</span>
101
101
  </div>
102
- <input type="range" id="mmg-grad-angle" min="0" max="360" value="135" class="mmg-range" />
102
+ <input type="range" id="mmg-grad-angle" min="0" max="360" value="135" class="mmg-range" aria-label={t.labelAngle} />
103
103
  </div>
104
104
  </div>
105
105
 
@@ -111,7 +111,7 @@ const t = (ui ?? {}) as MobileMockupGeneratorUI;
111
111
  </button>
112
112
  </div>
113
113
  <div class="mmg-safe-color-row">
114
- <input type="color" id="mmg-safe-color" value="#000000" class="mmg-color-picker-sm" />
114
+ <input type="color" id="mmg-safe-color" value="#000000" class="mmg-color-picker-sm" aria-label={t.labelSafeAreaColor} />
115
115
  <span class="mmg-safe-color-label">{t.labelSafeAreaColor}</span>
116
116
  </div>
117
117
  </div>
@@ -135,4 +135,3 @@ const t = (ui ?? {}) as MobileMockupGeneratorUI;
135
135
  import { App } from './app';
136
136
  new App();
137
137
  </script>
138
-
@@ -43,14 +43,14 @@ const t = (ui ?? {}) as PlaceholderGeneratorUI;
43
43
  <label class="pg-label" for="p-bg-color">{t.labelBgColor}</label>
44
44
  <div class="pg-color-wrapper">
45
45
  <input type="color" id="p-bg-color" value="#e2e8f0" />
46
- <input class="pg-color-text" type="text" id="p-bg-text" value="#e2e8f0" maxlength="7" />
46
+ <input class="pg-color-text" type="text" id="p-bg-text" value="#e2e8f0" maxlength="7" aria-label={t.labelBgColor} />
47
47
  </div>
48
48
  </div>
49
49
  <div class="pg-control-group">
50
50
  <label class="pg-label" for="p-text-color">{t.labelTextColor}</label>
51
51
  <div class="pg-color-wrapper">
52
52
  <input type="color" id="p-text-color" value="#64748b" />
53
- <input class="pg-color-text" type="text" id="p-text-text" value="#64748b" maxlength="7" />
53
+ <input class="pg-color-text" type="text" id="p-text-text" value="#64748b" maxlength="7" aria-label={t.labelTextColor} />
54
54
  </div>
55
55
  </div>
56
56
  </div>
@@ -18,14 +18,14 @@ const t = (ui ?? {}) as ReadabilityCalculatorUI;
18
18
  <div class="rc-input-wrapper">
19
19
  <label class="rc-label-mini">{t.labelText}</label>
20
20
  <div class="rc-color-input-container">
21
- <input type="color" id="rc-text-color" class="rc-color-picker" value="#1e293b" />
21
+ <input type="color" id="rc-text-color" class="rc-color-picker" value="#1e293b" aria-label={t.labelText} />
22
22
  <span id="rc-text-hex" class="rc-hex-text">#1E293B</span>
23
23
  </div>
24
24
  </div>
25
25
  <div class="rc-input-wrapper">
26
26
  <label class="rc-label-mini">{t.labelBg}</label>
27
27
  <div class="rc-color-input-container">
28
- <input type="color" id="rc-bg-color" class="rc-color-picker" value="#ffffff" />
28
+ <input type="color" id="rc-bg-color" class="rc-color-picker" value="#ffffff" aria-label={t.labelBg} />
29
29
  <span id="rc-bg-hex" class="rc-hex-text">#FFFFFF</span>
30
30
  </div>
31
31
  </div>
@@ -38,14 +38,14 @@ const t = (ui ?? {}) as ReadabilityCalculatorUI;
38
38
  <label class="rc-label-mini">{t.labelFontSize}</label>
39
39
  <span id="rc-size-val" class="rc-slider-value">16px</span>
40
40
  </div>
41
- <input type="range" id="rc-size-slider" class="rc-range" min="12" max="72" value="16" />
41
+ <input type="range" id="rc-size-slider" class="rc-range" min="12" max="72" value="16" aria-label={t.labelFontSize} />
42
42
  </div>
43
43
  <div class="rc-slider-group">
44
44
  <div class="rc-slider-header">
45
45
  <label class="rc-label-mini">{t.labelFontWeight}</label>
46
46
  <span id="rc-weight-val" class="rc-slider-value">400</span>
47
47
  </div>
48
- <input type="range" id="rc-weight-slider" class="rc-range" min="100" max="900" step="100" value="400" />
48
+ <input type="range" id="rc-weight-slider" class="rc-range" min="100" max="900" step="100" value="400" aria-label={t.labelFontWeight} />
49
49
  </div>
50
50
  </div>
51
51
 
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { serpPixelSimulator } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'es' } = Astro.props;
11
+ const content = await serpPixelSimulator.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Google Search Central: Control your snippets in search results',
6
+ url: 'https://developers.google.com/search/docs/appearance/snippet',
7
+ },
8
+ {
9
+ name: 'Google Search Central: Influencing title links in search results',
10
+ url: 'https://developers.google.com/search/docs/appearance/title-link',
11
+ },
12
+ {
13
+ name: 'Google Search Central: Create good titles and snippets in search results',
14
+ url: 'https://developers.google.com/search/docs/appearance/title-link#best-practices',
15
+ },
16
+ ];
@@ -0,0 +1,84 @@
1
+ ---
2
+ import type { SerpPixelSimulatorUI } from './ui';
3
+ import './serp-pixel-simulator.css';
4
+
5
+ interface Props {
6
+ ui?: Record<string, unknown>;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ const t = (ui ?? {}) as SerpPixelSimulatorUI;
11
+ ---
12
+
13
+ <div id="sps-root" class="sps-root" data-i18n={JSON.stringify(t)}>
14
+ <section class="sps-card">
15
+ <div class="sps-shell">
16
+ <div class="sps-panel" aria-label={t.titleLabel}>
17
+ <div class="sps-field">
18
+ <label class="sps-label" for="sps-title">{t.titleLabel}</label>
19
+ <input id="sps-title" class="sps-input" type="text" maxlength="180" placeholder={t.titlePlaceholder} value={t.defaultTitle} />
20
+ </div>
21
+
22
+ <div class="sps-field">
23
+ <label class="sps-label" for="sps-url">{t.urlLabel}</label>
24
+ <div class="sps-url-control">
25
+ <input id="sps-url" class="sps-input sps-url-input" type="url" placeholder={t.urlPlaceholder} value={t.defaultUrl} />
26
+ <button id="sps-fetch" class="sps-fetch-button" type="button">{t.fetchButtonLabel}</button>
27
+ </div>
28
+ <p id="sps-fetch-message" class="sps-fetch-message" aria-live="polite"></p>
29
+ </div>
30
+
31
+ <div class="sps-field">
32
+ <label class="sps-label" for="sps-description">{t.descriptionLabel}</label>
33
+ <textarea id="sps-description" class="sps-textarea" maxlength="360" placeholder={t.descriptionPlaceholder}>{t.defaultDescription}</textarea>
34
+ </div>
35
+
36
+ <div class="sps-device">
37
+ <span class="sps-device-label">{t.deviceLabel}</span>
38
+ <div class="sps-segment" role="group" aria-label={t.deviceLabel}>
39
+ <button id="sps-desktop" type="button" aria-pressed="true">{t.desktopLabel}</button>
40
+ <button id="sps-mobile" type="button" aria-pressed="false">{t.mobileLabel}</button>
41
+ </div>
42
+ </div>
43
+
44
+ <div id="sps-title-meter" class="sps-meter">
45
+ <div class="sps-meter-top">
46
+ <span>{t.titlePixelsLabel}</span>
47
+ <span id="sps-title-count">0 {t.pixelUnit} / 580 {t.pixelUnit}</span>
48
+ </div>
49
+ <div class="sps-track"><span id="sps-title-fill" class="sps-fill"></span></div>
50
+ <span id="sps-title-status" class="sps-meter-status">{t.goodLabel}</span>
51
+ </div>
52
+
53
+ <div id="sps-description-meter" class="sps-meter">
54
+ <div class="sps-meter-top">
55
+ <span>{t.descriptionPixelsLabel}</span>
56
+ <span id="sps-description-count">0 {t.pixelUnit} / 920 {t.pixelUnit}</span>
57
+ </div>
58
+ <div class="sps-track"><span id="sps-description-fill" class="sps-fill"></span></div>
59
+ <span id="sps-description-status" class="sps-meter-status">{t.goodLabel}</span>
60
+ </div>
61
+ </div>
62
+
63
+ <div class="sps-preview-wrap" aria-label={t.previewLabel}>
64
+ <h2 class="sps-preview-title">{t.previewLabel}</h2>
65
+ <article id="sps-serp" class="sps-serp">
66
+ <div class="sps-url-line">
67
+ <span id="sps-favicon-fallback" class="sps-favicon" aria-hidden="true">{t.fallbackFaviconText}</span>
68
+ <img id="sps-favicon-image" class="sps-favicon-image sps-hidden" alt="" />
69
+ <div id="sps-preview-url" class="sps-url-text">{t.fallbackUrl}</div>
70
+ </div>
71
+ <h3 id="sps-preview-title" class="sps-serp-title">{t.emptyTitle}</h3>
72
+ <p id="sps-preview-description" class="sps-description">{t.emptyDescription}</p>
73
+ </article>
74
+ </div>
75
+ </div>
76
+ </section>
77
+ </div>
78
+
79
+ <script>
80
+ import { initSerpPixelSimulator } from './controller';
81
+
82
+ const root = document.getElementById('sps-root') as HTMLElement;
83
+ initSerpPixelSimulator(root);
84
+ </script>
@@ -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
+ };