@jjlmoya/utils-developer 1.14.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.
Files changed (200) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/fr.ts +1 -1
  3. package/src/category/i18n/ru.ts +1 -1
  4. package/src/category/index.ts +2 -1
  5. package/src/entries.ts +4 -1
  6. package/src/index.ts +2 -0
  7. package/src/layouts/PreviewLayout.astro +1 -0
  8. package/src/pages/[locale]/[slug].astro +29 -12
  9. package/src/tests/diacritics_density.test.ts +118 -0
  10. package/src/tests/inverted_punctuation.test.ts +113 -0
  11. package/src/tests/locale_completeness.test.ts +2 -3
  12. package/src/tests/no_en_dash.test.ts +73 -0
  13. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  14. package/src/tests/script_density.test.ts +94 -0
  15. package/src/tests/shared-test-helpers.ts +56 -0
  16. package/src/tests/tool_exports.test.ts +34 -0
  17. package/src/tests/tool_validation.test.ts +2 -2
  18. package/src/tool/aspectRatio/component.astro +3 -1
  19. package/src/tool/aspectRatio/i18n/de.ts +1 -1
  20. package/src/tool/aspectRatio/i18n/fr.ts +1 -1
  21. package/src/tool/aspectRatio/i18n/pl.ts +2 -2
  22. package/src/tool/aspectRatio/i18n/ru.ts +4 -4
  23. package/src/tool/aspectRatio/i18n/zh.ts +1 -1
  24. package/src/tool/aspectRatio/seo.astro +2 -1
  25. package/src/tool/calculadoraTiempoDatos/i18n/de.ts +1 -1
  26. package/src/tool/calculadoraTiempoDatos/i18n/pl.ts +1 -1
  27. package/src/tool/calculadoraTiempoDatos/i18n/ru.ts +5 -5
  28. package/src/tool/calculadoraTiempoDatos/i18n/sv.ts +5 -5
  29. package/src/tool/calculadoraTiempoDatos/i18n/zh.ts +1 -1
  30. package/src/tool/calculadoraTiempoDatos/seo.astro +2 -1
  31. package/src/tool/colorConverter/component.astro +3 -3
  32. package/src/tool/colorConverter/i18n/de.ts +1 -1
  33. package/src/tool/colorConverter/i18n/ru.ts +2 -2
  34. package/src/tool/colorConverter/seo.astro +2 -1
  35. package/src/tool/conversorExcelCsvHtml/i18n/es.ts +1 -1
  36. package/src/tool/conversorExcelCsvHtml/i18n/pl.ts +1 -1
  37. package/src/tool/conversorExcelCsvHtml/i18n/ru.ts +2 -2
  38. package/src/tool/conversorExcelCsvHtml/i18n/zh.ts +2 -2
  39. package/src/tool/conversorExcelCsvHtml/seo.astro +2 -1
  40. package/src/tool/cronGenerator/i18n/de.ts +4 -4
  41. package/src/tool/cronGenerator/i18n/en.ts +1 -1
  42. package/src/tool/cronGenerator/i18n/es.ts +2 -2
  43. package/src/tool/cronGenerator/i18n/fr.ts +5 -5
  44. package/src/tool/cronGenerator/i18n/id.ts +1 -1
  45. package/src/tool/cronGenerator/i18n/nl.ts +1 -1
  46. package/src/tool/cronGenerator/i18n/pl.ts +8 -8
  47. package/src/tool/cronGenerator/i18n/pt.ts +1 -1
  48. package/src/tool/cronGenerator/i18n/ru.ts +9 -9
  49. package/src/tool/cronGenerator/i18n/sv.ts +1 -1
  50. package/src/tool/cronGenerator/i18n/zh.ts +3 -3
  51. package/src/tool/cronGenerator/seo.astro +2 -1
  52. package/src/tool/cssSpecificityCalculator/i18n/de.ts +1 -1
  53. package/src/tool/cssSpecificityCalculator/i18n/fr.ts +3 -3
  54. package/src/tool/cssSpecificityCalculator/i18n/pl.ts +2 -2
  55. package/src/tool/cssSpecificityCalculator/i18n/ru.ts +2 -2
  56. package/src/tool/cssSpecificityCalculator/seo.astro +2 -1
  57. package/src/tool/cssToInlineConverter/i18n/de.ts +6 -6
  58. package/src/tool/cssToInlineConverter/i18n/es.ts +1 -1
  59. package/src/tool/cssToInlineConverter/i18n/fr.ts +3 -3
  60. package/src/tool/cssToInlineConverter/seo.astro +2 -1
  61. package/src/tool/duplicateCssRemover/i18n/de.ts +7 -7
  62. package/src/tool/duplicateCssRemover/i18n/fr.ts +1 -1
  63. package/src/tool/duplicateCssRemover/i18n/id.ts +1 -1
  64. package/src/tool/duplicateCssRemover/i18n/nl.ts +1 -1
  65. package/src/tool/duplicateCssRemover/i18n/pl.ts +1 -1
  66. package/src/tool/duplicateCssRemover/i18n/pt.ts +1 -1
  67. package/src/tool/duplicateCssRemover/i18n/ru.ts +3 -3
  68. package/src/tool/duplicateCssRemover/i18n/sv.ts +1 -1
  69. package/src/tool/duplicateCssRemover/seo.astro +2 -1
  70. package/src/tool/generadorSecurityTxt/i18n/de.ts +5 -5
  71. package/src/tool/generadorSecurityTxt/i18n/ru.ts +2 -2
  72. package/src/tool/generadorSecurityTxt/seo.astro +2 -1
  73. package/src/tool/hashGenerator/i18n/ru.ts +5 -5
  74. package/src/tool/hashGenerator/i18n/zh.ts +2 -2
  75. package/src/tool/hashGenerator/seo.astro +2 -1
  76. package/src/tool/inspectorCertificadosSsl/i18n/de.ts +6 -6
  77. package/src/tool/inspectorCertificadosSsl/i18n/ru.ts +5 -5
  78. package/src/tool/inspectorCertificadosSsl/seo.astro +2 -1
  79. package/src/tool/jsonFormatter/i18n/de.ts +4 -4
  80. package/src/tool/jsonFormatter/i18n/en.ts +4 -4
  81. package/src/tool/jsonFormatter/i18n/es.ts +4 -4
  82. package/src/tool/jsonFormatter/i18n/fr.ts +4 -4
  83. package/src/tool/jsonFormatter/i18n/id.ts +4 -4
  84. package/src/tool/jsonFormatter/i18n/it.ts +4 -4
  85. package/src/tool/jsonFormatter/i18n/ja.ts +4 -4
  86. package/src/tool/jsonFormatter/i18n/ko.ts +4 -4
  87. package/src/tool/jsonFormatter/i18n/nl.ts +4 -4
  88. package/src/tool/jsonFormatter/i18n/pl.ts +4 -4
  89. package/src/tool/jsonFormatter/i18n/pt.ts +4 -4
  90. package/src/tool/jsonFormatter/i18n/ru.ts +5 -5
  91. package/src/tool/jsonFormatter/i18n/sv.ts +4 -4
  92. package/src/tool/jsonFormatter/i18n/tr.ts +4 -4
  93. package/src/tool/jsonFormatter/i18n/zh.ts +5 -5
  94. package/src/tool/jsonFormatter/seo.astro +2 -1
  95. package/src/tool/keycode/i18n/de.ts +7 -7
  96. package/src/tool/keycode/i18n/en.ts +5 -5
  97. package/src/tool/keycode/i18n/es.ts +5 -5
  98. package/src/tool/keycode/i18n/fr.ts +7 -7
  99. package/src/tool/keycode/i18n/id.ts +5 -5
  100. package/src/tool/keycode/i18n/it.ts +5 -5
  101. package/src/tool/keycode/i18n/ja.ts +5 -5
  102. package/src/tool/keycode/i18n/ko.ts +5 -5
  103. package/src/tool/keycode/i18n/nl.ts +5 -5
  104. package/src/tool/keycode/i18n/pl.ts +7 -7
  105. package/src/tool/keycode/i18n/pt.ts +5 -5
  106. package/src/tool/keycode/i18n/ru.ts +6 -6
  107. package/src/tool/keycode/i18n/sv.ts +5 -5
  108. package/src/tool/keycode/i18n/tr.ts +5 -5
  109. package/src/tool/keycode/i18n/zh.ts +5 -5
  110. package/src/tool/keycode/seo.astro +2 -1
  111. package/src/tool/llmCostCalculator/component.astro +1 -2
  112. package/src/tool/llmCostCalculator/i18n/de.ts +4 -4
  113. package/src/tool/llmCostCalculator/i18n/en.ts +3 -3
  114. package/src/tool/llmCostCalculator/i18n/fr.ts +3 -3
  115. package/src/tool/llmCostCalculator/i18n/id.ts +3 -3
  116. package/src/tool/llmCostCalculator/i18n/pt.ts +3 -3
  117. package/src/tool/llmCostCalculator/i18n/ru.ts +3 -3
  118. package/src/tool/llmCostCalculator/i18n/sv.ts +3 -3
  119. package/src/tool/llmCostCalculator/i18n/tr.ts +3 -3
  120. package/src/tool/llmCostCalculator/i18n/zh.ts +3 -3
  121. package/src/tool/llmCostCalculator/seo.astro +2 -1
  122. package/src/tool/mobileMockupGenerator/component.astro +4 -5
  123. package/src/tool/mobileMockupGenerator/i18n/es.ts +1 -1
  124. package/src/tool/mobileMockupGenerator/seo.astro +2 -1
  125. package/src/tool/musicalTypography/i18n/de.ts +1 -1
  126. package/src/tool/musicalTypography/i18n/fr.ts +1 -1
  127. package/src/tool/musicalTypography/i18n/id.ts +1 -1
  128. package/src/tool/musicalTypography/i18n/it.ts +1 -1
  129. package/src/tool/musicalTypography/i18n/ko.ts +1 -1
  130. package/src/tool/musicalTypography/i18n/ru.ts +3 -3
  131. package/src/tool/musicalTypography/seo.astro +2 -1
  132. package/src/tool/placeholderGenerator/component.astro +2 -2
  133. package/src/tool/placeholderGenerator/i18n/de.ts +4 -4
  134. package/src/tool/placeholderGenerator/i18n/ru.ts +1 -1
  135. package/src/tool/placeholderGenerator/seo.astro +2 -1
  136. package/src/tool/promptLibrary/i18n/de.ts +1 -1
  137. package/src/tool/promptLibrary/seo.astro +2 -1
  138. package/src/tool/readabilityCalculator/component.astro +4 -4
  139. package/src/tool/readabilityCalculator/i18n/de.ts +2 -2
  140. package/src/tool/readabilityCalculator/seo.astro +2 -1
  141. package/src/tool/readabilityCalculator/visual-readability-calculator-wcag-apca.css +87 -3
  142. package/src/tool/serpPixelSimulator/bibliography.astro +7 -0
  143. package/src/tool/serpPixelSimulator/bibliography.ts +16 -0
  144. package/src/tool/serpPixelSimulator/component.astro +84 -0
  145. package/src/tool/serpPixelSimulator/controller.ts +323 -0
  146. package/src/tool/serpPixelSimulator/entry.ts +30 -0
  147. package/src/tool/serpPixelSimulator/i18n/de.ts +214 -0
  148. package/src/tool/serpPixelSimulator/i18n/en.ts +213 -0
  149. package/src/tool/serpPixelSimulator/i18n/es.ts +214 -0
  150. package/src/tool/serpPixelSimulator/i18n/fr.ts +214 -0
  151. package/src/tool/serpPixelSimulator/i18n/id.ts +214 -0
  152. package/src/tool/serpPixelSimulator/i18n/it.ts +214 -0
  153. package/src/tool/serpPixelSimulator/i18n/ja.ts +214 -0
  154. package/src/tool/serpPixelSimulator/i18n/ko.ts +214 -0
  155. package/src/tool/serpPixelSimulator/i18n/nl.ts +214 -0
  156. package/src/tool/serpPixelSimulator/i18n/pl.ts +214 -0
  157. package/src/tool/serpPixelSimulator/i18n/pt.ts +214 -0
  158. package/src/tool/serpPixelSimulator/i18n/ru.ts +214 -0
  159. package/src/tool/serpPixelSimulator/i18n/sv.ts +214 -0
  160. package/src/tool/serpPixelSimulator/i18n/tr.ts +214 -0
  161. package/src/tool/serpPixelSimulator/i18n/zh.ts +214 -0
  162. package/src/tool/serpPixelSimulator/index.ts +12 -0
  163. package/src/tool/serpPixelSimulator/logic.ts +18 -0
  164. package/src/tool/serpPixelSimulator/seo.astro +12 -0
  165. package/src/tool/serpPixelSimulator/serp-pixel-simulator.css +335 -0
  166. package/src/tool/serpPixelSimulator/ui.ts +33 -0
  167. package/src/tool/svgSanitizer/i18n/de.ts +2 -2
  168. package/src/tool/svgSanitizer/i18n/id.ts +1 -1
  169. package/src/tool/svgSanitizer/i18n/pl.ts +1 -1
  170. package/src/tool/svgSanitizer/i18n/ru.ts +7 -7
  171. package/src/tool/svgSanitizer/i18n/sv.ts +3 -3
  172. package/src/tool/svgSanitizer/i18n/tr.ts +3 -3
  173. package/src/tool/svgSanitizer/i18n/zh.ts +3 -3
  174. package/src/tool/svgSanitizer/seo.astro +2 -1
  175. package/src/tool/svgToCss/i18n/de.ts +2 -2
  176. package/src/tool/svgToCss/i18n/es.ts +1 -1
  177. package/src/tool/svgToCss/i18n/fr.ts +1 -1
  178. package/src/tool/svgToCss/i18n/pl.ts +1 -1
  179. package/src/tool/svgToCss/i18n/ru.ts +2 -2
  180. package/src/tool/svgToCss/i18n/zh.ts +2 -2
  181. package/src/tool/svgToCss/seo.astro +2 -1
  182. package/src/tool/urlCleaner/component.astro +23 -5
  183. package/src/tool/urlCleaner/i18n/de.ts +3 -3
  184. package/src/tool/urlCleaner/i18n/es.ts +1 -1
  185. package/src/tool/urlCleaner/i18n/id.ts +1 -1
  186. package/src/tool/urlCleaner/i18n/nl.ts +1 -1
  187. package/src/tool/urlCleaner/seo.astro +2 -1
  188. package/src/tool/urlCleaner/url-tracking-cleaner.css +92 -2
  189. package/src/tool/urlEncoderDecoder/i18n/de.ts +5 -5
  190. package/src/tool/urlEncoderDecoder/i18n/fr.ts +2 -2
  191. package/src/tool/urlEncoderDecoder/i18n/pl.ts +5 -5
  192. package/src/tool/urlEncoderDecoder/i18n/ru.ts +8 -8
  193. package/src/tool/urlEncoderDecoder/i18n/sv.ts +5 -5
  194. package/src/tool/urlEncoderDecoder/i18n/zh.ts +5 -5
  195. package/src/tool/urlEncoderDecoder/seo.astro +2 -1
  196. package/src/tool/utmGenerator/i18n/de.ts +2 -2
  197. package/src/tool/utmGenerator/i18n/pl.ts +5 -5
  198. package/src/tool/utmGenerator/i18n/ru.ts +6 -6
  199. package/src/tool/utmGenerator/seo.astro +2 -1
  200. package/src/tools.ts +2 -2
