Skip to content

Commit 42c597a

Browse files
author
vke
committed
add sphinx extension to localize example comments
1 parent 304e406 commit 42c597a

File tree

3 files changed

+211
-1
lines changed

3 files changed

+211
-1
lines changed

CONTRIBUTING.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,47 @@ Then follows a list of arguments:
118118
Then follows a description of the return value. The type is omitted here since
119119
it is already include in the docstring signature.
120120

121+
**Translating Code Examples:**
122+
123+
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.
124+
125+
Directory Structure:
126+
```
127+
examples/
128+
├── micropython/
129+
│ └── example.py # Original example with English comments
130+
└── translations/
131+
└── de/ # Language code (e.g., 'de' for German)
132+
└── micropython/
133+
└── example.py.comments # Translations file
134+
```
135+
136+
Translation Files:
137+
- Create a `.comments` file in the corresponding language directory
138+
- Use the format: `original comment = translated comment`
139+
- Each translation should be on a new line
140+
- Lines starting with `#` are ignored (can be used for translator notes)
141+
142+
Example translation file (`example.py.comments`):
143+
```
144+
# Translations for example.py
145+
Initialize the motor. = Initialisiere den Motor.
146+
Start moving at 500 deg/s. = Beginne die Bewegung mit 500 Grad/Sekunde.
147+
```
148+
149+
In RST files, use the `translated-literalinclude` directive instead of `literalinclude` to include code examples that should have translated comments:
150+
151+
```rst
152+
.. translated-literalinclude::
153+
../../../examples/micropython/example.py
154+
```
155+
156+
The translation system will:
157+
1. Include the original code file
158+
2. If building for a non-English language, look for corresponding `.comments` file
159+
3. Replace comments with translations if they exist
160+
4. Fall back to original comments if no translation exists
161+
121162
**Development environment:**
122163

