aboutsummaryrefslogtreecommitdiff
path: root/plugins/i18n_subsites/i18n_subsites.py
blob: dc27799d41bbf7d89d701fc1bfd47006bd409a26 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
"""i18n_subsites plugin creates i18n-ized subsites of the default site

This plugin is designed for Pelican 3.4 and later
"""


import os
import six
import logging
import posixpath

from copy import copy
from itertools import chain
from operator import attrgetter
try:
    from collections.abc import OrderedDict
except ImportError:
    from collections import OrderedDict
from contextlib import contextmanager
from six.moves.urllib.parse import urlparse

import gettext
import locale

from pelican import signals
from pelican.generators import ArticlesGenerator, PagesGenerator
from pelican.settings import configure_settings
try:
    from pelican.contents import Draft
except ImportError:
    from pelican.contents import Article as Draft


# Global vars
_MAIN_SETTINGS = None     # settings dict of the main Pelican instance
_MAIN_LANG = None         # lang of the main Pelican instance
_MAIN_SITEURL = None      # siteurl of the main Pelican instance
_MAIN_STATIC_FILES = None # list of Static instances the main Pelican instance
_SUBSITE_QUEUE = {}   # map: lang -> settings overrides
_SITE_DB = OrderedDict()           # OrderedDict: lang -> siteurl
_SITES_RELPATH_DB = {}       # map: (lang, base_lang) -> relpath
# map: generator -> list of removed contents that need interlinking
_GENERATOR_DB = {}
_NATIVE_CONTENT_URL_DB = {} # map: source_path -> content in its native lang
_LOGGER = logging.getLogger(__name__)


@contextmanager
def temporary_locale(temp_locale=None):
    '''Enable code to run in a context with a temporary locale

    Resets the locale back when exiting context.
    Can set a temporary locale if provided
    '''
    orig_locale = locale.setlocale(locale.LC_ALL)
    if temp_locale is not None:
        locale.setlocale(locale.LC_ALL, temp_locale)
    yield
    locale.setlocale(locale.LC_ALL, orig_locale)


def initialize_dbs(settings):
    '''Initialize internal DBs using the Pelican settings dict

    This clears the DBs for e.g. autoreload mode to work
    '''
    global _MAIN_SETTINGS, _MAIN_SITEURL, _MAIN_LANG, _SUBSITE_QUEUE
    _MAIN_SETTINGS = settings
    _MAIN_LANG = settings['DEFAULT_LANG']
    _MAIN_SITEURL = settings['SITEURL']
    _SUBSITE_QUEUE = settings.get('I18N_SUBSITES', {}).copy()
    prepare_site_db_and_overrides()
    # clear databases in case of autoreload mode
    _SITES_RELPATH_DB.clear()
    _NATIVE_CONTENT_URL_DB.clear()
    _GENERATOR_DB.clear()


def prepare_site_db_and_overrides():
    '''Prepare overrides and create _SITE_DB

    _SITE_DB.keys() need to be ready for filter_translations
    '''
    _SITE_DB.clear()
    _SITE_DB[_MAIN_LANG] = _MAIN_SITEURL
    # make sure it works for both root-relative and absolute
    main_siteurl = '/' if _MAIN_SITEURL == '' else _MAIN_SITEURL
    for lang, overrides in _SUBSITE_QUEUE.items():
        if 'SITEURL' not in overrides:
            overrides['SITEURL'] = posixpath.join(main_siteurl, lang)
        _SITE_DB[lang] = overrides['SITEURL']
        # default subsite hierarchy
        if 'OUTPUT_PATH' not in overrides:
            overrides['OUTPUT_PATH'] = os.path.join(
                _MAIN_SETTINGS['OUTPUT_PATH'], lang)
        if 'CACHE_PATH' not in overrides:
            overrides['CACHE_PATH'] = os.path.join(
                _MAIN_SETTINGS['CACHE_PATH'], lang)
        if 'STATIC_PATHS' not in overrides:
            overrides['STATIC_PATHS'] = []
        if ('THEME' not in overrides and 'THEME_STATIC_DIR' not in overrides and
                'THEME_STATIC_PATHS' not in overrides):
            relpath = relpath_to_site(lang, _MAIN_LANG)
            overrides['THEME_STATIC_DIR'] = posixpath.join(
                relpath, _MAIN_SETTINGS['THEME_STATIC_DIR'])
            overrides['THEME_STATIC_PATHS'] = []
        # to change what is perceived as translations
        overrides['DEFAULT_LANG'] = lang


