i18n_subsites.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. """i18n_subsites plugin creates i18n-ized subsites of the default site
  2. This plugin is designed for Pelican 3.4 and later
  3. """
  4. import os
  5. import six
  6. import logging
  7. import posixpath
  8. from copy import copy
  9. from itertools import chain
  10. from operator import attrgetter
  11. try:
  12. from collections.abc import OrderedDict
  13. except ImportError:
  14. from collections import OrderedDict
  15. from contextlib import contextmanager
  16. from six.moves.urllib.parse import urlparse
  17. import gettext
  18. import locale
  19. from pelican import signals
  20. from pelican.generators import ArticlesGenerator, PagesGenerator
  21. from pelican.settings import configure_settings
  22. try:
  23. from pelican.contents import Draft
  24. except ImportError:
  25. from pelican.contents import Article as Draft
  26. # Global vars
  27. _MAIN_SETTINGS = None # settings dict of the main Pelican instance
  28. _MAIN_LANG = None # lang of the main Pelican instance
  29. _MAIN_SITEURL = None # siteurl of the main Pelican instance
  30. _MAIN_STATIC_FILES = None # list of Static instances the main Pelican instance
  31. _SUBSITE_QUEUE = {} # map: lang -> settings overrides
  32. _SITE_DB = OrderedDict() # OrderedDict: lang -> siteurl
  33. _SITES_RELPATH_DB = {} # map: (lang, base_lang) -> relpath
  34. # map: generator -> list of removed contents that need interlinking
  35. _GENERATOR_DB = {}
  36. _NATIVE_CONTENT_URL_DB = {} # map: source_path -> content in its native lang
  37. _LOGGER = logging.getLogger(__name__)
  38. @contextmanager
  39. def temporary_locale(temp_locale=None):
  40. '''Enable code to run in a context with a temporary locale
  41. Resets the locale back when exiting context.
  42. Can set a temporary locale if provided
  43. '''
  44. orig_locale = locale.setlocale(locale.LC_ALL)
  45. if temp_locale is not None:
  46. locale.setlocale(locale.LC_ALL, temp_locale)
  47. yield
  48. locale.setlocale(locale.LC_ALL, orig_locale)
  49. def initialize_dbs(settings):
  50. '''Initialize internal DBs using the Pelican settings dict
  51. This clears the DBs for e.g. autoreload mode to work
  52. '''
  53. global _MAIN_SETTINGS, _MAIN_SITEURL, _MAIN_LANG, _SUBSITE_QUEUE
  54. _MAIN_SETTINGS = settings
  55. _MAIN_LANG = settings['DEFAULT_LANG']
  56. _MAIN_SITEURL = settings['SITEURL']
  57. _SUBSITE_QUEUE = settings.get('I18N_SUBSITES', {}).copy()
  58. prepare_site_db_and_overrides()
  59. # clear databases in case of autoreload mode
  60. _SITES_RELPATH_DB.clear()
  61. _NATIVE_CONTENT_URL_DB.clear()
  62. _GENERATOR_DB.clear()
  63. def prepare_site_db_and_overrides():
  64. '''Prepare overrides and create _SITE_DB
  65. _SITE_DB.keys() need to be ready for filter_translations
  66. '''
  67. _SITE_DB.clear()
  68. _SITE_DB[_MAIN_LANG] = _MAIN_SITEURL
  69. # make sure it works for both root-relative and absolute
  70. main_siteurl = '/' if _MAIN_SITEURL == '' else _MAIN_SITEURL
  71. for lang, overrides in _SUBSITE_QUEUE.items():
  72. if 'SITEURL' not in overrides:
  73. overrides['SITEURL'] = posixpath.join(main_siteurl, lang)
  74. _SITE_DB[lang] = overrides['SITEURL']
  75. # default subsite hierarchy
  76. if 'OUTPUT_PATH' not in overrides:
  77. overrides['OUTPUT_PATH'] = os.path.join(
  78. _MAIN_SETTINGS['OUTPUT_PATH'], lang)
  79. if 'CACHE_PATH' not in overrides:
  80. overrides['CACHE_PATH'] = os.path.join(
  81. _MAIN_SETTINGS['CACHE_PATH'], lang)
  82. if 'STATIC_PATHS' not in overrides:
  83. overrides['STATIC_PATHS'] = []
  84. if ('THEME' not in overrides and 'THEME_STATIC_DIR' not in overrides and
  85. 'THEME_STATIC_PATHS' not in overrides):
  86. relpath = relpath_to_site(lang, _MAIN_LANG)
  87. overrides['THEME_STATIC_DIR'] = posixpath.join(
  88. relpath, _MAIN_SETTINGS['THEME_STATIC_DIR'])
  89. overrides['THEME_STATIC_PATHS'] = []
  90. if 'FEED_DOMAIN' not in overrides:
  91. overrides['FEED_DOMAIN'] = posixpath.join(main_siteurl, lang)
  92. # to change what is perceived as translations
  93. overrides['DEFAULT_LANG'] = lang
  94. def subscribe_filter_to_signals(settings):
  95. '''Subscribe content filter to requested signals'''
  96. for sig in settings.get('I18N_FILTER_SIGNALS', []):
  97. sig.connect(filter_contents_translations)
  98. def initialize_plugin(pelican_obj):
  99. '''Initialize plugin variables and Pelican settings'''
  100. if _MAIN_SETTINGS is None:
  101. initialize_dbs(pelican_obj.settings)
  102. subscribe_filter_to_signals(pelican_obj.settings)
  103. def get_site_path(url):
  104. '''Get the path component of an url, excludes siteurl
  105. also normalizes '' to '/' for relpath to work,
  106. otherwise it could be interpreted as a relative filesystem path
  107. '''
  108. path = urlparse(url).path
  109. if path == '':
  110. path = '/'
  111. return path
  112. def relpath_to_site(lang, target_lang):
  113. '''Get relative path from siteurl of lang to siteurl of base_lang
  114. the output is cached in _SITES_RELPATH_DB
  115. '''
  116. path = _SITES_RELPATH_DB.get((lang, target_lang), None)
  117. if path is None:
  118. siteurl = _SITE_DB.get(lang, _MAIN_SITEURL)
  119. target_siteurl = _SITE_DB.get(target_lang, _MAIN_SITEURL)
  120. path = posixpath.relpath(get_site_path(target_siteurl),
  121. get_site_path(siteurl))
  122. _SITES_RELPATH_DB[(lang, target_lang)] = path
  123. return path
  124. def save_generator(generator):
  125. '''Save the generator for later use
  126. initialize the removed content list
  127. '''
  128. _GENERATOR_DB[generator] = []
  129. def article2draft(article):
  130. '''Transform an Article to Draft'''
  131. draft = Draft(article._content, article.metadata, article.settings,
  132. article.source_path, article._context)
  133. draft.status = 'draft'
  134. return draft
  135. def page2hidden_page(page):
  136. '''Transform a Page to a hidden Page'''
  137. page.status = 'hidden'
  138. return page
  139. class GeneratorInspector(object):
  140. '''Inspector of generator instances'''
  141. generators_info = {
  142. ArticlesGenerator: {
  143. 'translations_lists': ['translations', 'drafts_translations'],
  144. 'contents_lists': [('articles', 'drafts')],
  145. 'hiding_func': article2draft,
  146. 'policy': 'I18N_UNTRANSLATED_ARTICLES',
  147. },
  148. PagesGenerator: {
  149. 'translations_lists': ['translations', 'hidden_translations'],
  150. 'contents_lists': [('pages', 'hidden_pages')],
  151. 'hiding_func': page2hidden_page,
  152. 'policy': 'I18N_UNTRANSLATED_PAGES',
  153. },
  154. }
  155. def __init__(self, generator):
  156. '''Identify the best known class of the generator instance
  157. The class '''
  158. self.generator = generator
  159. self.generators_info.update(generator.settings.get(
  160. 'I18N_GENERATORS_INFO', {}))
  161. for cls in generator.__class__.__mro__:
  162. if cls in self.generators_info:
  163. self.info = self.generators_info[cls]
  164. break
  165. else:
  166. self.info = {}
  167. def translations_lists(self):
  168. '''Iterator over lists of content translations'''
  169. return (getattr(self.generator, name) for name in
  170. self.info.get('translations_lists', []))
  171. def contents_list_pairs(self):
  172. '''Iterator over pairs of normal and hidden contents'''
  173. return (tuple(getattr(self.generator, name) for name in names)
  174. for names in self.info.get('contents_lists', []))
  175. def hiding_function(self):
  176. '''Function for transforming content to a hidden version'''
  177. hiding_func = self.info.get('hiding_func', lambda x: x)
  178. return hiding_func
  179. def untranslated_policy(self, default):
  180. '''Get the policy for untranslated content'''
  181. return self.generator.settings.get(self.info.get('policy', None),
  182. default)
  183. def all_contents(self):
  184. '''Iterator over all contents'''
  185. translations_iterator = chain(*self.translations_lists())
  186. return chain(translations_iterator,
  187. *(pair[i] for pair in self.contents_list_pairs()
  188. for i in (0, 1)))
  189. def filter_contents_translations(generator):
  190. '''Filter the content and translations lists of a generator
  191. Filters out
  192. 1) translations which will be generated in a different site
  193. 2) content that is not in the language of the currently
  194. generated site but in that of a different site, content in a
  195. language which has no site is generated always. The filtering
  196. method bay be modified by the respective untranslated policy
  197. '''
  198. inspector = GeneratorInspector(generator)
  199. current_lang = generator.settings['DEFAULT_LANG']
  200. langs_with_sites = _SITE_DB.keys()
  201. removed_contents = _GENERATOR_DB[generator]
  202. for translations in inspector.translations_lists():
  203. for translation in translations[:]: # copy to be able to remove
  204. if translation.lang in langs_with_sites:
  205. translations.remove(translation)
  206. removed_contents.append(translation)
  207. hiding_func = inspector.hiding_function()
  208. untrans_policy = inspector.untranslated_policy(default='hide')
  209. for (contents, other_contents) in inspector.contents_list_pairs():
  210. for content in other_contents: # save any hidden native content first
  211. if content.lang == current_lang: # in native lang
  212. # save the native URL attr formatted in the current locale
  213. _NATIVE_CONTENT_URL_DB[content.source_path] = content.url
  214. for content in contents[:]: # copy for removing in loop
  215. if content.lang == current_lang: # in native lang
  216. # save the native URL attr formatted in the current locale
  217. _NATIVE_CONTENT_URL_DB[content.source_path] = content.url
  218. elif content.lang in langs_with_sites and untrans_policy != 'keep':
  219. contents.remove(content)
  220. if untrans_policy == 'hide':
  221. other_contents.append(hiding_func(content))
  222. elif untrans_policy == 'remove':
  223. removed_contents.append(content)
  224. def install_templates_translations(generator):
  225. '''Install gettext translations in the jinja2.Environment
  226. Only if the 'jinja2.ext.i18n' jinja2 extension is enabled
  227. the translations for the current DEFAULT_LANG are installed.
  228. '''
  229. if 'JINJA_ENVIRONMENT' in generator.settings: # pelican 3.7+
  230. jinja_extensions = generator.settings['JINJA_ENVIRONMENT'].get(
  231. 'extensions', [])
  232. else:
  233. jinja_extensions = generator.settings['JINJA_EXTENSIONS']
  234. if 'jinja2.ext.i18n' in jinja_extensions:
  235. domain = generator.settings.get('I18N_GETTEXT_DOMAIN', 'messages')
  236. localedir = generator.settings.get('I18N_GETTEXT_LOCALEDIR')
  237. if localedir is None:
  238. localedir = os.path.join(generator.theme, 'translations')
  239. current_lang = generator.settings['DEFAULT_LANG']
  240. if current_lang == generator.settings.get('I18N_TEMPLATES_LANG',
  241. _MAIN_LANG):
  242. translations = gettext.NullTranslations()
  243. else:
  244. langs = [current_lang]
  245. try:
  246. translations = gettext.translation(domain, localedir, langs)
  247. except (IOError, OSError):
  248. _LOGGER.error((
  249. "Cannot find translations for language '{}' in '{}' with "
  250. "domain '{}'. Installing NullTranslations.").format(
  251. langs[0], localedir, domain))
  252. translations = gettext.NullTranslations()
  253. newstyle = generator.settings.get('I18N_GETTEXT_NEWSTYLE', True)
  254. generator.env.install_gettext_translations(translations, newstyle)
  255. def add_variables_to_context(generator):
  256. '''Adds useful iterable variables to template context'''
  257. context = generator.context # minimize attr lookup
  258. context['relpath_to_site'] = relpath_to_site
  259. context['main_siteurl'] = _MAIN_SITEURL
  260. context['main_lang'] = _MAIN_LANG
  261. context['lang_siteurls'] = _SITE_DB
  262. current_lang = generator.settings['DEFAULT_LANG']
  263. extra_siteurls = _SITE_DB.copy()
  264. extra_siteurls.pop(current_lang)
  265. context['extra_siteurls'] = extra_siteurls
  266. def interlink_translations(content):
  267. '''Link content to translations in their main language
  268. so the URL (including localized month names) of the different subsites
  269. will be honored
  270. '''
  271. lang = content.lang
  272. # sort translations by lang
  273. content.translations.sort(key=attrgetter('lang'))
  274. for translation in content.translations:
  275. relpath = relpath_to_site(lang, translation.lang)
  276. url = _NATIVE_CONTENT_URL_DB[translation.source_path]
  277. translation.override_url = posixpath.join(relpath, url)
  278. def interlink_translated_content(generator):
  279. '''Make translations link to the native locations
  280. for generators that may contain translated content
  281. '''
  282. inspector = GeneratorInspector(generator)
  283. for content in inspector.all_contents():
  284. interlink_translations(content)
  285. def interlink_removed_content(generator):
  286. '''For all contents removed from generation queue update interlinks
  287. link to the native location
  288. '''
  289. current_lang = generator.settings['DEFAULT_LANG']
  290. for content in _GENERATOR_DB[generator]:
  291. url = _NATIVE_CONTENT_URL_DB[content.source_path]
  292. relpath = relpath_to_site(current_lang, content.lang)
  293. content.override_url = posixpath.join(relpath, url)
  294. def interlink_static_files(generator):
  295. '''Add links to static files in the main site if necessary'''
  296. if generator.settings['STATIC_PATHS'] != []:
  297. return # customized STATIC_PATHS
  298. try: # minimize attr lookup
  299. static_content = generator.context['static_content']
  300. except KeyError:
  301. static_content = generator.context['filenames']
  302. relpath = relpath_to_site(generator.settings['DEFAULT_LANG'], _MAIN_LANG)
  303. for staticfile in _MAIN_STATIC_FILES:
  304. if staticfile.get_relative_source_path() not in static_content:
  305. staticfile = copy(staticfile) # prevent override in main site
  306. staticfile.override_url = posixpath.join(relpath, staticfile.url)
  307. try:
  308. generator.add_source_path(staticfile, static=True)
  309. except TypeError:
  310. generator.add_source_path(staticfile)
  311. def save_main_static_files(static_generator):
  312. '''Save the static files generated for the main site'''
  313. global _MAIN_STATIC_FILES
  314. # test just for current lang as settings change in autoreload mode
  315. if static_generator.settings['DEFAULT_LANG'] == _MAIN_LANG:
  316. _MAIN_STATIC_FILES = static_generator.staticfiles
  317. def update_generators():
  318. '''Update the context of all generators
  319. Ads useful variables and translations into the template context
  320. and interlink translations
  321. '''
  322. for generator in _GENERATOR_DB.keys():
  323. install_templates_translations(generator)
  324. add_variables_to_context(generator)
  325. interlink_static_files(generator)
  326. interlink_removed_content(generator)
  327. interlink_translated_content(generator)
  328. def get_pelican_cls(settings):
  329. '''Get the Pelican class requested in settings'''
  330. cls = settings['PELICAN_CLASS']
  331. if isinstance(cls, six.string_types):
  332. module, cls_name = cls.rsplit('.', 1)
  333. module = __import__(module)
  334. cls = getattr(module, cls_name)
  335. return cls
  336. def create_next_subsite(pelican_obj):
  337. '''Create the next subsite using the lang-specific config
  338. If there are no more subsites in the generation queue, update all
  339. the generators (interlink translations and removed content, add
  340. variables and translations to template context). Otherwise get the
  341. language and overrides for next the subsite in the queue and apply
  342. overrides. Then generate the subsite using a PELICAN_CLASS
  343. instance and its run method. Finally, restore the previous locale.
  344. '''
  345. global _MAIN_SETTINGS
  346. if len(_SUBSITE_QUEUE) == 0:
  347. _LOGGER.debug(
  348. 'i18n: Updating cross-site links and context of all generators.')
  349. update_generators()
  350. _MAIN_SETTINGS = None # to initialize next time
  351. else:
  352. with temporary_locale():
  353. settings = _MAIN_SETTINGS.copy()
  354. lang, overrides = _SUBSITE_QUEUE.popitem()
  355. settings.update(overrides)
  356. settings = configure_settings(settings) # to set LOCALE, etc.
  357. cls = get_pelican_cls(settings)
  358. new_pelican_obj = cls(settings)
  359. _LOGGER.debug(("Generating i18n subsite for language '{}' "
  360. "using class {}").format(lang, cls))
  361. new_pelican_obj.run()
  362. # map: signal name -> function name
  363. _SIGNAL_HANDLERS_DB = {
  364. 'get_generators': initialize_plugin,
  365. 'article_generator_pretaxonomy': filter_contents_translations,
  366. 'page_generator_finalized': filter_contents_translations,
  367. 'get_writer': create_next_subsite,
  368. 'static_generator_finalized': save_main_static_files,
  369. 'generator_init': save_generator,
  370. }
  371. def register():
  372. '''Register the plugin only if required signals are available'''
  373. for sig_name in _SIGNAL_HANDLERS_DB.keys():
  374. if not hasattr(signals, sig_name):
  375. _LOGGER.error((
  376. 'The i18n_subsites plugin requires the {} '
  377. 'signal available for sure in Pelican 3.4.0 and later, '
  378. 'plugin will not be used.').format(sig_name))
  379. return
  380. for sig_name, handler in _SIGNAL_HANDLERS_DB.items():
  381. sig = getattr(signals, sig_name)
  382. sig.connect(handler)