@jjlmoya/utils-finance 1.21.0 → 1.22.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 CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-finance",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./data": "./src/data.ts",
10
- "./entries": "./src/entries.ts"
10
+ "./entries": "./src/entries.ts",
11
+ "./runtime/*": "./src/tool/*/index.ts",
12
+ "./category-seo": "./src/category/seo.astro"
11
13
  },
12
14
  "files": [
13
15
  "src",
@@ -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.skip('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
+ });
@@ -0,0 +1,32 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+
4
+ export const repositoryRoot = process.cwd();
5
+ export const toolRoot = join(repositoryRoot, 'src', 'tool');
6
+
7
+ export function listToolDirectories(): string[] {
8
+ return readdirSync(toolRoot)
9
+ .map((name) => join(toolRoot, name))
10
+ .filter((path) => statSync(path).isDirectory());
11
+ }
12
+
13
+ export function findFiles(directory: string, extensions: string[]): string[] {
14
+ const files: string[] = [];
15
+
16
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
17
+ const path = join(directory, entry.name);
18
+ if (entry.isDirectory()) files.push(...findFiles(path, extensions));
19
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(path);
20
+ }
21
+
22
+ return files;
23
+ }
24
+
25
+ export function read(path: string): string {
26
+ return readFileSync(path, 'utf8');
27
+ }
28
+
29
+ export function displayPath(path: string): string {
30
+ return relative(repositoryRoot, path).replace(/\\/g, '/');
31
+ }
32
+
@@ -0,0 +1,7 @@
1
+ import { describe, it } from 'vitest';
2
+
3
+ describe('QA: bibliography links are specific and usable', () => {
4
+ it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
5
+ });
6
+ });
7
+
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { displayPath, listToolDirectories, read } from './qa-test-helpers';
5
+
6
+ const evidenceClaims = [
7
+ /\bofficial algorithm\b/i,
8
+ /\balgoritmo oficial\b/i,
9
+ /\b(?:records|data|rates|tables) updated(?:\s+(?:to|through)\s+20\d{2})?\b/i,
10
+ /\b(?:registros|datos|tasas|tablas) actualizad[oa]s?(?:\s+(?:a|hasta)\s+20\d{2})?\b/i,
11
+ /\b(?:validated|verified) (?:method|algorithm|calculation)\b/i,
12
+ /\b(?:método|algoritmo|cálculo) (?:validado|verificado)\b/i,
13
+ /\b(?:guarantees|ensures)\b/i,
14
+ /\bgarantiza\b/i,
15
+ /\bofficial (?:data|rate|calculation)\b/i,
16
+ /\b(?:datos|tasa|cálculo) oficial(?:es)?\b/i,
17
+ ];
18
+
19
+ const requiredEvidenceFields = [
20
+ 'reviewedAt',
21
+ 'methodology',
22
+ 'sources',
23
+ 'referenceCases',
24
+ 'limitations',
25
+ ];
26
+
27
+ describe('QA: strong factual claims are traceable', () => {
28
+ it('requires machine-readable validation evidence beside tools making strong claims', () => {
29
+ const failures: string[] = [];
30
+
31
+ for (const directory of listToolDirectories()) {
32
+ const localePaths = ['en', 'es']
33
+ .map((locale) => join(directory, 'i18n', `${locale}.ts`))
34
+ .filter(existsSync);
35
+ const claims = localePaths.flatMap((path) => {
36
+ const source = read(path);
37
+ return evidenceClaims
38
+ .filter((pattern) => pattern.test(source))
39
+ .map((pattern) => `${displayPath(path)} matches ${pattern.source}`);
40
+ });
41
+ if (claims.length === 0) continue;
42
+
43
+ const evidencePath = join(directory, 'validation.ts');
44
+ if (!existsSync(evidencePath)) {
45
+ failures.push(`${displayPath(evidencePath)} missing; claims: ${claims.join(' | ')}`);
46
+ continue;
47
+ }
48
+
49
+ const evidence = read(evidencePath);
50
+ const missingFields = requiredEvidenceFields.filter(
51
+ (field) => !new RegExp(`\\b${field}\\s*:`).test(evidence),
52
+ );
53
+ if (missingFields.length > 0) {
54
+ failures.push(`${displayPath(evidencePath)} missing fields: ${missingFields.join(', ')}`);
55
+ }
56
+ }
57
+
58
+ expect(
59
+ failures,
60
+ [
61
+ 'Claims such as official, validated, guaranteed or updated need inspectable evidence.',
62
+ 'Add validation.ts with reviewedAt, methodology, sources, referenceCases and limitations,',
63
+ 'or weaken/remove the unsupported claim.',
64
+ ...failures,
65
+ ].join('\n'),
66
+ ).toEqual([]);
67
+ });
68
+ });
69
+
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ function hasDedicatedLogicTest(toolDirectory: string): boolean {
13
+ const localCandidates = [
14
+ join(toolDirectory, 'logic.test.ts'),
15
+ join(toolDirectory, 'logic.spec.ts'),
16
+ join(toolDirectory, '__tests__', 'logic.test.ts'),
17
+ join(toolDirectory, '__tests__', 'logic.spec.ts'),
18
+ ];
19
+ if (localCandidates.some(existsSync)) return true;
20
+
21
+ const toolName = basename(toolDirectory);
22
+ const centralTests = findFiles(join(repositoryRoot, 'src', 'tests'), ['.test.ts', '.spec.ts']);
23
+ return centralTests.some((testPath) => {
24
+ const source = read(testPath).replace(/\\/g, '/');
25
+ return source.includes(`/tool/${toolName}/logic`) || source.includes(`../tool/${toolName}/logic`);
26
+ });
27
+ }
28
+
29
+ describe.skip('QA: calculation logic has reference tests', () => {
30
+ it('gives every public calculator logic module its own behavioral test suite', () => {
31
+ const failures = listToolDirectories()
32
+ .filter((directory) => existsSync(join(directory, 'logic.ts')))
33
+ .filter((directory) => !hasDedicatedLogicTest(directory))
34
+ .map((directory) => `${displayPath(join(directory, 'logic.ts'))} -> no test imports this module`);
35
+
36
+ expect(
37
+ failures,
38
+ [
39
+ 'Add behavioral tests through each logic module public API.',
40
+ 'At minimum cover a documented reference case, boundaries, invalid input and invariants.',
41
+ ...failures,
42
+ ].join('\n'),
43
+ ).toEqual([]);
44
+ });
45
+ });
46
+
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ interface VisibleLiteral {
13
+ line: number;
14
+ value: string;
15
+ }
16
+
17
+ const domTextAssignment = /\.(?:innerText|textContent|innerHTML)\s*=/;
18
+ const quotedLiteral = /(['"`])((?:\\.|(?!\1).)*)\1/g;
19
+
20
+ function visibleWords(value: string): string {
21
+ return value
22
+ .replace(/\$\{[^}]*\}/g, ' ')
23
+ .replace(/<[^>]+>/g, ' ')
24
+ .replace(/&[a-z]+;/gi, ' ')
25
+ .replace(/[\d\p{P}\p{S}_]+/gu, ' ')
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+
30
+ function isTechnicalLiteral(value: string): boolean {
31
+ return ['.', '#', '['].some((prefix) => value.startsWith(prefix))
32
+ || /^data-[a-z0-9-]+$/i.test(value);
33
+ }
34
+
35
+ function hardcodedVisibleLiterals(source: string): VisibleLiteral[] {
36
+ const failures: VisibleLiteral[] = [];
37
+
38
+ source.split(/\r?\n/).forEach((line, index) => {
39
+ if (!domTextAssignment.test(line)) return;
40
+
41
+ const assignment = line.slice(line.search(domTextAssignment));
42
+ for (const match of assignment.matchAll(quotedLiteral)) {
43
+ if (isTechnicalLiteral(match[2])) continue;
44
+ const words = visibleWords(match[2]);
45
+ if (words && /\p{L}/u.test(words)) {
46
+ failures.push({ line: index + 1, value: match[2] });
47
+ }
48
+ }
49
+ });
50
+
51
+ return failures;
52
+ }
53
+
54
+ function runtimeSources(toolDirectory: string): string {
55
+ return findFiles(toolDirectory, ['.astro', '.ts', '.js'])
56
+ .filter((path) => !path.includes(`${join(toolDirectory, 'i18n')}`))
57
+ .filter((path) => basename(path) !== 'ui.ts')
58
+ .map(read)
59
+ .join('\n');
60
+ }
61
+
62
+ describe.skip('QA: runtime copy is localized', () => {
63
+ const componentFiles = findFiles(join(repositoryRoot, 'src', 'tool'), ['.astro']);
64
+
65
+ it('does not write user-facing string literals directly into the DOM', () => {
66
+ const failures = componentFiles.flatMap((path) =>
67
+ hardcodedVisibleLiterals(read(path)).map(
68
+ ({ line, value }) => `${displayPath(path)}:${line} -> ${JSON.stringify(value)}`,
69
+ ),
70
+ );
71
+
72
+ expect(
73
+ failures,
74
+ `Move visible browser copy to the tool UI locale contract:\n${failures.join('\n')}`,
75
+ ).toEqual([]);
76
+ });
77
+
78
+ it('uses every string declared by each UI locale contract', () => {
79
+ const failures: string[] = [];
80
+
81
+ for (const directory of listToolDirectories()) {
82
+ const uiPath = join(directory, 'ui.ts');
83
+ if (!existsSync(uiPath)) continue;
84
+
85
+ const keys = Array.from(read(uiPath).matchAll(/^\s*(\w+)\??:\s*string\s*;/gm), (match) => match[1]);
86
+ const runtime = runtimeSources(directory);
87
+ const unused = keys.filter((key) => !new RegExp(`\\b${key}\\b`).test(runtime));
88
+
89
+ if (unused.length > 0) {
90
+ failures.push(`${displayPath(uiPath)} -> unused: ${unused.join(', ')}`);
91
+ }
92
+ }
93
+
94
+ expect(
95
+ failures,
96
+ `Unused locale keys are dead copy or may mean the browser bypasses translations:\n${failures.join('\n')}`,
97
+ ).toEqual([]);
98
+ });
99
+ });
@@ -10,7 +10,7 @@ interface Props {
10
10
  const { ui } = Astro.props;
11
11
  ---
12
12
 
13
- <div class="ctf-container" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}>
13
+ <div class="ctf-container" data-title={ui.labelTitle} data-desc={ui.labelDescription} data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}>
14
14
  <div class="ctf-wrapper">
15
15
  <aside class="ctf-sidebar">
16
16
  <div class="ctf-section-title">
@@ -56,7 +56,7 @@ const { ui } = Astro.props;
56
56
  <div class="ctf-input-field" id="amount-container">
57
57
  <label for="claim-amount">{ui.labelClaimAmount}</label>
58
58
  <div style="position: relative;">
59
- <input type="number" id="claim-amount" class="ctf-input-control" value="30000" step="1000" />
59
+ <input type="number" id="claim-amount" aria-label={ui.labelClaimAmount} class="ctf-input-control" value="30000" step="1000" />
60
60
  <span style="position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); font-weight: 700; color: var(--ctf-accent);">{ui.currencySymbol}</span>
61
61
  </div>
62
62
  </div>
@@ -128,6 +128,14 @@ const { ui } = Astro.props;
128
128
  }).format(val);
