Skip to content

Commit ba54ecd

Browse files
committed
svelte_makemessages seems to work
0 parents  commit ba54ecd

11 files changed

+530
-0
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.pyc

README.md

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
Django i18n localization for Svelte apps
2+
========================================
3+
4+
Contains:
5+
6+
* View `JSONCatalog` serving the translation strings to the frontend
7+
* Management command `svelte_makemessages` extracting the translatable strings.
8+
* Management command `svelte_compilemessages` - creates the per-route catalogs and compiles them into .mo files.
9+
10+
11+
How to internatialize your Svelte app
12+
-------------------------------------
13+
14+
Todo: write this up.
15+
16+
17+
How to use this app
18+
-------------------
19+
20+
1) Install and configure the app as described in the section "Installation" below.
21+
2) Create the global per-app translation catalogs by running `python manage.py svelte_makemessages`.
22+
The catalogs will be located in the directory specified
23+
by the `SVELTE_I18N[<app_name>]['locale_path']` value.
24+
3) Make the translations (refer to the Django documentation).
25+
4) Create the per-route catalogs by running `python manage.py svelte_compilemessages`
26+
27+
28+
Installation
29+
------------
30+
31+
```
32+
pip install django-svelte-i18n
33+
```
34+
35+
To the urls.py add:
36+
37+
```
38+
from svelte_i18n import views
39+
40+
urlpatterns += (url('^i18n/$', views.JSONCatalog.as_view(), name='svelte-i18n'),)
41+
```
42+
43+
Add to the `settings.py` file:
44+
45+
46+
```
47+
INSTALLED_APPS = [
48+
... <other apps>,
49+
'svelte_i18n',
50+
]
51+
52+
53+
SVELTE_I18N = {
54+
'<package_name>': {
55+
'locale_dir': '<path to the app locale dir>', # for each package list path to the locale directory
56+
'app_src_dir': '<path to the app source code directory>,
57+
'routes_dir': '<path to the root directory of the router>', #next.js, routify style router
58+
'router_type': 'routify',
59+
'non_route_patterns': ('_*', '.*'),
60+
'comment_tags': ('tr:',)
61+
}
62+
}
63+
```
64+
65+
* `package_name` - any string that identifies the Svelte app, e.g. 'myapp'
66+
* `app_src_dir` - root directory of the app code - to avoid extraction from any files
67+
outside this directory
68+
* `locale_dir` - file system path to `locale` directory. Can be anywhere on disk,
69+
for example at the `/home/smith/myapp/locale`
70+
* `routes_dir` - root directory of the routes in the routify style
71+
* `router_type` - the only supported value at the moment is 'routify'
72+
* `non_route_patterns` - glob patterns for for the directories and file names
73+
that should not be interpreted as routes, so that
74+
we don't create unnecessary per-route translation catalogs.
75+
76+
77+
JSONCatalog view
78+
----------------
79+
80+
This app provides one view `JSONCatalog` which provide the translation strings.
81+
82+
This view returs JSON data, accepts two parameters: `route` and `package`.
83+
84+
Parameter `route` is optional. If not provided, the view will return all translation
85+
strings for the given package (i.e. a Svelte app),
86+
the downside in this case may be
87+
that the view will serve a lot of data at once.
88+
89+
Parameter `package` is required if the app is serving strings for more than one package.
90+
Package corresponds to a Svelte app.

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
babel

setup.py

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from distutils.core import setup
2+
import sys
3+
4+
setup(
5+
name="django-svelte-i18n",
6+
version="0.0.1",
7+
description='A Django app for extraction and serving of translation strings for Svelte apps',
8+
author='Evgeny.Fadeev',
9+
author_email='[email protected]',
10+
install_requires=['esprima', 'beautifulsoup'],
11+
license='GPLv3',
12+
keywords='translation, django, svelte, i18n',
13+
url='http://askbot.org',
14+
classifiers = [
15+
'Development Status :: 4 - Beta',
16+
'Environment :: Web Environment',
17+
'Framework :: Django',
18+
'Framework :: Svelte',
19+
'Framework :: Routify',
20+
'Intended Audience :: Developers',
21+
'License :: OSI Approved :: GNU General Public License (GPL)',
22+
'Natural Language :: English',
23+
'Operating System :: OS Independent',
24+
'Programming Language :: Python :: 3',
25+
'Programming Language :: JavaScript',
26+
],
27+
long_description=open('./README.md').read(),
28+
)

svelte_i18n/__init__.py

Whitespace-only changes.

svelte_i18n/extractor.py