def subscribe_filter_to_signals(settings):
    '''Subscribe content filter to requested signals'''
    for sig in settings.get('I18N_FILTER_SIGNALS', []):
        sig.connect(filter_contents_translations)


def initialize_plugin(pelican_obj):
    '''Initialize plugin variables and Pelican settings'''
    if _MAIN_SETTINGS is None:
        initialize_dbs(pelican_obj.settings)
        subscribe_filter_to_signals(pelican_obj.settings)


def get_site_path(url):
    '''Get the path component of an url, excludes siteurl

    also normalizes '' to '/' for relpath to work,
    otherwise it could be interpreted as a relative filesystem path
    '''
    path = urlparse(url).path
    if path == '':
        path = '/'
    return path


def relpath_to_site(lang, target_lang):
    '''Get relative path from siteurl of lang to siteurl of base_lang

    the output is cached in _SITES_RELPATH_DB
    '''
    path = _SITES_RELPATH_DB.get((lang, target_lang), None)
    if path is None:
        siteurl = _SITE_DB.get(lang, _MAIN_SITEURL)
        target_siteurl = _SITE_DB.get(target_lang, _MAIN_SITEURL)
        path = posixpath.relpath(get_site_path(target_siteurl),
                                 get_site_path(siteurl))
        _SITES_RELPATH_DB[(lang, target_lang)] = path
    return path


def save_generator(generator):
    '''Save the generator for later use

    initialize the removed content list
    '''
    _GENERATOR_DB[generator] = []


def article2draft(article):
    '''Transform an Article to Draft'''
    draft = Draft(article._content, article.metadata, article.settings,
                  article.source_path, article._context)
    draft.status = 'draft'
    return draft


def page2hidden_page(page):
    '''Transform a Page to a hidden Page'''
    page.status = 'hidden'
    return page


class GeneratorInspector(object):
    '''Inspector of generator instances'''

    generators_info = {
        ArticlesGenerator: {
            'translations_lists': ['translations', 'drafts_translations'],
            'contents_lists': [('articles', 'drafts')],
            'hiding_func': article2draft,
            'policy': 'I18N_UNTRANSLATED_ARTICLES',
        },
        PagesGenerator: {
            'translations_lists': ['translations', 'hidden_translations'],
            'contents_lists': [('pages', 'hidden_pages')],
            'hiding_func': page2hidden_page,
            'policy': 'I18N_UNTRANSLATED_PAGES',
        },
    }

    def __init__(self, generator):
        '''Identify the best known class of the generator instance

        The class '''
        self.generator = generator
        self.generators_info.update(generator.settings.get(
            'I18N_GENERATORS_INFO', {}))
        for cls in generator.__class__.__mro__:
            if cls in self.generators_info:
                self.info = self.generators_info[cls]
                break
        else:
            self.info = {}

    def translations_lists(self):
        '''Iterator over lists of content translations'''
        return (getattr(self.generator, name) for name in
                self.info.get('translations_lists', []))

    def contents_list_pairs(self):
        '''Iterator over pairs of normal and hidden contents'''
        return (tuple(getattr(self.generator, name) for name in names)
                for names in self.info.get('contents_lists', []))

    def hiding_function(self):
        '''Function for transforming content to a hidden version'''
        hiding_func = self.info.get('hiding_func', lambda x: x)
        return hiding_func

    def untranslated_policy(self, default):
        '''Get the policy for untranslated content'''
        return self.generator.settings.get(self.info.get('policy', None),
                                           default)

    def all_contents(self):
        '''Iterator over all contents'''
        translations_iterator = chain(*self.translations_lists())
        return chain(translations_iterator,
                     *(pair[i] for pair in self.contents_list_pairs()
                       for i in (0, 1)))