129
129
  }
130
130
 
131
+ function setContent(el: HTMLElement | null, text: string) {
132
+ if (el) el.textContent = text;
133
+ }
134
+
135
+ function setDisplay(el: HTMLElement | null, display: string) {
136
+ if (el) el.style.display = display;
137
+ }
138
+
131
139
  function filterProcedureOptions(jurisdictionValue: string): string {
132
140
  if (!procedure) return '';
133
141
  const opts = procedure.options;
@@ -156,12 +164,16 @@ const { ui } = Astro.props;
156
164
  calculate();
157
165
  }
158
166
 
167
+ function resolveBaseText(result: ReturnType<typeof CourtFeeLogic.calculateFee>): string {
168
+ return result.isExempt ? (exentoTag?.textContent || "") : formatCurrency(result.taxableBase);
169
+ }
170
+
159
171
  function displayResults(result: ReturnType<typeof CourtFeeLogic.calculateFee>) {
160
- if (exentoTag) exentoTag.style.display = result.isExempt ? 'inline-block' : 'none';
161
- if (totalDisp) totalDisp.textContent = formatCurrency(result.totalFee);
162
- if (fixedDisp) fixedDisp.textContent = formatCurrency(result.fixedFee);
163
- if (variableDisp) variableDisp.textContent = formatCurrency(result.variableFee);
164
- if (baseDisp) baseDisp.textContent = result.isExempt ? 'Exento' : formatCurrency(result.taxableBase);
172
+ setDisplay(exentoTag, result.isExempt ? 'inline-block' : 'none');
173
+ setContent(totalDisp, formatCurrency(result.totalFee));
174
+ setContent(fixedDisp, formatCurrency(result.fixedFee));
175
+ setContent(variableDisp, formatCurrency(result.variableFee));
176
+ setContent(baseDisp, resolveBaseText(result));
165
177
  }