+241
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"""Contains the message extractor file"""
2+
import os
3+
import esprima
4+
from bs4 import BeautifulSoup
5+
from django.conf import settings as django_settings
6+
from babel.messages.catalog import Catalog, Message
7+
from babel.messages.extract import extract_from_file
8+
from babel.messages.pofile import write_po
9+
from svelte_i18n import routify
10+
from svelte_i18n.utils import is_route, get_route, get_app_file_path
11+
12+
class Extractor:
13+
"""Extracts i18n messages from .js and .svelte files into svelte.po file
14+
of the default locale.
15+
16+
Marks each phrase with the route where this phrase is used.
17+
18+
Main methods are `.extract_messages()` and `.write_pofile()`
19+
"""
20+
21+
def __init__(self, app_name):
22+
self.app_name = app_name
23+
self.config = django_settings.SVELTE_I18N[self.app_name]
24+
self.catalog = Catalog(locale=django_settings.LANGUAGE_CODE)
25+
26+
def extract_messages(self):
27+
"""Extracts messages for the app"""
28+
# iterate over the routes directory
29+
routes_dir = self.config['routes_dir']
30+
31+
for root, _, files in os.walk(routes_dir):
32+
rel_dir = root.replace(routes_dir, '', 1).lstrip(os.path.sep)
33+
if not is_route(self.app_name, rel_dir):
34+
#print(f'non-route: {rel_dir}')
35+
continue
36+
37+
for fname in files:
38+
if not is_route(self.app_name, fname):
39+
#print(f'non-route: {fname}')
40+
continue
41+
42+
path = os.path.join(routes_dir, rel_dir, fname)
43+
path = os.path.normpath(path)
44+
route = get_route(self.app_name, path)
45+
self.process_route(path, route)
46+
47+
48+
def write_pofile(self):
49+
"""Writes catalog of the app to the pofile"""
50+
with self.open_pofile() as po_file:
51+
write_po(po_file, self.catalog)
52+
53+
54+
def open_pofile(self):
55+
"""Returns writeable file object at the source language
56+
pofile location.
57+
Insures that all parent directories are created.
58+
"""
59+
locale_dir = self.config['locale_dir']
60+
lang = django_settings.LANGUAGE_CODE
61+
src_locale_dir = os.path.join(locale_dir, lang, 'LC_MESSAGES')
62+
os.makedirs(src_locale_dir, exist_ok=True)
63+
pofile_path = os.path.join(src_locale_dir, 'svelte.po')
64+
return open(pofile_path, 'wb')
65+
66+
67+
def process_route(self, path, route):
68+
"""Extracts messages for the route and all
69+
the imported files (recursively), EXCLUDING
70+
the third party libraries.
71+
72+
* `path` - path to the route file relative
73+
to the app's routes root.
74+
* `app_name` - name of the Svelte app.
75+
"""
76+
paths = [path]
77+
78+
# Routify uses additional per-route files: layouts, reset and fallback.
79+
if self.config['router_type'] == 'routify':
80+
paths.extend(routify.get_special_route_files(self.app_name, path))
81+
82+
# In addition to the thus far found files for this route,
83+
# recursively find imported files, excluding those
84+
# that are outside of the 'app_src_dir' - (i.e. excluding the 3rd party libs)
85+
for used_file_path in self.get_route_files_recursive(paths):
86+
# and extract the i18n messages from all of them
87+
self.extract_messages_from_file(used_file_path, route)
88+
89+
90+
def get_route_files_recursive(self, paths):
91+
"""Returns list of all files used in the route,
92+
belonging to the current app.
93+
Does not include 3rd party library files.
94+
"""
95+
untested_paths = set(paths)
96+
tested_paths = set()
97+
while untested_paths:
98+
path = untested_paths.pop()
99+
more_paths = set(self.get_imported_paths(path))
100+
tested_paths.add(path)
101+
untested_paths |= (more_paths - tested_paths)
102+
103+
return list(tested_paths)
104+
105+
106+
def get_imported_paths(self, path):
107+
"""For a given file, returns paths of the imported files
108+
relative to the router root"""
109+
src = open(path).read()
110+
script = BeautifulSoup(src, 'lxml').find('script')
111+
if not script:
112+
return list()
113+
114+
tokens = esprima.tokenize(script.text)
115+
paths = list()
116+
idx = 0
117+
118+
while idx < len(tokens):
119+
token = tokens[idx]
120+
if token.type == 'Keyword' and token.value == 'import':
121+
idx += 1
122+
imported_path, new_idx = self.get_imported_path(tokens, idx, path)
123+
if imported_path and self.file_exists(imported_path):
124+
paths.append(imported_path)
125+
if new_idx <= idx:
126+
raise ValueError(f'Could not extract imported paths from {path}')
127+
idx = new_idx
128+
else:
129+
idx += 1
130+
return paths
131+
132+
133+
def file_exists(self, path):
134+
"""Returns `true` if path exists relative
135+
to the routes path"""
136+
app_src_dir = self.config['app_src_dir']
137+
#must be within the app and must exist
138+
return path.startswith(app_src_dir) and os.path.exists(path)
139+
140+
141+
def get_imported_path(self, tokens, idx, container_path):
142+
"""Extracts import path or the module name from the ES token stream.
143+
144+
Returns a tuple: (path, idx) where path is the file path
145+
if it can be found within the project source code, or `None`.
146+
idx is the next token stream position."""
147+
token = tokens[idx]
148+
if token.type == 'Punctuator':
149+
if token.value == '{':
150+
path, next_idx = self.get_statically_imported_path(tokens, idx + 1)
151+
return self.get_normpath(path, container_path), next_idx
152+
if token.value == '(':
153+
path, next_idx = self.get_dynamically_imported_path(tokens, idx + 1)
154+
return self.get_normpath(path, container_path), next_idx
155+
return None, idx
156+
157+
if token.type == 'Identifier':
158+
path, next_idx = self.get_statically_imported_path(tokens, idx + 1)
159+
return self.get_normpath(path, container_path), next_idx
160+
return None, idx + 1
161+
162+
163+
@classmethod
164+
def get_normpath(cls, path, container_path):
165+
"""Returns path relative to the routes_root"""
166+
if path.startswith('.'):
167+
container_dir = os.path.dirname(container_path)
168+
return os.path.normpath(os.path.join(container_dir, path))
169+
return path
170+
171+
172+
@classmethod
173+
def get_statically_imported_path(cls, tokens, idx):
174+
"""Returns import path or the module name coming after the
175+
`from` keyword"""
176+
end = len(tokens)
177+
while idx < end:
178+
token = tokens[idx]
179+
if token.type == 'Identifier' and token.value == 'from':
180+
idx += 1
181+
token = tokens[idx]
182+
if token.type == 'String':
183+
value = token.value
184+
return value.strip('"').strip("'"), idx + 1
185+
raise ValueError('Unexpected token')
186+
idx += 1
187+
return None, idx
188+
189+
@classmethod
190+
def get_dynamically_imported_path(cls, tokens, idx):
191+
"""Returns import path or the module name coming after the (
192+
punctuator"""
193+
idx += 1
194+
token = tokens[idx]
195+
if token.type == 'Punctuator' and token.value == '(':
196+
idx += 1
197+
token = tokens[idx]
198+
if token.type == 'String':
199+
value = token.value
200+
return value.strip('"').strip("'"), idx + 1
201+
raise ValueError('Unexpected token')
202+
raise ValueError('Unexpected token')
203+
204+
205+
206+
def extract_messages_from_file(self, path, route):
207+
"""Extracts messages from the file at path into the
208+
Babel `Catalog` object
209+
210+
`path` - absolute path to the file corresponding to the route.
211+
"""
212+
results = extract_from_file('javascript',
213+
path,
214+
comment_tags=self.config['comment_tags'])
215+
for line_no, msg_id, tr_comments, _ in results:
216+
self.add_message(msg_id=msg_id,
217+
line_no=line_no,
218+
file_path=path,
219+
route=route,
220+
translator_comments=tr_comments)
221+
222+
223+
def add_message(self, msg_id=None, #pylint: disable=too-many-arguments
224+
line_no=None, file_path=None,
225+
route=None, translator_comments=None):
226+
"""Adds message to the catalog"""
227+
msg = self.catalog.get(msg_id) or Message(msg_id)
228+
app_file_path = get_app_file_path(self.app_name, file_path)
229+
msg.locations.append((app_file_path, line_no))
230+
for comment in translator_comments:
231+
msg.user_comments.append(self.format_translator_comment(comment, app_file_path))
232+
msg.auto_comments.append(f'route: {route}')
233+
self.catalog[msg_id] = msg
234+
235+
236+
def format_translator_comment(self, comment, app_file_path):
237+
"""Removes the translation comment prefix"""
238+
for tag in self.config['comment_tags']:
239+
if comment.startswith(tag):
240+
comment = comment[len(tag):].strip()
241+
return f'{app_file_path}: {comment}'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Using each translated master catalog and the per-message
2+
comments within, creates the per-route catalogs and compiles them
3+
into the .mo files"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Management command that creates the master locale
2+
for each svelte app"""
3+
import os
4+
from django.conf import settings as django_settings
5+
from django.core.management.base import BaseCommand
6+
from svelte_i18n.extractor import Extractor
7+
8+
9+
class Command(BaseCommand): #pylint: disable=missing-docstring
10+
help = 'Extracts translation messages from the .svelte and .js files'
11+
12+
def handle(self, *args, **options):
13+
"""Iterates over the svelte apps and does the job for each"""
14+
for app_name in django_settings.SVELTE_I18N:
15+
extractor = Extractor(app_name)
16+
extractor.extract_messages()
17+
extractor.write_pofile()

0 commit comments

Comments
 (0)