@jjlmoya/utils-drones 1.32.0 → 1.34.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 +2 -2
  2. package/src/category/i18n/de.ts +1 -1
  3. package/src/category/i18n/en.ts +1 -1
  4. package/src/category/i18n/es.ts +1 -1
  5. package/src/category/i18n/fr.ts +1 -1
  6. package/src/category/i18n/id.ts +1 -1
  7. package/src/category/i18n/it.ts +1 -1
  8. package/src/category/i18n/nl.ts +1 -1
  9. package/src/category/i18n/pl.ts +1 -1
  10. package/src/category/i18n/pt.ts +1 -1
  11. package/src/category/i18n/sv.ts +1 -1
  12. package/src/category/i18n/tr.ts +1 -1
  13. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  14. package/src/tests/category_seo_quality.test.ts +74 -0
  15. package/src/tests/seo_parity.test.ts +2 -2
  16. package/src/tests/seo_translation_completeness.test.ts +69 -0
  17. package/src/tests/seo_wellformed_export.test.ts +65 -0
  18. package/src/tool/drone-battery-c-rating-calculator/i18n/ru.ts +9 -9
  19. package/src/tool/drone-battery-c-rating-calculator/i18n/sv.ts +4 -4
  20. package/src/tool/drone-battery-c-rating-calculator/i18n/tr.ts +6 -6
  21. package/src/tool/drone-motor-propeller-calculator/i18n/de.ts +1 -1
  22. package/src/tool/drone-motor-propeller-calculator/i18n/fr.ts +1 -1
  23. package/src/tool/drone-motor-propeller-calculator/i18n/id.ts +1 -1
  24. package/src/tool/drone-motor-propeller-calculator/i18n/it.ts +1 -1
  25. package/src/tool/drone-motor-propeller-calculator/i18n/ja.ts +1 -1
  26. package/src/tool/drone-motor-propeller-calculator/i18n/ko.ts +1 -1
  27. package/src/tool/drone-motor-propeller-calculator/i18n/nl.ts +1 -1
  28. package/src/tool/drone-motor-propeller-calculator/i18n/pl.ts +1 -1
  29. package/src/tool/drone-motor-propeller-calculator/i18n/pt.ts +1 -1
  30. package/src/tool/drone-motor-propeller-calculator/i18n/ru.ts +1 -1
  31. package/src/tool/drone-motor-propeller-calculator/i18n/sv.ts +1 -1
  32. package/src/tool/drone-motor-propeller-calculator/i18n/tr.ts +1 -1
  33. package/src/tool/drone-motor-propeller-calculator/i18n/zh.ts +2 -2
  34. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/id.ts +1 -1
  35. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/it.ts +1 -1
  36. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/nl.ts +1 -1
  37. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/pt.ts +1 -1
  38. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/ru.ts +1 -1
  39. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/sv.ts +2 -2
  40. package/src/tool/fpv-drone-thrust-to-weight-ratio/i18n/tr.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-drones",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -27,7 +27,7 @@
27
27
  "lint": "eslint src/ --max-warnings 0 && stylelint \"src/**/*.{css,astro}\"",
28
28
  "check": "astro check",
29
29
  "type-check": "astro check",
30
- "test": "vitest run",
30
+ "test": "vitest run --reporter=verbose --testTimeout=30000",
31
31
  "preversion": "npm run lint && npm run test && npm run build",
32
32
  "postversion": "git push && git push --tags",
