@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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +2 -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/llmCostCalculator/component.astro +1 -2
  11. package/src/tool/mobileMockupGenerator/component.astro +4 -5
  12. package/src/tool/placeholderGenerator/component.astro +2 -2
  13. package/src/tool/readabilityCalculator/component.astro +4 -4
  14. package/src/tool/serpPixelSimulator/bibliography.astro +7 -0
  15. package/src/tool/serpPixelSimulator/bibliography.ts +16 -0
  16. package/src/tool/serpPixelSimulator/component.astro +84 -0
  17. package/src/tool/serpPixelSimulator/controller.ts +323 -0
  18. package/src/tool/serpPixelSimulator/entry.ts +30 -0
  19. package/src/tool/serpPixelSimulator/i18n/de.ts +214 -0
  20. package/src/tool/serpPixelSimulator/i18n/en.ts +213 -0
  21. package/src/tool/serpPixelSimulator/i18n/es.ts +214 -0
  22. package/src/tool/serpPixelSimulator/i18n/fr.ts +214 -0
  23. package/src/tool/serpPixelSimulator/i18n/id.ts +214 -0
  24. package/src/tool/serpPixelSimulator/i18n/it.ts +214 -0
  25. package/src/tool/serpPixelSimulator/i18n/ja.ts +214 -0
  26. package/src/tool/serpPixelSimulator/i18n/ko.ts +214 -0
  27. package/src/tool/serpPixelSimulator/i18n/nl.ts +214 -0
  28. package/src/tool/serpPixelSimulator/i18n/pl.ts +214 -0
  29. package/src/tool/serpPixelSimulator/i18n/pt.ts +214 -0
  30. package/src/tool/serpPixelSimulator/i18n/ru.ts +214 -0
  31. package/src/tool/serpPixelSimulator/i18n/sv.ts +214 -0
  32. package/src/tool/serpPixelSimulator/i18n/tr.ts +214 -0
  33. package/src/tool/serpPixelSimulator/i18n/zh.ts +214 -0
  34. package/src/tool/serpPixelSimulator/index.ts +12 -0
  35. package/src/tool/serpPixelSimulator/logic.ts +18 -0
  36. package/src/tool/serpPixelSimulator/seo.astro +12 -0
  37. package/src/tool/serpPixelSimulator/serp-pixel-simulator.css +335 -0
  38. package/src/tool/serpPixelSimulator/ui.ts +33 -0
  39. package/src/tools.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-developer",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -19,10 +19,11 @@ import { readabilityCalculator } from '../tool/readabilityCalculator/index';
19
19
  import { svgSanitizer } from '../tool/svgSanitizer/index';
20
20
  import { utmGenerator } from '../tool/utmGenerator/index';
21
21
  import { urlCleaner } from '../tool/urlCleaner/index';
22
+ import { serpPixelSimulator } from '../tool/serpPixelSimulator/index';
22
23
 
