aboutsummaryrefslogtreecommitdiff
path: root/plugins/i18n_subsites/i18n_subsites.py
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/i18n_subsites/i18n_subsites.py')
-rw-r--r--plugins/i18n_subsites/i18n_subsites.py462
1 files changed, 462 insertions, 0 deletions
diff --git a/plugins/i18n_subsites/i18n_subsites.py b/plugins/i18n_subsites/i18n_subsites.py
new file mode 100644
index 0000000..dc27799
--- /dev/null
+++ b/plugins/i18n_subsites/i18n_subsites.py
@@ -0,0 +1,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)