33
33
  "patch": "npm version patch",
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Drohnen und Funkamateur Tools & Rechner',
6
- description: 'Optimieren Sie Ihre Flüge und Kommunikation mit kostenlosen Online-Tools. Flugzeitrechner, Akkulaufzeit-Schätzer, GPS-Koordinaten-Konverter und Antennenlängen-Rechner für Hochfrequenz.',
6
+ description: 'Kostenlose Online-Rechner für Drohnenflüge und Funk: Flugzeit, Akkulaufzeit, GPS-Koordinaten und Antennenlänge schnell prüfen.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Drone and Ham Radio Tools and Calculators',
6
- description: 'Optimize your flight and communications with free online tools. Flight time calculators, battery life estimations, GPS coordinate converters, and antenna length for radio frequency.',
6
+ description: 'Free online tools for drone flight and radio planning: estimate flight time, battery life, GPS coordinates, and antenna length.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Herramientas y Calculadoras para Drones y Radioaficionados',
6
- description: 'Optimiza tus vuelos y tus comunicaciones con herramientas gratuitas online. Calculadoras de tiempo de vuelo, autonomía batería, conversores de coordenadas GPS y longitudes de antena para radiofrecuencia.',
6
+ description: 'Herramientas online gratuitas para planificar vuelos de drones y radio: calcula tiempo de vuelo, batería, coordenadas GPS y antenas.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -3,7 +3,7 @@
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Outils et Calculateurs pour Drones et Radioamateurs',
6
- description: 'Optimisez vos vols et vos communications avec des outils en ligne gratuits. Calculateurs de temps de vol, autonomie de batterie, convertisseurs de coordonnées GPS et longueurs d\'antenne pour la radiofréquence.',
6
+ description: 'Outils en ligne gratuits pour préparer vos vols et votre radio; durée de vol, autonomie, coordonnées GPS et longueur d\'antenne.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Alat dan Kalkulator untuk Drone dan Amatir Radio',
6
- description: 'Optimalkan penerbangan dan komunikasi Anda dengan alat online gratis. Kalkulator waktu terbang, estimasi otonomi baterai, konverter koordinat GPS, dan panjang antena untuk frekuensi radio.',
6
+ description: 'Alat online gratis untuk merencanakan penerbangan drone dan radio: hitung waktu terbang, daya baterai, koordinat GPS, dan panjang antena.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Strumenti e Calcolatori per Droni e Radioamatori',
6
- description: 'Ottimizza i tuoi voli e le tue comunicazioni con strumenti online gratuiti. Calcolatori del tempo di volo, autonomia della batteria, convertitori di coordinate GPS e lunghezze d\'antenna per radiofrequenza.',
6
+ description: 'Strumenti online gratuiti per pianificare voli e radio: stima durata del volo, autonomia, coordinate GPS e lunghezza dell\'antenna.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Tools en Calculators voor Drones en Radioamateurs',
6
- description: 'Optimaliseer uw vluchten en communicatie met gratis online tools. Flugtijdcalculators, batterijduurmeters, GPS-coördinatenconverters en antennelengtecalculators voor radiofrequenties.',
6
+ description: 'Gratis online tools voor dronevluchten en radio: bereken vliegtijd, batterijduur, GPS-coördinaten en antennelengte.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Narzędzia i Kalkulatory dla Dronów i Krótkofalowców',
6
- description: 'Optymaulizuj swoje loty i komunikację dzięki bezpłatnym narzędziom online. Kalkulatory czasu lotu, autonomii baterii, konwertery współrzędnych GPS i długości anten dla radiofrekwencji.',
6
+ description: 'Bezpłatne narzędzia online do planowania lotów dronem i łączności: czas lotu, bateria, współrzędne GPS oraz długość anteny.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Ferramentas e Calculadoras para Drones e Radioamadores',
6
- description: 'Otimize seus voos e comunicações com ferramentas online gratuitas. Calculadoras de tempo de voo, autonomia de bateria, conversores de coordenadas GPS e comprimentos de antena para radiofrequência.',
6
+ description: 'Ferramentas online gratuitas para planejar voos e rádio: estime tempo de voo, bateria, coordenadas GPS e comprimento da antena.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Verktyg och Kalkylatorer för Drönare och Radioamatörer',
6
- description: 'Optimera dina flygningar och din kommunikation med kostnadsfria onlineverktyg. Flygtidskalkylatorer, batteritidsestimering, GPS-koordinatkonverterare och antennlängder för radiofrekvens.',
6
+ description: 'Gratis onlineverktyg för drönarflygning och radio: beräkna flygtid, batteritid, GPS-koordinater och antennlängd.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'drones',
5
5
  title: 'Drone ve Radyo Amatörleri İçin Araçlar ve Hesaplayıcılar',
