@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-developer",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -19,10 +19,12 @@ 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';
23
+ import { jwtDecoder } from '../tool/jwtDecoder/index';
22
24
 
23
25
  export const developerCategory: DeveloperCategoryEntry = {
24
26
  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],
27
+ tools: [jsonFormatter, svgToCss, aspectRatio, placeholderGenerator, urlEncoderDecoder, duplicateCssRemover, cssToInlineConverter, cssSpecificityCalculator, cronGenerator, keycode, llmCostCalculator, musicalTypography, mobileMockupGenerator, hashGenerator, promptLibrary, colorConverter, readabilityCalculator, svgSanitizer, utmGenerator, urlCleaner, serpPixelSimulator, jwtDecoder],
26
28
  i18n: {
27
29
  de: () => import('./i18n/de').then((m) => m.content),
28
30
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -46,6 +46,10 @@ 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';
51
+ export { jwtDecoder } from './tool/jwtDecoder/entry';
52
+ export type { JwtDecoderLocaleContent } from './tool/jwtDecoder/entry';
49
53
  export { developerCategory } from './category';
50
54
  import { aspectRatio } from './tool/aspectRatio/entry';
51
55
  import { calculadoraTiempoDatos } from './tool/calculadoraTiempoDatos/entry';
@@ -71,4 +75,6 @@ import { svgToCss } from './tool/svgToCss/entry';
71
75
  import { urlCleaner } from './tool/urlCleaner/entry';
72
76
  import { urlEncoderDecoder } from './tool/urlEncoderDecoder/entry';
73
77
  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];
