@jjlmoya/utils-civic 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.github/workflows/npm-publish.yml +40 -0
  2. package/.gitignore +6 -0
  3. package/.stylelintrc.json +98 -0
  4. package/astro.config.mjs +19 -0
  5. package/eslint.config.js +201 -0
  6. package/package.json +79 -0
  7. package/prompts/create_tool.md +98 -0
  8. package/prompts/i18n/de.md +16 -0
  9. package/prompts/i18n/en.md +16 -0
  10. package/prompts/i18n/es.md +16 -0
  11. package/prompts/i18n/fr.md +16 -0
  12. package/prompts/i18n/id.md +16 -0
  13. package/prompts/i18n/it.md +16 -0
  14. package/prompts/i18n/ja.md +16 -0
  15. package/prompts/i18n/ko.md +16 -0
  16. package/prompts/i18n/nl.md +16 -0
  17. package/prompts/i18n/pl.md +16 -0
  18. package/prompts/i18n/pt.md +16 -0
  19. package/prompts/i18n/ru.md +16 -0
  20. package/prompts/i18n/sv.md +16 -0
  21. package/prompts/i18n/tr.md +16 -0
  22. package/prompts/i18n/zh.md +16 -0
  23. package/prompts/seo.md +58 -0
  24. package/prompts/translations/french.md +33 -0
  25. package/scripts/postinstall.mjs +27 -0
  26. package/src/category/CivicCategorySEO.astro +9 -0
  27. package/src/category/i18n/de.ts +21 -0
  28. package/src/category/i18n/en.ts +25 -0
  29. package/src/category/i18n/es.ts +21 -0
  30. package/src/category/i18n/fr.ts +21 -0
  31. package/src/category/i18n/id.ts +21 -0
  32. package/src/category/i18n/it.ts +21 -0
  33. package/src/category/i18n/ja.ts +21 -0
  34. package/src/category/i18n/ko.ts +21 -0
  35. package/src/category/i18n/nl.ts +21 -0
  36. package/src/category/i18n/pl.ts +21 -0
  37. package/src/category/i18n/pt.ts +21 -0
  38. package/src/category/i18n/ru.ts +21 -0
  39. package/src/category/i18n/sv.ts +21 -0
  40. package/src/category/i18n/tr.ts +21 -0
  41. package/src/category/i18n/zh.ts +21 -0
  42. package/src/category/index.ts +24 -0
  43. package/src/components/PreviewNavSidebar.astro +116 -0
  44. package/src/components/PreviewToolbar.astro +143 -0
  45. package/src/data.ts +10 -0
  46. package/src/entries.ts +9 -0
  47. package/src/env.d.ts +5 -0
  48. package/src/index.ts +20 -0
  49. package/src/layouts/PreviewLayout.astro +117 -0
  50. package/src/pages/[locale]/[slug].astro +163 -0
  51. package/src/pages/[locale].astro +253 -0
  52. package/src/pages/index.astro +4 -0
  53. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  54. package/src/tests/category_seo_quality.test.ts +79 -0
  55. package/src/tests/diacritics_density.test.ts +118 -0
  56. package/src/tests/faq_count.test.ts +18 -0
  57. package/src/tests/i18n_coverage.test.ts +34 -0
  58. package/src/tests/inverted_punctuation.test.ts +84 -0
  59. package/src/tests/locale_completeness.test.ts +23 -0
  60. package/src/tests/mocks/astro_mock.js +2 -0
  61. package/src/tests/no_em_dash.test.ts +47 -0
  62. package/src/tests/no_en_dash.test.ts +70 -0
  63. package/src/tests/no_h1_in_components.test.ts +48 -0
  64. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  65. package/src/tests/qa-test-helpers.ts +31 -0
  66. package/src/tests/qa_bibliography_links.test.ts +53 -0
  67. package/src/tests/qa_claim_evidence.test.ts +68 -0
  68. package/src/tests/qa_logic_reference_coverage.test.ts +45 -0
  69. package/src/tests/qa_runtime_i18n.test.ts +100 -0
  70. package/src/tests/registry_contract.test.ts +67 -0
  71. package/src/tests/schemas_fulfillment.test.ts +23 -0
  72. package/src/tests/script_density.test.ts +94 -0
  73. package/src/tests/seo_length.test.ts +23 -0
  74. package/src/tests/seo_parity.test.ts +60 -0
  75. package/src/tests/seo_translation_completeness.test.ts +75 -0
  76. package/src/tests/seo_wellformed_export.test.ts +65 -0
  77. package/src/tests/shared-test-helpers.ts +56 -0
  78. package/src/tests/slug_language_code_format.test.ts +23 -0
  79. package/src/tests/slug_uniqueness.test.ts +81 -0
  80. package/src/tests/spanish_leakage.test.ts +175 -0
  81. package/src/tests/title_quality.test.ts +55 -0
  82. package/src/tests/tool_exports.test.ts +34 -0
  83. package/src/tests/tool_validation.test.ts +16 -0
  84. package/src/tests/translation_copy.test.ts +123 -0
  85. package/src/tool/election-seat-apportionment-calculator/bibliography.astro +6 -0
  86. package/src/tool/election-seat-apportionment-calculator/bibliography.ts +16 -0
  87. package/src/tool/election-seat-apportionment-calculator/component.astro +69 -0
  88. package/src/tool/election-seat-apportionment-calculator/controller.ts +181 -0
  89. package/src/tool/election-seat-apportionment-calculator/dom-views.ts +75 -0
  90. package/src/tool/election-seat-apportionment-calculator/election-seat-apportionment-calculator.css +585 -0
  91. package/src/tool/election-seat-apportionment-calculator/entry.ts +28 -0
  92. package/src/tool/election-seat-apportionment-calculator/evaluator.ts +20 -0
  93. package/src/tool/election-seat-apportionment-calculator/i18n/de.ts +55 -0
  94. package/src/tool/election-seat-apportionment-calculator/i18n/en.ts +108 -0
  95. package/src/tool/election-seat-apportionment-calculator/i18n/es.ts +50 -0
  96. package/src/tool/election-seat-apportionment-calculator/i18n/fr.ts +48 -0
  97. package/src/tool/election-seat-apportionment-calculator/i18n/id.ts +48 -0
  98. package/src/tool/election-seat-apportionment-calculator/i18n/it.ts +48 -0
  99. package/src/tool/election-seat-apportionment-calculator/i18n/ja.ts +48 -0
  100. package/src/tool/election-seat-apportionment-calculator/i18n/ko.ts +48 -0
  101. package/src/tool/election-seat-apportionment-calculator/i18n/nl.ts +48 -0
  102. package/src/tool/election-seat-apportionment-calculator/i18n/pl.ts +48 -0
  103. package/src/tool/election-seat-apportionment-calculator/i18n/pt.ts +48 -0
  104. package/src/tool/election-seat-apportionment-calculator/i18n/ru.ts +48 -0
  105. package/src/tool/election-seat-apportionment-calculator/i18n/sv.ts +48 -0
  106. package/src/tool/election-seat-apportionment-calculator/i18n/tr.ts +48 -0
  107. package/src/tool/election-seat-apportionment-calculator/i18n/zh.ts +48 -0
  108. package/src/tool/election-seat-apportionment-calculator/index.ts +11 -0
  109. package/src/tool/election-seat-apportionment-calculator/logic.test.ts +90 -0
  110. package/src/tool/election-seat-apportionment-calculator/logic.ts +268 -0
  111. package/src/tool/election-seat-apportionment-calculator/seo.astro +14 -0
  112. package/src/tool/election-seat-apportionment-calculator/sharing.test.ts +34 -0
  113. package/src/tool/election-seat-apportionment-calculator/sharing.ts +88 -0
  114. package/src/tool/election-seat-apportionment-calculator/storage.ts +28 -0
  115. package/src/tool/election-seat-apportionment-calculator/ui.ts +92 -0
  116. package/src/tools.ts +7 -0
  117. package/src/types.ts +69 -0
  118. package/tsconfig.json +15 -0
  119. package/vitest.config.ts +20 -0
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_ENTRIES } from '../entries';
3
+
4
+ const STRUCTURAL_KEYS = new Set([
5
+ 'id',
6
+ 'slug',
7
+ 'url',
8
+ 'icon',
9
+ 'image',
10
+ 'imageUrl',
11
+ 'keywords',
12
+ 'category',
13
+ 'tags',
14
+ 'toolId',
15
+ 'type',
16
+ 'locale',
17
+ 'language',
18
+ 'direction',
19
+ ]);
20
+
21
+ const TRANSLATABLE_KEYS = [
22
+ 'title',
23
+ 'description',
24
+ 'faqTitle',
25
+ 'faq',
26
+ 'howTo',
27
+ 'seo',
28
+ 'schemas',
29
+ ] as const;
30
+
31
+ const COPY_THRESHOLD = 0.9;
32
+
33
+ const SPANISH_MARKERS = [
34
+ ['sangre', /\bsangre\b/gi],
35
+ ['molino', /\bmolino\b/gi],
36
+ ['grano', /\bgrano\b/gi],
37
+ ['paladar', /\bpaladar\b/gi],
38
+ ['cuchillas', /\bcuchillas\b/gi],
39
+ ['trozos', /\btrozos\b/gi],
40
+ ['frescura', /\bfrescura\b/gi],
41
+ ['ingresa', /\bingresa\b/gi],
42
+ ['selecciona', /\bselecciona\b/gi],
43
+ ['herramienta', /\bherramienta\b/gi],
44
+ ['según', /\bsegún\b/gi],
45
+ ['después', /\bdespués\b/gi],
46
+ ['puedes', /\bpuedes\b/gi],
47
+ ['debes', /\bdebes\b/gi],
48
+ ['tus', /\btus\b/gi],
49
+ ['a la vez', /\ba la vez\b/gi],
50
+ ['los datos', /\blos datos\b/gi],
51
+ ['las opciones', /\blas opciones\b/gi],
52
+ ['el resultado', /\bel resultado\b/gi],
53
+ ['método de extracción', /\bmétodo de extracción\b/gi],
54
+ ['uniformidad del molino', /\buniformidad del molino\b/gi],
55
+ ['café recién tostado', /\bcafé recién tostado\b/gi],
56
+ ] as const;
57
+
58
+ type UnknownRecord = Record<string, unknown>;
59
+
60
+ function normalize(value: string): string {
61
+ return value
62
+ .replace(/<[^>]*>/g, ' ')
63
+ .replace(/&nbsp;/gi, ' ')
64
+ .replace(/\s+/g, ' ')
65
+ .trim()
66
+ .toLocaleLowerCase('es');
67
+ }
68
+
69
+ function isTechnicalInvariant(text: string): boolean {
70
+ const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
71
+ return [
72
+ 'data:image/svg+xml;base64',
73
+ 'background-image: url',
74
+ '.layout-playground {',
75
+ 'const samplerate =',
76
+ ].some((pattern) => text.includes(pattern)) ||
77
+ (text.includes('presets') && text.includes('hz')) ||
78
+ (text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
79
+ (text.includes('led') && ledUnitMatches.length >= 3);
80
+ }
81
+
82
+ function collectString(value: string, output: string[]): void {
83
+ const text = normalize(value);
84
+ if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
85
+ }
86
+
87
+ function collectObject(value: UnknownRecord, output: string[]): void {
88
+ Object.entries(value).forEach(([childKey, childValue]) => {
89
+ collectText(childValue, output, childKey);
90
+ });
91
+ }
92
+
93
+ function collectText(value: unknown, output: string[], key?: string): void {
94
+ if (key && STRUCTURAL_KEYS.has(key)) return;
95
+ if (typeof value === 'string') return collectString(value, output);
96
+ if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
97
+ if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
98
+ }
99
+
100
+ async function loadText(loader: unknown): Promise<string[]> {
101
+ if (typeof loader !== 'function') return [];
102
+ const module = await (loader as () => Promise<unknown>)();
103
+ const output: string[] = [];
104
+ if (module && typeof module === 'object') {
105
+ const record = module as UnknownRecord;
106
+ for (const key of TRANSLATABLE_KEYS) {
107
+ collectText(record[key], output, key);
108
+ }
109
+ }
110
+ return output;
111
+ }
112
+
113
+ function findSpanishMarkers(text: string[]): string[] {
114
+ const corpus = text.join(' ');
115
+ return SPANISH_MARKERS.flatMap(([label, pattern]) =>
116
+ pattern.test(corpus) ? [label] : [],
117
+ );
118
+ }
119
+
120
+ function similarity(left: string, right: string): number {
121
+ const leftTokens = left.split(/\s+/);
122
+ const rightCounts = new Map<string, number>();
123
+ right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
124
+ const matches = leftTokens.reduce((total, token) => {
125
+ const count = rightCounts.get(token) ?? 0;
126
+ if (count > 0) rightCounts.set(token, count - 1);
127
+ return total + (count > 0 ? 1 : 0);
128
+ }, 0);
129
+ return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
130
+ }
131
+
132
+ function findCopiedFragments(spanish: string[], translated: string[]): string[] {
133
+ return spanish
134
+ .filter((fragment) => fragment.length >= 80)
135
+ .filter((fragment) => translated.some((candidate) =>
136
+ candidate.length >= 80 &&
137
+ Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
138
+ similarity(fragment, candidate) >= COPY_THRESHOLD,
139
+ ))
140
+ .sort((a, b) => b.length - a.length)
141
+ .slice(0, 3);
142
+ }
143
+
144
+ describe('Locales must not contain copied Spanish content', () => {
145
+ for (const entry of ALL_ENTRIES) {
146
+ it(`${entry.id} has no untranslated Spanish blocks`, async () => {
147
+ const spanish = await loadText(entry.i18n.es);
148
+ const failures: string[] = [];
149
+
150
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
151
+ if (locale === 'es') continue;
152
+
153
+ const translated = await loadText(loader);
154
+ const copiedFragments = findCopiedFragments(spanish, translated);
155
+ const markerHits = findSpanishMarkers(translated);
156
+
157
+ if (copiedFragments.length > 0 || markerHits.length >= 2) {
158
+ const details = [
159
+ copiedFragments.length > 0
160
+ ? `copied fragments: ${copiedFragments
161
+ .map((fragment) => JSON.stringify(fragment.slice(0, 120)))
162
+ .join(', ')}`
163
+ : '',
164
+ markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
165
+ ]
166
+ .filter(Boolean)
167
+ .join('; ');
168
+ failures.push(`${locale}: ${details}`);
169
+ }
170
+ }
171
+
172
+ expect(failures, failures.join('\n')).toEqual([]);
173
+ });
174
+ }
175
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ function getFiles(dir: string, ext: string[]): string[] {
6
+ const results: string[] = [];
7
+ if (!fs.existsSync(dir)) return results;
8
+ const list = fs.readdirSync(dir);
9
+ for (const file of list) {
10
+ const fullPath = path.join(dir, file);
11
+ const stat = fs.statSync(fullPath);
12
+ if (stat && stat.isDirectory()) {
13
+ results.push(...getFiles(fullPath, ext));
14
+ } else if (ext.some((e) => file.endsWith(e))) {
15
+ results.push(fullPath);
16
+ }
17
+ }
18
+ return results;
19
+ }
20
+
21
+ const SRC_DIR = path.join(process.cwd(), 'src');
22
+
23
+ describe('Project Titles - Separator Validation', () => {
24
+ const files = [
25
+ ...getFiles(path.join(SRC_DIR, 'tool'), ['.ts']),
26
+ ...getFiles(path.join(SRC_DIR, 'category'), ['.ts']),
27
+ ].filter(f => f.includes('i18n'));
28
+
29
+ it.each(files)('Verify that titles in %s do not contain | or -', (filePath) => {
30
+ const content = fs.readFileSync(filePath, 'utf-8');
31
+ const relativePath = path.relative(process.cwd(), filePath);
32
+
33
+ const titlePatterns = [
34
+ /const\s+title\s*=\s*['"]([^'"]+)['"]/g,
35
+ /title\s*:\s*['"]([^'"]+)['"]/g,
36
+ ];
37
+
38
+ const findings: string[] = [];
39
+
40
+ for (const pattern of titlePatterns) {
41
+ let match;
42
+ while ((match = pattern.exec(content)) !== null) {
43
+ const title = match[1]!;
44
+ if (title.includes('|') || title.includes('-')) {
45
+ findings.push(title);
46
+ }
47
+ }
48
+ }
49
+
50
+ if (findings.length > 0) {
51
+ const list = findings.map((f) => ` - "${f}"`).join('\n');
52
+ throw new Error(`Forbidden separators (| or -) found in titles in ${relativePath}:\n${list}`);
53
+ }
54
+ });
55
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import { validateToolExports } from './shared-test-helpers';
4
+
5
+ describe('Tool Exports Pattern Validation', () => {
6
+ describe('Component Exports Format', () => {
7
+ ALL_TOOLS.forEach((tool) => {
8
+ it(`${tool.entry.id}: Component should be a lazy-loaded function`, () => {
9
+ expect(typeof tool.Component).toBe('function');
10
+ expect(tool.Component).toBeInstanceOf(Function);
11
+ });
12
+
13
+ it(`${tool.entry.id}: SEOComponent should be a lazy-loaded function`, () => {
14
+ expect(typeof tool.SEOComponent).toBe('function');
15
+ expect(tool.SEOComponent).toBeInstanceOf(Function);
16
+ });
17
+
18
+ it(`${tool.entry.id}: BibliographyComponent should be a lazy-loaded function`, () => {
19
+ expect(typeof tool.BibliographyComponent).toBe('function');
20
+ expect(tool.BibliographyComponent).toBeInstanceOf(Function);
21
+ });
22
+ });
23
+ });
24
+
25
+ describe('Dynamic Import Validation', () => {
26
+ it('all tools must have functional dynamic imports', async () => {
27
+ const result = await validateToolExports(ALL_TOOLS);
28
+ if (!result.passed) {
29
+ throw new Error(`Tool export validation failed:\n${result.failures.join('\n')}`);
30
+ }
31
+ expect(result.passed).toBe(true);
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import { civicCategory } from '../data';
4
+
5
+ describe('Tool Validation Suite', () => {
6
+ describe('Library Registration', () => {
7
+ it('should have tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(1);
9
+ });
10
+
11
+ it('civicCategory should be defined', () => {
12
+ expect(civicCategory).toBeDefined();
13
+ expect(civicCategory.i18n).toBeDefined();
14
+ });
15
+ });
16
+ });
@@ -0,0 +1,123 @@
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
+ async function loadCorpora(entry: (typeof ALL_ENTRIES)[number]): Promise<Map<string, string>> {
95
+ const corpora = new Map<string, string>();
96
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
97
+ if (loader) corpora.set(locale, localeCorpus(await loader()));
98
+ }
99
+ return corpora;
100
+ }
101
+
102
+ function findViolations(corpora: Map<string, string>): string[] {
103
+ const locales = [...corpora.keys()];
104
+ const violations: string[] = [];
105
+ for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
106
+ for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
107
+ const left = locales[leftIndex] ?? '';
108
+ const right = locales[rightIndex] ?? '';
109
+ const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
110
+ if (similarity >= COPY_THRESHOLD) violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
111
+ }
112
+ }
113
+ return violations;
114
+ }
115
+
116
+ describe('Locales must not copy another locale wholesale', () => {
117
+ ALL_ENTRIES.forEach((entry) => {
118
+ it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
119
+ const violations = findViolations(await loadCorpora(entry));
120
+ expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
121
+ });
122
+ });
123
+ });
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <Bibliography links={bibliography} />
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'International IDEA, Electoral System Design: The New International IDEA Handbook',
6
+ url: 'https://www.idea.int/sites/default/files/publications/electoral-system-design-the-new-international-idea-handbook.pdf',
7
+ },
8
+ {
9
+ name: 'BOE, Ley Orgánica 5/1985 del Régimen Electoral General, artículo 163',
10
+ url: 'https://www.boe.es/buscar/act.php?id=BOE-A-1985-11672&p=20250103&tn=1#a163',
11
+ },
12
+ {
13
+ name: 'Bundeswahlgesetz, § 5 Berechnung der Sitzverteilung',
14
+ url: 'https://www.gesetze-im-internet.de/bwahlg/__5.html',
15
+ },
16
+ ];
@@ -0,0 +1,69 @@
1
+ ---
2
+ import type { ElectionSeatUI } from './ui';
3
+
4
+ interface Props {
5
+ ui: ElectionSeatUI;
6
+ }
7
+
8
+ const { ui } = Astro.props as Props;
9
+ const initialParties = [
10
+ { name: 'Cedar', votes: 42000 },
11
+ { name: 'River', votes: 31000 },
12
+ { name: 'Lantern', votes: 17000 },
13
+ { name: 'Common', votes: 10000 },
14
+ ];
15
+ ---
16
+
17
+ <div class="election-tool" data-election-tool data-ui={JSON.stringify(ui)}>
18
+ <div class="tool-layout">
19
+ <form class="control-surface" data-election-form>
20
+ <fieldset class="method-fieldset">
21
+ <legend>{ui.methodLabel}</legend>
22
+ <label class="method-option is-selected"><input type="radio" name="allocation-method" value="dhondt" checked /><span>{ui.dhondtLabel}</span></label>
23
+ <label class="method-option"><input type="radio" name="allocation-method" value="sainte-lague" /><span>{ui.sainteLagueLabel}</span></label>
24
+ <label class="method-option"><input type="radio" name="allocation-method" value="hare" /><span>{ui.hareLabel}</span></label>
25
+ </fieldset>
26
+
27
+ <section class="party-editor" aria-labelledby="party-editor-title">
28
+ <div class="control-heading"><span class="eyebrow">01</span><h2 id="party-editor-title">{ui.partiesLabel}</h2></div>
29
+ <div class="party-list" data-party-list>
30
+ {initialParties.map((party, index) => (
31
+ <div class="party-row" data-party-row>
32
+ <span class="party-row-index">{index + 1}</span>
33
+ <label><span>{ui.partyNameLabel}</span><input data-party-name type="text" value={party.name} maxlength="32" /></label>
34
+ <label><span>{ui.votesLabel}</span><input data-party-votes type="number" min="0" step="1" value={party.votes} /></label>
35
+ <button class="icon-button" data-remove-party type="button" aria-label={ui.removeParty}>×</button>
36
+ </div>
37
+ ))}
38
+ </div>
39
+ <button class="text-button" data-add-party type="button">+ {ui.addParty}</button>
40
+ </section>
41
+
42
+ <div class="number-controls">
43
+ <label><span>{ui.totalSeatsLabel}</span><input data-total-seats type="number" min="1" max="500" step="1" value="12" /></label>
44
+ <label><span>{ui.thresholdLabel}</span><input data-threshold type="number" min="0" max="100" step="0.1" value="5" /></label>
45
+ </div>
46
+
47
+ <section class="district-control" aria-labelledby="districts-title">
48
+ <div class="control-heading"><span class="eyebrow">02</span><h2 id="districts-title">{ui.districtsLabel}</h2></div>
49
+ <label><span>{ui.districtsLabel}</span><input data-district-seats type="text" placeholder={ui.districtsPlaceholder} /></label>
50
+ <p>{ui.districtsHelp}</p>
51
+ </section>
52
+
53
+ <div class="action-row"><button class="secondary-button" data-share type="button">{ui.shareResult}</button><button class="secondary-button" data-reset type="button">{ui.reset}</button></div>
54
+ <p class="tool-status" data-status role="status"></p>
55
+ </form>
56
+
57
+ <section class="result-surface" aria-live="polite">
58
+ <div class="result-intro"><span class="eyebrow">03</span><p>{ui.modelNotice}</p></div>
59
+ <div data-result class="result-content"><p class="empty-result">{ui.emptyState}</p></div>
60
+ </section>
61
+ </div>
62
+ </div>
63
+
64
+ <script>
65
+ import { mountElectionTool } from './controller';
66
+
67
+ const root = document.querySelector<HTMLElement>('[data-election-tool]');
68
+ if (root) mountElectionTool(root);
69
+ </script>
@@ -0,0 +1,181 @@
1
+ import { calculateApportionment, type ElectionInput, type PartyInput } from './logic';
2
+ import { renderResults } from './dom-views';
3
+ import { evaluateElection } from './evaluator';
4
+ import { clearElectionInput, loadElectionInput, saveElectionInput } from './storage';
5
+ import { buildShareUrl, hasSharePayload, readSharePayload } from './sharing';
6
+ import type { ElectionSeatUI } from './ui';
7
+
8
+ const defaultInput: ElectionInput = {
9
+ parties: [
10
+ { name: 'Cedar', votes: 42000 },
11
+ { name: 'River', votes: 31000 },
12
+ { name: 'Lantern', votes: 17000 },
13
+ { name: 'Common', votes: 10000 },
14
+ ],
15
+ totalSeats: 12,
16
+ thresholdPercent: 5,
17
+ method: 'dhondt',
18
+ };
19
+
20
+ const select = <T extends Element>(root: HTMLElement, selector: string): T => {
21
+ const element = root.querySelector<T>(selector);
22
+ if (!element) throw new Error(`Missing election tool element: ${selector}`);
23
+ return element;
24
+ };
25
+
26
+ const parseInputNumber = (element: HTMLInputElement): number => Number(element.value);
27
+
28
+ const escapeAttribute = (value: string): string => value
29
+ .replaceAll('&', '&amp;')
30
+ .replaceAll('"', '&quot;')
31
+ .replaceAll('<', '&lt;')
32
+ .replaceAll('>', '&gt;');
33
+
34
+ const getPartyRows = (root: HTMLElement): HTMLElement[] => Array.from(root.querySelectorAll<HTMLElement>('.party-row'));
35
+
36
+ const readParties = (root: HTMLElement): PartyInput[] => getPartyRows(root).map((row) => ({
37
+ name: select<HTMLInputElement>(row, '[data-party-name]').value,
38
+ votes: parseInputNumber(select<HTMLInputElement>(row, '[data-party-votes]')),
39
+ }));
40
+
41
+ const readDistrictSeats = (root: HTMLElement): number[] | undefined => {
42
+ const raw = select<HTMLInputElement>(root, '[data-district-seats]').value.trim();
43
+ if (!raw) return undefined;
44
+ return raw.split(',').map((value) => Number(value.trim()));
45
+ };
46
+
47
+ const readInput = (root: HTMLElement): ElectionInput => {
48
+ const districtSeats = readDistrictSeats(root);
49
+ return {
50
+ parties: readParties(root),
51
+ totalSeats: parseInputNumber(select<HTMLInputElement>(root, '[data-total-seats]')),
52
+ thresholdPercent: parseInputNumber(select<HTMLInputElement>(root, '[data-threshold]')),
53
+ method: select<HTMLInputElement>(root, 'input[name="allocation-method"]:checked').value as ElectionInput['method'],
54
+ ...(districtSeats ? { districtSeats } : {}),
55
+ };
56
+ };
57
+
58
+ const partyRowHtml = (party: PartyInput, index: number, ui: ElectionSeatUI): string => `
59
+ <div class="party-row" data-party-row>
60
+ <span class="party-row-index">${index + 1}</span>
61
+ <label><span>${ui.partyNameLabel}</span><input data-party-name type="text" value="${escapeAttribute(party.name)}" maxlength="32" /></label>
62
+ <label><span>${ui.votesLabel}</span><input data-party-votes type="number" min="0" step="1" value="${party.votes}" /></label>
63
+ <button class="icon-button" data-remove-party type="button" aria-label="${ui.removeParty}">×</button>
64
+ </div>
65
+ `;
66
+
67
+ const renderPartyInputs = (root: HTMLElement, parties: PartyInput[], ui: ElectionSeatUI): void => {
68
+ select<HTMLElement>(root, '[data-party-list]').innerHTML = parties.map((party, index) => partyRowHtml(party, index, ui)).join('');
69
+ };
70
+
71
+ const setFormValue = (root: HTMLElement, input: ElectionInput): void => {
72
+ renderPartyInputs(root, input.parties, JSON.parse(root.dataset.ui ?? '{}') as ElectionSeatUI);
73
+ select<HTMLInputElement>(root, '[data-total-seats]').value = String(input.totalSeats);
74
+ select<HTMLInputElement>(root, '[data-threshold]').value = String(input.thresholdPercent);
75
+ select<HTMLInputElement>(root, '[data-district-seats]').value = input.districtSeats?.join(', ') ?? '';
76
+ const method = select<HTMLInputElement>(root, `input[name="allocation-method"][value="${input.method}"]`);
77
+ method.checked = true;
78
+ };
79
+
80
+ const showError = (root: HTMLElement, message: string): void => {
81
+ const status = select<HTMLElement>(root, '[data-status]');
82
+ status.textContent = message;
83
+ status.className = 'tool-status is-error';
84
+ };
85
+
86
+ const showStatus = (root: HTMLElement, message: string, tone: 'positive' | 'caution'): void => {
87
+ const status = select<HTMLElement>(root, '[data-status]');
88
+ status.textContent = message;
89
+ status.className = `tool-status is-${tone}`;
90
+ };
91
+
92
+ const showResult = (root: HTMLElement, input: ElectionInput, ui: ElectionSeatUI): void => {
93
+ try {
94
+ const result = calculateApportionment(input);
95
+ const status = evaluateElection(result, ui);
96
+ const statusElement = select<HTMLElement>(root, '[data-status]');
97
+ statusElement.textContent = status.text;
98
+ statusElement.className = `tool-status is-${status.tone}`;
99
+ select<HTMLElement>(root, '[data-result]').innerHTML = renderResults(result, ui);
100
+ saveElectionInput(input);
101
+ } catch (error) {
102
+ showError(root, error instanceof Error ? error.message : ui.invalidNumber);
103
+ }
104
+ };
105
+
106
+ const bindPartyActions = (root: HTMLElement, ui: ElectionSeatUI, recalculate: () => void): void => {
107
+ select<HTMLButtonElement>(root, '[data-add-party]').onclick = () => {
108
+ const parties = readParties(root);
109
+ parties.push({ name: `List ${parties.length + 1}`, votes: 0 });
110
+ renderPartyInputs(root, parties, ui);
111
+ bindPartyActions(root, ui, recalculate);
112
+ recalculate();
113
+ };
114
+ root.querySelectorAll<HTMLButtonElement>('[data-remove-party]').forEach((button) => { button.onclick = () => {
115
+ const parties = readParties(root);
116
+ const row = button.closest<HTMLElement>('[data-party-row]');
117
+ const index = row ? getPartyRows(root).indexOf(row) : -1;
118
+ if (index >= 0 && parties.length > 2) parties.splice(index, 1);
119
+ renderPartyInputs(root, parties, ui);
120
+ bindPartyActions(root, ui, recalculate);
121
+ recalculate();
122
+ }; });
123
+ };
124
+
125
+ const bindFormActions = (root: HTMLElement, recalculate: () => void): void => {
126
+ const form = select<HTMLFormElement>(root, '[data-election-form]');
127
+ form.addEventListener('input', recalculate);
128
+ form.addEventListener('change', recalculate);
129
+ form.addEventListener('submit', (event) => {
130
+ event.preventDefault();
131
+ recalculate();
132
+ });
133
+ };
134
+
135
+ const bindResetAction = (root: HTMLElement, recalculate: () => void, ui: ElectionSeatUI): void => {
136
+ select<HTMLButtonElement>(root, '[data-reset]').addEventListener('click', () => {
137
+ clearElectionInput();
138
+ setFormValue(root, defaultInput);
139
+ bindPartyActions(root, ui, recalculate);
140
+ recalculate();
141
+ });
142
+ };
143
+
144
+ const copyShareLink = async (root: HTMLElement, ui: ElectionSeatUI): Promise<void> => {
145
+ const input = readInput(root);
146
+ try {
147
+ calculateApportionment(input);
148
+ const shareUrl = buildShareUrl(window.location.href, input);
149
+ try {
150
+ await navigator.clipboard.writeText(shareUrl);
151
+ showStatus(root, ui.shareCopied, 'positive');
152
+ } catch {
153
+ window.prompt(ui.shareCopyFallback, shareUrl);
154
+ showStatus(root, ui.shareCopyFallback, 'caution');
155
+ }
156
+ } catch (error) {
157
+ showError(root, error instanceof Error ? error.message : ui.invalidNumber);
158
+ }
159
+ };
160
+
161
+ const bindShareAction = (root: HTMLElement, ui: ElectionSeatUI): void => {
162
+ select<HTMLButtonElement>(root, '[data-share]').addEventListener('click', () => {
163
+ void copyShareLink(root, ui);
164
+ });
165
+ };
166
+
167
+ export const mountElectionTool = (root: HTMLElement): void => {
168
+ const ui = JSON.parse(root.dataset.ui ?? '{}') as ElectionSeatUI;
169
+ const sharedInput = readSharePayload(window.location.search);
170
+ const invalidSharedInput = hasSharePayload(window.location.search) && !sharedInput;
171
+ const saved = loadElectionInput();
172
+ const initial = sharedInput ?? saved ?? defaultInput;
173
+ setFormValue(root, initial);
174
+ const recalculate = (): void => showResult(root, readInput(root), ui);
175
+ bindPartyActions(root, ui, recalculate);
176
+ bindFormActions(root, recalculate);
177
+ bindResetAction(root, recalculate, ui);
178
+ bindShareAction(root, ui);
179
+ recalculate();
180
+ if (invalidSharedInput) showError(root, ui.invalidSharedResult);
181
+ };