23
24
  export const developerCategory: DeveloperCategoryEntry = {
24
25
  icon: 'mdi:code-tags',
25
- tools: [jsonFormatter, svgToCss, aspectRatio, placeholderGenerator, urlEncoderDecoder, duplicateCssRemover, cssToInlineConverter, cssSpecificityCalculator, cronGenerator, keycode, llmCostCalculator, musicalTypography, mobileMockupGenerator, hashGenerator, promptLibrary, colorConverter, readabilityCalculator, svgSanitizer, utmGenerator, urlCleaner],
26
+ tools: [jsonFormatter, svgToCss, aspectRatio, placeholderGenerator, urlEncoderDecoder, duplicateCssRemover, cssToInlineConverter, cssSpecificityCalculator, cronGenerator, keycode, llmCostCalculator, musicalTypography, mobileMockupGenerator, hashGenerator, promptLibrary, colorConverter, readabilityCalculator, svgSanitizer, utmGenerator, urlCleaner, serpPixelSimulator],
26
27
  i18n: {
27
28
  de: () => import('./i18n/de').then((m) => m.content),
28
29
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -46,6 +46,8 @@ export { urlEncoderDecoder } from './tool/urlEncoderDecoder/entry';
46
46
  export type { UrlEncoderDecoderLocaleContent } from './tool/urlEncoderDecoder/entry';
47
47
  export { utmGenerator } from './tool/utmGenerator/entry';
48
48
  export type { UtmGeneratorLocaleContent } from './tool/utmGenerator/entry';
49
+ export { serpPixelSimulator } from './tool/serpPixelSimulator/entry';
50
+ export type { SerpPixelSimulatorLocaleContent } from './tool/serpPixelSimulator/entry';
49
51
  export { developerCategory } from './category';
50
52
  import { aspectRatio } from './tool/aspectRatio/entry';
51
53
  import { calculadoraTiempoDatos } from './tool/calculadoraTiempoDatos/entry';
@@ -71,4 +73,5 @@ import { svgToCss } from './tool/svgToCss/entry';
71
73
  import { urlCleaner } from './tool/urlCleaner/entry';
72
74
  import { urlEncoderDecoder } from './tool/urlEncoderDecoder/entry';
73
75
  import { utmGenerator } from './tool/utmGenerator/entry';
74
- export const ALL_ENTRIES = [aspectRatio, calculadoraTiempoDatos, colorConverter, conversorExcelCsvHtml, cronGenerator, cssSpecificityCalculator, cssToInlineConverter, duplicateCssRemover, generadorSecurityTxt, hashGenerator, inspectorCertificadosSsl, jsonFormatter, keycode, llmCostCalculator, mobileMockupGenerator, musicalTypography, placeholderGenerator, promptLibrary, readabilityCalculator, svgSanitizer, svgToCss, urlCleaner, urlEncoderDecoder, utmGenerator];
76
+ import { serpPixelSimulator } from './tool/serpPixelSimulator/entry';
77
+ export const ALL_ENTRIES = [aspectRatio, calculadoraTiempoDatos, colorConverter, conversorExcelCsvHtml, cronGenerator, cssSpecificityCalculator, cssToInlineConverter, duplicateCssRemover, generadorSecurityTxt, hashGenerator, inspectorCertificadosSsl, jsonFormatter, keycode, llmCostCalculator, mobileMockupGenerator, musicalTypography, placeholderGenerator, promptLibrary, readabilityCalculator, svgSanitizer, svgToCss, urlCleaner, urlEncoderDecoder, utmGenerator, serpPixelSimulator];
package/src/index.ts CHANGED
@@ -64,3 +64,5 @@ export { CALCULADORA_TIEMPO_DATOS_TOOL } from './tool/calculadoraTiempoDatos/ind
64
64
  export { INSPECTOR_CERTIFICADOS_SSL_TOOL } from './tool/inspectorCertificadosSsl/index';
65
65
 
66
66
  export { CONVERSOR_EXCEL_CSV_HTML_TOOL } from './tool/conversorExcelCsvHtml/index';
67
+
68
+ export { SERP_PIXEL_SIMULATOR_TOOL } from './tool/serpPixelSimulator/index';
@@ -35,8 +35,7 @@ describe('Locale Completeness Validation', () => {
35
35
  });
36
36
  });
37
37
 
38
- it('all 24 tools registered', () => {
39
- expect(ALL_TOOLS.length).toBe(24);
38
+ it('all 25 tools registered', () => {
39
+ expect(ALL_TOOLS.length).toBe(25);
40
40
  });
41
41
  });
