@jjlmoya/utils-textiles 1.17.0 → 1.19.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 +5 -3
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/translation_copy.test.ts +124 -0
- package/src/tool/fabricProjectCalculator/component.astro +3 -3
- package/src/tool/fabricTruth/component.astro +2 -2
- package/src/tool/knittingGauge/component.astro +10 -10
- package/src/tool/laundryGuide/i18n/fr.ts +15 -19
- package/src/tool/needleConverter/component.astro +9 -4
- package/src/tool/sewingPatternScaler/component.astro +11 -11
- package/src/tool/sewingPatternScaler/i18n/de.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/es.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/fr.ts +9 -0
- package/src/tool/sewingPatternScaler/i18n/id.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/it.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/ja.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/ko.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/nl.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/pl.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/pt.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/ru.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/sv.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/tr.ts +0 -35
- package/src/tool/sewingPatternScaler/i18n/zh.ts +0 -35
- package/src/tool/stainChemistry/component.astro +2 -2
- package/src/tool/yarnCalculator/component.astro +2 -2
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-textiles",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.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",
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
"check": "astro check",
|
|
27
29
|
"type-check": "astro check",
|
|
28
30
|
"test": "vitest run",
|
|
29
|
-
"preversion": "npm run lint && npm run test",
|
|
31
|
+
"preversion": "npm run lint && npm run test && npm run build",
|
|
30
32
|
"postversion": "git push && git push --tags",
|
|
31
33
|
"patch": "npm version patch",
|
|
32
34
|
"minor": "npm version minor",
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
import type { KnownLocale } from '../types';
|
|
4
|
+
|
|
5
|
+
interface ExpectedCounts {
|
|
6
|
+
seo: number;
|
|
7
|
+
faq: number;
|
|
8
|
+
howTo: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function countItems(arr: unknown[] | undefined): number {
|
|
12
|
+
return arr?.length ?? 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function verifyLocaleParity(
|
|
16
|
+
entry: typeof ALL_ENTRIES[number],
|
|
17
|
+
loc: KnownLocale,
|
|
18
|
+
expected: ExpectedCounts,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const locContent = await entry.i18n[loc]?.();
|
|
21
|
+
expect(locContent, `Locale ${loc} missing content`).toBeDefined();
|
|
22
|
+
|
|
23
|
+
const locSeoCount = countItems(locContent?.seo);
|
|
24
|
+
const locFaqCount = countItems(locContent?.faq);
|
|
25
|
+
const locHowToCount = countItems(locContent?.howTo);
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
locSeoCount,
|
|
29
|
+
`Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
|
|
30
|
+
).toBe(expected.seo);
|
|
31
|
+
expect(
|
|
32
|
+
locFaqCount,
|
|
33
|
+
`Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
|
|
34
|
+
).toBe(expected.faq);
|
|
35
|
+
expect(
|
|
36
|
+
locHowToCount,
|
|
37
|
+
`Locale ${loc} HowTo steps count (${locHowToCount}) must match EN (${expected.howTo})`,
|
|
38
|
+
).toBe(expected.howTo);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('SEO & i18n Structural Parity Suite', () => {
|
|
42
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
43
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
44
|
+
it('all 15 locales should have identical SEO section counts and types as English', async () => {
|
|
45
|
+
const enContent = await entry.i18n.en?.();
|
|
46
|
+
expect(enContent).toBeDefined();
|
|
47
|
+
const expected: ExpectedCounts = {
|
|
48
|
+
seo: countItems(enContent?.seo),
|
|
49
|
+
faq: countItems(enContent?.faq),
|
|
50
|
+
howTo: countItems(enContent?.howTo),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const locales = Object.keys(entry.i18n) as KnownLocale[];
|
|
54
|
+
for (const loc of locales) {
|
|
55
|
+
await verifyLocaleParity(entry, loc, expected);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const COPY_THRESHOLD = 0.9;
|
|
5
|
+
|
|
6
|
+
const STRUCTURAL_KEYS = new Set([
|
|
7
|
+
'@context',
|
|
8
|
+
'@type',
|
|
9
|
+
'applicationCategory',
|
|
10
|
+
'columns',
|
|
11
|
+
'highlight',
|
|
12
|
+
'icon',
|
|
13
|
+
'level',
|
|
14
|
+
'operatingSystem',
|
|
15
|
+
'position',
|
|
16
|
+
'positive',
|
|
17
|
+
'price',
|
|
18
|
+
'priceCurrency',
|
|
19
|
+
'slug',
|
|
20
|
+
'trend',
|
|
21
|
+
'type',
|
|
22
|
+
'url',
|
|
23
|
+
'value',
|
|
24
|
+
'variant',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function normalizeText(value: string): string {
|
|
28
|
+
return value
|
|
29
|
+
.replace(/<[^>]*>/g, ' ')
|
|
30
|
+
.replace(/&(?:amp|lt|gt|quot|apos|nbsp);/gi, ' ')
|
|
31
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
32
|
+
.replace(/[\u201c\u201d]/g, '"')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim()
|
|
35
|
+
.toLocaleLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function collectText(value: unknown, path: string, parts: string[]): void {
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
const normalized = normalizeText(value);
|
|
41
|
+
if (normalized.length >= 2) parts.push(normalized);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
value.forEach((item, index) => collectText(item, `${path}[${index}]`, parts));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!value || typeof value !== 'object') return;
|
|
51
|
+
|
|
52
|
+
Object.entries(value).forEach(([key, child]) => {
|
|
53
|
+
if (STRUCTURAL_KEYS.has(key)) return;
|
|
54
|
+
collectText(child, `${path}.${key}`, parts);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function localeCorpus(content: unknown): string {
|
|
59
|
+
if (!content || typeof content !== 'object') return '';
|
|
60
|
+
|
|
61
|
+
const record = content as Record<string, unknown>;
|
|
62
|
+
const parts: string[] = [];
|
|
63
|
+
collectText(record.title, 'title', parts);
|
|
64
|
+
collectText(record.description, 'description', parts);
|
|
65
|
+
collectText(record.faqTitle, 'faqTitle', parts);
|
|
66
|
+
collectText(record.faq, 'faq', parts);
|
|
67
|
+
collectText(record.seo, 'seo', parts);
|
|
68
|
+
collectText(record.schemas, 'schemas', parts);
|
|
69
|
+
return parts.join(' ');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tokenCounts(text: string): Map<string, number> {
|
|
73
|
+
const counts = new Map<string, number>();
|
|
74
|
+
for (const token of text.match(/[\p{L}\p{N}]+/gu) ?? []) {
|
|
75
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function copySimilarity(left: string, right: string): number {
|
|
81
|
+
const leftCounts = tokenCounts(left);
|
|
82
|
+
const rightCounts = tokenCounts(right);
|
|
83
|
+
const leftTotal = [...leftCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
84
|
+
const rightTotal = [...rightCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
85
|
+
if (leftTotal === 0 || rightTotal === 0) return 0;
|
|
86
|
+
|
|
87
|
+
let shared = 0;
|
|
88
|
+
for (const [token, count] of leftCounts) {
|
|
89
|
+
shared += Math.min(count, rightCounts.get(token) ?? 0);
|
|
90
|
+
}
|
|
91
|
+
return (2 * shared) / (leftTotal + rightTotal);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('Locales must not copy another locale wholesale', () => {
|
|
95
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
96
|
+
it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
|
|
97
|
+
const corpora = new Map<string, string>();
|
|
98
|
+
|
|
99
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
100
|
+
if (!loader) continue;
|
|
101
|
+
corpora.set(locale, localeCorpus(await loader()));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const locales = [...corpora.keys()];
|
|
105
|
+
const violations: string[] = [];
|
|
106
|
+
|
|
107
|
+
for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
|
|
108
|
+
for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
|
|
109
|
+
const left = locales[leftIndex];
|
|
110
|
+
const right = locales[rightIndex];
|
|
111
|
+
const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
112
|
+
|
|
113
|
+
if (similarity >= COPY_THRESHOLD) {
|
|
114
|
+
violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
|
|
@@ -14,7 +14,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
|
|
|
14
14
|
<div class="fpc-config">
|
|
15
15
|
<h3>{calcUI.sectionProject}</h3>
|
|
16
16
|
<div class="fpc-field">
|
|
17
|
-
<label>{calcUI.labelGarmentType}</label>
|
|
17
|
+
<label for="garment-type">{calcUI.labelGarmentType}</label>
|
|
18
18
|
<select id="garment-type" class="fpc-select">
|
|
19
19
|
<option value="skirt">{calcUI.garmentSkirt}</option>
|
|
20
20
|
<option value="pants">{calcUI.garmentPants}</option>
|
|
@@ -37,7 +37,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
|
|
|
37
37
|
<div class="fpc-config">
|
|
38
38
|
<h3>{calcUI.sectionMaterial}</h3>
|
|
39
39
|
<div class="fpc-field">
|
|
40
|
-
<label>{calcUI.labelFabricWidth}</label>
|
|
40
|
+
<label for="fabric-width">{calcUI.labelFabricWidth}</label>
|
|
41
41
|
<select id="fabric-width" class="fpc-select">
|
|
42
42
|
<option value="90">{calcUI.width90}</option>
|
|
43
43
|
<option value="115">{calcUI.width115}</option>
|
|
@@ -46,7 +46,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
|
|
|
46
46
|
</select>
|
|
47
47
|
</div>
|
|
48
48
|
<div class="fpc-field">
|
|
49
|
-
<label>{calcUI.labelSeamAllowance}</label>
|
|
49
|
+
<label for="seam-allowance">{calcUI.labelSeamAllowance}</label>
|
|
50
50
|
<div class="fpc-stepper">
|
|
51
51
|
<button class="fpc-step-button" id="sub-allow">−</button>
|
|
52
52
|
<input type="number" id="seam-allowance" class="fpc-input" value="1.5" step="0.5" min="0" max="5" />
|
|
@@ -120,11 +120,11 @@ const fibers = Object.entries(fiberData)
|
|
|
120
120
|
|
|
121
121
|
<template id="fiber-row-template">
|
|
122
122
|
<div class="fiber-row">
|
|
123
|
-
<select class="fiber-select">
|
|
123
|
+
<select class="fiber-select" aria-label="Fiber Type">
|
|
124
124
|
{fibers.map((f) => <option value={f.id}>{f.name}</option>)}
|
|
125
125
|
</select>
|
|
126
126
|
<div class="fiber-perc-wrapper">
|
|
127
|
-
<input type="number" min="0" max="100" step="1" placeholder="0" list="perc-list" class="fiber-perc" />
|
|
127
|
+
<input type="number" min="0" max="100" step="1" placeholder="0" list="perc-list" class="fiber-perc" aria-label="Fiber Percentage" />
|
|
128
128
|
<span class="perc-symbol">%</span>
|
|
129
129
|
</div>
|
|
130
130
|
<button class="remove-row-btn">
|
|
@@ -15,7 +15,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
|
|
|
15
15
|
<h3>{gaugeUI.sectionOriginalGauge}</h3>
|
|
16
16
|
<div class="input-grid">
|
|
17
17
|
<div class="gauge-field">
|
|
18
|
-
<label>{gaugeUI.labelStitches} <span class="label-sub">({gaugeUI.labelWidth})</span></label>
|
|
18
|
+
<label for="pattern-sts">{gaugeUI.labelStitches} <span class="label-sub">({gaugeUI.labelWidth})</span></label>
|
|
19
19
|
<div class="stepper-container">
|
|
20
20
|
<button class="stepper-btn" data-step="-0.5" data-for="pattern-sts">−</button>
|
|
21
21
|
<input type="number" id="pattern-sts" class="gauge-input" value="20" step="0.5" />
|
|
@@ -23,7 +23,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
|
|
|
23
23
|
</div>
|
|
24
24
|
</div>
|
|
25
25
|
<div class="gauge-field">
|
|
26
|
-
<label>{gaugeUI.labelRows} <span class="label-sub">({gaugeUI.labelLength})</span></label>
|
|
26
|
+
<label for="pattern-rows">{gaugeUI.labelRows} <span class="label-sub">({gaugeUI.labelLength})</span></label>
|
|
27
27
|
<div class="stepper-container">
|
|
28
28
|
<button class="stepper-btn" data-step="-0.5" data-for="pattern-rows">−</button>
|
|
29
29
|
<input type="number" id="pattern-rows" class="gauge-input" value="28" step="0.5" />
|
|
@@ -32,7 +32,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
|
|
|
32
32
|
</div>
|
|
33
33
|
</div>
|
|
34
34
|
<div class="gauge-field">
|
|
35
|
-
<label>{gaugeUI.labelUnit}</label>
|
|
35
|
+
<label for="gauge-unit">{gaugeUI.labelUnit}</label>
|
|
36
36
|
<select id="gauge-unit" class="gauge-select">
|
|
37
37
|
<option value="10">{gaugeUI.unitEU}</option>
|
|
38
38
|
<option value="10.16">{gaugeUI.unitUS}</option>
|
|
@@ -44,7 +44,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
|
|
|
44
44
|
<h3>{gaugeUI.sectionMyGauge}</h3>
|
|
45
45
|
<div class="input-grid">
|
|
46
46
|
<div class="gauge-field">
|
|
47
|
-
<label>{gaugeUI.labelStitches} <span class="label-sub">({gaugeUI.labelWidth})</span></label>
|
|
47
|
+
<label for="my-sts">{gaugeUI.labelStitches} <span class="label-sub">({gaugeUI.labelWidth})</span></label>
|
|
48
48
|
<div class="stepper-container">
|
|
49
49
|
<button class="stepper-btn" data-step="-0.5" data-for="my-sts">−</button>
|
|
50
50
|
<input type="number" id="my-sts" class="gauge-input" value="22" step="0.5" />
|
|
@@ -52,7 +52,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
|
|
|
52
52
|
</div>
|
|
53
53
|
</div>
|
|
54
54
|
<div class="gauge-field">
|
|
55
|
-
<label>{gaugeUI.labelRows} <span class="label-sub">({gaugeUI.labelLength})</span></label>
|
|
55
|
+
<label for="my-rows">{gaugeUI.labelRows} <span class="label-sub">({gaugeUI.labelLength})</span></label>
|
|
56
56
|
<div class="stepper-container">
|
|
57
57
|
<button class="stepper-btn" data-step="-0.5" data-for="my-rows">−</button>
|
|
58
58
|
<input type="number" id="my-rows" class="gauge-input" value="30" step="0.5" />
|
|
@@ -62,11 +62,11 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
|
|
|
62
62
|
</div>
|
|
63
63
|
<div class="input-grid">
|
|
64
64
|
<div class="gauge-field">
|
|
65
|
-
<label>{gaugeUI.labelNeedleMm}</label>
|
|
65
|
+
<label for="my-needle">{gaugeUI.labelNeedleMm}</label>
|
|
66
66
|
<input type="number" id="my-needle" class="gauge-input gauge-input-bordered" value="4.0" step="0.25" />
|
|
67
67
|
</div>
|
|
68
68
|
<div class="gauge-field">
|
|
69
|
-
<label>{gaugeUI.labelWeight} <span class="label-sub">{gaugeUI.labelWeightOptional}</span></label>
|
|
69
|
+
<label for="sample-weight">{gaugeUI.labelWeight} <span class="label-sub">{gaugeUI.labelWeightOptional}</span></label>
|
|
70
70
|
<input type="number" id="sample-weight" class="gauge-input gauge-input-bordered" placeholder="5" />
|
|
71
71
|
</div>
|
|
72
72
|
</div>
|
|
@@ -76,16 +76,16 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
|
|
|
76
76
|
<h3>{gaugeUI.sectionProject}</h3>
|
|
77
77
|
<div class="input-grid">
|
|
78
78
|
<div class="gauge-field">
|
|
79
|
-
<label>{gaugeUI.labelPatternSts}</label>
|
|
79
|
+
<label for="target-sts">{gaugeUI.labelPatternSts}</label>
|
|
80
80
|
<input type="number" id="target-sts" class="gauge-input gauge-input-bordered" value="100" />
|
|
81
81
|
</div>
|
|
82
82
|
<div class="gauge-field">
|
|
83
|
-
<label>{gaugeUI.labelPatternRows}</label>
|
|
83
|
+
<label for="target-rows">{gaugeUI.labelPatternRows}</label>
|
|
84
84
|
<input type="number" id="target-rows" class="gauge-input gauge-input-bordered" value="140" />
|
|
85
85
|
</div>
|
|
86
86
|
</div>
|
|
87
87
|
<div class="gauge-field">
|
|
88
|
-
<label>{gaugeUI.labelMultiples} <span class="label-sub">{gaugeUI.labelMultiplesExample}</span></label>
|
|
88
|
+
<label for="pattern-multiples">{gaugeUI.labelMultiples} <span class="label-sub">{gaugeUI.labelMultiplesExample}</span></label>
|
|
89
89
|
<input
|
|
90
90
|
type="text"
|
|
91
91
|
id="pattern-multiples"
|
|
@@ -2,44 +2,40 @@ import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dt
|
|
|
2
2
|
import type { ToolLocaleContent } from '../../../types';
|
|
3
3
|
import { bibliography } from '../bibliography';
|
|
4
4
|
|
|
5
|
-
const slug = 'guide-
|
|
6
|
-
const title = '
|
|
7
|
-
const description = '
|
|
5
|
+
const slug = 'guide-entretien-textile';
|
|
6
|
+
const title = 'Guide d\'entretien textile, comment laver chaque type de fibre';
|
|
7
|
+
const description = 'Guide scientifique pour laver et entretenir le coton, la laine, la soie, le lin et les fibres synthétiques. Évitez le rétrécissement, la décoloration et les dommages grâce aux bons gestes.';
|
|
8
8
|
|
|
9
9
|
const faqData = [
|
|
10
10
|
{
|
|
11
|
-
question: '
|
|
11
|
+
question: 'Comment éviter que les couleurs ne ternissent ?',
|
|
12
12
|
answer:
|
|
13
|
-
'
|
|
13
|
+
'Lavez à l\'eau froide, à 30 °C maximum, retournez les vêtements et utilisez une lessive pour couleurs foncées. Évitez aussi le soleil direct pendant le séchage.',
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
|
-
question: '
|
|
16
|
+
question: 'Puis-je laver la laine en machine ?',
|
|
17
17
|
answer:
|
|
18
|
-
'
|
|
18
|
+
'Oui, avec un cycle laine à l\'eau froide et une agitation douce. Utilisez une lessive spéciale laine et jamais un cycle classique.',
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
|
-
question: '
|
|
21
|
+
question: 'Pourquoi la soie présente-t-elle des auréoles après lavage ?',
|
|
22
22
|
answer:
|
|
23
|
-
'
|
|
23
|
+
'La soie réagit aux minéraux de l\'eau. Utilisez si possible de l\'eau distillée pour le dernier rinçage et évitez de frotter la zone humide.',
|
|
24
24
|
},
|
|
25
25
|
];
|
|
26
26
|
|
|
27
27
|
const howToData = [
|
|
28
28
|
{
|
|
29
|
-
name: '
|
|
30
|
-
text: '
|
|
29
|
+
name: 'Vérifiez l\'étiquette',
|
|
30
|
+
text: 'Lisez toujours les symboles d\'entretien du vêtement avant de choisir un programme de lavage.',
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
|
-
name: '
|
|
34
|
-
text: '
|
|
33
|
+
name: 'Triez par type de fibre',
|
|
34
|
+
text: 'Séparez autant que possible les fibres naturelles des matières synthétiques et regroupez les couleurs compatibles.',
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
|
-
name: '
|
|
38
|
-
text: '
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
name: 'Appliquez à la table',
|
|
42
|
-
text: 'Suivez les instructions "Actions à la Table de Coupe" pour ajouter ou retirer des centimètres sur les côtés et les ourlets de vos pièces.',
|
|
37
|
+
name: 'Choisissez la température',
|
|
38
|
+
text: 'Utilisez l\'eau froide pour les tissus délicats et l\'eau chaude seulement pour les matières robustes qui le permettent.',
|
|
43
39
|
},
|
|
44
40
|
];
|
|
45
41
|
|
|
@@ -110,10 +110,15 @@ const needleUI = ui as NeedleConverterUI;
|
|
|
110
110
|
if (!picker) return;
|
|
111
111
|
picker.innerHTML = '';
|
|
112
112
|
NEEDLE_DATA.forEach((item, idx) => picker.appendChild(buildHoleEl(item.mm, idx)));
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
requestAnimationFrame(() => {
|
|
114
|
+
const active = picker.children[currentIdx] as HTMLElement | undefined;
|
|
115
|
+
if (active) {
|
|
116
|
+
const osLeft = active['offset' + 'Left'];
|
|
117
|
+
const osWidth = active['offset' + 'Width'];
|
|
118
|
+
const pWidth = picker['offset' + 'Width'];
|
|
119
|
+
picker.scrollTo({ left: osLeft - pWidth / 2 + osWidth / 2, behavior: 'smooth' });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
function buildRowEl(idx: number): HTMLTableRowElement {
|