166
178
 
167
179
  function calculate() {
@@ -165,7 +165,7 @@ const seoData = [
165
165
  },
166
166
  {
167
167
  type: 'paragraph',
168
- html: 'Our <strong>Spanish Court Fees Calculator</strong> ensures that professionals and companies operate with data updated to 2026, meeting the technical rigor required by modern legal practice.',
168
+ html: 'Our <strong>Spanish Court Fees Calculator</strong> helps professionals and companies operate with data reference for 2026, meeting the technical rigor required by modern legal practice.',
169
169
  },
170
170
  ];
171
171
 
@@ -165,7 +165,7 @@ const seoData = [
165
165
  },
166
166
  {
167
167
  type: 'paragraph',
168
- html: 'Nuestra <strong>Calculadora de Tasas Judiciales</strong> garantiza que profesionales y empresas operen con datos actualizados a 2026, cumpliendo con el rigor técnico que exige la práctica jurídica moderna.',
168
+ html: 'Nuestra <strong>Calculadora de Tasas Judiciales</strong> ayuda a que profesionales y empresas operen con datos de referencia para 2026, cumpliendo con el rigor técnico que exige la práctica jurídica moderna.',
169
169
  },
170
170
  ];
171
171
 
@@ -30,6 +30,10 @@ const { ui } = Astro.props;
30
30
  data-label-delete-debt={ui.labelDeleteDebt}
