@jjlmoya/utils-language 1.10.0 → 1.11.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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +2 -1
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/registry_contract.test.ts +67 -0
  6. package/src/tests/tool_exports.test.ts +8 -0
  7. package/src/tests/tool_validation.test.ts +1 -1
  8. package/src/tool/language-shadowing-session-planner/component.astro +1 -0
  9. package/src/tool/lexical-coverage-vocabulary-calculator/bibliography.astro +6 -0
  10. package/src/tool/lexical-coverage-vocabulary-calculator/bibliography.ts +6 -0
  11. package/src/tool/lexical-coverage-vocabulary-calculator/component.astro +77 -0
  12. package/src/tool/lexical-coverage-vocabulary-calculator/controller.ts +157 -0
  13. package/src/tool/lexical-coverage-vocabulary-calculator/dom-views.ts +158 -0
  14. package/src/tool/lexical-coverage-vocabulary-calculator/entry.ts +34 -0
  15. package/src/tool/lexical-coverage-vocabulary-calculator/evaluator.ts +13 -0
  16. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/de.ts +13 -0
  17. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/en.ts +59 -0
  18. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/es.ts +13 -0
  19. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/fr.ts +53 -0
  20. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/id.ts +13 -0
  21. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/it.ts +13 -0
  22. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/ja.ts +13 -0
  23. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/ko.ts +13 -0
  24. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/nl.ts +13 -0
  25. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/pl.ts +13 -0
  26. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/pt.ts +13 -0
  27. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/ru.ts +13 -0
  28. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/sv.ts +13 -0
  29. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/tr.ts +59 -0
  30. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/zh.ts +13 -0
  31. package/src/tool/lexical-coverage-vocabulary-calculator/index.ts +14 -0
  32. package/src/tool/lexical-coverage-vocabulary-calculator/lexical-coverage-vocabulary-calculator.css +497 -0
  33. package/src/tool/lexical-coverage-vocabulary-calculator/logic.test.ts +35 -0
  34. package/src/tool/lexical-coverage-vocabulary-calculator/logic.ts +76 -0
  35. package/src/tool/lexical-coverage-vocabulary-calculator/seo.astro +11 -0
  36. package/src/tool/lexical-coverage-vocabulary-calculator/storage.ts +24 -0
  37. package/src/tool/lexical-coverage-vocabulary-calculator/types.ts +33 -0
  38. package/src/tool/lexical-coverage-vocabulary-calculator/ui.ts +50 -0
  39. package/src/tool/lexical-coverage-vocabulary-calculator/validation.ts +18 -0
  40. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-language",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,11 +1,12 @@
1
1
  import { cefrLanguageSkillProfilePlanner } from '../tool/cefr-language-skill-profile-planner/entry';
2
2
  import { languageShadowingSessionPlanner } from '../tool/language-shadowing-session-planner/entry';
3
3
  import { languageLearningStudyPlanPlanner } from '../tool/language-learning-study-plan-planner/entry';
4
+ import { lexicalCoverageVocabularyCalculator } from '../tool/lexical-coverage-vocabulary-calculator/entry';
4
5
  import type { CategoryLocaleContent, KnownLocale } from '../types';
5
6
 
6
7
  export const languageCategory = {
7
8
  icon: 'mdi:translate-variant',
8
- tools: [languageLearningStudyPlanPlanner, cefrLanguageSkillProfilePlanner, languageShadowingSessionPlanner],
9
+ tools: [languageLearningStudyPlanPlanner, cefrLanguageSkillProfilePlanner, languageShadowingSessionPlanner, lexicalCoverageVocabularyCalculator],
9
10
  i18n: {
10
11
  de: () => import('./i18n/de').then((module) => module.content),
11
12
  en: () => import('./i18n/en').then((module) => module.content),
package/src/entries.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cefrLanguageSkillProfilePlanner } from './tool/cefr-language-skill-profile-planner/entry';
2
2
  import { languageShadowingSessionPlanner } from './tool/language-shadowing-session-planner/entry';
3
3
  import { languageLearningStudyPlanPlanner } from './tool/language-learning-study-plan-planner/entry';
4
+ import { lexicalCoverageVocabularyCalculator } from './tool/lexical-coverage-vocabulary-calculator/entry';
4
5
 
5
- export const ALL_ENTRIES = [languageLearningStudyPlanPlanner, cefrLanguageSkillProfilePlanner, languageShadowingSessionPlanner];
6
+ export const ALL_ENTRIES = [languageLearningStudyPlanPlanner, cefrLanguageSkillProfilePlanner, languageShadowingSessionPlanner, lexicalCoverageVocabularyCalculator];
@@ -18,6 +18,6 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
 
20
20
  it('all tools registered', () => {
21
- expect(ALL_TOOLS.length).toBe(3);
21
+ expect(ALL_TOOLS.length).toBe(4);
22
22
  });
23
23
  });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { existsSync, readdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import { ALL_ENTRIES } from '../entries';
6
+ import { ALL_TOOLS } from '../tools';
7
+ import type { ToolDefinition } from '../types';
8
+
9
+ function isToolDefinition(value: unknown): value is ToolDefinition {
10
+ if (!value || typeof value !== 'object') return false;
11
+
12
+ const candidate = value as Record<string, unknown>;
13
+ return Boolean(
14
+ candidate.entry &&
15
+ typeof candidate.entry === 'object' &&
16
+ typeof candidate.Component === 'function' &&
17
+ typeof candidate.SEOComponent === 'function' &&
18
+ typeof candidate.BibliographyComponent === 'function',
19
+ );
20
+ }
21
+
22
+ const toolRoot = join(process.cwd(), 'src', 'tool');
23
+ const toolDirectories = readdirSync(toolRoot, { withFileTypes: true })
24
+ .filter((entry) => entry.isDirectory() && existsSync(join(toolRoot, entry.name, 'index.ts')))
25
+ .map((entry) => entry.name)
26
+ .sort();
27
+
28
+ describe('Library registry contract', () => {
29
+ it('keeps ALL_ENTRIES and ALL_TOOLS synchronized by id', () => {
30
+ const entryIds = ALL_ENTRIES.map((entry) => entry.id);
31
+ const toolIds = ALL_TOOLS.map((tool) => tool.entry.id);
32
+
33
+ expect(new Set(entryIds).size).toBe(entryIds.length);
34
+ expect(new Set(toolIds).size).toBe(toolIds.length);
35
+ expect([...toolIds].sort()).toEqual([...entryIds].sort());
36
+ });
37
+
38
+ it('registers every tool runtime index in both public registries', async () => {
39
+ const failures: string[] = [];
40
+
41
+ for (const directory of toolDirectories) {
42
+ const runtimeModule = await import(
43
+ pathToFileURL(join(toolRoot, directory, 'index.ts')).href,
44
+ );
45
+ const definitions = [...new Set(Object.values(runtimeModule).filter(isToolDefinition))];
46
+
47
+ if (definitions.length !== 1) {
48
+ failures.push(`${directory}: expected exactly one ToolDefinition export, found ${definitions.length}`);
49
+ continue;
50
+ }
51
+
52
+ const [definition] = definitions;
53
+ if (!definition) {
54
+ failures.push(`${directory}: runtime definition is undefined`);
55
+ continue;
56
+ }
57
+ if (!ALL_TOOLS.some((tool) => tool.entry.id === definition.entry.id)) {
58
+ failures.push(`${directory}: runtime definition is missing from ALL_TOOLS`);
59
+ }
60
+ if (!ALL_ENTRIES.some((entry) => entry.id === definition.entry.id)) {
61
+ failures.push(`${directory}: runtime entry is missing from ALL_ENTRIES`);
62
+ }
63
+ }
64
+
65
+ expect(failures).toEqual([]);
66
+ });
67
+ });
@@ -1,4 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import { ALL_TOOLS } from '../tools';
3
5
  import { validateToolExports } from './shared-test-helpers';
4
6
 
@@ -20,6 +22,12 @@ describe('Tool Exports Pattern Validation', () => {
20
22
  expect(tool.BibliographyComponent).toBeInstanceOf(Function);
21
23
  });
22
24
  });