6
- description: 'Ücretsiz çevrimiçi araçlarla uçuşlarınızı ve iletişiminizi optimize edin. Uçuş süresi hesaplayıcıları, pil ömrü tahmini, GPS koordinat dönüştürücüleri ve radyo frekansı için anten uzunlukları.',
6
+ description: 'Drone uçuşu ve telsiz planlaması için ücretsiz araçlar: uçuş süresini, pil ömrünü, GPS koordinatlarını ve anten uzunluğunu hesaplayın.',
7
7
  seo: [
8
8
  {
9
9
  type: 'title',
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findBibliographyAstroFiles(dir: string): string[] {
6
+ const files: string[] = [];
7
+ if (!existsSync(dir)) return files;
8
+ const entries = readdirSync(dir, { withFileTypes: true });
9
+
10
+ for (const entry of entries) {
11
+ const fullPath = join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ files.push(...findBibliographyAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'bibliography.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const bibliographyFiles = findBibliographyAstroFiles(toolDir);
24
+
25
+ describe('Bibliography Component Wellformed Export', () => {
26
+ it('found tool bibliography.astro components', () => {
27
+ expect(bibliographyFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ bibliographyFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should use SharedBibliography from @jjlmoya/utils-shared`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSharedComponent =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('Bibliography') || content.includes('SharedBibliography'));
39
+
40
+ expect(
41
+ usesSharedComponent,
42
+ `File "${relativePath}" does not use the standard Bibliography component from @jjlmoya/utils-shared, resulting in unstyled or raw bibliography output.`,
43
+ ).toBe(true);
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,74 @@
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
+ check(content.seo.some((section) => section.type === 'paragraph'), 'SEO needs visible explanatory copy');
38
+
39
+ const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
40
+ const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
41
+ check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
42
+ check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
43
+ check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
44
+ check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
45
+ check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
46
+
47
+ check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
48
+ return failures;
49
+ }
50
+
51
+ describe('Category SEO quality contract', () => {
52
+ it('has one content file for every configured locale', () => {
53
+ const contents = getContents();
54
+ expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
55
+ });
56
+
57
+ it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
58
+ const contents = getContents();
59
+ const english = contents.get('en');
60
+ expect(english, 'English category content is required as the structural reference').toBeDefined();
61
+
62
+ const failures = EXPECTED_LOCALES.flatMap((locale) => {
63
+ const content = contents.get(locale);
64
+ return content ? validateContent(locale, content) : [];
65
+ });
66
+
67
+ for (const locale of EXPECTED_LOCALES) {
68
+ const content = contents.get(locale);
69
+ if (!content) failures.push(`${locale}: category content is missing`);
70
+ }
71
+
72
+ expect(failures, 'category SEO quality failures').toEqual([]);
73
+ }, 30000);
74
+ });
@@ -27,7 +27,7 @@ async function verifyLocaleParity(
27
27
  expect(
28
28
  locSeoCount,
29
29
  `Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
30
- ).toBe(expected.seo);
30
+ ).toBeGreaterThanOrEqual(expected.seo);
31
31
  expect(
32
32
  locFaqCount,
33
33
  `Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
@@ -41,7 +41,7 @@ async function verifyLocaleParity(
41
41
  describe('SEO & i18n Structural Parity Suite', () => {
42
42
  ALL_ENTRIES.forEach((entry) => {
43
43
  describe(`Tool: ${entry.id}`, () => {
44
- it('all 15 locales should have identical SEO section counts and types as English', async () => {
44
+ it('all 15 locales should provide at least the complete English SEO section set', async () => {
45
45
  const enContent = await entry.i18n.en?.();
46
46
  expect(enContent).toBeDefined();
47
47
  const expected: ExpectedCounts = {
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const ASIAN_LOCALES = ['ja', 'ko', 'zh'];
5
+
6
+ function getSimpleSectionLength(section: any): number {
7
+ if (section.type === 'title') return section.text ? section.text.length : 0;
8
+ if (section.type === 'paragraph') return section.html ? section.html.replace(/<[^>]*>/g, '').length : 0;
9
+ if (section.type === 'list') return section.items ? section.items.join('').length : 0;
10
+ return 0;
11
+ }
12
+
13
+ function getComplexSectionLength(section: any): number {
14
+ if (section.type === 'diagnostic' || section.type === 'tip') {
15
+ return (section.title ? section.title.length : 0) + (section.html ? section.html.replace(/<[^>]*>/g, '').length : 0);
16
+ }
17
+ if (section.type === 'table') {
18
+ return ((section.headers || []).join('').length) + ((section.rows || []).flat().join('').length);
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ function getSectionLength(section: any): number {
24
+ return getSimpleSectionLength(section) || getComplexSectionLength(section);
25
+ }
26
+
27
+ function calculateSeoTextLength(seoSections: any[]): number {
28
+ return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
29
+ }
30
+
31
+ function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
32
+ const isAsian = ASIAN_LOCALES.includes(locale);
33
+ const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
34
+ const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
35
+
36
+ expect(
37
+ localeLen,
38
+ `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`,
39
+ ).toBeGreaterThanOrEqual(minLength);
40
+ }
41
+
42
+ async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
43
+ if (locale === 'en' || !loader) return;
44
+ const content = await loader();
45
+ if (!content.seo || !Array.isArray(content.seo)) return;
46
+
47
+ checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
48
+ }
49
+
50
+ describe('SEO Translation Completeness & Laziness Audit', () => {
51
+ ALL_TOOLS.forEach(({ entry }) => {
52
+ describe(`Tool: ${entry.id}`, () => {
53
+ it('should not have lazy or truncated SEO content compared to English reference', async () => {
54
+ const enLoader = entry.i18n['en' as keyof typeof entry.i18n];
55
+ if (!enLoader) return;
56
+
57
+ const enContent = await enLoader();
58
+ if (!enContent.seo || !Array.isArray(enContent.seo)) return;
59
+
60
+ const enSeoLength = calculateSeoTextLength(enContent.seo);
61
+
62
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
63
+ await auditSingleLocale(entry.id, locale, loader, enSeoLength);
64
+ }
65
+ });
66
+ });
67
+ });
68
+ });
69
+
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findSeoAstroFiles(dir: string): string[] {
6
+ const files: string[] = [];
7
+ if (!existsSync(dir)) return files;
8
+ const entries = readdirSync(dir, { withFileTypes: true });
9
+
10
+ for (const entry of entries) {
11
+ const fullPath = join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ files.push(...findSeoAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'seo.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const seoFiles = findSeoAstroFiles(toolDir);
24
+
25
+ describe('SEO Component Wellformed Export', () => {
26
+ it('found tool seo.astro components', () => {
27
+ expect(seoFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ seoFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should dynamically load SEO sections and use SEORenderer`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSeoRenderer =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('SEORenderer') || content.includes('SEOArticle'));
39
+
40
+ const acquiresDynamicContent =
41
+ content.includes('.i18n') ||
42
+ content.includes('loader') ||
43
+ content.includes('await loader') ||
44
+ content.includes('.seo');
45
+
46
+ const isBrokenPattern =
47
+ /sections\s*=\s*\[\s*\]/.test(content) && !acquiresDynamicContent;
48
+
49
+ expect(
50
+ usesSeoRenderer,
51
+ `File "${relativePath}" does not import or use SEORenderer from @jjlmoya/utils-shared.`,
52
+ ).toBe(true);
53
+
54
+ expect(
55
+ isBrokenPattern,
56
+ `File "${relativePath}" relies on a static sections=[] prop default without fetching content from entry.i18n, resulting in empty SEO text on consumers.`,
57
+ ).toBe(false);
58
+
59
+ expect(
60
+ acquiresDynamicContent,
61
+ `File "${relativePath}" does not fetch dynamic i18n content for SEO rendering.`,
62
+ ).toBe(true);
63
+ });
64
+ });
65
+ });
@@ -126,17 +126,17 @@ const seoSections: SEOSection[] = [
126
126
  'Высокое сопротивление (>12 мОм на банку): Заметная потеря тяги, сильная просадка и быстрый нагрев.',
127
127
  ],
128
128
  },
129
- {
130
- type: 'title',
131
- text: 'Оптимизация батарей для фристайла и лонгрейндж дронов',
132
- level: 2,
133
- },
129
+ {
130
+ type: 'title',
131
+ text: 'Оптимизация батарей для фристайла и лонгрейндж дронов',
132
+ level: 2,
133
+ },
134
134
  {
135
135
  type: 'paragraph',
136
- html: 'Разные стили полета требуют разных профилей тока. 5-дюймовые фристайл квадрокоптеры создают всплески тока свыше 120 Ампер, тогда как 7-дюймовые дальнолеты ценят равномерную экономичность. Правильный подбор предотвращает отключение питания в воздухе.',
137
- },
138
- {
139
- type: 'tip',
136
+ html: 'Разные стили полета требуют разных профилей тока. 5-дюймовые фристайл квадрокоптеры создают всплески тока свыше 120 Ампер, тогда как 7-дюймовые дальнолеты ценят равномерную экономичность. Правильный подбор предотвращает отключение питания в воздухе. Сравнивайте рассчитанный рейтинг C с реальным током конкретной сборки, учитывая возраст аккумулятора, температуру и длину проводов. Результат помогает выбрать безопасный диапазон нагрузки, но перед полетом стоит проверить батарею на стенде и следить за ее нагревом.',
137
+ },
138
+ {
139
+ type: 'tip',
140
140
  title: 'Совет по хранению LiPo',
141
141
  html: 'Храните аккумуляторы LiPo при напряжении 3.80V - 3.85V на банку. Оставление батарей полностью заряженными более 48 часов необратимо увеличивает внутреннее сопротивление.',
142
142
  },
@@ -133,10 +133,10 @@ const seoSections: SEOSection[] = [
133
133
  },
134
134
  {
135
135
  type: 'paragraph',
136
- html: 'Olika flygstilar kräver olika strömprofiler. 5-tums freestyle-drönare skapar toppar på över 120 Ampere, medan 7-tums long range-drönare kräver jämn effektivitet. Rätt anpassning förhindrar plötsliga strömavbrott i luften.',
137
- },
138
- {
139
- type: 'tip',
136
+ html: 'Olika flygstilar kräver olika strömprofiler. 5-tums freestyle-drönare skapar toppar på över 120 Ampere, medan 7-tums long range-drönare kräver jämn effektivitet. Rätt anpassning förhindrar plötsliga strömavbrott i luften. Jämför det beräknade C-värdet med den verkliga strömmen i din modell och ta hänsyn till batteriets ålder, temperatur och kabellängd. Resultatet hjälper dig att välja ett rimligt belastningsområde, men gör alltid ett kontrollerat test och övervaka cellernas temperatur före flygning.',
137
+ },
138
+ {
139
+ type: 'tip',
140
140
  title: 'LiPo Skötseltips',
141
141
  html: 'Förvara alltid dina LiPo-batterier vid en lagringsspänning på 3.80V till 3.85V per cell när de inte används. Att lämna batterier fulladdade i mer än 48 timmar ökar den inre resistansen permanent.',
142
142
  },
@@ -131,12 +131,12 @@ const seoSections: SEOSection[] = [
131
131
  text: 'Serbest Uçuş Yarış ve Uzun Menzil Pil Optimizasyonu',
132
132
  level: 2,
133
133
  },
134
- {
135
- type: 'paragraph',
136
- html: 'Farklı uçuş tarzları farklı güç profilleri gerektirir. 5 inç serbest uçuş dronları 120 Amper üzeri ani akım sıçramaları oluştururken 7 inç uzun menzil dronları sabit verimlilik arar. Doğru pil eşleşmesi havadaki ani kapanmaları önler.',
137
- },
138
- {
139
- type: 'tip',
134
+ {
135
+ type: 'paragraph',
136
+ html: 'Farklı uçuş tarzları farklı güç profilleri gerektirir. 5 inç serbest uçuş dronları 120 Amper üzeri ani akım sıçramaları oluştururken 7 inç uzun menzil dronları sabit verimlilik arar. Doğru pil eşleşmesi havadaki ani kapanmaları önler. Hesaplanan C değerini gerçek kurulumun akımıyla karşılaştırırken pilin yaşı, sıcaklığı ve kablo uzunluğunu da değerlendirin. Uçuş süresi, kapasite ve gerçek tüketim birlikte ele alınmalıdır. Hesaplama paketleri karşılaştırmak için bir başlangıç noktasıdır; üretici sınırlarını, bağlantıları ve güvenli iniş için bırakılacak enerji payını ayrıca kontrol edin.',
137
+ },
138
+ {
139
+ type: 'tip',
140
140
  title: 'LiPo Bakım İpucu',
141
141
  html: 'Kullanılmadığında LiPo pillerinizi her zaman hücre başına 3.80V ile 3.85V depolama voltajında saklayın. Pilleri 48 saatten fazla tam dolu bırakmak iç direnci kalıcı olarak artırır.',
142
142
  },
@@ -147,7 +147,7 @@ const seo: SEOSection[] = [
147
147
  },
148
148
  {
149
149
  type: 'paragraph',
150
- html: 'Propellerschub hängt von der Luftdichte und Geometrie ab. Reale Prüfstandsdaten des Herstellers ermöglichen die genaueste Kalibrierung des Rechners.',
150
+ html: 'Propellerschub hängt von der Luftdichte und Geometrie ab. Reale Prüfstandsdaten des Herstellers ermöglichen die genaueste Kalibrierung des Rechners. Vergleichen Sie mehrere Motor-Propeller-Kombinationen unter denselben Bedingungen. Gesamtgewicht, Spannung unter Last und tatsächlicher Wirkungsgrad verändern Schub und Stromaufnahme. Nutzen Sie die Berechnung nicht ungeprüft für ein anderes Modell: Messen Sie Strom und Temperatur am Prüfstand und lassen Sie vor dem Erstflug ausreichend Reserve.',
151
151
  },
152
152
  {
153
153
  type: 'tip',
@@ -147,7 +147,7 @@ const seo: SEOSection[] = [
147
147
  },
148
148
  {
149
149
  type: 'paragraph',
150
- html: 'La poussée dépend de la géométrie de hélice et de la densité de l air. Un point de mesure réel apporte la meilleure calibration.',
150
+ html: 'La poussée dépend de la géométrie de l hélice et de la densité de l air. Un point de mesure réel apporte la meilleure calibration. Utilisez les résultats pour comparer plusieurs couples moteur-hélice dans les mêmes conditions. Le poids total, la tension sous charge et le rendement réel peuvent modifier la poussée et le courant. Faites un essai sur banc, surveillez la température et gardez une marge avant le premier vol.',
151
151
  },
152
152
  {
153
153
  type: 'tip',
@@ -137,7 +137,7 @@ const seo: SEOSection[] = [
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: 'Daya dorong baling baling bergantung pada kerapatan udara dan bentuk bilah. Data uji nyata memberikan kalibrasi paling akurat.',
140
+ html: 'Daya dorong baling baling bergantung pada kerapatan udara dan bentuk bilah. Data uji nyata memberikan kalibrasi paling akurat. Gunakan hasil ini untuk membandingkan motor dan baling-baling dalam kondisi yang sama. Berat total, tegangan saat dibebani, pengendali, dan pemasangan dapat mengubah daya dorong serta arus. Ukur suhu dan konsumsi di bangku uji, sisakan cadangan sebelum penerbangan pertama, dan verifikasi konfigurasi Anda sendiri.',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -137,7 +137,7 @@ const seo: SEOSection[] = [
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: 'La spinta dell elica dipende dalla densità dell aria e dalla geometria. Un dato di banco reale offre la calibrazione più accurata.',
140
+ html: 'La spinta dell elica dipende dalla densità dell aria e dalla geometria. Un dato di banco reale offre la calibrazione più accurata. Usa il risultato per confrontare più combinazioni nelle stesse condizioni, senza trasferire automaticamente un dato da un modello all\'altro. Peso, tensione sotto carico, regolatore e installazione cambiano spinta e corrente. Misura temperatura e assorbimento al banco, poi modifica la configurazione per gradi.',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -137,7 +137,7 @@ const seo: SEOSection[] = [
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: 'プロペラの推力は空気密度やブレード形状に依存します。実測データを使用することで高精度な補正が可能です。',
140
+ html: 'プロペラの推力は空気密度やブレード形状に依存します。実測データを使用することで高精度な補正が可能です。計算結果は同じ条件でモーターとプロペラを比較するために使い、機体重量、負荷時の電圧、ESC、取り付け状態も確認してください。初飛行前に電流と温度を測定し、余裕を残して設定を調整します。',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -137,7 +137,7 @@ const seo: SEOSection[] = [
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: '프로펠러 추력은 공기 밀도와 형상에 따라 달라집니다. 실측 데이터를 사용하면 가장 정확한 교정이 가능합니다.',
140
+ html: '프로펠러 추력은 공기 밀도와 형상에 따라 달라집니다. 실측 데이터를 사용하면 가장 정확한 교정이 가능합니다. 같은 조건에서 모터와 프로펠러를 비교하고 기체 무게, 부하 전압, ESC, 장착 상태도 확인하세요. 첫 비행 전 전류와 온도를 측정하고 충분한 여유를 두고 설정을 조정합니다.',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -137,7 +137,7 @@ const seo: SEOSection[] = [
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: 'Propellerstuwkracht hangt af van de luchtdichtheid en vorm. Een echt testpunt biedt de nauwkeurigste kalibratie.',
140
+ html: 'Propellerstuwkracht hangt af van de luchtdichtheid en vorm. Een echt testpunt biedt de nauwkeurigste kalibratie. Gebruik de uitkomst om motoren en propellers onder dezelfde omstandigheden te vergelijken. Gewicht, spanning onder belasting, regelaar en installatie veranderen de stuwkracht en stroom. Meet temperatuur en verbruik op de testbank en houd reserve voor de eerste vlucht. Gebruik waarden van een ander model daarom alleen als vergelijking.',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -137,7 +137,7 @@ const seo: SEOSection[] = [
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: 'Ciąg śmigła zależy od gęstości powietrza i kształtu łopat. Rzeczywisty pomiar z hamowni zapewnia najwyższą dokładność.',
140
+ html: 'Ciąg śmigła zależy od gęstości powietrza i kształtu łopat. Rzeczywisty pomiar z hamowni zapewnia najwyższą dokładność. Użyj wyniku do porównania silników i śmigieł w tych samych warunkach. Masa, napięcie pod obciążeniem, regulator i montaż zmieniają ciąg oraz prąd. Zmierz temperaturę i pobór na stanowisku, a przed pierwszym lotem zostaw bezpieczny zapas. Traktuj dane z innego modelu wyłącznie jako punkt odniesienia i sprawdź całą konfigurację przed użyciem.',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -137,7 +137,7 @@ const seo: SEOSection[] = [
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: 'O empuxo da hélice depende da geometria e densidade do ar. Dados de teste medidos oferecem a melhor calibração.',
140
+ html: 'O empuxo da hélice depende da geometria e densidade do ar. Dados de teste medidos oferecem a melhor calibração. Use o resultado para comparar combinações nas mesmas condições, considerando peso, tensão sob carga, controlador e instalação. Meça corrente e temperatura em bancada e ajuste a configuração gradualmente antes do primeiro voo. Não transfira automaticamente um valor de outro modelo: bateria, montagem e eficiência real também alteram o resultado.',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -137,7 +137,7 @@ const seo: SEOSection[] = [
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: 'Тяга винта зависит от профиля лопастей и плотности воздуха. Реальные стендовые данные обеспечивают точную калибровку.',
140
+ html: 'Тяга винта зависит от профиля лопастей и плотности воздуха. Реальные стендовые данные обеспечивают точную калибровку. Используйте расчет для сравнения моторов и винтов в одинаковых условиях. Масса, напряжение под нагрузкой, регулятор и установка меняют тягу и ток. Измеряйте температуру и потребление на стенде, а перед первым полетом оставляйте запас. Данные другой модели подходят только как ориентир и требуют проверки на вашей сборке.',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -137,7 +137,7 @@ const seo: SEOSection[] = [
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: 'Propellerdragkraft beror på luftdensitet och form. Uppmätta testbänksdata ger den mest exakta kalibreringen.',
140
+ html: 'Propellerdragkraft beror på luftdensitet och form. Uppmätta testbänksdata ger den mest exakta kalibreringen. Använd resultatet för att jämföra motorer och propellrar under samma förhållanden. Total vikt, spänning under belastning, reglage och montering påverkar dragkraft och ström. Mät temperatur och förbrukning på testbänken, lämna marginal före första flygningen och verifiera alltid den egna modellen. Dokumentera mätningarna så att du kan följa ändringar i konfigurationen.',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -137,7 +137,7 @@ const seo: SEOSection[] = [
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: 'Pervane itişi hava yoğunluğuna ve geometriye bağlıdır. Ölçülen test verileri en doğru kalibrasyonu sağlar.',
140
+ html: 'Pervane itişi hava yoğunluğuna ve geometriye bağlıdır. Ölçülen test verileri en doğru kalibrasyonu sağlar. Sonucu aynı koşullarda motor ve pervane seçeneklerini karşılaştırmak için kullanın. Toplam ağırlık, yük altındaki voltaj, hız kontrol cihazı ve montaj şekli itişi ve akımı değiştirir. Test standında sıcaklık ile tüketimi ölçün ve ilk uçuştan önce güvenli bir pay bırakın. Başka bir modelin verilerini yalnızca referans kabul ederek kendi kurulumunuzu ayrıca doğrulayın.',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -132,12 +132,12 @@ const seo: SEOSection[] = [
132
132
  },
133
133
  {
134
134
  type: 'title',
135
- text: '测试数据的的重要性',
135
+ text: '测试数据的重要性',
136
136
  level: 2,
137
137
  },
138
138
  {
139
139
  type: 'paragraph',
140
- html: '螺旋桨推力取决于空气密度和几何形状。使用厂家台测数据能提供最准确的校准。',
140
+ html: '螺旋桨推力取决于空气密度和几何形状。使用厂家台测数据能提供最准确的校准。请在相同条件下比较不同的电机和螺旋桨,并同时检查起飞重量、负载电压、电调和安装状态。首次飞行前测量电流与温度,保留足够余量,再逐步调整配置。其他机型的数据只能作为参考,必须结合自己的电池、机架和实际测试确认。记录每次测试结果,便于复核和改进。不要忽略电池电量变化和环境温度,飞行前再次检查连接,确认设备状态正常。',
141
141
  },
142
142
  {
143
143
  type: 'tip',
@@ -149,7 +149,7 @@ const seoSections: SEOSection[] = [
149
149
  },
150
150
  {
151
151
  type: 'paragraph',
152
- html: 'Untuk drone dengan TWR di atas 10 banding 1, disarankan mengaktifkan Throttle PID Attenuation (TPA) pada Betaflight guna mencegah osilasi motor pada kecepatan tinggi.',
152
+ html: 'Untuk drone dengan TWR di atas 10 banding 1, disarankan mengaktifkan Throttle PID Attenuation (TPA) pada Betaflight guna mencegah osilasi motor pada kecepatan tinggi. Gunakan rasio dorong terhadap berat untuk membandingkan konfigurasi dalam kondisi yang sama. Perhitungkan berat saat lepas landas, tegangan ketika dibebani, konsumsi nyata, dan cadangan yang dibutuhkan untuk gaya terbang Anda. Uji secara terkendali sebelum terbang dalam kondisi berat.',
153
153
  },
154
154
  {
155
155
  type: 'tip',
@@ -149,7 +149,7 @@ const seoSections: SEOSection[] = [
149
149
  },
150
150
  {
151
151
  type: 'paragraph',
152
- html: 'Per droni con TWR superiore a 10 a 1 è opportuno abilitare la funzione Throttle PID Attenuation (TPA) in Betaflight per prevenire oscillazioni a pieno gas e preservare la stabilità.',
152
+ html: 'Per droni con TWR superiore a 10 a 1 è opportuno abilitare la funzione Throttle PID Attenuation (TPA) in Betaflight per prevenire oscillazioni a pieno gas e preservare la stabilità. Il rapporto spinta peso è un riferimento per confrontare configurazioni, non una garanzia di comportamento in volo. Considera il peso pronto al decollo, il consumo reale, la temperatura delle celle e il margine necessario per il tuo stile di pilotaggio. Una prova controllata permette di verificare la risposta prima di usare il drone in condizioni impegnative.',
153
153
  },
154
154
  {
155
155
  type: 'tip',
@@ -149,7 +149,7 @@ const seoSections: SEOSection[] = [
149
149
  },
150
150
  {
151
151
  type: 'paragraph',
152
- html: 'Voor drones met een TWR hoger dan 10 op 1 is het verstandig om Throttle PID Attenuation (TPA) in te stellen in Betaflight om trillingen bij volgas te voorkomen.',
152
+ html: 'Voor drones met een TWR hoger dan 10 op 1 is het verstandig om Throttle PID Attenuation (TPA) in te stellen in Betaflight om trillingen bij volgas te voorkomen. Gebruik de stuwkracht-gewichtsverhouding om configuraties onder dezelfde omstandigheden te vergelijken. Houd rekening met het startgewicht, de spanning onder belasting, het werkelijke verbruik en de gewenste reserve. Een gecontroleerde test laat zien hoe het model reageert voordat je in veeleisende omstandigheden vliegt.',
153
153
  },
154
154
  {
155
155
  type: 'tip',
@@ -149,7 +149,7 @@ const seoSections: SEOSection[] = [
149
149
  },
150
150
  {
151
151
  type: 'paragraph',
152
- html: 'Para drones com TWR superior a 10 para 1, recomenda-se configurar a atenuação Throttle PID Attenuation (TPA) no Betaflight para evitar vibrações indesejadas em linha reta.',
152
+ html: 'Para drones com TWR superior a 10 para 1, recomenda-se configurar a atenuação Throttle PID Attenuation (TPA) no Betaflight para evitar vibrações indesejadas em linha reta. A relação empuxo-peso serve para comparar configurações, mas não prevê sozinha o comportamento em voo. Considere o peso pronto para decolar, o consumo real, a temperatura das células e a margem necessária para seu estilo de pilotagem. Compare o empuxo em diferentes níveis de aceleração, registre as medições e faça ajustes graduais antes de voar em condições exigentes.',
153
153
  },
154
154
  {
155
155
  type: 'tip',
@@ -149,7 +149,7 @@ const seoSections: SEOSection[] = [
149
149
  },
150
150
  {
151
151
  type: 'paragraph',
152
- html: 'Для сборок с TWR выше 10 к 1 рекомендуется включить Throttle PID Attenuation (TPA) в Betaflight, чтобы предотвратить высокочастотные колебания на полном газе.',
152
+ html: 'Для сборок с TWR выше 10 к 1 рекомендуется включить Throttle PID Attenuation (TPA) в Betaflight, чтобы предотвратить высокочастотные колебания на полном газе. Используйте тяговооруженность для сравнения конфигураций, но учитывайте полетный вес, просадку напряжения, реальный ток и запас для своего стиля пилотирования. Контролируемый тест помогает проверить реакцию модели перед полетом в сложных условиях.',
153
153
  },
154
154
  {
155
155
  type: 'tip',
@@ -149,12 +149,12 @@ const seoSections: SEOSection[] = [
149
149
  },
150
150
  {
151
151
  type: 'paragraph',
152
- html: 'För drönare med TWR över 10 till 1 rekommenderas att aktivera Throttle PID Attenuation (TPA) i Betaflight för att eliminera vibrationer vid fullgas.',
152
+ html: 'För drönare med TWR över 10 till 1 rekommenderas att aktivera Throttle PID Attenuation (TPA) i Betaflight för att minska vibrationer vid fullgas. Använd förhållandet mellan dragkraft och vikt för att jämföra konfigurationer under samma förhållanden. Ta hänsyn till startvikt, spänning under belastning, verklig förbrukning och den reserv som behövs för din flygstil. Ett kontrollerat test visar hur modellen reagerar innan flygning i krävande miljöer.',
153
153
  },
154
154
  {
155
155
  type: 'tip',
156
156
  title: 'Tips för Betaflight TPA Inställning',
157
- html: 'Ställ in TPA brytpunkt på 1250 eller 1350 med en dämpning på 0.65 för att säkerställa perfekt stabilitet snabba raksträckor.',
157
+ html: 'Ställ in TPA-brytpunkten på 1250 eller 1350 med en dämpning på 0.65 och kontrollera resultatet med en kontrollerad provflygning.',
158
158
  },
159
159
  ];
160
160
 
@@ -149,7 +149,7 @@ const seoSections: SEOSection[] = [
149
149
  },
150
150
  {
151
151
  type: 'paragraph',
152
- html: 'TWR si 10 a 1 in üzerinde olan dronlar için tam gazdaki titreşimleri önlemek adına Betaflight Throttle PID Attenuation (TPA) özelliğinin etkinleştirilmesi önerilir.',
152
+ html: 'TWR si 10 a 1 in üzerinde olan dronlar için tam gazdaki titreşimleri önlemek adına Betaflight Throttle PID Attenuation (TPA) özelliğinin etkinleştirilmesi önerilir. İtki-ağırlık oranını yapılandırmaları karşılaştırmak için kullanın; kalkış ağırlığını, yük altındaki voltajı, gerçek tüketimi ve uçuş tarzınız için gereken payı da hesaba katın. Kontrollü bir test, zorlu koşullarda uçmadan önce modelin tepkisini doğrulamanıza yardımcı olur. Sonuçları farklı gaz seviyelerinde karşılaştırmak, kontrol ve uçuş süresini daha gerçekçi değerlendirmeyi sağlar.',
153
153
  },
154
154
  {
155
155
  type: 'tip',