diff --git a/.github/workflows/deploy-pr-preview.yml b/.github/workflows/deploy-pr-preview.yml index 8e672c54..264a3b32 100644 --- a/.github/workflows/deploy-pr-preview.yml +++ b/.github/workflows/deploy-pr-preview.yml @@ -338,6 +338,51 @@ jobs: echo " - Total docs: $TOTAL_CONTENT" find static/images -type f 2>/dev/null | wc -l | xargs echo " - Images:" + - name: Restore hand-maintained i18n files + # The content-provisioning step above may have overlaid i18n/ from the + # content branch (or regenerated it from Notion), silently discarding + # the hand-maintained theme translation files this repo owns + # (docusaurus-theme-classic/*). Restore them unconditionally from the + # checked-out PR ref so the preview renders what the PR is actually + # proposing, not unrelated content-branch state. + # + # code.json also carries Notion-generated, content-only translation + # keys that live only on the content branch. A blind `checkout HEAD` + # here would silently drop those on every build. Merge instead: keep + # content-only keys, let HEAD win any key this repo also defines. + run: | + set -e + git ls-files -z 'i18n/*/docusaurus-theme-classic/*' \ + | xargs -0 -r git checkout HEAD -- + + # `jq -s` slurps every JSON document across BOTH input files into + # one flat array and blindly indexes [0]/[1] — it does not verify + # each file actually contributed exactly one object. An empty or + # `null` content file silently falls back to HEAD alone; a content + # file with two concatenated JSON documents silently drops HEAD + # entirely. Validate each side is exactly one JSON object before + # merging so malformed input fails the step instead of shipping a + # silently wrong catalog. + validate_single_json_object() { + local file="$1" label="$2" count + count=$(jq -s 'length' "$file" 2>/dev/null) || { echo "::error::$label: not valid JSON" >&2; return 1; } + if [ "$count" != "1" ]; then + echo "::error::$label: expected exactly 1 JSON document, found $count" >&2 + return 1 + fi + jq -e 'type == "object"' "$file" >/dev/null 2>&1 || { echo "::error::$label: root value is not a JSON object" >&2; return 1; } + } + + git ls-files -z 'i18n/*/code.json' | while IFS= read -r -d '' f; do + head_tmp=$(mktemp) + git show "HEAD:$f" > "$head_tmp" + validate_single_json_object "$f" "$f (content overlay)" + validate_single_json_object "$head_tmp" "HEAD:$f" + jq -s '.[0] + .[1]' "$f" "$head_tmp" > "$f.tmp" + mv "$f.tmp" "$f" + rm -f "$head_tmp" + done + - name: Build documentation # IS_PRODUCTION not set - generates noindex meta tags and disallow robots.txt run: bun run build diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 4d9b9c16..fb9e73ea 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -132,6 +132,50 @@ jobs: echo "šŸ“‚ Checking out content at SHA ${SHA:0:8}..." git checkout "$SHA" -- docs/ i18n/ static/images/ + # The checkout above overlays i18n/ from the locked content SHA, + # which does not carry this repo's hand-maintained theme + # translation files (docusaurus-theme-classic/*) — those live + # only on main. Restore them from main so the theme chrome + # translations this repo owns actually reach production instead + # of silently falling back to whatever (or nothing) the content + # SHA has for those paths. + git ls-files -z 'i18n/*/docusaurus-theme-classic/*' \ + | xargs -0 -r git checkout HEAD -- + + # code.json also carries Notion-generated, content-only + # translation keys that live only on the content SHA. A blind + # `checkout HEAD` here would silently drop those on every build. + # Merge instead: keep content-only keys, let HEAD win any key + # this repo also defines. + # + # `jq -s` slurps every JSON document across BOTH input files into + # one flat array and blindly indexes [0]/[1] — it does not verify + # each file actually contributed exactly one object. An empty or + # `null` content file silently falls back to HEAD alone; a content + # file with two concatenated JSON documents silently drops HEAD + # entirely. Validate each side is exactly one JSON object before + # merging so malformed input fails the step instead of shipping a + # silently wrong catalog. + validate_single_json_object() { + local file="$1" label="$2" count + count=$(jq -s 'length' "$file" 2>/dev/null) || { echo "::error::$label: not valid JSON" >&2; return 1; } + if [ "$count" != "1" ]; then + echo "::error::$label: expected exactly 1 JSON document, found $count" >&2 + return 1 + fi + jq -e 'type == "object"' "$file" >/dev/null 2>&1 || { echo "::error::$label: root value is not a JSON object" >&2; return 1; } + } + + git ls-files -z 'i18n/*/code.json' | while IFS= read -r -d '' f; do + head_tmp=$(mktemp) + git show "HEAD:$f" > "$head_tmp" + validate_single_json_object "$f" "$f (content overlay)" + validate_single_json_object "$head_tmp" "HEAD:$f" + jq -s '.[0] + .[1]' "$f" "$head_tmp" > "$f.tmp" + mv "$f.tmp" "$f" + rm -f "$head_tmp" + done + # Validate content exists echo "šŸ” Validating content..." @@ -220,7 +264,14 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" echo "${SHA}" > content-lock.sha git add content-lock.sha - git commit -m "chore(content): promote content ${SHA:0:8} to production [skip ci]" + + # Earlier steps in this job overlay content into i18n/ and merge + # code.json in the working tree only, without re-staging it — the + # index still holds the raw content-branch (unmerged) catalog. A + # plain `git commit` here would commit that whole index snapshot, + # silently regressing code.json on main. Scope the commit to + # content-lock.sha only so any other staged changes are ignored. + git commit content-lock.sha -m "chore(content): promote content ${SHA:0:8} to production [skip ci]" git push origin HEAD:main echo "āœ… Updated content-lock.sha → ${SHA:0:8}" diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index b8d7c7f6..d1399d4d 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -66,7 +66,7 @@ jobs: if [ "$SHOULD_DEPLOY" != "true" ]; then if [ "$BRANCH_NAME" = "main" ]; then - if printf '%s\n' "$CHANGED_FILES" | grep -Eq '^(src/|static/img/|docusaurus\.config\.ts$|sidebars\.ts$|package\.json$)'; then + if printf '%s\n' "$CHANGED_FILES" | grep -Eq '^(src/|static/img/|docusaurus\.config\.ts$|sidebars\.ts$|package\.json$|docs/|i18n/)'; then SHOULD_DEPLOY="true" REASON="main push includes code/config paths." else @@ -124,6 +124,50 @@ jobs: echo "šŸ“‚ Checking out content files..." git checkout origin/content -- docs/ i18n/ static/images/ + # The checkout above overlays i18n/ from the content branch, + # which does not carry this repo's hand-maintained theme + # translation files (docusaurus-theme-classic/*) — those live + # only on main. Restore them from main so the theme chrome + # translations this repo owns actually reach staging instead of + # silently falling back to whatever (or nothing) the content + # branch has for those paths. + git ls-files -z 'i18n/*/docusaurus-theme-classic/*' \ + | xargs -0 -r git checkout HEAD -- + + # code.json also carries Notion-generated, content-only + # translation keys that live only on the content branch. A blind + # `checkout HEAD` here would silently drop those on every build. + # Merge instead: keep content-only keys, let HEAD win any key + # this repo also defines. + # + # `jq -s` slurps every JSON document across BOTH input files into + # one flat array and blindly indexes [0]/[1] — it does not verify + # each file actually contributed exactly one object. An empty or + # `null` content file silently falls back to HEAD alone; a content + # file with two concatenated JSON documents silently drops HEAD + # entirely. Validate each side is exactly one JSON object before + # merging so malformed input fails the step instead of shipping a + # silently wrong catalog. + validate_single_json_object() { + local file="$1" label="$2" count + count=$(jq -s 'length' "$file" 2>/dev/null) || { echo "::error::$label: not valid JSON" >&2; return 1; } + if [ "$count" != "1" ]; then + echo "::error::$label: expected exactly 1 JSON document, found $count" >&2 + return 1 + fi + jq -e 'type == "object"' "$file" >/dev/null 2>&1 || { echo "::error::$label: root value is not a JSON object" >&2; return 1; } + } + + git ls-files -z 'i18n/*/code.json' | while IFS= read -r -d '' f; do + head_tmp=$(mktemp) + git show "HEAD:$f" > "$head_tmp" + validate_single_json_object "$f" "$f (content overlay)" + validate_single_json_object "$head_tmp" "HEAD:$f" + jq -s '.[0] + .[1]' "$f" "$head_tmp" > "$f.tmp" + mv "$f.tmp" "$f" + rm -f "$head_tmp" + done + # Validate content exists echo "šŸ” Validating content..." diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index 12fc9f47..19056d78 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -28,6 +28,50 @@ jobs: echo "šŸ“‚ Checking out content files..." git checkout origin/content -- docs/ i18n/ static/images/ + # The checkout above overlays i18n/ from the content branch, + # which does not carry this repo's hand-maintained theme + # translation files (docusaurus-theme-classic/*) — those live + # only on main. Restore them from main so the theme chrome + # translations this repo owns actually reach the test deployment + # instead of silently falling back to whatever (or nothing) the + # content branch has for those paths. + git ls-files -z 'i18n/*/docusaurus-theme-classic/*' \ + | xargs -0 -r git checkout HEAD -- + + # code.json also carries Notion-generated, content-only + # translation keys that live only on the content branch. A blind + # `checkout HEAD` here would silently drop those on every build. + # Merge instead: keep content-only keys, let HEAD win any key + # this repo also defines. + # + # `jq -s` slurps every JSON document across BOTH input files into + # one flat array and blindly indexes [0]/[1] — it does not verify + # each file actually contributed exactly one object. An empty or + # `null` content file silently falls back to HEAD alone; a content + # file with two concatenated JSON documents silently drops HEAD + # entirely. Validate each side is exactly one JSON object before + # merging so malformed input fails the step instead of shipping a + # silently wrong catalog. + validate_single_json_object() { + local file="$1" label="$2" count + count=$(jq -s 'length' "$file" 2>/dev/null) || { echo "::error::$label: not valid JSON" >&2; return 1; } + if [ "$count" != "1" ]; then + echo "::error::$label: expected exactly 1 JSON document, found $count" >&2 + return 1 + fi + jq -e 'type == "object"' "$file" >/dev/null 2>&1 || { echo "::error::$label: root value is not a JSON object" >&2; return 1; } + } + + git ls-files -z 'i18n/*/code.json' | while IFS= read -r -d '' f; do + head_tmp=$(mktemp) + git show "HEAD:$f" > "$head_tmp" + validate_single_json_object "$f" "$f (content overlay)" + validate_single_json_object "$head_tmp" "HEAD:$f" + jq -s '.[0] + .[1]' "$f" "$head_tmp" > "$f.tmp" + mv "$f.tmp" "$f" + rm -f "$head_tmp" + done + # Validate content exists echo "šŸ” Validating content..." diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78316fae..52d36e19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,56 @@ jobs: if git checkout origin/content -- i18n/; then echo "has_i18n=true" >> "$GITHUB_OUTPUT" echo "āœ… Loaded i18n content from origin/content." + + # The checkout above overlays i18n/ from the content branch, + # which would silently discard the hand-maintained theme + # translation files this repo owns (docusaurus-theme-classic/*), + # letting locale tests pass against stale content-branch data + # instead of what's actually committed here. A prior version of + # this restore only looked at what the current ref changed + # relative to its merge-base with main, which is empty on a push + # to main itself (HEAD == origin/main) and for any PR that + # doesn't touch i18n/ — so it silently did nothing on exactly + # the runs that matter most. Restore unconditionally instead: + # these paths are always supposed to come from the git-tracked + # ref being tested, never from content. + git ls-files -z 'i18n/*/docusaurus-theme-classic/*' \ + | xargs -0 -r git checkout HEAD -- + + # code.json also carries Notion-generated, content-only + # translation keys that live only on the content branch. A + # blind `checkout HEAD` here would silently drop those and let + # locale tests validate an unrealistic, content-key-free + # catalog. Merge instead: keep content-only keys, let HEAD win + # any key this repo also defines. + # + # `jq -s` slurps every JSON document across BOTH input files + # into one flat array and blindly indexes [0]/[1] — it does not + # verify each file actually contributed exactly one object. An + # empty or `null` content file silently falls back to HEAD + # alone; a content file with two concatenated JSON documents + # silently drops HEAD entirely. Validate each side is exactly + # one JSON object before merging so malformed input fails the + # step instead of shipping a silently wrong catalog. + validate_single_json_object() { + local file="$1" label="$2" count + count=$(jq -s 'length' "$file" 2>/dev/null) || { echo "::error::$label: not valid JSON" >&2; return 1; } + if [ "$count" != "1" ]; then + echo "::error::$label: expected exactly 1 JSON document, found $count" >&2 + return 1 + fi + jq -e 'type == "object"' "$file" >/dev/null 2>&1 || { echo "::error::$label: root value is not a JSON object" >&2; return 1; } + } + + git ls-files -z 'i18n/*/code.json' | while IFS= read -r -d '' f; do + head_tmp=$(mktemp) + git show "HEAD:$f" > "$head_tmp" + validate_single_json_object "$f" "$f (content overlay)" + validate_single_json_object "$head_tmp" "HEAD:$f" + jq -s '.[0] + .[1]' "$f" "$head_tmp" > "$f.tmp" + mv "$f.tmp" "$f" + rm -f "$head_tmp" + done else echo "has_i18n=false" >> "$GITHUB_OUTPUT" echo "āš ļø origin/content checkout failed. Locale-dependent tests will be skipped." diff --git a/.github/workflows/translate-docs.yml b/.github/workflows/translate-docs.yml index d11b85a0..dca96107 100644 --- a/.github/workflows/translate-docs.yml +++ b/.github/workflows/translate-docs.yml @@ -58,7 +58,10 @@ jobs: # Remove the lines that ignore generated content directories and their associated comments # This preserves all other .gitignore updates from main while allowing commits to these dirs - sed -i '/^# Generated content (synced from content branch)$/d; /^# These directories are populated by checking out from the content branch$/d; /^\/docs\/$/d; /^\/i18n\/$/d; /^\/static\/images\/$/d' .gitignore + # The i18n negation block (theme JSON allowlist for es/pt) must also be stripped here, + # otherwise its /i18n/* rule keeps everything outside navbar.json/footer.json ignored + # on the content branch (e.g. code.json, translated doc pages never get committed). + sed -i '/^# Generated content (synced from content branch)$/d; /^# These directories are populated by checking out from the content branch$/d; /^\/docs\/$/d; /^\/i18n\/$/d; /^\/static\/images\/$/d; /^# Generated i18n content/,/^$/d' .gitignore # Only commit if .gitignore actually changed if ! git diff --quiet .gitignore; then diff --git a/.gitignore b/.gitignore index 8c5ebd8c..cd1508c7 100644 --- a/.gitignore +++ b/.gitignore @@ -59,9 +59,23 @@ assets/ # Generated content (synced from content branch) # These directories are populated by checking out from the content branch /docs/ -/i18n/ /static/images/ +# Generated i18n content — selectively un-ignore theme JSON for es/pt +/i18n/* +!/i18n/es/ +/i18n/es/* +!/i18n/es/docusaurus-theme-classic/ +/i18n/es/docusaurus-theme-classic/* +!/i18n/es/docusaurus-theme-classic/navbar.json +!/i18n/es/docusaurus-theme-classic/footer.json +!/i18n/pt/ +/i18n/pt/* +!/i18n/pt/docusaurus-theme-classic/ +/i18n/pt/docusaurus-theme-classic/* +!/i18n/pt/docusaurus-theme-classic/navbar.json +!/i18n/pt/docusaurus-theme-classic/footer.json + # Generated robots.txt (created at build time based on IS_PRODUCTION env var) /static/robots.txt diff --git a/i18n/es/code.json b/i18n/es/code.json index 92b9f565..1822ba99 100644 --- a/i18n/es/code.json +++ b/i18n/es/code.json @@ -10,124 +10,124 @@ "message": "Preparación para el uso de CoMapeo" }, "Understanding CoMapeo's Core Concepts and Functions": { - "message": "Nueva PĆ”gina" + "message": "Comprender los Conceptos y Funciones Principales de CoMapeo" }, "Getting Started Essentials": { - "message": "Nuevo tĆ­tulo de sección" + "message": "Introducción y Elementos Esenciales" }, "Gathering the Right Equipment for CoMapeo": { "message": "Reunir el Equipo Adecuado para CoMapeo" }, "Device Setup and Maintenance for CoMapeo": { - "message": "Nueva PĆ”gina" + "message": "Configuración y Mantenimiento de Dispositivos para CoMapeo" }, "Installing CoMapeo & Onboarding": { - "message": "Nueva PĆ”gina" + "message": "Instalación de CoMapeo e Incorporación" }, "Initial Use and CoMapeo Settings": { - "message": "Nueva PĆ”gina" + "message": "Uso Inicial y Configuración de CoMapeo" }, "Uninstalling CoMapeo": { "message": "Desinstalar CoMapeo" }, "Customizing CoMapeo": { - "message": "Nueva Palanca" + "message": "Personalización de CoMapeo" }, "Organizing Key Materials for Projects": { - "message": "Nueva PĆ”gina" + "message": "Organización de Materiales Clave para Proyectos" }, "Building a Custom Categories Set": { - "message": "Nueva PĆ”gina" + "message": "Crear un Conjunto de CategorĆ­as Personalizado" }, "Building Custom Background Maps": { - "message": "Nueva PĆ”gina" + "message": "Crear Mapas de Fondo Personalizados" }, "Observations & Tracks": { - "message": "Nuevo tĆ­tulo de sección" + "message": "Observaciones y Recorridos" }, "Gathering Observations & Tracks": { "message": "Recopilación de observaciones" }, "Creating a New Observation": { - "message": "Nueva PĆ”gina" + "message": "Crear una Nueva Observación" }, "Creating a New Track": { - "message": "Nueva PĆ”gina" + "message": "Crear un Nuevo Recorrido" }, "Reviewing Observations": { "message": "Revisión de observaciones" }, "Exploring the Observations List": { - "message": "Nueva PĆ”gina" + "message": "Explorar la Lista de Observaciones" }, "Reviewing an Observation": { - "message": "Nueva PĆ”gina" + "message": "Revisar una Observación" }, "Editing Observations": { - "message": "Nueva PĆ”gina" + "message": "Editar Observaciones" }, "Data Privacy & Security": { - "message": "Nuevo tĆ­tulo de sección" + "message": "Datos, Privacidad y Seguridad" }, "Encryption and Security": { - "message": "Nueva PĆ”gina" + "message": "Encriptación y Seguridad" }, "Managing Data Privacy & Security": { "message": "Gestión de datos y privacidad" }, "Using an App Passcode for Security": { - "message": "Nueva PĆ”gina" + "message": "Usar un Código de Acceso para Seguridad" }, "Adjusting Data Sharing and Privacy": { - "message": "Nueva PĆ”gina" + "message": "Ajustar el Intercambio de Datos y la Privacidad" }, "Mapping with Collaborators": { - "message": "Nueva PĆ”gina" + "message": "Mapeo con Colaboradores" }, "Managing Projects": { "message": "Gestión de proyectos" }, "Understanding Projects": { - "message": "Nueva PĆ”gina" + "message": "Comprender los Proyectos" }, "Creating a New Project": { - "message": "Nueva PĆ”gina" + "message": "Crear un Nuevo Proyecto" }, "Changing Categories Set": { - "message": "Nueva PĆ”gina" + "message": "Cambiar Conjunto de CategorĆ­as" }, "Managing a Team": { - "message": "Nueva PĆ”gina" + "message": "Gestionar un Equipo" }, "Inviting Collaborators": { - "message": "Nueva PĆ”gina" + "message": "Invitar Colaboradores" }, "Ending a Project": { - "message": "Nueva PĆ”gina" + "message": "Finalizar un Proyecto" }, "Exchanging Project Data": { "message": "Intercambio de Datos del Proyecto" }, "Understanding How Exchange Works": { - "message": "Nueva PĆ”gina A" + "message": "Comprender Cómo Funciona el Intercambio" }, "Using Exchange Offline": { - "message": "Nueva PĆ”gina" + "message": "Usar Intercambio sin Conexión" }, "Using a Remote Archive": { - "message": "Nueva PĆ”gina" + "message": "Usar un Archivo Remoto" }, "Moving Observations & Tracks Outside of CoMapeo": { "message": "Compartir observaciones fuera de CoMapeo" }, "Sharing a Single Observation and Metadata": { - "message": "Nueva PĆ”gina" + "message": "Compartir una Observación Individual y Metadatos" }, "Exporting all Observations": { - "message": "Nueva PĆ”gina" + "message": "Exportar Todas las Observaciones" }, "Using Observations outside of CoMapeo": { - "message": "Nueva PĆ”gina" + "message": "Usar Observaciones Fuera de CoMapeo" }, "Miscellaneous": { "message": "MiscelĆ”neas" @@ -139,49 +139,40 @@ "message": "Glosario" }, "Troubleshooting": { - "message": "Nueva Palanca" + "message": "Resolución de Problemas" }, "Common Solutions": { - "message": "Nueva PĆ”gina" + "message": "Soluciones Comunes" }, "Troubleshooting: Setup and Customization": { - "message": "Nueva PĆ”gina" + "message": "Resolución de Problemas: Configuración y Personalización" }, "Troubleshooting: Observations and Tracks": { - "message": "Nueva PĆ”gina" + "message": "Resolución de Problemas: Observaciones y Recorridos" }, "Troubleshooting: Data Privacy and Security": { - "message": "Nueva PĆ”gina" + "message": "Resolución de Problemas: Privacidad de Datos y Seguridad" }, "Troubleshooting: Mapping with Collaborators": { - "message": "Nueva PĆ”gina" + "message": "Resolución de Problemas: Mapeo con Colaboradores" }, "Troubleshooting: Moving Observations and Tracks outside of CoMapeo": { - "message": "Nueva PĆ”gina" - }, - "Elementos de contenido de prueba": { - "message": "Elementos de contenido de prueba" - }, - "Testing links": { - "message": "Nueva PĆ”gina" - }, - "Understanding CoMapeo's Core Concepts and Functions": { - "message": "Nueva PĆ”gina" + "message": "Resolución de Problemas: Mover Observaciones y Recorridos Fuera de CoMapeo" }, "Installing CoMapeo and Onboarding": { - "message": "Nueva PĆ”gina" + "message": "Instalación de CoMapeo e Incorporación" }, "Planning and Preparing for a Project": { - "message": "Nueva PĆ”gina" + "message": "Planificación y Preparación para un Proyecto" }, "Observations and Tracks": { - "message": "Nuevo tĆ­tulo de sección" + "message": "Observaciones y Recorridos" }, "Gathering Observations and Tracks": { "message": "Recopilación de observaciones" }, "Data Privacy and Security": { - "message": "Nuevo tĆ­tulo de sección" + "message": "Privacidad de Datos y Seguridad" }, "Managing Data Privacy and Security": { "message": "Gestión de datos y privacidad" @@ -197,5 +188,388 @@ }, "CLI Reference": { "message": "Referencia de CLI" + }, + "Conversational Guidance": { + "message": "Orientación Conversacional", + "description": "Feature title for voice-enabled QA bots section" + }, + "Get instant voice assistance through our documentation bots that listen to your questions and respond with precise answers in real-time.": { + "message": "ObtĆ©n asistencia de voz instantĆ”nea a travĆ©s de nuestros bots de documentación que escuchan tus preguntas y responden con respuestas precisas en tiempo real.", + "description": "Description for voice-enabled QA bots section" + }, + "Map in Any Language": { + "message": "Mapea en Cualquier Idioma", + "description": "Feature title for multi-lingual documentation section" + }, + "Access CoMapeo documentation in multiple languages, ensuring every team member can learn and contribute regardless of their native tongue.": { + "message": "Accede a la documentación de CoMapeo en varios idiomas, asegurando que cada miembro del equipo pueda aprender y contribuir independientemente de su lengua materna.", + "description": "Description for multi-lingual documentation section" + }, + "Your Mapping Journey Starts Here": { + "message": "Tu Viaje de Mapeo Comienza AquĆ­", + "description": "Feature title for comprehensive learning hub section" + }, + "Everything you need to master the CoMapeo platform, from beginner tutorials to advanced techniques, all in one centralized knowledge center.": { + "message": "Todo lo que necesitas para dominar la plataforma CoMapeo, desde tutoriales para principiantes hasta tĆ©cnicas avanzadas, todo en un centro de conocimiento centralizado.", + "description": "Description for comprehensive learning hub section" + }, + "theme.docs.DocCard.categoryDescription.plurals": { + "message": "1 artĆ­culo|{count} artĆ­culos", + "description": "The default description for a category card in the generated index about how many items this category includes" + }, + "theme.navbar.mobileLanguageDropdown.label": { + "message": "Idiomas", + "description": "The label for the mobile language switcher dropdown" + }, + "theme.ErrorPageContent.title": { + "message": "Esta pĆ”gina ha fallado.", + "description": "The title of the fallback page when the page crashed" + }, + "theme.BackToTopButton.buttonAriaLabel": { + "message": "Volver al principio", + "description": "The ARIA label for the back to top button" + }, + "theme.blog.archive.title": { + "message": "Archivo", + "description": "The page & hero title of the blog archive page" + }, + "theme.blog.archive.description": { + "message": "Archivo", + "description": "The page & hero description of the blog archive page" + }, + "theme.blog.paginator.navAriaLabel": { + "message": "Navegación por la pĆ”gina de la lista de blogs", + "description": "The ARIA label for the blog pagination" + }, + "theme.blog.paginator.newerEntries": { + "message": "Entradas mĆ”s recientes", + "description": "The label used to navigate to the newer blog posts page (previous page)" + }, + "theme.blog.paginator.olderEntries": { + "message": "Entradas mĆ”s antiguas", + "description": "The label used to navigate to the older blog posts page (next page)" + }, + "theme.blog.post.paginator.navAriaLabel": { + "message": "Barra de paginación de publicaciones del blog", + "description": "The ARIA label for the blog posts pagination" + }, + "theme.blog.post.paginator.newerPost": { + "message": "Publicación mĆ”s reciente", + "description": "The blog post button label to navigate to the newer/previous post" + }, + "theme.blog.post.paginator.olderPost": { + "message": "Publicación mĆ”s antigua", + "description": "The blog post button label to navigate to the older/next post" + }, + "theme.tags.tagsPageLink": { + "message": "Ver Todas las Etiquetas", + "description": "The label of the link targeting the tag list page" + }, + "theme.colorToggle.ariaLabel.mode.system": { + "message": "modo del sistema", + "description": "The name for the system color mode" + }, + "theme.colorToggle.ariaLabel.mode.light": { + "message": "modo claro", + "description": "The name for the light color mode" + }, + "theme.colorToggle.ariaLabel.mode.dark": { + "message": "modo oscuro", + "description": "The name for the dark color mode" + }, + "theme.colorToggle.ariaLabel": { + "message": "Cambiar entre modo oscuro y claro (actualmente {mode})", + "description": "The ARIA label for the color mode toggle" + }, + "theme.docs.breadcrumbs.navAriaLabel": { + "message": "Rastro de navegación", + "description": "The ARIA label for the breadcrumbs" + }, + "theme.docs.paginator.navAriaLabel": { + "message": "PĆ”gina del documento", + "description": "The ARIA label for the docs pagination" + }, + "theme.docs.paginator.previous": { + "message": "Anterior", + "description": "The label used to navigate to the previous doc" + }, + "theme.docs.paginator.next": { + "message": "Siguiente", + "description": "The label used to navigate to the next doc" + }, + "theme.docs.tagDocListPageTitle.nDocsTagged": { + "message": "Un documento etiquetado|{count} documentos etiquetados", + "description": "Pluralized label for \"{count} docs tagged\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" + }, + "theme.docs.tagDocListPageTitle": { + "message": "{nDocsTagged} con \"{tagName}\"", + "description": "The title of the page for a docs tag" + }, + "theme.docs.versionBadge.label": { + "message": "Versión: {versionLabel}" + }, + "theme.docs.versions.unreleasedVersionLabel": { + "message": "Esta es la documentación sin publicar para {siteTitle}, versión {versionLabel}.", + "description": "The label used to tell the user that he's browsing an unreleased doc version" + }, + "theme.docs.versions.unmaintainedVersionLabel": { + "message": "Esta es la documentación para {siteTitle} {versionLabel}, que ya no se mantiene activamente.", + "description": "The label used to tell the user that he's browsing an unmaintained doc version" + }, + "theme.docs.versions.latestVersionSuggestionLabel": { + "message": "Para la documentación actualizada, vea {latestVersionLink} ({versionLabel}).", + "description": "The label used to tell the user to check the latest version" + }, + "theme.docs.versions.latestVersionLinkLabel": { + "message": "Ćŗltima versión", + "description": "The label used for the latest version suggestion link label" + }, + "theme.common.headingLinkTitle": { + "message": "Enlace directo al {heading}", + "description": "Title for link to heading" + }, + "theme.common.editThisPage": { + "message": "Editar esta pĆ”gina", + "description": "The link label to edit the current page" + }, + "theme.lastUpdated.atDate": { + "message": " en {date}", + "description": "The words used to describe on which date a page has been last updated" + }, + "theme.lastUpdated.byUser": { + "message": " por {user}", + "description": "The words used to describe by who the page has been last updated" + }, + "theme.lastUpdated.lastUpdatedAtBy": { + "message": "Última actualización{atDate}{byUser}", + "description": "The sentence used to display when a page has been last updated, and by who" + }, + "theme.navbar.mobileVersionsDropdown.label": { + "message": "Versiones", + "description": "The label for the navbar versions dropdown on mobile view" + }, + "theme.NotFound.title": { + "message": "PĆ”gina No Encontrada", + "description": "The title of the 404 page" + }, + "theme.tags.tagsListLabel": { + "message": "Etiquetas:", + "description": "The label alongside a tag list" + }, + "theme.admonition.caution": { + "message": "precaución", + "description": "The default label used for the Caution admonition (:::caution)" + }, + "theme.admonition.danger": { + "message": "peligro", + "description": "The default label used for the Danger admonition (:::danger)" + }, + "theme.admonition.info": { + "message": "información", + "description": "The default label used for the Info admonition (:::info)" + }, + "theme.admonition.note": { + "message": "nota", + "description": "The default label used for the Note admonition (:::note)" + }, + "theme.admonition.tip": { + "message": "consejo", + "description": "The default label used for the Tip admonition (:::tip)" + }, + "theme.admonition.warning": { + "message": "aviso", + "description": "The default label used for the Warning admonition (:::warning)" + }, + "theme.AnnouncementBar.closeButtonAriaLabel": { + "message": "Cerrar", + "description": "The ARIA label for close button of announcement bar" + }, + "theme.blog.sidebar.navAriaLabel": { + "message": "Navegación de publicaciones recientes", + "description": "The ARIA label for recent posts in the blog sidebar" + }, + "theme.DocSidebarItem.expandCategoryAriaLabel": { + "message": "Ampliar la categorĆ­a '{label}' de la barra lateral", + "description": "The ARIA label to expand the sidebar category" + }, + "theme.DocSidebarItem.collapseCategoryAriaLabel": { + "message": "Colapsar categorĆ­a '{label}' de la barra lateral", + "description": "The ARIA label to collapse the sidebar category" + }, + "theme.IconExternalLink.ariaLabel": { + "message": "(se abre en una nueva pestaƱa)", + "description": "The ARIA label for the external link icon" + }, + "theme.NavBar.navAriaLabel": { + "message": "Principal", + "description": "The ARIA label for the main navigation" + }, + "theme.NotFound.p1": { + "message": "No pudimos encontrar lo que buscaba.", + "description": "The first paragraph of the 404 page" + }, + "theme.NotFound.p2": { + "message": "ComunĆ­quese con el dueƱo del sitio que le proporcionó la URL original y hĆ”gale saber que su vĆ­nculo estĆ” roto.", + "description": "The 2nd paragraph of the 404 page" + }, + "theme.TOCCollapsible.toggleButtonLabel": { + "message": "En esta pĆ”gina", + "description": "The label used by the button on the collapsible TOC component" + }, + "theme.blog.post.readingTime.plurals": { + "message": "Lectura de un minuto|{readingTime} min de lectura", + "description": "Pluralized label for \"{readingTime} min read\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" + }, + "theme.blog.post.readMore": { + "message": "Leer MĆ”s", + "description": "The label used in blog post item excerpts to link to full blog posts" + }, + "theme.blog.post.readMoreLabel": { + "message": "Leer mĆ”s acerca de {title}", + "description": "The ARIA label for the link to full blog posts from excerpts" + }, + "theme.CodeBlock.copy": { + "message": "Copiar", + "description": "The copy button label on code blocks" + }, + "theme.CodeBlock.copied": { + "message": "Copiado", + "description": "The copied button label on code blocks" + }, + "theme.CodeBlock.copyButtonAriaLabel": { + "message": "Copiar código", + "description": "The ARIA label for copy code blocks button" + }, + "theme.CodeBlock.wordWrapToggle": { + "message": "Alternar ajuste de palabras", + "description": "The title attribute for toggle word wrapping button of code block lines" + }, + "theme.docs.breadcrumbs.home": { + "message": "PĆ”gina de Inicio", + "description": "The ARIA label for the home page in the breadcrumbs" + }, + "theme.docs.sidebar.collapseButtonTitle": { + "message": "Colapsar barra lateral", + "description": "The title attribute for collapse button of doc sidebar" + }, + "theme.docs.sidebar.collapseButtonAriaLabel": { + "message": "Colapsar barra lateral", + "description": "The title attribute for collapse button of doc sidebar" + }, + "theme.docs.sidebar.navAriaLabel": { + "message": "Barra lateral de Documentos", + "description": "The ARIA label for the sidebar navigation" + }, + "theme.docs.sidebar.closeSidebarButtonAriaLabel": { + "message": "Cerrar barra lateral", + "description": "The ARIA label for close button of mobile sidebar" + }, + "theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": { + "message": "← Volver al menĆŗ principal", + "description": "The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)" + }, + "theme.docs.sidebar.toggleSidebarButtonAriaLabel": { + "message": "Alternar barra lateral", + "description": "The ARIA label for hamburger menu button of mobile navigation" + }, + "theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": { + "message": "Expandir el menĆŗ desplegable", + "description": "The ARIA label of the button to expand the mobile dropdown navbar item" + }, + "theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": { + "message": "Contraer el menĆŗ desplegable", + "description": "The ARIA label of the button to collapse the mobile dropdown navbar item" + }, + "theme.docs.sidebar.expandButtonTitle": { + "message": "Expandir barra lateral", + "description": "The ARIA label and title attribute for expand button of doc sidebar" + }, + "theme.docs.sidebar.expandButtonAriaLabel": { + "message": "Expandir barra lateral", + "description": "The ARIA label and title attribute for expand button of doc sidebar" + }, + "theme.IdealImageMessage.loading": { + "message": "Cargando...", + "description": "When the full-scale image is loading" + }, + "theme.IdealImageMessage.load": { + "message": "Haz clic para cargar{sizeMessage}", + "description": "To prompt users to load the full image. sizeMessage is a parenthesized size figure." + }, + "theme.IdealImageMessage.offline": { + "message": "Tu navegador estĆ” desconectado. Imagen no cargada", + "description": "When the user is viewing an offline document" + }, + "theme.IdealImageMessage.404error": { + "message": "404. Imagen no encontrada", + "description": "When the image is not found" + }, + "theme.IdealImageMessage.error": { + "message": "Error. Haz clic para recargar", + "description": "When the image fails to load for unknown error" + }, + "theme.blog.post.plurals": { + "message": "Una publicación|{count} publicaciones", + "description": "Pluralized label for \"{count} posts\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" + }, + "theme.blog.tagTitle": { + "message": "{nPosts} etiquetados con \"{tagName}\"", + "description": "The title of the page for a blog tag" + }, + "theme.blog.author.pageTitle": { + "message": "{authorName} - {nPosts}", + "description": "The title of the page for a blog author" + }, + "theme.blog.authorsList.pageTitle": { + "message": "Autores", + "description": "The title of the authors page" + }, + "theme.blog.authorsList.viewAll": { + "message": "Ver Todos los Autores", + "description": "The label of the link targeting the blog authors page" + }, + "theme.blog.author.noPosts": { + "message": "Este autor aĆŗn no ha escrito ninguna publicación.", + "description": "The text for authors with 0 blog post" + }, + "theme.contentVisibility.unlistedBanner.title": { + "message": "PĆ”gina sin clasificar", + "description": "The unlisted content banner title" + }, + "theme.contentVisibility.unlistedBanner.message": { + "message": "Esta pĆ”gina estĆ” sin clasificar. Los motores de bĆŗsqueda no la indexarĆ”n, y solo los usuarios con el enlace directo podrĆ”n acceder a esta.", + "description": "The unlisted content banner message" + }, + "theme.contentVisibility.draftBanner.title": { + "message": "PĆ”gina de borrador", + "description": "The draft content banner title" + }, + "theme.contentVisibility.draftBanner.message": { + "message": "Esta pĆ”gina es un borrador. Solo serĆ” visible en desarrollo y se excluirĆ” de la compilación de producción.", + "description": "The draft content banner message" + }, + "theme.ErrorPageContent.tryAgain": { + "message": "Intente de nuevo", + "description": "The label of the button to try again rendering when the React error boundary captures an error" + }, + "theme.common.skipToMainContent": { + "message": "Saltar al contenido principal", + "description": "The skip to content label used for accessibility, allowing to rapidly navigate to main content with keyboard tab/enter navigation" + }, + "theme.tags.tagsPageTitle": { + "message": "Etiquetas", + "description": "The title of the tag list page" + }, + "theme.PwaReloadPopup.closeButtonAriaLabel": { + "message": "Cerrar", + "description": "The ARIA label for close button of PWA reload popup" + }, + "theme.PwaReloadPopup.info": { + "message": "Nueva versión disponible", + "description": "The text used in PWA reload popup" + }, + "theme.PwaReloadPopup.refreshButtonText": { + "message": "Actualizar", + "description": "The text used for PWA reload button" } } diff --git a/i18n/es/docusaurus-theme-classic/footer.json b/i18n/es/docusaurus-theme-classic/footer.json new file mode 100644 index 00000000..2d95cc5a --- /dev/null +++ b/i18n/es/docusaurus-theme-classic/footer.json @@ -0,0 +1,106 @@ +{ + "links.title.Awana Digital": { + "message": "Awana Digital", + "description": "Footer section title: Awana Digital" + }, + "links.Awana Digital.Website": { + "message": "Sitio web", + "description": "Footer link label: Website" + }, + "links.Awana Digital.Discord": { + "message": "Discord", + "description": "Footer link label: Discord" + }, + "links.Awana Digital.Bluesky": { + "message": "Bluesky", + "description": "Footer link label: Bluesky" + }, + "links.Awana Digital.Blog": { + "message": "Blog", + "description": "Footer link label: Blog" + }, + "links.title.CoMapeo": { + "message": "CoMapeo", + "description": "Footer section title: CoMapeo" + }, + "links.CoMapeo.Website": { + "message": "Sitio web", + "description": "Footer link label: Website" + }, + "links.CoMapeo.CoMapeo Mobile GitHub": { + "message": "GitHub de CoMapeo Mobile", + "description": "Footer link label: CoMapeo Mobile GitHub" + }, + "links.CoMapeo.CoMapeo Desktop GitHub": { + "message": "GitHub de CoMapeo Desktop", + "description": "Footer link label: CoMapeo Desktop GitHub" + }, + "links.title.More": { + "message": "MĆ”s", + "description": "Footer section title: More" + }, + "links.More.PlayStore": { + "message": "PlayStore", + "description": "Footer link label: PlayStore" + }, + "links.More.GitHub": { + "message": "GitHub", + "description": "Footer link label: GitHub" + }, + "links.More.Earth Defenders Toolkit": { + "message": "Earth Defenders Toolkit", + "description": "Footer link label: Earth Defenders Toolkit" + }, + "copyright": { + "message": "Hecho con ā¤ļø por Awana Digital - 2026", + "description": "The footer copyright" + }, + "link.title.Awana Digital": { + "message": "Awana Digital", + "description": "The title of the footer links column with title=Awana Digital in the footer" + }, + "link.title.CoMapeo": { + "message": "CoMapeo", + "description": "The title of the footer links column with title=CoMapeo in the footer" + }, + "link.title.More": { + "message": "MĆ”s", + "description": "The title of the footer links column with title=More in the footer" + }, + "link.item.label.Website": { + "message": "Sitio web", + "description": "The label of footer link with label=Website linking to https://comapeo.app" + }, + "link.item.label.Discord": { + "message": "Discord", + "description": "The label of footer link with label=Discord linking to https://discord.gg/NtZgtAjj" + }, + "link.item.label.Bluesky": { + "message": "Bluesky", + "description": "The label of footer link with label=Bluesky linking to https://bsky.app/profile/awana.digital" + }, + "link.item.label.Blog": { + "message": "Blog", + "description": "The label of footer link with label=Blog linking to https://awana.digital/blog" + }, + "link.item.label.CoMapeo Mobile GitHub": { + "message": "GitHub de CoMapeo Mobile", + "description": "The label of footer link with label=CoMapeo Mobile GitHub linking to https://github.com/digidem/comapeo-docs" + }, + "link.item.label.CoMapeo Desktop GitHub": { + "message": "GitHub de CoMapeo Desktop", + "description": "The label of footer link with label=CoMapeo Desktop GitHub linking to https://github.com/digidem/comapeo-docs" + }, + "link.item.label.PlayStore": { + "message": "PlayStore", + "description": "The label of footer link with label=PlayStore linking to https://play.google.com/store/apps/details?id=com.comapeo" + }, + "link.item.label.GitHub": { + "message": "GitHub", + "description": "The label of footer link with label=GitHub linking to https://github.com/digidem/comapeo-docs" + }, + "link.item.label.Earth Defenders Toolkit": { + "message": "Earth Defenders Toolkit", + "description": "The label of footer link with label=Earth Defenders Toolkit linking to https://www.earthdefenderstoolkit.com/" + } +} diff --git a/i18n/es/docusaurus-theme-classic/navbar.json b/i18n/es/docusaurus-theme-classic/navbar.json new file mode 100644 index 00000000..45e1a8ad --- /dev/null +++ b/i18n/es/docusaurus-theme-classic/navbar.json @@ -0,0 +1,14 @@ +{ + "item.label.Documentation": { + "message": "Documentación", + "description": "Navbar item with label Documentation" + }, + "item.label.GitHub": { + "message": "GitHub", + "description": "Navbar item with label GitHub" + }, + "logo.alt": { + "message": "CoMapeo", + "description": "The alt text of navbar logo" + } +} diff --git a/i18n/pt/code.json b/i18n/pt/code.json index 7c22c3c1..3f004f3d 100644 --- a/i18n/pt/code.json +++ b/i18n/pt/code.json @@ -7,130 +7,130 @@ "message": "Introdução" }, "Preparing to Use CoMapeo": { - "message": "Preparando para usar do CoMapeo (Mobile)" + "message": "Preparando para Usar o CoMapeo" }, "Understanding CoMapeo's Core Concepts and Functions": { - "message": "Nova PĆ”gina" + "message": "Compreendendo os Conceitos e FunƧƵes Principais do CoMapeo" }, "Getting Started Essentials": { - "message": "Novo tĆ­tulo da seção" + "message": "Introdução e Elementos Essenciais" }, "Gathering the Right Equipment for CoMapeo": { "message": "Reunindo o Equipamento Certo para o CoMapeo" }, "Device Setup and Maintenance for CoMapeo": { - "message": "Nova PĆ”gina" + "message": "Configuração e Manutenção de Dispositivos para CoMapeo" }, "Installing CoMapeo & Onboarding": { - "message": "Nova PĆ”gina" + "message": "Instalação do CoMapeo e Integração" }, "Initial Use and CoMapeo Settings": { - "message": "Nova PĆ”gina" + "message": "Uso Inicial e ConfiguraƧƵes do CoMapeo" }, "Uninstalling CoMapeo": { - "message": "Nova PĆ”gina" + "message": "Desinstalando o CoMapeo" }, "Customizing CoMapeo": { - "message": "Novo Alternar" + "message": "Personalizando o CoMapeo" }, "Organizing Key Materials for Projects": { - "message": "Nova PĆ”gina" + "message": "Organizando Materiais Essenciais para Projetos" }, "Building a Custom Categories Set": { - "message": "Nova PĆ”gina" + "message": "Criando um Conjunto de Categorias Personalizado" }, "Building Custom Background Maps": { - "message": "Nova PĆ”gina" + "message": "Criando Mapas de Fundo Personalizados" }, "Observations & Tracks": { - "message": "Novo tĆ­tulo da seção" + "message": "ObservaƧƵes e Percursos" }, "Gathering Observations & Tracks": { "message": "Coletando ObservaƧƵes" }, "Creating a New Observation": { - "message": "Nova PĆ”gina" + "message": "Criando uma Nova Observação" }, "Creating a New Track": { - "message": "Nova PĆ”gina" + "message": "Criando um Novo Percurso" }, "Reviewing Observations": { "message": "Revisando ObservaƧƵes" }, "Exploring the Observations List": { - "message": "Nova PĆ”gina" + "message": "Explorando a Lista de ObservaƧƵes" }, "Reviewing an Observation": { - "message": "Nova PĆ”gina" + "message": "Revisando uma Observação" }, "Editing Observations": { - "message": "Nova PĆ”gina" + "message": "Editando ObservaƧƵes" }, "Data Privacy & Security": { - "message": "Novo tĆ­tulo da seção" + "message": "Dados, Privacidade e SeguranƧa" }, "Encryption and Security": { - "message": "Nova PĆ”gina" + "message": "Criptografia e SeguranƧa" }, "Managing Data Privacy & Security": { "message": "Gerenciamento de dados e privacidade" }, "Using an App Passcode for Security": { - "message": "Nova PĆ”gina" + "message": "Usando um Código de Acesso para SeguranƧa" }, "Adjusting Data Sharing and Privacy": { - "message": "Nova PĆ”gina" + "message": "Ajustando o Compartilhamento de Dados e Privacidade" }, "Mapping with Collaborators": { - "message": "Nova PĆ”gina" + "message": "Mapeamento com Colaboradores" }, "Managing Projects": { "message": "Gerenciando Projetos" }, "Understanding Projects": { - "message": "Nova PĆ”gina" + "message": "Compreendendo os Projetos" }, "Creating a New Project": { - "message": "Nova PĆ”gina" + "message": "Criando um Novo Projeto" }, "Changing Categories Set": { - "message": "Nova PĆ”gina" + "message": "Alterando Conjunto de Categorias" }, "Managing a Team": { - "message": "Nova PĆ”gina" + "message": "Gerenciando uma Equipe" }, "Inviting Collaborators": { - "message": "Nova PĆ”gina" + "message": "Convidando Colaboradores" }, "Ending a Project": { - "message": "Nova PĆ”gina" + "message": "Finalizando um Projeto" }, "Exchanging Project Data": { "message": "Troca de Dados do Projeto" }, "Understanding How Exchange Works": { - "message": "Nova PĆ”gina A" + "message": "Compreendendo Como Funciona a Troca" }, "Using Exchange Offline": { - "message": "Nova PĆ”gina" + "message": "Usando a Troca Offline" }, "Using a Remote Archive": { - "message": "Nova PĆ”gina" + "message": "Usando um Arquivo Remoto" }, "Moving Observations & Tracks Outside of CoMapeo": { "message": "Compartilhando observaƧƵes fora do CoMapeo" }, "Sharing a Single Observation and Metadata": { - "message": "Nova PĆ”gina" + "message": "Compartilhando uma Observação Individual e Metadados" }, "Exporting all Observations": { - "message": "Nova PĆ”gina" + "message": "Exportando Todas as ObservaƧƵes" }, "Using Observations outside of CoMapeo": { - "message": "Nova PĆ”gina" + "message": "Usando ObservaƧƵes Fora do CoMapeo" }, "Miscellaneous": { - "message": "Variado" + "message": "Diversos" }, "FAQ": { "message": "Perguntas frequentes" @@ -142,46 +142,37 @@ "message": "Resolução de Problemas" }, "Common Solutions": { - "message": "Nova PĆ”gina" + "message": "SoluƧƵes Comuns" }, "Troubleshooting: Setup and Customization": { - "message": "Nova PĆ”gina" + "message": "Resolução de Problemas: Configuração e Personalização" }, "Troubleshooting: Observations and Tracks": { - "message": "Nova PĆ”gina" + "message": "Resolução de Problemas: ObservaƧƵes e Percursos" }, "Troubleshooting: Data Privacy and Security": { - "message": "Nova PĆ”gina" + "message": "Resolução de Problemas: Privacidade de Dados e SeguranƧa" }, "Troubleshooting: Mapping with Collaborators": { - "message": "Nova PĆ”gina" + "message": "Resolução de Problemas: Mapeamento com Colaboradores" }, "Troubleshooting: Moving Observations and Tracks outside of CoMapeo": { - "message": "Nova PĆ”gina" - }, - "Elementos de ConteĆŗdo de Teste": { - "message": "Elementos de ConteĆŗdo de Teste" - }, - "Testing links": { - "message": "Nova PĆ”gina" - }, - "Understanding CoMapeo's Core Concepts and Functions": { - "message": "Nova PĆ”gina" + "message": "Resolução de Problemas: Movendo ObservaƧƵes e Percursos Fora do CoMapeo" }, "Installing CoMapeo and Onboarding": { - "message": "Nova PĆ”gina" + "message": "Instalação do CoMapeo e Integração" }, "Planning and Preparing for a Project": { - "message": "Nova PĆ”gina" + "message": "Planejamento e Preparação para um Projeto" }, "Observations and Tracks": { - "message": "Novo tĆ­tulo da seção" + "message": "ObservaƧƵes e Percursos" }, "Gathering Observations and Tracks": { "message": "Coletando ObservaƧƵes" }, "Data Privacy and Security": { - "message": "Novo tĆ­tulo da seção" + "message": "Privacidade de Dados e SeguranƧa" }, "Managing Data Privacy and Security": { "message": "Gerenciamento de dados e privacidade" @@ -197,5 +188,388 @@ }, "CLI Reference": { "message": "ReferĆŖncia de CLI" + }, + "Conversational Guidance": { + "message": "Orientação Conversacional", + "description": "Feature title for voice-enabled QA bots section" + }, + "Get instant voice assistance through our documentation bots that listen to your questions and respond with precise answers in real-time.": { + "message": "Obtenha assistĆŖncia de voz instantĆ¢nea atravĆ©s dos nossos bots de documentação que ouvem suas perguntas e respondem com respostas precisas em tempo real.", + "description": "Description for voice-enabled QA bots section" + }, + "Map in Any Language": { + "message": "Mapeie em Qualquer Idioma", + "description": "Feature title for multi-lingual documentation section" + }, + "Access CoMapeo documentation in multiple languages, ensuring every team member can learn and contribute regardless of their native tongue.": { + "message": "Acesse a documentação do CoMapeo em vĆ”rios idiomas, garantindo que cada membro da equipe possa aprender e contribuir independentemente de sua lĆ­ngua nativa.", + "description": "Description for multi-lingual documentation section" + }, + "Your Mapping Journey Starts Here": { + "message": "Sua Jornada de Mapeamento ComeƧa Aqui", + "description": "Feature title for comprehensive learning hub section" + }, + "Everything you need to master the CoMapeo platform, from beginner tutorials to advanced techniques, all in one centralized knowledge center.": { + "message": "Tudo o que vocĆŖ precisa para dominar a plataforma CoMapeo, desde tutoriais para iniciantes atĆ© tĆ©cnicas avanƧadas, tudo em um centro de conhecimento centralizado.", + "description": "Description for comprehensive learning hub section" + }, + "theme.docs.DocCard.categoryDescription.plurals": { + "message": "1 item|{count} itens", + "description": "The default description for a category card in the generated index about how many items this category includes" + }, + "theme.navbar.mobileLanguageDropdown.label": { + "message": "LĆ­nguas", + "description": "The label for the mobile language switcher dropdown" + }, + "theme.ErrorPageContent.title": { + "message": "Esta pĆ”gina deu erro.", + "description": "The title of the fallback page when the page crashed" + }, + "theme.BackToTopButton.buttonAriaLabel": { + "message": "Voltar para o topo", + "description": "The ARIA label for the back to top button" + }, + "theme.blog.archive.title": { + "message": "Arquivo", + "description": "The page & hero title of the blog archive page" + }, + "theme.blog.archive.description": { + "message": "Arquivo", + "description": "The page & hero description of the blog archive page" + }, + "theme.blog.paginator.navAriaLabel": { + "message": "Navegação da pĆ”gina de listagem do blog", + "description": "The ARIA label for the blog pagination" + }, + "theme.blog.paginator.newerEntries": { + "message": "PublicaƧƵes mais recentes", + "description": "The label used to navigate to the newer blog posts page (previous page)" + }, + "theme.blog.paginator.olderEntries": { + "message": "PublicaƧƵes mais antigas", + "description": "The label used to navigate to the older blog posts page (next page)" + }, + "theme.blog.post.paginator.navAriaLabel": { + "message": "Navegação da pĆ”gina de publicação do blog", + "description": "The ARIA label for the blog posts pagination" + }, + "theme.blog.post.paginator.newerPost": { + "message": "Publicação mais recente", + "description": "The blog post button label to navigate to the newer/previous post" + }, + "theme.blog.post.paginator.olderPost": { + "message": "Publicação mais antiga", + "description": "The blog post button label to navigate to the older/next post" + }, + "theme.tags.tagsPageLink": { + "message": "Ver todas as etiquetas", + "description": "The label of the link targeting the tag list page" + }, + "theme.colorToggle.ariaLabel.mode.system": { + "message": "modo do sistema", + "description": "The name for the system color mode" + }, + "theme.colorToggle.ariaLabel.mode.light": { + "message": "modo claro", + "description": "The name for the light color mode" + }, + "theme.colorToggle.ariaLabel.mode.dark": { + "message": "modo escuro", + "description": "The name for the dark color mode" + }, + "theme.colorToggle.ariaLabel": { + "message": "Mudar entre modo claro e escuro ({mode} estĆ” ativo)", + "description": "The ARIA label for the color mode toggle" + }, + "theme.docs.breadcrumbs.navAriaLabel": { + "message": "Trilha", + "description": "The ARIA label for the breadcrumbs" + }, + "theme.docs.paginator.navAriaLabel": { + "message": "PĆ”ginas de documento", + "description": "The ARIA label for the docs pagination" + }, + "theme.docs.paginator.previous": { + "message": "Anterior", + "description": "The label used to navigate to the previous doc" + }, + "theme.docs.paginator.next": { + "message": "Próxima", + "description": "The label used to navigate to the next doc" + }, + "theme.docs.tagDocListPageTitle.nDocsTagged": { + "message": "Um documento marcado|{count} documentos marcados", + "description": "Pluralized label for \"{count} docs tagged\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" + }, + "theme.docs.tagDocListPageTitle": { + "message": "{nDocsTagged} com \"{tagName}\"", + "description": "The title of the page for a docs tag" + }, + "theme.docs.versionBadge.label": { + "message": "VersĆ£o: {versionLabel}" + }, + "theme.docs.versions.unreleasedVersionLabel": { + "message": "Esta Ć© uma documentação nĆ£o lanƧada da versĆ£o {versionLabel} para {siteTitle}.", + "description": "The label used to tell the user that he's browsing an unreleased doc version" + }, + "theme.docs.versions.unmaintainedVersionLabel": { + "message": "Esta Ć© a documentação para {siteTitle} {versionLabel}, que nĆ£o Ć© mais mantida ativamente.", + "description": "The label used to tell the user that he's browsing an unmaintained doc version" + }, + "theme.docs.versions.latestVersionSuggestionLabel": { + "message": "Para a documentação atualizada, consulte a {latestVersionLink} ({versionLabel}).", + "description": "The label used to tell the user to check the latest version" + }, + "theme.docs.versions.latestVersionLinkLabel": { + "message": "versĆ£o mais recente", + "description": "The label used for the latest version suggestion link label" + }, + "theme.common.editThisPage": { + "message": "Editar esta pĆ”gina", + "description": "The link label to edit the current page" + }, + "theme.common.headingLinkTitle": { + "message": "Link direto para {heading}", + "description": "Title for link to heading" + }, + "theme.lastUpdated.atDate": { + "message": " em {date}", + "description": "The words used to describe on which date a page has been last updated" + }, + "theme.lastUpdated.byUser": { + "message": " por {user}", + "description": "The words used to describe by who the page has been last updated" + }, + "theme.lastUpdated.lastUpdatedAtBy": { + "message": "Última atualização{atDate}{byUser}", + "description": "The sentence used to display when a page has been last updated, and by who" + }, + "theme.navbar.mobileVersionsDropdown.label": { + "message": "VersƵes", + "description": "The label for the navbar versions dropdown on mobile view" + }, + "theme.NotFound.title": { + "message": "PĆ”gina nĆ£o encontrada", + "description": "The title of the 404 page" + }, + "theme.tags.tagsListLabel": { + "message": "Etiquetas:", + "description": "The label alongside a tag list" + }, + "theme.admonition.caution": { + "message": "cuidado", + "description": "The default label used for the Caution admonition (:::caution)" + }, + "theme.admonition.danger": { + "message": "perigo", + "description": "The default label used for the Danger admonition (:::danger)" + }, + "theme.admonition.info": { + "message": "informação", + "description": "The default label used for the Info admonition (:::info)" + }, + "theme.admonition.note": { + "message": "observação", + "description": "The default label used for the Note admonition (:::note)" + }, + "theme.admonition.tip": { + "message": "dica", + "description": "The default label used for the Tip admonition (:::tip)" + }, + "theme.admonition.warning": { + "message": "aviso", + "description": "The default label used for the Warning admonition (:::warning)" + }, + "theme.AnnouncementBar.closeButtonAriaLabel": { + "message": "Fechar", + "description": "The ARIA label for close button of announcement bar" + }, + "theme.blog.sidebar.navAriaLabel": { + "message": "Navegação das publicaƧƵes recentes do blog", + "description": "The ARIA label for recent posts in the blog sidebar" + }, + "theme.DocSidebarItem.expandCategoryAriaLabel": { + "message": "Expandir a categoria '{label}'", + "description": "The ARIA label to expand the sidebar category" + }, + "theme.DocSidebarItem.collapseCategoryAriaLabel": { + "message": "Recolher a categoria '{label}'", + "description": "The ARIA label to collapse the sidebar category" + }, + "theme.IconExternalLink.ariaLabel": { + "message": "(abre em nova aba)", + "description": "The ARIA label for the external link icon" + }, + "theme.NavBar.navAriaLabel": { + "message": "Navegação principal", + "description": "The ARIA label for the main navigation" + }, + "theme.NotFound.p1": { + "message": "NĆ£o foi possĆ­vel encontrar o que vocĆŖ estĆ” procurando.", + "description": "The first paragraph of the 404 page" + }, + "theme.NotFound.p2": { + "message": "Por favor, entre em contato com o dono do site que ligou vocĆŖ Ć  URL original e informe que o link estĆ” quebrado.", + "description": "The 2nd paragraph of the 404 page" + }, + "theme.TOCCollapsible.toggleButtonLabel": { + "message": "Nesta pĆ”gina", + "description": "The label used by the button on the collapsible TOC component" + }, + "theme.blog.post.readingTime.plurals": { + "message": "Um minuto para ler|{readingTime} min para ler", + "description": "Pluralized label for \"{readingTime} min read\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" + }, + "theme.blog.post.readMore": { + "message": "Ler mais", + "description": "The label used in blog post item excerpts to link to full blog posts" + }, + "theme.blog.post.readMoreLabel": { + "message": "Ler mais sobre {title}", + "description": "The ARIA label for the link to full blog posts from excerpts" + }, + "theme.CodeBlock.copy": { + "message": "Copiar", + "description": "The copy button label on code blocks" + }, + "theme.CodeBlock.copied": { + "message": "Copiado", + "description": "The copied button label on code blocks" + }, + "theme.CodeBlock.copyButtonAriaLabel": { + "message": "Copiar código para a Ć”rea de transferĆŖncia", + "description": "The ARIA label for copy code blocks button" + }, + "theme.CodeBlock.wordWrapToggle": { + "message": "Alternar quebra de linha", + "description": "The title attribute for toggle word wrapping button of code block lines" + }, + "theme.docs.sidebar.collapseButtonTitle": { + "message": "Recolher painel lateral", + "description": "The title attribute for collapse button of doc sidebar" + }, + "theme.docs.sidebar.collapseButtonAriaLabel": { + "message": "Recolher painel lateral", + "description": "The title attribute for collapse button of doc sidebar" + }, + "theme.docs.breadcrumbs.home": { + "message": "PĆ”gina Inicial", + "description": "The ARIA label for the home page in the breadcrumbs" + }, + "theme.docs.sidebar.navAriaLabel": { + "message": "Painel lateral de documentos", + "description": "The ARIA label for the sidebar navigation" + }, + "theme.docs.sidebar.closeSidebarButtonAriaLabel": { + "message": "Fechar painel de navegação", + "description": "The ARIA label for close button of mobile sidebar" + }, + "theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": { + "message": "← Voltar para o menu principal", + "description": "The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)" + }, + "theme.docs.sidebar.toggleSidebarButtonAriaLabel": { + "message": "Alternar painel de navegação", + "description": "The ARIA label for hamburger menu button of mobile navigation" + }, + "theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": { + "message": "Expandir a seleção", + "description": "The ARIA label of the button to expand the mobile dropdown navbar item" + }, + "theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": { + "message": "Recolher a seleção", + "description": "The ARIA label of the button to collapse the mobile dropdown navbar item" + }, + "theme.docs.sidebar.expandButtonTitle": { + "message": "Expandir painel lateral", + "description": "The ARIA label and title attribute for expand button of doc sidebar" + }, + "theme.docs.sidebar.expandButtonAriaLabel": { + "message": "Expandir painel lateral", + "description": "The ARIA label and title attribute for expand button of doc sidebar" + }, + "theme.IdealImageMessage.loading": { + "message": "Carregando...", + "description": "When the full-scale image is loading" + }, + "theme.IdealImageMessage.load": { + "message": "Clique para carregar{sizeMessage}", + "description": "To prompt users to load the full image. sizeMessage is a parenthesized size figure." + }, + "theme.IdealImageMessage.offline": { + "message": "Seu browser estĆ” offline. Imagem nĆ£o carregada", + "description": "When the user is viewing an offline document" + }, + "theme.IdealImageMessage.404error": { + "message": "404. Imagem nĆ£o encontrada", + "description": "When the image is not found" + }, + "theme.IdealImageMessage.error": { + "message": "Erro. Clique para recarregar", + "description": "When the image fails to load for unknown error" + }, + "theme.blog.post.plurals": { + "message": "Uma publicação|{count} publicaƧƵes", + "description": "Pluralized label for \"{count} posts\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" + }, + "theme.blog.tagTitle": { + "message": "{nPosts} com a etiqueta \"{tagName}\"", + "description": "The title of the page for a blog tag" + }, + "theme.blog.author.pageTitle": { + "message": "{authorName} - {nPosts}", + "description": "The title of the page for a blog author" + }, + "theme.blog.authorsList.pageTitle": { + "message": "Autores", + "description": "The title of the authors page" + }, + "theme.blog.authorsList.viewAll": { + "message": "Ver todos os autores", + "description": "The label of the link targeting the blog authors page" + }, + "theme.blog.author.noPosts": { + "message": "Este autor ainda nĆ£o escreveu nenhuma publicação.", + "description": "The text for authors with 0 blog post" + }, + "theme.contentVisibility.unlistedBanner.title": { + "message": "PĆ”gina nĆ£o listada", + "description": "The unlisted content banner title" + }, + "theme.contentVisibility.unlistedBanner.message": { + "message": "Esta pĆ”gina nĆ£o estĆ” listada. Mecanismos de busca nĆ£o irĆ£o indexĆ”-la, e somente usuĆ”rios que possuam o link direto poderĆ£o acessĆ”-la", + "description": "The unlisted content banner message" + }, + "theme.contentVisibility.draftBanner.title": { + "message": "PĆ”gina de rascunho", + "description": "The draft content banner title" + }, + "theme.contentVisibility.draftBanner.message": { + "message": "Esta pĆ”gina Ć© um rascunho. Ela estarĆ” visĆ­vel apenas no desenvolvimento e serĆ” excluĆ­da da compilação de produção.", + "description": "The draft content banner message" + }, + "theme.ErrorPageContent.tryAgain": { + "message": "Tentar novamente", + "description": "The label of the button to try again rendering when the React error boundary captures an error" + }, + "theme.common.skipToMainContent": { + "message": "Pular para o conteĆŗdo principal", + "description": "The skip to content label used for accessibility, allowing to rapidly navigate to main content with keyboard tab/enter navigation" + }, + "theme.tags.tagsPageTitle": { + "message": "Etiquetas", + "description": "The title of the tag list page" + }, + "theme.PwaReloadPopup.closeButtonAriaLabel": { + "message": "Fechar", + "description": "The ARIA label for close button of PWA reload popup" + }, + "theme.PwaReloadPopup.info": { + "message": "Nova versĆ£o disponĆ­vel", + "description": "The text used in PWA reload popup" + }, + "theme.PwaReloadPopup.refreshButtonText": { + "message": "Atualizar", + "description": "The text used for PWA reload button" } } diff --git a/i18n/pt/docusaurus-theme-classic/footer.json b/i18n/pt/docusaurus-theme-classic/footer.json new file mode 100644 index 00000000..048e5a18 --- /dev/null +++ b/i18n/pt/docusaurus-theme-classic/footer.json @@ -0,0 +1,106 @@ +{ + "links.title.Awana Digital": { + "message": "Awana Digital", + "description": "Footer section title: Awana Digital" + }, + "links.Awana Digital.Website": { + "message": "Site", + "description": "Footer link label: Website" + }, + "links.Awana Digital.Discord": { + "message": "Discord", + "description": "Footer link label: Discord" + }, + "links.Awana Digital.Bluesky": { + "message": "Bluesky", + "description": "Footer link label: Bluesky" + }, + "links.Awana Digital.Blog": { + "message": "Blog", + "description": "Footer link label: Blog" + }, + "links.title.CoMapeo": { + "message": "CoMapeo", + "description": "Footer section title: CoMapeo" + }, + "links.CoMapeo.Website": { + "message": "Site", + "description": "Footer link label: Website" + }, + "links.CoMapeo.CoMapeo Mobile GitHub": { + "message": "GitHub do CoMapeo Mobile", + "description": "Footer link label: CoMapeo Mobile GitHub" + }, + "links.CoMapeo.CoMapeo Desktop GitHub": { + "message": "GitHub do CoMapeo Desktop", + "description": "Footer link label: CoMapeo Desktop GitHub" + }, + "links.title.More": { + "message": "Mais", + "description": "Footer section title: More" + }, + "links.More.PlayStore": { + "message": "PlayStore", + "description": "Footer link label: PlayStore" + }, + "links.More.GitHub": { + "message": "GitHub", + "description": "Footer link label: GitHub" + }, + "links.More.Earth Defenders Toolkit": { + "message": "Earth Defenders Toolkit", + "description": "Footer link label: Earth Defenders Toolkit" + }, + "copyright": { + "message": "Feito com ā¤ļø por Awana Digital - 2026", + "description": "The footer copyright" + }, + "link.title.Awana Digital": { + "message": "Awana Digital", + "description": "The title of the footer links column with title=Awana Digital in the footer" + }, + "link.title.CoMapeo": { + "message": "CoMapeo", + "description": "The title of the footer links column with title=CoMapeo in the footer" + }, + "link.title.More": { + "message": "Mais", + "description": "The title of the footer links column with title=More in the footer" + }, + "link.item.label.Website": { + "message": "Site", + "description": "The label of footer link with label=Website linking to https://comapeo.app" + }, + "link.item.label.Discord": { + "message": "Discord", + "description": "The label of footer link with label=Discord linking to https://discord.gg/NtZgtAjj" + }, + "link.item.label.Bluesky": { + "message": "Bluesky", + "description": "The label of footer link with label=Bluesky linking to https://bsky.app/profile/awana.digital" + }, + "link.item.label.Blog": { + "message": "Blog", + "description": "The label of footer link with label=Blog linking to https://awana.digital/blog" + }, + "link.item.label.CoMapeo Mobile GitHub": { + "message": "GitHub do CoMapeo Mobile", + "description": "The label of footer link with label=CoMapeo Mobile GitHub linking to https://github.com/digidem/comapeo-docs" + }, + "link.item.label.CoMapeo Desktop GitHub": { + "message": "GitHub do CoMapeo Desktop", + "description": "The label of footer link with label=CoMapeo Desktop GitHub linking to https://github.com/digidem/comapeo-docs" + }, + "link.item.label.PlayStore": { + "message": "PlayStore", + "description": "The label of footer link with label=PlayStore linking to https://play.google.com/store/apps/details?id=com.comapeo" + }, + "link.item.label.GitHub": { + "message": "GitHub", + "description": "The label of footer link with label=GitHub linking to https://github.com/digidem/comapeo-docs" + }, + "link.item.label.Earth Defenders Toolkit": { + "message": "Earth Defenders Toolkit", + "description": "The label of footer link with label=Earth Defenders Toolkit linking to https://www.earthdefenderstoolkit.com/" + } +} diff --git a/i18n/pt/docusaurus-theme-classic/navbar.json b/i18n/pt/docusaurus-theme-classic/navbar.json new file mode 100644 index 00000000..51390c24 --- /dev/null +++ b/i18n/pt/docusaurus-theme-classic/navbar.json @@ -0,0 +1,14 @@ +{ + "item.label.Documentation": { + "message": "Documentação", + "description": "Navbar item with label Documentation" + }, + "item.label.GitHub": { + "message": "GitHub", + "description": "Navbar item with label GitHub" + }, + "logo.alt": { + "message": "CoMapeo", + "description": "The alt text of navbar logo" + } +} diff --git a/scripts/locale-parity.test.ts b/scripts/locale-parity.test.ts index 8f4efa95..97352442 100644 --- a/scripts/locale-parity.test.ts +++ b/scripts/locale-parity.test.ts @@ -1160,4 +1160,101 @@ TĆ­tulo }); }); }); + + describe("Real project structural parity", () => { + it("has no empty translations in existing localized files", async () => { + const projectRoot = process.cwd(); + let issues: ParityIssue[]; + + try { + issues = await collectParityIssues(projectRoot); + } catch (error) { + if (isMissingDirectoryError(error)) { + return; + } + throw error; + } + + const emptyIssues = issues.filter( + (issue) => issue.type === "empty-translation" + ); + + if (emptyIssues.length > 0) { + const lines = emptyIssues.map( + (i) => ` ${i.key} (${i.locale}): ${i.type}` + ); + console.warn(`Real project empty translations:\n${lines.join("\n")}`); + } + + expect( + emptyIssues.length, + `Found ${emptyIssues.length} empty translations in real locale files` + ).toBe(0); + }); + + it("has no frontmatter mismatches when validation is enabled", async () => { + if (!shouldValidateFrontmatter()) { + return; + } + + const projectRoot = process.cwd(); + const issues = await collectParityIssues(projectRoot); + + const frontmatterIssues = issues.filter( + (issue) => issue.type === "frontmatter-mismatch" + ); + + expect( + frontmatterIssues, + `Found ${frontmatterIssues.length} frontmatter mismatches` + ).toEqual([]); + }); + + it("has non-empty labels in real locale _category_.json files", async () => { + const locales = ["pt", "es"] as const; + for (const locale of locales) { + const localeRoot = getLocaleRoot(process.cwd(), locale); + let entries; + try { + entries = await fs.readdir(localeRoot, { withFileTypes: true }); + } catch { + continue; + } + + const findCategoryFiles = async (dir: string): Promise => { + const results: string[] = []; + let dirEntries; + try { + dirEntries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return results; + } + for (const entry of dirEntries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...(await findCategoryFiles(fullPath))); + } else if (entry.name === "_category_.json") { + results.push(fullPath); + } + } + return results; + }; + + const categoryFiles = await findCategoryFiles(localeRoot); + + for (const filePath of categoryFiles) { + const content = await fs.readFile(filePath, "utf8"); + const category = JSON.parse(content); + expect( + typeof category.label, + `${locale} ${filePath}: label missing` + ).toBe("string"); + expect( + category.label.trim().length, + `${locale} ${filePath}: label is empty` + ).toBeGreaterThan(0); + } + } + }); + }); }); diff --git a/scripts/notion-translate/translateCodeJson.test.ts b/scripts/notion-translate/translateCodeJson.test.ts index 9be2f045..2d53e082 100644 --- a/scripts/notion-translate/translateCodeJson.test.ts +++ b/scripts/notion-translate/translateCodeJson.test.ts @@ -114,4 +114,88 @@ describe("notion-translate translateCodeJson", () => { expect(request?.response_format?.type).toBe("json_object"); expect(request?.max_tokens).toBe(DEFAULT_OPENAI_MAX_TOKENS); }); + + describe("extractTranslatableText", () => { + it("extracts navbar item labels and logo.alt", async () => { + const { extractTranslatableText } = await importTranslateCodeJson(); + + const result = extractTranslatableText( + { + logo: { alt: "CoMapeo" }, + items: [ + { label: "Documentation", type: "docSidebar" }, + { label: "GitHub", href: "https://github.com/example" }, + ], + }, + "navbar" + ); + + expect(result).toEqual({ + "item.label.Documentation": { + message: "Documentation", + description: "Navbar item with label Documentation", + }, + "item.label.GitHub": { + message: "GitHub", + description: "Navbar item with label GitHub", + }, + "logo.alt": { + message: "CoMapeo", + description: "The alt text of navbar logo", + }, + }); + }); + + it("omits logo.alt when navbar config has no logo", async () => { + const { extractTranslatableText } = await importTranslateCodeJson(); + + const result = extractTranslatableText( + { items: [{ label: "Documentation" }] }, + "navbar" + ); + + expect(result).not.toHaveProperty("logo.alt"); + expect(result["item.label.Documentation"]).toBeDefined(); + }); + + it("extracts footer section titles and item labels in both the legacy and Docusaurus runtime key formats", async () => { + const { extractTranslatableText } = await importTranslateCodeJson(); + + const result = extractTranslatableText( + { + links: [ + { + title: "CoMapeo", + items: [{ label: "Website", href: "https://comapeo.app" }], + }, + ], + copyright: "Made with love", + }, + "footer" + ); + + expect(result["links.title.CoMapeo"]).toEqual({ + message: "CoMapeo", + description: "Footer section title: CoMapeo", + }); + expect(result["link.title.CoMapeo"]).toEqual({ + message: "CoMapeo", + description: + "The title of the footer links column with title=CoMapeo in the footer", + }); + expect(result["links.CoMapeo.Website"]).toEqual({ + message: "Website", + description: "Footer link label: Website", + }); + expect(result["link.item.label.Website"]).toEqual({ + message: "Website", + description: + "The label of footer link with label=Website linking to https://comapeo.app", + }); + expect(result.copyright).toEqual({ + message: "Made with love", + description: "Footer copyright text", + }); + }); + }); }); diff --git a/scripts/notion-translate/translateCodeJson.ts b/scripts/notion-translate/translateCodeJson.ts index 37711035..5976b03b 100644 --- a/scripts/notion-translate/translateCodeJson.ts +++ b/scripts/notion-translate/translateCodeJson.ts @@ -153,8 +153,13 @@ interface FooterSection { items?: FooterLink[]; } +interface NavbarLogo { + alt?: string; +} + interface NavbarConfig { items?: NavbarItem[]; + logo?: NavbarLogo; } interface FooterConfig { @@ -174,7 +179,7 @@ export function extractTranslatableText( nav.items.forEach((item: NavbarItem) => { if (item.label) { const key = `item.label.${item.label}`; - // eslint-disable-next-line security/detect-object-injection -- translation keys are generated from controlled config labels + result[key] = { message: item.label, description: `Navbar item with label ${item.label}`, @@ -182,6 +187,15 @@ export function extractTranslatableText( } }); } + + // Docusaurus's own navbar i18n key (see @docusaurus/theme-classic + // translations.js): required or the logo alt text is dropped at runtime. + if (nav.logo?.alt) { + result["logo.alt"] = { + message: nav.logo.alt, + description: "The alt text of navbar logo", + }; + } } if (type === "footer") { @@ -190,22 +204,40 @@ export function extractTranslatableText( footer.links.forEach((section: FooterSection) => { if (section.title) { const titleKey = `links.title.${section.title}`; - // eslint-disable-next-line security/detect-object-injection -- translation keys are generated from controlled config labels + result[titleKey] = { message: section.title, description: `Footer section title: ${section.title}`, }; + + // Docusaurus's own footer i18n key (see @docusaurus/theme-classic + // translations.js): required or translations are dropped at runtime. + const docusaurusTitleKey = `link.title.${section.title}`; + + result[docusaurusTitleKey] = { + message: section.title, + description: `The title of the footer links column with title=${section.title} in the footer`, + }; } if (section.items) { section.items.forEach((item: FooterLink) => { if (item.label) { const labelKey = `links.${section.title}.${item.label}`; - // eslint-disable-next-line security/detect-object-injection -- translation keys are generated from controlled config labels + result[labelKey] = { message: item.label, description: `Footer link label: ${item.label}`, }; + + // Docusaurus's own footer i18n key (see @docusaurus/theme-classic + // translations.js): required or translations are dropped at runtime. + const docusaurusLabelKey = `link.item.label.${item.label}`; + + result[docusaurusLabelKey] = { + message: item.label, + description: `The label of footer link with label=${item.label} linking to ${item.href ?? ""}`, + }; } }); } @@ -312,7 +344,6 @@ export function getLanguageName(langCode: string): string { en: "English", }; - // eslint-disable-next-line security/detect-object-injection -- dictionary lookup by locale code is expected behavior return languageMap[langCode] || langCode; } diff --git a/scripts/verify-locale-output.test.ts b/scripts/verify-locale-output.test.ts index 034ac73f..2f641c47 100644 --- a/scripts/verify-locale-output.test.ts +++ b/scripts/verify-locale-output.test.ts @@ -9,134 +9,342 @@ interface TranslationEntry { type TranslationCodeJson = Record; +const PLACEHOLDER_PATTERNS_ES = [ + /^Nueva P[aĆ”]gina( A)?$/u, + /^Nuevo t[iĆ­]tulo de secci[oó]n$/u, + /^Nueva Palanca$/u, +]; + +const PLACEHOLDER_PATTERNS_PT = [ + /^Nova P[aĆ”]gina( A)?$/u, + /^Novo t[iĆ­]tulo da se[cƧ][aĆ£]o$/u, + /^Novo Alternar$/u, +]; + const parseTranslationCodeJson = (content: string): TranslationCodeJson => JSON.parse(content) as TranslationCodeJson; -/** - * Verification tests for locale output correctness - * - * These tests verify that: - * 1. Locale files contain translated content (not English) - * 2. No unintended English writes occurred in non-English locales - * 3. Locale files have the expected structure - * 4. Translation keys match between source and target locales - */ +const readJsonFile = async (filePath: string): Promise => { + const content = await fs.readFile(filePath, "utf8"); + return JSON.parse(content); +}; + +const readTranslationCodeJson = async ( + filePath: string +): Promise => { + const content = await fs.readFile(filePath, "utf8"); + return parseTranslationCodeJson(content); +}; + +const assertFileExists = async (filePath: string): Promise => { + try { + await fs.access(filePath); + } catch { + throw new Error( + `Required file not found: ${path.relative(process.cwd(), filePath)}` + ); + } +}; + +const assertTranslationFileHasExpectedKeys = ( + translations: TranslationCodeJson, + expectedKeys: string[], + fileLabel: string +): void => { + for (const key of expectedKeys) { + if (!(key in translations)) { + throw new Error(`${fileLabel}: missing required key "${key}"`); + } + // eslint-disable-next-line security/detect-object-injection -- key comes from the hardcoded expectedKeys param, never external input + const entry = translations[key]; + if ( + !entry || + typeof entry.message !== "string" || + entry.message.trim().length === 0 + ) { + throw new Error( + `${fileLabel}: key "${key}" has empty or missing message` + ); + } + } +}; + +const assertNoPlaceholderMessages = ( + translations: TranslationCodeJson, + patterns: RegExp[], + fileLabel: string +): void => { + const violations: string[] = []; + for (const [key, entry] of Object.entries(translations)) { + if (!entry.message) continue; + for (const pattern of patterns) { + if (pattern.test(entry.message.trim())) { + violations.push(` "${key}": "${entry.message}"`); + break; + } + } + } + if (violations.length > 0) { + throw new Error( + `${fileLabel}: found ${violations.length} placeholder message(s):\n${violations.join("\n")}` + ); + } +}; + +const assertNoUntranslatedMessages = ( + translations: TranslationCodeJson, + fileLabel: string +): void => { + const violations: string[] = []; + for (const [key, entry] of Object.entries(translations)) { + if (!entry.message) continue; + if (entry.message === key) { + violations.push(` "${key}": message identical to key (untranslated)`); + } + } + if (violations.length > 0) { + throw new Error( + `${fileLabel}: found ${violations.length} untranslated message(s):\n${violations.join("\n")}` + ); + } +}; + +const assertNoEmptyMessages = ( + translations: TranslationCodeJson, + fileLabel: string +): void => { + const violations: string[] = []; + for (const [key, entry] of Object.entries(translations)) { + if ( + typeof entry.message !== "string" || + entry.message.trim().length === 0 + ) { + violations.push(` "${key}"`); + } + } + if (violations.length > 0) { + throw new Error( + `${fileLabel}: found ${violations.length} empty or missing message(s):\n${violations.join("\n")}` + ); + } +}; + +// Docusaurus theme default English strings are common leak points: a key +// whose identifier differs from its message (e.g. "theme.TOC.title") slips +// past assertNoUntranslatedMessages if the message is left as the English +// default. Rather than a hand-curated sample, load every base English +// string Docusaurus itself ships for the plugins this site actually uses +// (see docusaurus.config.ts) and compare the full set. +const DOCUSAURUS_BASE_TRANSLATION_FILES = [ + "node_modules/@docusaurus/theme-translations/locales/base/theme-common.json", + "node_modules/@docusaurus/theme-translations/locales/base/plugin-pwa.json", + "node_modules/@docusaurus/theme-translations/locales/base/plugin-ideal-image.json", +]; + +const loadDocusaurusBaseEnglishDefaults = async (): Promise< + Record +> => { + const merged: Record = {}; + for (const relPath of DOCUSAURUS_BASE_TRANSLATION_FILES) { + const filePath = path.join(process.cwd(), relPath); + // Every file in DOCUSAURUS_BASE_TRANSLATION_FILES corresponds to a + // plugin this site has confirmed active in docusaurus.config.ts, so a + // read/parse failure here means something is actually broken (missing + // dependency, corrupted install) — fail loudly rather than silently + // treating the catalog as empty, which would make this check fail-open. + const content = await fs.readFile(filePath, "utf8"); + const data = JSON.parse(content) as Record; + for (const [key, value] of Object.entries(data)) { + if (key.endsWith("___DESCRIPTION")) continue; + // eslint-disable-next-line security/detect-object-injection -- key comes from a Docusaurus-shipped JSON catalog, never external input + merged[key] = value; + } + } + return merged; +}; + +// Keys whose Docusaurus English default is allowed to remain unchanged in +// es/pt — e.g. brand names, or pure interpolation templates with no literal +// English words to translate. +const ENGLISH_DEFAULT_ALLOWLIST = new Set([ + // "{authorName} - {nPosts}" — just an interpolation pattern, not prose. + "theme.blog.author.pageTitle", +]); + +const assertHasAllDocusaurusBaseKeys = ( + translations: TranslationCodeJson, + baseDefaults: Record, + fileLabel: string +): void => { + const missing = Object.keys(baseDefaults).filter( + (key) => !(key in translations) + ); + if (missing.length > 0) { + throw new Error( + `${fileLabel}: missing ${missing.length} Docusaurus base translation key(s) — these silently fall back to English at runtime:\n${missing.map((k) => ` "${k}"`).join("\n")}` + ); + } +}; + +const assertNoUntranslatedDocusaurusDefaults = ( + translations: TranslationCodeJson, + baseDefaults: Record, + fileLabel: string +): void => { + const violations: string[] = []; + for (const [key, expectedEnglish] of Object.entries(baseDefaults)) { + if (ENGLISH_DEFAULT_ALLOWLIST.has(key)) continue; + // eslint-disable-next-line security/detect-object-injection -- key comes from the Docusaurus base translation catalog, never external input + const entry = translations[key]; + if (entry && entry.message.trim() === expectedEnglish.trim()) { + violations.push(` "${key}": still English ("${expectedEnglish}")`); + } + } + if (violations.length > 0) { + throw new Error( + `${fileLabel}: found ${violations.length} untranslated Docusaurus default string(s):\n${violations.join("\n")}` + ); + } +}; + +const NAVBAR_EXPECTED_KEYS = [ + "item.label.Documentation", + "item.label.GitHub", + "logo.alt", +]; + +const FOOTER_EXPECTED_KEYS = [ + "links.title.Awana Digital", + "links.Awana Digital.Website", + "links.Awana Digital.Discord", + "links.Awana Digital.Bluesky", + "links.Awana Digital.Blog", + "links.title.CoMapeo", + "links.CoMapeo.Website", + "links.CoMapeo.CoMapeo Mobile GitHub", + "links.CoMapeo.CoMapeo Desktop GitHub", + "links.title.More", + "links.More.PlayStore", + "links.More.GitHub", + "links.More.Earth Defenders Toolkit", + "copyright", + "link.title.More", + "link.item.label.Website", + "link.item.label.CoMapeo Mobile GitHub", + "link.item.label.CoMapeo Desktop GitHub", +]; + +const GENERIC_TRANSLATABLE_LABELS = [ + { + locale: "es", + key: "item.label.Documentation", + english: "Documentation", + hint: "Documentación", + }, + { + locale: "pt", + key: "item.label.Documentation", + english: "Documentation", + hint: "Documentação", + }, + { + locale: "es", + key: "links.Awana Digital.Website", + english: "Website", + hint: "Sitio web", + }, + { + locale: "pt", + key: "links.Awana Digital.Website", + english: "Website", + hint: "Site", + }, + { + locale: "es", + key: "links.CoMapeo.Website", + english: "Website", + hint: "Sitio web", + }, + { + locale: "pt", + key: "links.CoMapeo.Website", + english: "Website", + hint: "Site", + }, + { locale: "es", key: "links.title.More", english: "More", hint: "MĆ”s" }, + { locale: "pt", key: "links.title.More", english: "More", hint: "Mais" }, + { locale: "es", key: "link.title.More", english: "More", hint: "MĆ”s" }, + { locale: "pt", key: "link.title.More", english: "More", hint: "Mais" }, + { + locale: "es", + key: "link.item.label.Website", + english: "Website", + hint: "Sitio web", + }, + { + locale: "pt", + key: "link.item.label.Website", + english: "Website", + hint: "Site", + }, + { + locale: "es", + key: "link.item.label.CoMapeo Mobile GitHub", + english: "CoMapeo Mobile GitHub", + hint: "GitHub de CoMapeo Mobile", + }, + { + locale: "pt", + key: "link.item.label.CoMapeo Mobile GitHub", + english: "CoMapeo Mobile GitHub", + hint: "GitHub do CoMapeo Mobile", + }, + { + locale: "es", + key: "link.item.label.CoMapeo Desktop GitHub", + english: "CoMapeo Desktop GitHub", + hint: "GitHub de CoMapeo Desktop", + }, + { + locale: "pt", + key: "link.item.label.CoMapeo Desktop GitHub", + english: "CoMapeo Desktop GitHub", + hint: "GitHub do CoMapeo Desktop", + }, +]; + describe("Locale Output Verification", () => { const i18nDir = path.join(process.cwd(), "i18n"); describe("Spanish locale (es)", () => { - it("has code.json with Spanish translations", async () => { + it("has code.json with no placeholder or untranslated messages", async () => { const codeJsonPath = path.join(i18nDir, "es", "code.json"); + const codeJson = await readTranslationCodeJson(codeJsonPath); - // Read and parse the file - const content = await fs.readFile(codeJsonPath, "utf8"); - const codeJson = parseTranslationCodeJson(content); - - // Verify it has translations expect(Object.keys(codeJson).length).toBeGreaterThan(0); - - // Sample a few keys to verify they're in Spanish (not English) - const sampleKeys = Object.keys(codeJson).slice(0, 5); - - for (const key of sampleKeys) { - // eslint-disable-next-line security/detect-object-injection -- test code with controlled JSON data - const entry = codeJson[key]; - if (entry.message) { - // Check that it's not English by looking for common English words - const message = entry.message.toLowerCase(); - - // Skip "Nova PĆ”gina" and "Nuevo tĆ­tulo" which are placeholder translations - if ( - message.includes("nova pĆ”gina") || - message.includes("nuevo tĆ­tulo") - ) { - continue; - } - - // Verify it's not English by checking for Spanish indicators - const hasSpanishIndicators = - message.includes(" en ") || - message.includes(" de ") || - message.includes(" para ") || - message.includes(" el ") || - message.includes(" la ") || - message.includes("ón") || - message.includes("ción") || - message.includes(" esta ") || - message.includes("nueva") || - message.includes("pĆ”gina") || - message.includes("introducción"); - - // If it doesn't have Spanish indicators, it might be a proper noun or short text - // We'll just verify it's a valid string for now - expect(typeof entry.message).toBe("string"); - expect(entry.message.length).toBeGreaterThan(0); - } - } - }); - - it("does not contain unintended English content in code.json", async () => { - const codeJsonPath = path.join(i18nDir, "es", "code.json"); - const content = await fs.readFile(codeJsonPath, "utf8"); - const codeJson = parseTranslationCodeJson(content); - - // Check that common English words are not present in messages - // (except for proper nouns or technical terms) - const englishOnlyPatterns = [ - /\bthe\b/i, - /\bis\b/i, - /\band\b/i, - /\bfor\b/i, - /\bwith\b/i, - /\bgetting started\b/i, - /\bdevice setup\b/i, - ]; - - let hasUnintendedEnglish = false; - const unintendedEnglishEntries: string[] = []; - - for (const [key, entry] of Object.entries(codeJson)) { - if (entry.message) { - const message = entry.message.toLowerCase(); - - // Skip placeholder translations - if ( - message.includes("nova pĆ”gina") || - message.includes("nuevo tĆ­tulo") - ) { - continue; - } - - // Check for multiple English-only patterns (suggesting untranslated content) - const matchCount = englishOnlyPatterns.filter((pattern) => - pattern.test(message) - ).length; - - if (matchCount >= 3) { - // If 3+ English patterns match, likely untranslated - hasUnintendedEnglish = true; - unintendedEnglishEntries.push(`${key}: ${entry.message}`); - } - } - } - - expect( - hasUnintendedEnglish, - `Found potential untranslated English content in es/code.json:\n${unintendedEnglishEntries.join("\n")}` - ).toBe(false); + assertNoPlaceholderMessages( + codeJson, + PLACEHOLDER_PATTERNS_ES, + "es/code.json" + ); + assertNoUntranslatedMessages(codeJson, "es/code.json"); + assertNoEmptyMessages(codeJson, "es/code.json"); + const baseDefaults = await loadDocusaurusBaseEnglishDefaults(); + assertHasAllDocusaurusBaseKeys(codeJson, baseDefaults, "es/code.json"); + assertNoUntranslatedDocusaurusDefaults( + codeJson, + baseDefaults, + "es/code.json" + ); }); it("has valid structure with message and optional description", async () => { const codeJsonPath = path.join(i18nDir, "es", "code.json"); - const content = await fs.readFile(codeJsonPath, "utf8"); - const codeJson = parseTranslationCodeJson(content); + const codeJson = await readTranslationCodeJson(codeJsonPath); for (const [key, entry] of Object.entries(codeJson)) { - // Every entry must have a message expect(entry).toHaveProperty("message"); expect(typeof entry.message).toBe("string"); - - // Description is optional but must be string if present if (entry.description) { expect(typeof entry.description).toBe("string"); } @@ -145,93 +353,34 @@ describe("Locale Output Verification", () => { }); describe("Portuguese locale (pt)", () => { - it("has code.json with Portuguese translations", async () => { + it("has code.json with no placeholder or untranslated messages", async () => { const codeJsonPath = path.join(i18nDir, "pt", "code.json"); - - const content = await fs.readFile(codeJsonPath, "utf8"); - const codeJson = parseTranslationCodeJson(content); + const codeJson = await readTranslationCodeJson(codeJsonPath); expect(Object.keys(codeJson).length).toBeGreaterThan(0); - - const sampleKeys = Object.keys(codeJson).slice(0, 5); - - for (const key of sampleKeys) { - // eslint-disable-next-line security/detect-object-injection -- test code with controlled JSON data - const entry = codeJson[key]; - if (entry.message) { - const message = entry.message.toLowerCase(); - - // Skip placeholder translations - if ( - message.includes("nova pĆ”gina") || - message.includes("novo tĆ­tulo") - ) { - continue; - } - - // Verify it's a valid string - expect(typeof entry.message).toBe("string"); - expect(entry.message.length).toBeGreaterThan(0); - } - } - }); - - it("does not contain unintended English content in code.json", async () => { - const codeJsonPath = path.join(i18nDir, "pt", "code.json"); - const content = await fs.readFile(codeJsonPath, "utf8"); - const codeJson = parseTranslationCodeJson(content); - - const englishOnlyPatterns = [ - /\bthe\b/i, - /\bis\b/i, - /\band\b/i, - /\bfor\b/i, - /\bwith\b/i, - /\bgetting started\b/i, - /\bdevice setup\b/i, - ]; - - let hasUnintendedEnglish = false; - const unintendedEnglishEntries: string[] = []; - - for (const [key, entry] of Object.entries(codeJson)) { - if (entry.message) { - const message = entry.message.toLowerCase(); - - // Skip placeholder translations - if ( - message.includes("nova pĆ”gina") || - message.includes("novo tĆ­tulo") - ) { - continue; - } - - const matchCount = englishOnlyPatterns.filter((pattern) => - pattern.test(message) - ).length; - - if (matchCount >= 3) { - hasUnintendedEnglish = true; - unintendedEnglishEntries.push(`${key}: ${entry.message}`); - } - } - } - - expect( - hasUnintendedEnglish, - `Found potential untranslated English content in pt/code.json:\n${unintendedEnglishEntries.join("\n")}` - ).toBe(false); + assertNoPlaceholderMessages( + codeJson, + PLACEHOLDER_PATTERNS_PT, + "pt/code.json" + ); + assertNoUntranslatedMessages(codeJson, "pt/code.json"); + assertNoEmptyMessages(codeJson, "pt/code.json"); + const baseDefaults = await loadDocusaurusBaseEnglishDefaults(); + assertHasAllDocusaurusBaseKeys(codeJson, baseDefaults, "pt/code.json"); + assertNoUntranslatedDocusaurusDefaults( + codeJson, + baseDefaults, + "pt/code.json" + ); }); it("has valid structure with message and optional description", async () => { const codeJsonPath = path.join(i18nDir, "pt", "code.json"); - const content = await fs.readFile(codeJsonPath, "utf8"); - const codeJson = parseTranslationCodeJson(content); + const codeJson = await readTranslationCodeJson(codeJsonPath); for (const [key, entry] of Object.entries(codeJson)) { expect(entry).toHaveProperty("message"); expect(typeof entry.message).toBe("string"); - if (entry.description) { expect(typeof entry.description).toBe("string"); } @@ -244,34 +393,18 @@ describe("Locale Output Verification", () => { const esCodeJsonPath = path.join(i18nDir, "es", "code.json"); const ptCodeJsonPath = path.join(i18nDir, "pt", "code.json"); - const esContent = await fs.readFile(esCodeJsonPath, "utf8"); - const ptContent = await fs.readFile(ptCodeJsonPath, "utf8"); - - const esCodeJson = parseTranslationCodeJson(esContent); - const ptCodeJson = parseTranslationCodeJson(ptContent); + const esCodeJson = await readTranslationCodeJson(esCodeJsonPath); + const ptCodeJson = await readTranslationCodeJson(ptCodeJsonPath); const esKeys = Object.keys(esCodeJson).sort(); const ptKeys = Object.keys(ptCodeJson).sort(); - // Should have the same number of keys expect(esKeys.length).toBe(ptKeys.length); - // Check for keys that differ (may indicate data quality issues) const diff = esKeys .filter((k) => !ptKeys.includes(k)) .concat(ptKeys.filter((k) => !esKeys.includes(k))); - if (diff.length > 0) { - console.warn( - "Warning: Translation keys differ between es and pt locales:", - diff - ); - console.warn( - "This may indicate a data quality issue - translation keys should be based on English source" - ); - } - - // Allow up to 10% difference in keys, with minimum of 3 to handle small datasets const maxAllowedDiff = Math.max(3, Math.ceil(esKeys.length * 0.1)); expect( diff.length, @@ -288,55 +421,41 @@ describe("Locale Output Verification", () => { "docusaurus-plugin-content-docs", "current" ); - let categoryFiles: string[]; - try { - // Recursively find all _category_.json files under the locale docs dir - const findCategoryFiles = async (dir: string): Promise => { - const results: string[] = []; - let entries; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } catch { - return results; - } - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...(await findCategoryFiles(fullPath))); - } else if (entry.name === "_category_.json") { - results.push(fullPath); - } - } + + const findCategoryFiles = async (dir: string): Promise => { + const results: string[] = []; + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { return results; - }; - categoryFiles = await findCategoryFiles(localeDocsDir); - } catch (error) { - if ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ) { - console.log( - `${locale} locale docs directory not found - content branch may not have toggle pages` - ); - continue; } - throw error; - } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...(await findCategoryFiles(fullPath))); + } else if (entry.name === "_category_.json") { + results.push(fullPath); + } + } + return results; + }; - if (categoryFiles.length === 0) { - console.log( - `No _category_.json files found for locale ${locale} - may not have toggle pages` - ); + let categoryFiles: string[]; + try { + categoryFiles = await findCategoryFiles(localeDocsDir); + } catch { continue; } + if (categoryFiles.length === 0) continue; + for (const filePath of categoryFiles) { const content = await fs.readFile(filePath, "utf8"); const category = JSON.parse(content); expect( category.label, - `_category_.json at ${filePath} has empty label` + `_category_.json at ${path.relative(process.cwd(), filePath)} has empty label` ).toBeTruthy(); expect(typeof category.label).toBe("string"); expect(category.label.trim().length).toBeGreaterThan(0); @@ -344,160 +463,88 @@ describe("Locale Output Verification", () => { } }); - it("does not have English locale directory (en/)", async () => { + it("does not have English locale directory (en/) with code.json", async () => { const enDir = path.join(i18nDir, "en"); - - // English source files should NOT be in i18n/en/ - // They should be in the root or handled separately try { await fs.access(enDir); - // If we get here, the directory exists - this might be a problem - // Check if it has code.json const enCodeJsonPath = path.join(enDir, "code.json"); try { await fs.access(enCodeJsonPath); - // English code.json exists in i18n/en/ - this could cause issues - // Log a warning but don't fail (it might be intentional for source) - console.warn( - "Warning: i18n/en/code.json exists. This should only contain source English strings." - ); + console.warn("Warning: i18n/en/code.json exists."); } catch { - // Directory exists but no code.json - that's fine + // no code.json - fine } } catch { - // Directory doesn't exist - that's expected + // directory doesn't exist - expected } }); }); describe("Theme translations", () => { - it("has navbar.json for Spanish", async () => { - const navbarPath = path.join( + const verifyThemeFile = async ( + locale: string, + fileName: string, + expectedKeys: string[] + ): Promise => { + const filePath = path.join( i18nDir, - "es", + locale, "docusaurus-theme-classic", - "navbar.json" + fileName ); - try { - const content = await fs.readFile(navbarPath, "utf8"); - const navbar = JSON.parse(content); - - expect(Object.keys(navbar).length).toBeGreaterThan(0); + await assertFileExists(filePath); - // Verify entries have messages - for (const [key, entry] of Object.entries(navbar)) { - expect(entry).toHaveProperty("message"); - } - } catch (error) { - // Only catch ENOENT (file not found) - let other errors propagate - if ( - error instanceof Error && - "code" in error && - error.code === "ENOENT" - ) { - console.log( - "Spanish navbar.json not found - may need to run translation" - ); - return; // Exit gracefully for missing file only - } - throw error; // Re-throw all other errors (including assertion failures) - } - }); - - it("has footer.json for Spanish", async () => { - const footerPath = path.join( - i18nDir, - "es", - "docusaurus-theme-classic", - "footer.json" + const data = (await readJsonFile(filePath)) as TranslationCodeJson; + assertTranslationFileHasExpectedKeys( + data, + expectedKeys, + `${locale}/docusaurus-theme-classic/${fileName}` ); + }; - try { - const content = await fs.readFile(footerPath, "utf8"); - const footer = JSON.parse(content); - - expect(Object.keys(footer).length).toBeGreaterThan(0); - - for (const [key, entry] of Object.entries(footer)) { - expect(entry).toHaveProperty("message"); - } - } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "ENOENT" - ) { - console.log( - "Spanish footer.json not found - may need to run translation" - ); - return; - } - throw error; - } + it("has navbar.json for Spanish with expected keys", async () => { + await verifyThemeFile("es", "navbar.json", NAVBAR_EXPECTED_KEYS); }); - it("has navbar.json for Portuguese", async () => { - const navbarPath = path.join( - i18nDir, - "pt", - "docusaurus-theme-classic", - "navbar.json" - ); - - try { - const content = await fs.readFile(navbarPath, "utf8"); - const navbar = JSON.parse(content); - - expect(Object.keys(navbar).length).toBeGreaterThan(0); - - for (const [key, entry] of Object.entries(navbar)) { - expect(entry).toHaveProperty("message"); - } - } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "ENOENT" - ) { - console.log( - "Portuguese navbar.json not found - may need to run translation" - ); - return; - } - throw error; - } + it("has footer.json for Spanish with expected keys", async () => { + await verifyThemeFile("es", "footer.json", FOOTER_EXPECTED_KEYS); }); - it("has footer.json for Portuguese", async () => { - const footerPath = path.join( - i18nDir, - "pt", - "docusaurus-theme-classic", - "footer.json" - ); - - try { - const content = await fs.readFile(footerPath, "utf8"); - const footer = JSON.parse(content); + it("has navbar.json for Portuguese with expected keys", async () => { + await verifyThemeFile("pt", "navbar.json", NAVBAR_EXPECTED_KEYS); + }); - expect(Object.keys(footer).length).toBeGreaterThan(0); + it("has footer.json for Portuguese with expected keys", async () => { + await verifyThemeFile("pt", "footer.json", FOOTER_EXPECTED_KEYS); + }); - for (const [key, entry] of Object.entries(footer)) { - expect(entry).toHaveProperty("message"); - } - } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "ENOENT" - ) { - console.log( - "Portuguese footer.json not found - may need to run translation" - ); - return; - } - throw error; + it("localizes generic translatable UI labels", async () => { + for (const { + locale, + key, + english, + hint, + } of GENERIC_TRANSLATABLE_LABELS) { + const filePath = path.join( + i18nDir, + locale, + "docusaurus-theme-classic", + key.startsWith("item.label.") ? "navbar.json" : "footer.json" + ); + const data = (await readJsonFile(filePath)) as TranslationCodeJson; + // eslint-disable-next-line security/detect-object-injection -- key comes from the hardcoded GENERIC_TRANSLATABLE_LABELS constant, never external input + const entry = data[key]; + expect( + entry, + `${locale} ${filePath.split("/").pop()}: missing key "${key}"` + ).toBeTruthy(); + expect(typeof entry.message).toBe("string"); + expect(entry.message.trim().length).toBeGreaterThan(0); + expect( + entry.message, + `${locale}: "${key}" appears untranslated (message="${entry.message}", expected something like "${hint}")` + ).not.toBe(english); } }); });