@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.
- package/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +7 -1
- package/src/index.ts +4 -0
- package/src/tests/locale_completeness.test.ts +2 -3
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/aspectRatio/component.astro +3 -1
- package/src/tool/colorConverter/component.astro +3 -3
- package/src/tool/jwtDecoder/bibliography.astro +14 -0
- package/src/tool/jwtDecoder/bibliography.ts +12 -0
- package/src/tool/jwtDecoder/component.astro +209 -0
- package/src/tool/jwtDecoder/entry.ts +30 -0
- package/src/tool/jwtDecoder/i18n/de.ts +248 -0
- package/src/tool/jwtDecoder/i18n/en.ts +247 -0
- package/src/tool/jwtDecoder/i18n/es.ts +248 -0
- package/src/tool/jwtDecoder/i18n/fr.ts +248 -0
- package/src/tool/jwtDecoder/i18n/id.ts +248 -0
- package/src/tool/jwtDecoder/i18n/it.ts +248 -0
- package/src/tool/jwtDecoder/i18n/ja.ts +248 -0
- package/src/tool/jwtDecoder/i18n/ko.ts +248 -0
- package/src/tool/jwtDecoder/i18n/nl.ts +247 -0
- package/src/tool/jwtDecoder/i18n/pl.ts +247 -0
- package/src/tool/jwtDecoder/i18n/pt.ts +247 -0
- package/src/tool/jwtDecoder/i18n/ru.ts +248 -0
- package/src/tool/jwtDecoder/i18n/sv.ts +248 -0
- package/src/tool/jwtDecoder/i18n/tr.ts +248 -0
- package/src/tool/jwtDecoder/i18n/zh.ts +248 -0
- package/src/tool/jwtDecoder/index.ts +11 -0
- package/src/tool/jwtDecoder/jwt-decoder-parser-and-claims-inspector.css +358 -0
- package/src/tool/jwtDecoder/logic.ts +96 -0
- package/src/tool/jwtDecoder/seo.astro +15 -0
- package/src/tool/jwtDecoder/ui.ts +38 -0
- package/src/tool/llmCostCalculator/component.astro +1 -2
- package/src/tool/mobileMockupGenerator/component.astro +4 -5
- package/src/tool/placeholderGenerator/component.astro +2 -2
- package/src/tool/readabilityCalculator/component.astro +4 -4
- package/src/tool/serpPixelSimulator/bibliography.astro +14 -0
- package/src/tool/serpPixelSimulator/bibliography.ts +16 -0
- package/src/tool/serpPixelSimulator/component.astro +84 -0
- package/src/tool/serpPixelSimulator/controller.ts +323 -0
- package/src/tool/serpPixelSimulator/entry.ts +30 -0
- package/src/tool/serpPixelSimulator/i18n/de.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/en.ts +213 -0
- package/src/tool/serpPixelSimulator/i18n/es.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/fr.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/id.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/it.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/ja.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/ko.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/nl.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/pl.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/pt.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/ru.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/sv.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/tr.ts +214 -0
- package/src/tool/serpPixelSimulator/i18n/zh.ts +214 -0
- package/src/tool/serpPixelSimulator/index.ts +8 -0
- package/src/tool/serpPixelSimulator/logic.ts +18 -0
- package/src/tool/serpPixelSimulator/seo.astro +15 -0
- package/src/tool/serpPixelSimulator/serp-pixel-simulator.css +335 -0
- package/src/tool/serpPixelSimulator/ui.ts +33 -0
- package/src/tools.ts +3 -2
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -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
|
-
|
|
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';
|
|
@@ -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
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
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} × ${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
|
+
};
|