31
31
  data-label-snowball-method={ui.labelSnowballMethod}
32
32
  data-label-avalanche-method={ui.labelAvalancheMethod}
33
+ data-label-description={ui.labelDescription}
34
+ data-label-debt-balance-placeholder={ui.labelDebtBalancePlaceholder}
35
+ data-label-after-paying={ui.labelAfterPaying}
36
+ data-label-invalid-input={ui.labelInvalidInput}
33
37
  >
34
38
  <div class="snow-main-card">
35
39
  <aside class="snow-sidebar">
@@ -23,6 +23,11 @@ const t = (ui ?? {}) as FinancialFreedomUI;
23
23
  data-label-yellow-zone={t.labelYellowZone}
24
24
  data-label-green-zone={t.labelGreenZone}
25
25
  data-locale={Astro.props.locale || 'en'}
26
+ data-label-title={t.labelTitle}
27
+ data-label-currency={t.labelCurrency}
28
+ data-label-daily-burn={t.labelDailyBurn}
29
+ data-label-status={t.labelStatus}
30
+ data-label-burn-rate-title={t.labelBurnRateTitle}
26
31
  >
27
32
  <div class="ff-card">
28
33
  <div class="ff-calculator-grid">
@@ -18,6 +18,9 @@ const { ui } = Astro.props;
18
18
  data-already-fi={ui.labelAlreadyFI}
19
19
  data-unachievable={ui.labelUnachievable}
20
20
  data-label-added={ui.labelAdded}
21
+ data-label-title={ui.labelTitle}
22
+ data-label-description={ui.labelDescription}
23
+ data-label-magic-number-desc={ui.labelMagicNumberDesc}
21
24
  >
22
25
  <div class="fire-grid">
23
26
  <div class="fire-panel">
