@jjlmoya/utils-language 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-language",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -10,7 +10,7 @@ export const content: CategoryLocaleContent = {
10
10
  { type: 'title', text: 'Language Learning Tools for Practical Study Planning', level: 2 },
11
11
  { type: 'paragraph', html: 'Learning a language is easier to sustain when a broad goal becomes a small, visible routine. This focused collection starts with a study plan planner that connects your current CEFR level, target level, deadline, weekly time, and preferred skills into a clear weekly rhythm.' },
12
12
  { type: 'title', text: 'Plan Progress Without Promising Fluency', level: 2 },
13
- { type: 'paragraph', html: 'The planner uses broad guided-learning ranges associated with CEFR progression to estimate a workload and surface the time available before your deadline. It is a planning aid, not a placement test, course recommendation, or guarantee of fluency. Results vary with language pair, previous exposure, study quality, and real-world practice.' },
13
+ { type: 'paragraph', html: 'The planner uses broad guided-learning ranges associated with CEFR progression to estimate a workload and surface the time available before your deadline. It is a planning aid, not a placement test, course recommendation, or promise of fluency. Results vary with language pair, previous exposure, study quality, and real-world practice.' },
14
14
  { type: 'title', text: 'Private and Useful on Any Device', level: 2 },
15
15
  { type: 'paragraph', html: 'Your language choices, level, deadline, and schedule stay in your browser. No account is required, and the planner does not send your study inputs to a server. Use the result as a starting point, then adjust it when your weekly life or progress changes.' },
16
16
  { type: 'stats', items: [
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CategoryLocaleContent } from '../types';
3
+
4
+ const EXPECTED_LOCALES = [
5
+ 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
6
+ ] as const;
7
+ const COMPACT_LOCALES = new Set(['ja', 'ko', 'zh']);
8
+
9
+ const localeModules = import.meta.glob('../category/i18n/*.ts', { eager: true }) as Record<
10
+ string,
11
+ { content: CategoryLocaleContent }
12
+ >;
13
+
14
+ function textFrom(value: unknown): string {
15
+ if (typeof value === 'string') return value.replace(/<[^>]*>/g, ' ');
16
+ if (Array.isArray(value)) return value.map(textFrom).join(' ');
17
+ if (value && typeof value === 'object') return Object.values(value).map(textFrom).join(' ');
18
+ return '';
19
+ }
20
+
21
+ const STRONG_CLAIM = /\b(?:garantiz\w*|guarante\w*|validat(?:ed)|valid(?:ated|ado|ada|ados|adas|ée|ées)|actualizad\w*)\b/iu;
22
+
23
+ function getContents(): Map<string, CategoryLocaleContent> {
24
+ return new Map(
25
+ Object.entries(localeModules).map(([file, module]) => [file.match(/\/([a-z]{2})\.ts$/u)?.[1] ?? file, module.content]),
26
+ );
27
+ }
28
+
29
+ function validateContent(locale: string, content: CategoryLocaleContent): string[] {
30
+ const failures: string[] = [];
31
+ const check = (condition: boolean, message: string) => { if (!condition) failures.push(`${locale}: ${message}`); };
32
+ const minimumTitleLength = COMPACT_LOCALES.has(locale) ? 4 : 12;
33
+ const minimumDescriptionLength = COMPACT_LOCALES.has(locale) ? 20 : 60;
34
+ check(content.title.trim().length >= minimumTitleLength && content.title.trim().length <= 70, 'title must use a useful 4/12–70 character length');
35
+ check(content.description.trim().length >= minimumDescriptionLength && content.description.trim().length <= 180, 'description must use a useful 20/60–180 character length');
36
+ check(content.seo.length >= 2, 'SEO needs at least two sections');
37
+
38
+ const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
39
+ const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
40
+ check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
41
+ check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
42
+ check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
43
+ check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
44
+ check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
45
+
46
+ check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
47
+ return failures;
48
+ }
49
+
50
+ describe('Category SEO quality contract', () => {
51
+ it('has one content file for every configured locale', () => {
52
+ const contents = getContents();
53
+ expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
54
+ });
55
+
56
+ it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
57
+ const contents = getContents();
58
+ const english = contents.get('en');
59
+ expect(english, 'English category content is required as the structural reference').toBeDefined();
60
+
61
+ const failures = EXPECTED_LOCALES.flatMap((locale) => {
62
+ const content = contents.get(locale);
63
+ return content ? validateContent(locale, content) : [];
64
+ });
65
+
66
+ const englishShape = english?.seo.map((section) => section.type);
67
+ for (const locale of EXPECTED_LOCALES) {
68
+ const content = contents.get(locale);
69
+ if (content && JSON.stringify(content.seo.map((section) => section.type)) !== JSON.stringify(englishShape)) {
70
+ failures.push(`${locale}: SEO section types must match English`);
71
+ }
72
+ }
73
+
74
+ expect(failures, 'category SEO quality failures').toEqual([]);
75
+ }, 30000);
76
+ });
@@ -40,10 +40,10 @@ function hardcodedVisibleLiterals(source: string): VisibleLiteral[] {
40
40
 
41
41
  const assignment = line.slice(line.search(domTextAssignment));
42
42
  for (const match of assignment.matchAll(quotedLiteral)) {
43
- if (isTechnicalLiteral(match[2])) continue;
44
- const words = visibleWords(match[2]);
43
+ if (isTechnicalLiteral(match[2]!)) continue;
44
+ const words = visibleWords(match[2]!);
45
45
  if (words && /\p{L}/u.test(words)) {
46
- failures.push({ line: index + 1, value: match[2] });
46
+ failures.push({ line: index + 1, value: match[2]! });
47
47
  }
48
48
  }
49
49
  });
@@ -4,6 +4,7 @@ import {
4
4
  } from './entry';
5
5
 
6
6
  export type { LanguagePlannerUI, LanguageLearningStudyPlanPlannerLocaleContent } from './entry';
7
+ export { languageLearningStudyPlanPlanner } from './entry';
7
8
  export { calculateStudyPlan, GUIDED_HOURS_FROM_BEGINNER, LEVEL_LABELS, PRIORITY_ALLOCATIONS, STUDY_LEVELS } from './logic';
8
9
  export type { LanguagePlannerInputs, PlanStatus, SkillAllocation, StudyLevel, StudyMilestone, StudyPlan } from './types';
9
10