def filter_contents_translations(generator):
    '''Filter the content and translations lists of a generator

    Filters out
        1) translations which will be generated in a different site
        2) content that is not in the language of the currently
        generated site but in that of a different site, content in a
        language which has no site is generated always. The filtering
        method bay be modified by the respective untranslated policy
    '''
    inspector = GeneratorInspector(generator)
    current_lang = generator.settings['DEFAULT_LANG']
    langs_with_sites = _SITE_DB.keys()
    removed_contents = _GENERATOR_DB[generator]

    for translations in inspector.translations_lists():
        for translation in translations[:]:    # copy to be able to remove
            if translation.lang in langs_with_sites:
                translations.remove(translation)
                removed_contents.append(translation)

    hiding_func = inspector.hiding_function()
    untrans_policy = inspector.untranslated_policy(default='hide')
    for (contents, other_contents) in inspector.contents_list_pairs():
        for content in other_contents: # save any hidden native content first
            if content.lang == current_lang: # in native lang
                # save the native URL attr formatted in the current locale
                _NATIVE_CONTENT_URL_DB[content.source_path] = content.url
        for content in contents[:]:        # copy for removing in loop
            if content.lang == current_lang: # in native lang
                # save the native URL attr formatted in the current locale
                _NATIVE_CONTENT_URL_DB[content.source_path] = content.url
            elif content.lang in langs_with_sites and untrans_policy != 'keep':
                contents.remove(content)
                if untrans_policy == 'hide':
                    other_contents.append(hiding_func(content))
                elif untrans_policy == 'remove':
                    removed_contents.append(content)


def install_templates_translations(generator):
    '''Install gettext translations in the jinja2.Environment

    Only if the 'jinja2.ext.i18n' jinja2 extension is enabled
    the translations for the current DEFAULT_LANG are installed.
    '''
    if 'JINJA_ENVIRONMENT' in generator.settings: # pelican 3.7+
        jinja_extensions = generator.settings['JINJA_ENVIRONMENT'].get(
            'extensions', [])
    else:
        jinja_extensions = generator.settings['JINJA_EXTENSIONS']

    if 'jinja2.ext.i18n' in jinja_extensions:
        domain = generator.settings.get('I18N_GETTEXT_DOMAIN', 'messages')
        localedir = generator.settings.get('I18N_GETTEXT_LOCALEDIR')
        if localedir is None:
            localedir = os.path.join(generator.theme, 'translations')
        current_lang = generator.settings['DEFAULT_LANG']
        if current_lang == generator.settings.get('I18N_TEMPLATES_LANG',
                                                  _MAIN_LANG):
            translations = gettext.NullTranslations()
        else:
            langs = [current_lang]
            try:
                translations = gettext.translation(domain, localedir, langs)
            except (IOError, OSError):
                _LOGGER.error((
                    "Cannot find translations for language '{}' in '{}' with "
                    "domain '{}'. Installing NullTranslations.").format(
                        langs[0], localedir, domain))
                translations = gettext.NullTranslations()
        newstyle = generator.settings.get('I18N_GETTEXT_NEWSTYLE', True)
        generator.env.install_gettext_translations(translations, newstyle)


def add_variables_to_context(generator):
    '''Adds useful iterable variables to template context'''
    context = generator.context             # minimize attr lookup
    context['relpath_to_site'] = relpath_to_site
    context['main_siteurl'] = _MAIN_SITEURL
    context['main_lang'] = _MAIN_LANG
    context['lang_siteurls'] = _SITE_DB
    current_lang = generator.settings['DEFAULT_LANG']
    extra_siteurls = _SITE_DB.copy()
    extra_siteurls.pop(current_lang)
    context['extra_siteurls'] = extra_siteurls


def interlink_translations(content):
    '''Link content to translations in their main language

    so the URL (including localized month names) of the different subsites
    will be honored
    '''
    lang = content.lang
    # sort translations by lang
    content.translations.sort(key=attrgetter('lang'))
    for translation in content.translations:
        relpath = relpath_to_site(lang, translation.lang)
        url = _NATIVE_CONTENT_URL_DB[translation.source_path]
        translation.override_url = posixpath.join(relpath, url)


def interlink_translated_content(generator):
    '''Make translations link to the native locations

    for generators that may contain translated content
    '''
    inspector = GeneratorInspector(generator)
    for content in inspector.all_contents():
        interlink_translations(content)