@@ -1,14 +1,17 @@
1
1
  .rc-root {
2
2
  width: 100%;
3
+ min-width: 0;
3
4
  }
4
5
 
5
6
  .rc-container {
7
+ container-type: inline-size;
6
8
  display: grid;
7
9
  grid-template-columns: 1fr;
8
10
  gap: 2rem;
9
11
  max-width: 1200px;
10
12
  margin: 0 auto;
11
13
  padding: 1.5rem;
14
+ min-width: 0;
12
15
  }
13
16
 
14
17
  @media (min-width: 1024px) {
@@ -28,6 +31,7 @@
28
31
  flex-direction: column;
29
32
  gap: 2rem;
30
33
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 10px 15px -3px rgba(0, 0, 0, 0.05), inset 0 0 0 1px rgba(255, 255, 255, 0.5);
34
+ min-width: 0;
31
35
  }
32
36
 
33
37
  .theme-dark .rc-card {
@@ -61,7 +65,7 @@
61
65
 
62
66
  .rc-inputs-grid {
63
67
  display: grid;
64
- grid-template-columns: repeat(2, 1fr);
68
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
65
69
  gap: 1.5rem;
66
70
  }
67
71
 
@@ -90,6 +94,7 @@
90
94
  padding: 0.5rem;
91
95
  border-radius: 0.75rem;
92
96
  transition: all 0.2s ease;
97
+ min-width: 0;
93
98
  }