78
+ import { serpPixelSimulator } from './tool/serpPixelSimulator/entry';
79
+ import { jwtDecoder } from './tool/jwtDecoder/entry';
80
+ 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, jwtDecoder];
package/src/index.ts CHANGED
@@ -64,3 +64,7 @@ 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';
69
+
70
+ export { JWT_DECODER_TOOL } from './tool/jwtDecoder/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 26 tools registered', () => {
39
+ expect(ALL_TOOLS.length).toBe(26);
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 26 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(26);
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>
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } 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
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'IETF: JSON Web Token (JWT), RFC 7519',
6
+ url: 'https://datatracker.ietf.org/doc/html/rfc7519',
7
+ },
8
+ {
9
+ name: 'IETF: JSON Web Signature (JWS), RFC 7515',
10
+ url: 'https://datatracker.ietf.org/doc/html/rfc7515',
11
+ }
12
+ ];
@@ -0,0 +1,209 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+ import type { JwtDecoderUI } from './ui';
4
+ import './jwt-decoder-parser-and-claims-inspector.css';
5
+
6
+ interface Props {
7
+ ui?: Record<string, unknown>;
8
+ }
9
+
10
+ const { ui } = Astro.props;
11
+ const t = (ui ?? {}) as JwtDecoderUI;
12
+ ---
13
+
14
+ <div id="jwt-root" class="jwt-root" data-i18n={JSON.stringify(t)}>
15
+ <section class="jwt-card">
16
+ <div class="jwt-input-panel">
17
+ <div class="jwt-token-field">
18
+ <label for="jwt-token" class="jwt-label">
19
+ <Icon name="mdi:key-variant" class="jwt-label-icon" />
20
+ {t.tokenLabel}
21
+ </label>
22
+ <textarea id="jwt-token" class="jwt-token-input" spellcheck="false" placeholder={t.tokenPlaceholder}>{t.sampleToken}</textarea>
23
+ </div>
24
+
25
+ <div class="jwt-action-row">
26
+ <button id="jwt-sample" class="jwt-action jwt-action-primary" type="button">
27
+ <Icon name="mdi:test-tube" class="jwt-button-icon" />
28
+ {t.sampleButton}
29
+ </button>
30
+ <button id="jwt-clear" class="jwt-action" type="button">
31
+ <Icon name="mdi:eraser" class="jwt-button-icon" />
32
+ {t.clearButton}
33
+ </button>
34
+ <button id="jwt-copy-all" class="jwt-action" type="button">
35
+ <Icon name="mdi:content-copy" class="jwt-button-icon" />
36
+ {t.copyAll}
37
+ </button>
38
+ </div>
39
+
40
+ <p id="jwt-status" class="jwt-status jwt-status-waiting" aria-live="polite">{t.statusWaiting}</p>
41
+ <p class="jwt-privacy">{t.privacyNote}</p>
42
+ </div>
43
+
44
+ <div class="jwt-output-grid">
45
+ <article class="jwt-json-panel">
46
+ <div class="jwt-panel-heading">
47
+ <h2>{t.headerTitle}</h2>
48
+ <button class="jwt-copy" type="button" data-copy-target="jwt-header-output" title={t.copyHeader} aria-label={t.copyHeader}>
49
+ <Icon name="mdi:content-copy" />
50
+ </button>
51
+ </div>
52
+ <pre id="jwt-header-output" class="jwt-code">{t.emptyJson}</pre>
53
+ </article>
54
+
55
+ <article class="jwt-json-panel jwt-json-panel-main">
56
+ <div class="jwt-panel-heading">
57
+ <h2>{t.payloadTitle}</h2>
58
+ <button class="jwt-copy" type="button" data-copy-target="jwt-payload-output" title={t.copyPayload} aria-label={t.copyPayload}>
59
+ <Icon name="mdi:content-copy" />
60
+ </button>
61
+ </div>
62
+ <pre id="jwt-payload-output" class="jwt-code">{t.emptyJson}</pre>
63
+ </article>
64
+
65
+ <article class="jwt-json-panel">
66
+ <div class="jwt-panel-heading">
67
+ <h2>{t.signatureTitle}</h2>
68
+ <button class="jwt-copy" type="button" data-copy-target="jwt-signature-output" title={t.copySignature} aria-label={t.copySignature}>
69
+ <Icon name="mdi:content-copy" />
70
+ </button>
71
+ </div>
72
+ <pre id="jwt-signature-output" class="jwt-code jwt-signature-code">{t.signatureMissing}</pre>
73
+ </article>
74
+ </div>
75
+
76
+ <aside class="jwt-claims-panel" aria-label={t.claimsTitle}>
77
+ <h2>{t.claimsTitle}</h2>
78
+ <dl class="jwt-claims">
79
+ <div><dt>{t.algorithmLabel}</dt><dd id="jwt-claim-alg">{t.claimMissing}</dd></div>
80
+ <div><dt>{t.typeLabel}</dt><dd id="jwt-claim-typ">{t.claimMissing}</dd></div>
81
+ <div><dt>{t.issuerLabel}</dt><dd id="jwt-claim-iss">{t.claimMissing}</dd></div>
82
+ <div><dt>{t.subjectLabel}</dt><dd id="jwt-claim-sub">{t.claimMissing}</dd></div>
83
+ <div><dt>{t.audienceLabel}</dt><dd id="jwt-claim-aud">{t.claimMissing}</dd></div>
84
+ <div><dt>{t.issuedAtLabel}</dt><dd id="jwt-claim-iat">{t.claimMissing}</dd></div>
85
+ <div><dt>{t.notBeforeLabel}</dt><dd id="jwt-claim-nbf">{t.claimMissing}</dd></div>
86
+ <div><dt>{t.expiresAtLabel}</dt><dd id="jwt-claim-exp">{t.claimMissing}</dd></div>
87
+ </dl>
88
+ </aside>
89
+ </section>
90
+ </div>
91
+
92
+ <script>
93
+ import { decodeJwt, formatClaimDate, formatClaimValue, formatJson } from './logic';
94
+ import type { JwtDecoderUI } from './ui';
95
+
96
+ const root = document.getElementById('jwt-root') as HTMLElement;
97
+ const t = JSON.parse(root.dataset.i18n ?? '{}') as JwtDecoderUI;
98
+ const tokenInput = document.getElementById('jwt-token') as HTMLTextAreaElement;
99
+ const headerOutput = document.getElementById('jwt-header-output') as HTMLElement;
100
+ const payloadOutput = document.getElementById('jwt-payload-output') as HTMLElement;
101
+ const signatureOutput = document.getElementById('jwt-signature-output') as HTMLElement;
102
+ const status = document.getElementById('jwt-status') as HTMLElement;
103
+ const claims = {
104
+ alg: document.getElementById('jwt-claim-alg'),
105
+ typ: document.getElementById('jwt-claim-typ'),
106
+ iss: document.getElementById('jwt-claim-iss'),
107
+ sub: document.getElementById('jwt-claim-sub'),
108
+ aud: document.getElementById('jwt-claim-aud'),
109
+ iat: document.getElementById('jwt-claim-iat'),
110
+ nbf: document.getElementById('jwt-claim-nbf'),
111
+ exp: document.getElementById('jwt-claim-exp'),
112
+ };
113
+
114
+ function setStatus(text: string, tone: string) {
115
+ status.textContent = text;
116
+ status.className = `jwt-status jwt-status-${tone}`;
117
+ }
118
+
119
+ function setClaim(key: keyof typeof claims, value: string | null) {
120
+ const element = claims[key];
121
+ if (element) element.textContent = value ?? t.claimMissing;
122
+ }
123
+
124
+ function resetOutputs() {
125
+ headerOutput.textContent = t.emptyJson;
126
+ payloadOutput.textContent = t.emptyJson;
127
+ signatureOutput.textContent = t.signatureMissing;
128
+ Object.keys(claims).forEach((key) => setClaim(key as keyof typeof claims, null));
129
+ setStatus(t.statusWaiting, 'waiting');
130
+ }
131
+
132
+ function renderDecodedClaims(decoded: ReturnType<typeof decodeJwt>) {
133
+ headerOutput.textContent = formatJson(decoded.header);
134
+ payloadOutput.textContent = formatJson(decoded.payload);
135
+ signatureOutput.textContent = decoded.signature ? decoded.signature : t.signatureMissing;
136
+ setClaim('alg', formatClaimValue(decoded.header['alg']));
137
+ setClaim('typ', formatClaimValue(decoded.header['typ']));
138
+ setClaim('iss', formatClaimValue(decoded.payload['iss']));
139
+ setClaim('sub', formatClaimValue(decoded.payload['sub']));
140
+ setClaim('aud', formatClaimValue(decoded.payload['aud']));
141
+ setClaim('iat', formatClaimDate(decoded.payload['iat']) ?? formatClaimValue(decoded.payload['iat']));
142
+ setClaim('nbf', formatClaimDate(decoded.payload['nbf']) ?? formatClaimValue(decoded.payload['nbf']));
143
+ setClaim('exp', formatClaimDate(decoded.payload['exp']) ?? formatClaimValue(decoded.payload['exp']));
144
+ }
145
+
146
+ function renderDecodedStatus(decoded: ReturnType<typeof decodeJwt>) {
147
+ if (decoded.expired) {
148
+ setStatus(t.statusExpired, 'expired');
149
+ } else if (decoded.unsigned) {
150
+ setStatus(t.statusUnsigned, 'unsigned');
151
+ } else {
152
+ setStatus(`${t.statusValid} ${t.signaturePresent}`, 'valid');
153
+ }
154
+ }
155
+
156
+ function renderInvalidToken(error: ReturnType<typeof decodeJwt>['error']) {
157
+ resetOutputs();
158
+ headerOutput.textContent = t.invalidTokenTitle;
159
+ payloadOutput.textContent = error === 'invalid-segments' ? t.invalidSegmentError : t.invalidDecodeError;
160
+ setStatus(t.statusInvalid, 'invalid');
161
+ }
162
+
163
+ function updateDecoder() {
164
+ const token = tokenInput.value.trim();
165
+ if (!token) {
166
+ resetOutputs();
167
+ return;
168
+ }
169
+
170
+ const decoded = decodeJwt(token);
171
+ if (!decoded.valid) {
172
+ renderInvalidToken(decoded.error);
173
+ return;
174
+ }
175
+
176
+ renderDecodedClaims(decoded);
177
+ renderDecodedStatus(decoded);
178
+ }
179
+
180
+ async function copyText(text: string) {
181
+ if (!text || text === t.emptyJson || text === t.signatureMissing) return;
182
+ await navigator.clipboard.writeText(text);
183
+ }
184
+
185
+ tokenInput.addEventListener('input', updateDecoder);
186
+ document.getElementById('jwt-sample')?.addEventListener('click', () => {
187
+ tokenInput.value = t.sampleToken;
188
+ updateDecoder();
189
+ });
190
+ document.getElementById('jwt-clear')?.addEventListener('click', () => {
191
+ tokenInput.value = '';
192
+ resetOutputs();
193
+ });
194
+ document.getElementById('jwt-copy-all')?.addEventListener('click', () => {
195
+ copyText([headerOutput.textContent, payloadOutput.textContent, signatureOutput.textContent].join('\n\n'));
196
+ });
197
+ document.querySelectorAll<HTMLButtonElement>('.jwt-copy').forEach((button) => {
198
+ button.addEventListener('click', () => {
199
+ const target = document.getElementById(button.dataset.copyTarget ?? '');
200
+ copyText(target?.textContent ?? '');
201
+ button.dataset.copied = t.copiedLabel;
202
+ window.setTimeout(() => {
203
+ delete button.dataset.copied;
204
+ }, 1400);
205
+ });
206
+ });
207
+
208
+ updateDecoder();
209
+ </script>
@@ -0,0 +1,30 @@
1
+ import type { DeveloperToolEntry, ToolLocaleContent } from '../../types';
2
+
3
+ import type { JwtDecoderUI } from './ui';
4
+
5
+ export type JwtDecoderLocaleContent = ToolLocaleContent<JwtDecoderUI>;
6
+
7
+ export const jwtDecoder: DeveloperToolEntry<JwtDecoderUI> = {
8
+ id: 'jwt-decoder-parser-and-claims-inspector',
9
+ icons: {
10
+ bg: 'mdi:key-chain',
11
+ fg: 'mdi:code-json',
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
+ };