123164
Prerequisites:
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""
2+
Sphinx extension for including code files with translated comments.
3+
"""
4+
import os
5+
from pathlib import Path
6+
from docutils import nodes
7+
from docutils.parsers.rst import directives
8+
from sphinx.directives.code import LiteralInclude, container_wrapper
9+
from sphinx.util import logging as sphinx_logging
10+
from sphinx.util.nodes import set_source_info
11+
12+
logger = sphinx_logging.getLogger(__name__)
13+
14+
class TranslatedNode(nodes.literal_block):
15+
"""Custom node that can be pickled."""
16+
def astext(self):
17+
return self.rawsource
18+
19+
class TranslatedLiteralInclude(LiteralInclude):
20+
"""A LiteralInclude directive that supports translated comments."""
21+
22+
option_spec = LiteralInclude.option_spec.copy()
23+
option_spec['language'] = directives.unchanged
24+
25+
def __init__(self, *args, **kwargs):
26+
super().__init__(*args, **kwargs)
27+
self.translations = {}
28+
29+
def load_translations(self, source_file: Path, language: str) -> dict:
30+
"""Load translations for the given file and language.
31+
32+
Args:
33+
source_file: Path to the source file
34+
language: Target language code
35+
36+
Returns:
37+
Dictionary of original comments to translated comments
38+
"""
39+
try:
40+
project_root = Path(__file__).parent.parent.parent
41+
rel_path = Path(source_file).relative_to(project_root)
42+
43+
# Get path relative to examples directory
44+
try:
45+
examples_index = rel_path.parts.index('examples')
46+
rel_to_examples = Path(*rel_path.parts[examples_index + 1:])
47+
except ValueError:
48+
logger.error(f"Source file not in examples directory: {rel_path}")
49+
return {}
50+
51+
trans_path = project_root / 'examples' / 'translations' / language / f"{rel_to_examples}.comments"
52+
53+
if not trans_path.exists():
54+
logger.warning(f"No translation file found at: {trans_path}")
55+
return {}
56+
57+
translations = {}
58+
for line in trans_path.read_text(encoding='utf-8').splitlines():
59+
line = line.strip()
60+
if line and not line.startswith('#'):
61+
try:
62+
orig, trans = line.split('=', 1)
63+
translations[orig.strip()] = trans.strip()
64+
except ValueError:
65+
logger.warning(f"Invalid translation line: {line}")
66+
67+
logger.info(f"Loaded {len(translations)} translations")
68+
return translations
69+
70+
except Exception as e:
71+
logger.error(f"Error loading translations: {e}")
72+
return {}
73+
74+
def translate_content(self, content: str, language: str) -> str:
75+
"""Translate comments in the content using loaded translations."""
76+
if not language or language == 'en':
77+
return content
78+
79+
result = []
80+
translations_used = 0
81+
82+
for line in content.splitlines():
83+
stripped = line.strip()
84+
if stripped.startswith('#'):
85+
comment_text = stripped[1:].strip()
86+
if comment_text in self.translations:
87+
indent = line[:len(line) - len(stripped)]
88+
result.append(f"{indent}# {self.translations[comment_text]}")
89+
translations_used += 1
90+
else:
91+
result.append(line)
92+
else:
93+
result.append(line)
94+
95+
logger.info(f"Applied {translations_used} translations")
96+
return '\n'.join(result)
97+
98+
def run(self):
99+
env = self.state.document.settings.env
100+
language = (getattr(env.config, 'language', None) or 'en')[:2].lower()
101+
102+
# Get absolute path of source file
103+
source_file = Path(env.srcdir) / self.arguments[0]
104+
if '..' in str(source_file):
105+
project_root = Path(__file__).parent.parent.parent
106+
parts = Path(self.arguments[0]).parts
107+
up_count = sum(1 for part in parts if part == '..')
108+
source_file = project_root.joinpath(*parts[up_count:])
109+
110+
source_file = source_file.resolve()
111+
logger.info(f"Processing file: {source_file}")
112+
113+
# Load translations for non-English languages
114+
if language != 'en':
115+
self.translations = self.load_translations(source_file, language)
116+
117+
# Get original content and process nodes
118+
document = super().run()
119+
if not self.translations:
120+
return document
121+
122+
result = []
123+
for node in document:
124+
if not isinstance(node, nodes.literal_block):
125+
result.append(node)
126+
continue
127+
128+
translated_content = self.translate_content(node.rawsource, language)
129+
new_node = TranslatedNode(
130+
translated_content,
131+
translated_content,
132+
source=node.source,
133+
line=node.line
134+
)
135+
136+
# Copy node attributes
137+
new_node['language'] = node.get('language', 'python')
138+
new_node['highlight_args'] = node.get('highlight_args', {})
139+
new_node['linenos'] = node.get('linenos', False)
140+
new_node['classes'] = node.get('classes', [])
141+
142+
if 'caption' in node:
143+
new_node['caption'] = node['caption']
144+
145+
set_source_info(self, new_node)
146+
147+
# Apply container wrapper if needed
148+
caption = node.get('caption', '') or self.options.get('caption', '')
149+
if caption or node.get('linenos', False):
150+
new_node = container_wrapper(self, new_node, caption)
151+
152+
result.append(new_node)
153+
154+
return result
155+
156+
def setup(app):
157+
app.add_directive('translated-literalinclude', TranslatedLiteralInclude)
158+
app.add_node(TranslatedNode)
159+
return {
160+
'version': '0.1',
161+
'parallel_read_safe': True,
162+
'parallel_write_safe': True,
163+
}

doc/main/conf.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,22 @@
4545
\usepackage{newtxsf}
4646
"""
4747

48+
# Add path to our extensions
49+
sys.path.append(os.path.abspath("../extensions"))
50+
4851
exec(open(os.path.abspath("../common/conf.py")).read())
4952

53+
# Add our custom extension
54+
extensions.append("translated_literalinclude") # noqa F821
55+
5056
# Additional configuration of the IDE docs
5157
if "ide" in tags.tags: # noqa F821
5258
extensions.remove("sphinx.ext.mathjax") # noqa F821
5359
extensions.append("sphinx.ext.imgmath") # noqa F821
5460
html_theme_options["prev_next_buttons_location"] = None # noqa F821
5561

5662
# Internationalization configuration
57-
language = 'en'
63+
language = os.environ.get('SPHINX_LANGUAGE', 'en')
5864
locale_dirs = ['../locales'] # path relative to conf.py location
5965
gettext_compact = False # optional
6066
gettext_uuid = True # Use UUIDs to preserve translations when text moves

0 commit comments

Comments
 (0)