42
-
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import type { SEOSection, ToolLocaleContent } from '../types';
6
+
7
+ const srcDir = join(process.cwd(), 'src');
8
+ const toolDir = join(srcDir, 'tool');
9
+ const geometryReads = [
10
+ 'offsetWidth',
11
+ 'offsetHeight',
12
+ 'offsetTop',
13
+ 'offsetLeft',
14
+ 'clientWidth',
15
+ 'clientHeight',
16
+ 'clientTop',
17
+ 'clientLeft',
18
+ 'scrollWidth',
19
+ 'scrollHeight',
20
+ 'scrollTop',
21
+ 'scrollLeft',
22
+ 'getBoundingClientRect',
23
+ 'getClientRects',
24
+ 'computedStyle',
25
+ 'getComputedStyle',
26
+ ];
27
+ const domWrites = [
28
+ '.style.',
29
+ '.classList.add',
30
+ '.classList.remove',
31
+ '.classList.toggle',
32
+ '.appendChild',
33
+ '.insertBefore',
34
+ '.prepend',
35
+ '.append',
36
+ '.remove',
37
+ '.innerHTML',
38
+ '.textContent',
39
+ '.setAttribute',
40
+ ];
41
+
42
+ function findFiles(dir: string, extensions: string[]): string[] {
43
+ const files: string[] = [];
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const fullPath = join(dir, entry.name);
46
+ if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
47
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
48
+ }
49
+ return files;
50
+ }
51
+
52
+ function relativePath(file: string): string {
53
+ return relative(process.cwd(), file).replace(/\\/g, '/');
54
+ }
55
+
56
+ function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
57
+ return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
58
+ }
59
+
60
+ function attrValue(tag: string, attr: string): string | null {
61
+ const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
62
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
63
+ }
64
+
65
+ function booleanAttr(tag: string, attr: string): boolean {
66
+ return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
67
+ }
68
+
69
+ function controlStartIndex(content: string, tag: RegExpMatchArray): number {
70
+ return tag.index ?? content.indexOf(tag[0]);
71
+ }
72
+
73
+ function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
74
+ const index = controlStartIndex(content, tag);
75
+ const before = content.slice(0, index);
76
+ const labelOpen = before.lastIndexOf('<label');
77
+ const labelClose = before.lastIndexOf('</label>');
78
+ const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
79
+ return labelOpen > labelClose && nextLabelClose !== -1;
80
+ }
81
+
82
+ function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
83
+ const source = tag[0];
84
+ if (attrValue(source, 'aria-label')) return true;
85
+ if (attrValue(source, 'aria-labelledby')) return true;
86
+ const id = attrValue(source, 'id');
87
+ if (id && hasExplicitLabel(content, id)) return true;
88
+ return hasWrappingLabel(content, tag);
89
+ }
90
+
91
+ function isVisuallyHiddenFileInput(tag: string): boolean {
92
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
93
+ const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
94
+ const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
95
+ return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
96
+ }
97
+
98
+ function isIgnoredInput(tag: string): boolean {
99
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
100
+ return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
101
+ }
102
+
103
+ function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
104
+ return findFormControls(content, tagName)
105
+ .filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
106
+ .filter((tag) => !hasAccessibleName(content, tag))
107
+ .map((tag) => tag[0]);
108
+ }
109
+
110
+ function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
111
+ return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
112
+ }
113
+
114
+ function hasExplicitLabel(content: string, id: string): boolean {
115
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ return (
117
+ new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
118
+ || new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
119
+ );
120
+ }
121
+
122
+ function headingLevels(sections: SEOSection[]): number[] {
123
+ return sections
124
+ .filter((section) => section.type === 'title')
125
+ .map((section) => Number('level' in section ? section.level : 0))
126
+ .filter((level) => Number.isInteger(level) && level > 0);
127
+ }
128
+
129
+ function findHeadingLevelJumps(levels: number[]): string[] {
130
+ const failures: string[] = [];
131
+ levels.forEach((level, index) => {
132
+ const previous = index === 0 ? 1 : levels[index - 1];
133
+ if (previous && level > previous + 1) {
134
+ failures.push(`h${previous} -> h${level}`);
135
+ }
136
+ });
137
+ return failures;
138
+ }
139
+
140
+ function hasDomWriteBeforeGeometryRead(content: string): boolean {
141
+ const normalized = content.replace(/\s+/g, ' ');
142
+ return domWrites.some((write) => {
143
+ const writeIndex = normalized.indexOf(write);
144
+ if (writeIndex === -1) return false;
145
+ return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
146
+ });
147
+ }
148
+
149
+ describe('PageSpeed best-practice guards', () => {
150
+ const astroToolFiles = findFiles(toolDir, ['.astro']);
151
+ const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
152
+
153
+ astroToolFiles.forEach((file) => {
154
+ const displayPath = relativePath(file);
155
+
156
+ it(`${displayPath} labels every input with an explicit label`, () => {
157
+ const content = readFileSync(file, 'utf-8');
158
+ const failures = controlFailures(content, 'input');
159
+
160
+ expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
161
+ });
162
+
163
+ it(`${displayPath} labels every select with an explicit label`, () => {
164
+ const content = readFileSync(file, 'utf-8');
165
+ const failures = controlFailures(content, 'select');
166
+
167
+ expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
168
+ });
169
+ });
170
+
171
+ ALL_TOOLS.forEach((tool) => {
172
+ Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
173
+ it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
174
+ if (!loader) return;
175
+ const content = (await loader()) as ToolLocaleContent;
176
+ const levels = headingLevels(content.seo);
177
+ const failures = findHeadingLevelJumps(levels);
178
+
179
+ expect(
180
+ failures,
181
+ `SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
182
+ ).toEqual([]);
183
+ });
184
+ });
185
+ });
186
+
187
+ scriptFiles.forEach((file) => {
188
+ const displayPath = relativePath(file);
189
+
190
+ it(`${displayPath} avoids static forced-reflow patterns`, () => {
191
+ const content = readFileSync(file, 'utf-8');
192
+ expect(
193
+ hasDomWriteBeforeGeometryRead(content),
194
+ `${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
195
+ ).toBe(false);
196
+ });
197
+ });
198
+ });
@@ -4,8 +4,8 @@ import { developerCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 24 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(24);
7
+ it('should have 25 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(25);
9
9
  });