94
99
 
95
100
  .theme-dark .rc-color-input-container {
@@ -127,6 +132,8 @@
127
132
  font-weight: 600;
128
133
  color: rgb(71, 85, 105);
129
134
  flex: 1;
135
+ min-width: 0;
136
+ overflow-wrap: anywhere;
130
137
  }
131
138
 
132
139
  .theme-dark .rc-hex-text {
@@ -193,7 +200,7 @@
193
200
 
194
201
  .rc-comparison-grid {
195
202
  display: grid;
196
- grid-template-columns: repeat(2, 1fr);
203
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
197
204
  gap: 1rem;
198
205
  }
199
206
 
@@ -206,6 +213,7 @@
206
213
  gap: 0.5rem;
207
214
  background: rgba(255, 255, 255, 0.4);
208
215
  border: 1px solid rgba(0, 0, 0, 0.05);
216
+ min-width: 0;
209
217
  }
210
218
 
211
219
  .theme-dark .rc-score-card {
@@ -232,6 +240,8 @@
232
240
  padding: 0.2rem 0.6rem;
233
241
  border-radius: 0.5rem;
234
242
  text-transform: uppercase;
243
+ text-align: center;
244
+ overflow-wrap: anywhere;
235
245
  }
236
246
 
237
247
  .rc-status-pass {
@@ -274,6 +284,7 @@
274
284
  .rc-preview-text {
275
285
  line-height: 1.5;
276
286
  margin: 0;
287
+ overflow-wrap: anywhere;
277
288
  }
278
289
 
279
290
  .rc-apca-breakdown {
@@ -292,6 +303,7 @@
292
303
  .rc-breakdown-item {
293
304
  display: flex;
294
305
  justify-content: space-between;
306
+ gap: 1rem;
295
307
  font-size: 0.875rem;
296
308
  }
297
309
 
@@ -309,6 +321,8 @@
309
321
  display: flex;
310
322
  align-items: center;
311
323
  gap: 0.4rem;
324
+ text-align: right;
325
+ white-space: nowrap;
312
326
  }
313
327
 
314
328
  .rc-check-yes {
@@ -317,4 +331,74 @@
317
331
 
318
332
  .rc-check-no {
319
333
  color: rgb(239, 68, 68);
320
- }
334
+ }
335
+
336
+ @container (max-width: 520px) {
337
+ .rc-container {
338
+ gap: 1rem;
339
+ padding: 0;
340
+ }
341
+
342
+ .rc-card {
343
+ border-radius: 1rem;
344
+ gap: 1.25rem;
345
+ padding: 1rem;
346
+ }
347
+
348
+ .rc-control-section {
349
+ gap: 1.25rem;
350
+ }
351
+
352
+ .rc-group-title {
353
+ margin-bottom: 0.75rem;
354
+ }
355
+
356
+ .rc-inputs-grid,
357
+ .rc-comparison-grid {
358
+ grid-template-columns: 1fr;
359
+ gap: 1rem;
360
+ }
361
+
362
+ .rc-color-input-container {
363
+ align-items: stretch;
364
+ }
365
+
366
+ .rc-color-picker {
367
+ flex: 0 0 2.5rem;
368
+ }
369
+
370
+ .rc-score-card,
371
+ .rc-apca-breakdown {
372
+ padding: 1rem;
373
+ border-radius: 0.75rem;
374
+ }
375
+
376
+ .rc-main-preview-box {
377
+ min-height: 120px;
378
+ padding: 1.25rem;
379
+ border-radius: 0.875rem;
380
+ }
381
+
382
+ .rc-breakdown-item {
383
+ align-items: flex-start;
384
+ flex-direction: column;
385
+ gap: 0.35rem;
386
+ }
387
+
388
+ .rc-item-check {
389
+ text-align: left;
390
+ }
391
+ }
392
+
393
+ @media (max-width: 640px) {
394
+ .rc-container {
395
+ gap: 1rem;
396
+ padding: 0;
397
+ }
398
+
399
+ .rc-card {
400
+ border-radius: 1rem;
401
+ gap: 1.25rem;
402
+ padding: 1rem;
403
+ }
404
+ }
@@ -0,0 +1,7 @@
1
+ ---
2
+ import { bibliography } from './bibliography';
3
+ ---
4
+
5
+ <ul>
6
+ {bibliography.map((item) => <li><a href={item.url} rel="nofollow noopener" target="_blank">{item.name}</a></li>)}
7
+ </ul>
@@ -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
+ };