25
+
26
+ it.each(ALL_TOOLS)('$entry.id: Component should load its tool stylesheet', (tool) => {
27
+ const componentPath = join(process.cwd(), 'src', 'tool', tool.entry.id, 'component.astro');
28
+ const source = readFileSync(componentPath, 'utf8');
29
+ expect(source).toContain(`import './${tool.entry.id}.css';`);
30
+ });
23
31
  });
24
32
 
25
33
  describe('Dynamic Import Validation', () => {
@@ -5,7 +5,7 @@ import { languageCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(3);
8
+ expect(ALL_TOOLS.length).toBe(4);
9
9
  });
10
10
 
11
11
  it('languageCategory should be defined', () => {
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import './language-shadowing-session-planner.css';
2
3
  import { calculateShadowingPlan, DEFAULT_SHADOWING_INPUTS } from './logic';
3
4
  import type { ShadowingSessionUI } from './ui';
4
5
 
@@ -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,6 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'Paul Nation, How Large a Vocabulary Is Needed for Reading and Listening?', url: 'https://www.scienceguide.nl/wp-content/uploads/2017/11/nation-2006-vocabulary.pdf' },
5
+ { name: 'Izquierdo Gil, La selección del léxico en la enseñanza del español como lengua extranjera', url: 'https://asele.org/media/2025/03/8izquierdogilm-adelcarmenlaselecciondellexicoenlaensananza.pdf' },
6
+ ];
@@ -0,0 +1,77 @@
1
+ ---
2
+ import './lexical-coverage-vocabulary-calculator.css';
3
+ import { COVERAGE_TARGETS } from './logic';
4
+ import type { ContentType } from './types';
5
+ import type { LexicalCoverageUI } from './ui';
6
+
7
+ interface Props {
8
+ ui: LexicalCoverageUI;
9
+ }
10
+
11
+ const { ui } = Astro.props as Props;
12
+ const contentTypes: ContentType[] = ['conversation', 'travel', 'media', 'generalReading', 'novels', 'business', 'academic'];
13
+ const contentLabels: Record<ContentType, string> = {
14
+ conversation: ui.conversation, travel: ui.travel, media: ui.media, generalReading: ui.generalReading, novels: ui.novels, business: ui.business, academic: ui.academic,
15
+ };
16
+ const coverageLabels: Record<number, string> = { 80: ui.coverage80, 90: ui.coverage90, 95: ui.coverage95, 98: ui.coverage98 };
17
+ ---
18
+
19
+ <div class="lexical-coverage" data-lexical-coverage data-ui={JSON.stringify(ui)}>
20
+ <form class="lexical-form" data-lexical-form>
21
+ <div class="content-field" data-content-choice>
22
+ <span class="field-label">{ui.contentType}</span>
23
+ <input type="hidden" name="contentType" value="conversation" />
24
+ <button class="choice-trigger" type="button" aria-haspopup="listbox" aria-expanded="false">{ui.conversation}</button>
25
+ <div class="choice-menu" role="listbox" hidden>
26
+ {contentTypes.map((type) => <button type="button" role="option" data-choice-value={type} aria-selected={type === 'conversation'}>{contentLabels[type]}</button>)}
27
+ </div>
28
+ </div>
29
+
30
+ <label class="words-field">
31
+ <span class="field-label">{ui.knownWords}</span>
32
+ <input type="number" name="knownWords" min="0" max="50000" step="100" value="1000" required />
33
+ <input class="words-range" type="range" name="knownWordsRange" min="0" max="15000" step="100" value="1000" aria-label={ui.knownWords} />
34
+ <span class="words-hint">{ui.knownWordsHint}</span>
35
+ </label>
36
+
37
+ <fieldset>
38
+ <legend class="coverage-label">{ui.targetCoverage}</legend>
39
+ <input type="hidden" name="targetCoverage" value="80" />
40
+ <div class="coverage-options">
41
+ {COVERAGE_TARGETS.map((coverage) => <button type="button" data-coverage-value={coverage} aria-label={coverageLabels[coverage]} aria-pressed={coverage === 80}>{coverage}%</button>)}
42
+ </div>
43
+ </fieldset>
44
+
45
+ <div>
46
+ <span class="preset-label">{ui.quickStart}</span>
47
+ <div class="preset-grid">
48
+ <button type="button" data-lexical-preset="conversation">{ui.conversationPreset}</button>
49
+ <button type="button" data-lexical-preset="reading">{ui.readingPreset}</button>
50
+ <button type="button" data-lexical-preset="work">{ui.workPreset}</button>
51
+ </div>
52
+ </div>
53
+
54
+ <p class="lexical-note">{ui.contentTypeHelp} {ui.coverageNote} {ui.wordFamilyNote}</p>
55
+ <div class="lexical-actions">
56
+ <button class="reset-button" type="reset">{ui.resetButton}</button>
57
+ </div>
58
+ </form>
59
+
60
+ <section class="lexical-result" aria-live="polite" aria-atomic="true">
61
+ <h2 class="lexical-result-title">{ui.resultTitle}</h2>
62
+ <div data-lexical-results>
63
+ <div class="lexical-empty">
64
+ <svg class="empty-atlas-glyph" viewBox="0 0 120 90" aria-hidden="true"><path class="empty-page" d="M 18 68 L 27 18 L 88 10 L 102 66 Z"></path><line class="empty-line" x1="36" y1="37" x2="82" y2="31"></line><line class="empty-line" x1="33" y1="49" x2="79" y2="43"></line></svg>
65
+ <span class="lexical-empty-copy">{ui.emptyResult}</span>
66
+ </div>
67
+ </div>
68
+ </section>
69
+ </div>
70
+
71
+ <script>
72
+ import { mountLexicalCoverage } from './controller';
73
+ import type { LexicalCoverageUI } from './ui';
74
+
75
+ const root = document.querySelector<HTMLElement>('[data-lexical-coverage]');
76
+ if (root) mountLexicalCoverage(root, JSON.parse(root.dataset.ui ?? '{}') as LexicalCoverageUI);
77
+ </script>
@@ -0,0 +1,157 @@
1
+ import { calculateLexicalCoverage } from './logic';
2
+ import { renderLexicalCoverage, renderLexicalCoverageEmpty, renderLexicalCoverageError } from './dom-views';
3
+ import { clearLexicalCoverageInputs, readLexicalCoverageInputs, saveLexicalCoverageInputs } from './storage';
4
+ import type { ContentType, CoverageTarget, LexicalCoverageInputs } from './types';
5
+ import type { LexicalCoverageUI } from './ui';
6
+
7
+ export function mountLexicalCoverage(root: HTMLElement, ui: LexicalCoverageUI): void {
8
+ const form = root.querySelector<HTMLFormElement>('[data-lexical-form]');
9
+ if (!form) return;
10
+ applySavedInputs(form, readLexicalCoverageInputs());
11
+ const recalculate = (): void => calculateAndRender(root, form, ui);
12
+ setupContentChoice(root, form, recalculate);
13
+ setupCoverageChips(form, recalculate);
14
+ setupWordSync(form, recalculate);
15
+ form.addEventListener('submit', (event) => submit(event, root, form, ui));
16
+ form.addEventListener('reset', () => reset(root, form, ui));
17
+ root.querySelectorAll<HTMLButtonElement>('[data-lexical-preset]').forEach((button) => button.addEventListener('click', () => applyPreset(button.dataset.lexicalPreset, form, recalculate)));
18
+ recalculate();
19
+ }
20
+
21
+ function submit(event: SubmitEvent, root: HTMLElement, form: HTMLFormElement, ui: LexicalCoverageUI): void {
22
+ event.preventDefault();
23
+ calculateAndRender(root, form, ui);
24
+ }
25
+
26
+ function calculateAndRender(root: HTMLElement, form: HTMLFormElement, ui: LexicalCoverageUI): void {
27
+ const result = calculateLexicalCoverage(readInputs(form));
28
+ if (!result.ok) {
29
+ renderLexicalCoverageError(root, translateError(result.error, ui), ui);
30
+ return;
31
+ }
32
+ saveLexicalCoverageInputs(result.plan.inputs);
33
+ renderLexicalCoverage(root, result.plan, ui);
34
+ }
35
+
36
+ function reset(root: HTMLElement, form: HTMLFormElement, ui: LexicalCoverageUI): void {
37
+ window.setTimeout(() => {
38
+ clearLexicalCoverageInputs();
39
+ setField(form, 'contentType', 'conversation');
40
+ setField(form, 'knownWords', 1000);
41
+ setField(form, 'knownWordsRange', 1000);
42
+ setField(form, 'targetCoverage', 80);
43
+ syncChoice(form);
44
+ syncCoverage(form);
45
+ renderLexicalCoverageEmpty(root, ui);
46
+ }, 0);
47
+ }
48
+
49
+ function setupContentChoice(root: HTMLElement, form: HTMLFormElement, onChange: () => void): void {
50
+ const choice = root.querySelector<HTMLElement>('[data-content-choice]');
51
+ const trigger = choice?.querySelector<HTMLButtonElement>('.choice-trigger');
52
+ const menu = choice?.querySelector<HTMLElement>('.choice-menu');
53
+ if (!choice || !trigger || !menu) return;
54
+ trigger.addEventListener('click', () => {
55
+ const open = trigger.getAttribute('aria-expanded') === 'true';
56
+ closeChoice(trigger, menu);
57
+ if (!open) {
58
+ trigger.setAttribute('aria-expanded', 'true');
59
+ menu.hidden = false;
60
+ }
61
+ });
62
+ menu.querySelectorAll<HTMLButtonElement>('[data-choice-value]').forEach((option) => option.addEventListener('click', () => {
63
+ setField(form, 'contentType', option.dataset.choiceValue);
64
+ trigger.textContent = option.textContent;
65
+ menu.hidden = true;
66
+ trigger.setAttribute('aria-expanded', 'false');
67
+ menu.querySelectorAll('[data-choice-value]').forEach((item) => item.setAttribute('aria-selected', String(item === option)));
68
+ onChange();
69
+ }));
70
+ document.addEventListener('click', (event) => {
71
+ if (!choice.contains(event.target as Node)) closeChoice(trigger, menu);
72
+ });
73
+ trigger.addEventListener('keydown', (event) => {
74
+ if (event.key === 'Escape') closeChoice(trigger, menu);
75
+ });
76
+ syncChoice(form);
77
+ }
78
+
79
+ function setupCoverageChips(form: HTMLFormElement, onChange: () => void): void {
80
+ form.querySelectorAll<HTMLButtonElement>('[data-coverage-value]').forEach((button) => button.addEventListener('click', () => {
81
+ setField(form, 'targetCoverage', button.dataset.coverageValue);
82
+ syncCoverage(form);
83
+ onChange();
84
+ }));
85
+ syncCoverage(form);
86
+ }
87
+
88
+ function setupWordSync(form: HTMLFormElement, onChange: () => void): void {
89
+ const range = form.elements.namedItem('knownWordsRange') as HTMLInputElement | null;
90
+ const number = form.elements.namedItem('knownWords') as HTMLInputElement | null;
91
+ if (!range || !number) return;
92
+ range.addEventListener('input', () => { number.value = range.value; onChange(); });
93
+ number.addEventListener('input', () => { range.value = number.value; onChange(); });
94
+ }
95
+
96
+ function applyPreset(preset: string | undefined, form: HTMLFormElement, onChange: () => void): void {
97
+ const values: Record<string, [ContentType, number, CoverageTarget]> = {
98
+ conversation: ['conversation', 1000, 80], reading: ['novels', 4000, 95], work: ['business', 5000, 95],
99
+ };
100
+ const value = values[preset ?? 'conversation'] ?? values.conversation!;
101
+ setField(form, 'contentType', value[0]);
102
+ setField(form, 'knownWords', value[1]);
103
+ setField(form, 'knownWordsRange', value[1]);
104
+ setField(form, 'targetCoverage', value[2]);
105
+ syncChoice(form);
106
+ syncCoverage(form);
107
+ onChange();
108
+ }
109
+
110
+ function applySavedInputs(form: HTMLFormElement, saved: Partial<LexicalCoverageInputs> | null): void {
111
+ if (!saved) return;
112
+ setField(form, 'contentType', saved.contentType);
113
+ setField(form, 'knownWords', saved.knownWords);
114
+ setField(form, 'knownWordsRange', saved.knownWords);
115
+ setField(form, 'targetCoverage', saved.targetCoverage);
116
+ }
117
+
118
+ function readInputs(form: HTMLFormElement): LexicalCoverageInputs {
119
+ return { contentType: value(form, 'contentType') as ContentType, knownWords: Number(value(form, 'knownWords')), targetCoverage: Number(value(form, 'targetCoverage')) as CoverageTarget };
120
+ }
121
+
122
+ function syncChoice(form: HTMLFormElement): void {
123
+ const choice = form.querySelector<HTMLElement>('[data-content-choice]');
124
+ const hidden = form.elements.namedItem('contentType') as HTMLInputElement | null;
125
+ if (!choice || !hidden) return;
126
+ const selected = choice.querySelector<HTMLButtonElement>(`[data-choice-value="${hidden.value}"]`);
127
+ const trigger = choice.querySelector<HTMLButtonElement>('.choice-trigger');
128
+ if (selected && trigger) trigger.textContent = selected.textContent;
129
+ choice.querySelectorAll('[data-choice-value]').forEach((item) => item.setAttribute('aria-selected', String((item as HTMLElement).dataset.choiceValue === hidden.value)));
130
+ }
131
+
132
+ function syncCoverage(form: HTMLFormElement): void {
133
+ const selected = value(form, 'targetCoverage');
134
+ form.querySelectorAll('[data-coverage-value]').forEach((item) => item.setAttribute('aria-pressed', String((item as HTMLElement).dataset.coverageValue === selected)));
135
+ }
136
+
137
+ function closeChoice(trigger: HTMLButtonElement, menu: HTMLElement): void {
138
+ trigger.setAttribute('aria-expanded', 'false');
139
+ menu.hidden = true;
140
+ }
141
+
142
+ function translateError(error: string, ui: LexicalCoverageUI): string {
143
+ if (error.includes('content type')) return ui.invalidContentType;
144
+ if (error.includes('Known vocabulary')) return ui.invalidWords;
145
+ if (error.includes('coverage target')) return ui.invalidCoverage;
146
+ return error;
147
+ }
148
+
149
+ function value(form: HTMLFormElement, name: string): string {
150
+ return String((form.elements.namedItem(name) as HTMLInputElement).value);
151
+ }
152
+
153
+ function setField(form: HTMLFormElement, name: string, fieldValue: unknown): void {
154
+ if (fieldValue === undefined) return;
155
+ const field = form.elements.namedItem(name) as HTMLInputElement | null;
156
+ if (field) field.value = String(fieldValue);
157
+ }
@@ -0,0 +1,158 @@
1
+ import { evaluateLexicalCoverage } from './evaluator';
2
+ import type { LexicalCoveragePlan } from './types';
3
+ import type { LexicalCoverageUI } from './ui';
4
+
5
+ export function renderLexicalCoverage(root: HTMLElement, plan: LexicalCoveragePlan, ui: LexicalCoverageUI): void {
6
+ const results = root.querySelector<HTMLElement>('[data-lexical-results]');
7
+ if (!results) return;
8
+ const evaluation = evaluateLexicalCoverage(plan);
9
+ results.replaceChildren(createStatus(evaluation.messageKey, ui), createHeadline(plan, ui), createCoverageScene(plan, ui), createReadout(plan, ui));
10
+ }
11
+
12
+ export function renderLexicalCoverageEmpty(root: HTMLElement, ui: LexicalCoverageUI): void {
13
+ const results = root.querySelector<HTMLElement>('[data-lexical-results]');
14
+ if (!results) return;
15
+ const empty = document.createElement('div');
16
+ empty.className = 'lexical-empty';
17
+ empty.append(createEmptyGlyph(), createText('lexical-empty-copy', ui.emptyResult));
18
+ results.replaceChildren(empty);
19
+ }
20
+
21
+ export function renderLexicalCoverageError(root: HTMLElement, message: string, ui: LexicalCoverageUI): void {
22
+ const results = root.querySelector<HTMLElement>('[data-lexical-results]');
23
+ if (!results) return;
24
+ results.replaceChildren(createText('lexical-error', `${ui.checkInputs}: ${message}`));
25
+ }
26
+
27
+ function createStatus(messageKey: 'belowTarget' | 'atTarget' | 'buffer', ui: LexicalCoverageUI): HTMLElement {
28
+ const status = document.createElement('p');
29
+ status.className = `lexical-status ${messageKey.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
30
+ status.textContent = ui[messageKey];
31
+ return status;
32
+ }
33
+
34
+ function createHeadline(plan: LexicalCoveragePlan, ui: LexicalCoverageUI): HTMLElement {
35
+ const headline = document.createElement('div');
36
+ headline.className = 'lexical-headline';
37
+ headline.append(metric(ui.recommendedWords, `${formatNumber(plan.recommendedWords)} ${ui.wordUnit}`), metric(ui.gapWords, `${formatNumber(plan.gapWords)} ${ui.wordUnit}`));
38
+ return headline;
39
+ }
40
+
41
+ function createCoverageScene(plan: LexicalCoveragePlan, ui: LexicalCoverageUI): HTMLElement {
42
+ const section = document.createElement('section');
43
+ section.className = 'lexical-scene-wrap';
44
+ section.append(createText('lexical-scene-label', ui.coverageRoute), createAtlas(plan, ui));
45
+ return section;
46
+ }
47
+
48
+ function createAtlas(plan: LexicalCoveragePlan, ui: LexicalCoverageUI): SVGSVGElement {
49
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
50
+ svg.classList.add('lexical-atlas');
51
+ svg.setAttribute('viewBox', '0 0 720 240');
52
+ svg.setAttribute('role', 'img');
53
+ svg.setAttribute('aria-label', `${formatNumber(plan.estimatedCoverage)}% ${ui.targetMarker}: ${formatNumber(plan.knownWords)} ${ui.wordUnit}`);
54
+ svg.append(createSvgPath('atlas-river', 'M 34 170 C 160 120 190 190 310 140 S 490 80 686 54'), createSvgPath('atlas-bank', 'M 34 194 C 160 144 190 214 310 164 S 490 104 686 78'));
55
+ plan.checkpoints.forEach((checkpoint, index) => appendCheckpoint(svg, { checkpoint, index, checkpointCount: plan.checkpoints.length, ui }));
56
+ const markerX = getMarkerX(plan.estimatedCoverage);
57
+ svg.append(createSvgCircle(markerX, 112, 'atlas-current-marker'), createSvgText(markerX, 91, `${formatNumber(plan.estimatedCoverage)}%`, 'atlas-current-label'));
58
+ return svg;
59
+ }
60
+
61
+ function appendCheckpoint(svg: SVGSVGElement, data: { checkpoint: { coverage: number; words: number }; index: number; checkpointCount: number; ui: LexicalCoverageUI }): void {
62
+ const { checkpoint, index, checkpointCount, ui } = data;
63
+ const x = getMarkerX(checkpoint.coverage);
64
+ const y = 170 - index * 39;
65
+ const coverageLabel = createSvgText(x, 228, `${checkpoint.coverage}%`, 'atlas-coverage');
66
+ coverageLabel.setAttribute('text-anchor', index === checkpointCount - 1 ? 'start' : 'end');
67
+ const wordLabel = createSvgText(x, index === 2 ? 38 : 16, `${formatNumber(checkpoint.words)} ${ui.wordUnit}`, 'atlas-word-count');
68
+ wordLabel.classList.toggle('atlas-word-count-intermediate', index > 0 && index < checkpointCount - 1);
69
+ wordLabel.setAttribute('text-anchor', index === checkpointCount - 1 ? 'start' : 'end');
70
+ svg.append(createSvgLine({ x1: x, y1: y, x2: x, y2: 210 }, 'atlas-guide'), createSvgCircle(x, y, 'atlas-checkpoint'), coverageLabel, wordLabel);
71
+ }
72
+
73
+ function createReadout(plan: LexicalCoveragePlan, ui: LexicalCoverageUI): HTMLElement {
74
+ const readout = document.createElement('div');
75
+ readout.className = 'lexical-readout';
76
+ readout.append(metric(ui.vocabularyKnown, `${formatNumber(plan.knownWords)} ${ui.wordUnit}`), metric(ui.estimatedCoverage, `${formatNumber(plan.estimatedCoverage)}%`), metric(ui.unknownPerHundred, `${formatNumber(plan.unknownPerHundred)} ${ui.unknownWords}`));
77
+ const interpretation = document.createElement('div');
78
+ interpretation.className = 'lexical-interpretation';
79
+ interpretation.append(createText('lexical-interpretation-title', ui.interpretationTitle), createText('lexical-interpretation-copy', getAction(plan, ui)));
80
+ const next = document.createElement('p');
81
+ next.className = 'lexical-next';
82
+ next.textContent = plan.nextCheckpoint ? `${ui.nextCheckpoint}: ${formatNumber(plan.nextCheckpoint.words)} ${ui.wordUnit} at ${plan.nextCheckpoint.coverage}%` : ui.noMoreCheckpoints;
83
+ readout.append(interpretation, next);
84
+ return readout;
85
+ }
86
+
87
+ function getAction(plan: LexicalCoveragePlan, ui: LexicalCoverageUI): string {
88
+ if (plan.status === 'below-target') return ui.actionBelow;
89
+ if (plan.status === 'at-target') return ui.actionAt;
90
+ return ui.actionBuffer;
91
+ }
92
+
93
+ function createEmptyGlyph(): SVGSVGElement {
94
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
95
+ svg.classList.add('empty-atlas-glyph');
96
+ svg.setAttribute('viewBox', '0 0 120 90');
97
+ svg.setAttribute('aria-hidden', 'true');
98
+ svg.append(createSvgPath('empty-page', 'M 18 68 L 27 18 L 88 10 L 102 66 Z'), createSvgLine({ x1: 36, y1: 37, x2: 82, y2: 31 }, 'empty-line'), createSvgLine({ x1: 33, y1: 49, x2: 79, y2: 43 }, 'empty-line'));
99
+ return svg;
100
+ }
101
+
102
+ function createSvgPath(className: string, d: string): SVGPathElement {
103
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
104
+ path.classList.add(className);
105
+ path.setAttribute('d', d);
106
+ return path;
107
+ }
108
+
109
+ function createSvgLine(lineData: { x1: number; y1: number; x2: number; y2: number }, className: string): SVGLineElement {
110
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
111
+ line.classList.add(className);
112
+ line.setAttribute('x1', String(lineData.x1));
113
+ line.setAttribute('y1', String(lineData.y1));
114
+ line.setAttribute('x2', String(lineData.x2));
115
+ line.setAttribute('y2', String(lineData.y2));
116
+ return line;
117
+ }
118
+
119
+ function createSvgCircle(cx: number, cy: number, className: string): SVGCircleElement {
120
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
121
+ circle.classList.add(className);
122
+ circle.setAttribute('cx', String(cx));
123
+ circle.setAttribute('cy', String(cy));
124
+ circle.setAttribute('r', className === 'atlas-current-marker' ? '10' : '7');
125
+ return circle;
126
+ }
127
+
128
+ function createSvgText(x: number, y: number, text: string, className: string): SVGTextElement {
129
+ const element = document.createElementNS('http://www.w3.org/2000/svg', 'text');
130
+ element.classList.add(className);
131
+ element.setAttribute('x', String(x));
132
+ element.setAttribute('y', String(y));
133
+ element.setAttribute('text-anchor', 'middle');
134
+ element.textContent = text;
135
+ return element;
136
+ }
137
+
138
+ function createText(className: string, text: string): HTMLElement {
139
+ const element = document.createElement('span');
140
+ element.className = className;
141
+ element.textContent = text;
142
+ return element;
143
+ }
144
+
145
+ function metric(label: string, value: string): HTMLElement {
146
+ const item = document.createElement('div');
147
+ item.className = 'lexical-metric';
148
+ item.append(createText('lexical-metric-label', label), createText('lexical-metric-value', value));
149
+ return item;
150
+ }
151
+
152
+ function getMarkerX(coverage: number): number {
153
+ return 34 + (Math.min(98, Math.max(0, coverage)) / 98) * 652;
154
+ }
155
+
156
+ function formatNumber(value: number): string {
157
+ return new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value);
158
+ }
@@ -0,0 +1,34 @@
1
+ import type { LanguageToolEntry, ToolDefinition, ToolLocaleContent } from '../../types';
2
+ import type { LexicalCoverageUI } from './ui';
3
+
4
+ export type { LexicalCoverageUI } from './ui';
5
+ export type LexicalCoverageVocabularyCalculatorLocaleContent = ToolLocaleContent<LexicalCoverageUI>;
6
+
7
+ export const lexicalCoverageVocabularyCalculator: LanguageToolEntry<LexicalCoverageUI> = {
8
+ id: 'lexical-coverage-vocabulary-calculator',
9
+ icons: { bg: 'mdi:book-search-outline', fg: 'mdi:translate-variant' },
10
+ i18n: {
11
+ de: () => import('./i18n/de').then((module) => module.content),
12
+ en: () => import('./i18n/en').then((module) => module.content),
13
+ es: () => import('./i18n/es').then((module) => module.content),
14
+ fr: () => import('./i18n/fr').then((module) => module.content),
15
+ id: () => import('./i18n/id').then((module) => module.content),
16
+ it: () => import('./i18n/it').then((module) => module.content),
17
+ ja: () => import('./i18n/ja').then((module) => module.content),
18
+ ko: () => import('./i18n/ko').then((module) => module.content),
19
+ nl: () => import('./i18n/nl').then((module) => module.content),
20
+ pl: () => import('./i18n/pl').then((module) => module.content),
21
+ pt: () => import('./i18n/pt').then((module) => module.content),
22
+ ru: () => import('./i18n/ru').then((module) => module.content),
23
+ sv: () => import('./i18n/sv').then((module) => module.content),
24
+ tr: () => import('./i18n/tr').then((module) => module.content),
25
+ zh: () => import('./i18n/zh').then((module) => module.content),
26
+ },
27
+ };
28
+
29
+ export const LEXICAL_COVERAGE_VOCABULARY_CALCULATOR_TOOL: ToolDefinition = {
30
+ entry: lexicalCoverageVocabularyCalculator,
31
+ Component: () => import('./component.astro'),
32
+ SEOComponent: () => import('./seo.astro'),
33
+ BibliographyComponent: () => import('./bibliography.astro'),
34
+ };
@@ -0,0 +1,13 @@
1
+ import type { CoverageStatus, LexicalCoveragePlan } from './types';
2
+
3
+ export interface CoverageEvaluation {
4
+ status: CoverageStatus;
5
+ tone: 'warn' | 'good' | 'strong';
6
+ messageKey: 'belowTarget' | 'atTarget' | 'buffer';
7
+ }
8
+
9
+ export function evaluateLexicalCoverage(plan: LexicalCoveragePlan): CoverageEvaluation {
10
+ if (plan.status === 'below-target') return { status: plan.status, tone: 'warn', messageKey: 'belowTarget' };
11
+ if (plan.status === 'at-target') return { status: plan.status, tone: 'good', messageKey: 'atTarget' };
12
+ return { status: plan.status, tone: 'strong', messageKey: 'buffer' };
13
+ }
@@ -0,0 +1,13 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import type { LexicalCoverageUI } from '../ui';
4
+
5
+ const ui: LexicalCoverageUI = { contentType: 'Was möchtest du verstehen?', conversation: 'Alltagsgespräche', travel: 'Reisen und Alltag', media: 'Filme und Serien', generalReading: 'Allgemeines Lesen', novels: 'Romane', business: 'Business und Arbeit', academic: 'Akademische Texte', knownWords: 'Bereits bekannte Wörter', knownWordsHint: 'Nutze möglichst eine begründete Schätzung. Zähle Wortfamilien oder Lemmata konsequent.', targetCoverage: 'Gewünschte Abdeckung', coverage80: 'Erste Orientierung', coverage90: 'Dem Faden folgen', coverage95: 'Mit Unterstützung lesen', coverage98: 'Selbstständig lesen', resetButton: 'Zurücksetzen', quickStart: 'Schnellstart', conversationPreset: 'Gespräch', readingPreset: 'Roman', workPreset: 'Arbeit', resultTitle: 'Deine Wortschatzkarte', emptyResult: 'Wähle einen Inhalt und gib deinen bekannten Wortschatz ein, um den nächsten sinnvollen Meilenstein zu sehen.', checkInputs: 'Prüfe deine Eingaben', invalidWords: 'Gib zwischen 0 und 50.000 bekannte Wörter ein.', invalidContentType: 'Wähle einen verfügbaren Inhaltstyp.', invalidCoverage: 'Wähle ein Ziel zwischen 80 % und 98 %.', belowTarget: 'Du liegst unter dem gewählten Abdeckungsband. Die Lücke ist dein nächstes Wortschatzprojekt.', atTarget: 'Du hast das gewählte Planungsband erreicht. Halte thematische Wörter griffbereit.', buffer: 'Du hast für diesen Inhaltstyp einen guten Puffer. Prüfe jetzt echte Texte aus deinem Zielbereich.', recommendedWords: 'Wörter für dein Ziel', vocabularyKnown: 'Gezählte Wörter', estimatedCoverage: 'Geschätzte Abdeckung', gapWords: 'Wörter bis zum Ziel', unknownPerHundred: 'Unbekannt je 100', coverageRoute: 'Leseroute von häufigen Wörtern zum selbstständigen Lesen', nextCheckpoint: 'Nächster Meilenstein', noMoreCheckpoints: 'Du liegst über dem höchsten Meilenstein dieses Planungsmodells.', words: 'Wörter', wordUnit: 'Wörter', unknownWords: 'unbekannt', targetMarker: 'Ziel', coverageNote: 'Abdeckung bezeichnet den Anteil der laufenden Wörter eines Textes, den dein Wortschatz erkennt. Sie ist nicht dasselbe wie vollständiges Sprachverständnis.', wordFamilyNote: 'Forschung zählt oft Wortfamilien, während Apps Lemmata oder Formen zählen. Vergleiche deshalb nur gleiche Einheiten.', contentTypeHelp: 'Das Genre verändert die Schwelle: Häufige Gesprächswörter kommen früher, Romane, Arbeit und Studium bringen seltenere Fachwörter.', interpretationTitle: 'So nutzt du die Karte', actionBelow: 'Erstelle aus echten Gesprächen, Büchern oder Arbeitsdokumenten eine fokussierte Liste und lerne wiederkehrende Wörter in Phrasen.', actionAt: 'Prüfe eine Seite oder ein Transkript aus deinem Zielgenre und ergänze wiederkehrende unbekannte Wörter.', actionBuffer: 'Sammle weniger Einzelwörter und mehr Wendungen, Kollokationen und Fachwortschatz für deinen nächsten Text.' };
6
+ const faq = [{ question: 'Was schätzt der Rechner für lexikalische Abdeckung?', answer: 'Er schätzt eine nützliche Wortschatzgröße für Gespräche, Medien, Romane, Arbeit oder Studium bei 80 %, 90 %, 95 % oder 98 % Abdeckung und zeigt die nächste Schwelle.' }, { question: 'Warum braucht ein Roman mehr Wörter als ein Gespräch?', answer: 'Gesprochene Alltagssprache wiederholt häufige Wörter. Romane und Fachtexte enthalten mehr seltene, thematische und formelhafte Ausdrücke, sodass dieselbe Wortzahl weniger Text abdeckt.' }, { question: 'Bedeutet 80 % Abdeckung, dass ich 80 % des Sinns verstehe?', answer: 'Nein. Die Zahl beschreibt den Anteil erkannter laufender Wörter. Fehlende Schlüsselwörter, Grammatik, Vorwissen, Aussprache und Sprechtempo beeinflussen das Verständnis zusätzlich.' }, { question: 'Soll ich Wörter, Lemmata oder Wortfamilien zählen?', answer: 'Wähle eine Einheit und bleibe dabei. Forschung berichtet oft Wortfamilien, Apps dagegen Lemmata oder Wortformen. Diese Zahlen sind nicht direkt austauschbar.' }, { question: 'Was mache ich mit der Lücke?', answer: 'Nimm kurze Proben aus deinem Zielgenre, notiere wiederkehrende Wörter und lerne sie in Phrasen. Prüfe nach dem Wiederholen erneut eine ähnliche Probe.' }];
7
+ const howTo = [{ name: 'Inhalt wählen', text: 'Wähle den Inhalt, den du als Nächstes verstehen möchtest.' }, { name: 'Wortschatz schätzen', text: 'Gib eine konsistente Schätzung der Wörter, Lemmata oder Wortfamilien ein, die du erkennst.' }, { name: 'Abdeckung festlegen', text: 'Wähle ein Planungsband von erster Orientierung bis selbstständigem Lesen.' }, { name: 'Lücke nutzen', text: 'Baue mit der Lücke und dem nächsten Meilenstein eine Liste aus echten Texten deines Genres.' }];
8
+ const appSchema: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Rechner für lexikalische Abdeckung und Wortschatz', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', isAccessibleForFree: true, url: 'https://gamebob.dev/de/lexikalische-abdeckung-wortschatz-rechner' };
9
+ const howToSchema: HowTo = { '@type': 'HowTo', name: 'Benötigten Wortschatz nach Inhalt schätzen', step: howTo.map((item) => ({ '@type': 'HowToStep', ...item })) };
10
+ const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
11
+ const withContext = (schema: SoftwareApplication | HowTo | FAQPage): Record<string, unknown> => ({ '@context': 'https://schema.org', ...schema });
12
+
13
+ export const content: ToolLocaleContent<LexicalCoverageUI> = { slug: 'lexikalische-abdeckung-wortschatz-rechner', title: 'Rechner für lexikalische Abdeckung und Wortschatz', description: 'Schätze den nötigen Wortschatz für Gespräche, Romane, Arbeit, Medien und Studium anhand einer verständlichen Abdeckungsschwelle.', ui, seo: [{ type: 'title', text: 'Wortschatz nach Inhalt sinnvoll planen', level: 2 }, { type: 'paragraph', html: 'Ein kleiner Kern häufiger Wörter kann Gespräche öffnen, deckt aber einen Roman oder Fachtext nicht im gleichen Maß ab. Dieser Rechner verbindet dein Zielgenre mit einer konkreten Wortzahl und zeigt die nächste erreichbare Schwelle.' }, { type: 'title', text: 'Was lexikalische Abdeckung misst', level: 2 }, { type: 'paragraph', html: 'Lexikalische Abdeckung ist der Anteil laufender Wörter in einer Textprobe, den dein bekannter Wortschatz erkennt. 80 %, 90 %, 95 % und 98 % sind Planungsbänder, keine Prüfung und keine Garantie für vollständiges Verstehen.' }, { type: 'title', text: 'Warum das Genre die Zahl verändert', level: 2 }, { type: 'table', headers: ['Inhalt', 'Planung bei 80 %', 'Planung bei 95 %', 'Typische Ergänzung'], rows: [['Alltagsgespräch', '2.000 Wörter', '4.500 Wörter', 'Routinen und häufige Wendungen'], ['Filme und Serien', '3.000 Wörter', '6.000 Wörter', 'Dialog, Umgangssprache und Themenwechsel'], ['Romane', '5.000 Wörter', '8.000 Wörter', 'Erzählung, Beschreibung und Genrewörter'], ['Arbeit', '5.000 Wörter', '8.000 Wörter', 'Fachbegriffe und Dokumentformeln'], ['Akademische Texte', '6.000 Wörter', '10.000 Wörter', 'Abstrakte und disziplinspezifische Begriffe']] }, { type: 'title', text: 'Aus der Lücke eine Lernentscheidung machen', level: 2 }, { type: 'paragraph', html: 'Wenn du unter dem Band liegst, sammle nicht wahllos Wörter. Nimm eine kurze Probe aus dem echten Inhalt, behalte wiederkehrende oder bedeutungsentscheidende Wörter und lerne sie als Teil einer Wendung.' }, { type: 'list', items: ['Wähle das Genre deiner nächsten realen Aufgabe.', 'Zähle immer dieselbe Einheit.', 'Priorisiere Wörter, die in mehreren Proben wiederkehren.', 'Prüfe dieselbe Textsorte nach dem Lernen erneut.'] }, { type: 'tip', title: 'Evidenz und Grenzen', html: 'Die Referenz von 2.000 Wörtern bei 80 % stammt aus Forschung zu Wortfrequenz und Textabdeckung. Höhere schriftsprachliche Werte unterscheiden sich nach Korpus und Genre. Sprache, Morphologie, Eigennamen, Vorwissen, Grammatik und Tempo bleiben wichtige Faktoren.' }], faq, bibliography: [{ name: 'Paul Nation, How Large a Vocabulary Is Needed for Reading and Listening?', url: 'https://www.scienceguide.nl/wp-content/uploads/2017/11/nation-2006-vocabulary.pdf' }, { name: 'Izquierdo Gil, La selección del léxico en la enseñanza del español como lengua extranjera', url: 'https://asele.org/media/2025/03/8izquierdogilm-adelcarmenlaselecciondellexicoenlaensananza.pdf' }], howTo, schemas: [withContext(appSchema), withContext(faqSchema), withContext(howToSchema)] };