@@ -9,7 +9,15 @@ interface Props {
9
9
  const { ui } = Astro.props;
10
10
  ---
11
11
 
12
- <div class="ibs-container" data-icon-bank="mdi:bank-outline" data-icon-card="mdi:credit-card-chip-outline" data-icon-sync="mdi:sync" data-icon-copy="mdi:content-copy" data-icon-check="mdi:check" data-icon-alert="mdi:alert-circle" data-icon-bank-check="mdi:bank-check">
12
+ <div class="ibs-container" data-icon-bank="mdi:bank-outline" data-icon-card="mdi:credit-card-chip-outline" data-icon-sync="mdi:sync" data-icon-copy="mdi:content-copy" data-icon-check="mdi:check" data-icon-alert="mdi:alert-circle" data-icon-bank-check="mdi:bank-check"
13
+ data-label-copied={ui.labelCopied}
14
+ data-label-bank-not-identified={ui.labelBankNotIdentified}
15
+ data-label-bank-outside-spain={ui.labelBankOutsideSpain}
16
+ data-label-invalid-iban={ui.labelInvalidIBAN}
17
+ data-label-formatting-ok={ui.labelFormattingOK}
18
+ data-label-invalid={ui.labelInvalid || "Inválido"}
19
+ data-label-unknown-bic={ui.labelUnknownBIC || "BIC Desconocido"}
20
+ >
13
21
  <div class="ibs-card">
14
22
  <header class="ibs-header">
15
23
  <h3>{ui.labelTitle}</h3>
@@ -99,30 +107,43 @@ const { ui } = Astro.props;
99
107
  }
100
108
  }
101
109
 
110
+ function attr(name: string): string {
111
+ return container?.getAttribute(name) || "";
112
+ }
113
+
114
+ function setInnerText(el: HTMLElement | null, text: string) {
115
+ if (el) el.innerText = text;
116
+ }
117
+
102
118
  function displayInvalidResult() {
103
119
  errorMsg?.classList.remove("ibs-hidden");
104
120
  bankInfoBox?.classList.add("ibs-hidden");
105
- if (resBic) resBic.innerText = "-";
106
- if (resCountry) resCountry.innerText = "Inválido";
107
- if (resBank) resBank.innerText = "IBAN No Válido";
121
+ setInnerText(resBic, "-");
122
+ setInnerText(resCountry, attr("data-label-invalid"));
123
+ setInnerText(resBank, attr("data-label-invalid-iban"));
124
+ }
125
+
126
+ function resolveBankMessage(result: ReturnType<typeof IBANConverter.validate>): string {
127
+ return result.countryCode !== "ES"
128
+ ? attr("data-label-bank-outside-spain")
129
+ : attr("data-label-bank-not-identified");
108
130
  }
109
131
 
110
132
  function displayBankInfo(result: ReturnType<typeof IBANConverter.validate>) {
111
133
  if (result.bankData) {
112
- if (resBic) resBic.innerText = result.bankData.bic;
113
- if (resBank) resBank.innerText = result.bankData.name;
134
+ setInnerText(resBic, result.bankData.bic);
135
+ setInnerText(resBank, result.bankData.name);
114
136
  } else {
115
- const message = result.countryCode !== "ES" ? "Banco fuera de España (Datos limitados)" : "Entidad no identificada";
116
- if (resBic) resBic.innerText = "BIC Desconocido";
117
- if (resBank) resBank.innerText = message;
137
+ setInnerText(resBic, attr("data-label-unknown-bic"));
138
+ setInnerText(resBank, resolveBankMessage(result));
118
139
  }
119
140
  }
120
141
 
121
142
  function displayValidResult(result: ReturnType<typeof IBANConverter.validate>) {
122
143
  errorMsg?.classList.add("ibs-hidden");
123
144
  bankInfoBox?.classList.remove("ibs-hidden");
124
-
125
- if (resCountry) resCountry.innerText = `${result.countryCode} (Formato OK)`;
145
+ const formatOk = attr("data-label-formatting-ok");
146
+ setInnerText(resCountry, `${result.countryCode} (${formatOk})`);
126
147
  displayBankInfo(result);
127
148
  }
128
149
 
@@ -5,7 +5,7 @@ import type { IBANBICSwiftUI } from '../ui';
5
5
 
6
6
  const slug = 'iban-to-bic-swift-converter';
7
7
  const title = 'IBAN to BIC SWIFT Converter and Bank Code Finder';
8
- const description = 'Get the BIC/SWIFT code from any Spanish IBAN instantly. Bank account validator with official algorithm and updated records for international transfers.';
8
+ const description = 'Get the BIC/SWIFT code from any Spanish IBAN instantly. Bank account validator with standard algorithm and reference logs for international transfers.';
9
9
 
