From 8aecfc8a6981e33f19d4d391445cc63bf63ed0a0 Mon Sep 17 00:00:00 2001 From: luandro Date: Wed, 15 Jul 2026 21:17:42 +0200 Subject: [PATCH 01/15] fix(i18n): translate theme chrome (navbar, footer, code.json) for ES/PT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docusaurus theme UI strings — navbar labels, footer links, and site.docsSidebar's `code.json` bucket (breadcrumbs, search, pagination, and other generic chrome) — were either untranslated (English defaults) or, for section/category labels sourced from Notion Title/Toggle rows, carried Docusaurus's own generic write-translations placeholders ("Nueva Página", "Nuevo título de sección", "Nueva Palanca") instead of real content. This is the theme-side half of the untranslated-header fix; the content-side half (canonical hierarchy engine, sidebar category generation) lives in comapeo-content-pipeline PR #6. Adds i18n/{es,pt}/docusaurus-theme-classic/{navbar,footer}.json with translated navbar item labels and footer section/link labels. Replaces placeholder/English messages in i18n/{es,pt}/code.json with real translations. Selectively un-ignores the new theme-classic files in .gitignore (previously the whole /i18n/ tree was generated/ignored) while leaving the rest of i18n/ — which is regenerated per-sync from the content pipeline — untouched. Test coverage: scripts/locale-parity.test.ts (new markdown structural- parity harness) and scripts/verify-locale-output.test.ts (refactored) assert no placeholder/empty translations, matching key counts across locales, and expected theme-translation keys. Two pre-existing security/detect-object-injection lint warnings in the latter (dynamic property access against a hardcoded, test-local key list, never external input) are resolved with justified inline disables so the repo's zero-warnings pre-commit policy passes cleanly. --- .gitignore | 16 +- i18n/es/code.json | 466 +++++++++++-- i18n/es/docusaurus-theme-classic/footer.json | 106 +++ i18n/es/docusaurus-theme-classic/navbar.json | 14 + i18n/pt/code.json | 471 +++++++++++-- i18n/pt/docusaurus-theme-classic/footer.json | 106 +++ i18n/pt/docusaurus-theme-classic/navbar.json | 14 + scripts/locale-parity.test.ts | 97 +++ scripts/verify-locale-output.test.ts | 671 +++++++++---------- 9 files changed, 1485 insertions(+), 476 deletions(-) create mode 100644 i18n/es/docusaurus-theme-classic/footer.json create mode 100644 i18n/es/docusaurus-theme-classic/navbar.json create mode 100644 i18n/pt/docusaurus-theme-classic/footer.json create mode 100644 i18n/pt/docusaurus-theme-classic/navbar.json 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..9da1c09d 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,376 @@ }, "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": "info", + "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": "tip", + "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 de 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": "Click para recargar{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. Click 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 indexaran, 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" } } 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..e9edd0a8 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,38 @@ "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 +189,376 @@ }, "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" } } 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/verify-locale-output.test.ts b/scripts/verify-locale-output.test.ts index 034ac73f..4fb181f2 100644 --- a/scripts/verify-locale-output.test.ts +++ b/scripts/verify-locale-output.test.ts @@ -9,134 +9,236 @@ 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 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"); }); 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 +247,26 @@ 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"); }); 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 +279,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 +307,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 +349,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); } }); }); From 193923dfb8556c7e7c3aa5a4c6df2dbc15420b21 Mon Sep 17 00:00:00 2001 From: luandro Date: Thu, 16 Jul 2026 19:44:16 +0200 Subject: [PATCH 02/15] fix(i18n): emit Docusaurus-correct footer translation keys extractTranslatableText only generated links.title.* / links.
.