def interlink_removed_content(generator):
    '''For all contents removed from generation queue update interlinks

    link to the native location
    '''
    current_lang = generator.settings['DEFAULT_LANG']
    for content in _GENERATOR_DB[generator]:
        url = _NATIVE_CONTENT_URL_DB[content.source_path]
        relpath = relpath_to_site(current_lang, content.lang)
        content.override_url = posixpath.join(relpath, url)


def interlink_static_files(generator):
    '''Add links to static files in the main site if necessary'''
    if generator.settings['STATIC_PATHS'] != []:
        return                               # customized STATIC_PATHS
    try: # minimize attr lookup
        static_content = generator.context['static_content']
    except KeyError:
        static_content = generator.context['filenames']
    relpath = relpath_to_site(generator.settings['DEFAULT_LANG'], _MAIN_LANG)
    for staticfile in _MAIN_STATIC_FILES:
        if staticfile.get_relative_source_path() not in static_content:
            staticfile = copy(staticfile) # prevent override in main site
            staticfile.override_url = posixpath.join(relpath, staticfile.url)
            try:
                generator.add_source_path(staticfile, static=True)
            except TypeError:
                generator.add_source_path(staticfile)


def save_main_static_files(static_generator):
    '''Save the static files generated for the main site'''
    global _MAIN_STATIC_FILES
    # test just for current lang as settings change in autoreload mode
    if static_generator.settings['DEFAULT_LANG'] == _MAIN_LANG:
        _MAIN_STATIC_FILES = static_generator.staticfiles


def update_generators():
    '''Update the context of all generators

    Ads useful variables and translations into the template context
    and interlink translations
    '''
    for generator in _GENERATOR_DB.keys():
        install_templates_translations(generator)
        add_variables_to_context(generator)
        interlink_static_files(generator)
        interlink_removed_content(generator)
        interlink_translated_content(generator)


def get_pelican_cls(settings):
    '''Get the Pelican class requested in settings'''
    cls = settings['PELICAN_CLASS']
    if isinstance(cls, six.string_types):
        module, cls_name = cls.rsplit('.', 1)
        module = __import__(module)
        cls = getattr(module, cls_name)
    return cls


def create_next_subsite(pelican_obj):
    '''Create the next subsite using the lang-specific config

    If there are no more subsites in the generation queue, update all
    the generators (interlink translations and removed content, add
    variables and translations to template context). Otherwise get the
    language and overrides for next the subsite in the queue and apply
    overrides.  Then generate the subsite using a PELICAN_CLASS
    instance and its run method. Finally, restore the previous locale.
    '''
    global _MAIN_SETTINGS
    if len(_SUBSITE_QUEUE) == 0:
        _LOGGER.debug(
            'i18n: Updating cross-site links and context of all generators.')
        update_generators()
        _MAIN_SETTINGS = None             # to initialize next time
    else:
        with temporary_locale():
            settings = _MAIN_SETTINGS.copy()
            lang, overrides = _SUBSITE_QUEUE.popitem()
            settings.update(overrides)
            settings = configure_settings(settings)      # to set LOCALE, etc.
            cls = get_pelican_cls(settings)

            new_pelican_obj = cls(settings)
            _LOGGER.debug(("Generating i18n subsite for language '{}' "
                           "using class {}").format(lang, cls))
            new_pelican_obj.run()


# map: signal name -> function name
_SIGNAL_HANDLERS_DB = {
    'get_generators': initialize_plugin,
    'article_generator_pretaxonomy': filter_contents_translations,
    'page_generator_finalized': filter_contents_translations,
    'get_writer': create_next_subsite,
    'static_generator_finalized': save_main_static_files,
    'generator_init': save_generator,
}


def register():
    '''Register the plugin only if required signals are available'''
    for sig_name in _SIGNAL_HANDLERS_DB.keys():
        if not hasattr(signals, sig_name):
            _LOGGER.error((
                'The i18n_subsites plugin requires the {} '
                'signal available for sure in Pelican 3.4.0 and later, '
                'plugin will not be used.').format(sig_name))
            return

    for sig_name, handler in _SIGNAL_HANDLERS_DB.items():
        sig = getattr(signals, sig_name)
        sig.connect(handler)