Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

doc: Add i18n support #159

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,8 @@ genrst/
stderr-ldview
stdout-ldview
LPub3D/

# Translation files
######################
doc/locales/pot/
*.mo
82 changes: 82 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,47 @@ Then follows a list of arguments:
Then follows a description of the return value. The type is omitted here since
it is already include in the docstring signature.

**Translating Code Examples:**

The documentation system supports translating comments in code examples without duplicating the code itself. This is particularly useful for maintaining example code in multiple languages while keeping the actual code synchronized.

Directory Structure:
```
examples/
├── micropython/
│ └── example.py # Original example with English comments
└── translations/
└── de/ # Language code (e.g., 'de' for German)
└── micropython/
└── example.py.comments # Translations file
```

Translation Files:
- Create a `.comments` file in the corresponding language directory
- Use the format: `original comment = translated comment`
- Each translation should be on a new line
- Lines starting with `#` are ignored (can be used for translator notes)

Example translation file (`example.py.comments`):
```
# Translations for example.py
Initialize the motor. = Initialisiere den Motor.
Start moving at 500 deg/s. = Beginne die Bewegung mit 500 Grad/Sekunde.
```

In RST files, use the `translated-literalinclude` directive instead of `literalinclude` to include code examples that should have translated comments:

```rst
.. translated-literalinclude::
../../../examples/micropython/example.py
```

The translation system will:
1. Include the original code file
2. If building for a non-English language, look for corresponding `.comments` file
3. Replace comments with translations if they exist
4. Fall back to original comments if no translation exists

**Development environment:**

Prerequisites:
Expand Down Expand Up @@ -149,6 +190,47 @@ Build docs:
poetry run doc\make.bat html
Invoke-Item doc\main\build\html\index.html

Translations:

The documentation supports multiple languages through Sphinx's internationalization (i18n) feature.

Directory structure:
- `doc/locales/pot/` - Contains template (.pot) files generated by `make gettext`. These files contain all original strings that need to be translated.
- `doc/locales/<language>/LC_MESSAGES/` - Contains translation (.po) files for each language. These files must be directly in the LC_MESSAGES directory (not in subdirectories) and contain the actual translations that will be used to build the documentation.

Note about translations:
The translation system uses unique IDs (UUIDs) for each translatable string. This ensures that translations are preserved even if you move text to different files or lines. The source file locations are included as comments in the .po files to help track where translations are used.

Translation files:
- `.po` files are human-readable text files containing the translations. These should be committed to the repository.
- `.mo` files are compiled binary versions of .po files, generated automatically during build. These should not be committed.

To work with translations:

1. Generate translation templates:

poetry run make -C doc gettext

This creates .pot template files in `doc/locales/pot/`

2. Initialize or update a language (e.g., German 'de'):

poetry run make -C doc update-po-de

This creates or updates .po files in `doc/locales/de/LC_MESSAGES/`

3. Edit the .po files in `doc/locales/<language>/LC_MESSAGES/` to add translations

4. Build documentation for a specific language:

poetry run make -C doc html-de # For German
poetry run make -C doc html-fr # For French
poetry run make -C doc html-ja # For Japanese

When adding translations to the repository:
- Commit the .po files in `doc/locales/<language>/LC_MESSAGES/`
- Do not commit the .pot files in `doc/locales/pot/` (these are generated files)

Building IDE docs variant:

# Linux/macOS
Expand Down
13 changes: 13 additions & 0 deletions doc/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ SPHINXPROJ = Pybricks
SOURCEDIR = main
BUILDDIR = "$(SOURCEDIR)"/build
TAG = main
LOCALEDIR = locales

# Put it first so that "make" without argument is like "make help".
help:
Expand All @@ -19,7 +20,19 @@ diagrams:
@$(MAKE) -C "$(SOURCEDIR)"/diagrams_source clean
@$(MAKE) -C "$(SOURCEDIR)"/diagrams_source

# i18n targets
gettext:
@$(SPHINXBUILD) -b gettext "$(SOURCEDIR)" "$(LOCALEDIR)/pot" -D gettext_compact=0 $(SPHINXOPTS) -t $(TAG) $(O)

update-po-%:
@sphinx-intl update -p "$(LOCALEDIR)/pot" -l $*

html-%:
@$(SPHINXBUILD) -b html "$(SOURCEDIR)" "$(BUILDDIR)/$*" $(SPHINXOPTS) -t $(TAG) -D language=$* $(O)

# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) -t $(TAG) $(O)

.PHONY: gettext update-po-% html-%
163 changes: 163 additions & 0 deletions doc/extensions/translated_literalinclude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""
Sphinx extension for including code files with translated comments.
"""
import os
from pathlib import Path
from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.directives.code import LiteralInclude, container_wrapper
from sphinx.util import logging as sphinx_logging
from sphinx.util.nodes import set_source_info

logger = sphinx_logging.getLogger(__name__)

class TranslatedNode(nodes.literal_block):
"""Custom node that can be pickled."""
def astext(self):
return self.rawsource

class TranslatedLiteralInclude(LiteralInclude):
"""A LiteralInclude directive that supports translated comments."""

option_spec = LiteralInclude.option_spec.copy()
option_spec['language'] = directives.unchanged

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.translations = {}

def load_translations(self, source_file: Path, language: str) -> dict:
"""Load translations for the given file and language.

Args:
source_file: Path to the source file
language: Target language code

Returns:
Dictionary of original comments to translated comments
"""
try:
project_root = Path(__file__).parent.parent.parent
rel_path = Path(source_file).relative_to(project_root)

# Get path relative to examples directory
try:
examples_index = rel_path.parts.index('examples')
rel_to_examples = Path(*rel_path.parts[examples_index + 1:])
except ValueError:
logger.error(f"Source file not in examples directory: {rel_path}")
return {}

trans_path = project_root / 'examples' / 'translations' / language / f"{rel_to_examples}.comments"

if not trans_path.exists():
logger.warning(f"No translation file found at: {trans_path}")
return {}

translations = {}
for line in trans_path.read_text(encoding='utf-8').splitlines():
line = line.strip()
if line and not line.startswith('#'):
try:
orig, trans = line.split('=', 1)
translations[orig.strip()] = trans.strip()
except ValueError:
logger.warning(f"Invalid translation line: {line}")

logger.info(f"Loaded {len(translations)} translations")
return translations

except Exception as e:
logger.error(f"Error loading translations: {e}")
return {}

def translate_content(self, content: str, language: str) -> str:
"""Translate comments in the content using loaded translations."""
if not language or language == 'en':
return content

result = []
translations_used = 0

for line in content.splitlines():
stripped = line.strip()
if stripped.startswith('#'):
comment_text = stripped[1:].strip()
if comment_text in self.translations:
indent = line[:len(line) - len(stripped)]
result.append(f"{indent}# {self.translations[comment_text]}")
translations_used += 1
else:
result.append(line)
else:
result.append(line)

logger.info(f"Applied {translations_used} translations")
return '\n'.join(result)

def run(self):
env = self.state.document.settings.env
language = (getattr(env.config, 'language', None) or 'en')[:2].lower()

# Get absolute path of source file
source_file = Path(env.srcdir) / self.arguments[0]
if '..' in str(source_file):
project_root = Path(__file__).parent.parent.parent
parts = Path(self.arguments[0]).parts
up_count = sum(1 for part in parts if part == '..')
source_file = project_root.joinpath(*parts[up_count:])

source_file = source_file.resolve()
logger.info(f"Processing file: {source_file}")

# Load translations for non-English languages
if language != 'en':
self.translations = self.load_translations(source_file, language)

# Get original content and process nodes
document = super().run()
if not self.translations:
return document

result = []
for node in document:
if not isinstance(node, nodes.literal_block):
result.append(node)
continue

translated_content = self.translate_content(node.rawsource, language)
new_node = TranslatedNode(
translated_content,
translated_content,
source=node.source,
line=node.line
)

# Copy node attributes
new_node['language'] = node.get('language', 'python')
new_node['highlight_args'] = node.get('highlight_args', {})
new_node['linenos'] = node.get('linenos', False)
new_node['classes'] = node.get('classes', [])

if 'caption' in node:
new_node['caption'] = node['caption']

set_source_info(self, new_node)

# Apply container wrapper if needed
caption = node.get('caption', '') or self.options.get('caption', '')
if caption or node.get('linenos', False):
new_node = container_wrapper(self, new_node, caption)

result.append(new_node)

return result

def setup(app):
app.add_directive('translated-literalinclude', TranslatedLiteralInclude)
app.add_node(TranslatedNode)
return {
'version': '0.1',
'parallel_read_safe': True,
'parallel_write_safe': True,
}
Loading