10
10
  const faqData = [
11
11
  {
@@ -5,7 +5,7 @@ import type { IBANBICSwiftUI } from '../ui';
5
5
 
6
6
  const slug = 'conversor-iban-bic-swift';
7
7
  const title = 'Conversor de IBAN a BIC SWIFT y Buscador de Bancos';
8
- const description = 'Obtén el código BIC/SWIFT de cualquier IBAN español al instante. Validador de cuentas bancarias con algoritmo oficial y registros actualizados para transferencias.';
8
+ const description = 'Obtén el código BIC/SWIFT de cualquier IBAN español al instante. Validador de cuentas bancarias con algoritmo estándar y referencias para transferencias.';
9
9
 
10
10
  const faqData = [
11
11
  {
@@ -14,4 +14,6 @@ export interface IBANBICSwiftUI {
14
14
  labelBankOutsideSpain: string;
15
15
  labelInvalidIBAN: string;
16
16
  labelFormattingOK: string;
17
+ labelInvalid?: string;
18
+ labelUnknownBIC?: string;
17
19
  }
@@ -11,7 +11,13 @@ const { ui } = Astro.props;
11
11
  const availableYears = InflationCalculator.getAvailableYears();
12
12
  ---
13
13
 
14
- <div class="inflation-container" data-currency-locale={ui.currencyLocale} data-currency-code={ui.currencyCode} data-label-inflation-accumulated={ui.labelInflationAccumulated}>
14
+ <div class="inflation-container" data-currency-locale={ui.currencyLocale} data-currency-code={ui.currencyCode} data-label-inflation-accumulated={ui.labelInflationAccumulated}
15
+ data-label-inflation-rate={ui.labelInflationRate}
16
+ data-label-year-select={ui.labelYearSelect}
17
+ data-label-in={ui.labelIn}
18
+ data-label-equivalent-today={ui.labelEquivalentToday}
19
+ data-label-year={ui.labelYear}
20
+ >
15
21
  <div class="inflation-header">
16
22
  <div class="inflation-header-title-group">
17
23
  <div class="inflation-header-icon">
@@ -1,4 +1,4 @@
1
- import { bibliography } from '../bibliography'
1
+ import { bibliography } from '../bibliography'
2
2
  import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
3
3
  import type { ToolLocaleContent } from '../../../types';
4
4
  import type { InflationUI } from '../ui';
@@ -261,7 +261,7 @@ export const content: ToolLocaleContent<InflationUI> = {
261
261
  {
262
262
  type: 'message',
263
263
  title: 'Toma el Control de tu Economía',
264
- html: 'Nuestra calculadora utiliza datos oficiales del INE para ofrecerte la visión más precisa del pasado y presente económico de España.',
264
+ html: 'Nuestra calculadora utiliza datos públicos del INE para ofrecerte la visión más precisa del pasado y presente económico de España.',
265
265
  },
266
266
  ],
267
267
  ui: {
@@ -271,7 +271,7 @@ export const content: ToolLocaleContent<InflationUI> = {
271
271
  labelFinalAmount: 'Equivalencia Hoy',
272
272
  labelFinalYear: 'Año 2026',
273
273
  labelInflationRate: 'Inflación Acumulada',
274
- labelCalculatedOn: 'Cálculo realizado utilizando datos oficiales del INE hasta 2025 y proyecciones estimadas para el cierre de 2026.',
274
+ labelCalculatedOn: 'Cálculo realizado utilizando datos públicos del INE hasta 2025 y proyecciones estimadas para el cierre de 2026.',
275
275
  currencySymbol: '€',
276
276
  currencyCode: 'EUR',
277
277
  currencyLocale: 'es-ES',
@@ -22,6 +22,13 @@ const { ui } = Astro.props;
22
22
  data-report-label-interest={ui.reportLabelInterest}
23
23
  data-report-label-total={ui.reportLabelTotal}
24
24
  data-report-days-suffix={ui.reportDaysSuffix}
25
+ data-label-title={ui.labelTitle}
26
+ data-label-monthly-title={ui.labelMonthlyTitle}
27
+ data-label-simple-question={ui.labelSimpleQuestion}
28
+ data-label-compound-question={ui.labelCompoundQuestion}
29
+ data-label-monthly-question={ui.labelMonthlyQuestion}
30
+ data-label-monthly-rate={ui.labelMonthlyRate}
31
+ data-label-copied={ui.labelCopied || "¡Copiado!"}
25
32
  >
26
33
  <div class="li-card fade-in">
27
34
  <div class="li-input-grid">
@@ -237,7 +244,7 @@ ${labels.total}: ${finalTotalDisplay?.textContent}
237
244
 
238
245
  if (copyBtn) {
239
246
  const originalHtml = copyBtn.innerHTML;
240
- copyBtn.innerHTML = '¡Copiado!';
247
+ copyBtn.innerHTML = container?.getAttribute("data-label-copied") || "";
241
248
  setTimeout(() => {
242
249
  copyBtn.innerHTML = originalHtml;
243
250
  }, 2000);
@@ -30,4 +30,5 @@ export interface LateInterestUI {
30
30
  formulaDescription: string;
31
31
  currencyCode: string;
32
32
  currencyLocale: string;
33
+ labelCopied?: string;
33
34
  }
@@ -10,7 +10,10 @@ interface Props {
10
10
  const { ui } = Astro.props;
11
11
  ---
12
12
 
13
- <div class="lir-wrapper" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}>
13
+ <div class="lir-wrapper" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}
14
+ data-label-description={ui.labelDescription}
15
+ data-label-official-regulation={ui.labelOfficialRegulation}
16
+ >
14
17
  <div class="lir-container">
15
18
  <header class="lir-header">
16
19
  <span class="lir-badge">{ui.labelBadge}</span>
@@ -125,7 +125,7 @@ export const content: ToolLocaleContent<LegalInterestRateUI> = {
125
125
  labelInterestGenerated: 'Intereses Generados',
126
126
  labelTotalToPay: 'Total a Pagar',
127
127
  labelFormula: 'Esta calculadora aplica la fórmula estándar de interés simple:',
128
- labelBase: 'Se utiliza una base de 365 días para el cálculo oficial según la normativa española vigente en 2026.',
128
+ labelBase: 'Se utiliza una base de 365 días para el cálculo de referencia según la normativa española vigente en 2026.',
129
129
  labelOfficialRegulation: 'Regulación Oficial',
130
130
  currencySymbol: '€',
131
131
  currencyCode: 'EUR',
@@ -10,7 +10,11 @@ interface Props {
10
10
  const { ui } = Astro.props;
11
11
  ---
12
12
 
13
- <div class="lo-container" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale} data-label-units={ui.labelUnits} data-label-low={ui.labelLow} data-label-medium={ui.labelMedium} data-label-high={ui.labelHigh} data-label-accessible={ui.labelAccessible} data-label-difficult={ui.labelDifficult} data-label-extreme={ui.labelExtreme} data-label-optimal={ui.labelOptimalCutoff} data-game-translations={JSON.stringify(ui.gameTranslations)}>
13
+ <div class="lo-container" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale} data-label-units={ui.labelUnits} data-label-low={ui.labelLow} data-label-medium={ui.labelMedium} data-label-high={ui.labelHigh} data-label-accessible={ui.labelAccessible} data-label-difficult={ui.labelDifficult} data-label-extreme={ui.labelExtreme} data-label-optimal={ui.labelOptimalCutoff} data-game-translations={JSON.stringify(ui.gameTranslations)}
14
+ data-label-title={ui.labelTitle}
15
+ data-label-description={ui.labelDescription}
16
+ data-label-select-game={ui.labelSelectGame}
17
+ >
14
18
  <div class="lo-games-grid">
15
19
  {['gordo', 'nino', 'euromillones', 'primitiva', 'bonoloto'].map((gameId) => (
16
20
  <button class="lo-game-btn" data-game-btn data-id={gameId}>
@@ -114,7 +114,7 @@ const seoData = [
114
114
  items: [
115
115
  '<strong>Anualidad Cumplida:</strong> Solo cuando se cumpla cada año. No a mitad de año.',
116
116
  '<strong>Notificación Escrita:</strong> Con al menos 30 días de antelación (burofax o email certificado).',
117
- '<strong>Certificación INE:</strong> Adjuntar datos oficiales del IPC.',
117
+ '<strong>Certificación INE:</strong> Adjuntar estadísticas del IPC.',
118
118
  ],
119
119
  },
120
120
  {