10
10
 
11
11
  it('developerCategory should be defined', () => {
@@ -137,12 +137,14 @@ const t = (ui ?? {}) as AspectRatioUI;
137
137
  const { rw, rh, cw, ch } = getInputValues();
138
138
  const divisor = calculateGcd(Math.round(rw * 100), Math.round(rh * 100)) / 100;
139
139
  const ratioText = `${Math.round(rw / divisor)}:${Math.round(rh / divisor)}`;
140
+ const viewWidth = viewport.clientWidth - 80;
141
+ const viewHeight = viewport.clientHeight - 80;
142
+ const { w, h } = computeBoxDimensions(rw, rh, viewWidth, viewHeight);
140
143
 
141
144
  arcBoxRatio.textContent = ratioText;
142
145
  arcBoxRes.innerHTML = `${cw} &times; ${ch}px`;
143
146
  arcBadge.textContent = ratioText;
144
147
 
145
- const { w, h } = computeBoxDimensions(rw, rh, viewport.clientWidth - 80, viewport.clientHeight - 80);
146
148
  arcBox.style.width = w + 'px';
147
149
  arcBox.style.height = h + 'px';
148
150
  };
@@ -53,21 +53,21 @@ const t = (ui ?? {}) as ColorConverterUI;
53
53
  <span>{t.labelR}</span>
54
54
  <span id="cc-r-val">255</span>
55
55
  </div>
56
- <input type="range" id="cc-r-slider" class="cc-range" min="0" max="255" value="255" />
56
+ <input type="range" id="cc-r-slider" class="cc-range" min="0" max="255" value="255" aria-label={t.labelR} />
57
57
  </div>
58
58
  <div class="cc-slider-group">
59
59
  <div class="cc-slider-header">
60
60
  <span>{t.labelG}</span>
61
61
  <span id="cc-g-val">255</span>
62
62
  </div>
63
- <input type="range" id="cc-g-slider" class="cc-range" min="0" max="255" value="255" />
63
+ <input type="range" id="cc-g-slider" class="cc-range" min="0" max="255" value="255" aria-label={t.labelG} />
64
64
  </div>
65
65
  <div class="cc-slider-group">
66
66
  <div class="cc-slider-header">
67
67
  <span>{t.labelB}</span>
68
68
  <span id="cc-b-val">255</span>
69
69
  </div>
70
- <input type="range" id="cc-b-slider" class="cc-range" min="0" max="255" value="255" />
70
+ <input type="range" id="cc-b-slider" class="cc-range" min="0" max="255" value="255" aria-label={t.labelB} />
71
71
  </div>
72
72
  </div>
73
73
  </div>
@@ -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,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>