From 3a0d66f07b112b6d2bdc2b57bbf717a89a351ce6 Mon Sep 17 00:00:00 2001 From: Unit 193 Date: Wed, 3 Feb 2021 19:17:00 -0500 Subject: New upstream version 8.1.2. --- nikola/__init__.py | 10 +- nikola/__main__.py | 185 +- nikola/conf.py.in | 768 ++- .../samplesite/galleries/demo/metadata.sample.yml | 13 + nikola/data/samplesite/images/frontispiece.jpg | Bin 0 -> 80761 bytes nikola/data/samplesite/images/illus_001.jpg | Bin 0 -> 156895 bytes nikola/data/samplesite/listings/hello.py | 1 + nikola/data/samplesite/pages/1.rst | 11 + nikola/data/samplesite/pages/bootstrap-demo.rst | 1047 ++++ nikola/data/samplesite/pages/charts.rst | 59 + nikola/data/samplesite/pages/creating-a-theme.rst | 1 + .../data/samplesite/pages/dr-nikolas-vendetta.rst | 468 ++ nikola/data/samplesite/pages/extending.rst | 1 + nikola/data/samplesite/pages/internals.rst | 1 + nikola/data/samplesite/pages/listings-demo.rst | 10 + nikola/data/samplesite/pages/manual.rst | 1 + nikola/data/samplesite/pages/path_handlers.rst | 1 + nikola/data/samplesite/pages/quickref.rst | 1348 +++++ nikola/data/samplesite/pages/quickstart.rst | 410 ++ nikola/data/samplesite/pages/social_buttons.rst | 1 + nikola/data/samplesite/pages/theming.rst | 1 + nikola/data/samplesite/posts/1.rst | 7 +- nikola/data/samplesite/stories/1.rst | 11 - .../data/samplesite/stories/a-study-in-scarlet.txt | 5139 -------------------- nikola/data/samplesite/stories/bootstrap-demo.rst | 1047 ---- nikola/data/samplesite/stories/charts.txt | 59 - .../data/samplesite/stories/creating-a-theme.rst | 1 - nikola/data/samplesite/stories/extending.txt | 1 - nikola/data/samplesite/stories/internals.txt | 1 - nikola/data/samplesite/stories/listings-demo.rst | 10 - nikola/data/samplesite/stories/manual.rst | 1 - nikola/data/samplesite/stories/quickref.rst | 1347 ----- nikola/data/samplesite/stories/quickstart.rst | 413 -- nikola/data/samplesite/stories/slides-demo.rst | 17 - nikola/data/samplesite/stories/social_buttons.txt | 1 - nikola/data/samplesite/stories/theming.rst | 1 - nikola/data/samplesite/templates/book.tmpl | 117 + nikola/data/shortcodes/jinja/raw.tmpl | 1 + nikola/data/shortcodes/mako/raw.tmpl | 1 + nikola/data/symlinked.txt | 167 +- nikola/data/themes/base-jinja/AUTHORS.txt | 1 - nikola/data/themes/base-jinja/base-jinja.theme | 10 + nikola/data/themes/base-jinja/bundles | 2 - nikola/data/themes/base-jinja/engine | 1 - nikola/data/themes/base-jinja/parent | 1 - .../data/themes/base-jinja/templates/archive.tmpl | 1 + .../templates/archive_navigation_helper.tmpl | 27 + .../themes/base-jinja/templates/archiveindex.tmpl | 21 +- .../data/themes/base-jinja/templates/author.tmpl | 28 + .../themes/base-jinja/templates/authorindex.tmpl | 21 + .../data/themes/base-jinja/templates/authors.tmpl | 25 + nikola/data/themes/base-jinja/templates/base.tmpl | 25 +- .../themes/base-jinja/templates/base_footer.tmpl | 1 - .../themes/base-jinja/templates/base_header.tmpl | 25 +- .../themes/base-jinja/templates/base_helper.tmpl | 91 +- .../base-jinja/templates/comments_helper.tmpl | 28 +- .../templates/comments_helper_commento.tmpl | 13 + .../templates/comments_helper_disqus.tmpl | 10 +- .../templates/comments_helper_facebook.tmpl | 4 +- .../templates/comments_helper_googleplus.tmpl | 17 - .../templates/comments_helper_intensedebate.tmpl | 4 +- .../base-jinja/templates/comments_helper_isso.tmpl | 16 +- .../templates/comments_helper_livefyre.tmpl | 33 - .../templates/comments_helper_mustache.tmpl | 5 - .../base-jinja/templates/comments_helper_muut.tmpl | 2 +- .../templates/comments_helper_utterances.tmpl | 23 + .../data/themes/base-jinja/templates/crumbs.tmpl | 19 - .../templates/feeds_translations_helper.tmpl | 124 + .../data/themes/base-jinja/templates/gallery.tmpl | 82 +- nikola/data/themes/base-jinja/templates/index.tmpl | 40 +- .../themes/base-jinja/templates/index_helper.tmpl | 8 +- nikola/data/themes/base-jinja/templates/list.tmpl | 17 +- .../themes/base-jinja/templates/list_post.tmpl | 12 +- .../data/themes/base-jinja/templates/listing.tmpl | 17 +- .../themes/base-jinja/templates/math_helper.tmpl | 69 + nikola/data/themes/base-jinja/templates/page.tmpl | 1 + .../base-jinja/templates/pagination_helper.tmpl | 16 + nikola/data/themes/base-jinja/templates/post.tmpl | 11 +- .../themes/base-jinja/templates/post_header.tmpl | 29 +- .../themes/base-jinja/templates/post_helper.tmpl | 46 +- .../base-jinja/templates/post_list_directive.tmpl | 8 +- .../data/themes/base-jinja/templates/slides.tmpl | 24 - nikola/data/themes/base-jinja/templates/story.tmpl | 5 +- nikola/data/themes/base-jinja/templates/tag.tmpl | 36 +- .../data/themes/base-jinja/templates/tagindex.tmpl | 35 +- nikola/data/themes/base-jinja/templates/tags.tmpl | 12 +- .../themes/base-jinja/templates/ui_helper.tmpl | 18 + .../themes/base/assets/css/baguetteBox.min.css | 1 + nikola/data/themes/base/assets/css/html4css1.css | 1 + nikola/data/themes/base/assets/css/ipython.min.css | 9 + .../data/themes/base/assets/css/nikola_ipython.css | 70 +- nikola/data/themes/base/assets/css/nikola_rst.css | 79 + nikola/data/themes/base/assets/css/rst.css | 332 +- nikola/data/themes/base/assets/css/rst_base.css | 474 ++ nikola/data/themes/base/assets/css/theme.css | 79 +- .../data/themes/base/assets/js/baguetteBox.min.js | 1 + nikola/data/themes/base/assets/js/fancydates.js | 22 +- .../data/themes/base/assets/js/fancydates.min.js | 1 + nikola/data/themes/base/assets/js/gallery.js | 32 + nikola/data/themes/base/assets/js/gallery.min.js | 1 + nikola/data/themes/base/assets/js/html5.js | 9 +- .../base/assets/js/html5shiv-printshiv.min.js | 1 + .../themes/base/assets/js/justified-layout.min.js | 1 + nikola/data/themes/base/assets/js/luxon.min.js | 1 + nikola/data/themes/base/assets/js/mathjax.js | 11 - nikola/data/themes/base/assets/xml/atom.xsl | 2 +- nikola/data/themes/base/assets/xml/rss.xsl | 2 +- nikola/data/themes/base/base.theme | 9 + nikola/data/themes/base/bundles | 21 +- nikola/data/themes/base/engine | 1 - nikola/data/themes/base/messages/messages_af.py | 49 + nikola/data/themes/base/messages/messages_ar.py | 42 +- nikola/data/themes/base/messages/messages_az.py | 24 +- nikola/data/themes/base/messages/messages_bg.py | 48 +- nikola/data/themes/base/messages/messages_br.py | 49 + nikola/data/themes/base/messages/messages_bs.py | 26 +- nikola/data/themes/base/messages/messages_ca.py | 38 +- nikola/data/themes/base/messages/messages_cs.py | 16 +- nikola/data/themes/base/messages/messages_da.py | 16 +- nikola/data/themes/base/messages/messages_de.py | 22 +- nikola/data/themes/base/messages/messages_el.py | 16 +- nikola/data/themes/base/messages/messages_en.py | 16 +- nikola/data/themes/base/messages/messages_eo.py | 54 +- nikola/data/themes/base/messages/messages_es.py | 20 +- nikola/data/themes/base/messages/messages_et.py | 40 +- nikola/data/themes/base/messages/messages_eu.py | 62 +- nikola/data/themes/base/messages/messages_fa.py | 24 +- nikola/data/themes/base/messages/messages_fi.py | 44 +- nikola/data/themes/base/messages/messages_fil.py | 39 - nikola/data/themes/base/messages/messages_fr.py | 26 +- nikola/data/themes/base/messages/messages_fur.py | 49 + nikola/data/themes/base/messages/messages_gl.py | 80 +- nikola/data/themes/base/messages/messages_he.py | 49 + nikola/data/themes/base/messages/messages_hi.py | 54 +- nikola/data/themes/base/messages/messages_hr.py | 20 +- nikola/data/themes/base/messages/messages_hu.py | 49 + nikola/data/themes/base/messages/messages_ia.py | 49 + nikola/data/themes/base/messages/messages_id.py | 24 +- nikola/data/themes/base/messages/messages_it.py | 18 +- nikola/data/themes/base/messages/messages_ja.py | 58 +- nikola/data/themes/base/messages/messages_ko.py | 30 +- nikola/data/themes/base/messages/messages_lt.py | 49 + nikola/data/themes/base/messages/messages_mi.py | 49 + nikola/data/themes/base/messages/messages_ml.py | 49 + nikola/data/themes/base/messages/messages_mr.py | 49 + nikola/data/themes/base/messages/messages_nb.py | 16 +- nikola/data/themes/base/messages/messages_nl.py | 20 +- nikola/data/themes/base/messages/messages_pa.py | 20 +- nikola/data/themes/base/messages/messages_pl.py | 16 +- nikola/data/themes/base/messages/messages_pt.py | 16 +- nikola/data/themes/base/messages/messages_pt_br.py | 24 +- nikola/data/themes/base/messages/messages_ru.py | 24 +- nikola/data/themes/base/messages/messages_si_lk.py | 39 - nikola/data/themes/base/messages/messages_sk.py | 16 +- nikola/data/themes/base/messages/messages_sl.py | 16 +- nikola/data/themes/base/messages/messages_sq.py | 49 + nikola/data/themes/base/messages/messages_sr.py | 16 +- .../data/themes/base/messages/messages_sr@latin.py | 39 - .../data/themes/base/messages/messages_sr_latin.py | 49 + nikola/data/themes/base/messages/messages_sv.py | 16 +- nikola/data/themes/base/messages/messages_te.py | 49 + nikola/data/themes/base/messages/messages_th.py | 49 + nikola/data/themes/base/messages/messages_tl.py | 39 - nikola/data/themes/base/messages/messages_tr.py | 26 +- nikola/data/themes/base/messages/messages_uk.py | 30 +- nikola/data/themes/base/messages/messages_ur.py | 18 +- nikola/data/themes/base/messages/messages_vi.py | 49 + nikola/data/themes/base/messages/messages_zh_cn.py | 64 +- nikola/data/themes/base/messages/messages_zh_tw.py | 80 +- nikola/data/themes/base/templates/archive.tmpl | 1 + .../base/templates/archive_navigation_helper.tmpl | 27 + .../data/themes/base/templates/archiveindex.tmpl | 21 +- nikola/data/themes/base/templates/author.tmpl | 28 + nikola/data/themes/base/templates/authorindex.tmpl | 21 + nikola/data/themes/base/templates/authors.tmpl | 25 + nikola/data/themes/base/templates/base.tmpl | 25 +- nikola/data/themes/base/templates/base_footer.tmpl | 1 - nikola/data/themes/base/templates/base_header.tmpl | 25 +- nikola/data/themes/base/templates/base_helper.tmpl | 91 +- .../themes/base/templates/comments_helper.tmpl | 28 +- .../base/templates/comments_helper_commento.tmpl | 13 + .../base/templates/comments_helper_disqus.tmpl | 10 +- .../base/templates/comments_helper_facebook.tmpl | 4 +- .../base/templates/comments_helper_googleplus.tmpl | 17 - .../templates/comments_helper_intensedebate.tmpl | 4 +- .../base/templates/comments_helper_isso.tmpl | 16 +- .../base/templates/comments_helper_livefyre.tmpl | 33 - .../base/templates/comments_helper_mustache.tmpl | 5 - .../base/templates/comments_helper_muut.tmpl | 2 +- .../base/templates/comments_helper_utterances.tmpl | 23 + nikola/data/themes/base/templates/crumbs.tmpl | 19 - .../base/templates/feeds_translations_helper.tmpl | 124 + nikola/data/themes/base/templates/gallery.tmpl | 84 +- nikola/data/themes/base/templates/index.tmpl | 40 +- .../data/themes/base/templates/index_helper.tmpl | 8 +- nikola/data/themes/base/templates/list.tmpl | 17 +- nikola/data/themes/base/templates/list_post.tmpl | 12 +- nikola/data/themes/base/templates/listing.tmpl | 17 +- nikola/data/themes/base/templates/math_helper.tmpl | 69 + nikola/data/themes/base/templates/page.tmpl | 1 + .../themes/base/templates/pagination_helper.tmpl | 16 + nikola/data/themes/base/templates/post.tmpl | 11 +- nikola/data/themes/base/templates/post_header.tmpl | 29 +- nikola/data/themes/base/templates/post_helper.tmpl | 46 +- .../themes/base/templates/post_list_directive.tmpl | 8 +- nikola/data/themes/base/templates/slides.tmpl | 24 - nikola/data/themes/base/templates/story.tmpl | 5 +- nikola/data/themes/base/templates/tag.tmpl | 36 +- nikola/data/themes/base/templates/tagindex.tmpl | 35 +- nikola/data/themes/base/templates/tags.tmpl | 12 +- nikola/data/themes/base/templates/ui_helper.tmpl | 18 + nikola/data/themes/bootblog4-jinja/README.md | 6 + .../themes/bootblog4-jinja/assets/css/bootblog.css | 1 + .../themes/bootblog4-jinja/bootblog4-jinja.theme | 12 + nikola/data/themes/bootblog4-jinja/bundles | 1 + .../themes/bootblog4-jinja/templates/base.tmpl | 104 + .../bootblog4-jinja/templates/base_helper.tmpl | 169 + .../themes/bootblog4-jinja/templates/index.tmpl | 150 + nikola/data/themes/bootblog4/README.md | 6 + .../data/themes/bootblog4/assets/css/bootblog.css | 225 + nikola/data/themes/bootblog4/bootblog4.theme | 12 + nikola/data/themes/bootblog4/bundles | 28 + nikola/data/themes/bootblog4/templates/base.tmpl | 104 + .../themes/bootblog4/templates/base_helper.tmpl | 169 + nikola/data/themes/bootblog4/templates/index.tmpl | 150 + nikola/data/themes/bootstrap3-jinja/AUTHORS.txt | 1 - nikola/data/themes/bootstrap3-jinja/README.md | 8 - .../assets/css/bootstrap-theme.css.map | 1 - .../bootstrap3-jinja/assets/css/bootstrap.css.map | 1 - .../bootstrap3-jinja/assets/css/colorbox.css | 1 - .../themes/bootstrap3-jinja/assets/css/docs.css | 160 - .../assets/css/images/controls.png | 1 - .../assets/css/images/ie6/borderBottomCenter.png | Bin 111 -> 0 bytes .../assets/css/images/ie6/borderBottomLeft.png | Bin 215 -> 0 bytes .../assets/css/images/ie6/borderBottomRight.png | Bin 217 -> 0 bytes .../assets/css/images/ie6/borderMiddleLeft.png | Bin 108 -> 0 bytes .../assets/css/images/ie6/borderMiddleRight.png | Bin 108 -> 0 bytes .../assets/css/images/ie6/borderTopCenter.png | Bin 111 -> 0 bytes .../assets/css/images/ie6/borderTopLeft.png | Bin 216 -> 0 bytes .../assets/css/images/ie6/borderTopRight.png | Bin 214 -> 0 bytes .../bootstrap3-jinja/assets/css/images/loading.gif | 1 - .../themes/bootstrap3-jinja/assets/css/theme.css | 207 - .../assets/fonts/glyphicons-halflings-regular.eot | 1 - .../assets/fonts/glyphicons-halflings-regular.svg | 1 - .../assets/fonts/glyphicons-halflings-regular.ttf | 1 - .../assets/fonts/glyphicons-halflings-regular.woff | 1 - .../fonts/glyphicons-halflings-regular.woff2 | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-ar.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-bg.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-bn.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-ca.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-cs.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-da.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-de.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-es.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-et.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-fa.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-fi.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-fr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-gl.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-gr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-he.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-hr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-hu.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-id.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-it.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-ja.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-kr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-lt.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-lv.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-my.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-nl.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-no.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-pl.js | 1 - .../js/colorbox-i18n/jquery.colorbox-pt-BR.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-ro.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-ru.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-si.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-sk.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-sr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-sv.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-tr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-uk.js | 1 - .../js/colorbox-i18n/jquery.colorbox-zh-CN.js | 1 - .../js/colorbox-i18n/jquery.colorbox-zh-TW.js | 1 - .../bootstrap3-jinja/assets/js/flowr.plugin.js | 265 - .../bootstrap3-jinja/assets/js/jquery.colorbox.js | 1 - .../themes/bootstrap3-jinja/assets/js/jquery.js | 1 - nikola/data/themes/bootstrap3-jinja/bundles | 1 - nikola/data/themes/bootstrap3-jinja/engine | 1 - nikola/data/themes/bootstrap3-jinja/parent | 1 - .../themes/bootstrap3-jinja/templates/base.tmpl | 94 - .../bootstrap3-jinja/templates/base_helper.tmpl | 184 - .../themes/bootstrap3-jinja/templates/gallery.tmpl | 94 - .../themes/bootstrap3-jinja/templates/listing.tmpl | 28 - .../themes/bootstrap3-jinja/templates/post.tmpl | 59 - .../themes/bootstrap3-jinja/templates/slides.tmpl | 24 - .../themes/bootstrap3-jinja/templates/tags.tmpl | 38 - nikola/data/themes/bootstrap3/README.md | 8 - .../bootstrap3/assets/css/bootstrap-theme.css.map | 1 - .../themes/bootstrap3/assets/css/bootstrap.css.map | 1 - .../data/themes/bootstrap3/assets/css/colorbox.css | 1 - nikola/data/themes/bootstrap3/assets/css/docs.css | 160 - .../bootstrap3/assets/css/images/controls.png | 1 - .../assets/css/images/ie6/borderBottomCenter.png | Bin 111 -> 0 bytes .../assets/css/images/ie6/borderBottomLeft.png | Bin 215 -> 0 bytes .../assets/css/images/ie6/borderBottomRight.png | Bin 217 -> 0 bytes .../assets/css/images/ie6/borderMiddleLeft.png | Bin 108 -> 0 bytes .../assets/css/images/ie6/borderMiddleRight.png | Bin 108 -> 0 bytes .../assets/css/images/ie6/borderTopCenter.png | Bin 111 -> 0 bytes .../assets/css/images/ie6/borderTopLeft.png | Bin 216 -> 0 bytes .../assets/css/images/ie6/borderTopRight.png | Bin 214 -> 0 bytes .../bootstrap3/assets/css/images/loading.gif | 1 - nikola/data/themes/bootstrap3/assets/css/theme.css | 207 - .../assets/fonts/glyphicons-halflings-regular.eot | 1 - .../assets/fonts/glyphicons-halflings-regular.svg | 1 - .../assets/fonts/glyphicons-halflings-regular.ttf | 1 - .../assets/fonts/glyphicons-halflings-regular.woff | 1 - .../fonts/glyphicons-halflings-regular.woff2 | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-ar.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-bg.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-bn.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-ca.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-cs.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-da.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-de.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-es.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-et.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-fa.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-fi.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-fr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-gl.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-gr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-he.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-hr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-hu.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-id.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-it.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-ja.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-kr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-lt.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-lv.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-my.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-nl.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-no.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-pl.js | 1 - .../js/colorbox-i18n/jquery.colorbox-pt-BR.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-ro.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-ru.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-si.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-sk.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-sr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-sv.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-tr.js | 1 - .../assets/js/colorbox-i18n/jquery.colorbox-uk.js | 1 - .../js/colorbox-i18n/jquery.colorbox-zh-CN.js | 1 - .../js/colorbox-i18n/jquery.colorbox-zh-TW.js | 1 - .../themes/bootstrap3/assets/js/flowr.plugin.js | 265 - .../themes/bootstrap3/assets/js/jquery.colorbox.js | 1 - nikola/data/themes/bootstrap3/assets/js/jquery.js | 1 - nikola/data/themes/bootstrap3/bundles | 4 - nikola/data/themes/bootstrap3/engine | 1 - nikola/data/themes/bootstrap3/parent | 1 - nikola/data/themes/bootstrap3/templates/base.tmpl | 94 - .../themes/bootstrap3/templates/base_helper.tmpl | 184 - .../data/themes/bootstrap3/templates/gallery.tmpl | 94 - .../data/themes/bootstrap3/templates/listing.tmpl | 28 - nikola/data/themes/bootstrap3/templates/post.tmpl | 59 - .../data/themes/bootstrap3/templates/slides.tmpl | 24 - nikola/data/themes/bootstrap3/templates/tags.tmpl | 38 - nikola/data/themes/bootstrap4-jinja/README.md | 10 + .../bootstrap4-jinja/assets/css/bootstrap.min.css | 1 + .../themes/bootstrap4-jinja/assets/css/theme.css | 232 + .../bootstrap4-jinja/assets/js/bootstrap.min.js | 1 + .../bootstrap4-jinja/assets/js/jquery.min.js | 1 + .../bootstrap4-jinja/assets/js/popper.min.js | 1 + .../themes/bootstrap4-jinja/bootstrap4-jinja.theme | 12 + nikola/data/themes/bootstrap4-jinja/bundles | 1 + .../themes/bootstrap4-jinja/templates/authors.tmpl | 25 + .../themes/bootstrap4-jinja/templates/base.tmpl | 105 + .../bootstrap4-jinja/templates/base_helper.tmpl | 165 + .../bootstrap4-jinja/templates/index_helper.tmpl | 13 + .../themes/bootstrap4-jinja/templates/listing.tmpl | 30 + .../templates/pagination_helper.tmpl | 40 + .../themes/bootstrap4-jinja/templates/post.tmpl | 57 + .../themes/bootstrap4-jinja/templates/tags.tmpl | 38 + .../bootstrap4-jinja/templates/ui_helper.tmpl | 24 + nikola/data/themes/bootstrap4/README.md | 10 + .../themes/bootstrap4/assets/css/bootstrap.min.css | 1 + nikola/data/themes/bootstrap4/assets/css/theme.css | 232 + .../themes/bootstrap4/assets/js/bootstrap.min.js | 1 + .../data/themes/bootstrap4/assets/js/jquery.min.js | 1 + .../data/themes/bootstrap4/assets/js/popper.min.js | 1 + nikola/data/themes/bootstrap4/bootstrap4.theme | 12 + nikola/data/themes/bootstrap4/bundles | 26 + .../data/themes/bootstrap4/templates/authors.tmpl | 25 + nikola/data/themes/bootstrap4/templates/base.tmpl | 105 + .../themes/bootstrap4/templates/base_helper.tmpl | 165 + .../themes/bootstrap4/templates/index_helper.tmpl | 13 + .../data/themes/bootstrap4/templates/listing.tmpl | 30 + .../bootstrap4/templates/pagination_helper.tmpl | 40 + nikola/data/themes/bootstrap4/templates/post.tmpl | 57 + nikola/data/themes/bootstrap4/templates/tags.tmpl | 38 + .../themes/bootstrap4/templates/ui_helper.tmpl | 24 + nikola/filters.py | 356 +- nikola/hierarchy_utils.py | 274 ++ nikola/image_processing.py | 215 +- nikola/log.py | 152 + nikola/metadata_extractors.py | 274 ++ nikola/nikola.py | 2108 +++++--- nikola/packages/README.md | 7 +- nikola/packages/datecond/LICENSE | 30 + nikola/packages/datecond/__init__.py | 96 + nikola/packages/pygments_better_html/LICENSE | 30 + .../packages/pygments_better_html/LICENSE.pygments | 25 + nikola/packages/pygments_better_html/__init__.py | 241 + nikola/packages/tzlocal/__init__.py | 9 +- nikola/packages/tzlocal/darwin.py | 43 - nikola/packages/tzlocal/unix.py | 184 +- nikola/packages/tzlocal/win32.py | 31 +- nikola/packages/tzlocal/windows_tz.py | 1226 +++-- nikola/plugin_categories.py | 581 ++- nikola/plugins/__init__.py | 2 - nikola/plugins/basic_import.py | 61 +- nikola/plugins/command/__init__.py | 2 +- nikola/plugins/command/auto.plugin | 4 +- nikola/plugins/command/auto/__init__.py | 695 +-- nikola/plugins/command/auto/livereload.js | 2 +- nikola/plugins/command/bootswatch_theme.plugin | 13 - nikola/plugins/command/bootswatch_theme.py | 106 - nikola/plugins/command/check.plugin | 4 +- nikola/plugins/command/check.py | 228 +- nikola/plugins/command/console.plugin | 4 +- nikola/plugins/command/console.py | 52 +- nikola/plugins/command/default_config.plugin | 13 + nikola/plugins/command/default_config.py | 54 + nikola/plugins/command/deploy.plugin | 4 +- nikola/plugins/command/deploy.py | 76 +- nikola/plugins/command/github_deploy.plugin | 4 +- nikola/plugins/command/github_deploy.py | 129 +- nikola/plugins/command/import_wordpress.plugin | 4 +- nikola/plugins/command/import_wordpress.py | 480 +- nikola/plugins/command/init.plugin | 4 +- nikola/plugins/command/init.py | 124 +- nikola/plugins/command/install_theme.plugin | 13 - nikola/plugins/command/install_theme.py | 172 - nikola/plugins/command/new_page.plugin | 4 +- nikola/plugins/command/new_page.py | 5 +- nikola/plugins/command/new_post.plugin | 4 +- nikola/plugins/command/new_post.py | 140 +- nikola/plugins/command/orphans.plugin | 4 +- nikola/plugins/command/orphans.py | 4 +- nikola/plugins/command/plugin.plugin | 4 +- nikola/plugins/command/plugin.py | 129 +- nikola/plugins/command/rst2html.plugin | 4 +- nikola/plugins/command/rst2html/__init__.py | 14 +- nikola/plugins/command/serve.plugin | 4 +- nikola/plugins/command/serve.py | 95 +- nikola/plugins/command/status.plugin | 2 +- nikola/plugins/command/status.py | 60 +- nikola/plugins/command/subtheme.plugin | 13 + nikola/plugins/command/subtheme.py | 150 + nikola/plugins/command/theme.plugin | 13 + nikola/plugins/command/theme.py | 393 ++ nikola/plugins/command/version.plugin | 4 +- nikola/plugins/command/version.py | 18 +- nikola/plugins/compile/__init__.py | 2 +- nikola/plugins/compile/html.plugin | 4 +- nikola/plugins/compile/html.py | 79 +- nikola/plugins/compile/ipynb.plugin | 6 +- nikola/plugins/compile/ipynb.py | 188 +- nikola/plugins/compile/markdown.plugin | 4 +- nikola/plugins/compile/markdown/__init__.py | 132 +- nikola/plugins/compile/markdown/mdx_gist.plugin | 4 +- nikola/plugins/compile/markdown/mdx_gist.py | 180 +- nikola/plugins/compile/markdown/mdx_nikola.plugin | 4 +- nikola/plugins/compile/markdown/mdx_nikola.py | 35 +- nikola/plugins/compile/markdown/mdx_podcast.plugin | 4 +- nikola/plugins/compile/markdown/mdx_podcast.py | 16 +- nikola/plugins/compile/pandoc.plugin | 4 +- nikola/plugins/compile/pandoc.py | 31 +- nikola/plugins/compile/php.plugin | 4 +- nikola/plugins/compile/php.py | 23 +- nikola/plugins/compile/rest.plugin | 6 +- nikola/plugins/compile/rest/__init__.py | 277 +- nikola/plugins/compile/rest/chart.plugin | 4 +- nikola/plugins/compile/rest/chart.py | 91 +- nikola/plugins/compile/rest/doc.plugin | 4 +- nikola/plugins/compile/rest/doc.py | 81 +- nikola/plugins/compile/rest/gist.plugin | 4 +- nikola/plugins/compile/rest/gist.py | 4 +- nikola/plugins/compile/rest/listing.plugin | 4 +- nikola/plugins/compile/rest/listing.py | 42 +- nikola/plugins/compile/rest/media.plugin | 4 +- nikola/plugins/compile/rest/media.py | 30 +- nikola/plugins/compile/rest/post_list.plugin | 8 +- nikola/plugins/compile/rest/post_list.py | 183 +- nikola/plugins/compile/rest/slides.plugin | 14 - nikola/plugins/compile/rest/slides.py | 80 - nikola/plugins/compile/rest/soundcloud.plugin | 4 +- nikola/plugins/compile/rest/soundcloud.py | 46 +- nikola/plugins/compile/rest/thumbnail.plugin | 4 +- nikola/plugins/compile/rest/thumbnail.py | 12 +- nikola/plugins/compile/rest/vimeo.plugin | 2 +- nikola/plugins/compile/rest/vimeo.py | 25 +- nikola/plugins/compile/rest/youtube.plugin | 2 +- nikola/plugins/compile/rest/youtube.py | 33 +- nikola/plugins/misc/__init__.py | 2 +- nikola/plugins/misc/scan_posts.plugin | 2 +- nikola/plugins/misc/scan_posts.py | 46 +- nikola/plugins/misc/taxonomies_classifier.plugin | 12 + nikola/plugins/misc/taxonomies_classifier.py | 335 ++ nikola/plugins/shortcode/chart.plugin | 13 + nikola/plugins/shortcode/chart.py | 90 + nikola/plugins/shortcode/emoji.plugin | 13 + nikola/plugins/shortcode/emoji/__init__.py | 46 + nikola/plugins/shortcode/emoji/data/Activity.json | 418 ++ nikola/plugins/shortcode/emoji/data/Flags.json | 998 ++++ nikola/plugins/shortcode/emoji/data/Food.json | 274 ++ nikola/plugins/shortcode/emoji/data/LICENSE | 25 + nikola/plugins/shortcode/emoji/data/Nature.json | 594 +++ nikola/plugins/shortcode/emoji/data/Objects.json | 718 +++ nikola/plugins/shortcode/emoji/data/People.json | 1922 ++++++++ nikola/plugins/shortcode/emoji/data/Symbols.json | 1082 +++++ nikola/plugins/shortcode/emoji/data/Travel.json | 466 ++ nikola/plugins/shortcode/gist.plugin | 13 + nikola/plugins/shortcode/gist.py | 50 + nikola/plugins/shortcode/listing.plugin | 13 + nikola/plugins/shortcode/listing.py | 77 + nikola/plugins/shortcode/post_list.plugin | 13 + nikola/plugins/shortcode/post_list.py | 245 + nikola/plugins/shortcode/thumbnail.plugin | 12 + nikola/plugins/shortcode/thumbnail.py | 69 + nikola/plugins/task/__init__.py | 2 +- nikola/plugins/task/archive.plugin | 6 +- nikola/plugins/task/archive.py | 404 +- nikola/plugins/task/authors.plugin | 12 + nikola/plugins/task/authors.py | 159 + nikola/plugins/task/bundles.plugin | 6 +- nikola/plugins/task/bundles.py | 89 +- nikola/plugins/task/categories.plugin | 12 + nikola/plugins/task/categories.py | 248 + nikola/plugins/task/copy_assets.plugin | 4 +- nikola/plugins/task/copy_assets.py | 47 +- nikola/plugins/task/copy_files.plugin | 4 +- nikola/plugins/task/copy_files.py | 3 +- nikola/plugins/task/galleries.plugin | 4 +- nikola/plugins/task/galleries.py | 324 +- nikola/plugins/task/gzip.plugin | 4 +- nikola/plugins/task/gzip.py | 3 +- nikola/plugins/task/indexes.plugin | 7 +- nikola/plugins/task/indexes.py | 223 +- nikola/plugins/task/listings.plugin | 4 +- nikola/plugins/task/listings.py | 121 +- nikola/plugins/task/page_index.plugin | 12 + nikola/plugins/task/page_index.py | 111 + nikola/plugins/task/pages.plugin | 4 +- nikola/plugins/task/pages.py | 23 +- nikola/plugins/task/posts.plugin | 4 +- nikola/plugins/task/posts.py | 23 +- nikola/plugins/task/redirect.plugin | 4 +- nikola/plugins/task/redirect.py | 9 +- nikola/plugins/task/robots.plugin | 4 +- nikola/plugins/task/robots.py | 19 +- nikola/plugins/task/rss.plugin | 13 - nikola/plugins/task/rss.py | 111 - nikola/plugins/task/scale_images.plugin | 4 +- nikola/plugins/task/scale_images.py | 35 +- nikola/plugins/task/sitemap.plugin | 4 +- nikola/plugins/task/sitemap.py | 318 ++ nikola/plugins/task/sitemap/__init__.py | 320 -- nikola/plugins/task/sources.plugin | 4 +- nikola/plugins/task/sources.py | 11 +- nikola/plugins/task/tags.plugin | 7 +- nikola/plugins/task/tags.py | 502 +- nikola/plugins/task/taxonomies.plugin | 12 + nikola/plugins/task/taxonomies.py | 459 ++ nikola/plugins/template/__init__.py | 2 +- nikola/plugins/template/jinja.plugin | 4 +- nikola/plugins/template/jinja.py | 90 +- nikola/plugins/template/mako.plugin | 4 +- nikola/plugins/template/mako.py | 61 +- nikola/post.py | 1030 ++-- nikola/rc4.py | 84 - nikola/shortcodes.py | 387 ++ nikola/state.py | 87 + nikola/utils.py | 1296 +++-- nikola/winutils.py | 9 +- 588 files changed, 29711 insertions(+), 18657 deletions(-) create mode 100644 nikola/data/samplesite/galleries/demo/metadata.sample.yml create mode 100644 nikola/data/samplesite/images/frontispiece.jpg create mode 100644 nikola/data/samplesite/images/illus_001.jpg create mode 100644 nikola/data/samplesite/pages/1.rst create mode 100644 nikola/data/samplesite/pages/bootstrap-demo.rst create mode 100644 nikola/data/samplesite/pages/charts.rst create mode 120000 nikola/data/samplesite/pages/creating-a-theme.rst create mode 100644 nikola/data/samplesite/pages/dr-nikolas-vendetta.rst create mode 120000 nikola/data/samplesite/pages/extending.rst create mode 120000 nikola/data/samplesite/pages/internals.rst create mode 100644 nikola/data/samplesite/pages/listings-demo.rst create mode 120000 nikola/data/samplesite/pages/manual.rst create mode 120000 nikola/data/samplesite/pages/path_handlers.rst create mode 100644 nikola/data/samplesite/pages/quickref.rst create mode 100644 nikola/data/samplesite/pages/quickstart.rst create mode 120000 nikola/data/samplesite/pages/social_buttons.rst create mode 120000 nikola/data/samplesite/pages/theming.rst delete mode 100644 nikola/data/samplesite/stories/1.rst delete mode 100644 nikola/data/samplesite/stories/a-study-in-scarlet.txt delete mode 100644 nikola/data/samplesite/stories/bootstrap-demo.rst delete mode 100644 nikola/data/samplesite/stories/charts.txt delete mode 120000 nikola/data/samplesite/stories/creating-a-theme.rst delete mode 120000 nikola/data/samplesite/stories/extending.txt delete mode 120000 nikola/data/samplesite/stories/internals.txt delete mode 100644 nikola/data/samplesite/stories/listings-demo.rst delete mode 120000 nikola/data/samplesite/stories/manual.rst delete mode 100644 nikola/data/samplesite/stories/quickref.rst delete mode 100644 nikola/data/samplesite/stories/quickstart.rst delete mode 100644 nikola/data/samplesite/stories/slides-demo.rst delete mode 120000 nikola/data/samplesite/stories/social_buttons.txt delete mode 120000 nikola/data/samplesite/stories/theming.rst create mode 100644 nikola/data/samplesite/templates/book.tmpl create mode 100644 nikola/data/shortcodes/jinja/raw.tmpl create mode 100644 nikola/data/shortcodes/mako/raw.tmpl delete mode 100644 nikola/data/themes/base-jinja/AUTHORS.txt create mode 100644 nikola/data/themes/base-jinja/base-jinja.theme delete mode 100644 nikola/data/themes/base-jinja/bundles delete mode 100644 nikola/data/themes/base-jinja/engine delete mode 100644 nikola/data/themes/base-jinja/parent create mode 100644 nikola/data/themes/base-jinja/templates/archive.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/archive_navigation_helper.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/author.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/authorindex.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/authors.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/comments_helper_commento.tmpl delete mode 100644 nikola/data/themes/base-jinja/templates/comments_helper_googleplus.tmpl delete mode 100644 nikola/data/themes/base-jinja/templates/comments_helper_livefyre.tmpl delete mode 100644 nikola/data/themes/base-jinja/templates/comments_helper_mustache.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/comments_helper_utterances.tmpl delete mode 100644 nikola/data/themes/base-jinja/templates/crumbs.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/feeds_translations_helper.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/math_helper.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/page.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/pagination_helper.tmpl delete mode 100644 nikola/data/themes/base-jinja/templates/slides.tmpl create mode 100644 nikola/data/themes/base-jinja/templates/ui_helper.tmpl create mode 120000 nikola/data/themes/base/assets/css/baguetteBox.min.css create mode 100644 nikola/data/themes/base/assets/css/html4css1.css create mode 100644 nikola/data/themes/base/assets/css/ipython.min.css create mode 100644 nikola/data/themes/base/assets/css/nikola_rst.css create mode 100644 nikola/data/themes/base/assets/css/rst_base.css create mode 120000 nikola/data/themes/base/assets/js/baguetteBox.min.js create mode 100644 nikola/data/themes/base/assets/js/fancydates.min.js create mode 100644 nikola/data/themes/base/assets/js/gallery.js create mode 100644 nikola/data/themes/base/assets/js/gallery.min.js mode change 100644 => 120000 nikola/data/themes/base/assets/js/html5.js create mode 120000 nikola/data/themes/base/assets/js/html5shiv-printshiv.min.js create mode 120000 nikola/data/themes/base/assets/js/justified-layout.min.js create mode 120000 nikola/data/themes/base/assets/js/luxon.min.js delete mode 100644 nikola/data/themes/base/assets/js/mathjax.js create mode 100644 nikola/data/themes/base/base.theme delete mode 100644 nikola/data/themes/base/engine create mode 100644 nikola/data/themes/base/messages/messages_af.py create mode 100644 nikola/data/themes/base/messages/messages_br.py delete mode 100644 nikola/data/themes/base/messages/messages_fil.py create mode 100644 nikola/data/themes/base/messages/messages_fur.py create mode 100644 nikola/data/themes/base/messages/messages_he.py create mode 100644 nikola/data/themes/base/messages/messages_hu.py create mode 100644 nikola/data/themes/base/messages/messages_ia.py create mode 100644 nikola/data/themes/base/messages/messages_lt.py create mode 100644 nikola/data/themes/base/messages/messages_mi.py create mode 100644 nikola/data/themes/base/messages/messages_ml.py create mode 100644 nikola/data/themes/base/messages/messages_mr.py delete mode 100644 nikola/data/themes/base/messages/messages_si_lk.py create mode 100644 nikola/data/themes/base/messages/messages_sq.py delete mode 100644 nikola/data/themes/base/messages/messages_sr@latin.py create mode 100644 nikola/data/themes/base/messages/messages_sr_latin.py create mode 100644 nikola/data/themes/base/messages/messages_te.py create mode 100644 nikola/data/themes/base/messages/messages_th.py delete mode 100644 nikola/data/themes/base/messages/messages_tl.py create mode 100644 nikola/data/themes/base/messages/messages_vi.py create mode 100644 nikola/data/themes/base/templates/archive.tmpl create mode 100644 nikola/data/themes/base/templates/archive_navigation_helper.tmpl create mode 100644 nikola/data/themes/base/templates/author.tmpl create mode 100644 nikola/data/themes/base/templates/authorindex.tmpl create mode 100644 nikola/data/themes/base/templates/authors.tmpl create mode 100644 nikola/data/themes/base/templates/comments_helper_commento.tmpl delete mode 100644 nikola/data/themes/base/templates/comments_helper_googleplus.tmpl delete mode 100644 nikola/data/themes/base/templates/comments_helper_livefyre.tmpl delete mode 100644 nikola/data/themes/base/templates/comments_helper_mustache.tmpl create mode 100644 nikola/data/themes/base/templates/comments_helper_utterances.tmpl delete mode 100644 nikola/data/themes/base/templates/crumbs.tmpl create mode 100644 nikola/data/themes/base/templates/feeds_translations_helper.tmpl create mode 100644 nikola/data/themes/base/templates/math_helper.tmpl create mode 100644 nikola/data/themes/base/templates/page.tmpl create mode 100644 nikola/data/themes/base/templates/pagination_helper.tmpl delete mode 100644 nikola/data/themes/base/templates/slides.tmpl create mode 100644 nikola/data/themes/base/templates/ui_helper.tmpl create mode 100644 nikola/data/themes/bootblog4-jinja/README.md create mode 120000 nikola/data/themes/bootblog4-jinja/assets/css/bootblog.css create mode 100644 nikola/data/themes/bootblog4-jinja/bootblog4-jinja.theme create mode 120000 nikola/data/themes/bootblog4-jinja/bundles create mode 100644 nikola/data/themes/bootblog4-jinja/templates/base.tmpl create mode 100644 nikola/data/themes/bootblog4-jinja/templates/base_helper.tmpl create mode 100644 nikola/data/themes/bootblog4-jinja/templates/index.tmpl create mode 100644 nikola/data/themes/bootblog4/README.md create mode 100644 nikola/data/themes/bootblog4/assets/css/bootblog.css create mode 100644 nikola/data/themes/bootblog4/bootblog4.theme create mode 100644 nikola/data/themes/bootblog4/bundles create mode 100644 nikola/data/themes/bootblog4/templates/base.tmpl create mode 100644 nikola/data/themes/bootblog4/templates/base_helper.tmpl create mode 100644 nikola/data/themes/bootblog4/templates/index.tmpl delete mode 100644 nikola/data/themes/bootstrap3-jinja/AUTHORS.txt delete mode 100644 nikola/data/themes/bootstrap3-jinja/README.md delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/css/bootstrap-theme.css.map delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/css/bootstrap.css.map delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/css/colorbox.css delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/css/docs.css delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/css/images/controls.png delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/css/images/ie6/borderBottomCenter.png delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/css/images/ie6/borderBottomLeft.png delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/css/images/ie6/borderBottomRight.png delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/css/images/ie6/borderMiddleLeft.png delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/css/images/ie6/borderMiddleRight.png delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/css/images/ie6/borderTopCenter.png delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/css/images/ie6/borderTopLeft.png delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/css/images/ie6/borderTopRight.png delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/css/images/loading.gif delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/css/theme.css delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/fonts/glyphicons-halflings-regular.eot delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/fonts/glyphicons-halflings-regular.svg delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/fonts/glyphicons-halflings-regular.ttf delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/fonts/glyphicons-halflings-regular.woff delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/fonts/glyphicons-halflings-regular.woff2 delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-ar.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-bg.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-bn.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-ca.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-cs.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-da.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-de.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-es.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-et.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-fa.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-fi.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-fr.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-gl.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-gr.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-he.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-hr.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-hu.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-id.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-it.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-ja.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-kr.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-lt.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-lv.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-my.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-nl.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-no.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-pl.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-pt-BR.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-ro.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-ru.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-si.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-sk.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-sr.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-sv.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-tr.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-uk.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-zh-CN.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/colorbox-i18n/jquery.colorbox-zh-TW.js delete mode 100644 nikola/data/themes/bootstrap3-jinja/assets/js/flowr.plugin.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/jquery.colorbox.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/assets/js/jquery.js delete mode 120000 nikola/data/themes/bootstrap3-jinja/bundles delete mode 100644 nikola/data/themes/bootstrap3-jinja/engine delete mode 100644 nikola/data/themes/bootstrap3-jinja/parent delete mode 100644 nikola/data/themes/bootstrap3-jinja/templates/base.tmpl delete mode 100644 nikola/data/themes/bootstrap3-jinja/templates/base_helper.tmpl delete mode 100644 nikola/data/themes/bootstrap3-jinja/templates/gallery.tmpl delete mode 100644 nikola/data/themes/bootstrap3-jinja/templates/listing.tmpl delete mode 100644 nikola/data/themes/bootstrap3-jinja/templates/post.tmpl delete mode 100644 nikola/data/themes/bootstrap3-jinja/templates/slides.tmpl delete mode 100644 nikola/data/themes/bootstrap3-jinja/templates/tags.tmpl delete mode 100644 nikola/data/themes/bootstrap3/README.md delete mode 120000 nikola/data/themes/bootstrap3/assets/css/bootstrap-theme.css.map delete mode 120000 nikola/data/themes/bootstrap3/assets/css/bootstrap.css.map delete mode 120000 nikola/data/themes/bootstrap3/assets/css/colorbox.css delete mode 100644 nikola/data/themes/bootstrap3/assets/css/docs.css delete mode 120000 nikola/data/themes/bootstrap3/assets/css/images/controls.png delete mode 100644 nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomCenter.png delete mode 100644 nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomLeft.png delete mode 100644 nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomRight.png delete mode 100644 nikola/data/themes/bootstrap3/assets/css/images/ie6/borderMiddleLeft.png delete mode 100644 nikola/data/themes/bootstrap3/assets/css/images/ie6/borderMiddleRight.png delete mode 100644 nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopCenter.png delete mode 100644 nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopLeft.png delete mode 100644 nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopRight.png delete mode 120000 nikola/data/themes/bootstrap3/assets/css/images/loading.gif delete mode 100644 nikola/data/themes/bootstrap3/assets/css/theme.css delete mode 120000 nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.eot delete mode 120000 nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.svg delete mode 120000 nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.ttf delete mode 120000 nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.woff delete mode 120000 nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.woff2 delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ar.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-bg.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-bn.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ca.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-cs.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-da.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-de.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-es.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-et.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fa.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fi.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fr.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-gl.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-gr.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-he.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-hr.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-hu.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-id.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-it.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ja.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-kr.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-lt.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-lv.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-my.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-nl.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-no.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-pl.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-pt-BR.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ro.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ru.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-si.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sk.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sr.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sv.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-tr.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-uk.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-zh-CN.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-zh-TW.js delete mode 100644 nikola/data/themes/bootstrap3/assets/js/flowr.plugin.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/jquery.colorbox.js delete mode 120000 nikola/data/themes/bootstrap3/assets/js/jquery.js delete mode 100644 nikola/data/themes/bootstrap3/bundles delete mode 100644 nikola/data/themes/bootstrap3/engine delete mode 100644 nikola/data/themes/bootstrap3/parent delete mode 100644 nikola/data/themes/bootstrap3/templates/base.tmpl delete mode 100644 nikola/data/themes/bootstrap3/templates/base_helper.tmpl delete mode 100644 nikola/data/themes/bootstrap3/templates/gallery.tmpl delete mode 100644 nikola/data/themes/bootstrap3/templates/listing.tmpl delete mode 100644 nikola/data/themes/bootstrap3/templates/post.tmpl delete mode 100644 nikola/data/themes/bootstrap3/templates/slides.tmpl delete mode 100644 nikola/data/themes/bootstrap3/templates/tags.tmpl create mode 100644 nikola/data/themes/bootstrap4-jinja/README.md create mode 120000 nikola/data/themes/bootstrap4-jinja/assets/css/bootstrap.min.css create mode 100644 nikola/data/themes/bootstrap4-jinja/assets/css/theme.css create mode 120000 nikola/data/themes/bootstrap4-jinja/assets/js/bootstrap.min.js create mode 120000 nikola/data/themes/bootstrap4-jinja/assets/js/jquery.min.js create mode 120000 nikola/data/themes/bootstrap4-jinja/assets/js/popper.min.js create mode 100644 nikola/data/themes/bootstrap4-jinja/bootstrap4-jinja.theme create mode 120000 nikola/data/themes/bootstrap4-jinja/bundles create mode 100644 nikola/data/themes/bootstrap4-jinja/templates/authors.tmpl create mode 100644 nikola/data/themes/bootstrap4-jinja/templates/base.tmpl create mode 100644 nikola/data/themes/bootstrap4-jinja/templates/base_helper.tmpl create mode 100644 nikola/data/themes/bootstrap4-jinja/templates/index_helper.tmpl create mode 100644 nikola/data/themes/bootstrap4-jinja/templates/listing.tmpl create mode 100644 nikola/data/themes/bootstrap4-jinja/templates/pagination_helper.tmpl create mode 100644 nikola/data/themes/bootstrap4-jinja/templates/post.tmpl create mode 100644 nikola/data/themes/bootstrap4-jinja/templates/tags.tmpl create mode 100644 nikola/data/themes/bootstrap4-jinja/templates/ui_helper.tmpl create mode 100644 nikola/data/themes/bootstrap4/README.md create mode 120000 nikola/data/themes/bootstrap4/assets/css/bootstrap.min.css create mode 100644 nikola/data/themes/bootstrap4/assets/css/theme.css create mode 120000 nikola/data/themes/bootstrap4/assets/js/bootstrap.min.js create mode 120000 nikola/data/themes/bootstrap4/assets/js/jquery.min.js create mode 120000 nikola/data/themes/bootstrap4/assets/js/popper.min.js create mode 100644 nikola/data/themes/bootstrap4/bootstrap4.theme create mode 100644 nikola/data/themes/bootstrap4/bundles create mode 100644 nikola/data/themes/bootstrap4/templates/authors.tmpl create mode 100644 nikola/data/themes/bootstrap4/templates/base.tmpl create mode 100644 nikola/data/themes/bootstrap4/templates/base_helper.tmpl create mode 100644 nikola/data/themes/bootstrap4/templates/index_helper.tmpl create mode 100644 nikola/data/themes/bootstrap4/templates/listing.tmpl create mode 100644 nikola/data/themes/bootstrap4/templates/pagination_helper.tmpl create mode 100644 nikola/data/themes/bootstrap4/templates/post.tmpl create mode 100644 nikola/data/themes/bootstrap4/templates/tags.tmpl create mode 100644 nikola/data/themes/bootstrap4/templates/ui_helper.tmpl create mode 100644 nikola/hierarchy_utils.py create mode 100644 nikola/log.py create mode 100644 nikola/metadata_extractors.py create mode 100644 nikola/packages/datecond/LICENSE create mode 100644 nikola/packages/datecond/__init__.py create mode 100644 nikola/packages/pygments_better_html/LICENSE create mode 100644 nikola/packages/pygments_better_html/LICENSE.pygments create mode 100644 nikola/packages/pygments_better_html/__init__.py delete mode 100644 nikola/packages/tzlocal/darwin.py delete mode 100644 nikola/plugins/command/bootswatch_theme.plugin delete mode 100644 nikola/plugins/command/bootswatch_theme.py create mode 100644 nikola/plugins/command/default_config.plugin create mode 100644 nikola/plugins/command/default_config.py delete mode 100644 nikola/plugins/command/install_theme.plugin delete mode 100644 nikola/plugins/command/install_theme.py create mode 100644 nikola/plugins/command/subtheme.plugin create mode 100644 nikola/plugins/command/subtheme.py create mode 100644 nikola/plugins/command/theme.plugin create mode 100644 nikola/plugins/command/theme.py delete mode 100644 nikola/plugins/compile/rest/slides.plugin delete mode 100644 nikola/plugins/compile/rest/slides.py create mode 100644 nikola/plugins/misc/taxonomies_classifier.plugin create mode 100644 nikola/plugins/misc/taxonomies_classifier.py create mode 100644 nikola/plugins/shortcode/chart.plugin create mode 100644 nikola/plugins/shortcode/chart.py create mode 100644 nikola/plugins/shortcode/emoji.plugin create mode 100644 nikola/plugins/shortcode/emoji/__init__.py create mode 100644 nikola/plugins/shortcode/emoji/data/Activity.json create mode 100644 nikola/plugins/shortcode/emoji/data/Flags.json create mode 100644 nikola/plugins/shortcode/emoji/data/Food.json create mode 100644 nikola/plugins/shortcode/emoji/data/LICENSE create mode 100644 nikola/plugins/shortcode/emoji/data/Nature.json create mode 100644 nikola/plugins/shortcode/emoji/data/Objects.json create mode 100644 nikola/plugins/shortcode/emoji/data/People.json create mode 100644 nikola/plugins/shortcode/emoji/data/Symbols.json create mode 100644 nikola/plugins/shortcode/emoji/data/Travel.json create mode 100644 nikola/plugins/shortcode/gist.plugin create mode 100644 nikola/plugins/shortcode/gist.py create mode 100644 nikola/plugins/shortcode/listing.plugin create mode 100644 nikola/plugins/shortcode/listing.py create mode 100644 nikola/plugins/shortcode/post_list.plugin create mode 100644 nikola/plugins/shortcode/post_list.py create mode 100644 nikola/plugins/shortcode/thumbnail.plugin create mode 100644 nikola/plugins/shortcode/thumbnail.py create mode 100644 nikola/plugins/task/authors.plugin create mode 100644 nikola/plugins/task/authors.py create mode 100644 nikola/plugins/task/categories.plugin create mode 100644 nikola/plugins/task/categories.py create mode 100644 nikola/plugins/task/page_index.plugin create mode 100644 nikola/plugins/task/page_index.py delete mode 100644 nikola/plugins/task/rss.plugin delete mode 100644 nikola/plugins/task/rss.py create mode 100644 nikola/plugins/task/sitemap.py delete mode 100644 nikola/plugins/task/sitemap/__init__.py create mode 100644 nikola/plugins/task/taxonomies.plugin create mode 100644 nikola/plugins/task/taxonomies.py delete mode 100644 nikola/rc4.py create mode 100644 nikola/shortcodes.py create mode 100644 nikola/state.py (limited to 'nikola') diff --git a/nikola/__init__.py b/nikola/__init__.py index 4ab6e34..4ead429 100644 --- a/nikola/__init__.py +++ b/nikola/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,11 +26,15 @@ """Nikola -- a modular, fast, simple, static website generator.""" -from __future__ import absolute_import import os +import sys -__version__ = "7.6.4" +__version__ = '8.1.2' DEBUG = bool(os.getenv('NIKOLA_DEBUG')) +SHOW_TRACEBACKS = bool(os.getenv('NIKOLA_SHOW_TRACEBACKS')) + +if sys.version_info[0] == 2: + raise Exception("Nikola does not support Python 2.") from .nikola import Nikola # NOQA from . import plugins # NOQA diff --git a/nikola/__main__.py b/nikola/__main__.py index 2aa63f4..8330e67 100644 --- a/nikola/__main__.py +++ b/nikola/__main__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,37 +26,37 @@ """The main function of Nikola.""" -from __future__ import print_function, unicode_literals -from collections import defaultdict +import importlib.util import os import shutil -try: - import readline # NOQA -except ImportError: - pass # This is only so raw_input/input does nicer things if it's available import sys +import textwrap import traceback +import doit.cmd_base +from collections import defaultdict -from doit.loader import generate_tasks -from doit.cmd_base import TaskLoader -from doit.reporter import ExecutedOnlyReporter -from doit.doit_cmd import DoitMain +from blinker import signal +from doit.cmd_auto import Auto as DoitAuto +from doit.cmd_base import TaskLoader, _wrap +from doit.cmd_clean import Clean as DoitClean +from doit.cmd_completion import TabCompletion from doit.cmd_help import Help as DoitHelp from doit.cmd_run import Run as DoitRun -from doit.cmd_clean import Clean as DoitClean -from doit.cmd_auto import Auto as DoitAuto -from logbook import NullHandler -from blinker import signal +from doit.doit_cmd import DoitMain +from doit.loader import generate_tasks +from doit.reporter import ExecutedOnlyReporter from . import __version__ -from .plugin_categories import Command from .nikola import Nikola -from .utils import sys_decode, sys_encode, get_root_dir, req_missing, LOGGER, STRICT_HANDLER, STDERR_HANDLER, ColorfulStderrHandler +from .plugin_categories import Command +from .log import configure_logging, LOGGER, ColorfulFormatter, LoggingMode +from .utils import get_root_dir, req_missing, sys_decode + +try: + import readline # NOQA +except ImportError: + pass # This is only so raw_input/input does nicer things if it's available -if sys.version_info[0] == 3: - import importlib.machinery -else: - import imp config = {} @@ -67,10 +67,10 @@ _RETURN_DOITNIKOLA = False def main(args=None): """Run Nikola.""" colorful = False - if sys.stderr.isatty() and os.name != 'nt': + if sys.stderr.isatty() and os.name != 'nt' and os.getenv('NIKOLA_MONO') is None and os.getenv('TERM') != 'dumb': colorful = True - ColorfulStderrHandler._colorful = colorful + ColorfulFormatter._colorful = colorful if args is None: args = sys.argv[1:] @@ -79,29 +79,24 @@ def main(args=None): args = [sys_decode(arg) for arg in args] conf_filename = 'conf.py' - conf_filename_bytes = b'conf.py' conf_filename_changed = False for index, arg in enumerate(args): if arg[:7] == '--conf=': del args[index] del oargs[index] conf_filename = arg[7:] - conf_filename_bytes = sys_encode(arg[7:]) conf_filename_changed = True break quiet = False - strict = False if len(args) > 0 and args[0] == 'build' and '--strict' in args: - LOGGER.notice('Running in strict mode') - STRICT_HANDLER.push_application() - strict = True - if len(args) > 0 and args[0] == 'build' and '-q' in args or '--quiet' in args: - NullHandler().push_application() + LOGGER.info('Running in strict mode') + configure_logging(LoggingMode.STRICT) + elif len(args) > 0 and args[0] == 'build' and '-q' in args or '--quiet' in args: + configure_logging(LoggingMode.QUIET) quiet = True - if not quiet and not strict: - NullHandler().push_application() - STDERR_HANDLER[0].push_application() + else: + configure_logging() global config @@ -118,20 +113,22 @@ def main(args=None): os.chdir(root) # Help and imports don't require config, but can use one if it exists needs_config_file = (argname != 'help') and not argname.startswith('import_') + LOGGER.debug("Website root: %r", root) else: needs_config_file = False - sys.path.append('') + sys.path.insert(0, os.path.dirname(conf_filename)) try: - if sys.version_info[0] == 3: - loader = importlib.machinery.SourceFileLoader("conf", conf_filename) - conf = loader.load_module() - else: - conf = imp.load_source("conf", conf_filename_bytes) + spec = importlib.util.spec_from_file_location("conf", conf_filename) + conf = importlib.util.module_from_spec(spec) + # Preserve caching behavior of `import conf` if the filename matches + if os.path.splitext(os.path.basename(conf_filename))[0] == "conf": + sys.modules["conf"] = conf + spec.loader.exec_module(conf) config = conf.__dict__ except Exception: if os.path.exists(conf_filename): - msg = traceback.format_exc(0) + msg = traceback.format_exc() LOGGER.error('"{0}" cannot be parsed.\n{1}'.format(conf_filename, msg)) return 1 elif needs_config_file and conf_filename_changed: @@ -154,7 +151,7 @@ def main(args=None): req_missing(['freezegun'], 'perform invariant builds') if config: - if os.path.exists('plugins') and not os.path.exists('plugins/__init__.py'): + if os.path.isdir('plugins') and not os.path.exists('plugins/__init__.py'): with open('plugins/__init__.py', 'w') as fh: fh.write('# Plugin modules go here.') @@ -175,7 +172,6 @@ def main(args=None): class Help(DoitHelp): - """Show Nikola usage.""" @staticmethod @@ -199,7 +195,6 @@ class Help(DoitHelp): class Build(DoitRun): - """Expose "run" command as "build" for backwards compatibility.""" def __init__(self, *args, **kw): @@ -234,20 +229,21 @@ class Build(DoitRun): } ) self.cmd_options = tuple(opts) - super(Build, self).__init__(*args, **kw) + super().__init__(*args, **kw) class Clean(DoitClean): - """Clean site, including the cache directory.""" - def clean_tasks(self, tasks, dryrun): + # The unseemly *a is because this API changed between doit 0.30.1 and 0.31 + def clean_tasks(self, tasks, dryrun, *a): """Clean tasks.""" if not dryrun and config: cache_folder = config.get('CACHE_FOLDER', 'cache') if os.path.exists(cache_folder): shutil.rmtree(cache_folder) - return super(Clean, self).clean_tasks(tasks, dryrun) + return super(Clean, self).clean_tasks(tasks, dryrun, *a) + # Nikola has its own "auto" commands that uses livereload. # Expose original doit "auto" command as "doit_auto". @@ -255,7 +251,6 @@ DoitAuto.name = 'doit_auto' class NikolaTaskLoader(TaskLoader): - """Nikola-specific task loader.""" def __init__(self, nikola, quiet=False): @@ -277,18 +272,24 @@ class NikolaTaskLoader(TaskLoader): } DOIT_CONFIG['default_tasks'] = ['render_site', 'post_render'] DOIT_CONFIG.update(self.nikola._doit_config) - tasks = generate_tasks( - 'render_site', - self.nikola.gen_tasks('render_site', "Task", 'Group of tasks to render the site.')) - latetasks = generate_tasks( - 'post_render', - self.nikola.gen_tasks('post_render', "LateTask", 'Group of tasks to be executed after site is rendered.')) - signal('initialized').send(self.nikola) + try: + tasks = generate_tasks( + 'render_site', + self.nikola.gen_tasks('render_site', "Task", 'Group of tasks to render the site.')) + latetasks = generate_tasks( + 'post_render', + self.nikola.gen_tasks('post_render', "LateTask", 'Group of tasks to be executed after site is rendered.')) + signal('initialized').send(self.nikola) + except Exception: + LOGGER.error('Error loading tasks. An unhandled exception occurred.') + if self.nikola.debug or self.nikola.show_tracebacks: + raise + _print_exception() + sys.exit(3) return tasks + latetasks, DOIT_CONFIG class DoitNikola(DoitMain): - """Nikola-specific implementation of DoitMain.""" # overwite help command @@ -297,7 +298,7 @@ class DoitNikola(DoitMain): def __init__(self, nikola, quiet=False): """Initialzie DoitNikola.""" - super(DoitNikola, self).__init__() + super().__init__() self.nikola = nikola nikola.doit = self self.task_loader = self.TASK_LOADER(nikola, quiet) @@ -336,6 +337,8 @@ class DoitNikola(DoitMain): if args[0] == 'help': self.nikola.init_plugins(commands_only=True) + elif args[0] == 'plugin': + self.nikola.init_plugins(load_all=True) else: self.nikola.init_plugins() @@ -359,12 +362,19 @@ class DoitNikola(DoitMain): LOGGER.info('Did you mean "{}" or "{}"?'.format('", "'.join(best_sugg[:-1]), best_sugg[-1])) return 3 - if sub_cmds[args[0]] is not Help and not isinstance(sub_cmds[args[0]], Command): # Is a doit command + if not sub_cmds[args[0]] in (Help, TabCompletion) and not isinstance(sub_cmds[args[0]], Command): if not self.nikola.configured: LOGGER.error("This command needs to run inside an " "existing Nikola site.") return 3 - return super(DoitNikola, self).run(cmd_args) + try: + return super().run(cmd_args) + except Exception: + LOGGER.error('An unhandled exception occurred.') + if self.nikola.debug or self.nikola.show_tracebacks: + raise + _print_exception() + return 1 @staticmethod def print_version(): @@ -372,6 +382,53 @@ class DoitNikola(DoitMain): print("Nikola v" + __version__) +# Override Command.help() to make it more readable and to remove +# some doit-specific stuff. Based on doit's implementation. +# (see Issue #3342) +def _command_help(self: Command): + """Return help text for a command.""" + text = [] + + usage = "{} {} {}".format(self.bin_name, self.name, self.doc_usage) + text.extend(textwrap.wrap(usage, subsequent_indent=' ')) + text.extend(_wrap(self.doc_purpose, 4)) + + text.append("\nOptions:") + options = defaultdict(list) + for opt in self.cmdparser.options: + options[opt.section].append(opt) + for section, opts in sorted(options.items()): + if section: + section_name = '\n{}'.format(section) + text.extend(_wrap(section_name, 2)) + for opt in opts: + # ignore option that cant be modified on cmd line + if not (opt.short or opt.long): + continue + text.extend(_wrap(opt.help_param(), 4)) + opt_help = opt.help + if '%(default)s' in opt_help: + opt_help = opt.help % {'default': opt.default} + elif opt.default != '' and opt.default is not False and opt.default is not None: + opt_help += ' [default: {}]'.format(opt.default) + opt_choices = opt.help_choices() + desc = '{} {}'.format(opt_help, opt_choices) + text.extend(_wrap(desc, 8)) + + # print bool inverse option + if opt.inverse: + text.extend(_wrap('--{}'.format(opt.inverse), 4)) + text.extend(_wrap('opposite of --{}'.format(opt.long), 8)) + + if self.doc_description is not None: + text.append("\n\nDescription:") + text.extend(_wrap(self.doc_description, 4)) + return "\n".join(text) + + +doit.cmd_base.Command.help = _command_help + + def levenshtein(s1, s2): u"""Calculate the Levenshtein distance of two strings. @@ -399,5 +456,13 @@ def levenshtein(s1, s2): return previous_row[-1] + +def _print_exception(): + """Print an exception in a friendlier, shorter style.""" + etype, evalue, _ = sys.exc_info() + LOGGER.error(''.join(traceback.format_exception(etype, evalue, None, limit=0, chain=False)).strip()) + LOGGER.warning("To see more details, run Nikola in debug mode (set environment variable NIKOLA_DEBUG=1) or use NIKOLA_SHOW_TRACEBACKS=1") + + if __name__ == "__main__": sys.exit(main(sys.argv[1:])) diff --git a/nikola/conf.py.in b/nikola/conf.py.in index b7a88f7..4546460 100644 --- a/nikola/conf.py.in +++ b/nikola/conf.py.in @@ -1,7 +1,6 @@ ## -*- coding: utf-8 -*- # -*- coding: utf-8 -*- -from __future__ import unicode_literals import time # !! This is the configuration of Nikola. !! # @@ -57,7 +56,7 @@ TRANSLATIONS = ${TRANSLATIONS} # this pattern is also used for metadata: # something.meta -> something.pl.meta -TRANSLATIONS_PATTERN = ${TRANSLATIONS_PATTERN} +TRANSLATIONS_PATTERN = '{path}.{lang}.{ext}' # Links for the sidebar / navigation bar. (translatable) # This is a dict. The keys are languages, and values are tuples. @@ -68,17 +67,17 @@ TRANSLATIONS_PATTERN = ${TRANSLATIONS_PATTERN} # For submenus: # ( # ( -# ('http://apple.com/', 'Apple'), -# ('http://orange.com/', 'Orange'), +# ('https://apple.com/', 'Apple'), +# ('https://orange.com/', 'Orange'), # ), # 'Fruits' # ) # # WARNING: Support for submenus is theme-dependent. # Only one level of submenus is supported. -# WARNING: Some themes, including the default Bootstrap 3 theme, +# WARNING: Some themes, including the default Bootstrap 4 theme, # may present issues if the menu is too large. -# (in bootstrap3, the navbar can grow too large and cover contents.) +# (in Bootstrap, the navbar can grow too large and cover contents.) # WARNING: If you link to directories, make sure to follow # ``STRIP_INDEXES``. If it’s set to ``True``, end your links # with a ``/``, otherwise end them with ``/index.html`` — or @@ -86,9 +85,97 @@ TRANSLATIONS_PATTERN = ${TRANSLATIONS_PATTERN} NAVIGATION_LINKS = ${NAVIGATION_LINKS} +# Alternative navigation links. Works the same way NAVIGATION_LINKS does, +# although themes may not always support them. (translatable) +# (Bootstrap 4: right-side of navbar, Bootblog 4: right side of title) +NAVIGATION_ALT_LINKS = { + DEFAULT_LANG: () +} + # Name of the theme to use. THEME = ${THEME} +# A theme color. In default themes, it might be displayed by some browsers as +# the browser UI color (eg. Chrome on Android). Other themes might also use it +# as an accent color (the default ones don’t). Must be a HEX value. +THEME_COLOR = '#5670d4' + +# Theme configuration. Fully theme-dependent. (translatable) +# Samples for bootblog4 (enabled) and bootstrap4 (commented) follow. +# bootblog4 supports: featured_large featured_small featured_on_mobile +# featured_large_image_on_mobile featured_strip_html sidebar +# bootstrap4 supports: navbar_light (defaults to False) +# navbar_custom_bg (defaults to '') + +# Config for bootblog4: +THEME_CONFIG = { + DEFAULT_LANG: { + # Show the latest featured post in a large box, with the previewimage as its background. + 'featured_large': False, + # Show the first (remaining) two featured posts in small boxes. + 'featured_small': False, + # Show featured posts on mobile. + 'featured_on_mobile': True, + # Show image in `featured_large` on mobile. + # `featured_small` displays them only on desktop. + 'featured_large_image_on_mobile': True, + # Strip HTML from featured post text. + 'featured_strip_html': False, + # Contents of the sidebar, If empty, the sidebar is not displayed. + 'sidebar': '' + } +} +# Config for bootstrap4: +# THEME_CONFIG = { +# DEFAULT_LANG: { +# # Use a light navbar with dark text. Defaults to False. +# 'navbar_light': False, +# # Use a custom navbar color. If unset, 'navbar_light' sets text + +# # background color. If set, navbar_light controls only background +# # color. Supported values: bg-dark, bg-light, bg-primary, bg-secondary, +# # bg-success, bg-danger, bg-warning, bg-info, bg-white, bg-transparent. +# 'navbar_custom_bg': '', +# } +# } + +# POSTS and PAGES contains (wildcard, destination, template) tuples. +# (translatable) +# +# The wildcard is used to generate a list of source files +# (whatever/thing.rst, for example). +# +# That fragment could have an associated metadata file (whatever/thing.meta), +# and optionally translated files (example for Spanish, with code "es"): +# whatever/thing.es.rst and whatever/thing.es.meta +# +# This assumes you use the default TRANSLATIONS_PATTERN. +# +# From those files, a set of HTML fragment files will be generated: +# cache/whatever/thing.html (and maybe cache/whatever/thing.html.es) +# +# These files are combined with the template to produce rendered +# pages, which will be placed at +# output/TRANSLATIONS[lang]/destination/pagename.html +# +# where "pagename" is the "slug" specified in the metadata file. +# The page might also be placed in /destination/pagename/index.html +# if PRETTY_URLS are enabled. +# +# The difference between POSTS and PAGES is that POSTS are added +# to feeds, indexes, tag lists and archives and are considered part +# of a blog, while PAGES are just independent HTML pages. +# +# Finally, note that destination can be translated, i.e. you can +# specify a different translation folder per language. Example: +# PAGES = ( +# ("pages/*.rst", {"en": "pages", "de": "seiten"}, "page.tmpl"), +# ("pages/*.md", {"en": "pages", "de": "seiten"}, "page.tmpl"), +# ) + +POSTS = ${POSTS} +PAGES = ${PAGES} + + ############################################## # Below this point, everything is optional ############################################## @@ -96,7 +183,7 @@ THEME = ${THEME} # Post's dates are considered in UTC by default, if you want to use # another time zone, please set TIMEZONE to match. Check the available # list from Wikipedia: -# http://en.wikipedia.org/wiki/List_of_tz_database_time_zones +# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones # (e.g. 'Europe/Zurich') # Also, if you want to use a different time zone in some of your posts, # you can use the ISO 8601/RFC 3339 format (ex. 2012-03-30T23:00:00+02:00) @@ -107,61 +194,30 @@ TIMEZONE = ${TIMEZONE} # Note that this does not affect DATE_FORMAT. # FORCE_ISO8601 = False -# Date format used to display post dates. -# (str used by datetime.datetime.strftime) -# DATE_FORMAT = '%Y-%m-%d %H:%M' +# Date format used to display post dates. (translatable) +# Used by babel.dates, CLDR style: http://cldr.unicode.org/translation/date-time +# You can also use 'full', 'long', 'medium', or 'short' +# DATE_FORMAT = 'yyyy-MM-dd HH:mm' -# Date format used to display post dates, if local dates are used. -# (str used by moment.js) -# JS_DATE_FORMAT = 'YYYY-MM-DD HH:mm' +# Date format used to display post dates, if local dates are used. (translatable) +# Used by Luxon: https://moment.github.io/luxon/docs/manual/formatting +# Example for presets: {'preset': True, 'format': 'DATE_FULL'} +# LUXON_DATE_FORMAT = { +# DEFAULT_LANG: {'preset': False, 'format': 'yyyy-MM-dd HH:mm'}, +# } # Date fanciness. # -# 0 = using DATE_FORMAT and TIMEZONE -# 1 = using JS_DATE_FORMAT and local user time (via moment.js) -# 2 = using a string like “2 days ago” +# 0 = using DATE_FORMAT and TIMEZONE (without JS) +# 1 = using LUXON_DATE_FORMAT and local user time (JS, using Luxon) +# 2 = using a string like “2 days ago” (JS, using Luxon) # -# Your theme must support it, bootstrap and bootstrap3 already do. +# Your theme must support it, Bootstrap already does. # DATE_FANCINESS = 0 -# While Nikola can select a sensible locale for each language, -# sometimes explicit control can come handy. -# In this file we express locales in the string form that -# python's locales will accept in your OS, by example -# "en_US.utf8" in Unix-like OS, "English_United States" in Windows. -# LOCALES = dict mapping language --> explicit locale for the languages -# in TRANSLATIONS. You can omit one or more keys. -# LOCALE_FALLBACK = locale to use when an explicit locale is unavailable -# LOCALE_DEFAULT = locale to use for languages not mentioned in LOCALES; if -# not set the default Nikola mapping is used. - -# POSTS and PAGES contains (wildcard, destination, template) tuples. -# -# The wildcard is used to generate a list of reSt source files -# (whatever/thing.txt). -# -# That fragment could have an associated metadata file (whatever/thing.meta), -# and optionally translated files (example for Spanish, with code "es"): -# whatever/thing.es.txt and whatever/thing.es.meta -# -# This assumes you use the default TRANSLATIONS_PATTERN. -# -# From those files, a set of HTML fragment files will be generated: -# cache/whatever/thing.html (and maybe cache/whatever/thing.html.es) -# -# These files are combined with the template to produce rendered -# pages, which will be placed at -# output / TRANSLATIONS[lang] / destination / pagename.html -# -# where "pagename" is the "slug" specified in the metadata file. -# -# The difference between POSTS and PAGES is that POSTS are added -# to feeds and are considered part of a blog, while PAGES are -# just independent HTML pages. -# - -POSTS = ${POSTS} -PAGES = ${PAGES} +# Customize the locale/region used for a language. +# For example, to use British instead of US English: LOCALES = {'en': 'en_GB'} +# LOCALES = {} # One or more folders containing files to be copied as-is into the output. # The format is a dictionary of {source: relative destination}. @@ -169,8 +225,8 @@ PAGES = ${PAGES} # FILES_FOLDERS = {'files': ''} # Which means copy 'files' into 'output' -# One or more folders containing listings to be processed and stored into -# the output. The format is a dictionary of {source: relative destination}. +# One or more folders containing code listings to be processed and published on +# the site. The format is a dictionary of {source: relative destination}. # Default is: # LISTINGS_FOLDERS = {'listings': 'listings'} # Which means process listings from 'listings' into 'output/listings' @@ -179,20 +235,42 @@ PAGES = ${PAGES} # Feel free to add or delete extensions to any list, but don't add any new # compilers unless you write the interface for it yourself. # +# The default compiler for `new_post` is the first entry in the POSTS tuple. +# # 'rest' is reStructuredText -# 'markdown' is MarkDown +# 'markdown' is Markdown # 'html' assumes the file is HTML and just copies it COMPILERS = ${COMPILERS} +# Enable reST directives that insert the contents of external files such +# as "include" and "raw." This maps directly to the docutils file_insertion_enabled +# config. See: http://docutils.sourceforge.net/docs/user/config.html#file-insertion-enabled +# REST_FILE_INSERTION_ENABLED = True + # Create by default posts in one file format? # Set to False for two-file posts, with separate metadata. # ONE_FILE_POSTS = True +# Preferred metadata format for new posts +# "Nikola": reST comments, wrapped in a HTML comment if needed (default) +# "YAML": YAML wrapped in "---" +# "TOML": TOML wrapped in "+++" +# "Pelican": Native markdown metadata or reST docinfo fields. Nikola style for other formats. +# METADATA_FORMAT = "Nikola" + +# Use date-based path when creating posts? +# Can be enabled on a per-post basis with `nikola new_post -d`. +# The setting is ignored when creating pages. +# NEW_POST_DATE_PATH = False + +# What format to use when creating posts with date paths? +# Default is '%Y/%m/%d', other possibilities include '%Y' or '%Y/%m'. +# NEW_POST_DATE_PATH_FORMAT = '%Y/%m/%d' + # If this is set to True, the DEFAULT_LANG version will be displayed for # untranslated posts. # If this is set to False, then posts that are not translated to a language # LANG will not be visible at all in the pages in that language. -# Formerly known as HIDE_UNTRANSLATED_POSTS (inverse) # SHOW_UNTRANSLATED_POSTS = True # Nikola supports logo display. If you have one, you can put the URL here. @@ -200,23 +278,36 @@ COMPILERS = ${COMPILERS} # The URL may be relative to the site root. # LOGO_URL = '' +# When linking posts to social media, Nikola provides Open Graph metadata +# which is used to show a nice preview. This includes an image preview +# taken from the post's previewimage metadata field. +# This option lets you use an image to be used if the post doesn't have it. +# The default is None, valid values are URLs or output paths like +# "/images/foo.jpg" +# DEFAULT_PREVIEW_IMAGE = None + # If you want to hide the title of your website (for example, if your logo # already contains the text), set this to False. # SHOW_BLOG_TITLE = True -# Writes tag cloud data in form of tag_cloud_data.json. -# Warning: this option will change its default value to False in v8! -WRITE_TAG_CLOUD = True - # Paths for different autogenerated bits. These are combined with the # translation paths. # Final locations are: # output / TRANSLATION[lang] / TAG_PATH / index.html (list of tags) # output / TRANSLATION[lang] / TAG_PATH / tag.html (list of posts for a tag) -# output / TRANSLATION[lang] / TAG_PATH / tag.xml (RSS feed for a tag) +# output / TRANSLATION[lang] / TAG_PATH / tag RSS_EXTENSION (RSS feed for a tag) +# (translatable) # TAG_PATH = "categories" +# By default, the list of tags is stored in +# output / TRANSLATION[lang] / TAG_PATH / index.html +# (see explanation for TAG_PATH). This location can be changed to +# output / TRANSLATION[lang] / TAGS_INDEX_PATH +# with an arbitrary relative path TAGS_INDEX_PATH. +# (translatable) +# TAGS_INDEX_PATH = "tags.html" + # If TAG_PAGES_ARE_INDEXES is set to True, each tag's page will contain # the posts themselves. If set to False, it will be just a list of links. # TAG_PAGES_ARE_INDEXES = False @@ -224,16 +315,23 @@ WRITE_TAG_CLOUD = True # Set descriptions for tag pages to make them more interesting. The # default is no description. The value is used in the meta description # and displayed underneath the tag list or index page’s title. -# TAG_PAGES_DESCRIPTIONS = { +# TAG_DESCRIPTIONS = { # DEFAULT_LANG: { -# "blogging": "Meta-blog posts about blogging about blogging.", +# "blogging": "Meta-blog posts about blogging.", # "open source": "My contributions to my many, varied, ever-changing, and eternal libre software projects." # }, # } +# Set special titles for tag pages. The default is "Posts about TAG". +# TAG_TITLES = { +# DEFAULT_LANG: { +# "blogging": "Meta-posts about blogging", +# "open source": "Posts about open source software" +# }, +# } # If you do not want to display a tag publicly, you can mark it as hidden. -# The tag will not be displayed on the tag list page, the tag cloud and posts. +# The tag will not be displayed on the tag list page and posts. # Tag pages will still be generated. HIDDEN_TAGS = ['mathjax'] @@ -243,13 +341,36 @@ HIDDEN_TAGS = ['mathjax'] # However, more obscure tags can be hidden from the tag index page. # TAGLIST_MINIMUM_POSTS = 1 +# A list of dictionaries specifying tags which translate to each other. +# Format: a list of dicts {language: translation, language2: translation2, …} +# For example: +# [ +# {'en': 'private', 'de': 'Privat'}, +# {'en': 'work', 'fr': 'travail', 'de': 'Arbeit'}, +# ] +# TAG_TRANSLATIONS = [] + +# If set to True, a tag in a language will be treated as a translation +# of the literally same tag in all other languages. Enable this if you +# do not translate tags, for example. +# TAG_TRANSLATIONS_ADD_DEFAULTS = True + # Final locations are: # output / TRANSLATION[lang] / CATEGORY_PATH / index.html (list of categories) # output / TRANSLATION[lang] / CATEGORY_PATH / CATEGORY_PREFIX category.html (list of posts for a category) -# output / TRANSLATION[lang] / CATEGORY_PATH / CATEGORY_PREFIX category.xml (RSS feed for a category) +# output / TRANSLATION[lang] / CATEGORY_PATH / CATEGORY_PREFIX category RSS_EXTENSION (RSS feed for a category) +# (translatable) # CATEGORY_PATH = "categories" # CATEGORY_PREFIX = "cat_" +# By default, the list of categories is stored in +# output / TRANSLATION[lang] / CATEGORY_PATH / index.html +# (see explanation for CATEGORY_PATH). This location can be changed to +# output / TRANSLATION[lang] / CATEGORIES_INDEX_PATH +# with an arbitrary relative path CATEGORIES_INDEX_PATH. +# (translatable) +# CATEGORIES_INDEX_PATH = "categories.html" + # If CATEGORY_ALLOW_HIERARCHIES is set to True, categories can be organized in # hierarchies. For a post, the whole path in the hierarchy must be specified, # using a forward slash ('/') to separate paths. Use a backslash ('\') to escape @@ -267,22 +388,112 @@ CATEGORY_OUTPUT_FLAT_HIERARCHY = ${CATEGORY_OUTPUT_FLAT_HIERARCHY} # Set descriptions for category pages to make them more interesting. The # default is no description. The value is used in the meta description # and displayed underneath the category list or index page’s title. -# CATEGORY_PAGES_DESCRIPTIONS = { +# CATEGORY_DESCRIPTIONS = { # DEFAULT_LANG: { -# "blogging": "Meta-blog posts about blogging about blogging.", +# "blogging": "Meta-blog posts about blogging.", # "open source": "My contributions to my many, varied, ever-changing, and eternal libre software projects." # }, # } +# Set special titles for category pages. The default is "Posts about CATEGORY". +# CATEGORY_TITLES = { +# DEFAULT_LANG: { +# "blogging": "Meta-posts about blogging", +# "open source": "Posts about open source software" +# }, +# } + # If you do not want to display a category publicly, you can mark it as hidden. # The category will not be displayed on the category list page. # Category pages will still be generated. HIDDEN_CATEGORIES = [] +# A list of dictionaries specifying categories which translate to each other. +# Format: a list of dicts {language: translation, language2: translation2, …} +# See TAG_TRANSLATIONS example above. +# CATEGORY_TRANSLATIONS = [] + +# If set to True, a category in a language will be treated as a translation +# of the literally same category in all other languages. Enable this if you +# do not translate categories, for example. +# CATEGORY_TRANSLATIONS_ADD_DEFAULTS = True + +# If no category is specified in a post, the destination path of the post +# can be used in its place. This replaces the sections feature. Using +# category hierarchies is recommended. +# CATEGORY_DESTPATH_AS_DEFAULT = False + +# If True, the prefix will be trimmed from the category name, eg. if the +# POSTS destination is "foo/bar", and the path is "foo/bar/baz/quux", +# the category will be "baz/quux" (or "baz" if only the first directory is considered). +# Note that prefixes coming from translations are always ignored. +# CATEGORY_DESTPATH_TRIM_PREFIX = False + +# If True, only the first directory of a path will be used. +# CATEGORY_DESTPATH_FIRST_DIRECTORY_ONLY = True + +# Map paths to prettier category names. (translatable) +# CATEGORY_DESTPATH_NAMES = { +# DEFAULT_LANG: { +# 'webdev': 'Web Development', +# 'webdev/django': 'Web Development/Django', +# 'random': 'Odds and Ends', +# }, +# } + +# By default, category indexes will appear in CATEGORY_PATH and use +# CATEGORY_PREFIX. If this is enabled, those settings will be ignored (except +# for the index) and instead, they will follow destination paths (eg. category +# 'foo' might appear in 'posts/foo'). If the category does not come from a +# destpath, first entry in POSTS followed by the category name will be used. +# For this setting, category hierarchies are required and cannot be flattened. +# CATEGORY_PAGES_FOLLOW_DESTPATH = False + +# If ENABLE_AUTHOR_PAGES is set to True and there is more than one +# author, author pages are generated. +# ENABLE_AUTHOR_PAGES = True + +# Path to author pages. Final locations are: +# output / TRANSLATION[lang] / AUTHOR_PATH / index.html (list of authors) +# output / TRANSLATION[lang] / AUTHOR_PATH / author.html (list of posts by an author) +# output / TRANSLATION[lang] / AUTHOR_PATH / author RSS_EXTENSION (RSS feed for an author) +# (translatable) +# AUTHOR_PATH = "authors" + +# If AUTHOR_PAGES_ARE_INDEXES is set to True, each author's page will contain +# the posts themselves. If set to False, it will be just a list of links. +# AUTHOR_PAGES_ARE_INDEXES = False + +# Set descriptions for author pages to make them more interesting. The +# default is no description. The value is used in the meta description +# and displayed underneath the author list or index page’s title. +# AUTHOR_PAGES_DESCRIPTIONS = { +# DEFAULT_LANG: { +# "Juanjo Conti": "Python coder and writer.", +# "Roberto Alsina": "Nikola father." +# }, +# } + + +# If you do not want to display an author publicly, you can mark it as hidden. +# The author will not be displayed on the author list page and posts. +# Tag pages will still be generated. +HIDDEN_AUTHORS = ['Guest'] + +# Allow multiple, comma-separated authors for a post? (Requires theme support, present in built-in themes) +# MULTIPLE_AUTHORS_PER_POST = False + # Final location for the main blog page and sibling paginated pages is # output / TRANSLATION[lang] / INDEX_PATH / index-*.html +# (translatable) # INDEX_PATH = "" +# Optional HTML that displayed on “main” blog index.html files. +# May be used for a greeting. (translatable) +FRONT_INDEX_HEADER = { + DEFAULT_LANG: '' +} + # Create per-month archives instead of per-year # CREATE_MONTHLY_ARCHIVE = False # Create one large archive instead of per-year @@ -292,11 +503,14 @@ HIDDEN_CATEGORIES = [] # CREATE_FULL_ARCHIVES = False # If monthly archives or full archives are created, adds also one archive per day # CREATE_DAILY_ARCHIVE = False +# Create previous, up, next navigation links for archives +# CREATE_ARCHIVE_NAVIGATION = False # Final locations for the archives are: # output / TRANSLATION[lang] / ARCHIVE_PATH / ARCHIVE_FILENAME # output / TRANSLATION[lang] / ARCHIVE_PATH / YEAR / index.html # output / TRANSLATION[lang] / ARCHIVE_PATH / YEAR / MONTH / index.html # output / TRANSLATION[lang] / ARCHIVE_PATH / YEAR / MONTH / DAY / index.html +# (translatable) # ARCHIVE_PATH = "" # ARCHIVE_FILENAME = "archive.html" @@ -311,17 +525,38 @@ HIDDEN_CATEGORIES = [] # absolute: a complete URL (that includes the SITE_URL) # URL_TYPE = 'rel_path' +# Extension for RSS feed files +# RSS_EXTENSION = ".xml" + +# RSS filename base (without extension); used for indexes and galleries. +# (translatable) +# RSS_FILENAME_BASE = "rss" + # Final location for the blog main RSS feed is: -# output / TRANSLATION[lang] / RSS_PATH / rss.xml +# output / TRANSLATION[lang] / RSS_PATH / RSS_FILENAME_BASE RSS_EXTENSION +# (translatable) # RSS_PATH = "" -# Number of posts in RSS feeds -# FEED_LENGTH = 10 +# Final location for the blog main Atom feed is: +# output / TRANSLATION[lang] / ATOM_PATH / ATOM_FILENAME_BASE ATOM_EXTENSION +# (translatable) +# ATOM_PATH = "" + +# Atom filename base (without extension); used for indexes. +# (translatable) +ATOM_FILENAME_BASE = "feed" + +# Extension for Atom feed files +# ATOM_EXTENSION = ".atom" -# Slug the Tag URL easier for users to type, special characters are +# Slug the Tag URL. Easier for users to type, special characters are # often removed or replaced as well. # SLUG_TAG_PATH = True +# Slug the Author URL. Easier for users to type, special characters are +# often removed or replaced as well. +# SLUG_AUTHOR_PATH = True + # A list of redirection tuples, [("foo/from.html", "/bar/to.html")]. # # A HTML file will be created in output/foo/from.html that redirects @@ -347,13 +582,19 @@ REDIRECTIONS = ${REDIRECTIONS} # ] # } -# For user.github.io OR organization.github.io pages, the DEPLOY branch -# MUST be 'master', and 'gh-pages' for other repositories. -# GITHUB_SOURCE_BRANCH = 'master' -# GITHUB_DEPLOY_BRANCH = 'gh-pages' +# github_deploy configuration +# For more details, read the manual: +# https://getnikola.com/handbook.html#deploying-to-github +# You will need to configure the deployment branch on GitHub. +GITHUB_SOURCE_BRANCH = 'src' +GITHUB_DEPLOY_BRANCH = 'master' # The name of the remote where you wish to push to, using github_deploy. -# GITHUB_REMOTE_NAME = 'origin' +GITHUB_REMOTE_NAME = 'origin' + +# Whether or not github_deploy should commit to the source branch automatically +# before deploying. +GITHUB_COMMIT_SOURCE = True # Where the output site should be located # If you don't use an absolute path, it will be considered as relative @@ -394,6 +635,35 @@ REDIRECTIONS = ${REDIRECTIONS} # ".jpg": ["jpegoptim --strip-all -m75 -v %s"], # } +# Executable for the "yui_compressor" filter (defaults to 'yui-compressor'). +# YUI_COMPRESSOR_EXECUTABLE = 'yui-compressor' + +# Executable for the "closure_compiler" filter (defaults to 'closure-compiler'). +# CLOSURE_COMPILER_EXECUTABLE = 'closure-compiler' + +# Executable for the "optipng" filter (defaults to 'optipng'). +# OPTIPNG_EXECUTABLE = 'optipng' + +# Executable for the "jpegoptim" filter (defaults to 'jpegoptim'). +# JPEGOPTIM_EXECUTABLE = 'jpegoptim' + +# Executable for the "html_tidy_withconfig", "html_tidy_nowrap", +# "html_tidy_wrap", "html_tidy_wrap_attr" and "html_tidy_mini" filters +# (defaults to 'tidy5'). +# HTML_TIDY_EXECUTABLE = 'tidy5' + +# List of XPath expressions which should be used for finding headers +# ({hx} is replaced by headers h1 through h6). +# You must change this if you use a custom theme that does not use +# "e-content entry-content" as a class for post and page contents. +# HEADER_PERMALINKS_XPATH_LIST = ['*//div[@class="e-content entry-content"]//{hx}'] +# Include *every* header (not recommended): +# HEADER_PERMALINKS_XPATH_LIST = ['*//{hx}'] + +# File blacklist for header permalinks. Contains output path +# (eg. 'output/index.html') +# HEADER_PERMALINKS_FILE_BLACKLIST = [] + # Expert setting! Create a gzipped copy of each generated file. Cheap server- # side optimization for very high traffic sites or low memory servers. # GZIP_FILES = False @@ -407,20 +677,6 @@ REDIRECTIONS = ${REDIRECTIONS} # return partial content of another representation for these resources. Do not # use this feature if you do not understand what this means. -# Compiler to process LESS files. -# LESS_COMPILER = 'lessc' - -# A list of options to pass to the LESS compiler. -# Final command is: LESS_COMPILER LESS_OPTIONS file.less -# LESS_OPTIONS = [] - -# Compiler to process Sass files. -# SASS_COMPILER = 'sass' - -# A list of options to pass to the Sass compiler. -# Final command is: SASS_COMPILER SASS_OPTIONS file.s(a|c)ss -# SASS_OPTIONS = [] - # ############################################################################# # Image Gallery Options # ############################################################################# @@ -436,18 +692,81 @@ REDIRECTIONS = ${REDIRECTIONS} # MAX_IMAGE_SIZE = 1280 # USE_FILENAME_AS_TITLE = True # EXTRA_IMAGE_EXTENSIONS = [] -# + +# Use a thumbnail (defined by ".. previewimage:" in the gallery's index) in +# list of galleries for each gallery +GALLERIES_USE_THUMBNAIL = False + +# Image to use as thumbnail for those galleries that don't have one +# None: show a grey square +# '/url/to/file': show the image in that url +GALLERIES_DEFAULT_THUMBNAIL = None + # If set to False, it will sort by filename instead. Defaults to True # GALLERY_SORT_BY_DATE = True + +# If set to True, EXIF data will be copied when an image is thumbnailed or +# resized. (See also EXIF_WHITELIST) +# PRESERVE_EXIF_DATA = False + +# If you have enabled PRESERVE_EXIF_DATA, this option lets you choose EXIF +# fields you want to keep in images. (See also PRESERVE_EXIF_DATA) # -# Folders containing images to be used in normal posts or pages. Images will be -# scaled down according to IMAGE_THUMBNAIL_SIZE and MAX_IMAGE_SIZE options, but -# will have to be referenced manually to be visible on the site -# (the thumbnail has ``.thumbnail`` added before the file extension). -# The format is a dictionary of {source: relative destination}. +# For a full list of field names, please see here: +# http://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf +# +# This is a dictionary of lists. Each key in the dictionary is the +# name of a IDF, and each list item is a field you want to preserve. +# If you have a IDF with only a '*' item, *EVERY* item in it will be +# preserved. If you don't want to preserve anything in a IDF, remove it +# from the setting. By default, no EXIF information is kept. +# Setting the whitelist to anything other than {} implies +# PRESERVE_EXIF_DATA is set to True +# To preserve ALL EXIF data, set EXIF_WHITELIST to {"*": "*"} + +# EXIF_WHITELIST = {} + +# Some examples of EXIF_WHITELIST settings: + +# Basic image information: +# EXIF_WHITELIST['0th'] = [ +# "Orientation", +# "XResolution", +# "YResolution", +# ] + +# If you want to keep GPS data in the images: +# EXIF_WHITELIST['GPS'] = ["*"] + +# Embedded thumbnail information: +# EXIF_WHITELIST['1st'] = ["*"] + +# If set to True, any ICC profile will be copied when an image is thumbnailed or +# resized. +# PRESERVE_ICC_PROFILES = False + +# Folders containing images to be used in normal posts or pages. +# IMAGE_FOLDERS is a dictionary of the form {"source": "destination"}, +# where "source" is the folder containing the images to be published, and +# "destination" is the folder under OUTPUT_PATH containing the images copied +# to the site. Thumbnail images will be created there as well. + +# To reference the images in your posts, include a leading slash in the path. +# For example, if IMAGE_FOLDERS = {'images': 'images'}, write +# +# .. image:: /images/tesla.jpg +# +# See the Nikola Handbook for details (in the “Embedding Images” and +# “Thumbnails” sections) + +# Images will be scaled down according to IMAGE_THUMBNAIL_SIZE and MAX_IMAGE_SIZE +# options, but will have to be referenced manually to be visible on the site +# (the thumbnail has ``.thumbnail`` added before the file extension by default, +# but a different naming template can be configured with IMAGE_THUMBNAIL_FORMAT). IMAGE_FOLDERS = {'images': 'images'} # IMAGE_THUMBNAIL_SIZE = 400 +# IMAGE_THUMBNAIL_FORMAT = '{name}.thumbnail{ext}' # ############################################################################# # HTML fragments and diverse things that are used by the templates @@ -495,49 +814,28 @@ IMAGE_FOLDERS = {'images': 'images'} # for the full URL with the page number of the main page to the normal (shorter) main # page URL. # INDEXES_PRETTY_PAGE_URL = False +# +# If the following is true, a page range navigation will be inserted to indices. +# Please note that this will undo the effect of INDEXES_STATIC, as all index pages +# must be recreated whenever the number of pages changes. +# SHOW_INDEX_PAGE_NAVIGATION = False + +# If the following is True, a meta name="generator" tag is added to pages. The +# generator tag is used to specify the software used to generate the page +# (it promotes Nikola). +# META_GENERATOR_TAG = True # Color scheme to be used for code blocks. If your theme provides -# "assets/css/code.css" this is ignored. +# "assets/css/code.css" this is ignored. Set to None to disable. # Can be any of: -# algol -# algol_nu -# arduino -# autumn -# borland -# bw -# colorful -# default -# emacs -# friendly -# fruity -# igor -# lovelace -# manni -# monokai -# murphy -# native -# paraiso_dark -# paraiso_light -# pastie -# perldoc -# rrt -# tango -# trac -# vim -# vs -# xcode +# algol, algol_nu, autumn, borland, bw, colorful, default, emacs, friendly, +# fruity, igor, lovelace, manni, monokai, murphy, native, paraiso-dark, +# paraiso-light, pastie, perldoc, rrt, tango, trac, vim, vs, xcode # This list MAY be incomplete since pygments adds styles every now and then. +# Check with list(pygments.styles.get_all_styles()) in an interpreter. +# # CODE_COLOR_SCHEME = 'default' -# If you use 'site-reveal' theme you can select several subthemes -# THEME_REVEAL_CONFIG_SUBTHEME = 'sky' -# You can also use: beige/serif/simple/night/default - -# Again, if you use 'site-reveal' theme you can select several transitions -# between the slides -# THEME_REVEAL_CONFIG_TRANSITION = 'cube' -# You can also use: page/concave/linear/none/default - # FAVICONS contains (name, file, size) tuples. # Used to create favicon link like this: # @@ -558,15 +856,16 @@ IMAGE_FOLDERS = {'images': 'images'} # {min_remaining_read} The string “{remaining_reading_time} min remaining to read” in the current language. # {paragraph_count} The amount of paragraphs in the post. # {remaining_paragraph_count} The amount of paragraphs in the post, sans the teaser. +# {post_title} The title of the post. # {{ A literal { (U+007B LEFT CURLY BRACKET) # }} A literal } (U+007D RIGHT CURLY BRACKET) # 'Read more...' for the index page, if INDEX_TEASERS is True (translatable) INDEX_READ_MORE_LINK = ${INDEX_READ_MORE_LINK} -# 'Read more...' for the RSS_FEED, if RSS_TEASERS is True (translatable) -RSS_READ_MORE_LINK = ${RSS_READ_MORE_LINK} +# 'Read more...' for the feeds, if FEED_TEASERS is True (translatable) +FEED_READ_MORE_LINK = ${FEED_READ_MORE_LINK} -# Append a URL query to the RSS_READ_MORE_LINK in Atom and RSS feeds. Advanced +# Append a URL query to the FEED_READ_MORE_LINK in Atom and RSS feeds. Advanced # option used for traffic source tracking. # Minimum example for use with Piwik: "pk_campaign=feed" # The following tags exist and are replaced for you: @@ -574,18 +873,18 @@ RSS_READ_MORE_LINK = ${RSS_READ_MORE_LINK} # {feedFormat} The name of the syndication format. # Example using replacement for use with Google Analytics: # "utm_source={feedRelUri}&utm_medium=nikola_feed&utm_campaign={feedFormat}_feed" -RSS_LINKS_APPEND_QUERY = False +FEED_LINKS_APPEND_QUERY = False # A HTML fragment describing the license, for the sidebar. # (translatable) LICENSE = "" # I recommend using the Creative Commons' wizard: -# http://creativecommons.org/choose/ +# https://creativecommons.org/choose/ # LICENSE = """ -# +# # Creative Commons License BY-NC-SA""" +# src="https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png">""" # A small copyright notice for the page footer (in HTML). # (translatable) @@ -601,6 +900,8 @@ CONTENT_FOOTER = 'Contents © {date} \ # tuples of tuples of positional arguments and dicts of keyword arguments # to format(). For example, {'en': (('Hello'), {'target': 'World'})} # results in CONTENT_FOOTER['en'].format('Hello', target='World'). +# If you need to use the literal braces '{' and '}' in your footer text, use +# '{{' and '}}' to escape them (str.format is used) # WARNING: If you do not use multiple languages with CONTENT_FOOTER, this # still needs to be a dict of this format. (it can be empty if you # do not need formatting) @@ -617,6 +918,12 @@ CONTENT_FOOTER_FORMATS = { ) } +# A simple copyright tag for inclusion in RSS feeds that works just +# like CONTENT_FOOTER and CONTENT_FOOTER_FORMATS +RSS_COPYRIGHT = 'Contents © {date} {author} {license}' +RSS_COPYRIGHT_PLAIN = 'Contents © {date} {author} {license}' +RSS_COPYRIGHT_FORMATS = CONTENT_FOOTER_FORMATS + # To use comments, you can choose between different third party comment # systems. The following comment systems are supported by Nikola: ${_SUPPORTED_COMMENT_SYSTEMS} @@ -628,20 +935,13 @@ COMMENT_SYSTEM = ${COMMENT_SYSTEM} # is in the manual. COMMENT_SYSTEM_ID = ${COMMENT_SYSTEM_ID} -# Enable annotations using annotateit.org? -# If set to False, you can still enable them for individual posts and pages -# setting the "annotations" metadata. -# If set to True, you can disable them for individual posts and pages using -# the "noannotations" metadata. -# ANNOTATIONS = False - -# Create index.html for page (story) folders? +# Create index.html for page folders? # WARNING: if a page would conflict with the index file (usually -# caused by setting slug to `index`), the STORY_INDEX +# caused by setting slug to `index`), the PAGE_INDEX # will not be generated for that directory. -# STORY_INDEX = False -# Enable comments on story pages? -# COMMENTS_IN_STORIES = False +# PAGE_INDEX = False +# Enable comments on pages (i.e. not posts)? +# COMMENTS_IN_PAGES = False # Enable comments on picture gallery pages? # COMMENTS_IN_GALLERIES = False @@ -654,17 +954,8 @@ COMMENT_SYSTEM_ID = ${COMMENT_SYSTEM_ID} # http://mysite/foo/bar/index.html => http://mysite/foo/bar/ # (Uses the INDEX_FILE setting, so if that is, say, default.html, # it will instead /foo/default.html => /foo) -# (Note: This was briefly STRIP_INDEX_HTML in v 5.4.3 and 5.4.4) STRIP_INDEXES = ${STRIP_INDEXES} -# Should the sitemap list directories which only include other directories -# and no files. -# Default to True -# If this is False -# e.g. /2012 includes only /01, /02, /03, /04, ...: don't add it to the sitemap -# if /2012 includes any files (including index.html)... add it to the sitemap -# SITEMAP_INCLUDE_FILELESS_DIRS = True - # List of files relative to the server root (!) that will be asked to be excluded # from indexing and other robotic spidering. * is supported. Will only be effective # if SITE_URL points to server root. The list is used to exclude resources from @@ -692,13 +983,14 @@ PRETTY_URLS = ${PRETTY_URLS} # Allows scheduling of posts using the rule specified here (new_post -s) # Specify an iCal Recurrence Rule: http://www.kanzaki.com/docs/ical/rrule.html # SCHEDULE_RULE = '' -# If True, use the scheduling rule to all posts by default +# If True, use the scheduling rule to all posts (not pages!) by default # SCHEDULE_ALL = False -# Do you want a add a Mathjax config file? +# Do you want to add a Mathjax config file? # MATHJAX_CONFIG = "" -# If you are using the compile-ipynb plugin, just add this one: +# If you want support for the $.$ syntax (which may conflict with running +# text!), just use this config: # MATHJAX_CONFIG = """ # # """ +# Want to use KaTeX instead of MathJax? While KaTeX may not support every +# feature yet, it's faster and the output looks better. +# USE_KATEX = False + +# KaTeX auto-render settings. If you want support for the $.$ syntax (which may +# conflict with running text!), just use this config: +# KATEX_AUTO_RENDER = """ +# delimiters: [ +# {left: "$$", right: "$$", display: true}, +# {left: "\\\\[", right: "\\\\]", display: true}, +# {left: "\\\\begin{equation*}", right: "\\\\end{equation*}", display: true}, +# {left: "$", right: "$", display: false}, +# {left: "\\\\(", right: "\\\\)", display: false} +# ] +# """ + # Do you want to customize the nbconversion of your IPython notebook? # IPYNB_CONFIG = {} # With the following example configuration you can use a custom jinja template # called `toggle.tpl` which has to be located in your site/blog main folder: -# IPYNB_CONFIG = {'Exporter':{'template_file': 'toggle'}} +# IPYNB_CONFIG = {'Exporter': {'template_file': 'toggle'}} # What Markdown extensions to enable? # You will also get gist, nikola and podcast because those are # done in the code, hope you don't mind ;-) # Note: most Nikola-specific extensions are done via the Nikola plugin system, # with the MarkdownExtension class and should not be added here. -# The default is ['fenced_code', 'codehilite'] -MARKDOWN_EXTENSIONS = ['fenced_code', 'codehilite', 'extra'] +# Defaults are markdown.extensions.(fenced_code|codehilite|extra) +# markdown.extensions.meta is required for Markdown metadata. +MARKDOWN_EXTENSIONS = ['markdown.extensions.fenced_code', 'markdown.extensions.codehilite', 'markdown.extensions.extra'] + +# Options to be passed to markdown extensions (See https://python-markdown.github.io/reference/) +# Default is {} (no config at all) +# MARKDOWN_EXTENSION_CONFIGS = {} -# Extra options to pass to the pandoc comand. + +# Extra options to pass to the pandoc command. # by default, it's empty, is a list of strings, for example # ['-F', 'pandoc-citeproc', '--bibliography=/Users/foo/references.bib'] +# Pandoc does not demote headers by default. To enable this, you can use, for example +# ['--base-header-level=2'] # PANDOC_OPTIONS = [] # Social buttons. This is sample code for AddThis (which was the default for a @@ -748,12 +1064,11 @@ MARKDOWN_EXTENSIONS = ['fenced_code', 'codehilite', 'extra'] #
  • # # -# +# # # """ # Show link to source for the posts? -# Formerly known as HIDE_SOURCELINK (inverse) # SHOW_SOURCELINK = True # Copy the source files for your pages? # Setting it to False implies SHOW_SOURCELINK = False @@ -771,24 +1086,28 @@ MARKDOWN_EXTENSIONS = ['fenced_code', 'codehilite', 'extra'] # them. Generate Atom for tags by setting TAG_PAGES_ARE_INDEXES to True. # Atom feeds are built based on INDEX_DISPLAY_POST_COUNT and not FEED_LENGTH # Switch between plain-text summaries and full HTML content using the -# RSS_TEASER option. RSS_LINKS_APPEND_QUERY is also respected. Atom feeds +# FEED_TEASER option. FEED_LINKS_APPEND_QUERY is also respected. Atom feeds # are generated even for old indexes and have pagination link relations # between each other. Old Atom feeds with no changes are marked as archived. # GENERATE_ATOM = False +# Only include teasers in Atom and RSS feeds. Disabling include the full +# content. Defaults to True. +# FEED_TEASERS = True + +# Strip HTML from Atom and RSS feed summaries and content. Defaults to False. +# FEED_PLAIN = False + +# Number of posts in Atom and RSS feeds. +# FEED_LENGTH = 10 + # RSS_LINK is a HTML fragment to link the RSS or Atom feeds. If set to None, # the base.tmpl will use the feed Nikola generates. However, you may want to # change it for a FeedBurner feed or something else. # RSS_LINK = None -# Show teasers (instead of full posts) in feeds? Defaults to True. -# RSS_TEASERS = True - -# Strip HTML in the RSS feed? Default to False -# RSS_PLAIN = False - # A search form to search this site, for the sidebar. You can use a Google -# custom search (http://www.google.com/cse/) +# custom search (https://www.google.com/cse/) # Or a DuckDuckGo search: https://duckduckgo.com/search_box.html # Default is no search form. # (translatable) @@ -799,7 +1118,7 @@ MARKDOWN_EXTENSIONS = ['fenced_code', 'codehilite', 'extra'] # # SEARCH_FORM = """ # -#
  • - {% endif %} - {% endfor %} - {% endfor %} - {% if items %} -

    {{ messages("Tags") }}

    - {% endif %} -{% endif %} -{% if items %} - -{% endif %} -{% endblock %} diff --git a/nikola/data/themes/bootstrap3/README.md b/nikola/data/themes/bootstrap3/README.md deleted file mode 100644 index 10e673a..0000000 --- a/nikola/data/themes/bootstrap3/README.md +++ /dev/null @@ -1,8 +0,0 @@ -A theme based on Bootstrap 3. - -There is a variant called bootstrap3-gradients which uses an extra CSS -file for a *visually enhanced experience* (according to Bootstrap -developers at least). This one uses the default bootstrap3 flat look. - -This theme supports Bootswtach font/color schemes (unlike -bootstrap3-gradients) through the `nikola bootswatch_theme` command. diff --git a/nikola/data/themes/bootstrap3/assets/css/bootstrap-theme.css.map b/nikola/data/themes/bootstrap3/assets/css/bootstrap-theme.css.map deleted file mode 120000 index 639bdc1..0000000 --- a/nikola/data/themes/bootstrap3/assets/css/bootstrap-theme.css.map +++ /dev/null @@ -1 +0,0 @@ -../../../../../../bower_components/bootstrap/dist/css/bootstrap-theme.css.map \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/css/bootstrap.css.map b/nikola/data/themes/bootstrap3/assets/css/bootstrap.css.map deleted file mode 120000 index 8448a3d..0000000 --- a/nikola/data/themes/bootstrap3/assets/css/bootstrap.css.map +++ /dev/null @@ -1 +0,0 @@ -../../../../../../bower_components/bootstrap/dist/css/bootstrap.css.map \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/css/colorbox.css b/nikola/data/themes/bootstrap3/assets/css/colorbox.css deleted file mode 120000 index 5f8b3b0..0000000 --- a/nikola/data/themes/bootstrap3/assets/css/colorbox.css +++ /dev/null @@ -1 +0,0 @@ -../../../../../../bower_components/jquery-colorbox/example3/colorbox.css \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/css/docs.css b/nikola/data/themes/bootstrap3/assets/css/docs.css deleted file mode 100644 index 189ea89..0000000 --- a/nikola/data/themes/bootstrap3/assets/css/docs.css +++ /dev/null @@ -1,160 +0,0 @@ -body { - font-weight: 300; -} - -a:hover, -a:focus { - text-decoration: none; -} - -.container { - max-width: 700px; -} - -h2 { - text-align: center; - font-weight: 300; -} - - -/* Header --------------------------------------------------- */ - -.jumbotron { - position: relative; - font-size: 16px; - color: #fff; - color: rgba(255,255,255,.75); - text-align: center; - background-color: #b94a48; - border-radius: 0; -} -.jumbotron h1, -.jumbotron .glyphicon-ok { - margin-bottom: 15px; - font-weight: 300; - letter-spacing: -1px; - color: #fff; -} -.jumbotron .glyphicon-ok { - font-size: 40px; - line-height: 1; -} -.btn-outline { - margin-top: 15px; - margin-bottom: 15px; - padding: 18px 24px; - font-size: inherit; - font-weight: 500; - color: #fff; /* redeclare to override the `.jumbotron a` */ - background-color: transparent; - border-color: #fff; - border-color: rgba(255,255,255,.5); - transition: all .1s ease-in-out; -} -.btn-outline:hover, -.btn-outline:active { - color: #b94a48; - background-color: #fff; - border-color: #fff; -} - -.jumbotron:after { - position: absolute; - right: 0; - bottom: 0; - left: 0; - z-index: 10; - display: block; - content: ""; - height: 30px; - background-image: -moz-linear-gradient(rgba(0, 0, 0, 0), rgba(0,0,0,.1)); - background-image: -webkit-linear-gradient(rgba(0, 0, 0, 0), rgba(0,0,0,.1)); -} - -.jumbotron p a, -.jumbotron-links a { - font-weight: 500; - color: #fff; - transition: all .1s ease-in-out; -} -.jumbotron p a:hover, -.jumbotron-links a:hover { - text-shadow: 0 0 10px rgba(255,255,255,.55); -} - -/* Textual links */ -.jumbotron-links { - margin-top: 15px; - margin-bottom: 0; - padding-left: 0; - list-style: none; - font-size: 14px; -} -.jumbotron-links li { - display: inline; -} -.jumbotron-links li + li { - margin-left: 20px; -} - -@media (min-width: 768px) { - .jumbotron { - padding-top: 100px; - padding-bottom: 100px; - font-size: 21px; - } - .jumbotron h1, - .jumbotron .glyphicon-ok { - font-size: 50px; - } -} - -/* Steps for setup --------------------------------------------------- */ - -.how-to { - padding: 50px 20px; - border-top: 1px solid #eee; -} -.how-to li { - font-size: 21px; - line-height: 1.5; - margin-top: 20px; -} -.how-to li p { - font-size: 16px; - color: #555; -} -.how-to code { - font-size: 85%; - color: #b94a48; - background-color: #fcf3f2; - word-wrap: break-word; - white-space: normal; -} - -/* Icons --------------------------------------------------- */ - -.the-icons { - padding: 40px 10px; - font-size: 20px; - line-height: 2; - color: #333; - text-align: center; -} -.the-icons .glyphicon { - padding-left: 15px; - padding-right: 15px; -} - -/* Footer --------------------------------------------------- */ - -.footer { - padding: 50px 30px; - color: #777; - text-align: center; - border-top: 1px solid #eee; -} diff --git a/nikola/data/themes/bootstrap3/assets/css/images/controls.png b/nikola/data/themes/bootstrap3/assets/css/images/controls.png deleted file mode 120000 index 841a726..0000000 --- a/nikola/data/themes/bootstrap3/assets/css/images/controls.png +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/example3/images/controls.png \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomCenter.png b/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomCenter.png deleted file mode 100644 index 0d4475e..0000000 Binary files a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomCenter.png and /dev/null differ diff --git a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomLeft.png b/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomLeft.png deleted file mode 100644 index 2775eba..0000000 Binary files a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomLeft.png and /dev/null differ diff --git a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomRight.png b/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomRight.png deleted file mode 100644 index f7f5137..0000000 Binary files a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderBottomRight.png and /dev/null differ diff --git a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderMiddleLeft.png b/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderMiddleLeft.png deleted file mode 100644 index a2d63d1..0000000 Binary files a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderMiddleLeft.png and /dev/null differ diff --git a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderMiddleRight.png b/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderMiddleRight.png deleted file mode 100644 index fd7c3e8..0000000 Binary files a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderMiddleRight.png and /dev/null differ diff --git a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopCenter.png b/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopCenter.png deleted file mode 100644 index 2937a9c..0000000 Binary files a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopCenter.png and /dev/null differ diff --git a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopLeft.png b/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopLeft.png deleted file mode 100644 index f9d458b..0000000 Binary files a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopLeft.png and /dev/null differ diff --git a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopRight.png b/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopRight.png deleted file mode 100644 index 74b8583..0000000 Binary files a/nikola/data/themes/bootstrap3/assets/css/images/ie6/borderTopRight.png and /dev/null differ diff --git a/nikola/data/themes/bootstrap3/assets/css/images/loading.gif b/nikola/data/themes/bootstrap3/assets/css/images/loading.gif deleted file mode 120000 index b192a75..0000000 --- a/nikola/data/themes/bootstrap3/assets/css/images/loading.gif +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/example3/images/loading.gif \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/css/theme.css b/nikola/data/themes/bootstrap3/assets/css/theme.css deleted file mode 100644 index 6964ec6..0000000 --- a/nikola/data/themes/bootstrap3/assets/css/theme.css +++ /dev/null @@ -1,207 +0,0 @@ -#container { - width: 960px; - margin: 0 auto; -} - -#contentcolumn { - max-width: 760px; -} -#q { - width: 150px; -} - -img { - max-width: 90%; -} - -.postbox { - border-bottom: 2px solid darkgrey; - margin-bottom: 12px; -} - -.titlebox { - text-align: right; -} - -#addthisbox {margin-bottom: 12px;} - -td.label { - /* Issue #290 */ - background-color: inherit; -} - -.footnote-reference { - /* Issue 290 */ - vertical-align: super; - font-size: xx-small; -} - - -.caption { - /* Issue 292 */ - text-align: center; - padding-top: 1em; -} - -div.figure > img, -div.figure > a > img { - /* Issue 292 */ - display: block; - margin-left: auto; - margin-right: auto; -} - -blockquote p, blockquote { - font-size: 17.5px; - font-weight: 300; - line-height: 1.25; -} - -ul.bricks > li { - display: inline; - background-color: lightblue; - padding: 8px; - border-radius: 5px; - line-height: 3; - white-space:nowrap; - margin: 3px; -} - -.at300b, .stMainServices, .stButton, .stButton_gradient { - box-sizing: content-box; -} - -pre, pre code { - white-space: pre; - word-wrap: normal; - overflow: auto; -} - -article.post-micro { - font-family: Georgia, 'Times New Roman', Times, serif; - font-size: 1.5em; -} - -.image-block { - display: inline-block; -} - -.flowr_row { - width: 100%; -} - -.tags { - padding-left: 0; - margin-left: -5px; - list-style: none; - text-align: center; - -} - -.tags > li { - display: inline-block; - min-width: 10px; - padding: 3px 7px; - font-size: 12px; - font-weight: bold; - line-height: 1; - color: #fff; - text-align: center; - white-space: nowrap; - vertical-align: baseline; - background-color: #999; - border-radius: 10px; -} - -.tags > li a { - color: #fff; -} - -.metadata p:before, -.postlist .listdate:before { - content: " — "; -} - -.metadata p:first-of-type:before { - content: ""; -} - -.metadata p { - display: inline; -} - -.posttranslations h3 { - display: inline; - font-size: 1em; - font-weight: bold; -} - -.posttranslations h3:last-child { - display: none; -} - -.entry-content { - margin-top: 1em; -} - -.navbar-brand { - padding: 0 15px; -} - -.navbar-brand #blog-title { - padding: 15px 0; - display: inline-block; -} - -.navbar-brand #logo { - max-width: 100%; -} - -.navbar-brand>img { - display: inline; -} - -.row { - margin: 0; -} - -/* for alignment with Bootstrap's .entry-content styling */ -.entry-summary { - margin-top: 1em; -} - -/* Custom page footer */ -#footer { - padding-top: 19px; - color: #777; - border-top: 1px solid #e5e5e5; -} - -/* hat tip bootstrap/html5 boilerplate */ -@media print { - *, *:before, *:after { - font-family: Garamond, Junicode, serif; - } - - body { - font-size: 12pt; - } - - article .entry-title a[href]:after, - article .metadata a[href]:after, - article .tags a[href]:after { - content: ""; - } - - article .metadata .sourceline { - display: none; - } - - article .metadata .linkline a[href]:after { - content: " (" attr(href) ")"; - } - - .navbar { - display: none; - } -} diff --git a/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.eot b/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.eot deleted file mode 120000 index c2dfd17..0000000 --- a/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.eot +++ /dev/null @@ -1 +0,0 @@ -../../../../../../bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.eot \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.svg b/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.svg deleted file mode 120000 index 30abe9d..0000000 --- a/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.svg +++ /dev/null @@ -1 +0,0 @@ -../../../../../../bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.svg \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.ttf b/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.ttf deleted file mode 120000 index 93e3bf3..0000000 --- a/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.ttf +++ /dev/null @@ -1 +0,0 @@ -../../../../../../bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.woff b/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.woff deleted file mode 120000 index f7595ae..0000000 --- a/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.woff +++ /dev/null @@ -1 +0,0 @@ -../../../../../../bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.woff2 b/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.woff2 deleted file mode 120000 index 8c1e4d3..0000000 --- a/nikola/data/themes/bootstrap3/assets/fonts/glyphicons-halflings-regular.woff2 +++ /dev/null @@ -1 +0,0 @@ -../../../../../../bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2 \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ar.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ar.js deleted file mode 120000 index f83073f..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ar.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-ar.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-bg.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-bg.js deleted file mode 120000 index bafc4e0..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-bg.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-bg.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-bn.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-bn.js deleted file mode 120000 index 9b995d8..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-bn.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-bn.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ca.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ca.js deleted file mode 120000 index a749232..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ca.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-ca.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-cs.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-cs.js deleted file mode 120000 index e4a595c..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-cs.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-cs.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-da.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-da.js deleted file mode 120000 index 1e9a1d6..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-da.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-da.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-de.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-de.js deleted file mode 120000 index 748f53b..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-de.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-de.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-es.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-es.js deleted file mode 120000 index 1154fb5..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-es.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-es.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-et.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-et.js deleted file mode 120000 index 483e192..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-et.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-et.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fa.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fa.js deleted file mode 120000 index a30b13c..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fa.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-fa.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fi.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fi.js deleted file mode 120000 index 2a7e8ad..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fi.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-fi.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fr.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fr.js deleted file mode 120000 index e359290..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-fr.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-fr.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-gl.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-gl.js deleted file mode 120000 index 04fa276..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-gl.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-gl.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-gr.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-gr.js deleted file mode 120000 index d8105ab..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-gr.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-gr.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-he.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-he.js deleted file mode 120000 index 72dddf5..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-he.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-he.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-hr.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-hr.js deleted file mode 120000 index 34aa3c0..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-hr.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-hr.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-hu.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-hu.js deleted file mode 120000 index a87f03c..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-hu.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-hu.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-id.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-id.js deleted file mode 120000 index 31053b8..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-id.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-id.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-it.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-it.js deleted file mode 120000 index aad9d22..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-it.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-it.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ja.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ja.js deleted file mode 120000 index 3ea27c2..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ja.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-ja.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-kr.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-kr.js deleted file mode 120000 index 3e23b4a..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-kr.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-kr.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-lt.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-lt.js deleted file mode 120000 index 374b9bb..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-lt.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-lt.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-lv.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-lv.js deleted file mode 120000 index 101b476..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-lv.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-lv.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-my.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-my.js deleted file mode 120000 index 8e14f15..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-my.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-my.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-nl.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-nl.js deleted file mode 120000 index 2d03d48..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-nl.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-nl.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-no.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-no.js deleted file mode 120000 index 9af0ba7..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-no.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-no.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-pl.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-pl.js deleted file mode 120000 index 34f8ab1..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-pl.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-pl.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-pt-BR.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-pt-BR.js deleted file mode 120000 index e20bd38..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-pt-BR.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-pt-BR.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ro.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ro.js deleted file mode 120000 index 555f2e6..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ro.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-ro.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ru.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ru.js deleted file mode 120000 index bac4855..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-ru.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-ru.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-si.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-si.js deleted file mode 120000 index 65b0492..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-si.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-si.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sk.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sk.js deleted file mode 120000 index 99859fd..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sk.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-sk.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sr.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sr.js deleted file mode 120000 index c4fd9d5..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sr.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-sr.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sv.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sv.js deleted file mode 120000 index d7f26e0..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-sv.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-sv.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-tr.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-tr.js deleted file mode 120000 index 86fd98f..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-tr.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-tr.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-uk.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-uk.js deleted file mode 120000 index 7cd1336..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-uk.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-uk.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-zh-CN.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-zh-CN.js deleted file mode 120000 index e6c5965..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-zh-CN.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-zh-CN.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-zh-TW.js b/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-zh-TW.js deleted file mode 120000 index bd2254c..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/colorbox-i18n/jquery.colorbox-zh-TW.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../../bower_components/jquery-colorbox/i18n/jquery.colorbox-zh-TW.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/flowr.plugin.js b/nikola/data/themes/bootstrap3/assets/js/flowr.plugin.js deleted file mode 100644 index c0d986b..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/flowr.plugin.js +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Flowr.js - Simple jQuery plugin to emulate Flickr's justified view - * For usage information refer to http://github.com/kalyan02/flowr-js - * - * - * @author: Kalyan Chakravarthy (http://KalyanChakravarthy.net) - * @version: v0.1 - */ -(function($){ - //$("#container2").css( 'border', '1px solid #ccc'); - $.fn.flowr = function(options) { - - $this = this; - var ROW_CLASS_NAME = 'flowr-row'; // Class name for the row of flowy - var MAX_LAST_ROW_GAP = 25; // If the width of last row is lesser than max-width, recalculation is needed - var NO_COPY_FIELDS = [ 'complete', 'data', 'responsive' ]; // these attributes will not be carried forward for append related calls - var DEFAULTS = { - 'data' : [], - 'padding' : 5, // whats the padding between flowy items - 'height' : 240, // Minimum height an image row should take - 'render' : null, // callback function to get the tag - 'append' : false, // TODO - 'widthAttr' : 'width', // a custom data structure can specify which attribute refers to height/width - 'heightAttr' : 'height', - 'maxScale' : 1.5, // In case there is only 1 elment in last row - 'maxWidth' : this.width()-1, // 1px is just for offset - 'itemWidth' : null, // callback function for width - 'itemHeight' : null, // callback function for height - 'complete' : null, // complete callback - 'rowClassName' : ROW_CLASS_NAME, - 'rows' : -1, // Maximum number of rows to render. -1 for no limit. - 'responsive' : true // make content responsive - }; - var settings = $.extend( DEFAULTS, options); - - // If data is being appended, we already have settings - // If we already have settings, retrieve them - if( settings.append && $this.data('lastSettings') ) { - lastSettings = $this.data('lastSettings'); - - // Copy over the settings from previous init - for( attr in DEFAULTS ) { - if( NO_COPY_FIELDS.indexOf(attr)<0 && settings[attr] == DEFAULTS[attr] ) { - settings[attr] = lastSettings[attr]; - } - } - - // Check if we have an incomplete last row - lastRow = $this.data('lastRow'); - if( lastRow.data.length > 0 && settings.maxWidth-lastRow.width > MAX_LAST_ROW_GAP ) { - // Prepend the incomplete row to newly loaded data and redraw - lastRowData = lastSettings.data.slice( lastSettings.data.length - lastRow.data.length - 1 ); - settings.data = lastRowData.concat(settings.data); - - // Remove the incomplete row - // TODO: Don't reload this stuff later. Reattach to new row. - $( '.' + settings.rowClassName + ':last', $this ).detach(); - } else { - // console.log( lastRow.data.length ); - // console.log( lastRow.width ); - } - } - - // only on the first initial call - if( !settings.responsive && !settings.append ) - $this.width( $this.width() ); - - // Basic sanity checks - if( !(settings.data instanceof Array) ) - return; - - if( typeof(settings.padding) != 'number' ) - settings.padding = parseInt( settings.padding ); - - if( typeof(settings.itemWidth) != 'function' ) { - settings.itemWidth = function(data) { - return data[ settings.widthAttr ]; - } - } - - if( typeof(settings.itemHeight) != 'function' ) { - settings.itemHeight = function(data) { - return data[ settings.heightAttr ]; - } - } - - // A standalone utility to calculate the item widths for a particular row - // Returns rowWidth: width occupied & data : the items in the new row - var utils = { - getNextRow : function( data, settings ) { - var itemIndex = 0; - var itemsLength = data.length; - var lineItems = []; - var lineWidth = 0; - var maxWidth = settings.maxWidth; - var paddingSize = settings.padding; - - // console.log( 'maxItems=' + data.length ); - - requiredPadding = function() { - var extraPads = arguments.length == 1 ? arguments[0] : 0; - return (lineItems.length - 1 + extraPads) * settings.padding; - } - - while( lineWidth + requiredPadding() < settings.maxWidth && (itemIndex < itemsLength) ) { - var itemData = data[ itemIndex ]; - var itemWidth = settings.itemWidth.call( $this, itemData ); - var itemHeight = settings.itemHeight.call( $this, itemData ); - - var minHeight = settings.height; - var minWidth = Math.floor( itemWidth * settings.height / itemHeight ); - - var newLineWidth = lineWidth + minWidth + requiredPadding(1); - - if (minWidth > settings.maxWidth) { - // very short+wide images like panoramas - // show them even if ugly, as wide as possible - minWidth = settings.maxWidth-1; - minHeight = settings.height * minHeight / minWidth; - } - - // console.log( 'lineWidth = ' + lineWidth ); - // console.log( 'newLineWidth = ' + newLineWidth ); - if( newLineWidth < settings.maxWidth ) { - lineItems.push({ - 'height' : minHeight, - 'width' : minWidth, - 'itemData' : itemData - }); - - lineWidth += minWidth; - itemIndex ++; - } else { - // We'd have exceeded width. So break off to scale. - // console.log( 'breaking off = ' + itemIndex ); - // console.log( 'leave off size = ' + lineItems.length ); - break; - } - } //while - - // Scale the size to max width - testWidth=0; - if( lineWidth < settings.maxWidth ) { - var fullScaleWidth = settings.maxWidth - requiredPadding() - 10; - var currScaleWidth = lineWidth; - var scaleFactor = fullScaleWidth / currScaleWidth; - if( scaleFactor > settings.maxScale ) - scaleFactor = 1; - - var newHeight = Math.round( settings.height * scaleFactor ); - for( i=0; i 0 ) { - if( settings.rows > 0 && currentRow >= settings.rows ) - break; - // remove the number of elements in the new row from the top of data stack - data.splice( 0, rowData.data.length ); - - // Create a new row div, add class, append the htmls and insert the flowy items - var $row = $('
    ').addClass(settings.rowClassName); - for( i=0; i rowData.data.length="+rowData.data.length +" rowData.width="+rowData.width ); - - currentRow++; - $this.data('lastRow', rowData ); - } - // store the current state of settings and the items in last row - // we'll need this info when we append more items - $this.data('lastSettings', settings ); - - // onComplete callback - // pass back info about list of rows and items rendered - if( typeof (settings.complete) == 'function' ) { - var completeData = { - renderedRows : currentRow, - renderedItems : currentItem - } - settings.complete.call( $this, completeData ); - } - }); - }; - -})(jQuery); diff --git a/nikola/data/themes/bootstrap3/assets/js/jquery.colorbox.js b/nikola/data/themes/bootstrap3/assets/js/jquery.colorbox.js deleted file mode 120000 index 5ee7a90..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/jquery.colorbox.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../bower_components/jquery-colorbox/jquery.colorbox.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/assets/js/jquery.js b/nikola/data/themes/bootstrap3/assets/js/jquery.js deleted file mode 120000 index 966173b..0000000 --- a/nikola/data/themes/bootstrap3/assets/js/jquery.js +++ /dev/null @@ -1 +0,0 @@ -../../../../../../bower_components/jquery/dist/jquery.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap3/bundles b/nikola/data/themes/bootstrap3/bundles deleted file mode 100644 index 8bdc591..0000000 --- a/nikola/data/themes/bootstrap3/bundles +++ /dev/null @@ -1,4 +0,0 @@ -assets/css/all-nocdn.css=bootstrap.css,rst.css,code.css,colorbox.css,theme.css,custom.css -assets/css/all.css=rst.css,code.css,colorbox.css,theme.css,custom.css -assets/js/all-nocdn.js=jquery.min.js,bootstrap.min.js,jquery.colorbox-min.js,moment-with-locales.min.js,fancydates.js -assets/js/all.js=jquery.colorbox-min.js,moment-with-locales.min.js,fancydates.js diff --git a/nikola/data/themes/bootstrap3/engine b/nikola/data/themes/bootstrap3/engine deleted file mode 100644 index 2951cdd..0000000 --- a/nikola/data/themes/bootstrap3/engine +++ /dev/null @@ -1 +0,0 @@ -mako diff --git a/nikola/data/themes/bootstrap3/parent b/nikola/data/themes/bootstrap3/parent deleted file mode 100644 index df967b9..0000000 --- a/nikola/data/themes/bootstrap3/parent +++ /dev/null @@ -1 +0,0 @@ -base diff --git a/nikola/data/themes/bootstrap3/templates/base.tmpl b/nikola/data/themes/bootstrap3/templates/base.tmpl deleted file mode 100644 index 5f90733..0000000 --- a/nikola/data/themes/bootstrap3/templates/base.tmpl +++ /dev/null @@ -1,94 +0,0 @@ -## -*- coding: utf-8 -*- -<%namespace name="base" file="base_helper.tmpl" import="*" /> -<%namespace name="notes" file="annotation_helper.tmpl" import="*" /> -${set_locale(lang)} -${base.html_headstart()} -<%block name="extra_head"> -### Leave this block alone. - -${template_hooks['extra_head']()} - - -${messages("Skip to main content")} - - - - - - - -
    -
    - -
    - ${template_hooks['page_header']()} - <%block name="content"> -
    - - -
    - ${content_footer} - ${template_hooks['page_footer']()} -
    -
    -
    - -${base.late_load_js()} - - - - - <%block name="extra_js"> - % if annotations and post and not post.meta('noannotations'): - ${notes.code()} - % elif not annotations and post and post.meta('annotations'): - ${notes.code()} - % endif -${body_end} -${template_hooks['body_end']()} - - diff --git a/nikola/data/themes/bootstrap3/templates/base_helper.tmpl b/nikola/data/themes/bootstrap3/templates/base_helper.tmpl deleted file mode 100644 index a1e7508..0000000 --- a/nikola/data/themes/bootstrap3/templates/base_helper.tmpl +++ /dev/null @@ -1,184 +0,0 @@ -## -*- coding: utf-8 -*- - -<%namespace name="notes" file="annotation_helper.tmpl" import="*" /> -<%def name="html_headstart()"> - - - - - - %if description: - - %endif - - %if title == blog_title: - ${blog_title|h} - %else: - ${title|h} | ${blog_title|h} - %endif - - ${html_stylesheets()} - ${html_feedlinks()} - - - %if favicons: - %for name, file, size in favicons: - - %endfor - %endif - - % if comment_system == 'facebook': - - % endif - - %if prevlink: - - %endif - %if nextlink: - - %endif - - ${mathjax_config} - %if use_cdn: - - %else: - - %endif - - ${extra_head_data} - - -<%def name="late_load_js()"> - %if use_bundles: - %if use_cdn: - - - - %else: - - %endif - %else: - %if use_cdn: - - - %else: - - - - - %endif - - %endif - %if colorbox_locales[lang]: - - %endif - ${social_buttons_code} - - - -<%def name="html_stylesheets()"> - %if use_bundles: - %if use_cdn: - - - %else: - - %endif - %else: - %if use_cdn: - - %else: - - %endif - - - - - %if has_custom_css: - - %endif - %endif - % if needs_ipython_css: - - - % endif - % if annotations and post and not post.meta('noannotations'): - ${notes.css()} - % elif not annotations and post and post.meta('annotations'): - ${notes.css()} - % endif - - -<%def name="html_navigation_links()"> - %for url, text in navigation_links[lang]: - % if isinstance(url, tuple): -
  • ${text} ${messages("(active)", lang)} - %else: -
  • ${text} - %endif - % endif - %endfor - - -<%def name="html_feedlinks()"> - %if rss_link: - ${rss_link} - %elif generate_rss: - %if len(translations) > 1: - %for language in sorted(translations): - - %endfor - %else: - - %endif - %endif - %if generate_atom: - %if len(translations) > 1: - %for language in sorted(translations): - - %endfor - %else: - - %endif - %endif - - -<%def name="html_translations()"> - %for langname in sorted(translations): - %if langname != lang: -
  • ${messages("LANGUAGE", langname)}
  • - %endif - %endfor - diff --git a/nikola/data/themes/bootstrap3/templates/gallery.tmpl b/nikola/data/themes/bootstrap3/templates/gallery.tmpl deleted file mode 100644 index 3d6c01a..0000000 --- a/nikola/data/themes/bootstrap3/templates/gallery.tmpl +++ /dev/null @@ -1,94 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="base.tmpl"/> -<%namespace name="comments" file="comments_helper.tmpl"/> -<%namespace name="ui" file="crumbs.tmpl" import="bar"/> -<%block name="sourcelink"> - -<%block name="content"> - ${ui.bar(crumbs)} - %if title: -

    ${title|h}

    - %endif - %if post: -

    - ${post.text()} -

    - %endif - %if folders: -
      - % for folder, ftitle in folders: -
    •  ${ftitle}
    • - % endfor -
    - %endif - - -%if photo_array: - -%endif -%if site_has_comments and enable_comments: -${comments.comment_form(None, permalink, title)} -%endif - - -<%block name="extra_head"> -${parent.extra_head()} - - - - - -<%block name="extra_js"> - - - diff --git a/nikola/data/themes/bootstrap3/templates/listing.tmpl b/nikola/data/themes/bootstrap3/templates/listing.tmpl deleted file mode 100644 index 7b09e3e..0000000 --- a/nikola/data/themes/bootstrap3/templates/listing.tmpl +++ /dev/null @@ -1,28 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="base.tmpl"/> -<%namespace name="ui" file="crumbs.tmpl" import="bar"/> - -<%block name="content"> -${ui.bar(crumbs)} -%if folders or files: -
      -% for name in folders: -
    • ${name} -% endfor -% for name in files: -
    • ${name} -% endfor -
    -%endif -% if code: - ${code} -% endif - - -<%block name="sourcelink"> -% if source_link: -
  • - ${messages("Source")} -
  • -% endif - diff --git a/nikola/data/themes/bootstrap3/templates/post.tmpl b/nikola/data/themes/bootstrap3/templates/post.tmpl deleted file mode 100644 index 8d9f88a..0000000 --- a/nikola/data/themes/bootstrap3/templates/post.tmpl +++ /dev/null @@ -1,59 +0,0 @@ -## -*- coding: utf-8 -*- -<%namespace name="helper" file="post_helper.tmpl"/> -<%namespace name="pheader" file="post_header.tmpl"/> -<%namespace name="comments" file="comments_helper.tmpl"/> -<%inherit file="base.tmpl"/> - -<%block name="extra_head"> - ${parent.extra_head()} - % if post.meta('keywords'): - - % endif - %if post.description(): - - %endif - - %if post.prev_post: - - %endif - %if post.next_post: - - %endif - % if post.is_draft: - - % endif - ${helper.open_graph_metadata(post)} - ${helper.twitter_card_information(post)} - ${helper.meta_translations(post)} - - -<%block name="content"> -
    - ${pheader.html_post_header()} -
    - ${post.text()} -
    - - % if not post.meta('nocomments') and site_has_comments: -
    -

    ${messages("Comments")}

    - ${comments.comment_form(post.permalink(absolute=True), post.title(), post._base_path)} -
    - % endif - ${helper.mathjax_script(post)} -
    -${comments.comment_link_script()} - - -<%block name="sourcelink"> -% if show_sourcelink: -
  • - ${messages("Source")} -
  • -% endif - diff --git a/nikola/data/themes/bootstrap3/templates/slides.tmpl b/nikola/data/themes/bootstrap3/templates/slides.tmpl deleted file mode 100644 index a73848a..0000000 --- a/nikola/data/themes/bootstrap3/templates/slides.tmpl +++ /dev/null @@ -1,24 +0,0 @@ -<%block name="content"> - - diff --git a/nikola/data/themes/bootstrap3/templates/tags.tmpl b/nikola/data/themes/bootstrap3/templates/tags.tmpl deleted file mode 100644 index ead3b0a..0000000 --- a/nikola/data/themes/bootstrap3/templates/tags.tmpl +++ /dev/null @@ -1,38 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="base.tmpl"/> - -<%block name="content"> -

    ${title}

    -% if cat_items: - % if items: -

    ${messages("Categories")}

    - % endif - % for text, full_name, path, link, indent_levels, indent_change_before, indent_change_after in cat_hierarchy: - % for i in range(indent_change_before): -
      - % endfor -
    • ${text} - % if indent_change_after <= 0: -
    • - % endif - % for i in range(-indent_change_after): -
    - % if i + 1 < len(indent_levels): - - % endif - % endfor - % endfor - % if items: -

    ${messages("Tags")}

    - % endif -%endif -% if items: -
      - % for text, link in items: - % if text not in hidden_tags: -
    • ${text}
    • - % endif - % endfor -
    -% endif - diff --git a/nikola/data/themes/bootstrap4-jinja/README.md b/nikola/data/themes/bootstrap4-jinja/README.md new file mode 100644 index 0000000..bb1b484 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/README.md @@ -0,0 +1,10 @@ +This is a theme based on Bootstrap 4. + +The theme is a good building block for a site. It is based on a simple navbar + +content layout. For a more blog-style layout, check out `bootblog4`. + +Note that unlike previous versions of Bootstrap, icon fonts are not built-in. +You can use Font Awesome for this. + +This theme supports Bootswatch font/color schemes through the `nikola +bootwatch_theme` command. diff --git a/nikola/data/themes/bootstrap4-jinja/assets/css/bootstrap.min.css b/nikola/data/themes/bootstrap4-jinja/assets/css/bootstrap.min.css new file mode 120000 index 0000000..8c8dc62 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/assets/css/bootstrap.min.css @@ -0,0 +1 @@ +../../../../../../npm_assets/node_modules/bootstrap/dist/css/bootstrap.min.css \ No newline at end of file diff --git a/nikola/data/themes/bootstrap4-jinja/assets/css/theme.css b/nikola/data/themes/bootstrap4-jinja/assets/css/theme.css new file mode 100644 index 0000000..20eee8e --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/assets/css/theme.css @@ -0,0 +1,232 @@ +img { + max-width: 100%; +} + +.titlebox { + text-align: right; +} + + +td.label { + /* Issue #290 */ + background-color: inherit; +} + +.footnote-reference { + /* Issue 290 */ + vertical-align: super; + font-size: xx-small; +} + +.caption { + /* Issue 292 */ + text-align: center; + padding-top: 1em; +} + +div.figure > img, +div.figure > a > img { + /* Issue 292 */ + display: block; + margin-left: auto; + margin-right: auto; +} + +blockquote p, blockquote { + font-size: 1.25rem; + font-weight: 300; + line-height: 1.25; +} + +ul.bricks > li { + display: inline; + background-color: lightblue; + padding: 8px; + border-radius: 5px; + line-height: 3; + white-space:nowrap; + margin: 3px; +} + +pre, pre code { + white-space: pre; + word-wrap: normal; + overflow: auto; +} + +article.post-micro { + font-family: Georgia, 'Times New Roman', Times, serif; + font-size: 1.5em; +} + +.image-block { + display: inline-block; +} + +.tags { + padding-left: 0; + margin-left: -5px; + list-style: none; + text-align: center; + +} + +.tags > li { + display: inline-block; +} +.tags > li a { + display: inline-block; + padding: .25em .4em; + font-size: 75%; + font-weight: 700; + line-height: 1; + color: #fff; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25rem; + background-color: #868e96; +} + +.tags > li a:hover { + color: #fff; + text-decoration: none; + background-color: #6c757d; +} + +.metadata p:before, +.postlist .listdate:after { + content: " — "; +} + +.metadata p:first-of-type:before { + content: ""; +} + +.metadata p { + display: inline; +} + +.posttranslations h3 { + display: inline; + font-size: 1em; + font-weight: bold; +} + +.posttranslations h3:last-child { + display: none; +} + +.entry-content { + margin-top: 1em; +} + +/* for alignment with Bootstrap's .entry-content styling */ +.entry-summary { + margin-top: 1em; +} + +/* Custom page footer */ +#footer { + padding-top: 19px; + color: #777; + border-top: 1px solid #e5e5e5; +} + +/* hat tip bootstrap/html5 boilerplate */ +@media print { + *, *:before, *:after { + font-family: Garamond, Junicode, serif; + } + + body { + font-size: 12pt; + } + + article .entry-title a[href]:after, + article .metadata a[href]:after, + article .tags a[href]:after { + content: ""; + } + + article .metadata .sourceline { + display: none; + } + + article .metadata .linkline a[href]:after { + content: " (" attr(href) ")"; + } + + .navbar { + display: none; + } +} + +pre, .codetable { + border: 1px solid #ccc; + border-radius: 0.25rem; + margin-bottom: 1rem; +} + +pre { + padding: 0.75rem; +} + +.codetable tr:first-child td.linenos { + border-top-left-radius: 0.25rem; +} + +.codetable tr:last-child td.linenos { + border-bottom-left-radius: 0.25rem; +} + +.postindexpager { + padding-bottom: 1rem; +} + +ul.navbar-nav { + margin-top: 0; +} + +ul.pager { + display: flex; + padding-left: 0; + list-style: none; + border-radius: .25rem; + padding-left: 0; + margin: 0.5rem 0; +} + +ul.pager li.previous { + margin-right: auto; + display: inline; +} + +ul.pager li.next { + margin-left: auto; + display: inline; +} + + +ul.pager li a { + display: inline; + position: relative; + padding: .5rem .75rem; + margin-left: -1px; + line-height: 1.25; + border: 1px solid #ddd; + border-radius: .25rem; +} + +pre.code { + white-space: pre-wrap; +} + +.byline a:not(:last-child):after { + content: ","; +} + +/* Override incorrect Bootstrap 4 default */ +html[dir="rtl"] body { + text-align: right; +} diff --git a/nikola/data/themes/bootstrap4-jinja/assets/js/bootstrap.min.js b/nikola/data/themes/bootstrap4-jinja/assets/js/bootstrap.min.js new file mode 120000 index 0000000..593bffb --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/assets/js/bootstrap.min.js @@ -0,0 +1 @@ +../../../../../../npm_assets/node_modules/bootstrap/dist/js/bootstrap.min.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap4-jinja/assets/js/jquery.min.js b/nikola/data/themes/bootstrap4-jinja/assets/js/jquery.min.js new file mode 120000 index 0000000..2a592f6 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/assets/js/jquery.min.js @@ -0,0 +1 @@ +../../../../../../npm_assets/node_modules/jquery/dist/jquery.min.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap4-jinja/assets/js/popper.min.js b/nikola/data/themes/bootstrap4-jinja/assets/js/popper.min.js new file mode 120000 index 0000000..43fca04 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/assets/js/popper.min.js @@ -0,0 +1 @@ +../../../../../../npm_assets/node_modules/popper.js/dist/umd/popper.min.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap4-jinja/bootstrap4-jinja.theme b/nikola/data/themes/bootstrap4-jinja/bootstrap4-jinja.theme new file mode 100644 index 0000000..be27ebc --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/bootstrap4-jinja.theme @@ -0,0 +1,12 @@ +[Theme] +engine = jinja +parent = base-jinja +author = The Nikola Contributors +author_url = https://getnikola.com/ +license = MIT +based_on = Bootstrap 4 +tags = bootstrap + +[Family] +family = bootstrap4 +mako-version = bootstrap4 diff --git a/nikola/data/themes/bootstrap4-jinja/bundles b/nikola/data/themes/bootstrap4-jinja/bundles new file mode 120000 index 0000000..500c93e --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/bundles @@ -0,0 +1 @@ +../bootstrap4/bundles \ No newline at end of file diff --git a/nikola/data/themes/bootstrap4-jinja/templates/authors.tmpl b/nikola/data/themes/bootstrap4-jinja/templates/authors.tmpl new file mode 100644 index 0000000..922de74 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/templates/authors.tmpl @@ -0,0 +1,25 @@ +{# -*- coding: utf-8 -*- #} +{% extends 'base.tmpl' %} +{% import 'feeds_translations_helper.tmpl' as feeds_translations with context %} + +{% block extra_head %} + {{ feeds_translations.head(kind=kind, feeds=False) }} +{% endblock %} + +{% block content %} +{% if items %} +

    {{ messages("Authors") }}

    + +{% endif %} +{% if items %} +
      + {% for text, link in items %} + {% if text not in hidden_authors %} +
    • {{ text|e }}
    • + {% endif %} + {% endfor %} +
    +{% endif %} +{% endblock %} diff --git a/nikola/data/themes/bootstrap4-jinja/templates/base.tmpl b/nikola/data/themes/bootstrap4-jinja/templates/base.tmpl new file mode 100644 index 0000000..0748bb2 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/templates/base.tmpl @@ -0,0 +1,105 @@ +{# -*- coding: utf-8 -*- #} +{% import 'base_helper.tmpl' as base with context %} +{% import 'annotation_helper.tmpl' as notes with context %} +{{ set_locale(lang) }} +{{ base.html_headstart() }} +{% block extra_head %} +{# Leave this block alone. #} +{% endblock %} +{{ template_hooks['extra_head']() }} + + +{{ messages("Skip to main content") }} + + + + + + + +
    +
    + + {{ template_hooks['page_header']() }} + {% block extra_header %}{% endblock %} + {% block content %}{% endblock %} + + +
    + {{ content_footer }} + {{ template_hooks['page_footer']() }} + {% block extra_footer %}{% endblock %} +
    +
    +
    + +{{ base.late_load_js() }} + {% if date_fanciness != 0 %} + + + + {% endif %} + {% block extra_js %}{% endblock %} + +{{ body_end }} +{{ template_hooks['body_end']() }} + + diff --git a/nikola/data/themes/bootstrap4-jinja/templates/base_helper.tmpl b/nikola/data/themes/bootstrap4-jinja/templates/base_helper.tmpl new file mode 100644 index 0000000..b4bcf85 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/templates/base_helper.tmpl @@ -0,0 +1,165 @@ +{# -*- coding: utf-8 -*- #} +{% import 'feeds_translations_helper.tmpl' as feeds_translations with context %} + +{% macro html_headstart() %} + + + + + {% if description %} + + {% endif %} + + {% if title == blog_title %} + {{ blog_title|e }} + {% else %} + {{ title|e }} | {{ blog_title|e }} + {% endif %} + + {{ html_stylesheets() }} + + {% if meta_generator_tag %} + + {% endif %} + {{ html_feedlinks() }} + + + {% if favicons %} + {% for name, file, size in favicons %} + + {% endfor %} + {% endif %} + + {% if comment_system == 'facebook' %} + + {% endif %} + + {% if prevlink %} + + {% endif %} + {% if nextlink %} + + {% endif %} + + {% if use_cdn %} + + {% else %} + + {% endif %} + + {{ extra_head_data }} +{% endmacro %} + +{% macro late_load_js() %} + {% if use_cdn %} + + + + + {% endif %} + {% if use_bundles and use_cdn %} + + {% elif use_bundles %} + + {% else %} + {% if not use_cdn %} + + + + + {% endif %} + {% endif %} + {% if date_fanciness != 0 %} + {% if date_fanciness == 2 %} + + {% endif %} + {% if use_cdn %} + + {% else %} + + {% endif %} + {% if not use_bundles %} + + {% endif %} + {% endif %} + {{ social_buttons_code }} +{% endmacro %} + + +{% macro html_stylesheets() %} + {% if use_cdn %} + + + {% endif %} + {% if use_bundles and use_cdn %} + + {% elif use_bundles %} + + {% else %} + {% if not use_cdn %} + + + {% endif %} + + + + {% if has_custom_css %} + + {% endif %} + {% endif %} + {% if needs_ipython_css %} + + + {% endif %} +{% endmacro %} + +{% macro html_navigation_links() %} + {{ html_navigation_links_entries(navigation_links) }} +{% endmacro %} + +{% macro html_navigation_links_entries(navigation_links_source) %} + {% for url, text in navigation_links_source[lang] %} + {% if isinstance(url, tuple) %} + + {% endif %} + {% endfor %} +{% endmacro %} diff --git a/nikola/data/themes/bootstrap4-jinja/templates/index_helper.tmpl b/nikola/data/themes/bootstrap4-jinja/templates/index_helper.tmpl new file mode 100644 index 0000000..2fec2c6 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/templates/index_helper.tmpl @@ -0,0 +1,13 @@ +{# -*- coding: utf-8 -*- #} +{% macro html_pager() %} + {% if prevlink or nextlink %} + + {% endif %} +{% endmacro %} diff --git a/nikola/data/themes/bootstrap4-jinja/templates/listing.tmpl b/nikola/data/themes/bootstrap4-jinja/templates/listing.tmpl new file mode 100644 index 0000000..56a1b4f --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/templates/listing.tmpl @@ -0,0 +1,30 @@ +{# -*- coding: utf-8 -*- #} +{% extends 'base.tmpl' %} +{% import 'ui_helper.tmpl' as ui with context %} +{% block content %} +{{ ui.breadcrumbs(crumbs) }} +{% if folders or files %} + +{% endif %} +{% if code %} +

    {{ title }} + {% if source_link %} + ({{ messages("Source") }}) + {% endif %} +

    + {{ code }} +{% endif %} +{% endblock %} + +{% block sourcelink %} +{% if source_link and show_sourcelink %} + {{ ui.show_sourcelink(source_link) }} +{% endif %} +{% endblock %} diff --git a/nikola/data/themes/bootstrap4-jinja/templates/pagination_helper.tmpl b/nikola/data/themes/bootstrap4-jinja/templates/pagination_helper.tmpl new file mode 100644 index 0000000..30fe534 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/templates/pagination_helper.tmpl @@ -0,0 +1,40 @@ +{# -*- coding: utf-8 -*- #} +{% macro page_navigation(current_page, page_links, prevlink, nextlink, prev_next_links_reversed, surrounding=5) %} + +{% endmacro %} diff --git a/nikola/data/themes/bootstrap4-jinja/templates/post.tmpl b/nikola/data/themes/bootstrap4-jinja/templates/post.tmpl new file mode 100644 index 0000000..7e18f90 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/templates/post.tmpl @@ -0,0 +1,57 @@ +{# -*- coding: utf-8 -*- #} +{% import 'post_helper.tmpl' as helper with context %} +{% import 'post_header.tmpl' as pheader with context %} +{% import 'comments_helper.tmpl' as comments with context %} +{% import 'math_helper.tmpl' as math with context %} +{% import 'ui_helper.tmpl' as ui with context %} +{% extends 'base.tmpl' %} + +{% block extra_head %} + {{ super() }} + {% if post.meta('keywords') %} + + {% endif %} + + {% if post.prev_post %} + + {% endif %} + {% if post.next_post %} + + {% endif %} + {% if post.is_draft %} + + {% endif %} + {{ helper.open_graph_metadata(post) }} + {{ helper.twitter_card_information(post) }} + {{ helper.meta_translations(post) }} + {{ math.math_styles_ifpost(post) }} +{% endblock %} + +{% block content %} +
    + {{ pheader.html_post_header() }} +
    + {{ post.text() }} +
    + + {% if not post.meta('nocomments') and site_has_comments %} +
    +

    {{ messages("Comments") }}

    + {{ comments.comment_form(post.permalink(absolute=True), post.title(), post._base_path) }} +
    + {% endif %} + {{ math.math_scripts_ifpost(post) }} +
    +{{ comments.comment_link_script() }} +{% endblock %} + +{% block sourcelink %} +{% if show_sourcelink %} + {{ ui.show_sourcelink(post.source_link()) }} +{% endif %} +{% endblock %} diff --git a/nikola/data/themes/bootstrap4-jinja/templates/tags.tmpl b/nikola/data/themes/bootstrap4-jinja/templates/tags.tmpl new file mode 100644 index 0000000..0eadff6 --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/templates/tags.tmpl @@ -0,0 +1,38 @@ +{# -*- coding: utf-8 -*- #} +{% extends 'base.tmpl' %} + +{% block content %} +

    {{ title|e }}

    +{% if cat_items %} + {% if items %} +

    {{ messages("Categories") }}

    + {% endif %} + {% for text, full_name, path, link, indent_levels, indent_change_before, indent_change_after in cat_hierarchy %} + {% for i in range(indent_change_before) %} +
      + {% endfor %} +
    • {{ text|e }} + {% if indent_change_after <= 0 %} +
    • + {% endif %} + {% for i in range(-indent_change_after) %} +
    + {% if i + 1 < indent_levels|length %} + + {% endif %} + {% endfor %} + {% endfor %} + {% if items %} +

    {{ messages("Tags") }}

    + {% endif %} +{% endif %} +{% if items %} +
      + {% for text, link in items %} + {% if text not in hidden_tags %} +
    • {{ text|e }}
    • + {% endif %} + {% endfor %} +
    +{% endif %} +{% endblock %} diff --git a/nikola/data/themes/bootstrap4-jinja/templates/ui_helper.tmpl b/nikola/data/themes/bootstrap4-jinja/templates/ui_helper.tmpl new file mode 100644 index 0000000..d8c8d4d --- /dev/null +++ b/nikola/data/themes/bootstrap4-jinja/templates/ui_helper.tmpl @@ -0,0 +1,24 @@ +{# -*- coding: utf-8 -*- #} +{% macro breadcrumbs(crumbs) %} +{% if crumbs %} + +{% endif %} +{% endmacro %} + +{% macro show_sourcelink(sourcelink_href) %} + +{% endmacro %} diff --git a/nikola/data/themes/bootstrap4/README.md b/nikola/data/themes/bootstrap4/README.md new file mode 100644 index 0000000..bb1b484 --- /dev/null +++ b/nikola/data/themes/bootstrap4/README.md @@ -0,0 +1,10 @@ +This is a theme based on Bootstrap 4. + +The theme is a good building block for a site. It is based on a simple navbar + +content layout. For a more blog-style layout, check out `bootblog4`. + +Note that unlike previous versions of Bootstrap, icon fonts are not built-in. +You can use Font Awesome for this. + +This theme supports Bootswatch font/color schemes through the `nikola +bootwatch_theme` command. diff --git a/nikola/data/themes/bootstrap4/assets/css/bootstrap.min.css b/nikola/data/themes/bootstrap4/assets/css/bootstrap.min.css new file mode 120000 index 0000000..8c8dc62 --- /dev/null +++ b/nikola/data/themes/bootstrap4/assets/css/bootstrap.min.css @@ -0,0 +1 @@ +../../../../../../npm_assets/node_modules/bootstrap/dist/css/bootstrap.min.css \ No newline at end of file diff --git a/nikola/data/themes/bootstrap4/assets/css/theme.css b/nikola/data/themes/bootstrap4/assets/css/theme.css new file mode 100644 index 0000000..20eee8e --- /dev/null +++ b/nikola/data/themes/bootstrap4/assets/css/theme.css @@ -0,0 +1,232 @@ +img { + max-width: 100%; +} + +.titlebox { + text-align: right; +} + + +td.label { + /* Issue #290 */ + background-color: inherit; +} + +.footnote-reference { + /* Issue 290 */ + vertical-align: super; + font-size: xx-small; +} + +.caption { + /* Issue 292 */ + text-align: center; + padding-top: 1em; +} + +div.figure > img, +div.figure > a > img { + /* Issue 292 */ + display: block; + margin-left: auto; + margin-right: auto; +} + +blockquote p, blockquote { + font-size: 1.25rem; + font-weight: 300; + line-height: 1.25; +} + +ul.bricks > li { + display: inline; + background-color: lightblue; + padding: 8px; + border-radius: 5px; + line-height: 3; + white-space:nowrap; + margin: 3px; +} + +pre, pre code { + white-space: pre; + word-wrap: normal; + overflow: auto; +} + +article.post-micro { + font-family: Georgia, 'Times New Roman', Times, serif; + font-size: 1.5em; +} + +.image-block { + display: inline-block; +} + +.tags { + padding-left: 0; + margin-left: -5px; + list-style: none; + text-align: center; + +} + +.tags > li { + display: inline-block; +} +.tags > li a { + display: inline-block; + padding: .25em .4em; + font-size: 75%; + font-weight: 700; + line-height: 1; + color: #fff; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25rem; + background-color: #868e96; +} + +.tags > li a:hover { + color: #fff; + text-decoration: none; + background-color: #6c757d; +} + +.metadata p:before, +.postlist .listdate:after { + content: " — "; +} + +.metadata p:first-of-type:before { + content: ""; +} + +.metadata p { + display: inline; +} + +.posttranslations h3 { + display: inline; + font-size: 1em; + font-weight: bold; +} + +.posttranslations h3:last-child { + display: none; +} + +.entry-content { + margin-top: 1em; +} + +/* for alignment with Bootstrap's .entry-content styling */ +.entry-summary { + margin-top: 1em; +} + +/* Custom page footer */ +#footer { + padding-top: 19px; + color: #777; + border-top: 1px solid #e5e5e5; +} + +/* hat tip bootstrap/html5 boilerplate */ +@media print { + *, *:before, *:after { + font-family: Garamond, Junicode, serif; + } + + body { + font-size: 12pt; + } + + article .entry-title a[href]:after, + article .metadata a[href]:after, + article .tags a[href]:after { + content: ""; + } + + article .metadata .sourceline { + display: none; + } + + article .metadata .linkline a[href]:after { + content: " (" attr(href) ")"; + } + + .navbar { + display: none; + } +} + +pre, .codetable { + border: 1px solid #ccc; + border-radius: 0.25rem; + margin-bottom: 1rem; +} + +pre { + padding: 0.75rem; +} + +.codetable tr:first-child td.linenos { + border-top-left-radius: 0.25rem; +} + +.codetable tr:last-child td.linenos { + border-bottom-left-radius: 0.25rem; +} + +.postindexpager { + padding-bottom: 1rem; +} + +ul.navbar-nav { + margin-top: 0; +} + +ul.pager { + display: flex; + padding-left: 0; + list-style: none; + border-radius: .25rem; + padding-left: 0; + margin: 0.5rem 0; +} + +ul.pager li.previous { + margin-right: auto; + display: inline; +} + +ul.pager li.next { + margin-left: auto; + display: inline; +} + + +ul.pager li a { + display: inline; + position: relative; + padding: .5rem .75rem; + margin-left: -1px; + line-height: 1.25; + border: 1px solid #ddd; + border-radius: .25rem; +} + +pre.code { + white-space: pre-wrap; +} + +.byline a:not(:last-child):after { + content: ","; +} + +/* Override incorrect Bootstrap 4 default */ +html[dir="rtl"] body { + text-align: right; +} diff --git a/nikola/data/themes/bootstrap4/assets/js/bootstrap.min.js b/nikola/data/themes/bootstrap4/assets/js/bootstrap.min.js new file mode 120000 index 0000000..593bffb --- /dev/null +++ b/nikola/data/themes/bootstrap4/assets/js/bootstrap.min.js @@ -0,0 +1 @@ +../../../../../../npm_assets/node_modules/bootstrap/dist/js/bootstrap.min.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap4/assets/js/jquery.min.js b/nikola/data/themes/bootstrap4/assets/js/jquery.min.js new file mode 120000 index 0000000..2a592f6 --- /dev/null +++ b/nikola/data/themes/bootstrap4/assets/js/jquery.min.js @@ -0,0 +1 @@ +../../../../../../npm_assets/node_modules/jquery/dist/jquery.min.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap4/assets/js/popper.min.js b/nikola/data/themes/bootstrap4/assets/js/popper.min.js new file mode 120000 index 0000000..43fca04 --- /dev/null +++ b/nikola/data/themes/bootstrap4/assets/js/popper.min.js @@ -0,0 +1 @@ +../../../../../../npm_assets/node_modules/popper.js/dist/umd/popper.min.js \ No newline at end of file diff --git a/nikola/data/themes/bootstrap4/bootstrap4.theme b/nikola/data/themes/bootstrap4/bootstrap4.theme new file mode 100644 index 0000000..0f3f9ef --- /dev/null +++ b/nikola/data/themes/bootstrap4/bootstrap4.theme @@ -0,0 +1,12 @@ +[Theme] +engine = mako +parent = base +author = The Nikola Contributors +author_url = https://getnikola.com/ +license = MIT +based_on = Bootstrap 4 +tags = bootstrap + +[Family] +family = bootstrap4 +jinja_version = bootstrap4-jinja diff --git a/nikola/data/themes/bootstrap4/bundles b/nikola/data/themes/bootstrap4/bundles new file mode 100644 index 0000000..71d458b --- /dev/null +++ b/nikola/data/themes/bootstrap4/bundles @@ -0,0 +1,26 @@ +; css bundles +assets/css/all-nocdn.css= + bootstrap.min.css, + rst_base.css, + nikola_rst.css, + code.css, + baguetteBox.min.css, + theme.css, + custom.css, +assets/css/all.css= + rst_base.css, + nikola_rst.css, + code.css, + baguetteBox.min.css, + theme.css, + custom.css, + +; javascript bundles +assets/js/all-nocdn.js= + jquery.min.js, + popper.min.js, + bootstrap.min.js, + baguetteBox.min.js, + fancydates.min.js, +assets/js/all.js= + fancydates.min.js diff --git a/nikola/data/themes/bootstrap4/templates/authors.tmpl b/nikola/data/themes/bootstrap4/templates/authors.tmpl new file mode 100644 index 0000000..300377d --- /dev/null +++ b/nikola/data/themes/bootstrap4/templates/authors.tmpl @@ -0,0 +1,25 @@ +## -*- coding: utf-8 -*- +<%inherit file="base.tmpl"/> +<%namespace name="feeds_translations" file="feeds_translations_helper.tmpl" import="*"/> + +<%block name="extra_head"> + ${feeds_translations.head(kind=kind, feeds=False)} + + +<%block name="content"> +% if items: +

    ${messages("Authors")}

    + +% endif +% if items: +
      + % for text, link in items: + % if text not in hidden_authors: +
    • ${text|h}
    • + % endif + % endfor +
    +% endif + diff --git a/nikola/data/themes/bootstrap4/templates/base.tmpl b/nikola/data/themes/bootstrap4/templates/base.tmpl new file mode 100644 index 0000000..21b6141 --- /dev/null +++ b/nikola/data/themes/bootstrap4/templates/base.tmpl @@ -0,0 +1,105 @@ +## -*- coding: utf-8 -*- +<%namespace name="base" file="base_helper.tmpl" import="*" /> +<%namespace name="notes" file="annotation_helper.tmpl" import="*" /> +${set_locale(lang)} +${base.html_headstart()} +<%block name="extra_head"> +### Leave this block alone. + +${template_hooks['extra_head']()} + + +${messages("Skip to main content")} + + + + + + + +
    +
    + + ${template_hooks['page_header']()} + <%block name="extra_header"> + <%block name="content"> + + +
    + ${content_footer} + ${template_hooks['page_footer']()} + <%block name="extra_footer"> +
    +
    +
    + +${base.late_load_js()} + %if date_fanciness != 0: + + + + %endif + <%block name="extra_js"> + +${body_end} +${template_hooks['body_end']()} + + diff --git a/nikola/data/themes/bootstrap4/templates/base_helper.tmpl b/nikola/data/themes/bootstrap4/templates/base_helper.tmpl new file mode 100644 index 0000000..f551116 --- /dev/null +++ b/nikola/data/themes/bootstrap4/templates/base_helper.tmpl @@ -0,0 +1,165 @@ +## -*- coding: utf-8 -*- +<%namespace name="feeds_translations" file="feeds_translations_helper.tmpl" import="*"/> + +<%def name="html_headstart()"> + + + + + %if description: + + %endif + + %if title == blog_title: + ${blog_title|h} + %else: + ${title|h} | ${blog_title|h} + %endif + + ${html_stylesheets()} + + % if meta_generator_tag: + + % endif + ${html_feedlinks()} + + + %if favicons: + %for name, file, size in favicons: + + %endfor + %endif + + % if comment_system == 'facebook': + + % endif + + %if prevlink: + + %endif + %if nextlink: + + %endif + + %if use_cdn: + + %else: + + %endif + + ${extra_head_data} + + +<%def name="late_load_js()"> + %if use_cdn: + + + + + % endif + %if use_bundles and use_cdn: + + %elif use_bundles: + + %else: + %if not use_cdn: + + + + + %endif + %endif + %if date_fanciness != 0: + %if date_fanciness == 2: + + %endif + %if use_cdn: + + %else: + + %endif + %if not use_bundles: + + %endif + %endif + ${social_buttons_code} + + + +<%def name="html_stylesheets()"> + %if use_cdn: + + + % endif + %if use_bundles and use_cdn: + + %elif use_bundles: + + %else: + %if not use_cdn: + + + %endif + + + + %if has_custom_css: + + %endif + %endif + % if needs_ipython_css: + + + % endif + + +<%def name="html_navigation_links()"> + ${html_navigation_links_entries(navigation_links)} + + +<%def name="html_navigation_links_entries(navigation_links_source)"> + %for url, text in navigation_links_source[lang]: + % if isinstance(url, tuple): + + %endif + %endfor + diff --git a/nikola/data/themes/bootstrap4/templates/index_helper.tmpl b/nikola/data/themes/bootstrap4/templates/index_helper.tmpl new file mode 100644 index 0000000..e6b0089 --- /dev/null +++ b/nikola/data/themes/bootstrap4/templates/index_helper.tmpl @@ -0,0 +1,13 @@ +## -*- coding: utf-8 -*- +<%def name="html_pager()"> + %if prevlink or nextlink: + + %endif + diff --git a/nikola/data/themes/bootstrap4/templates/listing.tmpl b/nikola/data/themes/bootstrap4/templates/listing.tmpl new file mode 100644 index 0000000..d9a4c56 --- /dev/null +++ b/nikola/data/themes/bootstrap4/templates/listing.tmpl @@ -0,0 +1,30 @@ +## -*- coding: utf-8 -*- +<%inherit file="base.tmpl"/> +<%namespace name="ui" file="ui_helper.tmpl"/> +<%block name="content"> +${ui.breadcrumbs(crumbs)} +%if folders or files: + +%endif +% if code: +

    ${title} + % if source_link: + (${messages("Source")}) + % endif +

    + ${code} +% endif + + +<%block name="sourcelink"> +% if source_link and show_sourcelink: + ${ui.show_sourcelink(source_link)} +% endif + diff --git a/nikola/data/themes/bootstrap4/templates/pagination_helper.tmpl b/nikola/data/themes/bootstrap4/templates/pagination_helper.tmpl new file mode 100644 index 0000000..da0e920 --- /dev/null +++ b/nikola/data/themes/bootstrap4/templates/pagination_helper.tmpl @@ -0,0 +1,40 @@ +## -*- coding: utf-8 -*- +<%def name="page_navigation(current_page, page_links, prevlink, nextlink, prev_next_links_reversed, surrounding=5)"> + + diff --git a/nikola/data/themes/bootstrap4/templates/post.tmpl b/nikola/data/themes/bootstrap4/templates/post.tmpl new file mode 100644 index 0000000..0d4248e --- /dev/null +++ b/nikola/data/themes/bootstrap4/templates/post.tmpl @@ -0,0 +1,57 @@ +## -*- coding: utf-8 -*- +<%namespace name="helper" file="post_helper.tmpl"/> +<%namespace name="pheader" file="post_header.tmpl"/> +<%namespace name="comments" file="comments_helper.tmpl"/> +<%namespace name="math" file="math_helper.tmpl"/> +<%namespace name="ui" file="ui_helper.tmpl"/> +<%inherit file="base.tmpl"/> + +<%block name="extra_head"> + ${parent.extra_head()} + % if post.meta('keywords'): + + % endif + + %if post.prev_post: + + %endif + %if post.next_post: + + %endif + % if post.is_draft: + + % endif + ${helper.open_graph_metadata(post)} + ${helper.twitter_card_information(post)} + ${helper.meta_translations(post)} + ${math.math_styles_ifpost(post)} + + +<%block name="content"> +
    + ${pheader.html_post_header()} +
    + ${post.text()} +
    + + % if not post.meta('nocomments') and site_has_comments: +
    +

    ${messages("Comments")}

    + ${comments.comment_form(post.permalink(absolute=True), post.title(), post._base_path)} +
    + % endif + ${math.math_scripts_ifpost(post)} +
    +${comments.comment_link_script()} + + +<%block name="sourcelink"> +% if show_sourcelink: + ${ui.show_sourcelink(post.source_link())} +% endif + diff --git a/nikola/data/themes/bootstrap4/templates/tags.tmpl b/nikola/data/themes/bootstrap4/templates/tags.tmpl new file mode 100644 index 0000000..f1870f6 --- /dev/null +++ b/nikola/data/themes/bootstrap4/templates/tags.tmpl @@ -0,0 +1,38 @@ +## -*- coding: utf-8 -*- +<%inherit file="base.tmpl"/> + +<%block name="content"> +

    ${title|h}

    +% if cat_items: + % if items: +

    ${messages("Categories")}

    + % endif + % for text, full_name, path, link, indent_levels, indent_change_before, indent_change_after in cat_hierarchy: + % for i in range(indent_change_before): +
      + % endfor +
    • ${text|h} + % if indent_change_after <= 0: +
    • + % endif + % for i in range(-indent_change_after): +
    + % if i + 1 < len(indent_levels): + + % endif + % endfor + % endfor + % if items: +

    ${messages("Tags")}

    + % endif +%endif +% if items: +
      + % for text, link in items: + % if text not in hidden_tags: +
    • ${text|h}
    • + % endif + % endfor +
    +% endif + diff --git a/nikola/data/themes/bootstrap4/templates/ui_helper.tmpl b/nikola/data/themes/bootstrap4/templates/ui_helper.tmpl new file mode 100644 index 0000000..7e884f9 --- /dev/null +++ b/nikola/data/themes/bootstrap4/templates/ui_helper.tmpl @@ -0,0 +1,24 @@ +## -*- coding: utf-8 -*- +<%def name="breadcrumbs(crumbs)"> +%if crumbs: + +%endif + + +<%def name="show_sourcelink(sourcelink_href)"> + + diff --git a/nikola/filters.py b/nikola/filters.py index 4304860..9d7e492 100644 --- a/nikola/filters.py +++ b/nikola/filters.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -24,21 +24,43 @@ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -"""Utility functions to help run filters on files.""" +"""Utility functions to help run filters on files. + +All filters defined in this module are registered in Nikola.__init__. +""" -from .utils import req_missing -from functools import wraps -import os import io +import json +import os +import re import shutil +import shlex import subprocess import tempfile -import shlex +from functools import wraps + +import lxml +import requests + +from .utils import req_missing, LOGGER, slugify try: import typogrify.filters as typo except ImportError: - typo = None # NOQA + typo = None + + +class _ConfigurableFilter(object): + """Allow Nikola to configure filter with site's config.""" + + def __init__(self, **configuration_variables): + """Define which arguments to configure from which configuration variables.""" + self.configuration_variables = configuration_variables + + def __call__(self, f): + """Store configuration_variables as attribute of function.""" + f.configuration_variables = self.configuration_variables + return f def apply_to_binary_file(f): @@ -49,10 +71,10 @@ def apply_to_binary_file(f): in place. Reads files in binary mode. """ @wraps(f) - def f_in_file(fname): + def f_in_file(fname, *args, **kwargs): with open(fname, 'rb') as inf: data = inf.read() - data = f(data) + data = f(data, *args, **kwargs) with open(fname, 'wb+') as outf: outf.write(data) @@ -67,10 +89,10 @@ def apply_to_text_file(f): in place. Reads files in UTF-8. """ @wraps(f) - def f_in_file(fname): - with io.open(fname, 'r', encoding='utf-8') as inf: + def f_in_file(fname, *args, **kwargs): + with io.open(fname, 'r', encoding='utf-8-sig') as inf: data = inf.read() - data = f(data) + data = f(data, *args, **kwargs) with io.open(fname, 'w+', encoding='utf-8') as outf: outf.write(data) @@ -122,70 +144,86 @@ def runinplace(command, infile): shutil.rmtree(tmpdir) -def yui_compressor(infile): +@_ConfigurableFilter(executable='YUI_COMPRESSOR_EXECUTABLE') +def yui_compressor(infile, executable=None): """Run YUI Compressor on a file.""" - yuicompressor = False - try: - subprocess.call('yui-compressor', stdout=open(os.devnull, 'w'), stderr=open(os.devnull, 'w')) - yuicompressor = 'yui-compressor' - except Exception: - pass + yuicompressor = executable + if not yuicompressor: + try: + subprocess.call('yui-compressor', stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + yuicompressor = 'yui-compressor' + except Exception: + pass if not yuicompressor: try: - subprocess.call('yuicompressor', stdout=open(os.devnull, 'w'), stderr=open(os.devnull, 'w')) + subprocess.call('yuicompressor', stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) yuicompressor = 'yuicompressor' - except: + except Exception: raise Exception("yui-compressor is not installed.") return False - return runinplace(r'{} --nomunge %1 -o %2'.format(yuicompressor), infile) + return runinplace('{} --nomunge %1 -o %2'.format(yuicompressor), infile) -def closure_compiler(infile): +@_ConfigurableFilter(executable='CLOSURE_COMPILER_EXECUTABLE') +def closure_compiler(infile, executable='closure-compiler'): """Run closure-compiler on a file.""" - return runinplace(r'closure-compiler --warning_level QUIET --js %1 --js_output_file %2', infile) + return runinplace('{} --warning_level QUIET --js %1 --js_output_file %2'.format(executable), infile) -def optipng(infile): +@_ConfigurableFilter(executable='OPTIPNG_EXECUTABLE') +def optipng(infile, executable='optipng'): """Run optipng on a file.""" - return runinplace(r"optipng -preserve -o2 -quiet %1", infile) + return runinplace("{} -preserve -o2 -quiet %1".format(executable), infile) -def jpegoptim(infile): +@_ConfigurableFilter(executable='JPEGOPTIM_EXECUTABLE') +def jpegoptim(infile, executable='jpegoptim'): """Run jpegoptim on a file.""" - return runinplace(r"jpegoptim -p --strip-all -q %1", infile) + return runinplace("{} -p --strip-all -q %1".format(executable), infile) + +@_ConfigurableFilter(executable='JPEGOPTIM_EXECUTABLE') +def jpegoptim_progressive(infile, executable='jpegoptim'): + """Run jpegoptim on a file and convert to progressive.""" + return runinplace("{} -p --strip-all --all-progressive -q %1".format(executable), infile) -def html_tidy_withconfig(infile): + +@_ConfigurableFilter(executable='HTML_TIDY_EXECUTABLE') +def html_tidy_withconfig(infile, executable='tidy5'): """Run HTML Tidy with tidy5.conf as config file.""" - return _html_tidy_runner(infile, r"-quiet --show-info no --show-warnings no -utf8 -indent -config tidy5.conf -modify %1") + return _html_tidy_runner(infile, "-quiet --show-info no --show-warnings no -utf8 -indent -config tidy5.conf -modify %1", executable=executable) -def html_tidy_nowrap(infile): +@_ConfigurableFilter(executable='HTML_TIDY_EXECUTABLE') +def html_tidy_nowrap(infile, executable='tidy5'): """Run HTML Tidy without line wrapping.""" - return _html_tidy_runner(infile, r"-quiet --show-info no --show-warnings no -utf8 -indent --indent-attributes no --sort-attributes alpha --wrap 0 --wrap-sections no --drop-empty-elements no --tidy-mark no -modify %1") + return _html_tidy_runner(infile, "-quiet --show-info no --show-warnings no -utf8 -indent --indent-attributes no --sort-attributes alpha --wrap 0 --wrap-sections no --drop-empty-elements no --tidy-mark no -modify %1", executable=executable) -def html_tidy_wrap(infile): +@_ConfigurableFilter(executable='HTML_TIDY_EXECUTABLE') +def html_tidy_wrap(infile, executable='tidy5'): """Run HTML Tidy with line wrapping.""" - return _html_tidy_runner(infile, r"-quiet --show-info no --show-warnings no -utf8 -indent --indent-attributes no --sort-attributes alpha --wrap 80 --wrap-sections no --drop-empty-elements no --tidy-mark no -modify %1") + return _html_tidy_runner(infile, "-quiet --show-info no --show-warnings no -utf8 -indent --indent-attributes no --sort-attributes alpha --wrap 80 --wrap-sections no --drop-empty-elements no --tidy-mark no -modify %1", executable=executable) -def html_tidy_wrap_attr(infile): +@_ConfigurableFilter(executable='HTML_TIDY_EXECUTABLE') +def html_tidy_wrap_attr(infile, executable='tidy5'): """Run HTML tidy with line wrapping and attribute indentation.""" - return _html_tidy_runner(infile, r"-quiet --show-info no --show-warnings no -utf8 -indent --indent-attributes yes --sort-attributes alpha --wrap 80 --wrap-sections no --drop-empty-elements no --tidy-mark no -modify %1") + return _html_tidy_runner(infile, "-quiet --show-info no --show-warnings no -utf8 -indent --indent-attributes yes --sort-attributes alpha --wrap 80 --wrap-sections no --drop-empty-elements no --tidy-mark no -modify %1", executable=executable) -def html_tidy_mini(infile): +@_ConfigurableFilter(executable='HTML_TIDY_EXECUTABLE') +def html_tidy_mini(infile, executable='tidy5'): """Run HTML tidy with minimal settings.""" - return _html_tidy_runner(infile, r"-quiet --show-info no --show-warnings no -utf8 --indent-attributes no --sort-attributes alpha --wrap 0 --wrap-sections no --tidy-mark no --drop-empty-elements no -modify %1") + return _html_tidy_runner(infile, "-quiet --show-info no --show-warnings no -utf8 --indent-attributes no --sort-attributes alpha --wrap 0 --wrap-sections no --tidy-mark no --drop-empty-elements no -modify %1", executable=executable) -def _html_tidy_runner(infile, options): +def _html_tidy_runner(infile, options, executable='tidy5'): """Run HTML Tidy.""" # Warnings (returncode 1) are not critical, and *everything* is a warning. try: - status = runinplace(r"tidy5 " + options, infile) + status = runinplace(executable + " " + options, infile) except subprocess.CalledProcessError as err: status = 0 if err.returncode == 1 else err.returncode return status @@ -198,7 +236,7 @@ def html5lib_minify(data): import html5lib.serializer data = html5lib.serializer.serialize(html5lib.parse(data, treebuilder='lxml'), tree='lxml', - quote_attr_values=False, + quote_attr_values='spec', omit_optional_tags=True, minimize_boolean_attributes=True, strip_whitespace=True, @@ -214,7 +252,7 @@ def html5lib_xmllike(data): import html5lib.serializer data = html5lib.serializer.serialize(html5lib.parse(data, treebuilder='lxml'), tree='lxml', - quote_attr_values=True, + quote_attr_values='always', omit_optional_tags=False, strip_whitespace=False, alphabetical_attributes=True, @@ -228,19 +266,53 @@ def minify_lines(data): return data +def _run_typogrify(data, typogrify_filters, ignore_tags=None): + """Run typogrify with ignore support.""" + if ignore_tags is None: + ignore_tags = ["title"] + + data = _normalize_html(data) + + section_list = typo.process_ignores(data, ignore_tags) + + rendered_text = "" + for text_item, should_process in section_list: + if should_process: + for f in typogrify_filters: + text_item = f(text_item) + + rendered_text += text_item + + return rendered_text + + @apply_to_text_file def typogrify(data): """Prettify text with typogrify.""" if typo is None: - req_missing(['typogrify'], 'use the typogrify filter') - - data = typo.amp(data) - data = typo.widont(data) - data = typo.smartypants(data) - # Disabled because of typogrify bug where it breaks - # data = typo.caps(data) - data = typo.initial_quotes(data) - return data + req_missing(['typogrify'], 'use the typogrify filter', optional=True) + return data + return _run_typogrify(data, [typo.amp, typo.widont, typo.smartypants, typo.caps, typo.initial_quotes]) + + +def _smarty_oldschool(text): + try: + import smartypants + except ImportError: + raise typo.TypogrifyError("Error in {% smartypants %} filter: The Python smartypants library isn't installed.") + else: + output = smartypants.convert_dashes_oldschool(text) + return output + + +@apply_to_text_file +def typogrify_oldschool(data): + """Prettify text with typogrify.""" + if typo is None: + req_missing(['typogrify'], 'use the typogrify_oldschool filter', optional=True) + return data + + return _run_typogrify(data, [typo.amp, typo.widont, _smarty_oldschool, typo.smartypants, typo.caps, typo.initial_quotes]) @apply_to_text_file @@ -250,27 +322,189 @@ def typogrify_sans_widont(data): # wrapping, see issue #1465 if typo is None: req_missing(['typogrify'], 'use the typogrify_sans_widont filter') + return data + + return _run_typogrify(data, [typo.amp, typo.smartypants, typo.caps, typo.initial_quotes]) - data = typo.amp(data) - data = typo.smartypants(data) - # Disabled because of typogrify bug where it breaks <title> - # data = typo.caps(data) - data = typo.initial_quotes(data) - return data + +@apply_to_text_file +def typogrify_custom(data, typogrify_filters, ignore_tags=None): + """Run typogrify with a custom list of fliter functions.""" + if typo is None: + req_missing(['typogrify'], 'use the typogrify filter', optional=True) + return data + return _run_typogrify(data, typogrify_filters, ignore_tags) @apply_to_text_file def php_template_injection(data): """Insert PHP code into Nikola templates.""" - import re - template = re.search('<\!-- __NIKOLA_PHP_TEMPLATE_INJECTION source\:(.*) checksum\:(.*)__ -->', data) + template = re.search(r'<\!-- __NIKOLA_PHP_TEMPLATE_INJECTION source\:(.*) checksum\:(.*)__ -->', data) if template: source = template.group(1) - with io.open(source, "r", encoding="utf-8") as in_file: + with io.open(source, "r", encoding="utf-8-sig") as in_file: phpdata = in_file.read() _META_SEPARATOR = '(' + os.linesep * 2 + '|' + ('\n' * 2) + '|' + ("\r\n" * 2) + ')' phpdata = re.split(_META_SEPARATOR, phpdata, maxsplit=1)[-1] - phpdata = re.sub(template.group(0), phpdata, data) + phpdata = data.replace(template.group(0), phpdata) return phpdata else: return data + + +@apply_to_text_file +def cssminify(data): + """Minify CSS using https://cssminifier.com/.""" + try: + url = 'https://cssminifier.com/raw' + _data = {'input': data} + response = requests.post(url, data=_data) + if response.status_code != 200: + LOGGER.error("can't use cssminifier.com: HTTP status {}", response.status_code) + return data + return response.text + except Exception as exc: + LOGGER.error("can't use cssminifier.com: {}", exc) + return data + + +@apply_to_text_file +def jsminify(data): + """Minify JS using https://javascript-minifier.com/.""" + try: + url = 'https://javascript-minifier.com/raw' + _data = {'input': data} + response = requests.post(url, data=_data) + if response.status_code != 200: + LOGGER.error("can't use javascript-minifier.com: HTTP status {}", response.status_code) + return data + return response.text + except Exception as exc: + LOGGER.error("can't use javascript-minifier.com: {}", exc) + return data + + +@apply_to_text_file +def jsonminify(data): + """Minify JSON files (strip whitespace and use minimal separators).""" + data = json.dumps(json.loads(data), indent=None, separators=(',', ':')) + return data + + +@apply_to_binary_file +def xmlminify(data): + """Minify XML files (strip whitespace and use minimal separators).""" + parser = lxml.etree.XMLParser(remove_blank_text=True) + newdata = lxml.etree.XML(data, parser=parser) + return lxml.etree.tostring(newdata, encoding='utf-8', method='xml', xml_declaration=True) + + +def _normalize_html(data): + """Pass HTML through LXML to clean it up, if possible.""" + try: + data = lxml.html.tostring(lxml.html.fromstring(data), encoding='unicode') + except Exception: + pass + return '<!DOCTYPE html>\n' + data + + +# The function is used in other filters, so the decorator cannot be used directly. +normalize_html = apply_to_text_file(_normalize_html) + + +@_ConfigurableFilter(xpath_list='HEADER_PERMALINKS_XPATH_LIST', file_blacklist='HEADER_PERMALINKS_FILE_BLACKLIST') +def add_header_permalinks(fname, xpath_list=None, file_blacklist=None): + """Post-process HTML via lxml to add header permalinks Sphinx-style.""" + # Blacklist requires custom file handling + file_blacklist = file_blacklist or [] + if fname in file_blacklist: + return + with io.open(fname, 'r', encoding='utf-8-sig') as inf: + data = inf.read() + doc = lxml.html.document_fromstring(data) + # Get language for slugify + try: + lang = doc.attrib['lang'] # <html lang="…"> + except KeyError: + # Circular import workaround (utils imports filters) + from nikola.utils import LocaleBorg + lang = LocaleBorg().current_lang + + xpath_set = set() + if not xpath_list: + xpath_list = ['*//div[@class="e-content entry-content"]//{hx}'] + for xpath_expr in xpath_list: + for hx in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']: + xpath_set.add(xpath_expr.format(hx=hx)) + for x in xpath_set: + nodes = doc.findall(x) + for node in nodes: + parent = node.getparent() + if 'id' in node.attrib: + hid = node.attrib['id'] + elif 'id' in parent.attrib: + # docutils: <div> has an ID and contains the header + hid = parent.attrib['id'] + else: + # Using force-mode, because not every character can appear in a + # HTML id + node.attrib['id'] = slugify(node.text_content(), lang, True) + hid = node.attrib['id'] + + new_node = lxml.html.fragment_fromstring('<a href="#{0}" class="headerlink" title="Permalink to this heading">¶</a>'.format(hid)) + node.append(new_node) + + with io.open(fname, 'w', encoding='utf-8') as outf: + outf.write('<!DOCTYPE html>\n' + lxml.html.tostring(doc, encoding="unicode")) + + +@_ConfigurableFilter(top_classes='DEDUPLICATE_IDS_TOP_CLASSES') +@apply_to_text_file +def deduplicate_ids(data, top_classes=None): + """Post-process HTML via lxml to deduplicate IDs.""" + if not top_classes: + top_classes = ('postpage', 'storypage') + doc = lxml.html.document_fromstring(data) + elements = doc.xpath('//*') + all_ids = [element.attrib.get('id') for element in elements] + seen_ids = set() + duplicated_ids = set() + for i in all_ids: + if i is not None and i in seen_ids: + duplicated_ids.add(i) + else: + seen_ids.add(i) + + if duplicated_ids: + # Well, that sucks. + for i in duplicated_ids: + # Results are ordered the same way they are ordered in document + offending_elements = doc.xpath('//*[@id="{}"]'.format(i)) + counter = 2 + # If this is a story or a post, do it from top to bottom, because + # updates to those are more likely to appear at the bottom of pages. + # For anything else, including indexes, do it from bottom to top, + # because new posts appear at the top of pages. + # We also leave the first result out, so there is one element with + # "plain" ID + if any(doc.find_class(c) for c in top_classes): + off = offending_elements[1:] + else: + off = offending_elements[-2::-1] + for e in off: + new_id = i + while new_id in seen_ids: + new_id = '{0}-{1}'.format(i, counter) + counter += 1 + e.attrib['id'] = new_id + seen_ids.add(new_id) + # Find headerlinks that we can fix. + headerlinks = e.find_class('headerlink') + for hl in headerlinks: + # We might get headerlinks of child elements + if hl.attrib['href'] == '#' + i: + hl.attrib['href'] = '#' + new_id + break + return '<!DOCTYPE html>\n' + lxml.html.tostring(doc, encoding='unicode') + else: + return data diff --git a/nikola/hierarchy_utils.py b/nikola/hierarchy_utils.py new file mode 100644 index 0000000..8993518 --- /dev/null +++ b/nikola/hierarchy_utils.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Hierarchy utility functions.""" + +import natsort + +__all__ = ('TreeNode', 'clone_treenode', 'flatten_tree_structure', + 'sort_classifications', 'join_hierarchical_category_path', + 'parse_escaped_hierarchical_category_name',) + + +class TreeNode(object): + """A tree node.""" + + indent_levels = None # use for formatting comments as tree + indent_change_before = 0 # use for formatting comments as tree + indent_change_after = 0 # use for formatting comments as tree + + # The indent levels and changes allow to render a tree structure + # without keeping track of all that information during rendering. + # + # The indent_change_before is the different between the current + # comment's level and the previous comment's level; if the number + # is positive, the current level is indented further in, and if it + # is negative, it is indented further out. Positive values can be + # used to open HTML tags for each opened level. + # + # The indent_change_after is the difference between the next + # comment's level and the current comment's level. Negative values + # can be used to close HTML tags for each closed level. + # + # The indent_levels list contains one entry (index, count) per + # level, informing about the index of the current comment on that + # level and the count of comments on that level (before a comment + # of a higher level comes). This information can be used to render + # tree indicators, for example to generate a tree such as: + # + # +--- [(0,3)] + # +-+- [(1,3)] + # | +--- [(1,3), (0,2)] + # | +-+- [(1,3), (1,2)] + # | +--- [(1,3), (1,2), (0, 1)] + # +-+- [(2,3)] + # +- [(2,3), (0,1)] + # + # (The lists used as labels represent the content of the + # indent_levels property for that node.) + + def __init__(self, name, parent=None): + """Initialize node.""" + self.name = name + self.parent = parent + self.children = [] + + def get_path(self): + """Get path.""" + path = [] + curr = self + while curr is not None: + path.append(curr) + curr = curr.parent + return reversed(path) + + def get_children(self): + """Get children of a node.""" + return self.children + + def __str__(self): + """Stringify node (return name).""" + return self.name + + def _repr_partial(self): + """Return partial representation.""" + if self.parent: + return "{0}/{1!r}".format(self.parent._repr_partial(), self.name) + else: + return repr(self.name) + + def __repr__(self): + """Return programmer-friendly node representation.""" + return "<TreeNode {0}>".format(self._repr_partial()) + + +def clone_treenode(treenode, parent=None, acceptor=lambda x: True): + """Clone a TreeNode. + + Children are only cloned if `acceptor` returns `True` when + applied on them. + + Returns the cloned node if it has children or if `acceptor` + applied to it returns `True`. In case neither applies, `None` + is returned. + """ + # Copy standard TreeNode stuff + node_clone = TreeNode(treenode.name, parent) + node_clone.children = [clone_treenode(node, parent=node_clone, acceptor=acceptor) for node in treenode.children] + node_clone.children = [node for node in node_clone.children if node] + node_clone.indent_levels = treenode.indent_levels + node_clone.indent_change_before = treenode.indent_change_before + node_clone.indent_change_after = treenode.indent_change_after + if hasattr(treenode, 'classification_path'): + # Copy stuff added by taxonomies_classifier plugin + node_clone.classification_path = treenode.classification_path + node_clone.classification_name = treenode.classification_name + + # Accept this node if there are no children (left) and acceptor fails + if not node_clone.children and not acceptor(treenode): + return None + return node_clone + + +def flatten_tree_structure(root_list): + """Flatten a tree.""" + elements = [] + + def generate(input_list, indent_levels_so_far): + """Generate flat list of nodes.""" + for index, element in enumerate(input_list): + # add to destination + elements.append(element) + # compute and set indent levels + indent_levels = indent_levels_so_far + [(index, len(input_list))] + element.indent_levels = indent_levels + # add children + children = element.get_children() + element.children_count = len(children) + generate(children, indent_levels) + + generate(root_list, []) + # Add indent change counters + level = 0 + last_element = None + for element in elements: + new_level = len(element.indent_levels) + # Compute level change before this element + change = new_level - level + if last_element is not None: + last_element.indent_change_after = change + element.indent_change_before = change + # Update variables + level = new_level + last_element = element + # Set level change after last element + if last_element is not None: + last_element.indent_change_after = -level + return elements + + +def parse_escaped_hierarchical_category_name(category_name): + """Parse a category name.""" + result = [] + current = None + index = 0 + next_backslash = category_name.find('\\', index) + next_slash = category_name.find('/', index) + while index < len(category_name): + if next_backslash == -1 and next_slash == -1: + current = (current if current else "") + category_name[index:] + index = len(category_name) + elif next_slash >= 0 and (next_backslash == -1 or next_backslash > next_slash): + result.append((current if current else "") + category_name[index:next_slash]) + current = '' + index = next_slash + 1 + next_slash = category_name.find('/', index) + else: + if len(category_name) == next_backslash + 1: + raise Exception("Unexpected '\\' in '{0}' at last position!".format(category_name)) + esc_ch = category_name[next_backslash + 1] + if esc_ch not in {'/', '\\'}: + raise Exception("Unknown escape sequence '\\{0}' in '{1}'!".format(esc_ch, category_name)) + current = (current if current else "") + category_name[index:next_backslash] + esc_ch + index = next_backslash + 2 + next_backslash = category_name.find('\\', index) + if esc_ch == '/': + next_slash = category_name.find('/', index) + if current is not None: + result.append(current) + return result + + +def join_hierarchical_category_path(category_path): + """Join a category path.""" + def escape(s): + """Espace one part of category path.""" + return s.replace('\\', '\\\\').replace('/', '\\/') + + return '/'.join([escape(p) for p in category_path]) + + +def sort_classifications(taxonomy, classifications, lang): + """Sort the given list of classifications of the given taxonomy and language. + + ``taxonomy`` must be a ``Taxonomy`` plugin. + ``classifications`` must be an iterable collection of + classification strings for that taxonomy. + ``lang`` is the language the classifications are for. + + The result will be returned as a sorted list. Sorting will + happen according to the way the complete classification + hierarchy for the taxonomy is sorted. + """ + if taxonomy.has_hierarchy: + # To sort a hierarchy of classifications correctly, we first + # build a tree out of them (and mark for each node whether it + # appears in the list), then sort the tree node-wise, and finally + # collapse the tree into a list of recombined classifications. + + # Step 1: build hierarchy. Here, each node consists of a boolean + # flag (node appears in list) and a dictionary mapping path elements + # to nodes. + root = [False, {}] + for classification in classifications: + node = root + for elt in taxonomy.extract_hierarchy(classification): + if elt not in node[1]: + node[1][elt] = [False, {}] + node = node[1][elt] + node[0] = True + # Step 2: sort hierarchy. The result for a node is a pair + # (flag, subnodes), where subnodes is a list of pairs (name, subnode). + + def sort_node(node, level=0): + """Return sorted node, with children as `(name, node)` list instead of a dictionary.""" + keys = natsort.natsorted(node[1].keys(), alg=natsort.ns.F | natsort.ns.IC) + taxonomy.sort_classifications(keys, lang, level) + subnodes = [] + for key in keys: + subnodes.append((key, sort_node(node[1][key], level + 1))) + return (node[0], subnodes) + + root = sort_node(root) + # Step 3: collapse the tree structure into a linear sorted list, + # with a node coming before its children. + + def append_node(classifications, node, path=()): + """Append the node and then its children to the classifications list.""" + if node[0]: + classifications.append(taxonomy.recombine_classification_from_hierarchy(path)) + for key, subnode in node[1]: + append_node(classifications, subnode, path + (key, )) + + classifications = [] + append_node(classifications, root) + return classifications + else: + # Sorting a flat hierarchy is simpler. We pre-sort with + # natsorted and call taxonomy.sort_classifications. + classifications = natsort.natsorted(classifications, alg=natsort.ns.F | natsort.ns.IC) + taxonomy.sort_classifications(classifications, lang) + return classifications diff --git a/nikola/image_processing.py b/nikola/image_processing.py index 0ba139f..04d4e64 100644 --- a/nikola/image_processing.py +++ b/nikola/image_processing.py @@ -26,69 +26,191 @@ """Process images.""" -from __future__ import unicode_literals import datetime +import gzip import os +import re + +import lxml +import piexif +from PIL import ExifTags, Image from nikola import utils -Image = None -try: - from PIL import Image, ExifTags # NOQA -except ImportError: - try: - import Image as _Image - import ExifTags - Image = _Image - except ImportError: - pass +EXIF_TAG_NAMES = {} class ImageProcessor(object): - """Apply image operations.""" - image_ext_list_builtin = ['.jpg', '.png', '.jpeg', '.gif', '.svg', '.bmp', '.tiff'] + image_ext_list_builtin = ['.jpg', '.png', '.jpeg', '.gif', '.svg', '.svgz', '.bmp', '.tiff', '.webp'] + + def _fill_exif_tag_names(self): + """Connect EXIF tag names to numeric values.""" + if not EXIF_TAG_NAMES: + for ifd in piexif.TAGS: + for tag, data in piexif.TAGS[ifd].items(): + EXIF_TAG_NAMES[tag] = data['name'] + + def filter_exif(self, exif, whitelist): + """Filter EXIF data as described in the documentation.""" + # Scenario 1: keep everything + if whitelist == {'*': '*'}: + return exif + + # Scenario 2: keep nothing + if whitelist == {}: + return None + + # Scenario 3: keep some + self._fill_exif_tag_names() + exif = exif.copy() # Don't modify in-place, it's rude + for k in list(exif.keys()): + if type(exif[k]) != dict: + pass # At least thumbnails have no fields + elif k not in whitelist: + exif.pop(k) # Not whitelisted, remove + elif k in whitelist and whitelist[k] == '*': + # Fully whitelisted, keep all + pass + else: + # Partially whitelisted + for tag in list(exif[k].keys()): + if EXIF_TAG_NAMES[tag] not in whitelist[k]: + exif[k].pop(tag) + + return exif or None + + def resize_image(self, src, dst=None, max_size=None, bigger_panoramas=True, preserve_exif_data=False, exif_whitelist={}, preserve_icc_profiles=False, dst_paths=None, max_sizes=None): + """Make a copy of the image in the requested size(s). - def resize_image(self, src, dst, max_size, bigger_panoramas=True): - """Make a copy of the image in the requested size.""" - if not Image: - utils.copy_file(src, dst) + max_sizes should be a list of sizes, and the image would be resized to fit in a + square of each size (preserving aspect ratio). + + dst_paths is a list of the destination paths, and should be the same length as max_sizes. + + Backwards compatibility: + + * If max_sizes is None, it's set to [max_size] + * If dst_paths is None, it's set to [dst] + * Either max_size or max_sizes should be set + * Either dst or dst_paths should be set + """ + if dst_paths is None: + dst_paths = [dst] + if max_sizes is None: + max_sizes = [max_size] + if len(max_sizes) != len(dst_paths): + raise ValueError('resize_image called with incompatible arguments: {} / {}'.format(dst_paths, max_sizes)) + extension = os.path.splitext(src)[1].lower() + if extension in {'.svg', '.svgz'}: + self.resize_svg(src, dst_paths, max_sizes, bigger_panoramas) return - im = Image.open(src) - w, h = im.size - if w > max_size or h > max_size: - size = max_size, max_size - # Panoramas get larger thumbnails because they look *awful* - if bigger_panoramas and w > 2 * h: - size = min(w, max_size * 4), min(w, max_size * 4) + _im = Image.open(src) - try: - exif = im._getexif() - except Exception: - exif = None - if exif is not None: - for tag, value in list(exif.items()): - decoded = ExifTags.TAGS.get(tag, tag) + # The jpg exclusion is Issue #3332 + is_animated = hasattr(_im, 'n_frames') and _im.n_frames > 1 and extension not in {'.jpg', '.jpeg'} + + exif = None + if "exif" in _im.info: + exif = piexif.load(_im.info["exif"]) + # Rotate according to EXIF + if "0th" in exif: + value = exif['0th'].get(piexif.ImageIFD.Orientation, 1) + if value in (3, 4): + _im = _im.transpose(Image.ROTATE_180) + elif value in (5, 6): + _im = _im.transpose(Image.ROTATE_270) + elif value in (7, 8): + _im = _im.transpose(Image.ROTATE_90) + if value in (2, 4, 5, 7): + _im = _im.transpose(Image.FLIP_LEFT_RIGHT) + exif['0th'][piexif.ImageIFD.Orientation] = 1 + exif = self.filter_exif(exif, exif_whitelist) + + icc_profile = _im.info.get('icc_profile') if preserve_icc_profiles else None + + for dst, max_size in zip(dst_paths, max_sizes): + if is_animated: # Animated gif, leave as-is + utils.copy_file(src, dst) + continue - if decoded == 'Orientation': - if value == 3: - im = im.rotate(180) - elif value == 6: - im = im.rotate(270) - elif value == 8: - im = im.rotate(90) - break + im = _im.copy() + + size = w, h = im.size + if w > max_size or h > max_size: + size = max_size, max_size + # Panoramas get larger thumbnails because they look *awful* + if bigger_panoramas and w > 2 * h: + size = min(w, max_size * 4), min(w, max_size * 4) try: im.thumbnail(size, Image.ANTIALIAS) - im.save(dst) + save_args = {} + if icc_profile: + save_args['icc_profile'] = icc_profile + + if exif is not None and preserve_exif_data: + # Put right size in EXIF data + w, h = im.size + if '0th' in exif: + exif["0th"][piexif.ImageIFD.ImageWidth] = w + exif["0th"][piexif.ImageIFD.ImageLength] = h + if 'Exif' in exif: + exif["Exif"][piexif.ExifIFD.PixelXDimension] = w + exif["Exif"][piexif.ExifIFD.PixelYDimension] = h + # Filter EXIF data as required + save_args['exif'] = piexif.dump(exif) + + im.save(dst, **save_args) except Exception as e: - self.logger.warn("Can't thumbnail {0}, using original " - "image as thumbnail ({1})".format(src, e)) + self.logger.warning("Can't process {0}, using original " + "image! ({1})".format(src, e)) + utils.copy_file(src, dst) + + def resize_svg(self, src, dst_paths, max_sizes, bigger_panoramas): + """Make a copy of an svg at the requested sizes.""" + # Resize svg based on viewport hacking. + # note that this can also lead to enlarged svgs + if src.endswith('.svgz'): + with gzip.GzipFile(src, 'rb') as op: + xml = op.read() + else: + with open(src, 'rb') as op: + xml = op.read() + + for dst, max_size in zip(dst_paths, max_sizes): + try: + tree = lxml.etree.XML(xml) + width = tree.attrib['width'] + height = tree.attrib['height'] + w = int(re.search("[0-9]+", width).group(0)) + h = int(re.search("[0-9]+", height).group(0)) + # calculate new size preserving aspect ratio. + ratio = float(w) / h + # Panoramas get larger thumbnails because they look *awful* + if bigger_panoramas and w > 2 * h: + max_size = max_size * 4 + if w > h: + w = max_size + h = max_size / ratio + else: + w = max_size * ratio + h = max_size + w = int(w) + h = int(h) + tree.attrib.pop("width") + tree.attrib.pop("height") + tree.attrib['viewport'] = "0 0 %ipx %ipx" % (w, h) + if dst.endswith('.svgz'): + op = gzip.GzipFile(dst, 'wb') + else: + op = open(dst, 'wb') + op.write(lxml.etree.tostring(tree)) + op.close() + except (KeyError, AttributeError) as e: + self.logger.warning("No width/height in %s. Original exception: %s" % (src, e)) utils.copy_file(src, dst) - else: # Image is small - utils.copy_file(src, dst) def image_date(self, src): """Try to figure out the date of the image.""" @@ -96,6 +218,7 @@ class ImageProcessor(object): try: im = Image.open(src) exif = im._getexif() + im.close() except Exception: exif = None if exif is not None: @@ -103,8 +226,10 @@ class ImageProcessor(object): decoded = ExifTags.TAGS.get(tag, tag) if decoded in ('DateTimeOriginal', 'DateTimeDigitized'): try: + if isinstance(value, tuple): + value = value[0] self.dates[src] = datetime.datetime.strptime( - value, r'%Y:%m:%d %H:%M:%S') + value, '%Y:%m:%d %H:%M:%S') break except ValueError: # Invalid EXIF date. pass diff --git a/nikola/log.py b/nikola/log.py new file mode 100644 index 0000000..9960ba1 --- /dev/null +++ b/nikola/log.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Logging support.""" + +import enum +import logging +import warnings + +from nikola import DEBUG + +__all__ = ( + "get_logger", + "LOGGER", +) + + +# Handlers/formatters +class ApplicationWarning(Exception): + """An application warning, raised in strict mode.""" + + pass + + +class StrictModeExceptionHandler(logging.StreamHandler): + """A logging handler that raises an exception on warnings.""" + + def emit(self, record: logging.LogRecord) -> None: + """Emit a logging record.""" + if record.levelno >= logging.WARNING: + raise ApplicationWarning(self.format(record)) + + +class ColorfulFormatter(logging.Formatter): + """Stream handler with colors.""" + + _colorful = False + + def format(self, record: logging.LogRecord) -> str: + """Format a message and add colors to it.""" + message = super().format(record) + return self.wrap_in_color(record).format(message) + + def wrap_in_color(self, record: logging.LogRecord) -> str: + """Return the colorized string for this record.""" + if not self._colorful: + return "{}" + if record.levelno >= logging.ERROR: + return "\033[1;31m{}\033[0m" + elif record.levelno >= logging.WARNING: + return "\033[1;33m{}\033[0m" + elif record.levelno >= logging.INFO: + return "\033[1m{}\033[0m" + return "\033[37m{}\033[0m" + + +# Initial configuration +class LoggingMode(enum.Enum): + """Logging mode options.""" + + NORMAL = 0 + STRICT = 1 + QUIET = 2 + + +def configure_logging(logging_mode: LoggingMode = LoggingMode.NORMAL) -> None: + """Configure logging for Nikola. + + This method can be called multiple times, previous configuration will be overridden. + """ + if DEBUG: + logging.root.level = logging.DEBUG + else: + logging.root.level = logging.INFO + + if logging_mode == LoggingMode.QUIET: + logging.root.handlers = [] + return + + handler = logging.StreamHandler() + handler.setFormatter( + ColorfulFormatter( + fmt="[%(asctime)s] %(levelname)s: %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + + handlers = [handler] + if logging_mode == LoggingMode.STRICT: + handlers.append(StrictModeExceptionHandler()) + + logging.root.handlers = handlers + + +configure_logging() + + +# For compatibility with old code written with Logbook in mind +# TODO remove in v9 +def patch_notice_level(logger: logging.Logger) -> logging.Logger: + """Patch logger to issue WARNINGs with logger.notice.""" + logger.notice = logger.warning + return logger + + +# User-facing loggers +def get_logger(name: str, handlers=None) -> logging.Logger: + """Get a logger with handlers attached.""" + logger = logging.getLogger(name) + if handlers is not None: + for h in handlers: + logger.addHandler(h) + return patch_notice_level(logger) + + +LOGGER = get_logger("Nikola") + + +# Push warnings to logging +def showwarning(message, category, filename, lineno, file=None, line=None): + """Show a warning (from the warnings module) to the user.""" + try: + n = category.__name__ + except AttributeError: + n = str(category) + get_logger(n).warning("{0}:{1}: {2}".format(filename, lineno, message)) + + +warnings.showwarning = showwarning diff --git a/nikola/metadata_extractors.py b/nikola/metadata_extractors.py new file mode 100644 index 0000000..2377dc2 --- /dev/null +++ b/nikola/metadata_extractors.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Chris Warrick, Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Default metadata extractors and helper functions.""" + +import re +from enum import Enum +from io import StringIO + +import natsort + +from nikola.plugin_categories import MetadataExtractor +from nikola.utils import unslugify + +__all__ = ('MetaCondition', 'MetaPriority', 'MetaSource', 'check_conditions') +_default_extractors = [] +DEFAULT_EXTRACTOR_NAME = 'nikola' +DEFAULT_EXTRACTOR = None + + +class MetaCondition(Enum): + """Conditions for extracting metadata.""" + + config_bool = 1 + config_present = 2 + extension = 3 + compiler = 4 + first_line = 5 + never = -1 + + +class MetaPriority(Enum): + """Priority of metadata. + + An extractor is used if and only if the higher-priority extractors returned nothing. + """ + + override = 1 + specialized = 2 + normal = 3 + fallback = 4 + + +class MetaSource(Enum): + """Source of metadata.""" + + text = 1 + filename = 2 + + +def check_conditions(post, filename: str, conditions: list, config: dict, source_text: str) -> bool: + """Check the conditions for a metadata extractor.""" + for ct, arg in conditions: + if any(( + ct == MetaCondition.config_bool and not config.get(arg, False), + ct == MetaCondition.config_present and arg not in config, + ct == MetaCondition.extension and not filename.endswith(arg), + ct == MetaCondition.compiler and (post is None or post.compiler.name != arg), + ct == MetaCondition.never + )): + return False + elif ct == MetaCondition.first_line: + if not source_text or not source_text.startswith(arg + '\n'): + return False + return True + + +def classify_extractor(extractor: MetadataExtractor, metadata_extractors_by: dict): + """Classify an extractor and add it to the metadata_extractors_by dict.""" + global DEFAULT_EXTRACTOR + if extractor.name == DEFAULT_EXTRACTOR_NAME: + DEFAULT_EXTRACTOR = extractor + metadata_extractors_by['priority'][extractor.priority].append(extractor) + metadata_extractors_by['source'][extractor.source].append(extractor) + metadata_extractors_by['name'][extractor.name] = extractor + metadata_extractors_by['all'].append(extractor) + + +def load_defaults(site, metadata_extractors_by: dict): + """Load default metadata extractors.""" + for extractor in _default_extractors: + extractor.site = site + classify_extractor(extractor, metadata_extractors_by) + + +def is_extractor(extractor) -> bool: # pragma: no cover + """Check if a given class is an extractor.""" + return isinstance(extractor, MetadataExtractor) + + +def default_metadata_extractors_by() -> dict: + """Return the default metadata_extractors_by dictionary.""" + d = { + 'priority': {}, + 'source': {}, + 'name': {}, + 'all': [] + } + + for i in MetaPriority: + d['priority'][i] = [] + for i in MetaSource: + d['source'][i] = [] + + return d + + +def _register_default(extractor: type) -> type: + """Register a default extractor.""" + _default_extractors.append(extractor()) + return extractor + + +@_register_default +class NikolaMetadata(MetadataExtractor): + """Extractor for Nikola-style metadata.""" + + name = 'nikola' + source = MetaSource.text + priority = MetaPriority.normal + supports_write = True + split_metadata_re = re.compile('\n\n') + nikola_re = re.compile(r'^\s*\.\. (.*?): (.*)') + map_from = 'nikola' # advertised in values mapping only + + def _extract_metadata_from_text(self, source_text: str) -> dict: + """Extract metadata from text.""" + outdict = {} + for line in source_text.split('\n'): + match = self.nikola_re.match(line) + if match: + k, v = match.group(1), match.group(2) + if v: + outdict[k] = v + return outdict + + def write_metadata(self, metadata: dict, comment_wrap=False) -> str: + """Write metadata in this extractor’s format.""" + metadata = metadata.copy() + order = ('title', 'slug', 'date', 'tags', 'category', 'link', 'description', 'type') + f = '.. {0}: {1}' + meta = [] + for k in order: + try: + meta.append(f.format(k, metadata.pop(k))) + except KeyError: + pass + # Leftover metadata (user-specified/non-default). + for k in natsort.natsorted(list(metadata.keys()), alg=natsort.ns.F | natsort.ns.IC): + meta.append(f.format(k, metadata[k])) + data = '\n'.join(meta) + if comment_wrap is True: + comment_wrap = ('<!--', '-->') + if comment_wrap: + return '\n'.join((comment_wrap[0], data, comment_wrap[1], '', '')) + else: + return data + '\n\n' + + +@_register_default +class YAMLMetadata(MetadataExtractor): + """Extractor for YAML metadata.""" + + name = 'yaml' + source = MetaSource.text + conditions = ((MetaCondition.first_line, '---'),) + requirements = [('ruamel.yaml', 'ruamel.yaml', 'YAML')] + supports_write = True + split_metadata_re = re.compile('\n---\n') + map_from = 'yaml' + priority = MetaPriority.specialized + + def _extract_metadata_from_text(self, source_text: str) -> dict: + """Extract metadata from text.""" + from ruamel.yaml import YAML + yaml = YAML(typ='safe') + meta = yaml.load(source_text[4:]) + # We expect empty metadata to be '', not None + for k in meta: + if meta[k] is None: + meta[k] = '' + return meta + + def write_metadata(self, metadata: dict, comment_wrap=False) -> str: + """Write metadata in this extractor’s format.""" + from ruamel.yaml import YAML + yaml = YAML(typ='safe') + yaml.default_flow_style = False + stream = StringIO() + yaml.dump(metadata, stream) + stream.seek(0) + return '\n'.join(('---', stream.read().strip(), '---', '')) + + +@_register_default +class TOMLMetadata(MetadataExtractor): + """Extractor for TOML metadata.""" + + name = 'toml' + source = MetaSource.text + conditions = ((MetaCondition.first_line, '+++'),) + requirements = [('toml', 'toml', 'TOML')] + supports_write = True + split_metadata_re = re.compile('\n\\+\\+\\+\n') + map_from = 'toml' + priority = MetaPriority.specialized + + def _extract_metadata_from_text(self, source_text: str) -> dict: + """Extract metadata from text.""" + import toml + return toml.loads(source_text[4:]) + + def write_metadata(self, metadata: dict, comment_wrap=False) -> str: + """Write metadata in this extractor’s format.""" + import toml + return '\n'.join(('+++', toml.dumps(metadata).strip(), '+++', '')) + + +@_register_default +class FilenameRegexMetadata(MetadataExtractor): + """Extractor for filename metadata.""" + + name = 'filename_regex' + source = MetaSource.filename + priority = MetaPriority.fallback + conditions = [(MetaCondition.config_bool, 'FILE_METADATA_REGEXP')] + + def _extract_metadata_from_text(self, source_text: str) -> dict: + """Extract metadata from text.""" + # This extractor does not use the source text, and as such, this method returns an empty dict. + return {} + + def extract_filename(self, filename: str, lang: str) -> dict: + """Try to read the metadata from the filename based on the given re. + + This requires to use symbolic group names in the pattern. + The part to read the metadata from the filename based on a regular + expression is taken from Pelican - pelican/readers.py + """ + match = re.match(self.site.config['FILE_METADATA_REGEXP'], filename) + meta = {} + + if match: + for key, value in match.groupdict().items(): + k = key.lower().strip() # metadata must be lowercase + if k == 'title' and self.site.config['FILE_METADATA_UNSLUGIFY_TITLES']: + meta[k] = unslugify(value, lang, discard_numbers=False) + else: + meta[k] = value + + return meta diff --git a/nikola/nikola.py b/nikola/nikola.py index e0af7ad..86d81e6 100644 --- a/nikola/nikola.py +++ b/nikola/nikola.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,38 +26,32 @@ """The main Nikola site object.""" -from __future__ import print_function, unicode_literals -import io -from collections import defaultdict -from copy import copy -from pkg_resources import resource_filename import datetime -import locale -import os +import io import json +import functools +import logging +import operator +import os import sys -import natsort import mimetypes -try: - from urlparse import urlparse, urlsplit, urlunsplit, urljoin, unquote -except ImportError: - from urllib.parse import urlparse, urlsplit, urlunsplit, urljoin, unquote # NOQA - -try: - import pyphen -except ImportError: - pyphen = None +from collections import defaultdict +from copy import copy +from urllib.parse import urlparse, urlsplit, urlunsplit, urljoin, unquote, parse_qs import dateutil.tz -import logging -import PyRSS2Gen as rss import lxml.etree import lxml.html -from yapsy.PluginManager import PluginManager +import natsort +import PyRSS2Gen as rss +from pkg_resources import resource_filename from blinker import signal +from yapsy.PluginManager import PluginManager +from . import DEBUG, SHOW_TRACEBACKS, filters, utils, hierarchy_utils, shortcodes +from . import metadata_extractors +from .metadata_extractors import default_metadata_extractors_by from .post import Post # NOQA -from . import DEBUG, utils from .plugin_categories import ( Command, LateTask, @@ -65,13 +59,22 @@ from .plugin_categories import ( CompilerExtension, MarkdownExtension, RestExtension, + MetadataExtractor, + ShortcodePlugin, Task, TaskMultiplier, TemplateSystem, SignalHandler, ConfigPlugin, PostScanner, + Taxonomy, ) +from .state import Persistor + +try: + import pyphen +except ImportError: + pyphen = None if DEBUG: logging.basicConfig(level=logging.DEBUG) @@ -80,31 +83,31 @@ else: # Default "Read more..." link DEFAULT_INDEX_READ_MORE_LINK = '<p class="more"><a href="{link}">{read_more}…</a></p>' -DEFAULT_RSS_READ_MORE_LINK = '<p><a href="{link}">{read_more}…</a> ({min_remaining_read})</p>' - -# Default pattern for translation files' names -DEFAULT_TRANSLATIONS_PATTERN = '{path}.{lang}.{ext}' +DEFAULT_FEED_READ_MORE_LINK = '<p><a href="{link}">{read_more}…</a> ({min_remaining_read})</p>' config_changed = utils.config_changed __all__ = ('Nikola',) -# We store legal values for some setting here. For internal use. +# We store legal values for some settings here. For internal use. LEGAL_VALUES = { + 'DEFAULT_THEME': 'bootblog4', 'COMMENT_SYSTEM': [ 'disqus', 'facebook', - 'googleplus', 'intensedebate', 'isso', - 'livefyre', 'muut', + 'commento', + 'utterances', ], 'TRANSLATIONS': { + 'af': 'Afrikaans', 'ar': 'Arabic', 'az': 'Azerbaijani', 'bg': 'Bulgarian', + 'bs': 'Bosnian', 'ca': 'Catalan', ('cs', 'cz'): 'Czech', 'da': 'Danish', @@ -118,127 +121,120 @@ LEGAL_VALUES = { 'fa': 'Persian', 'fi': 'Finnish', 'fr': 'French', + 'fur': 'Friulian', + 'gl': 'Galician', + 'he': 'Hebrew', 'hi': 'Hindi', 'hr': 'Croatian', + 'hu': 'Hungarian', + 'ia': 'Interlingua', 'id': 'Indonesian', 'it': 'Italian', ('ja', '!jp'): 'Japanese', 'ko': 'Korean', - 'nb': 'Norwegian Bokmål', + 'lt': 'Lithuanian', + 'ml': 'Malayalam', + 'mr': 'Marathi', + 'nb': 'Norwegian (Bokmål)', 'nl': 'Dutch', 'pa': 'Punjabi', 'pl': 'Polish', - 'pt_br': 'Portuguese (Brasil)', + 'pt': 'Portuguese', + 'pt_br': 'Portuguese (Brazil)', 'ru': 'Russian', 'sk': 'Slovak', 'sl': 'Slovene', + 'sq': 'Albanian', 'sr': 'Serbian (Cyrillic)', + 'sr_latin': 'Serbian (Latin)', 'sv': 'Swedish', + 'te': 'Telugu', + 'th': 'Thai', ('tr', '!tr_TR'): 'Turkish', - 'ur': 'Urdu', 'uk': 'Ukrainian', + 'ur': 'Urdu', + 'vi': 'Vietnamese', 'zh_cn': 'Chinese (Simplified)', - }, - '_WINDOWS_LOCALE_GUESSES': { - # TODO incomplete - # some languages may need that the appropiate Microsoft Language Pack be instaled. - "bg": "Bulgarian", - "ca": "Catalan", - "de": "German", - "el": "Greek", - "en": "English", - "eo": "Esperanto", - "es": "Spanish", - "fa": "Farsi", # Persian - "fr": "French", - "hr": "Croatian", - "it": "Italian", - "jp": "Japanese", - "nl": "Dutch", - "pl": "Polish", - "pt_br": "Portuguese_Brazil", - "ru": "Russian", - "sl_si": "Slovenian", - "tr_tr": "Turkish", - "zh_cn": "Chinese_China", # Chinese (Simplified) + 'zh_tw': 'Chinese (Traditional)' }, '_TRANSLATIONS_WITH_COUNTRY_SPECIFIERS': { # This dict is used in `init` in case of locales that exist with a # country specifier. If there is no other locale that has the same # language with a different country, ``nikola init`` (but nobody else!) # will accept it, warning the user about it. - 'pt': 'pt_br', - 'zh': 'zh_cn', + + # This dict is currently empty. }, - 'RTL_LANGUAGES': ('ar', 'fa', 'ur'), - 'COLORBOX_LOCALES': defaultdict( - str, - ar='ar', - bg='bg', - ca='ca', - cs='cs', - cz='cs', - da='da', - de='de', - en='', - es='es', - et='et', - fa='fa', - fi='fi', - fr='fr', - hr='hr', - id='id', - it='it', - ja='ja', - ko='kr', # kr is South Korea, ko is the Korean language - nb='no', - nl='nl', - pl='pl', - pt_br='pt-BR', - ru='ru', - sk='sk', - sl='si', # country code is si, language code is sl, colorbox is wrong - sr='sr', # warning: this is serbian in Latin alphabet - sv='sv', - tr='tr', - uk='uk', - zh_cn='zh-CN' - ), - 'MOMENTJS_LOCALES': defaultdict( - str, - ar='ar', - bg='bg', - bn='bn', - ca='ca', - cs='cs', - cz='cs', - da='da', - de='de', - en='', - es='es', - et='et', - fa='fa', - fi='fi', - fr='fr', - hr='hr', - id='id', - it='it', - ja='ja', - ko='ko', - nb='nb', - nl='nl', - pl='pl', - pt_br='pt-br', - ru='ru', - sk='sk', - sl='sl', - sr='sr-cyrl', - sv='sv', - tr='tr', - zh_cn='zh-cn' - ), - 'PYPHEN_LOCALES': { + 'LOCALES_BASE': { + # A list of locale mappings to apply for every site. Can be overridden in the config. + 'sr_latin': 'sr_Latn', + }, + 'RTL_LANGUAGES': ('ar', 'fa', 'he', 'ur'), + 'LUXON_LOCALES': defaultdict(lambda: 'en', **{ + 'af': 'af', + 'ar': 'ar', + 'az': 'az', + 'bg': 'bg', + 'bn': 'bn', + 'bs': 'bs', + 'ca': 'ca', + 'cs': 'cs', + 'cz': 'cs', + 'da': 'da', + 'de': 'de', + 'el': 'el', + 'en': 'en', + 'eo': 'eo', + 'es': 'es', + 'et': 'et', + 'eu': 'eu', + 'fa': 'fa', + 'fi': 'fi', + 'fr': 'fr', + 'fur': 'fur', + 'gl': 'gl', + 'hi': 'hi', + 'he': 'he', + 'hr': 'hr', + 'hu': 'hu', + 'ia': 'ia', + 'id': 'id', + 'it': 'it', + 'ja': 'ja', + 'ko': 'ko', + 'lt': 'lt', + 'ml': 'ml', + 'mr': 'mr', + 'nb': 'nb', + 'nl': 'nl', + 'pa': 'pa', + 'pl': 'pl', + 'pt': 'pt', + 'pt_br': 'pt-BR', + 'ru': 'ru', + 'sk': 'sk', + 'sl': 'sl', + 'sq': 'sq', + 'sr': 'sr-Cyrl', + 'sr_latin': 'sr-Latn', + 'sv': 'sv', + 'te': 'te', + 'tr': 'tr', + 'th': 'th', + 'uk': 'uk', + 'ur': 'ur', + 'vi': 'vi', + 'zh_cn': 'zh-CN', + 'zh_tw': 'zh-TW' + }), + # TODO: remove in v9 + 'MOMENTJS_LOCALES': defaultdict(lambda: 'en', **{ + 'af': 'af', + 'ar': 'ar', + 'az': 'az', 'bg': 'bg', + 'bn': 'bn', + 'bs': 'bs', 'ca': 'ca', 'cs': 'cs', 'cz': 'cs', @@ -246,43 +242,142 @@ LEGAL_VALUES = { 'de': 'de', 'el': 'el', 'en': 'en', + 'eo': 'eo', + 'es': 'es', + 'et': 'et', + 'eu': 'eu', + 'fa': 'fa', + 'fi': 'fi', + 'fr': 'fr', + 'gl': 'gl', + 'hi': 'hi', + 'he': 'he', + 'hr': 'hr', + 'hu': 'hu', + 'id': 'id', + 'it': 'it', + 'ja': 'ja', + 'ko': 'ko', + 'lt': 'lt', + 'ml': 'ml', + 'mr': 'mr', + 'nb': 'nb', + 'nl': 'nl', + 'pa': 'pa-in', + 'pl': 'pl', + 'pt': 'pt', + 'pt_br': 'pt-br', + 'ru': 'ru', + 'sk': 'sk', + 'sl': 'sl', + 'sq': 'sq', + 'sr': 'sr-cyrl', + 'sr_latin': 'sr', + 'sv': 'sv', + 'te': 'te', + 'tr': 'tr', + 'th': 'th', + 'uk': 'uk', + 'ur': 'ur', + 'vi': 'vi', + 'zh_cn': 'zh-cn', + 'zh_tw': 'zh-tw' + }), + 'PYPHEN_LOCALES': { + 'af': 'af', + 'bg': 'bg', + 'ca': 'ca', + 'cs': 'cs', + 'cz': 'cs', + 'da': 'da', + 'de': 'de', + 'el': 'el', + 'en': 'en_US', 'es': 'es', 'et': 'et', 'fr': 'fr', 'hr': 'hr', + 'hu': 'hu', 'it': 'it', + 'lt': 'lt', 'nb': 'nb', 'nl': 'nl', 'pl': 'pl', + 'pt': 'pt', 'pt_br': 'pt_BR', 'ru': 'ru', 'sk': 'sk', 'sl': 'sl', 'sr': 'sr', 'sv': 'sv', + 'te': 'te', + 'uk': 'uk', }, + 'DOCUTILS_LOCALES': { + 'af': 'af', + 'ca': 'ca', + 'da': 'da', + 'de': 'de', + 'en': 'en', + 'eo': 'eo', + 'es': 'es', + 'fa': 'fa', + 'fi': 'fi', + 'fr': 'fr', + 'gl': 'gl', + 'he': 'he', + 'it': 'it', + 'ja': 'ja', + 'lt': 'lt', + 'nl': 'nl', + 'pl': 'pl', + 'pt': 'pt_br', # hope nobody will mind + 'pt_br': 'pt_br', + 'ru': 'ru', + 'sk': 'sk', + 'sv': 'sv', + 'zh_cn': 'zh_cn', + 'zh_tw': 'zh_tw' + }, + "METADATA_MAPPING": ["yaml", "toml", "rest_docinfo", "markdown_metadata"], +} + +# Mapping old pre-taxonomy plugin names to new post-taxonomy plugin names +TAXONOMY_COMPATIBILITY_PLUGIN_NAME_MAP = { + "render_archive": ["classify_archive"], + "render_authors": ["classify_authors"], + "render_indexes": ["classify_page_index", "classify_sections"], # "classify_indexes" removed from list (see #2591 and special-case logic below) + "render_tags": ["classify_categories", "classify_tags"], } +# Default value for the pattern used to name translated files +DEFAULT_TRANSLATIONS_PATTERN = '{path}.{lang}.{ext}' + def _enclosure(post, lang): """Add an enclosure to RSS.""" enclosure = post.meta('enclosure', lang) if enclosure: - length = 0 + try: + length = int(post.meta('enclosure_length', lang) or 0) + except KeyError: + length = 0 + except ValueError: + utils.LOGGER.warning("Invalid enclosure length for post {0}".format(post.source_path)) + length = 0 url = enclosure mime = mimetypes.guess_type(url)[0] return url, length, mime class Nikola(object): - """Class that handles site generation. Takes a site config as argument on creation. """ def __init__(self, **config): - """Setup proper environment for running tasks.""" + """Initialize proper environment for running tasks.""" # Register our own path handlers self.path_handlers = { 'slug': self.slug_path, @@ -305,8 +400,10 @@ class Nikola(object): self._scanned = False self._template_system = None self._THEMES = None + self._MESSAGES = None + self.filters = {} self.debug = DEBUG - self.loghandlers = utils.STDERR_HANDLER # TODO remove on v8 + self.show_tracebacks = SHOW_TRACEBACKS self.colorful = config.pop('__colorful__', False) self.invariant = config.pop('__invariant__', False) self.quiet = config.pop('__quiet__', False) @@ -315,6 +412,8 @@ class Nikola(object): self.configuration_filename = config.pop('__configuration_filename__', False) self.configured = bool(config) self.injected_deps = defaultdict(list) + self.shortcode_registry = {} + self.metadata_extractors_by = default_metadata_extractors_by() self.rst_transforms = [] self.template_hooks = { @@ -331,25 +430,38 @@ class Nikola(object): # This is the default config self.config = { - 'ANNOTATIONS': False, 'ARCHIVE_PATH': "", 'ARCHIVE_FILENAME': "archive.html", 'ARCHIVES_ARE_INDEXES': False, + 'AUTHOR_PATH': 'authors', + 'AUTHOR_PAGES_ARE_INDEXES': False, + 'AUTHOR_PAGES_DESCRIPTIONS': {}, + 'AUTHORLIST_MINIMUM_POSTS': 1, 'BLOG_AUTHOR': 'Default Author', 'BLOG_TITLE': 'Default Title', + 'BLOG_EMAIL': '', 'BLOG_DESCRIPTION': 'Default Description', 'BODY_END': "", 'CACHE_FOLDER': 'cache', + 'CATEGORIES_INDEX_PATH': '', 'CATEGORY_PATH': None, # None means: same as TAG_PATH 'CATEGORY_PAGES_ARE_INDEXES': None, # None means: same as TAG_PAGES_ARE_INDEXES - 'CATEGORY_PAGES_DESCRIPTIONS': {}, + 'CATEGORY_DESCRIPTIONS': {}, + 'CATEGORY_TITLES': {}, 'CATEGORY_PREFIX': 'cat_', 'CATEGORY_ALLOW_HIERARCHIES': False, 'CATEGORY_OUTPUT_FLAT_HIERARCHY': False, + 'CATEGORY_DESTPATH_AS_DEFAULT': False, + 'CATEGORY_DESTPATH_TRIM_PREFIX': False, + 'CATEGORY_DESTPATH_FIRST_DIRECTORY_ONLY': True, + 'CATEGORY_DESTPATH_NAMES': {}, + 'CATEGORY_PAGES_FOLLOW_DESTPATH': False, + 'CATEGORY_TRANSLATIONS': [], + 'CATEGORY_TRANSLATIONS_ADD_DEFAULTS': False, 'CODE_COLOR_SCHEME': 'default', 'COMMENT_SYSTEM': 'disqus', 'COMMENTS_IN_GALLERIES': False, - 'COMMENTS_IN_STORIES': False, + 'COMMENTS_IN_PAGES': False, 'COMPILERS': { "rest": ('.txt', '.rst'), "markdown": ('.md', '.mdown', '.markdown'), @@ -362,34 +474,51 @@ class Nikola(object): }, 'CONTENT_FOOTER': '', 'CONTENT_FOOTER_FORMATS': {}, + 'RSS_COPYRIGHT': '', + 'RSS_COPYRIGHT_PLAIN': '', + 'RSS_COPYRIGHT_FORMATS': {}, 'COPY_SOURCES': True, + 'CREATE_ARCHIVE_NAVIGATION': False, 'CREATE_MONTHLY_ARCHIVE': False, 'CREATE_SINGLE_ARCHIVE': False, 'CREATE_FULL_ARCHIVES': False, 'CREATE_DAILY_ARCHIVE': False, - 'DATE_FORMAT': '%Y-%m-%d %H:%M', - 'JS_DATE_FORMAT': 'YYYY-MM-DD HH:mm', + 'DATE_FORMAT': 'yyyy-MM-dd HH:mm', + 'DISABLE_INDEXES': False, + 'DISABLE_MAIN_ATOM_FEED': False, + 'DISABLE_MAIN_RSS_FEED': False, + 'MOMENTJS_DATE_FORMAT': 'YYYY-MM-DD HH:mm', + 'LUXON_DATE_FORMAT': {}, 'DATE_FANCINESS': 0, 'DEFAULT_LANG': "en", 'DEPLOY_COMMANDS': {'default': []}, 'DISABLED_PLUGINS': [], 'EXTRA_PLUGINS_DIRS': [], + 'EXTRA_THEMES_DIRS': [], 'COMMENT_SYSTEM_ID': 'nikolademo', + 'ENABLE_AUTHOR_PAGES': True, + 'EXIF_WHITELIST': {}, 'EXTRA_HEAD_DATA': '', 'FAVICONS': (), 'FEED_LENGTH': 10, 'FILE_METADATA_REGEXP': None, + 'FILE_METADATA_UNSLUGIFY_TITLES': True, 'ADDITIONAL_METADATA': {}, 'FILES_FOLDERS': {'files': ''}, 'FILTERS': {}, 'FORCE_ISO8601': False, + 'FRONT_INDEX_HEADER': '', 'GALLERY_FOLDERS': {'galleries': 'galleries'}, 'GALLERY_SORT_BY_DATE': True, + 'GALLERIES_USE_THUMBNAIL': False, + 'GALLERIES_DEFAULT_THUMBNAIL': None, 'GLOBAL_CONTEXT_FILLER': [], 'GZIP_COMMAND': None, 'GZIP_FILES': False, 'GZIP_EXTENSIONS': ('.txt', '.htm', '.html', '.css', '.js', '.json', '.xml'), + 'HIDDEN_AUTHORS': [], 'HIDDEN_TAGS': [], + 'HIDE_REST_DOCINFO': False, 'HIDDEN_CATEGORIES': [], 'HYPHENATE': False, 'IMAGE_FOLDERS': {'images': ''}, @@ -397,6 +526,7 @@ class Nikola(object): 'INDEX_FILE': 'index.html', 'INDEX_TEASERS': False, 'IMAGE_THUMBNAIL_SIZE': 400, + 'IMAGE_THUMBNAIL_FORMAT': '{name}.thumbnail{ext}', 'INDEXES_TITLE': "", 'INDEXES_PAGES': "", 'INDEXES_PAGES_MAIN': False, @@ -404,79 +534,105 @@ class Nikola(object): 'INDEXES_STATIC': True, 'INDEX_PATH': '', 'IPYNB_CONFIG': {}, - 'LESS_COMPILER': 'lessc', - 'LESS_OPTIONS': [], + 'KATEX_AUTO_RENDER': '', 'LICENSE': '', 'LINK_CHECK_WHITELIST': [], 'LISTINGS_FOLDERS': {'listings': 'listings'}, 'LOGO_URL': '', + 'DEFAULT_PREVIEW_IMAGE': None, 'NAVIGATION_LINKS': {}, - 'MARKDOWN_EXTENSIONS': ['fenced_code', 'codehilite'], # FIXME: Add 'extras' in v8 + 'NAVIGATION_ALT_LINKS': {}, + 'MARKDOWN_EXTENSIONS': ['fenced_code', 'codehilite', 'extra'], + 'MARKDOWN_EXTENSION_CONFIGS': {}, 'MAX_IMAGE_SIZE': 1280, 'MATHJAX_CONFIG': '', + 'METADATA_FORMAT': 'nikola', + 'METADATA_MAPPING': {}, + 'MULTIPLE_AUTHORS_PER_POST': False, + 'NEW_POST_DATE_PATH': False, + 'NEW_POST_DATE_PATH_FORMAT': '%Y/%m/%d', 'OLD_THEME_SUPPORT': True, 'OUTPUT_FOLDER': 'output', 'POSTS': (("posts/*.txt", "posts", "post.tmpl"),), - 'PAGES': (("stories/*.txt", "stories", "story.tmpl"),), + 'PRESERVE_EXIF_DATA': False, + 'PRESERVE_ICC_PROFILES': False, + 'PAGES': (("pages/*.txt", "pages", "page.tmpl"),), 'PANDOC_OPTIONS': [], - 'PRETTY_URLS': False, + 'PRETTY_URLS': True, 'FUTURE_IS_NOW': False, 'INDEX_READ_MORE_LINK': DEFAULT_INDEX_READ_MORE_LINK, - 'RSS_READ_MORE_LINK': DEFAULT_RSS_READ_MORE_LINK, - 'RSS_LINKS_APPEND_QUERY': False, 'REDIRECTIONS': [], 'ROBOTS_EXCLUSIONS': [], 'GENERATE_ATOM': False, + 'ATOM_EXTENSION': '.atom', + 'ATOM_PATH': '', + 'ATOM_FILENAME_BASE': 'index', + 'FEED_TEASERS': True, + 'FEED_PLAIN': False, + 'FEED_READ_MORE_LINK': DEFAULT_FEED_READ_MORE_LINK, + 'FEED_LINKS_APPEND_QUERY': False, 'GENERATE_RSS': True, + 'RSS_EXTENSION': '.xml', 'RSS_LINK': None, 'RSS_PATH': '', - 'RSS_PLAIN': False, - 'RSS_TEASERS': True, - 'SASS_COMPILER': 'sass', - 'SASS_OPTIONS': [], + 'RSS_FILENAME_BASE': 'rss', 'SEARCH_FORM': '', 'SHOW_BLOG_TITLE': True, + 'SHOW_INDEX_PAGE_NAVIGATION': False, 'SHOW_SOURCELINK': True, 'SHOW_UNTRANSLATED_POSTS': True, + 'SLUG_AUTHOR_PATH': True, 'SLUG_TAG_PATH': True, 'SOCIAL_BUTTONS_CODE': '', 'SITE_URL': 'https://example.com/', - 'STORY_INDEX': False, - 'STRIP_INDEXES': False, - 'SITEMAP_INCLUDE_FILELESS_DIRS': True, + 'PAGE_INDEX': False, + 'SECTION_PATH': '', + 'STRIP_INDEXES': True, 'TAG_PATH': 'categories', 'TAG_PAGES_ARE_INDEXES': False, - 'TAG_PAGES_DESCRIPTIONS': {}, + 'TAG_DESCRIPTIONS': {}, + 'TAG_TITLES': {}, + 'TAG_TRANSLATIONS': [], + 'TAG_TRANSLATIONS_ADD_DEFAULTS': False, + 'TAGS_INDEX_PATH': '', 'TAGLIST_MINIMUM_POSTS': 1, 'TEMPLATE_FILTERS': {}, - 'THEME': 'bootstrap3', - 'THEME_REVEAL_CONFIG_SUBTHEME': 'sky', - 'THEME_REVEAL_CONFIG_TRANSITION': 'cube', + 'THEME': LEGAL_VALUES['DEFAULT_THEME'], + 'THEME_COLOR': '#5670d4', # light "corporate blue" + 'THEME_CONFIG': {}, 'THUMBNAIL_SIZE': 180, - 'UNSLUGIFY_TITLES': False, # WARNING: conf.py.in overrides this with True for backwards compatibility + 'TRANSLATIONS_PATTERN': DEFAULT_TRANSLATIONS_PATTERN, 'URL_TYPE': 'rel_path', 'USE_BUNDLES': True, 'USE_CDN': False, 'USE_CDN_WARNING': True, + 'USE_REST_DOCINFO_METADATA': False, 'USE_FILENAME_AS_TITLE': True, - 'USE_OPEN_GRAPH': True, + 'USE_KATEX': False, 'USE_SLUGIFY': True, + 'USE_TAG_METADATA': True, 'TIMEZONE': 'UTC', - 'WRITE_TAG_CLOUD': True, + 'WARN_ABOUT_TAG_METADATA': True, 'DEPLOY_DRAFTS': True, 'DEPLOY_FUTURE': False, 'SCHEDULE_ALL': False, 'SCHEDULE_RULE': '', - 'LOGGING_HANDLERS': {'stderr': {'loglevel': 'WARNING', 'bubble': True}}, 'DEMOTE_HEADERS': 1, 'GITHUB_SOURCE_BRANCH': 'master', 'GITHUB_DEPLOY_BRANCH': 'gh-pages', 'GITHUB_REMOTE_NAME': 'origin', + 'GITHUB_COMMIT_SOURCE': False, # WARNING: conf.py.in overrides this with True for backwards compatibility + 'META_GENERATOR_TAG': True, + 'REST_FILE_INSERTION_ENABLED': True, + 'TYPES_TO_HIDE_TITLE': [], } # set global_context for template rendering self._GLOBAL_CONTEXT = {} + # dependencies for all pages, not included in global context + self.ALL_PAGE_DEPS = {} + self.config.update(config) # __builtins__ contains useless cruft @@ -490,13 +646,22 @@ class Nikola(object): self.config['__invariant__'] = self.invariant self.config['__quiet__'] = self.quiet - # Make sure we have sane NAVIGATION_LINKS. + # Use ATOM_PATH when set + self.config['ATOM_PATH'] = self.config['ATOM_PATH'] or self.config['INDEX_PATH'] + + # Make sure we have sane NAVIGATION_LINKS and NAVIGATION_ALT_LINKS. if not self.config['NAVIGATION_LINKS']: self.config['NAVIGATION_LINKS'] = {self.config['DEFAULT_LANG']: ()} + if not self.config['NAVIGATION_ALT_LINKS']: + self.config['NAVIGATION_ALT_LINKS'] = {self.config['DEFAULT_LANG']: ()} # Translatability configuration. self.config['TRANSLATIONS'] = self.config.get('TRANSLATIONS', {self.config['DEFAULT_LANG']: ''}) + for k, v in self.config['TRANSLATIONS'].items(): + if os.path.isabs(v): + self.config['TRANSLATIONS'][k] = os.path.relpath(v, '/') + utils.TranslatableSetting.default_lang = self.config['DEFAULT_LANG'] self.TRANSLATABLE_SETTINGS = ('BLOG_AUTHOR', @@ -509,43 +674,190 @@ class Nikola(object): 'BODY_END', 'EXTRA_HEAD_DATA', 'NAVIGATION_LINKS', + 'NAVIGATION_ALT_LINKS', + 'FRONT_INDEX_HEADER', 'INDEX_READ_MORE_LINK', - 'RSS_READ_MORE_LINK', + 'FEED_READ_MORE_LINK', 'INDEXES_TITLE', + 'CATEGORY_DESTPATH_NAMES', 'INDEXES_PAGES', - 'INDEXES_PRETTY_PAGE_URL',) + 'INDEXES_PRETTY_PAGE_URL', + 'THEME_CONFIG', + # PATH options (Issue #1914) + 'ARCHIVE_PATH', + 'ARCHIVE_FILENAME', + 'TAG_PATH', + 'TAGS_INDEX_PATH', + 'CATEGORY_PATH', + 'CATEGORIES_INDEX_PATH', + 'SECTION_PATH', + 'INDEX_PATH', + 'ATOM_PATH', + 'RSS_PATH', + 'RSS_FILENAME_BASE', + 'ATOM_FILENAME_BASE', + 'AUTHOR_PATH', + 'DATE_FORMAT', + 'LUXON_DATE_FORMAT', + 'MOMENTJS_DATE_FORMAT', # TODO: remove in v9 + 'RSS_COPYRIGHT', + 'RSS_COPYRIGHT_PLAIN', + # Issue #2970 + 'MARKDOWN_EXTENSION_CONFIGS', + ) self._GLOBAL_CONTEXT_TRANSLATABLE = ('blog_author', 'blog_title', - 'blog_desc', # TODO: remove in v8 'blog_description', 'license', 'content_footer', 'social_buttons_code', 'search_form', 'body_end', - 'extra_head_data',) - # WARNING: navigation_links SHOULD NOT be added to the list above. + 'extra_head_data', + 'date_format', + 'js_date_format', + 'luxon_date_format', + 'front_index_header', + 'theme_config', + ) + + self._ALL_PAGE_DEPS_TRANSLATABLE = ('atom_path', + 'rss_path', + 'rss_filename_base', + 'atom_filename_base', + ) + # WARNING: navigation_(alt_)links SHOULD NOT be added to the list above. # Themes ask for [lang] there and we should provide it. + # Luxon setup is a dict of dicts, so we need to set up the default here. + if not self.config['LUXON_DATE_FORMAT']: + self.config['LUXON_DATE_FORMAT'] = {self.config['DEFAULT_LANG']: {'preset': False, 'format': 'yyyy-MM-dd HH:mm'}} + # TODO: remove Moment.js stuff in v9 + if 'JS_DATE_FORMAT' in self.config: + utils.LOGGER.warning("Moment.js was replaced by Luxon in the default themes, which uses different date formats.") + utils.LOGGER.warning("If you’re using a built-in theme, set LUXON_DATE_FORMAT. If your theme uses Moment.js, you can silence this warning by renaming JS_DATE_FORMAT to MOMENTJS_DATE_FORMAT.") + utils.LOGGER.warning("Sample Luxon config: LUXON_DATE_FORMAT = " + str(self.config['LUXON_DATE_FORMAT'])) + self.config['MOMENTJS_DATE_FORMAT'] = self.config['LUXON_DATE_FORMAT'] + + # We first have to massage MOMENTJS_DATE_FORMAT and LUXON_DATE_FORMAT, otherwise we run into trouble + if 'MOMENTJS_DATE_FORMAT' in self.config: + if isinstance(self.config['MOMENTJS_DATE_FORMAT'], dict): + for k in self.config['MOMENTJS_DATE_FORMAT']: + self.config['MOMENTJS_DATE_FORMAT'][k] = json.dumps(self.config['MOMENTJS_DATE_FORMAT'][k]) + else: + self.config['MOMENTJS_DATE_FORMAT'] = json.dumps(self.config['MOMENTJS_DATE_FORMAT']) + + if 'LUXON_DATE_FORMAT' in self.config: + for k in self.config['LUXON_DATE_FORMAT']: + self.config['LUXON_DATE_FORMAT'][k] = json.dumps(self.config['LUXON_DATE_FORMAT'][k]) + for i in self.TRANSLATABLE_SETTINGS: try: self.config[i] = utils.TranslatableSetting(i, self.config[i], self.config['TRANSLATIONS']) except KeyError: pass - # Handle CONTENT_FOOTER properly. - # We provide the arguments to format in CONTENT_FOOTER_FORMATS. + # A EXIF_WHITELIST implies you want to keep EXIF data + if self.config['EXIF_WHITELIST'] and not self.config['PRESERVE_EXIF_DATA']: + utils.LOGGER.warning('Setting EXIF_WHITELIST implies PRESERVE_EXIF_DATA is set to True') + self.config['PRESERVE_EXIF_DATA'] = True + + # Setting PRESERVE_EXIF_DATA with an empty EXIF_WHITELIST implies 'keep everything' + if self.config['PRESERVE_EXIF_DATA'] and not self.config['EXIF_WHITELIST']: + utils.LOGGER.warning('You are setting PRESERVE_EXIF_DATA and not EXIF_WHITELIST so EXIF data is not really kept.') + + if 'UNSLUGIFY_TITLES' in self.config: + utils.LOGGER.warning('The UNSLUGIFY_TITLES setting was renamed to FILE_METADATA_UNSLUGIFY_TITLES.') + self.config['FILE_METADATA_UNSLUGIFY_TITLES'] = self.config['UNSLUGIFY_TITLES'] + + if 'TAG_PAGES_TITLES' in self.config: + utils.LOGGER.warning('The TAG_PAGES_TITLES setting was renamed to TAG_TITLES.') + self.config['TAG_TITLES'] = self.config['TAG_PAGES_TITLES'] + + if 'TAG_PAGES_DESCRIPTIONS' in self.config: + utils.LOGGER.warning('The TAG_PAGES_DESCRIPTIONS setting was renamed to TAG_DESCRIPTIONS.') + self.config['TAG_DESCRIPTIONS'] = self.config['TAG_PAGES_DESCRIPTIONS'] + + if 'CATEGORY_PAGES_TITLES' in self.config: + utils.LOGGER.warning('The CATEGORY_PAGES_TITLES setting was renamed to CATEGORY_TITLES.') + self.config['CATEGORY_TITLES'] = self.config['CATEGORY_PAGES_TITLES'] + + if 'CATEGORY_PAGES_DESCRIPTIONS' in self.config: + utils.LOGGER.warning('The CATEGORY_PAGES_DESCRIPTIONS setting was renamed to CATEGORY_DESCRIPTIONS.') + self.config['CATEGORY_DESCRIPTIONS'] = self.config['CATEGORY_PAGES_DESCRIPTIONS'] + + if 'DISABLE_INDEXES_PLUGIN_INDEX_AND_ATOM_FEED' in self.config: + utils.LOGGER.warning('The DISABLE_INDEXES_PLUGIN_INDEX_AND_ATOM_FEED setting was renamed and split to DISABLE_INDEXES and DISABLE_MAIN_ATOM_FEED.') + self.config['DISABLE_INDEXES'] = self.config['DISABLE_INDEXES_PLUGIN_INDEX_AND_ATOM_FEED'] + self.config['DISABLE_MAIN_ATOM_FEED'] = self.config['DISABLE_INDEXES_PLUGIN_INDEX_AND_ATOM_FEED'] + + if 'DISABLE_INDEXES_PLUGIN_RSS_FEED' in self.config: + utils.LOGGER.warning('The DISABLE_INDEXES_PLUGIN_RSS_FEED setting was renamed to DISABLE_MAIN_RSS_FEED.') + self.config['DISABLE_MAIN_RSS_FEED'] = self.config['DISABLE_INDEXES_PLUGIN_RSS_FEED'] + + for val in self.config['DATE_FORMAT'].values.values(): + if '%' in val: + utils.LOGGER.error('The DATE_FORMAT setting needs to be upgraded.') + utils.LOGGER.warning("Nikola now uses CLDR-style date strings. http://cldr.unicode.org/translation/date-time") + utils.LOGGER.warning("Example: %Y-%m-%d %H:%M ==> yyyy-MM-dd HH:mm") + utils.LOGGER.warning("(note it’s different to what moment.js uses!)") + sys.exit(1) + + # Silently upgrade LOCALES (remove encoding) + locales = LEGAL_VALUES['LOCALES_BASE'] + if 'LOCALES' in self.config: + for k, v in self.config['LOCALES'].items(): + self.config['LOCALES'][k] = v.split('.')[0] + locales.update(self.config['LOCALES']) + self.config['LOCALES'] = locales + + if self.config.get('POSTS_SECTIONS'): + utils.LOGGER.warning("The sections feature has been removed and its functionality has been merged into categories.") + utils.LOGGER.warning("For more information on how to migrate, please read: https://getnikola.com/blog/upgrading-to-nikola-v8.html#sections-were-replaced-by-categories") + + for section_config_suffix, cat_config_suffix in ( + ('DESCRIPTIONS', 'DESCRIPTIONS'), + ('TITLE', 'TITLES'), + ('TRANSLATIONS', 'TRANSLATIONS') + ): + section_config = 'POSTS_SECTION_' + section_config_suffix + cat_config = 'CATEGORY_' + cat_config_suffix + if section_config in self.config: + self.config[section_config].update(self.config[cat_config]) + self.config[cat_config] = self.config[section_config] + + self.config['CATEGORY_DESTPATH_NAMES'] = self.config.get('POSTS_SECTION_NAME', {}) + # Need to mark this translatable manually. + self.config['CATEGORY_DESTPATH_NAMES'] = utils.TranslatableSetting('CATEGORY_DESTPATH_NAMES', self.config['CATEGORY_DESTPATH_NAMES'], self.config['TRANSLATIONS']) + + self.config['CATEGORY_DESTPATH_AS_DEFAULT'] = not self.config.get('POSTS_SECTION_FROM_META') + utils.LOGGER.info("Setting CATEGORY_DESTPATH_AS_DEFAULT = " + str(self.config['CATEGORY_DESTPATH_AS_DEFAULT'])) + + if self.config.get('CATEGORY_PAGES_FOLLOW_DESTPATH') and (not self.config.get('CATEGORY_ALLOW_HIERARCHIES') or self.config.get('CATEGORY_OUTPUT_FLAT_HIERARCHY')): + utils.LOGGER.error('CATEGORY_PAGES_FOLLOW_DESTPATH requires CATEGORY_ALLOW_HIERARCHIES = True, CATEGORY_OUTPUT_FLAT_HIERARCHY = False.') + sys.exit(1) + + # The Utterances comment system has a required configuration value + if self.config.get('COMMENT_SYSTEM') == 'utterances': + utterances_config = self.config.get('GLOBAL_CONTEXT', {}).get('utterances_config', {}) + if not ('issue-term' in utterances_config or 'issue-number' in utterances_config): + utils.LOGGER.error("COMMENT_SYSTEM = 'utterances' must have either GLOBAL_CONTEXT['utterances_config']['issue-term'] or GLOBAL_CONTEXT['utterances_config']['issue-term'] defined.") + + # Handle CONTENT_FOOTER and RSS_COPYRIGHT* properly. + # We provide the arguments to format in CONTENT_FOOTER_FORMATS and RSS_COPYRIGHT_FORMATS. self.config['CONTENT_FOOTER'].langformat(self.config['CONTENT_FOOTER_FORMATS']) + self.config['RSS_COPYRIGHT'].langformat(self.config['RSS_COPYRIGHT_FORMATS']) + self.config['RSS_COPYRIGHT_PLAIN'].langformat(self.config['RSS_COPYRIGHT_FORMATS']) # propagate USE_SLUGIFY utils.USE_SLUGIFY = self.config['USE_SLUGIFY'] # Make sure we have pyphen installed if we are using it if self.config.get('HYPHENATE') and pyphen is None: - utils.LOGGER.warn('To use the hyphenation, you have to install ' - 'the "pyphen" package.') - utils.LOGGER.warn('Setting HYPHENATE to False.') + utils.LOGGER.warning('To use the hyphenation, you have to install ' + 'the "pyphen" package.') + utils.LOGGER.warning('Setting HYPHENATE to False.') self.config['HYPHENATE'] = False # FIXME: Internally, we still use post_pages because it's a pain to change it @@ -555,86 +867,44 @@ class Nikola(object): for i1, i2, i3 in self.config['PAGES']: self.config['post_pages'].append([i1, i2, i3, False]) - # DEFAULT_TRANSLATIONS_PATTERN was changed from "p.e.l" to "p.l.e" - # TODO: remove on v8 - if 'TRANSLATIONS_PATTERN' not in self.config: - if len(self.config.get('TRANSLATIONS', {})) > 1: - utils.LOGGER.warn('You do not have a TRANSLATIONS_PATTERN set in your config, yet you have multiple languages.') - utils.LOGGER.warn('Setting TRANSLATIONS_PATTERN to the pre-v6 default ("{path}.{ext}.{lang}").') - utils.LOGGER.warn('Please add the proper pattern to your conf.py. (The new default in v7 is "{0}".)'.format(DEFAULT_TRANSLATIONS_PATTERN)) - self.config['TRANSLATIONS_PATTERN'] = "{path}.{ext}.{lang}" - else: - # use v7 default there - self.config['TRANSLATIONS_PATTERN'] = DEFAULT_TRANSLATIONS_PATTERN - - # HIDE_SOURCELINK has been replaced with the inverted SHOW_SOURCELINK - # TODO: remove on v8 - if 'HIDE_SOURCELINK' in config: - utils.LOGGER.warn('The HIDE_SOURCELINK option is deprecated, use SHOW_SOURCELINK instead.') - if 'SHOW_SOURCELINK' in config: - utils.LOGGER.warn('HIDE_SOURCELINK conflicts with SHOW_SOURCELINK, ignoring HIDE_SOURCELINK.') - self.config['SHOW_SOURCELINK'] = not config['HIDE_SOURCELINK'] - - # HIDE_UNTRANSLATED_POSTS has been replaced with the inverted SHOW_UNTRANSLATED_POSTS - # TODO: remove on v8 - if 'HIDE_UNTRANSLATED_POSTS' in config: - utils.LOGGER.warn('The HIDE_UNTRANSLATED_POSTS option is deprecated, use SHOW_UNTRANSLATED_POSTS instead.') - if 'SHOW_UNTRANSLATED_POSTS' in config: - utils.LOGGER.warn('HIDE_UNTRANSLATED_POSTS conflicts with SHOW_UNTRANSLATED_POSTS, ignoring HIDE_UNTRANSLATED_POSTS.') - self.config['SHOW_UNTRANSLATED_POSTS'] = not config['HIDE_UNTRANSLATED_POSTS'] - - # READ_MORE_LINK has been split into INDEX_READ_MORE_LINK and RSS_READ_MORE_LINK - # TODO: remove on v8 - if 'READ_MORE_LINK' in config: - utils.LOGGER.warn('The READ_MORE_LINK option is deprecated, use INDEX_READ_MORE_LINK and RSS_READ_MORE_LINK instead.') - if 'INDEX_READ_MORE_LINK' in config: - utils.LOGGER.warn('READ_MORE_LINK conflicts with INDEX_READ_MORE_LINK, ignoring READ_MORE_LINK.') + # Handle old plugin names (from before merging the taxonomy PR #2535) + for old_plugin_name, new_plugin_names in TAXONOMY_COMPATIBILITY_PLUGIN_NAME_MAP.items(): + if old_plugin_name in self.config['DISABLED_PLUGINS']: + missing_plugins = [] + for plugin_name in new_plugin_names: + if plugin_name not in self.config['DISABLED_PLUGINS']: + missing_plugins.append(plugin_name) + if missing_plugins: + utils.LOGGER.warning('The "{}" plugin was replaced by several taxonomy plugins (see PR #2535): {}'.format(old_plugin_name, ', '.join(new_plugin_names))) + utils.LOGGER.warning('You are currently disabling "{}", but not the following new taxonomy plugins: {}'.format(old_plugin_name, ', '.join(missing_plugins))) + utils.LOGGER.warning('Please also disable these new plugins or remove "{}" from the DISABLED_PLUGINS list.'.format(old_plugin_name)) + self.config['DISABLED_PLUGINS'].extend(missing_plugins) + # Special-case logic for "render_indexes" to fix #2591 + if 'render_indexes' in self.config['DISABLED_PLUGINS']: + if 'generate_rss' in self.config['DISABLED_PLUGINS'] or self.config['GENERATE_RSS'] is False: + if 'classify_indexes' not in self.config['DISABLED_PLUGINS']: + utils.LOGGER.warning('You are disabling the "render_indexes" plugin, as well as disabling the "generate_rss" plugin or setting GENERATE_RSS to False. To achieve the same effect, please disable the "classify_indexes" plugin in the future.') + self.config['DISABLED_PLUGINS'].append('classify_indexes') else: - self.config['INDEX_READ_MORE_LINK'] = utils.TranslatableSetting('INDEX_READ_MORE_LINK', config['READ_MORE_LINK'], self.config['TRANSLATIONS']) - - if 'RSS_READ_MORE_LINK' in config: - utils.LOGGER.warn('READ_MORE_LINK conflicts with RSS_READ_MORE_LINK, ignoring READ_MORE_LINK.') - else: - self.config['RSS_READ_MORE_LINK'] = utils.TranslatableSetting('RSS_READ_MORE_LINK', config['READ_MORE_LINK'], self.config['TRANSLATIONS']) - - # Moot.it renamed themselves to muut.io - # TODO: remove on v8? - if self.config.get('COMMENT_SYSTEM') == 'moot': - utils.LOGGER.warn('The moot comment system has been renamed to muut by the upstream. Setting COMMENT_SYSTEM to "muut".') - self.config['COMMENT_SYSTEM'] = 'muut' + if not self.config['DISABLE_INDEXES']: + utils.LOGGER.warning('You are disabling the "render_indexes" plugin, but not the generation of RSS feeds. Please put "DISABLE_INDEXES = True" into your configuration instead.') + self.config['DISABLE_INDEXES'] = True # Disable RSS. For a successful disable, we must have both the option # false and the plugin disabled through the official means. if 'generate_rss' in self.config['DISABLED_PLUGINS'] and self.config['GENERATE_RSS'] is True: + utils.LOGGER.warning('Please use GENERATE_RSS to disable RSS feed generation, instead of mentioning generate_rss in DISABLED_PLUGINS.') self.config['GENERATE_RSS'] = False - - if not self.config['GENERATE_RSS'] and 'generate_rss' not in self.config['DISABLED_PLUGINS']: - self.config['DISABLED_PLUGINS'].append('generate_rss') + self.config['DISABLE_MAIN_RSS_FEED'] = True # PRETTY_URLS defaults to enabling STRIP_INDEXES unless explicitly disabled if self.config.get('PRETTY_URLS') and 'STRIP_INDEXES' not in config: self.config['STRIP_INDEXES'] = True - if 'LISTINGS_FOLDER' in config: - if 'LISTINGS_FOLDERS' not in config: - utils.LOGGER.warn("The LISTINGS_FOLDER option is deprecated, use LISTINGS_FOLDERS instead.") - self.config['LISTINGS_FOLDERS'] = {self.config['LISTINGS_FOLDER']: self.config['LISTINGS_FOLDER']} - utils.LOGGER.warn("LISTINGS_FOLDERS = {0}".format(self.config['LISTINGS_FOLDERS'])) - else: - utils.LOGGER.warn("Both LISTINGS_FOLDER and LISTINGS_FOLDERS are specified, ignoring LISTINGS_FOLDER.") - - if 'GALLERY_PATH' in config: - if 'GALLERY_FOLDERS' not in config: - utils.LOGGER.warn("The GALLERY_PATH option is deprecated, use GALLERY_FOLDERS instead.") - self.config['GALLERY_FOLDERS'] = {self.config['GALLERY_PATH']: self.config['GALLERY_PATH']} - utils.LOGGER.warn("GALLERY_FOLDERS = {0}".format(self.config['GALLERY_FOLDERS'])) - else: - utils.LOGGER.warn("Both GALLERY_PATH and GALLERY_FOLDERS are specified, ignoring GALLERY_PATH.") - if not self.config.get('COPY_SOURCES'): self.config['SHOW_SOURCELINK'] = False - if self.config['CATEGORY_PATH'] is None: + if self.config['CATEGORY_PATH']._inp is None: self.config['CATEGORY_PATH'] = self.config['TAG_PATH'] if self.config['CATEGORY_PAGES_ARE_INDEXES'] is None: self.config['CATEGORY_PAGES_ARE_INDEXES'] = self.config['TAG_PAGES_ARE_INDEXES'] @@ -642,18 +912,14 @@ class Nikola(object): self.default_lang = self.config['DEFAULT_LANG'] self.translations = self.config['TRANSLATIONS'] - locale_fallback, locale_default, locales = sanitized_locales( - self.config.get('LOCALE_FALLBACK', None), - self.config.get('LOCALE_DEFAULT', None), - self.config.get('LOCALES', {}), self.translations) - utils.LocaleBorg.initialize(locales, self.default_lang) + utils.LocaleBorg.initialize(self.config.get('LOCALES', {}), self.default_lang) # BASE_URL defaults to SITE_URL if 'BASE_URL' not in self.config: self.config['BASE_URL'] = self.config.get('SITE_URL') # BASE_URL should *always* end in / if self.config['BASE_URL'] and self.config['BASE_URL'][-1] != '/': - utils.LOGGER.warn("Your BASE_URL doesn't end in / -- adding it, but please fix it in your config file!") + utils.LOGGER.warning("Your BASE_URL doesn't end in / -- adding it, but please fix it in your config file!") self.config['BASE_URL'] += '/' try: @@ -665,26 +931,26 @@ class Nikola(object): utils.LOGGER.error("Punycode of {}: {}".format(_bnl, _bnl.encode('idna'))) sys.exit(1) - # TODO: remove in v8 - if not isinstance(self.config['DEPLOY_COMMANDS'], dict): - utils.LOGGER.warn("A single list as DEPLOY_COMMANDS is deprecated. DEPLOY_COMMANDS should be a dict, with deploy preset names as keys and lists of commands as values.") - utils.LOGGER.warn("The key `default` is used by `nikola deploy`:") - self.config['DEPLOY_COMMANDS'] = {'default': self.config['DEPLOY_COMMANDS']} - utils.LOGGER.warn("DEPLOY_COMMANDS = {0}".format(self.config['DEPLOY_COMMANDS'])) - utils.LOGGER.info("(The above can be used with `nikola deploy` or `nikola deploy default`. Multiple presets are accepted.)") - - # TODO: remove and change default in v8 - if 'BLOG_TITLE' in config and 'WRITE_TAG_CLOUD' not in config: - # BLOG_TITLE is a hack, otherwise the warning would be displayed - # when conf.py does not exist - utils.LOGGER.warn("WRITE_TAG_CLOUD is not set in your config. Defaulting to True (== writing tag_cloud_data.json).") - utils.LOGGER.warn("Please explicitly add the setting to your conf.py with the desired value, as the setting will default to False in the future.") + # Load built-in metadata extractors + metadata_extractors.load_defaults(self, self.metadata_extractors_by) + if metadata_extractors.DEFAULT_EXTRACTOR is None: + utils.LOGGER.error("Could not find default meta extractor ({})".format( + metadata_extractors.DEFAULT_EXTRACTOR_NAME)) + sys.exit(1) + + # The Pelican metadata format requires a markdown extension + if config.get('METADATA_FORMAT', 'nikola').lower() == 'pelican': + if 'markdown.extensions.meta' not in config.get('MARKDOWN_EXTENSIONS', []) \ + and 'markdown' in self.config['COMPILERS']: + utils.LOGGER.warning( + 'To use the Pelican metadata format, you need to add ' + '"markdown.extensions.meta" to your MARKDOWN_EXTENSIONS setting.') # We use one global tzinfo object all over Nikola. try: self.tzinfo = dateutil.tz.gettz(self.config['TIMEZONE']) except Exception as exc: - utils.LOGGER.warn("Error getting TZ: {}", exc) + utils.LOGGER.warning("Error getting TZ: {}", exc) self.tzinfo = dateutil.tz.gettz() self.config['__tzinfo__'] = self.tzinfo @@ -693,31 +959,60 @@ class Nikola(object): for k, v in self.config['COMPILERS'].items(): self.config['_COMPILERS_RAW'][k] = list(v) - compilers = defaultdict(set) - # Also add aliases for combinations with TRANSLATIONS_PATTERN - for compiler, exts in self.config['COMPILERS'].items(): - for ext in exts: - compilers[compiler].add(ext) - for lang in self.config['TRANSLATIONS'].keys(): - candidate = utils.get_translation_candidate(self.config, "f" + ext, lang) - compilers[compiler].add(candidate) - - # Avoid redundant compilers - # Remove compilers that match nothing in POSTS/PAGES - # And put them in "bad compilers" - pp_exts = set([os.path.splitext(x[0])[1] for x in self.config['post_pages']]) - self.config['COMPILERS'] = {} - self.disabled_compilers = {} - self.bad_compilers = set([]) - for k, v in compilers.items(): - if pp_exts.intersection(v): - self.config['COMPILERS'][k] = sorted(list(v)) - else: - self.bad_compilers.add(k) - - self._set_global_context() + # Get search path for themes + self.themes_dirs = ['themes'] + self.config['EXTRA_THEMES_DIRS'] + + # Register default filters + filter_name_format = 'filters.{0}' + for filter_name, filter_definition in filters.__dict__.items(): + # Ignore objects whose name starts with an underscore, or which are not callable + if filter_name.startswith('_') or not callable(filter_definition): + continue + # Register all other objects as filters + self.register_filter(filter_name_format.format(filter_name), filter_definition) + + self._set_global_context_from_config() + self._set_all_page_deps_from_config() + # Read data files only if a site exists (Issue #2708) + if self.configured: + self._set_global_context_from_data() + + # Set persistent state facility + self.state = Persistor('state_data.json') + + # Set cache facility + self.cache = Persistor(os.path.join(self.config['CACHE_FOLDER'], 'cache_data.json')) + + # Create directories for persistors only if a site exists (Issue #2334) + if self.configured: + self.state._set_site(self) + self.cache._set_site(self) + + def _filter_duplicate_plugins(self, plugin_list): + """Find repeated plugins and discard the less local copy.""" + def plugin_position_in_places(plugin): + # plugin here is a tuple: + # (path to the .plugin file, path to plugin module w/o .py, plugin metadata) + for i, place in enumerate(self._plugin_places): + if plugin[0].startswith(place): + return i + utils.LOGGER.warn("Duplicate plugin found in unexpected location: {}".format(plugin[0])) + return len(self._plugin_places) + + plugin_dict = defaultdict(list) + for data in plugin_list: + plugin_dict[data[2].name].append(data) + result = [] + for _, plugins in plugin_dict.items(): + if len(plugins) > 1: + # Sort by locality + plugins.sort(key=plugin_position_in_places) + utils.LOGGER.debug("Plugin {} exists in multiple places, using {}".format( + plugins[-1][2].name, plugins[-1][0])) + result.append(plugins[-1]) + return result - def init_plugins(self, commands_only=False): + def init_plugins(self, commands_only=False, load_all=False): """Load plugins as needed.""" self.plugin_manager = PluginManager(categories_filter={ "Command": Command, @@ -729,56 +1024,118 @@ class Nikola(object): "CompilerExtension": CompilerExtension, "MarkdownExtension": MarkdownExtension, "RestExtension": RestExtension, + "MetadataExtractor": MetadataExtractor, + "ShortcodePlugin": ShortcodePlugin, "SignalHandler": SignalHandler, "ConfigPlugin": ConfigPlugin, "PostScanner": PostScanner, + "Taxonomy": Taxonomy, }) self.plugin_manager.getPluginLocator().setPluginInfoExtension('plugin') extra_plugins_dirs = self.config['EXTRA_PLUGINS_DIRS'] - if sys.version_info[0] == 3: - places = [ - resource_filename('nikola', 'plugins'), - os.path.join(os.getcwd(), 'plugins'), - os.path.expanduser('~/.nikola/plugins'), - ] + [path for path in extra_plugins_dirs if path] - else: - places = [ - resource_filename('nikola', utils.sys_encode('plugins')), - os.path.join(os.getcwd(), utils.sys_encode('plugins')), - os.path.expanduser('~/.nikola/plugins'), - ] + [utils.sys_encode(path) for path in extra_plugins_dirs if path] + self._plugin_places = [ + resource_filename('nikola', 'plugins'), + os.path.expanduser(os.path.join('~', '.nikola', 'plugins')), + os.path.join(os.getcwd(), 'plugins'), + ] + [path for path in extra_plugins_dirs if path] + + compilers = defaultdict(set) + # Also add aliases for combinations with TRANSLATIONS_PATTERN + for compiler, exts in self.config['COMPILERS'].items(): + for ext in exts: + compilers[compiler].add(ext) + for lang in self.config['TRANSLATIONS'].keys(): + candidate = utils.get_translation_candidate(self.config, "f" + ext, lang) + compilers[compiler].add(candidate) - self.plugin_manager.getPluginLocator().setPluginPlaces(places) + # Avoid redundant compilers (if load_all is False): + # Remove compilers (and corresponding compiler extensions) that are not marked as + # needed by any PostScanner plugin and put them into self.disabled_compilers + # (respectively self.disabled_compiler_extensions). + self.config['COMPILERS'] = {} + self.disabled_compilers = {} + self.disabled_compiler_extensions = defaultdict(list) + + self.plugin_manager.getPluginLocator().setPluginPlaces(self._plugin_places) self.plugin_manager.locatePlugins() bad_candidates = set([]) - for p in self.plugin_manager._candidates: - if commands_only: - if p[-1].details.has_option('Nikola', 'plugincategory'): - # FIXME TemplateSystem should not be needed - if p[-1].details.get('Nikola', 'PluginCategory') not in {'Command', 'Template'}: + if not load_all: + for p in self.plugin_manager._candidates: + if commands_only: + if p[-1].details.has_option('Nikola', 'PluginCategory'): + # FIXME TemplateSystem should not be needed + if p[-1].details.get('Nikola', 'PluginCategory') not in {'Command', 'Template'}: + bad_candidates.add(p) + else: + bad_candidates.add(p) + elif self.configured: # Not commands-only, and configured + # Remove blacklisted plugins + if p[-1].name in self.config['DISABLED_PLUGINS']: + bad_candidates.add(p) + utils.LOGGER.debug('Not loading disabled plugin {}', p[-1].name) + # Remove compilers we don't use + if p[-1].details.has_option('Nikola', 'PluginCategory') and p[-1].details.get('Nikola', 'PluginCategory') in ('Compiler', 'PageCompiler'): bad_candidates.add(p) - else: # Not commands-only - # Remove compilers we don't use - if p[-1].name in self.bad_compilers: - bad_candidates.add(p) - self.disabled_compilers[p[-1].name] = p - utils.LOGGER.debug('Not loading unneeded compiler {}', p[-1].name) - if p[-1].name not in self.config['COMPILERS'] and \ - p[-1].details.has_option('Nikola', 'plugincategory') and p[-1].details.get('Nikola', 'PluginCategory') == 'Compiler': - bad_candidates.add(p) - self.disabled_compilers[p[-1].name] = p - utils.LOGGER.debug('Not loading unneeded compiler {}', p[-1].name) - # Remove blacklisted plugins - if p[-1].name in self.config['DISABLED_PLUGINS']: - bad_candidates.add(p) - utils.LOGGER.debug('Not loading disabled plugin {}', p[-1].name) - # Remove compiler extensions we don't need - if p[-1].details.has_option('Nikola', 'compiler') and p[-1].details.get('Nikola', 'compiler') in self.disabled_compilers: - bad_candidates.add(p) - utils.LOGGER.debug('Not loading comopiler extension {}', p[-1].name) - self.plugin_manager._candidates = list(set(self.plugin_manager._candidates) - bad_candidates) + self.disabled_compilers[p[-1].name] = p + # Remove compiler extensions we don't need + if p[-1].details.has_option('Nikola', 'compiler') and p[-1].details.get('Nikola', 'compiler') in self.disabled_compilers: + bad_candidates.add(p) + self.disabled_compiler_extensions[p[-1].details.get('Nikola', 'compiler')].append(p) + self.plugin_manager._candidates = list(set(self.plugin_manager._candidates) - bad_candidates) + + self.plugin_manager._candidates = self._filter_duplicate_plugins(self.plugin_manager._candidates) self.plugin_manager.loadPlugins() + # Search for compiler plugins which we disabled but shouldn't have + self._activate_plugins_of_category("PostScanner") + if not load_all: + file_extensions = set() + for post_scanner in [p.plugin_object for p in self.plugin_manager.getPluginsOfCategory('PostScanner')]: + exts = post_scanner.supported_extensions() + if exts is not None: + file_extensions.update(exts) + else: + # Stop scanning for more: once we get None, we have to load all compilers anyway + utils.LOGGER.debug("Post scanner {0!r} does not implement `supported_extensions`, loading all compilers".format(post_scanner)) + file_extensions = None + break + to_add = [] + for k, v in compilers.items(): + if file_extensions is None or file_extensions.intersection(v): + self.config['COMPILERS'][k] = sorted(list(v)) + p = self.disabled_compilers.pop(k, None) + if p: + to_add.append(p) + for p in self.disabled_compiler_extensions.pop(k, []): + to_add.append(p) + for _, p in self.disabled_compilers.items(): + utils.LOGGER.debug('Not loading unneeded compiler {}', p[-1].name) + for _, plugins in self.disabled_compiler_extensions.items(): + for p in plugins: + utils.LOGGER.debug('Not loading compiler extension {}', p[-1].name) + if to_add: + self.plugin_manager._candidates = self._filter_duplicate_plugins(to_add) + self.plugin_manager.loadPlugins() + + # Jupyter theme configuration. If a website has ipynb enabled in post_pages + # we should enable the Jupyter CSS (leaving that up to the theme itself). + if 'needs_ipython_css' not in self._GLOBAL_CONTEXT: + self._GLOBAL_CONTEXT['needs_ipython_css'] = 'ipynb' in self.config['COMPILERS'] + + # Activate metadata extractors and prepare them for use + for p in self._activate_plugins_of_category("MetadataExtractor"): + metadata_extractors.classify_extractor(p.plugin_object, self.metadata_extractors_by) + + self._activate_plugins_of_category("Taxonomy") + self.taxonomy_plugins = {} + for taxonomy in [p.plugin_object for p in self.plugin_manager.getPluginsOfCategory('Taxonomy')]: + if not taxonomy.is_enabled(): + continue + if taxonomy.classification_name in self.taxonomy_plugins: + utils.LOGGER.error("Found more than one taxonomy with classification name '{}'!".format(taxonomy.classification_name)) + sys.exit(1) + self.taxonomy_plugins[taxonomy.classification_name] = taxonomy + self._activate_plugins_of_category("SignalHandler") # Emit signal for SignalHandlers which need to start running immediately. @@ -791,7 +1148,6 @@ class Nikola(object): plugin_info.plugin_object.short_help = plugin_info.description self._commands[plugin_info.name] = plugin_info.plugin_object - self._activate_plugins_of_category("PostScanner") self._activate_plugins_of_category("Task") self._activate_plugins_of_category("LateTask") self._activate_plugins_of_category("TaskMultiplier") @@ -803,6 +1159,9 @@ class Nikola(object): self.plugin_manager.activatePluginByName(plugin_info.name) plugin_info.plugin_object.set_site(self) + # Activate shortcode plugins + self._activate_plugins_of_category("ShortcodePlugin") + # Load compiler plugins self.compilers = {} self.inverse_compilers = {} @@ -812,12 +1171,36 @@ class Nikola(object): self.compilers[plugin_info.name] = \ plugin_info.plugin_object + # Load config plugins and register templated shortcodes self._activate_plugins_of_category("ConfigPlugin") - + self._register_templated_shortcodes() + + # Check with registered filters and configure filters + for actions in self.config['FILTERS'].values(): + for i, f in enumerate(actions): + if isinstance(f, str): + # Check whether this denotes a registered filter + _f = self.filters.get(f) + if _f is not None: + f = _f + actions[i] = f + if hasattr(f, 'configuration_variables'): + args = {} + for arg, config in f.configuration_variables.items(): + if config in self.config: + args[arg] = self.config[config] + if args: + actions[i] = functools.partial(f, **args) + + # Signal that we are configured signal('configured').send(self) - def _set_global_context(self): - """Create global context from configuration.""" + def _set_global_context_from_config(self): + """Create global context from configuration. + + These are options that are used by templates, so they always need to be + available. + """ self._GLOBAL_CONTEXT['url_type'] = self.config['URL_TYPE'] self._GLOBAL_CONTEXT['timezone'] = self.tzinfo self._GLOBAL_CONTEXT['_link'] = self.link @@ -828,24 +1211,24 @@ class Nikola(object): self._GLOBAL_CONTEXT['rel_link'] = self.rel_link self._GLOBAL_CONTEXT['abs_link'] = self.abs_link self._GLOBAL_CONTEXT['exists'] = self.file_exists - self._GLOBAL_CONTEXT['SLUG_TAG_PATH'] = self.config['SLUG_TAG_PATH'] - self._GLOBAL_CONTEXT['annotations'] = self.config['ANNOTATIONS'] self._GLOBAL_CONTEXT['index_display_post_count'] = self.config[ 'INDEX_DISPLAY_POST_COUNT'] self._GLOBAL_CONTEXT['index_file'] = self.config['INDEX_FILE'] self._GLOBAL_CONTEXT['use_bundles'] = self.config['USE_BUNDLES'] self._GLOBAL_CONTEXT['use_cdn'] = self.config.get("USE_CDN") + self._GLOBAL_CONTEXT['theme_color'] = self.config.get("THEME_COLOR") + self._GLOBAL_CONTEXT['theme_config'] = self.config.get("THEME_CONFIG") self._GLOBAL_CONTEXT['favicons'] = self.config['FAVICONS'] self._GLOBAL_CONTEXT['date_format'] = self.config.get('DATE_FORMAT') self._GLOBAL_CONTEXT['blog_author'] = self.config.get('BLOG_AUTHOR') self._GLOBAL_CONTEXT['blog_title'] = self.config.get('BLOG_TITLE') + self._GLOBAL_CONTEXT['blog_email'] = self.config.get('BLOG_EMAIL') self._GLOBAL_CONTEXT['show_blog_title'] = self.config.get('SHOW_BLOG_TITLE') self._GLOBAL_CONTEXT['logo_url'] = self.config.get('LOGO_URL') self._GLOBAL_CONTEXT['blog_description'] = self.config.get('BLOG_DESCRIPTION') - - # TODO: remove in v8 - self._GLOBAL_CONTEXT['blog_desc'] = self.config.get('BLOG_DESCRIPTION') - + self._GLOBAL_CONTEXT['front_index_header'] = self.config.get('FRONT_INDEX_HEADER') + self._GLOBAL_CONTEXT['color_hsl_adjust_hex'] = utils.color_hsl_adjust_hex + self._GLOBAL_CONTEXT['colorize_str_from_base_color'] = utils.colorize_str_from_base_color self._GLOBAL_CONTEXT['blog_url'] = self.config.get('SITE_URL') self._GLOBAL_CONTEXT['template_hooks'] = self.template_hooks self._GLOBAL_CONTEXT['body_end'] = self.config.get('BODY_END') @@ -858,19 +1241,17 @@ class Nikola(object): self._GLOBAL_CONTEXT['site_has_comments'] = bool(self.config.get('COMMENT_SYSTEM')) self._GLOBAL_CONTEXT['mathjax_config'] = self.config.get( 'MATHJAX_CONFIG') - self._GLOBAL_CONTEXT['subtheme'] = self.config.get('THEME_REVEAL_CONFIG_SUBTHEME') - self._GLOBAL_CONTEXT['transition'] = self.config.get('THEME_REVEAL_CONFIG_TRANSITION') + self._GLOBAL_CONTEXT['use_katex'] = self.config.get('USE_KATEX') + self._GLOBAL_CONTEXT['katex_auto_render'] = self.config.get('KATEX_AUTO_RENDER') self._GLOBAL_CONTEXT['content_footer'] = self.config.get( 'CONTENT_FOOTER') self._GLOBAL_CONTEXT['generate_atom'] = self.config.get('GENERATE_ATOM') self._GLOBAL_CONTEXT['generate_rss'] = self.config.get('GENERATE_RSS') - self._GLOBAL_CONTEXT['rss_path'] = self.config.get('RSS_PATH') self._GLOBAL_CONTEXT['rss_link'] = self.config.get('RSS_LINK') self._GLOBAL_CONTEXT['navigation_links'] = self.config.get('NAVIGATION_LINKS') + self._GLOBAL_CONTEXT['navigation_alt_links'] = self.config.get('NAVIGATION_ALT_LINKS') - self._GLOBAL_CONTEXT['use_open_graph'] = self.config.get( - 'USE_OPEN_GRAPH', True) self._GLOBAL_CONTEXT['twitter_card'] = self.config.get( 'TWITTER_CARD', {}) self._GLOBAL_CONTEXT['hide_sourcelink'] = not self.config.get( @@ -879,20 +1260,55 @@ class Nikola(object): 'SHOW_SOURCELINK') self._GLOBAL_CONTEXT['extra_head_data'] = self.config.get('EXTRA_HEAD_DATA') self._GLOBAL_CONTEXT['date_fanciness'] = self.config.get('DATE_FANCINESS') - self._GLOBAL_CONTEXT['js_date_format'] = json.dumps(self.config.get('JS_DATE_FORMAT')) - self._GLOBAL_CONTEXT['colorbox_locales'] = LEGAL_VALUES['COLORBOX_LOCALES'] + self._GLOBAL_CONTEXT['luxon_locales'] = LEGAL_VALUES['LUXON_LOCALES'] + self._GLOBAL_CONTEXT['luxon_date_format'] = self.config.get('LUXON_DATE_FORMAT') + # TODO: remove in v9 + self._GLOBAL_CONTEXT['js_date_format'] = self.config.get('MOMENTJS_DATE_FORMAT') self._GLOBAL_CONTEXT['momentjs_locales'] = LEGAL_VALUES['MOMENTJS_LOCALES'] + # Patch missing locales into momentjs defaulting to English (Issue #3216) + for l in self._GLOBAL_CONTEXT['translations']: + if l not in self._GLOBAL_CONTEXT['momentjs_locales']: + self._GLOBAL_CONTEXT['momentjs_locales'][l] = "" self._GLOBAL_CONTEXT['hidden_tags'] = self.config.get('HIDDEN_TAGS') self._GLOBAL_CONTEXT['hidden_categories'] = self.config.get('HIDDEN_CATEGORIES') + self._GLOBAL_CONTEXT['hidden_authors'] = self.config.get('HIDDEN_AUTHORS') self._GLOBAL_CONTEXT['url_replacer'] = self.url_replacer - - # IPython theme configuration. If a website has ipynb enabled in post_pages - # we should enable the IPython CSS (leaving that up to the theme itself). - - self._GLOBAL_CONTEXT['needs_ipython_css'] = 'ipynb' in self.config['COMPILERS'] + self._GLOBAL_CONTEXT['sort_posts'] = utils.sort_posts + self._GLOBAL_CONTEXT['smartjoin'] = utils.smartjoin + self._GLOBAL_CONTEXT['colorize_str'] = utils.colorize_str + self._GLOBAL_CONTEXT['meta_generator_tag'] = self.config.get('META_GENERATOR_TAG') + self._GLOBAL_CONTEXT['multiple_authors_per_post'] = self.config.get('MULTIPLE_AUTHORS_PER_POST') self._GLOBAL_CONTEXT.update(self.config.get('GLOBAL_CONTEXT', {})) + def _set_global_context_from_data(self): + """Load files from data/ and put them in the global context.""" + self._GLOBAL_CONTEXT['data'] = {} + for root, dirs, files in os.walk('data', followlinks=True): + for fname in files: + fname = os.path.join(root, fname) + data = utils.load_data(fname) + key = os.path.splitext(fname.split(os.sep, 1)[1])[0] + self._GLOBAL_CONTEXT['data'][key] = data + # Offer global_data as an alias for data (Issue #2488) + self._GLOBAL_CONTEXT['global_data'] = self._GLOBAL_CONTEXT['data'] + + def _set_all_page_deps_from_config(self): + """Save dependencies for all pages from configuration. + + Changes of values in this dict will force a rebuild of all pages. + Unlike global context, contents are NOT available to templates. + """ + self.ALL_PAGE_DEPS['atom_extension'] = self.config.get('ATOM_EXTENSION') + self.ALL_PAGE_DEPS['atom_path'] = self.config.get('ATOM_PATH') + self.ALL_PAGE_DEPS['rss_extension'] = self.config.get('RSS_EXTENSION') + self.ALL_PAGE_DEPS['rss_path'] = self.config.get('RSS_PATH') + self.ALL_PAGE_DEPS['rss_filename_base'] = self.config.get('RSS_FILENAME_BASE') + self.ALL_PAGE_DEPS['atom_filename_base'] = self.config.get('ATOM_FILENAME_BASE') + self.ALL_PAGE_DEPS['slug_author_path'] = self.config.get('SLUG_AUTHOR_PATH') + self.ALL_PAGE_DEPS['slug_tag_path'] = self.config.get('SLUG_TAG_PATH') + self.ALL_PAGE_DEPS['locale'] = self.config.get('LOCALE') + def _activate_plugins_of_category(self, category): """Activate all the plugins of a given category and return them.""" # this code duplicated in tests/base.py @@ -906,17 +1322,20 @@ class Nikola(object): def _get_themes(self): if self._THEMES is None: try: - self._THEMES = utils.get_theme_chain(self.config['THEME']) + self._THEMES = utils.get_theme_chain(self.config['THEME'], self.themes_dirs) except Exception: - utils.LOGGER.warn('''Cannot load theme "{0}", using 'bootstrap3' instead.'''.format(self.config['THEME'])) - self.config['THEME'] = 'bootstrap3' - return self._get_themes() + if self.config['THEME'] != LEGAL_VALUES['DEFAULT_THEME']: + utils.LOGGER.warning('''Cannot load theme "{0}", using '{1}' instead.'''.format( + self.config['THEME'], LEGAL_VALUES['DEFAULT_THEME'])) + self.config['THEME'] = LEGAL_VALUES['DEFAULT_THEME'] + return self._get_themes() + raise # Check consistency of USE_CDN and the current THEME (Issue #386) if self.config['USE_CDN'] and self.config['USE_CDN_WARNING']: bootstrap_path = utils.get_asset_path(os.path.join( 'assets', 'css', 'bootstrap.min.css'), self._THEMES) - if bootstrap_path and bootstrap_path.split(os.sep)[-4] not in ['bootstrap', 'bootstrap3']: - utils.LOGGER.warn('The USE_CDN option may be incompatible with your theme, because it uses a hosted version of bootstrap.') + if bootstrap_path and bootstrap_path.split(os.sep)[-4] not in ['bootstrap', 'bootstrap3', 'bootstrap4']: + utils.LOGGER.warning('The USE_CDN option may be incompatible with your theme, because it uses a hosted version of bootstrap.') return self._THEMES @@ -924,9 +1343,12 @@ class Nikola(object): def _get_messages(self): try: - return utils.load_messages(self.THEMES, - self.translations, - self.default_lang) + if self._MESSAGES is None: + self._MESSAGES = utils.load_messages(self.THEMES, + self.translations, + self.default_lang, + themes_dirs=self.themes_dirs) + return self._MESSAGES except utils.LanguageNotFoundError as e: utils.LOGGER.error('''Cannot load language "{0}". Please make sure it is supported by Nikola itself, or that you have the appropriate messages files in your themes.'''.format(e.lang)) sys.exit(1) @@ -982,7 +1404,7 @@ class Nikola(object): """ ext = os.path.splitext(source_name)[1] try: - compile_html = self.inverse_compilers[ext] + compiler = self.inverse_compilers[ext] except KeyError: # Find the correct compiler for this files extension lang_exts_tab = list(self.config['COMPILERS'].items()) @@ -990,26 +1412,28 @@ class Nikola(object): len([ext_ for ext_ in exts if source_name.endswith(ext_)]) > 0] if len(langs) != 1: if len(set(langs)) > 1: - exit("Your file extension->compiler definition is " - "ambiguous.\nPlease remove one of the file extensions " - "from 'COMPILERS' in conf.py\n(The error is in " - "one of {0})".format(', '.join(langs))) + sys.exit("Your file extension->compiler definition is " + "ambiguous.\nPlease remove one of the file " + "extensions from 'COMPILERS' in conf.py\n(The " + "error is in one of {0})".format(', '.join(langs))) elif len(langs) > 1: langs = langs[:1] else: - exit("COMPILERS in conf.py does not tell me how to " - "handle '{0}' extensions.".format(ext)) + sys.exit("COMPILERS in conf.py does not tell me how to " + "handle '{0}' extensions.".format(ext)) lang = langs[0] try: - compile_html = self.compilers[lang] + compiler = self.compilers[lang] except KeyError: - exit("Cannot find '{0}' compiler; it might require an extra plugin -- do you have it installed?".format(lang)) - self.inverse_compilers[ext] = compile_html + sys.exit("Cannot find '{0}' compiler; " + "it might require an extra plugin -- " + "do you have it installed?".format(lang)) + self.inverse_compilers[ext] = compiler - return compile_html + return compiler - def render_template(self, template_name, output_name, context): + def render_template(self, template_name, output_name, context, url_type=None, is_fragment=False): """Render a template with the global context. If ``output_name`` is None, will return a string and all URL @@ -1017,6 +1441,12 @@ class Nikola(object): If ``output_name`` is a string, URLs will be normalized and the resultant HTML will be saved to the named file (path must start with OUTPUT_FOLDER). + + The argument ``url_type`` allows to override the ``URL_TYPE`` + configuration. + + If ``is_fragment`` is set to ``True``, a HTML fragment will + be rendered and not a whole HTML document. """ local_context = {} local_context["template_name"] = template_name @@ -1025,6 +1455,11 @@ class Nikola(object): for k in self._GLOBAL_CONTEXT_TRANSLATABLE: local_context[k] = local_context[k](local_context['lang']) local_context['is_rtl'] = local_context['lang'] in LEGAL_VALUES['RTL_LANGUAGES'] + local_context['url_type'] = self.config['URL_TYPE'] if url_type is None else url_type + local_context["translations_feedorder"] = sorted( + local_context["translations"], + key=lambda x: (int(x != local_context['lang']), x) + ) # string, arguments local_context["formatmsg"] = lambda s, *a: s % a for h in local_context['template_hooks'].values(): @@ -1039,8 +1474,8 @@ class Nikola(object): if output_name is None: return data - assert output_name.startswith( - self.config["OUTPUT_FOLDER"]) + if not output_name.startswith(self.config["OUTPUT_FOLDER"]): + raise ValueError("Output path for templates must start with OUTPUT_FOLDER") url_part = output_name[len(self.config["OUTPUT_FOLDER"]) + 1:] # Treat our site as if output/ is "/" and then make all URLs relative, @@ -1052,23 +1487,32 @@ class Nikola(object): utils.makedirs(os.path.dirname(output_name)) parser = lxml.html.HTMLParser(remove_blank_text=True) - doc = lxml.html.document_fromstring(data, parser) - self.rewrite_links(doc, src, context['lang']) - data = b'<!DOCTYPE html>\n' + lxml.html.tostring(doc, encoding='utf8', method='html', pretty_print=True) + if is_fragment: + doc = lxml.html.fragment_fromstring(data.strip(), parser) + else: + doc = lxml.html.document_fromstring(data.strip(), parser) + self.rewrite_links(doc, src, context['lang'], url_type) + if is_fragment: + # doc.text contains text before the first HTML, or None if there was no text + # The text after HTML elements is added by tostring() (because its implicit + # argument with_tail has default value True). + data = (doc.text or '').encode('utf-8') + b''.join([lxml.html.tostring(child, encoding='utf-8', method='html') for child in doc.iterchildren()]) + else: + data = lxml.html.tostring(doc, encoding='utf8', method='html', pretty_print=True, doctype='<!DOCTYPE html>') with open(output_name, "wb+") as post_file: post_file.write(data) - def rewrite_links(self, doc, src, lang): + def rewrite_links(self, doc, src, lang, url_type=None): """Replace links in document to point to the right places.""" # First let lxml replace most of them - doc.rewrite_links(lambda dst: self.url_replacer(src, dst, lang), resolve_base_href=False) + doc.rewrite_links(lambda dst: self.url_replacer(src, dst, lang, url_type), resolve_base_href=False) # lxml ignores srcset in img and source elements, so do that by hand - objs = list(doc.findall('*//img')) + list(doc.findall('*//source')) + objs = list(doc.xpath('(//img|//source)')) for obj in objs: if 'srcset' in obj.attrib: urls = [u.strip() for u in obj.attrib['srcset'].split(',')] - urls = [self.url_replacer(src, dst, lang) for dst in urls] + urls = [self.url_replacer(src, dst, lang, url_type) for dst in urls] obj.set('srcset', ', '.join(urls)) def url_replacer(self, src, dst, lang=None, url_type=None): @@ -1085,6 +1529,10 @@ class Nikola(object): lang is used for language-sensitive URLs in link:// url_type is used to determine final link appearance, defaulting to URL_TYPE from config """ + # Avoid mangling links within the page + if dst.startswith('#'): + return dst + parsed_src = urlsplit(src) src_elems = parsed_src.path.split('/')[1:] dst_url = urlparse(dst) @@ -1099,7 +1547,17 @@ class Nikola(object): # Refuse to replace links that are full URLs. if dst_url.netloc: if dst_url.scheme == 'link': # Magic link - dst = self.link(dst_url.netloc, dst_url.path.lstrip('/'), lang) + if dst_url.query: + # If query strings are used in magic link, they will be + # passed to the path handler as keyword arguments (strings) + link_kwargs = {unquote(k): unquote(v[-1]) for k, v in parse_qs(dst_url.query).items()} + else: + link_kwargs = {} + + # unquote from issue #2934 + dst = self.link(dst_url.netloc, unquote(dst_url.path.lstrip('/')), lang, **link_kwargs) + if dst_url.fragment: + dst += '#' + dst_url.fragment # Assuming the site is served over one of these, and # since those are the only URLs we want to rewrite... else: @@ -1112,7 +1570,7 @@ class Nikola(object): # python 3: already unicode pass nl = nl.encode('idna') - if isinstance(nl, utils.bytes_str): + if isinstance(nl, bytes): nl = nl.decode('latin-1') # so idna stays unchanged dst = urlunsplit((dst_url.scheme, nl, @@ -1140,7 +1598,7 @@ class Nikola(object): return dst elif url_type == 'full_path': dst = urljoin(self.config['BASE_URL'], dst.lstrip('/')) - return urlparse(dst).path + return utils.full_path_from_urlparse(urlparse(dst)) else: return "#" @@ -1155,10 +1613,7 @@ class Nikola(object): dst = urljoin(self.config['BASE_URL'], dst.lstrip('/')) if url_type == 'full_path': parsed = urlparse(urljoin(self.config['BASE_URL'], dst.lstrip('/'))) - if parsed.fragment: - dst = '{0}#{1}'.format(parsed.path, parsed.fragment) - else: - dst = parsed.path + dst = utils.full_path_from_urlparse(parsed) return dst # Now both paths are on the same site and absolute @@ -1171,7 +1626,7 @@ class Nikola(object): # Now i is the longest common prefix result = '/'.join(['..'] * (len(src_elems) - i - 1) + dst_elems[i:]) - if not result: + if not result and not parsed_dst.fragment: result = "." # Don't forget the query part of the link @@ -1182,23 +1637,135 @@ class Nikola(object): if parsed_dst.fragment: result += "#" + parsed_dst.fragment - assert result, (src, dst, i, src_elems, dst_elems) + if not result: + raise ValueError("Failed to parse link: {0}".format((src, dst, i, src_elems, dst_elems))) return result - def generic_rss_renderer(self, lang, title, link, description, timeline, output_path, - rss_teasers, rss_plain, feed_length=10, feed_url=None, - enclosure=_enclosure, rss_links_append_query=None): - """Take all necessary data, and render a RSS feed in output_path.""" + def _make_renderfunc(self, t_data, fname=None): + """Return a function that can be registered as a template shortcode. + + The returned function has access to the passed template data and + accepts any number of positional and keyword arguments. Positional + arguments values are added as a tuple under the key ``_args`` to the + keyword argument dict and then the latter provides the template + context. + + Global context keys are made available as part of the context, + respecting locale. + + As a special quirk, the "data" key from global_context is + available only as "global_data" because of name clobbering. + + """ + def render_shortcode(*args, **kw): + context = self.GLOBAL_CONTEXT.copy() + context.update(kw) + context['_args'] = args + context['lang'] = utils.LocaleBorg().current_lang + for k in self._GLOBAL_CONTEXT_TRANSLATABLE: + context[k] = context[k](context['lang']) + output = self.template_system.render_template_to_string(t_data, context) + if fname is not None: + dependencies = [fname] + self.template_system.get_deps(fname) + else: + dependencies = [] + return output, dependencies + return render_shortcode + + def _register_templated_shortcodes(self): + """Register shortcodes based on templates. + + This will register a shortcode for any template found in shortcodes/ + folders and a generic "template" shortcode which will consider the + content in the shortcode as a template in itself. + """ + self.register_shortcode('template', self._template_shortcode_handler) + + builtin_sc_dir = resource_filename( + 'nikola', + os.path.join('data', 'shortcodes', utils.get_template_engine(self.THEMES))) + + for sc_dir in [builtin_sc_dir, 'shortcodes']: + if not os.path.isdir(sc_dir): + continue + + for fname in os.listdir(sc_dir): + name, ext = os.path.splitext(fname) + + if ext != '.tmpl': + continue + with open(os.path.join(sc_dir, fname)) as fd: + self.register_shortcode(name, self._make_renderfunc( + fd.read(), os.path.join(sc_dir, fname))) + + def _template_shortcode_handler(self, *args, **kw): + t_data = kw.pop('data', '') + context = self.GLOBAL_CONTEXT.copy() + context.update(kw) + context['_args'] = args + context['lang'] = utils.LocaleBorg().current_lang + for k in self._GLOBAL_CONTEXT_TRANSLATABLE: + context[k] = context[k](context['lang']) + output = self.template_system.render_template_to_string(t_data, context) + dependencies = self.template_system.get_string_deps(t_data) + return output, dependencies + + def register_shortcode(self, name, f): + """Register function f to handle shortcode "name".""" + if name in self.shortcode_registry: + utils.LOGGER.warning('Shortcode name conflict: {}', name) + return + self.shortcode_registry[name] = f + + def apply_shortcodes(self, data, filename=None, lang=None, extra_context=None): + """Apply shortcodes from the registry on data.""" + if extra_context is None: + extra_context = {} + if lang is None: + lang = utils.LocaleBorg().current_lang + return shortcodes.apply_shortcodes(data, self.shortcode_registry, self, filename, lang=lang, extra_context=extra_context) + + def apply_shortcodes_uuid(self, data, _shortcodes, filename=None, lang=None, extra_context=None): + """Apply shortcodes from the registry on data.""" + if lang is None: + lang = utils.LocaleBorg().current_lang + if extra_context is None: + extra_context = {} + deps = [] + for k, v in _shortcodes.items(): + replacement, _deps = shortcodes.apply_shortcodes(v, self.shortcode_registry, self, filename, lang=lang, extra_context=extra_context) + data = data.replace(k, replacement) + deps.extend(_deps) + return data, deps + + def _get_rss_copyright(self, lang, rss_plain): + if rss_plain: + return ( + self.config['RSS_COPYRIGHT_PLAIN'](lang) or + lxml.html.fromstring(self.config['RSS_COPYRIGHT'](lang)).text_content().strip()) + else: + return self.config['RSS_COPYRIGHT'](lang) + + def generic_rss_feed(self, lang, title, link, description, timeline, + rss_teasers, rss_plain, feed_length=10, feed_url=None, + enclosure=_enclosure, rss_links_append_query=None, copyright_=None): + """Generate an ExtendedRSS2 feed object for later use.""" rss_obj = utils.ExtendedRSS2( title=title, - link=link, + link=utils.encodelink(link), description=description, lastBuildDate=datetime.datetime.utcnow(), - generator='https://getnikola.com/', + generator='Nikola (getnikola.com)', language=lang ) + if copyright_ is None: + copyright_ = self._get_rss_copyright(lang, rss_plain) + # Use the configured or specified copyright string if present. + if copyright_: + rss_obj.copyright = copyright_ + if feed_url: absurl = '/' + feed_url[len(self.config['BASE_URL']):] rss_obj.xsl_stylesheet_href = self.url_replacer(absurl, "/assets/xml/rss.xsl") @@ -1207,16 +1774,20 @@ class Nikola(object): feed_append_query = None if rss_links_append_query: + if rss_links_append_query is True: + raise ValueError("RSS_LINKS_APPEND_QUERY (or FEED_LINKS_APPEND_QUERY) cannot be True. Valid values are False or a formattable string.") feed_append_query = rss_links_append_query.format( feedRelUri='/' + feed_url[len(self.config['BASE_URL']):], feedFormat="rss") for post in timeline[:feed_length]: data = post.text(lang, teaser_only=rss_teasers, strip_html=rss_plain, - rss_read_more_link=True, rss_links_append_query=feed_append_query) + feed_read_more_link=True, feed_links_append_query=feed_append_query) if feed_url is not None and data: # Massage the post's HTML (unless plain) if not rss_plain: + if 'previewimage' in post.meta[lang] and post.meta[lang]['previewimage'] not in data: + data = "<figure><img src=\"{}\"></figure> {}".format(post.meta[lang]['previewimage'], data) # FIXME: this is duplicated with code in Post.text() try: doc = lxml.html.document_fromstring(data) @@ -1232,9 +1803,9 @@ class Nikola(object): if str(e) == "Document is empty": data = "" else: # let other errors raise - raise(e) + raise args = { - 'title': post.title(lang), + 'title': post.title(lang) if post.should_show_title() else None, 'link': post.permalink(lang, absolute=True, query=feed_append_query), 'description': data, # PyRSS2Gen's pubDate is GMT time. @@ -1242,7 +1813,7 @@ class Nikola(object): post.date.astimezone(dateutil.tz.tzutc())), 'categories': post._tags.get(lang, []), 'creator': post.author(lang), - 'guid': post.permalink(lang, absolute=True), + 'guid': post.guid(lang), } if post.author(lang): @@ -1260,16 +1831,18 @@ class Nikola(object): rss_obj.items = items rss_obj.self_url = feed_url rss_obj.rss_attrs["xmlns:atom"] = "http://www.w3.org/2005/Atom" + return rss_obj - dst_dir = os.path.dirname(output_path) - utils.makedirs(dst_dir) - with io.open(output_path, "w+", encoding="utf-8") as rss_file: - data = rss_obj.to_xml(encoding='utf-8') - if isinstance(data, utils.bytes_str): - data = data.decode('utf-8') - rss_file.write(data) + def generic_rss_renderer(self, lang, title, link, description, timeline, output_path, + rss_teasers, rss_plain, feed_length=10, feed_url=None, + enclosure=_enclosure, rss_links_append_query=None, copyright_=None): + """Take all necessary data, and render a RSS feed in output_path.""" + rss_obj = self.generic_rss_feed(lang, title, link, description, timeline, + rss_teasers, rss_plain, feed_length=feed_length, feed_url=feed_url, + enclosure=enclosure, rss_links_append_query=rss_links_append_query, copyright_=copyright_) + utils.rss_writer(rss_obj, output_path) - def path(self, kind, name, lang=None, is_link=False): + def path(self, kind, name, lang=None, is_link=False, **kwargs): r"""Build the path to a certain kind of page. These are mostly defined by plugins by registering via the @@ -1290,47 +1863,70 @@ class Nikola(object): * gallery (name is the gallery name) * listing (name is the source code file name) * post_path (name is 1st element in a POSTS/PAGES tuple) - * slug (name is the slug of a post or story) - * filename (name is the source filename of a post/story, in DEFAULT_LANG, relative to conf.py) + * slug (name is the slug of a post or page) + * filename (name is the source filename of a post/page, in DEFAULT_LANG, relative to conf.py) - The returned value is always a path relative to output, like - "categories/whatever.html" + The returned value is either a path relative to output, like "categories/whatever.html", or + an absolute URL ("https://getnikola.com/"), if path handler returns a string. If is_link is True, the path is absolute and uses "/" as separator (ex: "/archive/index.html"). If is_link is False, the path is relative to output and uses the platform's separator. (ex: "archive\index.html") + If the registered path handler returns a string instead of path component list - it's + considered to be an absolute URL and returned as is. + """ if lang is None: lang = utils.LocaleBorg().current_lang try: - path = self.path_handlers[kind](name, lang) - path = [os.path.normpath(p) for p in path if p != '.'] # Fix Issue #1028 - - if is_link: - link = '/' + ('/'.join(path)) - index_len = len(self.config['INDEX_FILE']) - if self.config['STRIP_INDEXES'] and \ - link[-(1 + index_len):] == '/' + self.config['INDEX_FILE']: - return link[:-index_len] - else: - return link - else: - return os.path.join(*path) + path = self.path_handlers[kind](name, lang, **kwargs) except KeyError: - utils.LOGGER.warn("Unknown path request of kind: {0}".format(kind)) + utils.LOGGER.warning("Unknown path request of kind: {0}".format(kind)) return "" + # If path handler returns a string we consider it to be an absolute URL not requiring any + # further processing, i.e 'https://getnikola.com/'. See Issue #2876. + if isinstance(path, str): + return path + + if path is None: + path = "#" + else: + path = [os.path.normpath(p) for p in path if p != '.'] # Fix Issue #1028 + if is_link: + link = '/' + ('/'.join(path)) + index_len = len(self.config['INDEX_FILE']) + if self.config['STRIP_INDEXES'] and \ + link[-(1 + index_len):] == '/' + self.config['INDEX_FILE']: + return link[:-index_len] + else: + return link + else: + return os.path.join(*path) + def post_path(self, name, lang): - """Handle post_path paths.""" + """Link to the destination of an element in the POSTS/PAGES settings. + + Example: + + link://post_path/posts => /blog + """ return [_f for _f in [self.config['TRANSLATIONS'][lang], os.path.dirname(name), self.config['INDEX_FILE']] if _f] def root_path(self, name, lang): - """Handle root_path paths.""" + """Link to the current language's root. + + Example: + + link://root_path => / + + link://root_path => /translations/spanish/ + """ d = self.config['TRANSLATIONS'][lang] if d: return [d, ''] @@ -1338,17 +1934,27 @@ class Nikola(object): return [] def slug_path(self, name, lang): - """Handle slug paths.""" + """Return a link to a post with given slug, if not ambiguous. + + Example: + + link://slug/yellow-camaro => /posts/cars/awful/yellow-camaro/index.html + """ results = [p for p in self.timeline if p.meta('slug') == name] if not results: utils.LOGGER.warning("Cannot resolve path request for slug: {0}".format(name)) else: if len(results) > 1: utils.LOGGER.warning('Ambiguous path request for slug: {0}'.format(name)) - return [_f for _f in results[0].permalink(lang).split('/') if _f] + return [_f for _f in results[0].permalink(lang).split('/')] def filename_path(self, name, lang): - """Handle filename paths.""" + """Link to post or page by source filename. + + Example: + + link://filename/manual.txt => /docs/handbook.html + """ results = [p for p in self.timeline if p.source_path == name] if not results: utils.LOGGER.warning("Cannot resolve path request for filename: {0}".format(name)) @@ -1364,9 +1970,11 @@ class Nikola(object): else: self.path_handlers[kind] = f - def link(self, *args): + def link(self, *args, **kwargs): """Create a link.""" - return self.path(*args, is_link=True) + url = self.path(*args, is_link=True, **kwargs) + url = utils.encodelink(url) + return url def abs_link(self, dst, protocol_relative=False): """Get an absolute link.""" @@ -1378,6 +1986,7 @@ class Nikola(object): url = urlparse(dst).geturl() if protocol_relative: url = url.split(":", 1)[1] + url = utils.encodelink(url) return url def rel_link(self, src, dst): @@ -1392,7 +2001,7 @@ class Nikola(object): parsed_src = urlsplit(src) parsed_dst = urlsplit(dst) if parsed_src[:2] != parsed_dst[:2]: - return dst + return utils.encodelink(dst) # Now both paths are on the same site and absolute src_elems = parsed_src.path.split('/')[1:] dst_elems = parsed_dst.path.split('/')[1:] @@ -1403,7 +2012,20 @@ class Nikola(object): else: i += 1 # Now i is the longest common prefix - return '/'.join(['..'] * (len(src_elems) - i - 1) + dst_elems[i:]) + url = '/'.join(['..'] * (len(src_elems) - i - 1) + dst_elems[i:]) + url = utils.encodelink(url) + return url + + def register_filter(self, filter_name, filter_definition): + """Register a filter. + + filter_name should be a name not confusable with an actual + executable. filter_definition should be a callable accepting + one argument (the filename). + """ + if filter_name in self.filters: + utils.LOGGER.warning('''The filter "{0}" is defined more than once.'''.format(filter_name)) + self.filters[filter_name] = filter_definition def file_exists(self, path, not_empty=False): """Check if the file exists. If not_empty is True, it also must not be empty.""" @@ -1433,7 +2055,8 @@ class Nikola(object): task_dep = [] for pluginInfo in self.plugin_manager.getPluginsOfCategory(plugin_category): for task in flatten(pluginInfo.plugin_object.gen_tasks()): - assert 'basename' in task + if 'basename' not in task: + raise ValueError("Task {0} does not have a basename".format(task)) task = self.clean_task_paths(task) if 'task_dep' not in task: task['task_dep'] = [] @@ -1460,7 +2083,7 @@ class Nikola(object): """Parse a category name into a hierarchy.""" if self.config['CATEGORY_ALLOW_HIERARCHIES']: try: - return utils.parse_escaped_hierarchical_category_name(category_name) + return hierarchy_utils.parse_escaped_hierarchical_category_name(category_name) except Exception as e: utils.LOGGER.error(str(e)) sys.exit(1) @@ -1470,7 +2093,7 @@ class Nikola(object): def category_path_to_category_name(self, category_path): """Translate a category path to a category name.""" if self.config['CATEGORY_ALLOW_HIERARCHIES']: - return utils.join_hierarchical_category_path(category_path) + return hierarchy_utils.join_hierarchical_category_path(category_path) else: return ''.join(category_path) @@ -1495,7 +2118,7 @@ class Nikola(object): """Create category hierarchy.""" result = [] for name, children in cat_hierarchy.items(): - node = utils.TreeNode(name, parent) + node = hierarchy_utils.TreeNode(name, parent) node.children = create_hierarchy(children, node) node.category_path = [pn.name for pn in node.get_path()] node.category_name = self.category_path_to_category_name(node.category_path) @@ -1506,7 +2129,25 @@ class Nikola(object): root_list = create_hierarchy(self.category_hierarchy) # Next, flatten the hierarchy - self.category_hierarchy = utils.flatten_tree_structure(root_list) + self.category_hierarchy = hierarchy_utils.flatten_tree_structure(root_list) + + @staticmethod + def sort_posts_chronologically(posts, lang=None): + """Sort a list of posts chronologically. + + This function also takes priority, title and source path into account. + """ + # Last tie breaker: sort by source path (A-Z) + posts = sorted(posts, key=lambda p: p.source_path) + # Next tie breaker: sort by title if language is given (A-Z) + if lang is not None: + posts = natsort.natsorted(posts, key=lambda p: p.title(lang), alg=natsort.ns.F | natsort.ns.IC) + # Next tie breaker: sort by date (reverse chronological order) + posts = sorted(posts, key=lambda p: p.date, reverse=True) + # Finally, sort by priority meta value (descending) + posts = sorted(posts, key=lambda p: int(p.meta('priority')) if p.meta('priority') else 0, reverse=True) + # Return result + return posts def scan_posts(self, really=False, ignore_quit=False, quiet=False): """Scan all the posts. @@ -1526,37 +2167,34 @@ class Nikola(object): self.tags_per_language = defaultdict(list) self.category_hierarchy = {} self.post_per_file = {} + self.post_per_input_file = {} self.timeline = [] self.pages = [] - for p in self.plugin_manager.getPluginsOfCategory('PostScanner'): - timeline = p.plugin_object.scan() + for p in sorted(self.plugin_manager.getPluginsOfCategory('PostScanner'), key=operator.attrgetter('name')): + try: + timeline = p.plugin_object.scan() + except Exception: + utils.LOGGER.error('Error reading timeline') + raise # FIXME: can there be conflicts here? self.timeline.extend(timeline) quit = False # Classify posts per year/tag/month/whatever - slugged_tags = set([]) + slugged_tags = defaultdict(set) for post in self.timeline: if post.use_in_feeds: self.posts.append(post) self.posts_per_year[str(post.date.year)].append(post) self.posts_per_month[ '{0}/{1:02d}'.format(post.date.year, post.date.month)].append(post) - for tag in post.alltags: - _tag_slugified = utils.slugify(tag) - if _tag_slugified in slugged_tags: - if tag not in self.posts_per_tag: - # Tags that differ only in case - other_tag = [existing for existing in self.posts_per_tag.keys() if utils.slugify(existing) == _tag_slugified][0] - utils.LOGGER.error('You have tags that are too similar: {0} and {1}'.format(tag, other_tag)) - utils.LOGGER.error('Tag {0} is used in: {1}'.format(tag, post.source_path)) - utils.LOGGER.error('Tag {0} is used in: {1}'.format(other_tag, ', '.join([p.source_path for p in self.posts_per_tag[other_tag]]))) - quit = True - else: - slugged_tags.add(utils.slugify(tag, force=True)) - self.posts_per_tag[tag].append(post) for lang in self.config['TRANSLATIONS'].keys(): + for tag in post.tags_for_language(lang): + _tag_slugified = utils.slugify(tag, lang) + slugged_tags[lang].add(_tag_slugified) + if post not in self.posts_per_tag[tag]: + self.posts_per_tag[tag].append(post) self.tags_per_language[lang].extend(post.tags_for_language(lang)) self._add_post_to_category(post, post.meta('category')) @@ -1569,6 +2207,7 @@ class Nikola(object): for lang in self.config['TRANSLATIONS'].keys(): dest = post.destination_path(lang=lang) src_dest = post.destination_path(lang=lang, extension=post.source_ext()) + src_file = post.translated_source_path(lang=lang) if dest in self.post_per_file: utils.LOGGER.error('Two posts are trying to generate {0}: {1} and {2}'.format( dest, @@ -1583,14 +2222,17 @@ class Nikola(object): quit = True self.post_per_file[dest] = post self.post_per_file[src_dest] = post + if src_file is not None: + self.post_per_input_file[src_file] = post # deduplicate tags_per_language self.tags_per_language[lang] = list(set(self.tags_per_language[lang])) # Sort everything. - for thing in self.timeline, self.posts, self.all_posts, self.pages: - thing.sort(key=lambda p: (p.date, p.source_path)) - thing.reverse() + self.timeline = self.sort_posts_chronologically(self.timeline) + self.posts = self.sort_posts_chronologically(self.posts) + self.all_posts = self.sort_posts_chronologically(self.all_posts) + self.pages = self.sort_posts_chronologically(self.pages) self._sort_category_hierarchy() for i, p in enumerate(self.posts[1:]): @@ -1604,102 +2246,149 @@ class Nikola(object): sys.exit(1) signal('scanned').send(self) + def generic_renderer(self, lang, output_name, template_name, filters, file_deps=None, uptodate_deps=None, context=None, context_deps_remove=None, post_deps_dict=None, url_type=None, is_fragment=False): + """Create tasks for rendering pages and post lists and other related pages. + + lang is the current language. + output_name is the destination file name. + template_name is the template to be used. + filters is the list of filters (usually site.config['FILTERS']) which will be used to post-process the result. + file_deps (optional) is a list of additional file dependencies (next to template and its dependencies). + uptodate_deps (optional) is a list of additional entries added to the task's uptodate list. + context (optional) a dict used as a basis for the template context. The lang parameter will always be added. + context_deps_remove (optional) is a list of keys to remove from the context after using it as an uptodate dependency. This should name all keys containing non-trivial Python objects; they can be replaced by adding JSON-style dicts in post_deps_dict. + post_deps_dict (optional) is a dict merged into the copy of context which is used as an uptodate dependency. + url_type (optional) allows to override the ``URL_TYPE`` configuration. + is_fragment (optional) allows to write a HTML fragment instead of a HTML document. + """ + utils.LocaleBorg().set_locale(lang) + + file_deps = copy(file_deps) if file_deps else [] + file_deps += self.template_system.template_deps(template_name) + file_deps = sorted(list(filter(None, file_deps))) + + context = copy(context) if context else {} + context["lang"] = lang + + deps_dict = copy(context) + if context_deps_remove: + for key in context_deps_remove: + deps_dict.pop(key) + deps_dict['OUTPUT_FOLDER'] = self.config['OUTPUT_FOLDER'] + deps_dict['TRANSLATIONS'] = self.config['TRANSLATIONS'] + deps_dict['global'] = self.GLOBAL_CONTEXT + deps_dict['all_page_deps'] = self.ALL_PAGE_DEPS + if post_deps_dict: + deps_dict.update(post_deps_dict) + + for k, v in self.GLOBAL_CONTEXT['template_hooks'].items(): + deps_dict['||template_hooks|{0}||'.format(k)] = v.calculate_deps() + + for k in self._GLOBAL_CONTEXT_TRANSLATABLE: + deps_dict[k] = deps_dict['global'][k](lang) + for k in self._ALL_PAGE_DEPS_TRANSLATABLE: + deps_dict[k] = deps_dict['all_page_deps'][k](lang) + + deps_dict['navigation_links'] = deps_dict['global']['navigation_links'](lang) + deps_dict['navigation_alt_links'] = deps_dict['global']['navigation_alt_links'](lang) + + task = { + 'name': os.path.normpath(output_name), + 'targets': [output_name], + 'file_dep': file_deps, + 'actions': [(self.render_template, [template_name, output_name, + context, url_type, is_fragment])], + 'clean': True, + 'uptodate': [config_changed(deps_dict, 'nikola.nikola.Nikola.generic_renderer')] + ([] if uptodate_deps is None else uptodate_deps) + } + + return utils.apply_filters(task, filters) + def generic_page_renderer(self, lang, post, filters, context=None): """Render post fragments to final HTML pages.""" - context = context.copy() if context else {} - deps = post.deps(lang) + \ - self.template_system.template_deps(post.template_name) + extension = post.compiler.extension() + output_name = os.path.join(self.config['OUTPUT_FOLDER'], + post.destination_path(lang, extension)) + + deps = post.deps(lang) + uptodate_deps = post.deps_uptodate(lang) deps.extend(utils.get_asset_path(x, self.THEMES) for x in ('bundles', 'parent', 'engine')) - deps = list(filter(None, deps)) + _theme_ini = utils.get_asset_path(self.config['THEME'] + '.theme', self.THEMES) + if _theme_ini: + deps.append(_theme_ini) + + context = copy(context) if context else {} context['post'] = post - context['lang'] = lang context['title'] = post.title(lang) context['description'] = post.description(lang) context['permalink'] = post.permalink(lang) + if 'crumbs' not in context: + crumb_path = post.permalink(lang).lstrip('/') + if crumb_path.endswith(self.config['INDEX_FILE']): + crumb_path = crumb_path[:-len(self.config['INDEX_FILE'])] + if crumb_path.endswith('/'): + context['crumbs'] = utils.get_crumbs(crumb_path.rstrip('/'), is_file=False) + else: + context['crumbs'] = utils.get_crumbs(crumb_path, is_file=True) if 'pagekind' not in context: context['pagekind'] = ['generic_page'] if post.use_in_feeds: context['enable_comments'] = True else: - context['enable_comments'] = self.config['COMMENTS_IN_STORIES'] - extension = self.get_compiler(post.source_path).extension() - output_name = os.path.join(self.config['OUTPUT_FOLDER'], - post.destination_path(lang, extension)) - deps_dict = copy(context) - deps_dict.pop('post') + context['enable_comments'] = self.config['COMMENTS_IN_PAGES'] + + deps_dict = {} if post.prev_post: deps_dict['PREV_LINK'] = [post.prev_post.permalink(lang)] if post.next_post: deps_dict['NEXT_LINK'] = [post.next_post.permalink(lang)] - deps_dict['OUTPUT_FOLDER'] = self.config['OUTPUT_FOLDER'] - deps_dict['TRANSLATIONS'] = self.config['TRANSLATIONS'] - deps_dict['global'] = self.GLOBAL_CONTEXT deps_dict['comments'] = context['enable_comments'] - - for k, v in self.GLOBAL_CONTEXT['template_hooks'].items(): - deps_dict['||template_hooks|{0}||'.format(k)] = v._items - - for k in self._GLOBAL_CONTEXT_TRANSLATABLE: - deps_dict[k] = deps_dict['global'][k](lang) - - deps_dict['navigation_links'] = deps_dict['global']['navigation_links'](lang) - if post: deps_dict['post_translations'] = post.translated_to - task = { - 'name': os.path.normpath(output_name), - 'file_dep': sorted(deps), - 'targets': [output_name], - 'actions': [(self.render_template, [post.template_name, - output_name, context])], - 'clean': True, - 'uptodate': [config_changed(deps_dict, 'nikola.nikola.Nikola.generic_page_renderer')] + post.deps_uptodate(lang), - } + signal('render_post').send({ + 'site': self, + 'post': post, + 'lang': lang, + 'context': context, + 'deps_dict': deps_dict, + }) - yield utils.apply_filters(task, filters) + yield self.generic_renderer(lang, output_name, post.template_name, filters, + file_deps=deps, + uptodate_deps=uptodate_deps, + context=context, + context_deps_remove=['post'], + post_deps_dict=deps_dict, + url_type=post.url_type) - def generic_post_list_renderer(self, lang, posts, output_name, - template_name, filters, extra_context): + def generic_post_list_renderer(self, lang, posts, output_name, template_name, filters, extra_context): """Render pages with lists of posts.""" deps = [] - deps += self.template_system.template_deps(template_name) uptodate_deps = [] for post in posts: deps += post.deps(lang) uptodate_deps += post.deps_uptodate(lang) + context = {} context["posts"] = posts context["title"] = self.config['BLOG_TITLE'](lang) context["description"] = self.config['BLOG_DESCRIPTION'](lang) - context["lang"] = lang context["prevlink"] = None context["nextlink"] = None - context.update(extra_context) - deps_context = copy(context) - deps_context["posts"] = [(p.meta[lang]['title'], p.permalink(lang)) for p in - posts] - deps_context["global"] = self.GLOBAL_CONTEXT + if extra_context: + context.update(extra_context) + if 'has_other_languages' not in context: + context['has_other_languages'] = False - for k, v in self.GLOBAL_CONTEXT['template_hooks'].items(): - deps_context['||template_hooks|{0}||'.format(k)] = v._items - - for k in self._GLOBAL_CONTEXT_TRANSLATABLE: - deps_context[k] = deps_context['global'][k](lang) + post_deps_dict = {} + post_deps_dict["posts"] = [(p.meta[lang]['title'], p.permalink(lang)) for p in posts] - deps_context['navigation_links'] = deps_context['global']['navigation_links'](lang) - - task = { - 'name': os.path.normpath(output_name), - 'targets': [output_name], - 'file_dep': sorted(deps), - 'actions': [(self.render_template, [template_name, output_name, - context])], - 'clean': True, - 'uptodate': [config_changed(deps_context, 'nikola.nikola.Nikola.generic_post_list_renderer')] + uptodate_deps - } - - return utils.apply_filters(task, filters) + return self.generic_renderer(lang, output_name, template_name, filters, + file_deps=deps, + uptodate_deps=uptodate_deps, + context=context, + post_deps_dict=post_deps_dict) def atom_feed_renderer(self, lang, posts, output_path, filters, extra_context): @@ -1711,43 +2400,42 @@ class Nikola(object): link = lxml.etree.Element("link") link.set("rel", link_rel) link.set("type", link_type) - link.set("href", link_href) + link.set("href", utils.encodelink(link_href)) return link + utils.LocaleBorg().set_locale(lang) deps = [] uptodate_deps = [] for post in posts: deps += post.deps(lang) uptodate_deps += post.deps_uptodate(lang) + context = {} + blog_title = self.config['BLOG_TITLE'](lang) context["posts"] = posts - context["title"] = self.config['BLOG_TITLE'](lang) + context["title"] = blog_title context["description"] = self.config['BLOG_DESCRIPTION'](lang) context["lang"] = lang - context["prevlink"] = None - context["nextlink"] = None - context["is_feed_stale"] = None context.update(extra_context) + + context["title"] = "{0} ({1})".format(blog_title, context["title"]) if blog_title != context["title"] else blog_title + deps_context = copy(context) deps_context["posts"] = [(p.meta[lang]['title'], p.permalink(lang)) for p in posts] deps_context["global"] = self.GLOBAL_CONTEXT + deps_context["all_page_deps"] = self.ALL_PAGE_DEPS for k in self._GLOBAL_CONTEXT_TRANSLATABLE: deps_context[k] = deps_context['global'][k](lang) + for k in self._ALL_PAGE_DEPS_TRANSLATABLE: + deps_context[k] = deps_context['all_page_deps'][k](lang) - deps_context['navigation_links'] = deps_context['global']['navigation_links'](lang) - - nslist = {} - if context["is_feed_stale"] or (not context["feedpagenum"] == context["feedpagecount"] - 1 and not context["feedpagenum"] == 0): - nslist["fh"] = "http://purl.org/syndication/history/1.0" - if not self.config["RSS_TEASERS"]: - nslist["xh"] = "http://www.w3.org/1999/xhtml" feed_xsl_link = self.abs_link("/assets/xml/atom.xsl") - feed_root = lxml.etree.Element("feed", nsmap=nslist) + feed_root = lxml.etree.Element("feed") feed_root.addprevious(lxml.etree.ProcessingInstruction( "xml-stylesheet", - 'href="' + feed_xsl_link + '" type="text/xsl media="all"')) + 'href="' + utils.encodelink(feed_xsl_link) + '" type="text/xsl media="all"')) feed_root.set("{http://www.w3.org/XML/1998/namespace}lang", lang) feed_root.set("xmlns", "http://www.w3.org/2005/Atom") feed_title = lxml.etree.SubElement(feed_root, "title") @@ -1755,63 +2443,58 @@ class Nikola(object): feed_id = lxml.etree.SubElement(feed_root, "id") feed_id.text = self.abs_link(context["feedlink"]) feed_updated = lxml.etree.SubElement(feed_root, "updated") - feed_updated.text = post.formatted_date('webiso', datetime.datetime.now(tz=dateutil.tz.tzutc())) + feed_updated.text = utils.LocaleBorg().formatted_date('webiso', datetime.datetime.now(tz=dateutil.tz.tzutc())) feed_author = lxml.etree.SubElement(feed_root, "author") feed_author_name = lxml.etree.SubElement(feed_author, "name") feed_author_name.text = self.config["BLOG_AUTHOR"](lang) feed_root.append(atom_link("self", "application/atom+xml", self.abs_link(context["feedlink"]))) - # Older is "next" and newer is "previous" in paginated feeds (opposite of archived) - if "nextfeedlink" in context: - feed_root.append(atom_link("next", "application/atom+xml", - self.abs_link(context["nextfeedlink"]))) - if "prevfeedlink" in context: - feed_root.append(atom_link("previous", "application/atom+xml", - self.abs_link(context["prevfeedlink"]))) - if context["is_feed_stale"] or not context["feedpagenum"] == 0: - feed_root.append(atom_link("current", "application/atom+xml", - self.abs_link(context["currentfeedlink"]))) - # Older is "prev-archive" and newer is "next-archive" in archived feeds (opposite of paginated) - if "prevfeedlink" in context and (context["is_feed_stale"] or not context["feedpagenum"] == context["feedpagecount"] - 1): - feed_root.append(atom_link("next-archive", "application/atom+xml", - self.abs_link(context["prevfeedlink"]))) - if "nextfeedlink" in context: - feed_root.append(atom_link("prev-archive", "application/atom+xml", - self.abs_link(context["nextfeedlink"]))) - if context["is_feed_stale"] or not context["feedpagenum"] == context["feedpagecount"] - 1: - lxml.etree.SubElement(feed_root, "{http://purl.org/syndication/history/1.0}archive") feed_root.append(atom_link("alternate", "text/html", self.abs_link(context["permalink"]))) feed_generator = lxml.etree.SubElement(feed_root, "generator") - feed_generator.set("uri", "http://getnikola.com/") + feed_generator.set("uri", "https://getnikola.com/") feed_generator.text = "Nikola" feed_append_query = None - if self.config["RSS_LINKS_APPEND_QUERY"]: - feed_append_query = self.config["RSS_LINKS_APPEND_QUERY"].format( + if self.config["FEED_LINKS_APPEND_QUERY"]: + feed_append_query = self.config["FEED_LINKS_APPEND_QUERY"].format( feedRelUri=context["feedlink"], feedFormat="atom") - for post in posts: - data = post.text(lang, teaser_only=self.config["RSS_TEASERS"], strip_html=self.config["RSS_TEASERS"], - rss_read_more_link=True, rss_links_append_query=feed_append_query) - if not self.config["RSS_TEASERS"]: + def atom_post_text(post, text): + if not self.config["FEED_PLAIN"]: + if 'previewimage' in post.meta[lang] and post.meta[lang]['previewimage'] not in text: + text = "<figure><img src=\"{}\"></figure> {}".format(post.meta[lang]['previewimage'], text) + # FIXME: this is duplicated with code in Post.text() and generic_rss_renderer try: - doc = lxml.html.document_fromstring(data) - doc.rewrite_links(lambda dst: self.url_replacer(post.permalink(), dst, lang, 'absolute')) + doc = lxml.html.document_fromstring(text) + doc.rewrite_links(lambda dst: self.url_replacer(post.permalink(lang), dst, lang, 'absolute')) try: body = doc.body - data = (body.text or '') + ''.join( + text = (body.text or '') + ''.join( [lxml.html.tostring(child, encoding='unicode') for child in body.iterchildren()]) except IndexError: # No body there, it happens sometimes - data = '' + text = '' except lxml.etree.ParserError as e: if str(e) == "Document is empty": - data = "" + text = "" else: # let other errors raise - raise(e) + raise + return text.strip() + + for post in posts: + summary = atom_post_text(post, post.text(lang, teaser_only=True, + strip_html=self.config["FEED_PLAIN"], + feed_read_more_link=True, + feed_links_append_query=feed_append_query)) + content = None + if not self.config["FEED_TEASERS"]: + content = atom_post_text(post, post.text(lang, teaser_only=self.config["FEED_TEASERS"], + strip_html=self.config["FEED_PLAIN"], + feed_read_more_link=True, + feed_links_append_query=feed_append_query)) entry_root = lxml.etree.SubElement(feed_root, "entry") entry_title = lxml.etree.SubElement(entry_root, "title") @@ -1826,30 +2509,35 @@ class Nikola(object): entry_author_name = lxml.etree.SubElement(entry_author, "name") entry_author_name.text = post.author(lang) entry_root.append(atom_link("alternate", "text/html", - post.permalink(lang, absolute=True, - query=feed_append_query))) - if self.config["RSS_TEASERS"]: - entry_summary = lxml.etree.SubElement(entry_root, "summary") - entry_summary.text = data + post.permalink(lang, absolute=True, + query=feed_append_query))) + entry_summary = lxml.etree.SubElement(entry_root, "summary") + if not self.config["FEED_PLAIN"]: + entry_summary.set("type", "html") else: + entry_summary.set("type", "text") + entry_summary.text = summary + if content: entry_content = lxml.etree.SubElement(entry_root, "content") - entry_content.set("type", "xhtml") - entry_content_nsdiv = lxml.etree.SubElement(entry_content, "{http://www.w3.org/1999/xhtml}div") - entry_content_nsdiv.text = data - for category in post.tags: + if not self.config["FEED_PLAIN"]: + entry_content.set("type", "html") + else: + entry_content.set("type", "text") + entry_content.text = content + for category in post.tags_for_language(lang): entry_category = lxml.etree.SubElement(entry_root, "category") - entry_category.set("term", utils.slugify(category)) + entry_category.set("term", utils.slugify(category, lang)) entry_category.set("label", category) dst_dir = os.path.dirname(output_path) utils.makedirs(dst_dir) with io.open(output_path, "w+", encoding="utf-8") as atom_file: data = lxml.etree.tostring(feed_root.getroottree(), encoding="UTF-8", pretty_print=True, xml_declaration=True) - if isinstance(data, utils.bytes_str): + if isinstance(data, bytes): data = data.decode('utf-8') atom_file.write(data) - def generic_index_renderer(self, lang, posts, indexes_title, template_name, context_source, kw, basename, page_link, page_path, additional_dependencies=[]): + def generic_index_renderer(self, lang, posts, indexes_title, template_name, context_source, kw, basename, page_link, page_path, additional_dependencies=None): """Create an index page. lang: The language @@ -1874,6 +2562,9 @@ class Nikola(object): as the ones for page_link. additional_dependencies: a list of dependencies which will be added to task['uptodate'] + + Note: if context['featured'] is present, it must be a list of posts, + whose dependencies will be taken added to task['uptodate']. """ # Update kw kw = kw.copy() @@ -1883,12 +2574,11 @@ class Nikola(object): kw["indexes_pages"] = self.config['INDEXES_PAGES'](lang) kw["indexes_pages_main"] = self.config['INDEXES_PAGES_MAIN'] kw["indexes_static"] = self.config['INDEXES_STATIC'] - kw['indexes_prety_page_url'] = self.config["INDEXES_PRETTY_PAGE_URL"] - kw['demote_headers'] = self.config['DEMOTE_HEADERS'] - kw['generate_atom'] = self.config["GENERATE_ATOM"] - kw['feed_link_append_query'] = self.config["RSS_LINKS_APPEND_QUERY"] - kw['feed_teasers'] = self.config["RSS_TEASERS"] - kw['currentfeed'] = None + kw['indexes_pretty_page_url'] = self.config["INDEXES_PRETTY_PAGE_URL"] + kw['show_index_page_navigation'] = self.config['SHOW_INDEX_PAGE_NAVIGATION'] + + if additional_dependencies is None: + additional_dependencies = [] # Split in smaller lists lists = [] @@ -1902,12 +2592,29 @@ class Nikola(object): while posts: lists.append(posts[:kw["index_display_post_count"]]) posts = posts[kw["index_display_post_count"]:] + if not lists: + lists.append([]) num_pages = len(lists) + displayed_page_numbers = [utils.get_displayed_page_number(i, num_pages, self) for i in range(num_pages)] + page_links = [page_link(i, page_number, num_pages, False) for i, page_number in enumerate(displayed_page_numbers)] + if kw['show_index_page_navigation']: + # Since the list displayed_page_numbers is not necessarily + # sorted -- in case INDEXES_STATIC is True, it is of the + # form [num_pages, 1, 2, ..., num_pages - 1] -- we order it + # via a map. This allows to not replicate the logic of + # utils.get_displayed_page_number() here. + if not kw["indexes_pages_main"] and not kw["indexes_static"]: + temp_map = {page_number: link for page_number, link in zip(displayed_page_numbers, page_links)} + else: + temp_map = {page_number - 1: link for page_number, link in zip(displayed_page_numbers, page_links)} + page_links_context = [temp_map[i] for i in range(num_pages)] for i, post_list in enumerate(lists): context = context_source.copy() if 'pagekind' not in context: context['pagekind'] = ['index'] - ipages_i = utils.get_displayed_page_number(i, num_pages, self) + if 'has_other_languages' not in context: + context['has_other_languages'] = False + ipages_i = displayed_page_numbers[i] if kw["indexes_pages"]: indexes_pages = kw["indexes_pages"] % ipages_i else: @@ -1943,20 +2650,29 @@ class Nikola(object): if i < num_pages - 1: nextlink = i + 1 if prevlink is not None: - context["prevlink"] = page_link(prevlink, - utils.get_displayed_page_number(prevlink, num_pages, self), - num_pages, False) - context["prevfeedlink"] = page_link(prevlink, - utils.get_displayed_page_number(prevlink, num_pages, self), + context["prevlink"] = page_links[prevlink] + context["prevfeedlink"] = page_link(prevlink, displayed_page_numbers[prevlink], num_pages, False, extension=".atom") if nextlink is not None: - context["nextlink"] = page_link(nextlink, - utils.get_displayed_page_number(nextlink, num_pages, self), - num_pages, False) - context["nextfeedlink"] = page_link(nextlink, - utils.get_displayed_page_number(nextlink, num_pages, self), + context["nextlink"] = page_links[nextlink] + context["nextfeedlink"] = page_link(nextlink, displayed_page_numbers[nextlink], num_pages, False, extension=".atom") - context["permalink"] = page_link(i, ipages_i, num_pages, False) + context['show_index_page_navigation'] = kw['show_index_page_navigation'] + if kw['show_index_page_navigation']: + context['page_links'] = page_links_context + if not kw["indexes_pages_main"] and not kw["indexes_static"]: + context['current_page'] = ipages_i + else: + context['current_page'] = ipages_i - 1 + context['prev_next_links_reversed'] = kw['indexes_static'] + context["permalink"] = page_links[i] + context["is_frontmost_index"] = i == 0 + + # Add dependencies to featured posts + if 'featured' in context: + for post in context['featured']: + additional_dependencies += post.deps_uptodate(lang) + output_name = os.path.join(kw['output_folder'], page_path(i, ipages_i, num_pages, False)) task = self.generic_post_list_renderer( lang, @@ -1970,34 +2686,10 @@ class Nikola(object): task['basename'] = basename yield task - if kw['generate_atom']: - atom_output_name = os.path.join(kw['output_folder'], page_path(i, ipages_i, num_pages, False, extension=".atom")) - context["feedlink"] = page_link(i, ipages_i, num_pages, False, extension=".atom") - if not kw["currentfeed"]: - kw["currentfeed"] = context["feedlink"] - context["currentfeedlink"] = kw["currentfeed"] - context["feedpagenum"] = i - context["feedpagecount"] = num_pages - atom_task = { - "basename": basename, - "name": atom_output_name, - "file_dep": sorted([_.base_path for _ in post_list]), - "targets": [atom_output_name], - "actions": [(self.atom_feed_renderer, - (lang, - post_list, - atom_output_name, - kw['filters'], - context,))], - "clean": True, - "uptodate": [utils.config_changed(kw, 'nikola.nikola.Nikola.atom_feed_renderer')] + additional_dependencies - } - yield utils.apply_filters(atom_task, kw['filters']) - - if kw["indexes_pages_main"] and kw['indexes_prety_page_url'](lang): + if kw["indexes_pages_main"] and kw['indexes_pretty_page_url'](lang): # create redirection - output_name = os.path.join(kw['output_folder'], page_path(0, utils.get_displayed_page_number(0, num_pages, self), num_pages, True)) - link = page_link(0, utils.get_displayed_page_number(0, num_pages, self), num_pages, False) + output_name = os.path.join(kw['output_folder'], page_path(0, displayed_page_numbers[0], num_pages, True)) + link = page_links[0] yield utils.apply_filters({ 'basename': basename, 'name': output_name, @@ -2007,156 +2699,58 @@ class Nikola(object): 'uptodate': [utils.config_changed(kw, 'nikola.nikola.Nikola.generic_index_renderer')], }, kw["filters"]) - def __repr__(self): - """Representation of a Nikola site.""" - return '<Nikola Site: {0!r}>'.format(self.config['BLOG_TITLE']()) - - -def sanitized_locales(locale_fallback, locale_default, locales, translations): - """Sanitize all locales availble in Nikola. - - There will be one locale for each language in translations. - - Locales for languages not in translations are ignored. - - An explicit locale for a language can be specified in locales[language]. - - Locales at the input must be in the string style (like 'en', 'en.utf8'), and - the string can be unicode or bytes; at the output will be of type str, as - required by locale.setlocale. + def generic_atom_renderer(self, lang, posts, context_source, kw, basename, classification, kind, additional_dependencies=None): + """Create an Atom feed. - Explicit but invalid locales are replaced with the sanitized locale_fallback - - Languages with no explicit locale are set to - the sanitized locale_default if it was explicitly set - sanitized guesses compatible with v 6.0.4 if locale_default was None - - NOTE: never use locale.getlocale() , it can return values that - locale.setlocale will not accept in Windows XP, 7 and pythons 2.6, 2.7, 3.3 - Examples: "Spanish", "French" can't do the full circle set / get / set - """ - if sys.platform != 'win32': - workaround_empty_LC_ALL_posix() - - # locales for languages not in translations are ignored - extras = set(locales) - set(translations) - if extras: - msg = 'Unexpected languages in LOCALES, ignoring them: {0}' - utils.LOGGER.warn(msg.format(', '.join(extras))) - for lang in extras: - del locales[lang] - - # py2x: get/setlocale related functions require the locale string as a str - # so convert - locale_fallback = str(locale_fallback) if locale_fallback else None - locale_default = str(locale_default) if locale_default else None - for lang in locales: - locales[lang] = str(locales[lang]) - - locale_fallback = valid_locale_fallback(locale_fallback) - - # explicit but invalid locales are replaced with the sanitized locale_fallback - for lang in locales: - if not is_valid_locale(locales[lang]): - msg = 'Locale {0} for language {1} not accepted by python locale.' - utils.LOGGER.warn(msg.format(locales[lang], lang)) - locales[lang] = locale_fallback - - # languages with no explicit locale - missing = set(translations) - set(locales) - if locale_default: - # are set to the sanitized locale_default if it was explicitly set - if not is_valid_locale(locale_default): - msg = 'LOCALE_DEFAULT {0} could not be set, using {1}' - utils.LOGGER.warn(msg.format(locale_default, locale_fallback)) - locale_default = locale_fallback - for lang in missing: - locales[lang] = locale_default - else: - # are set to sanitized guesses compatible with v 6.0.4 in Linux-Mac (was broken in Windows) - if sys.platform == 'win32': - guess_locale_fom_lang = guess_locale_from_lang_windows - else: - guess_locale_fom_lang = guess_locale_from_lang_posix - for lang in missing: - locale_n = guess_locale_fom_lang(lang) - if not locale_n: - locale_n = locale_fallback - msg = "Could not guess locale for language {0}, using locale {1}" - utils.LOGGER.warn(msg.format(lang, locale_n)) - locales[lang] = locale_n - - return locale_fallback, locale_default, locales + lang: The language + posts: A list of posts + context_source: This will be copied and extended and used as every + page's context + kw: An extended version will be used for uptodate dependencies + basename: Basename for task + classification: name of current classification (used to generate links) + kind: classification kind (used to generate links) + additional_dependencies: a list of dependencies which will be added + to task['uptodate'] + """ + # Update kw + kw = kw.copy() + kw["feed_length"] = self.config['FEED_LENGTH'] + kw['generate_atom'] = self.config["GENERATE_ATOM"] + kw['feed_links_append_query'] = self.config["FEED_LINKS_APPEND_QUERY"] + kw['feed_teasers'] = self.config['FEED_TEASERS'] + kw['feed_plain'] = self.config['FEED_PLAIN'] + if additional_dependencies is None: + additional_dependencies = [] -def is_valid_locale(locale_n): - """Check if locale (type str) is valid.""" - try: - locale.setlocale(locale.LC_ALL, locale_n) - return True - except locale.Error: - return False + post_list = posts[:kw["feed_length"]] + feedlink = self.link(kind + "_atom", classification, lang) + feedpath = self.path(kind + "_atom", classification, lang) + context = context_source.copy() + if 'has_other_languages' not in context: + context['has_other_languages'] = False -def valid_locale_fallback(desired_locale=None): - """Provide a default fallback_locale, a string that locale.setlocale will accept. + output_name = os.path.join(kw['output_folder'], feedpath) + context["feedlink"] = feedlink + task = { + "basename": basename, + "name": output_name, + "file_dep": sorted([_.base_path for _ in post_list]), + "task_dep": ['render_posts'], + "targets": [output_name], + "actions": [(self.atom_feed_renderer, + (lang, + post_list, + output_name, + kw['filters'], + context,))], + "clean": True, + "uptodate": [utils.config_changed(kw, 'nikola.nikola.Nikola.atom_feed_renderer')] + additional_dependencies + } + yield utils.apply_filters(task, kw['filters']) - If desired_locale is provided must be of type str for py2x compatibility - """ - # Whenever fallbacks change, adjust test TestHarcodedFallbacksWork - candidates_windows = [str('English'), str('C')] - candidates_posix = [str('en_US.utf8'), str('C')] - candidates = candidates_windows if sys.platform == 'win32' else candidates_posix - if desired_locale: - candidates = list(candidates) - candidates.insert(0, desired_locale) - found_valid = False - for locale_n in candidates: - found_valid = is_valid_locale(locale_n) - if found_valid: - break - if not found_valid: - msg = 'Could not find a valid fallback locale, tried: {0}' - utils.LOGGER.warn(msg.format(candidates)) - elif desired_locale and (desired_locale != locale_n): - msg = 'Desired fallback locale {0} could not be set, using: {1}' - utils.LOGGER.warn(msg.format(desired_locale, locale_n)) - return locale_n - - -def guess_locale_from_lang_windows(lang): - """Guess a locale, basing on Windows language.""" - locale_n = str(LEGAL_VALUES['_WINDOWS_LOCALE_GUESSES'].get(lang, None)) - if not is_valid_locale(locale_n): - locale_n = None - return locale_n - - -def guess_locale_from_lang_posix(lang): - """Guess a locale, basing on POSIX system language.""" - # compatibility v6.0.4 - if is_valid_locale(str(lang)): - locale_n = str(lang) - else: - # this works in Travis when locale support set by Travis suggestion - locale_n = str((locale.normalize(lang).split('.')[0]) + '.utf8') - if not is_valid_locale(locale_n): - # http://thread.gmane.org/gmane.comp.web.nikola/337/focus=343 - locale_n = str((locale.normalize(lang).split('.')[0])) - if not is_valid_locale(locale_n): - locale_n = None - return locale_n - - -def workaround_empty_LC_ALL_posix(): - # clunky hack: we have seen some posix locales with all or most of LC_* - # defined to the same value, but with LC_ALL empty. - # Manually doing what we do here seems to work for nikola in that case. - # It is unknown if it will work when the LC_* aren't homogeneous - try: - lc_time = os.environ.get('LC_TIME', None) - lc_all = os.environ.get('LC_ALL', None) - if lc_time and not lc_all: - os.environ['LC_ALL'] = lc_time - except Exception: - pass + def __repr__(self): + """Representation of a Nikola site.""" + return '<Nikola Site: {0!r}>'.format(self.config['BLOG_TITLE'](self.config['DEFAULT_LANG'])) diff --git a/nikola/packages/README.md b/nikola/packages/README.md index 156d43f..7265069 100644 --- a/nikola/packages/README.md +++ b/nikola/packages/README.md @@ -2,4 +2,9 @@ We ship some third-party things with Nikola. They live here, along with their l Packages: - * tzlocal by Lennart Regebro, CC0 license (modified) + * tzlocal by Lennart Regebro, CC0 license (modified to remove pytz dependency) + * datecond by Chris Warrick (Nikola contributor), 3-clause BSD license + (modified) + * pygments_better_html by Chris Warrick (Nikola contributor), 3-clause BSD license, + portions copyright the Pygments team (2-clause BSD license). + diff --git a/nikola/packages/datecond/LICENSE b/nikola/packages/datecond/LICENSE new file mode 100644 index 0000000..d9980a8 --- /dev/null +++ b/nikola/packages/datecond/LICENSE @@ -0,0 +1,30 @@ +Copyright © 2016-2020, Chris Warrick. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions, and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions, and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author of this software nor the names of + contributors to this software may be used to endorse or promote + products derived from this software without specific prior written + consent. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/nikola/packages/datecond/__init__.py b/nikola/packages/datecond/__init__.py new file mode 100644 index 0000000..92e7908 --- /dev/null +++ b/nikola/packages/datecond/__init__.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +# Date Conditionals v0.1.7 +# Copyright © 2015-2020, Chris Warrick. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions, and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# 3. Neither the name of the author of this software nor the names of +# contributors to this software may be used to endorse or promote +# products derived from this software without specific prior written +# consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Date range parser.""" + +import datetime +import dateutil.parser +import re +import operator + + +__all__ = ('date_in_range',) +CLAUSE = re.compile('(year|month|day|hour|minute|second|weekday|isoweekday)?' + ' ?(==|!=|<=|>=|<|>) ?(.*)') +OPERATORS = { + '==': operator.eq, + '!=': operator.ne, + '<=': operator.le, + '>=': operator.ge, + '<': operator.lt, + '>': operator.gt, +} + + +def date_in_range(date_range, date, debug=False, now=None): + """Check if date is in the range specified. + + Format: + * comma-separated clauses (AND) + * clause: attribute comparison_operator value (spaces optional) + * attribute: year, month, day, hour, month, second, weekday, isoweekday + or empty for full datetime + * comparison_operator: == != <= >= < > + * value: integer, 'now', 'today', or dateutil-compatible date input + + The optional `now` parameter can be used to provide a specific `now`/`today` value + (if none is provided, datetime.datetime.now()/datetime.date.today() is used). + """ + out = True + + for item in date_range.split(','): + attribute, comparison_operator, value = CLAUSE.match( + item.strip()).groups() + if attribute in ('weekday', 'isoweekday'): + left = getattr(date, attribute)() + right = int(value) + elif value == 'now': + left = date + right = now or datetime.datetime.now() + elif value == 'today': + left = date.date() if isinstance(date, datetime.datetime) else date + if now: + right = now.date() if isinstance(now, datetime.datetime) else now + else: + right = datetime.date.today() + elif attribute: + left = getattr(date, attribute) + right = int(value) + else: + left = date + right = dateutil.parser.parse(value) + if debug: # pragma: no cover + print(" <{0} {1} {2}>".format(left, comparison_operator, right)) + out = out and OPERATORS[comparison_operator](left, right) + return out diff --git a/nikola/packages/pygments_better_html/LICENSE b/nikola/packages/pygments_better_html/LICENSE new file mode 100644 index 0000000..196413e --- /dev/null +++ b/nikola/packages/pygments_better_html/LICENSE @@ -0,0 +1,30 @@ +Copyright © 2020, Chris Warrick. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions, and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions, and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the author of this software nor the names of + contributors to this software may be used to endorse or promote + products derived from this software without specific prior written + consent. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/nikola/packages/pygments_better_html/LICENSE.pygments b/nikola/packages/pygments_better_html/LICENSE.pygments new file mode 100644 index 0000000..13d1c74 --- /dev/null +++ b/nikola/packages/pygments_better_html/LICENSE.pygments @@ -0,0 +1,25 @@ +Copyright (c) 2006-2019 by the respective authors (see AUTHORS file). +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/nikola/packages/pygments_better_html/__init__.py b/nikola/packages/pygments_better_html/__init__.py new file mode 100644 index 0000000..ed6e004 --- /dev/null +++ b/nikola/packages/pygments_better_html/__init__.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +"""Better HTML formatter for Pygments. + +Copyright © 2020, Chris Warrick. +License: 3-clause BSD. +Portions copyright © 2006-2019, the Pygments authors. (2-clause BSD). +""" + +__all__ = ["BetterHtmlFormatter"] +__version__ = "0.1.4" + +import enum +import re +import warnings + +from pygments.formatters.html import HtmlFormatter + +MANY_SPACES = re.compile("( +)") + + +def _sp_to_nbsp(m): + return " " * (m.end() - m.start()) + + +class BetterLinenos(enum.Enum): + TABLE = "table" + OL = "ol" + + +class BetterHtmlFormatter(HtmlFormatter): + r""" + Format tokens as HTML 4 ``<span>`` tags, with alternate formatting styles. + + * ``linenos = 'table'`` renders each line of code in a separate table row + * ``linenos = 'ol'`` renders each line in a <li> element (inside <ol>) + + Both options allow word wrap and don't include line numbers when copying. + """ + + name = "HTML" + aliases = ["html"] + filenames = ["*.html", "*.htm"] + + def __init__(self, **options): + """Initialize the formatter.""" + super().__init__(**options) + self.linenos_name = self.options.get("linenos", "table") + if self.linenos_name is False: + self.linenos_val = False + self.linenos = 0 + elif self.linenos_name is True: + self.linenos_name = "table" + if self.linenos_name is not False: + self.linenos_val = BetterLinenos(self.linenos_name) + self.linenos = 2 if self.linenos_val == BetterLinenos.OL else 1 + + def get_style_defs(self, arg=None, wrapper_classes=None): + """Generate CSS style definitions. + + Return CSS style definitions for the classes produced by the current + highlighting style. ``arg`` can be a string or list of selectors to + insert before the token type classes. ``wrapper_classes`` are a list of + classes for the wrappers, defaults to the ``cssclass`` option. + """ + base = super().get_style_defs(arg) + new_styles = ( + ("{0} table, {0} tr, {0} td", "border-spacing: 0; border-collapse: separate; padding: 0"), + ("{0} pre", "white-space: pre-wrap; line-height: normal"), + ( + "{0}table td.linenos", + "vertical-align: top; padding-left: 10px; padding-right: 10px; user-select: none; -webkit-user-select: none", + ), + # Hack for Safari (user-select does not affect copy-paste) + ("{0}table td.linenos code:before", "content: attr(data-line-number)"), + ("{0}table td.code", "overflow-wrap: normal; border-collapse: collapse"), + ( + "{0}table td.code code", + "overflow: unset; border: none; padding: 0; margin: 0; white-space: pre-wrap; line-height: unset; background: none", + ), + ("{0} .lineno.nonumber", "list-style: none"), + ) + new_styles_code = [] + if wrapper_classes is None: + wrapper_classes = ["." + self.cssclass] + for cls, rule in new_styles: + new_styles_code.append(", ".join(cls.format(c) for c in wrapper_classes) + " { " + rule + " }") + return base + "\n" + "\n".join(new_styles_code) + + def _wrap_tablelinenos(self, inner): + lncount = 0 + codelines = [] + for t, line in inner: + if t: + lncount += 1 + codelines.append(line) + + fl = self.linenostart + mw = len(str(lncount + fl - 1)) + sp = self.linenospecial + st = self.linenostep + la = self.lineanchors + aln = self.anchorlinenos + nocls = self.noclasses + if sp: + lines = [] + + for i in range(fl, fl + lncount): + line_before = "" + line_after = "" + if i % st == 0: + if i % sp == 0: + if aln: + line_before = '<a href="#%s-%d" class="special">' % (la, i) + line_after = "</a>" + else: + line_before = '<span class="special">' + line_after = "</span>" + elif aln: + line_before = '<a href="#%s-%d">' % (la, i) + line_after = "</a>" + lines.append((line_before, "%*d" % (mw, i), line_after)) + else: + lines.append(("", "", "")) + else: + lines = [] + for i in range(fl, fl + lncount): + line_before = "" + line_after = "" + if i % st == 0: + if aln: + line_before = '<a href="#%s-%d">' % (la, i) + line_after = "</a>" + lines.append((line_before, "%*d" % (mw, i), line_after)) + else: + lines.append(("", "", "")) + + yield 0, '<div class="%s"><table class="%stable">' % ( + self.cssclass, + self.cssclass, + ) + for lndata, cl in zip(lines, codelines): + ln_b, ln, ln_a = lndata + cl = MANY_SPACES.sub(_sp_to_nbsp, cl) + if nocls: + yield 0, ( + '<tr><td class="linenos linenodiv" style="background-color: #f0f0f0; padding-right: 10px">' + ln_b + + '<code data-line-number="' + ln + '"></code>' + ln_a + '</td><td class="code"><code>' + cl + "</code></td></tr>" + ) + else: + yield 0, ( + '<tr><td class="linenos linenodiv">' + ln_b + '<code data-line-number="' + ln + + '"></code>' + ln_a + '</td><td class="code"><code>' + cl + "</code></td></tr>" + ) + yield 0, "</table></div>" + + def _wrap_inlinelinenos(self, inner): + # Override with new method + return self._wrap_ollineos(self, inner) + + def _wrap_ollinenos(self, inner): + lines = inner + sp = self.linenospecial + st = self.linenostep or 1 + num = self.linenostart + + if self.anchorlinenos: + warnings.warn("anchorlinenos is not supported for linenos='ol'.") + + yield 0, "<ol>" + if self.noclasses: + if sp: + for t, line in lines: + if num % sp == 0: + style = "background-color: #ffffc0; padding: 0 5px 0 5px" + else: + style = "background-color: #f0f0f0; padding: 0 5px 0 5px" + if num % st != 0: + style += "; list-style: none" + yield 1, '<li style="%s" value="%s">' % (style, num,) + line + "</li>" + num += 1 + else: + for t, line in lines: + yield 1, ( + '<li style="background-color: #f0f0f0; padding: 0 5px 0 5px%s" value="%s">' + % (("; list-style: none" if num % st != 0 else ""), num) + line + "</li>" + ) + num += 1 + elif sp: + for t, line in lines: + yield 1, '<li class="lineno%s%s" value="%s">' % ( + " special" if num % sp == 0 else "", + " nonumber" if num % st != 0 else "", + num, + ) + line + "</li>" + num += 1 + else: + for t, line in lines: + yield 1, '<li class="lineno%s" value="%s">' % ( + "" if num % st != 0 else " nonumber", + num, + ) + line + "</li>" + num += 1 + + yield 0, "</ol>" + + def format_unencoded(self, tokensource, outfile): + """Format code and write to outfile. + + The formatting process uses several nested generators; which of + them are used is determined by the user's options. + + Each generator should take at least one argument, ``inner``, + and wrap the pieces of text generated by this. + + Always yield 2-tuples: (code, text). If "code" is 1, the text + is part of the original tokensource being highlighted, if it's + 0, the text is some piece of wrapping. This makes it possible to + use several different wrappers that process the original source + linewise, e.g. line number generators. + """ + if self.linenos_val is False: + return super().format_unencoded(tokensource, outfile) + source = self._format_lines(tokensource) + if self.hl_lines: + source = self._highlight_lines(source) + if not self.nowrap: + if self.linenos_val == BetterLinenos.OL: + source = self._wrap_ollinenos(source) + if self.lineanchors: + source = self._wrap_lineanchors(source) + if self.linespans: + source = self._wrap_linespans(source) + if self.linenos_val == BetterLinenos.TABLE: + source = self._wrap_tablelinenos(source) + if self.linenos_val == BetterLinenos.OL: + source = self.wrap(source, outfile) + if self.full: + source = self._wrap_full(source, outfile) + + for t, piece in source: + outfile.write(piece) diff --git a/nikola/packages/tzlocal/__init__.py b/nikola/packages/tzlocal/__init__.py index 4a6b1d6..5b4947c 100644 --- a/nikola/packages/tzlocal/__init__.py +++ b/nikola/packages/tzlocal/__init__.py @@ -1,9 +1,8 @@ -"""tzlocal init.""" - +"""Try to figure out what your local timezone is.""" import sys -if sys.platform == 'win32': +__version__ = "2.0.0-nikola" + +if sys.platform == "win32": from .win32 import get_localzone, reload_localzone # NOQA -elif 'darwin' in sys.platform: - from .darwin import get_localzone, reload_localzone # NOQA else: from .unix import get_localzone, reload_localzone # NOQA diff --git a/nikola/packages/tzlocal/darwin.py b/nikola/packages/tzlocal/darwin.py deleted file mode 100644 index 0dbf1c1..0000000 --- a/nikola/packages/tzlocal/darwin.py +++ /dev/null @@ -1,43 +0,0 @@ -"""tzlocal for OS X.""" - -import os -import dateutil.tz -import subprocess - -_cache_tz = None - - -def _get_localzone(): - tzname = subprocess.check_output(["systemsetup", "-gettimezone"]).decode('utf-8') - tzname = tzname.replace("Time Zone: ", "") - # OS X 10.9+, this command is root-only - if 'exiting!' in tzname: - tzname = '' - - if not tzname: - # link will be something like /usr/share/zoneinfo/America/Los_Angeles. - link = os.readlink("/etc/localtime") - tzname = link.split('zoneinfo/')[-1] - tzname = tzname.strip() - try: - # test the name - assert tzname - dateutil.tz.gettz(tzname) - return tzname - except: - return None - - -def get_localzone(): - """Get the computers configured local timezone, if any.""" - global _cache_tz - if _cache_tz is None: - _cache_tz = _get_localzone() - return _cache_tz - - -def reload_localzone(): - """Reload the cached localzone. You need to call this if the timezone has changed.""" - global _cache_tz - _cache_tz = _get_localzone() - return _cache_tz diff --git a/nikola/packages/tzlocal/unix.py b/nikola/packages/tzlocal/unix.py index 8f7fc84..086ab7c 100644 --- a/nikola/packages/tzlocal/unix.py +++ b/nikola/packages/tzlocal/unix.py @@ -1,115 +1,128 @@ -"""tzlocal for UNIX.""" - -from __future__ import with_statement +"""Unix support for tzlocal.""" import os import re + import dateutil.tz _cache_tz = None -def _get_localzone(): - """Try to find the local timezone configuration. +def _try_tz_from_env(): + tzenv = os.environ.get("TZ") + if tzenv and tzenv[0] == ":": + tzenv = tzenv[1:] + try: + if tzenv: + dateutil.tz.gettz(tzenv) + return tzenv + except Exception: + pass + - This method prefers finding the timezone name and passing that to pytz, - over passing in the localtime file, as in the later case the zoneinfo - name is unknown. +def _get_localzone(_root="/"): + """Try to find the local timezone configuration. The parameter _root makes the function look for files like /etc/localtime beneath the _root directory. This is primarily used by the tests. In normal usage you call the function without parameters. """ - tz = os.environ.get('TZ') - if tz and tz[0] == ':': - tz = tz[1:] - try: - if tz: - dateutil.tz.gettz(tz) - return tz - except: - pass + tzenv = _try_tz_from_env() + if tzenv: + return tzenv - try: - # link will be something like /usr/share/zoneinfo/America/Los_Angeles. - link = os.readlink('/etc/localtime') - tz = link.split('zoneinfo/')[-1] + # Are we under Termux on Android? + if os.path.exists("/system/bin/getprop"): + import subprocess - if tz: - dateutil.tz.gettz(tz) - return tz - except: - return None + androidtz = ( + subprocess.check_output(["getprop", "persist.sys.timezone"]) + .strip() + .decode() + ) + return androidtz # Now look for distribution specific configuration files # that contain the timezone name. - tzpath = os.path.join('/etc/timezone') - if os.path.exists(tzpath): - with open(tzpath, 'rb') as tzfile: - data = tzfile.read() - - # Issue #3 was that /etc/timezone was a zoneinfo file. - # That's a misconfiguration, but we need to handle it gracefully: - if data[:5] != 'TZif2': + for configfile in ("etc/timezone", "var/db/zoneinfo"): + tzpath = os.path.join(_root, configfile) + try: + with open(tzpath, "rb") as tzfile: + data = tzfile.read() + + # Issue #3 was that /etc/timezone was a zoneinfo file. + # That's a misconfiguration, but we need to handle it gracefully: + if data[:5] == b"TZif2": + continue + etctz = data.strip().decode() - # Get rid of host definitions and comments: - if ' ' in etctz: - etctz, dummy = etctz.split(' ', 1) - if '#' in etctz: - etctz, dummy = etctz.split('#', 1) - tz = etctz.replace(' ', '_') - try: - if tz: - dateutil.tz.gettz(tz) - return tz - except: - pass + if not etctz: + # Empty file, skip + continue + for etctz in data.decode().splitlines(): + # Get rid of host definitions and comments: + if " " in etctz: + etctz, dummy = etctz.split(" ", 1) + if "#" in etctz: + etctz, dummy = etctz.split("#", 1) + if not etctz: + continue + tz = etctz.replace(" ", "_") + return tz + + except IOError: + # File doesn't exist or is a directory + continue # CentOS has a ZONE setting in /etc/sysconfig/clock, # OpenSUSE has a TIMEZONE setting in /etc/sysconfig/clock and # Gentoo has a TIMEZONE setting in /etc/conf.d/clock # We look through these files for a timezone: - zone_re = re.compile('\s*ZONE\s*=\s*\"') - timezone_re = re.compile('\s*TIMEZONE\s*=\s*\"') - end_re = re.compile('\"') - - for tzpath in ('/etc/sysconfig/clock', '/etc/conf.d/clock'): - if not os.path.exists(tzpath): + zone_re = re.compile(r"\s*ZONE\s*=\s*\"") + timezone_re = re.compile(r"\s*TIMEZONE\s*=\s*\"") + end_re = re.compile('"') + + for filename in ("etc/sysconfig/clock", "etc/conf.d/clock"): + tzpath = os.path.join(_root, filename) + try: + with open(tzpath, "rt") as tzfile: + data = tzfile.readlines() + + for line in data: + # Look for the ZONE= setting. + match = zone_re.match(line) + if match is None: + # No ZONE= setting. Look for the TIMEZONE= setting. + match = timezone_re.match(line) + if match is not None: + # Some setting existed + line = line[match.end():] + etctz = line[: end_re.search(line).start()] + + # We found a timezone + tz = etctz.replace(" ", "_") + return tz + + except IOError: + # File doesn't exist or is a directory continue - with open(tzpath, 'rt') as tzfile: - data = tzfile.readlines() - - for line in data: - # Look for the ZONE= setting. - match = zone_re.match(line) - if match is None: - # No ZONE= setting. Look for the TIMEZONE= setting. - match = timezone_re.match(line) - if match is not None: - # Some setting existed - line = line[match.end():] - etctz = line[:end_re.search(line).start()] - - # We found a timezone - tz = etctz.replace(' ', '_') - try: - if tz: - dateutil.tz.gettz(tz) - return tz - except: - pass - - # Nikola cannot use this thing below... - - # No explicit setting existed. Use localtime - # for filename in ('etc/localtime', 'usr/local/etc/localtime'): - # tzpath = os.path.join(_root, filename) - - # if not os.path.exists(tzpath): - # continue - # with open(tzpath, 'rb') as tzfile: - # return pytz.tzfile.build_tzinfo('local', tzfile) + # systemd distributions use symlinks that include the zone name, + # see manpage of localtime(5) and timedatectl(1) + tzpath = os.path.join(_root, "etc/localtime") + if os.path.exists(tzpath) and os.path.islink(tzpath): + tzpath = os.path.realpath(tzpath) + start = tzpath.find("/") + 1 + while start != 0: + tzpath = tzpath[start:] + try: + dateutil.tz.gettz(tzpath) + return tzpath + except Exception: + pass + start = tzpath.find("/") + 1 + + # Nothing found, return UTC return None @@ -118,6 +131,7 @@ def get_localzone(): global _cache_tz if _cache_tz is None: _cache_tz = _get_localzone() + return _cache_tz diff --git a/nikola/packages/tzlocal/win32.py b/nikola/packages/tzlocal/win32.py index cb19284..b8be8b4 100644 --- a/nikola/packages/tzlocal/win32.py +++ b/nikola/packages/tzlocal/win32.py @@ -1,12 +1,8 @@ -"""tzlocal for Windows.""" - +"""Windows support for tzlocal.""" try: import _winreg as winreg except ImportError: - try: - import winreg - except ImportError: - pass # not windows + import winreg from .windows_tz import win_tz @@ -24,7 +20,7 @@ def valuestodict(key): def get_localzone_name(): - """Get local time zone name.""" + """Get local zone name.""" # Windows is special. It has unique time zone names (in several # meanings of the word) available, but unfortunately, they can be # translated to the language of the operating system, so we need to @@ -36,18 +32,19 @@ def get_localzone_name(): localtz = winreg.OpenKey(handle, TZLOCALKEYNAME) keyvalues = valuestodict(localtz) localtz.Close() - if 'TimeZoneKeyName' in keyvalues: + + if "TimeZoneKeyName" in keyvalues: # Windows 7 (and Vista?) # For some reason this returns a string with loads of NUL bytes at # least on some systems. I don't know if this is a bug somewhere, I # just work around it. - tzkeyname = keyvalues['TimeZoneKeyName'].split('\x00', 1)[0] + tzkeyname = keyvalues["TimeZoneKeyName"].split("\x00", 1)[0] else: # Windows 2000 or XP # This is the localized name: - tzwin = keyvalues['StandardName'] + tzwin = keyvalues["StandardName"] # Open the list of timezones to look up the real name: TZKEYNAME = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones" @@ -60,15 +57,20 @@ def get_localzone_name(): sub = winreg.OpenKey(tzkey, subkey) data = valuestodict(sub) sub.Close() - if data['Std'] == tzwin: - tzkeyname = subkey - break + try: + if data["Std"] == tzwin: + tzkeyname = subkey + break + except KeyError: + # This timezone didn't have proper configuration. + # Ignore it. + pass tzkey.Close() handle.Close() if tzkeyname is None: - raise LookupError('Can not find Windows timezone configuration') + raise LookupError("Can not find Windows timezone configuration") timezone = win_tz.get(tzkeyname) if timezone is None: @@ -85,6 +87,7 @@ def get_localzone(): global _cache_tz if _cache_tz is None: _cache_tz = get_localzone_name() + return _cache_tz diff --git a/nikola/packages/tzlocal/windows_tz.py b/nikola/packages/tzlocal/windows_tz.py index c171aa5..0e911fd 100644 --- a/nikola/packages/tzlocal/windows_tz.py +++ b/nikola/packages/tzlocal/windows_tz.py @@ -1,544 +1,700 @@ -"""Windows timezone names.""" -# This file is autogenerated by the get_windows_info.py script +"""Windows timezone mapping.""" +# This file is autogenerated by the update_windows_mapping.py script # Do not edit. win_tz = { - 'AUS Central Standard Time': 'Australia/Darwin', - 'AUS Eastern Standard Time': 'Australia/Sydney', - 'Afghanistan Standard Time': 'Asia/Kabul', - 'Alaskan Standard Time': 'America/Anchorage', - 'Arab Standard Time': 'Asia/Riyadh', - 'Arabian Standard Time': 'Asia/Dubai', - 'Arabic Standard Time': 'Asia/Baghdad', - 'Argentina Standard Time': 'America/Buenos_Aires', - 'Atlantic Standard Time': 'America/Halifax', - 'Azerbaijan Standard Time': 'Asia/Baku', - 'Azores Standard Time': 'Atlantic/Azores', - 'Bahia Standard Time': 'America/Bahia', - 'Bangladesh Standard Time': 'Asia/Dhaka', - 'Canada Central Standard Time': 'America/Regina', - 'Cape Verde Standard Time': 'Atlantic/Cape_Verde', - 'Caucasus Standard Time': 'Asia/Yerevan', - 'Cen. Australia Standard Time': 'Australia/Adelaide', - 'Central America Standard Time': 'America/Guatemala', - 'Central Asia Standard Time': 'Asia/Almaty', - 'Central Brazilian Standard Time': 'America/Cuiaba', - 'Central Europe Standard Time': 'Europe/Budapest', - 'Central European Standard Time': 'Europe/Warsaw', - 'Central Pacific Standard Time': 'Pacific/Guadalcanal', - 'Central Standard Time': 'America/Chicago', - 'Central Standard Time (Mexico)': 'America/Mexico_City', - 'China Standard Time': 'Asia/Shanghai', - 'Dateline Standard Time': 'Etc/GMT+12', - 'E. Africa Standard Time': 'Africa/Nairobi', - 'E. Australia Standard Time': 'Australia/Brisbane', - 'E. Europe Standard Time': 'Asia/Nicosia', - 'E. South America Standard Time': 'America/Sao_Paulo', - 'Eastern Standard Time': 'America/New_York', - 'Egypt Standard Time': 'Africa/Cairo', - 'Ekaterinburg Standard Time': 'Asia/Yekaterinburg', - 'FLE Standard Time': 'Europe/Kiev', - 'Fiji Standard Time': 'Pacific/Fiji', - 'GMT Standard Time': 'Europe/London', - 'GTB Standard Time': 'Europe/Bucharest', - 'Georgian Standard Time': 'Asia/Tbilisi', - 'Greenland Standard Time': 'America/Godthab', - 'Greenwich Standard Time': 'Atlantic/Reykjavik', - 'Hawaiian Standard Time': 'Pacific/Honolulu', - 'India Standard Time': 'Asia/Calcutta', - 'Iran Standard Time': 'Asia/Tehran', - 'Israel Standard Time': 'Asia/Jerusalem', - 'Jordan Standard Time': 'Asia/Amman', - 'Kaliningrad Standard Time': 'Europe/Kaliningrad', - 'Korea Standard Time': 'Asia/Seoul', - 'Libya Standard Time': 'Africa/Tripoli', - 'Magadan Standard Time': 'Asia/Magadan', - 'Mauritius Standard Time': 'Indian/Mauritius', - 'Middle East Standard Time': 'Asia/Beirut', - 'Montevideo Standard Time': 'America/Montevideo', - 'Morocco Standard Time': 'Africa/Casablanca', - 'Mountain Standard Time': 'America/Denver', - 'Mountain Standard Time (Mexico)': 'America/Chihuahua', - 'Myanmar Standard Time': 'Asia/Rangoon', - 'N. Central Asia Standard Time': 'Asia/Novosibirsk', - 'Namibia Standard Time': 'Africa/Windhoek', - 'Nepal Standard Time': 'Asia/Katmandu', - 'New Zealand Standard Time': 'Pacific/Auckland', - 'Newfoundland Standard Time': 'America/St_Johns', - 'North Asia East Standard Time': 'Asia/Irkutsk', - 'North Asia Standard Time': 'Asia/Krasnoyarsk', - 'Pacific SA Standard Time': 'America/Santiago', - 'Pacific Standard Time': 'America/Los_Angeles', - 'Pacific Standard Time (Mexico)': 'America/Santa_Isabel', - 'Pakistan Standard Time': 'Asia/Karachi', - 'Paraguay Standard Time': 'America/Asuncion', - 'Romance Standard Time': 'Europe/Paris', - 'Russian Standard Time': 'Europe/Moscow', - 'SA Eastern Standard Time': 'America/Cayenne', - 'SA Pacific Standard Time': 'America/Bogota', - 'SA Western Standard Time': 'America/La_Paz', - 'SE Asia Standard Time': 'Asia/Bangkok', - 'Samoa Standard Time': 'Pacific/Apia', - 'Singapore Standard Time': 'Asia/Singapore', - 'South Africa Standard Time': 'Africa/Johannesburg', - 'Sri Lanka Standard Time': 'Asia/Colombo', - 'Syria Standard Time': 'Asia/Damascus', - 'Taipei Standard Time': 'Asia/Taipei', - 'Tasmania Standard Time': 'Australia/Hobart', - 'Tokyo Standard Time': 'Asia/Tokyo', - 'Tonga Standard Time': 'Pacific/Tongatapu', - 'Turkey Standard Time': 'Europe/Istanbul', - 'US Eastern Standard Time': 'America/Indianapolis', - 'US Mountain Standard Time': 'America/Phoenix', - 'UTC': 'Etc/GMT', - 'UTC+12': 'Etc/GMT-12', - 'UTC-02': 'Etc/GMT+2', - 'UTC-11': 'Etc/GMT+11', - 'Ulaanbaatar Standard Time': 'Asia/Ulaanbaatar', - 'Venezuela Standard Time': 'America/Caracas', - 'Vladivostok Standard Time': 'Asia/Vladivostok', - 'W. Australia Standard Time': 'Australia/Perth', - 'W. Central Africa Standard Time': 'Africa/Lagos', - 'W. Europe Standard Time': 'Europe/Berlin', - 'West Asia Standard Time': 'Asia/Tashkent', - 'West Pacific Standard Time': 'Pacific/Port_Moresby', - 'Yakutsk Standard Time': 'Asia/Yakutsk' + "AUS Central Standard Time": "Australia/Darwin", + "AUS Eastern Standard Time": "Australia/Sydney", + "Afghanistan Standard Time": "Asia/Kabul", + "Alaskan Standard Time": "America/Anchorage", + "Aleutian Standard Time": "America/Adak", + "Altai Standard Time": "Asia/Barnaul", + "Arab Standard Time": "Asia/Riyadh", + "Arabian Standard Time": "Asia/Dubai", + "Arabic Standard Time": "Asia/Baghdad", + "Argentina Standard Time": "America/Buenos_Aires", + "Astrakhan Standard Time": "Europe/Astrakhan", + "Atlantic Standard Time": "America/Halifax", + "Aus Central W. Standard Time": "Australia/Eucla", + "Azerbaijan Standard Time": "Asia/Baku", + "Azores Standard Time": "Atlantic/Azores", + "Bahia Standard Time": "America/Bahia", + "Bangladesh Standard Time": "Asia/Dhaka", + "Belarus Standard Time": "Europe/Minsk", + "Bougainville Standard Time": "Pacific/Bougainville", + "Canada Central Standard Time": "America/Regina", + "Cape Verde Standard Time": "Atlantic/Cape_Verde", + "Caucasus Standard Time": "Asia/Yerevan", + "Cen. Australia Standard Time": "Australia/Adelaide", + "Central America Standard Time": "America/Guatemala", + "Central Asia Standard Time": "Asia/Almaty", + "Central Brazilian Standard Time": "America/Cuiaba", + "Central Europe Standard Time": "Europe/Budapest", + "Central European Standard Time": "Europe/Warsaw", + "Central Pacific Standard Time": "Pacific/Guadalcanal", + "Central Standard Time": "America/Chicago", + "Central Standard Time (Mexico)": "America/Mexico_City", + "Chatham Islands Standard Time": "Pacific/Chatham", + "China Standard Time": "Asia/Shanghai", + "Cuba Standard Time": "America/Havana", + "Dateline Standard Time": "Etc/GMT+12", + "E. Africa Standard Time": "Africa/Nairobi", + "E. Australia Standard Time": "Australia/Brisbane", + "E. Europe Standard Time": "Europe/Chisinau", + "E. South America Standard Time": "America/Sao_Paulo", + "Easter Island Standard Time": "Pacific/Easter", + "Eastern Standard Time": "America/New_York", + "Eastern Standard Time (Mexico)": "America/Cancun", + "Egypt Standard Time": "Africa/Cairo", + "Ekaterinburg Standard Time": "Asia/Yekaterinburg", + "FLE Standard Time": "Europe/Kiev", + "Fiji Standard Time": "Pacific/Fiji", + "GMT Standard Time": "Europe/London", + "GTB Standard Time": "Europe/Bucharest", + "Georgian Standard Time": "Asia/Tbilisi", + "Greenland Standard Time": "America/Godthab", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "Haiti Standard Time": "America/Port-au-Prince", + "Hawaiian Standard Time": "Pacific/Honolulu", + "India Standard Time": "Asia/Calcutta", + "Iran Standard Time": "Asia/Tehran", + "Israel Standard Time": "Asia/Jerusalem", + "Jordan Standard Time": "Asia/Amman", + "Kaliningrad Standard Time": "Europe/Kaliningrad", + "Korea Standard Time": "Asia/Seoul", + "Libya Standard Time": "Africa/Tripoli", + "Line Islands Standard Time": "Pacific/Kiritimati", + "Lord Howe Standard Time": "Australia/Lord_Howe", + "Magadan Standard Time": "Asia/Magadan", + "Magallanes Standard Time": "America/Punta_Arenas", + "Marquesas Standard Time": "Pacific/Marquesas", + "Mauritius Standard Time": "Indian/Mauritius", + "Middle East Standard Time": "Asia/Beirut", + "Montevideo Standard Time": "America/Montevideo", + "Morocco Standard Time": "Africa/Casablanca", + "Mountain Standard Time": "America/Denver", + "Mountain Standard Time (Mexico)": "America/Chihuahua", + "Myanmar Standard Time": "Asia/Rangoon", + "N. Central Asia Standard Time": "Asia/Novosibirsk", + "Namibia Standard Time": "Africa/Windhoek", + "Nepal Standard Time": "Asia/Katmandu", + "New Zealand Standard Time": "Pacific/Auckland", + "Newfoundland Standard Time": "America/St_Johns", + "Norfolk Standard Time": "Pacific/Norfolk", + "North Asia East Standard Time": "Asia/Irkutsk", + "North Asia Standard Time": "Asia/Krasnoyarsk", + "North Korea Standard Time": "Asia/Pyongyang", + "Omsk Standard Time": "Asia/Omsk", + "Pacific SA Standard Time": "America/Santiago", + "Pacific Standard Time": "America/Los_Angeles", + "Pacific Standard Time (Mexico)": "America/Tijuana", + "Pakistan Standard Time": "Asia/Karachi", + "Paraguay Standard Time": "America/Asuncion", + "Romance Standard Time": "Europe/Paris", + "Russia Time Zone 10": "Asia/Srednekolymsk", + "Russia Time Zone 11": "Asia/Kamchatka", + "Russia Time Zone 3": "Europe/Samara", + "Russian Standard Time": "Europe/Moscow", + "SA Eastern Standard Time": "America/Cayenne", + "SA Pacific Standard Time": "America/Bogota", + "SA Western Standard Time": "America/La_Paz", + "SE Asia Standard Time": "Asia/Bangkok", + "Saint Pierre Standard Time": "America/Miquelon", + "Sakhalin Standard Time": "Asia/Sakhalin", + "Samoa Standard Time": "Pacific/Apia", + "Sao Tome Standard Time": "Africa/Sao_Tome", + "Saratov Standard Time": "Europe/Saratov", + "Singapore Standard Time": "Asia/Singapore", + "South Africa Standard Time": "Africa/Johannesburg", + "Sri Lanka Standard Time": "Asia/Colombo", + "Sudan Standard Time": "Africa/Khartoum", + "Syria Standard Time": "Asia/Damascus", + "Taipei Standard Time": "Asia/Taipei", + "Tasmania Standard Time": "Australia/Hobart", + "Tocantins Standard Time": "America/Araguaina", + "Tokyo Standard Time": "Asia/Tokyo", + "Tomsk Standard Time": "Asia/Tomsk", + "Tonga Standard Time": "Pacific/Tongatapu", + "Transbaikal Standard Time": "Asia/Chita", + "Turkey Standard Time": "Europe/Istanbul", + "Turks And Caicos Standard Time": "America/Grand_Turk", + "US Eastern Standard Time": "America/Indianapolis", + "US Mountain Standard Time": "America/Phoenix", + "UTC": "Etc/GMT", + "UTC+12": "Etc/GMT-12", + "UTC+13": "Etc/GMT-13", + "UTC-02": "Etc/GMT+2", + "UTC-08": "Etc/GMT+8", + "UTC-09": "Etc/GMT+9", + "UTC-11": "Etc/GMT+11", + "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", + "Venezuela Standard Time": "America/Caracas", + "Vladivostok Standard Time": "Asia/Vladivostok", + "W. Australia Standard Time": "Australia/Perth", + "W. Central Africa Standard Time": "Africa/Lagos", + "W. Europe Standard Time": "Europe/Berlin", + "W. Mongolia Standard Time": "Asia/Hovd", + "West Asia Standard Time": "Asia/Tashkent", + "West Bank Standard Time": "Asia/Hebron", + "West Pacific Standard Time": "Pacific/Port_Moresby", + "Yakutsk Standard Time": "Asia/Yakutsk", } # Old name for the win_tz variable: tz_names = win_tz tz_win = { - 'Africa/Abidjan': 'Greenwich Standard Time', - 'Africa/Accra': 'Greenwich Standard Time', - 'Africa/Addis_Ababa': 'E. Africa Standard Time', - 'Africa/Algiers': 'W. Central Africa Standard Time', - 'Africa/Asmera': 'E. Africa Standard Time', - 'Africa/Bamako': 'Greenwich Standard Time', - 'Africa/Bangui': 'W. Central Africa Standard Time', - 'Africa/Banjul': 'Greenwich Standard Time', - 'Africa/Bissau': 'Greenwich Standard Time', - 'Africa/Blantyre': 'South Africa Standard Time', - 'Africa/Brazzaville': 'W. Central Africa Standard Time', - 'Africa/Bujumbura': 'South Africa Standard Time', - 'Africa/Cairo': 'Egypt Standard Time', - 'Africa/Casablanca': 'Morocco Standard Time', - 'Africa/Ceuta': 'Romance Standard Time', - 'Africa/Conakry': 'Greenwich Standard Time', - 'Africa/Dakar': 'Greenwich Standard Time', - 'Africa/Dar_es_Salaam': 'E. Africa Standard Time', - 'Africa/Djibouti': 'E. Africa Standard Time', - 'Africa/Douala': 'W. Central Africa Standard Time', - 'Africa/El_Aaiun': 'Morocco Standard Time', - 'Africa/Freetown': 'Greenwich Standard Time', - 'Africa/Gaborone': 'South Africa Standard Time', - 'Africa/Harare': 'South Africa Standard Time', - 'Africa/Johannesburg': 'South Africa Standard Time', - 'Africa/Juba': 'E. Africa Standard Time', - 'Africa/Kampala': 'E. Africa Standard Time', - 'Africa/Khartoum': 'E. Africa Standard Time', - 'Africa/Kigali': 'South Africa Standard Time', - 'Africa/Kinshasa': 'W. Central Africa Standard Time', - 'Africa/Lagos': 'W. Central Africa Standard Time', - 'Africa/Libreville': 'W. Central Africa Standard Time', - 'Africa/Lome': 'Greenwich Standard Time', - 'Africa/Luanda': 'W. Central Africa Standard Time', - 'Africa/Lubumbashi': 'South Africa Standard Time', - 'Africa/Lusaka': 'South Africa Standard Time', - 'Africa/Malabo': 'W. Central Africa Standard Time', - 'Africa/Maputo': 'South Africa Standard Time', - 'Africa/Maseru': 'South Africa Standard Time', - 'Africa/Mbabane': 'South Africa Standard Time', - 'Africa/Mogadishu': 'E. Africa Standard Time', - 'Africa/Monrovia': 'Greenwich Standard Time', - 'Africa/Nairobi': 'E. Africa Standard Time', - 'Africa/Ndjamena': 'W. Central Africa Standard Time', - 'Africa/Niamey': 'W. Central Africa Standard Time', - 'Africa/Nouakchott': 'Greenwich Standard Time', - 'Africa/Ouagadougou': 'Greenwich Standard Time', - 'Africa/Porto-Novo': 'W. Central Africa Standard Time', - 'Africa/Sao_Tome': 'Greenwich Standard Time', - 'Africa/Tripoli': 'Libya Standard Time', - 'Africa/Tunis': 'W. Central Africa Standard Time', - 'Africa/Windhoek': 'Namibia Standard Time', - 'America/Anchorage': 'Alaskan Standard Time', - 'America/Anguilla': 'SA Western Standard Time', - 'America/Antigua': 'SA Western Standard Time', - 'America/Araguaina': 'SA Eastern Standard Time', - 'America/Argentina/La_Rioja': 'Argentina Standard Time', - 'America/Argentina/Rio_Gallegos': 'Argentina Standard Time', - 'America/Argentina/Salta': 'Argentina Standard Time', - 'America/Argentina/San_Juan': 'Argentina Standard Time', - 'America/Argentina/San_Luis': 'Argentina Standard Time', - 'America/Argentina/Tucuman': 'Argentina Standard Time', - 'America/Argentina/Ushuaia': 'Argentina Standard Time', - 'America/Aruba': 'SA Western Standard Time', - 'America/Asuncion': 'Paraguay Standard Time', - 'America/Bahia': 'Bahia Standard Time', - 'America/Bahia_Banderas': 'Central Standard Time (Mexico)', - 'America/Barbados': 'SA Western Standard Time', - 'America/Belem': 'SA Eastern Standard Time', - 'America/Belize': 'Central America Standard Time', - 'America/Blanc-Sablon': 'SA Western Standard Time', - 'America/Boa_Vista': 'SA Western Standard Time', - 'America/Bogota': 'SA Pacific Standard Time', - 'America/Boise': 'Mountain Standard Time', - 'America/Buenos_Aires': 'Argentina Standard Time', - 'America/Cambridge_Bay': 'Mountain Standard Time', - 'America/Campo_Grande': 'Central Brazilian Standard Time', - 'America/Cancun': 'Central Standard Time (Mexico)', - 'America/Caracas': 'Venezuela Standard Time', - 'America/Catamarca': 'Argentina Standard Time', - 'America/Cayenne': 'SA Eastern Standard Time', - 'America/Cayman': 'SA Pacific Standard Time', - 'America/Chicago': 'Central Standard Time', - 'America/Chihuahua': 'Mountain Standard Time (Mexico)', - 'America/Coral_Harbour': 'SA Pacific Standard Time', - 'America/Cordoba': 'Argentina Standard Time', - 'America/Costa_Rica': 'Central America Standard Time', - 'America/Creston': 'US Mountain Standard Time', - 'America/Cuiaba': 'Central Brazilian Standard Time', - 'America/Curacao': 'SA Western Standard Time', - 'America/Danmarkshavn': 'UTC', - 'America/Dawson': 'Pacific Standard Time', - 'America/Dawson_Creek': 'US Mountain Standard Time', - 'America/Denver': 'Mountain Standard Time', - 'America/Detroit': 'Eastern Standard Time', - 'America/Dominica': 'SA Western Standard Time', - 'America/Edmonton': 'Mountain Standard Time', - 'America/Eirunepe': 'SA Pacific Standard Time', - 'America/El_Salvador': 'Central America Standard Time', - 'America/Fortaleza': 'SA Eastern Standard Time', - 'America/Glace_Bay': 'Atlantic Standard Time', - 'America/Godthab': 'Greenland Standard Time', - 'America/Goose_Bay': 'Atlantic Standard Time', - 'America/Grand_Turk': 'Eastern Standard Time', - 'America/Grenada': 'SA Western Standard Time', - 'America/Guadeloupe': 'SA Western Standard Time', - 'America/Guatemala': 'Central America Standard Time', - 'America/Guayaquil': 'SA Pacific Standard Time', - 'America/Guyana': 'SA Western Standard Time', - 'America/Halifax': 'Atlantic Standard Time', - 'America/Havana': 'Eastern Standard Time', - 'America/Hermosillo': 'US Mountain Standard Time', - 'America/Indiana/Knox': 'Central Standard Time', - 'America/Indiana/Marengo': 'US Eastern Standard Time', - 'America/Indiana/Petersburg': 'Eastern Standard Time', - 'America/Indiana/Tell_City': 'Central Standard Time', - 'America/Indiana/Vevay': 'US Eastern Standard Time', - 'America/Indiana/Vincennes': 'Eastern Standard Time', - 'America/Indiana/Winamac': 'Eastern Standard Time', - 'America/Indianapolis': 'US Eastern Standard Time', - 'America/Inuvik': 'Mountain Standard Time', - 'America/Iqaluit': 'Eastern Standard Time', - 'America/Jamaica': 'SA Pacific Standard Time', - 'America/Jujuy': 'Argentina Standard Time', - 'America/Juneau': 'Alaskan Standard Time', - 'America/Kentucky/Monticello': 'Eastern Standard Time', - 'America/Kralendijk': 'SA Western Standard Time', - 'America/La_Paz': 'SA Western Standard Time', - 'America/Lima': 'SA Pacific Standard Time', - 'America/Los_Angeles': 'Pacific Standard Time', - 'America/Louisville': 'Eastern Standard Time', - 'America/Lower_Princes': 'SA Western Standard Time', - 'America/Maceio': 'SA Eastern Standard Time', - 'America/Managua': 'Central America Standard Time', - 'America/Manaus': 'SA Western Standard Time', - 'America/Marigot': 'SA Western Standard Time', - 'America/Martinique': 'SA Western Standard Time', - 'America/Matamoros': 'Central Standard Time', - 'America/Mazatlan': 'Mountain Standard Time (Mexico)', - 'America/Mendoza': 'Argentina Standard Time', - 'America/Menominee': 'Central Standard Time', - 'America/Merida': 'Central Standard Time (Mexico)', - 'America/Mexico_City': 'Central Standard Time (Mexico)', - 'America/Moncton': 'Atlantic Standard Time', - 'America/Monterrey': 'Central Standard Time (Mexico)', - 'America/Montevideo': 'Montevideo Standard Time', - 'America/Montreal': 'Eastern Standard Time', - 'America/Montserrat': 'SA Western Standard Time', - 'America/Nassau': 'Eastern Standard Time', - 'America/New_York': 'Eastern Standard Time', - 'America/Nipigon': 'Eastern Standard Time', - 'America/Nome': 'Alaskan Standard Time', - 'America/Noronha': 'UTC-02', - 'America/North_Dakota/Beulah': 'Central Standard Time', - 'America/North_Dakota/Center': 'Central Standard Time', - 'America/North_Dakota/New_Salem': 'Central Standard Time', - 'America/Ojinaga': 'Mountain Standard Time', - 'America/Panama': 'SA Pacific Standard Time', - 'America/Pangnirtung': 'Eastern Standard Time', - 'America/Paramaribo': 'SA Eastern Standard Time', - 'America/Phoenix': 'US Mountain Standard Time', - 'America/Port-au-Prince': 'Eastern Standard Time', - 'America/Port_of_Spain': 'SA Western Standard Time', - 'America/Porto_Velho': 'SA Western Standard Time', - 'America/Puerto_Rico': 'SA Western Standard Time', - 'America/Rainy_River': 'Central Standard Time', - 'America/Rankin_Inlet': 'Central Standard Time', - 'America/Recife': 'SA Eastern Standard Time', - 'America/Regina': 'Canada Central Standard Time', - 'America/Resolute': 'Central Standard Time', - 'America/Rio_Branco': 'SA Pacific Standard Time', - 'America/Santa_Isabel': 'Pacific Standard Time (Mexico)', - 'America/Santarem': 'SA Eastern Standard Time', - 'America/Santiago': 'Pacific SA Standard Time', - 'America/Santo_Domingo': 'SA Western Standard Time', - 'America/Sao_Paulo': 'E. South America Standard Time', - 'America/Scoresbysund': 'Azores Standard Time', - 'America/Shiprock': 'Mountain Standard Time', - 'America/Sitka': 'Alaskan Standard Time', - 'America/St_Barthelemy': 'SA Western Standard Time', - 'America/St_Johns': 'Newfoundland Standard Time', - 'America/St_Kitts': 'SA Western Standard Time', - 'America/St_Lucia': 'SA Western Standard Time', - 'America/St_Thomas': 'SA Western Standard Time', - 'America/St_Vincent': 'SA Western Standard Time', - 'America/Swift_Current': 'Canada Central Standard Time', - 'America/Tegucigalpa': 'Central America Standard Time', - 'America/Thule': 'Atlantic Standard Time', - 'America/Thunder_Bay': 'Eastern Standard Time', - 'America/Tijuana': 'Pacific Standard Time', - 'America/Toronto': 'Eastern Standard Time', - 'America/Tortola': 'SA Western Standard Time', - 'America/Vancouver': 'Pacific Standard Time', - 'America/Whitehorse': 'Pacific Standard Time', - 'America/Winnipeg': 'Central Standard Time', - 'America/Yakutat': 'Alaskan Standard Time', - 'America/Yellowknife': 'Mountain Standard Time', - 'Antarctica/Casey': 'W. Australia Standard Time', - 'Antarctica/Davis': 'SE Asia Standard Time', - 'Antarctica/DumontDUrville': 'West Pacific Standard Time', - 'Antarctica/Macquarie': 'Central Pacific Standard Time', - 'Antarctica/Mawson': 'West Asia Standard Time', - 'Antarctica/McMurdo': 'New Zealand Standard Time', - 'Antarctica/Palmer': 'Pacific SA Standard Time', - 'Antarctica/Rothera': 'SA Eastern Standard Time', - 'Antarctica/South_Pole': 'New Zealand Standard Time', - 'Antarctica/Syowa': 'E. Africa Standard Time', - 'Antarctica/Vostok': 'Central Asia Standard Time', - 'Arctic/Longyearbyen': 'W. Europe Standard Time', - 'Asia/Aden': 'Arab Standard Time', - 'Asia/Almaty': 'Central Asia Standard Time', - 'Asia/Amman': 'Jordan Standard Time', - 'Asia/Anadyr': 'Magadan Standard Time', - 'Asia/Aqtau': 'West Asia Standard Time', - 'Asia/Aqtobe': 'West Asia Standard Time', - 'Asia/Ashgabat': 'West Asia Standard Time', - 'Asia/Baghdad': 'Arabic Standard Time', - 'Asia/Bahrain': 'Arab Standard Time', - 'Asia/Baku': 'Azerbaijan Standard Time', - 'Asia/Bangkok': 'SE Asia Standard Time', - 'Asia/Beirut': 'Middle East Standard Time', - 'Asia/Bishkek': 'Central Asia Standard Time', - 'Asia/Brunei': 'Singapore Standard Time', - 'Asia/Calcutta': 'India Standard Time', - 'Asia/Choibalsan': 'Ulaanbaatar Standard Time', - 'Asia/Chongqing': 'China Standard Time', - 'Asia/Colombo': 'Sri Lanka Standard Time', - 'Asia/Damascus': 'Syria Standard Time', - 'Asia/Dhaka': 'Bangladesh Standard Time', - 'Asia/Dili': 'Tokyo Standard Time', - 'Asia/Dubai': 'Arabian Standard Time', - 'Asia/Dushanbe': 'West Asia Standard Time', - 'Asia/Harbin': 'China Standard Time', - 'Asia/Hong_Kong': 'China Standard Time', - 'Asia/Hovd': 'SE Asia Standard Time', - 'Asia/Irkutsk': 'North Asia East Standard Time', - 'Asia/Jakarta': 'SE Asia Standard Time', - 'Asia/Jayapura': 'Tokyo Standard Time', - 'Asia/Jerusalem': 'Israel Standard Time', - 'Asia/Kabul': 'Afghanistan Standard Time', - 'Asia/Kamchatka': 'Magadan Standard Time', - 'Asia/Karachi': 'Pakistan Standard Time', - 'Asia/Kashgar': 'China Standard Time', - 'Asia/Katmandu': 'Nepal Standard Time', - 'Asia/Khandyga': 'Yakutsk Standard Time', - 'Asia/Krasnoyarsk': 'North Asia Standard Time', - 'Asia/Kuala_Lumpur': 'Singapore Standard Time', - 'Asia/Kuching': 'Singapore Standard Time', - 'Asia/Kuwait': 'Arab Standard Time', - 'Asia/Macau': 'China Standard Time', - 'Asia/Magadan': 'Magadan Standard Time', - 'Asia/Makassar': 'Singapore Standard Time', - 'Asia/Manila': 'Singapore Standard Time', - 'Asia/Muscat': 'Arabian Standard Time', - 'Asia/Nicosia': 'E. Europe Standard Time', - 'Asia/Novokuznetsk': 'N. Central Asia Standard Time', - 'Asia/Novosibirsk': 'N. Central Asia Standard Time', - 'Asia/Omsk': 'N. Central Asia Standard Time', - 'Asia/Oral': 'West Asia Standard Time', - 'Asia/Phnom_Penh': 'SE Asia Standard Time', - 'Asia/Pontianak': 'SE Asia Standard Time', - 'Asia/Pyongyang': 'Korea Standard Time', - 'Asia/Qatar': 'Arab Standard Time', - 'Asia/Qyzylorda': 'Central Asia Standard Time', - 'Asia/Rangoon': 'Myanmar Standard Time', - 'Asia/Riyadh': 'Arab Standard Time', - 'Asia/Saigon': 'SE Asia Standard Time', - 'Asia/Sakhalin': 'Vladivostok Standard Time', - 'Asia/Samarkand': 'West Asia Standard Time', - 'Asia/Seoul': 'Korea Standard Time', - 'Asia/Shanghai': 'China Standard Time', - 'Asia/Singapore': 'Singapore Standard Time', - 'Asia/Taipei': 'Taipei Standard Time', - 'Asia/Tashkent': 'West Asia Standard Time', - 'Asia/Tbilisi': 'Georgian Standard Time', - 'Asia/Tehran': 'Iran Standard Time', - 'Asia/Thimphu': 'Bangladesh Standard Time', - 'Asia/Tokyo': 'Tokyo Standard Time', - 'Asia/Ulaanbaatar': 'Ulaanbaatar Standard Time', - 'Asia/Urumqi': 'China Standard Time', - 'Asia/Ust-Nera': 'Vladivostok Standard Time', - 'Asia/Vientiane': 'SE Asia Standard Time', - 'Asia/Vladivostok': 'Vladivostok Standard Time', - 'Asia/Yakutsk': 'Yakutsk Standard Time', - 'Asia/Yekaterinburg': 'Ekaterinburg Standard Time', - 'Asia/Yerevan': 'Caucasus Standard Time', - 'Atlantic/Azores': 'Azores Standard Time', - 'Atlantic/Bermuda': 'Atlantic Standard Time', - 'Atlantic/Canary': 'GMT Standard Time', - 'Atlantic/Cape_Verde': 'Cape Verde Standard Time', - 'Atlantic/Faeroe': 'GMT Standard Time', - 'Atlantic/Madeira': 'GMT Standard Time', - 'Atlantic/Reykjavik': 'Greenwich Standard Time', - 'Atlantic/South_Georgia': 'UTC-02', - 'Atlantic/St_Helena': 'Greenwich Standard Time', - 'Atlantic/Stanley': 'SA Eastern Standard Time', - 'Australia/Adelaide': 'Cen. Australia Standard Time', - 'Australia/Brisbane': 'E. Australia Standard Time', - 'Australia/Broken_Hill': 'Cen. Australia Standard Time', - 'Australia/Currie': 'Tasmania Standard Time', - 'Australia/Darwin': 'AUS Central Standard Time', - 'Australia/Hobart': 'Tasmania Standard Time', - 'Australia/Lindeman': 'E. Australia Standard Time', - 'Australia/Melbourne': 'AUS Eastern Standard Time', - 'Australia/Perth': 'W. Australia Standard Time', - 'Australia/Sydney': 'AUS Eastern Standard Time', - 'CST6CDT': 'Central Standard Time', - 'EST5EDT': 'Eastern Standard Time', - 'Etc/GMT': 'UTC', - 'Etc/GMT+1': 'Cape Verde Standard Time', - 'Etc/GMT+10': 'Hawaiian Standard Time', - 'Etc/GMT+11': 'UTC-11', - 'Etc/GMT+12': 'Dateline Standard Time', - 'Etc/GMT+2': 'UTC-02', - 'Etc/GMT+3': 'SA Eastern Standard Time', - 'Etc/GMT+4': 'SA Western Standard Time', - 'Etc/GMT+5': 'SA Pacific Standard Time', - 'Etc/GMT+6': 'Central America Standard Time', - 'Etc/GMT+7': 'US Mountain Standard Time', - 'Etc/GMT-1': 'W. Central Africa Standard Time', - 'Etc/GMT-10': 'West Pacific Standard Time', - 'Etc/GMT-11': 'Central Pacific Standard Time', - 'Etc/GMT-12': 'UTC+12', - 'Etc/GMT-13': 'Tonga Standard Time', - 'Etc/GMT-2': 'South Africa Standard Time', - 'Etc/GMT-3': 'E. Africa Standard Time', - 'Etc/GMT-4': 'Arabian Standard Time', - 'Etc/GMT-5': 'West Asia Standard Time', - 'Etc/GMT-6': 'Central Asia Standard Time', - 'Etc/GMT-7': 'SE Asia Standard Time', - 'Etc/GMT-8': 'Singapore Standard Time', - 'Etc/GMT-9': 'Tokyo Standard Time', - 'Etc/UTC': 'UTC', - 'Europe/Amsterdam': 'W. Europe Standard Time', - 'Europe/Andorra': 'W. Europe Standard Time', - 'Europe/Athens': 'GTB Standard Time', - 'Europe/Belgrade': 'Central Europe Standard Time', - 'Europe/Berlin': 'W. Europe Standard Time', - 'Europe/Bratislava': 'Central Europe Standard Time', - 'Europe/Brussels': 'Romance Standard Time', - 'Europe/Bucharest': 'GTB Standard Time', - 'Europe/Budapest': 'Central Europe Standard Time', - 'Europe/Busingen': 'W. Europe Standard Time', - 'Europe/Chisinau': 'GTB Standard Time', - 'Europe/Copenhagen': 'Romance Standard Time', - 'Europe/Dublin': 'GMT Standard Time', - 'Europe/Gibraltar': 'W. Europe Standard Time', - 'Europe/Guernsey': 'GMT Standard Time', - 'Europe/Helsinki': 'FLE Standard Time', - 'Europe/Isle_of_Man': 'GMT Standard Time', - 'Europe/Istanbul': 'Turkey Standard Time', - 'Europe/Jersey': 'GMT Standard Time', - 'Europe/Kaliningrad': 'Kaliningrad Standard Time', - 'Europe/Kiev': 'FLE Standard Time', - 'Europe/Lisbon': 'GMT Standard Time', - 'Europe/Ljubljana': 'Central Europe Standard Time', - 'Europe/London': 'GMT Standard Time', - 'Europe/Luxembourg': 'W. Europe Standard Time', - 'Europe/Madrid': 'Romance Standard Time', - 'Europe/Malta': 'W. Europe Standard Time', - 'Europe/Mariehamn': 'FLE Standard Time', - 'Europe/Minsk': 'Kaliningrad Standard Time', - 'Europe/Monaco': 'W. Europe Standard Time', - 'Europe/Moscow': 'Russian Standard Time', - 'Europe/Oslo': 'W. Europe Standard Time', - 'Europe/Paris': 'Romance Standard Time', - 'Europe/Podgorica': 'Central Europe Standard Time', - 'Europe/Prague': 'Central Europe Standard Time', - 'Europe/Riga': 'FLE Standard Time', - 'Europe/Rome': 'W. Europe Standard Time', - 'Europe/Samara': 'Russian Standard Time', - 'Europe/San_Marino': 'W. Europe Standard Time', - 'Europe/Sarajevo': 'Central European Standard Time', - 'Europe/Simferopol': 'FLE Standard Time', - 'Europe/Skopje': 'Central European Standard Time', - 'Europe/Sofia': 'FLE Standard Time', - 'Europe/Stockholm': 'W. Europe Standard Time', - 'Europe/Tallinn': 'FLE Standard Time', - 'Europe/Tirane': 'Central Europe Standard Time', - 'Europe/Uzhgorod': 'FLE Standard Time', - 'Europe/Vaduz': 'W. Europe Standard Time', - 'Europe/Vatican': 'W. Europe Standard Time', - 'Europe/Vienna': 'W. Europe Standard Time', - 'Europe/Vilnius': 'FLE Standard Time', - 'Europe/Volgograd': 'Russian Standard Time', - 'Europe/Warsaw': 'Central European Standard Time', - 'Europe/Zagreb': 'Central European Standard Time', - 'Europe/Zaporozhye': 'FLE Standard Time', - 'Europe/Zurich': 'W. Europe Standard Time', - 'Indian/Antananarivo': 'E. Africa Standard Time', - 'Indian/Chagos': 'Central Asia Standard Time', - 'Indian/Christmas': 'SE Asia Standard Time', - 'Indian/Cocos': 'Myanmar Standard Time', - 'Indian/Comoro': 'E. Africa Standard Time', - 'Indian/Kerguelen': 'West Asia Standard Time', - 'Indian/Mahe': 'Mauritius Standard Time', - 'Indian/Maldives': 'West Asia Standard Time', - 'Indian/Mauritius': 'Mauritius Standard Time', - 'Indian/Mayotte': 'E. Africa Standard Time', - 'Indian/Reunion': 'Mauritius Standard Time', - 'MST7MDT': 'Mountain Standard Time', - 'PST8PDT': 'Pacific Standard Time', - 'Pacific/Apia': 'Samoa Standard Time', - 'Pacific/Auckland': 'New Zealand Standard Time', - 'Pacific/Efate': 'Central Pacific Standard Time', - 'Pacific/Enderbury': 'Tonga Standard Time', - 'Pacific/Fakaofo': 'Tonga Standard Time', - 'Pacific/Fiji': 'Fiji Standard Time', - 'Pacific/Funafuti': 'UTC+12', - 'Pacific/Galapagos': 'Central America Standard Time', - 'Pacific/Guadalcanal': 'Central Pacific Standard Time', - 'Pacific/Guam': 'West Pacific Standard Time', - 'Pacific/Honolulu': 'Hawaiian Standard Time', - 'Pacific/Johnston': 'Hawaiian Standard Time', - 'Pacific/Kosrae': 'Central Pacific Standard Time', - 'Pacific/Kwajalein': 'UTC+12', - 'Pacific/Majuro': 'UTC+12', - 'Pacific/Midway': 'UTC-11', - 'Pacific/Nauru': 'UTC+12', - 'Pacific/Niue': 'UTC-11', - 'Pacific/Noumea': 'Central Pacific Standard Time', - 'Pacific/Pago_Pago': 'UTC-11', - 'Pacific/Palau': 'Tokyo Standard Time', - 'Pacific/Ponape': 'Central Pacific Standard Time', - 'Pacific/Port_Moresby': 'West Pacific Standard Time', - 'Pacific/Rarotonga': 'Hawaiian Standard Time', - 'Pacific/Saipan': 'West Pacific Standard Time', - 'Pacific/Tahiti': 'Hawaiian Standard Time', - 'Pacific/Tarawa': 'UTC+12', - 'Pacific/Tongatapu': 'Tonga Standard Time', - 'Pacific/Truk': 'West Pacific Standard Time', - 'Pacific/Wake': 'UTC+12', - 'Pacific/Wallis': 'UTC+12' + "Africa/Abidjan": "Greenwich Standard Time", + "Africa/Accra": "Greenwich Standard Time", + "Africa/Addis_Ababa": "E. Africa Standard Time", + "Africa/Algiers": "W. Central Africa Standard Time", + "Africa/Asmera": "E. Africa Standard Time", + "Africa/Bamako": "Greenwich Standard Time", + "Africa/Bangui": "W. Central Africa Standard Time", + "Africa/Banjul": "Greenwich Standard Time", + "Africa/Bissau": "Greenwich Standard Time", + "Africa/Blantyre": "South Africa Standard Time", + "Africa/Brazzaville": "W. Central Africa Standard Time", + "Africa/Bujumbura": "South Africa Standard Time", + "Africa/Cairo": "Egypt Standard Time", + "Africa/Casablanca": "Morocco Standard Time", + "Africa/Ceuta": "Romance Standard Time", + "Africa/Conakry": "Greenwich Standard Time", + "Africa/Dakar": "Greenwich Standard Time", + "Africa/Dar_es_Salaam": "E. Africa Standard Time", + "Africa/Djibouti": "E. Africa Standard Time", + "Africa/Douala": "W. Central Africa Standard Time", + "Africa/El_Aaiun": "Morocco Standard Time", + "Africa/Freetown": "Greenwich Standard Time", + "Africa/Gaborone": "South Africa Standard Time", + "Africa/Harare": "South Africa Standard Time", + "Africa/Johannesburg": "South Africa Standard Time", + "Africa/Juba": "E. Africa Standard Time", + "Africa/Kampala": "E. Africa Standard Time", + "Africa/Khartoum": "Sudan Standard Time", + "Africa/Kigali": "South Africa Standard Time", + "Africa/Kinshasa": "W. Central Africa Standard Time", + "Africa/Lagos": "W. Central Africa Standard Time", + "Africa/Libreville": "W. Central Africa Standard Time", + "Africa/Lome": "Greenwich Standard Time", + "Africa/Luanda": "W. Central Africa Standard Time", + "Africa/Lubumbashi": "South Africa Standard Time", + "Africa/Lusaka": "South Africa Standard Time", + "Africa/Malabo": "W. Central Africa Standard Time", + "Africa/Maputo": "South Africa Standard Time", + "Africa/Maseru": "South Africa Standard Time", + "Africa/Mbabane": "South Africa Standard Time", + "Africa/Mogadishu": "E. Africa Standard Time", + "Africa/Monrovia": "Greenwich Standard Time", + "Africa/Nairobi": "E. Africa Standard Time", + "Africa/Ndjamena": "W. Central Africa Standard Time", + "Africa/Niamey": "W. Central Africa Standard Time", + "Africa/Nouakchott": "Greenwich Standard Time", + "Africa/Ouagadougou": "Greenwich Standard Time", + "Africa/Porto-Novo": "W. Central Africa Standard Time", + "Africa/Sao_Tome": "Sao Tome Standard Time", + "Africa/Timbuktu": "Greenwich Standard Time", + "Africa/Tripoli": "Libya Standard Time", + "Africa/Tunis": "W. Central Africa Standard Time", + "Africa/Windhoek": "Namibia Standard Time", + "America/Adak": "Aleutian Standard Time", + "America/Anchorage": "Alaskan Standard Time", + "America/Anguilla": "SA Western Standard Time", + "America/Antigua": "SA Western Standard Time", + "America/Araguaina": "Tocantins Standard Time", + "America/Argentina/La_Rioja": "Argentina Standard Time", + "America/Argentina/Rio_Gallegos": "Argentina Standard Time", + "America/Argentina/Salta": "Argentina Standard Time", + "America/Argentina/San_Juan": "Argentina Standard Time", + "America/Argentina/San_Luis": "Argentina Standard Time", + "America/Argentina/Tucuman": "Argentina Standard Time", + "America/Argentina/Ushuaia": "Argentina Standard Time", + "America/Aruba": "SA Western Standard Time", + "America/Asuncion": "Paraguay Standard Time", + "America/Atka": "Aleutian Standard Time", + "America/Bahia": "Bahia Standard Time", + "America/Bahia_Banderas": "Central Standard Time (Mexico)", + "America/Barbados": "SA Western Standard Time", + "America/Belem": "SA Eastern Standard Time", + "America/Belize": "Central America Standard Time", + "America/Blanc-Sablon": "SA Western Standard Time", + "America/Boa_Vista": "SA Western Standard Time", + "America/Bogota": "SA Pacific Standard Time", + "America/Boise": "Mountain Standard Time", + "America/Buenos_Aires": "Argentina Standard Time", + "America/Cambridge_Bay": "Mountain Standard Time", + "America/Campo_Grande": "Central Brazilian Standard Time", + "America/Cancun": "Eastern Standard Time (Mexico)", + "America/Caracas": "Venezuela Standard Time", + "America/Catamarca": "Argentina Standard Time", + "America/Cayenne": "SA Eastern Standard Time", + "America/Cayman": "SA Pacific Standard Time", + "America/Chicago": "Central Standard Time", + "America/Chihuahua": "Mountain Standard Time (Mexico)", + "America/Coral_Harbour": "SA Pacific Standard Time", + "America/Cordoba": "Argentina Standard Time", + "America/Costa_Rica": "Central America Standard Time", + "America/Creston": "US Mountain Standard Time", + "America/Cuiaba": "Central Brazilian Standard Time", + "America/Curacao": "SA Western Standard Time", + "America/Danmarkshavn": "UTC", + "America/Dawson": "Pacific Standard Time", + "America/Dawson_Creek": "US Mountain Standard Time", + "America/Denver": "Mountain Standard Time", + "America/Detroit": "Eastern Standard Time", + "America/Dominica": "SA Western Standard Time", + "America/Edmonton": "Mountain Standard Time", + "America/Eirunepe": "SA Pacific Standard Time", + "America/El_Salvador": "Central America Standard Time", + "America/Ensenada": "Pacific Standard Time (Mexico)", + "America/Fort_Nelson": "US Mountain Standard Time", + "America/Fortaleza": "SA Eastern Standard Time", + "America/Glace_Bay": "Atlantic Standard Time", + "America/Godthab": "Greenland Standard Time", + "America/Goose_Bay": "Atlantic Standard Time", + "America/Grand_Turk": "Turks And Caicos Standard Time", + "America/Grenada": "SA Western Standard Time", + "America/Guadeloupe": "SA Western Standard Time", + "America/Guatemala": "Central America Standard Time", + "America/Guayaquil": "SA Pacific Standard Time", + "America/Guyana": "SA Western Standard Time", + "America/Halifax": "Atlantic Standard Time", + "America/Havana": "Cuba Standard Time", + "America/Hermosillo": "US Mountain Standard Time", + "America/Indiana/Knox": "Central Standard Time", + "America/Indiana/Marengo": "US Eastern Standard Time", + "America/Indiana/Petersburg": "Eastern Standard Time", + "America/Indiana/Tell_City": "Central Standard Time", + "America/Indiana/Vevay": "US Eastern Standard Time", + "America/Indiana/Vincennes": "Eastern Standard Time", + "America/Indiana/Winamac": "Eastern Standard Time", + "America/Indianapolis": "US Eastern Standard Time", + "America/Inuvik": "Mountain Standard Time", + "America/Iqaluit": "Eastern Standard Time", + "America/Jamaica": "SA Pacific Standard Time", + "America/Jujuy": "Argentina Standard Time", + "America/Juneau": "Alaskan Standard Time", + "America/Kentucky/Monticello": "Eastern Standard Time", + "America/Knox_IN": "Central Standard Time", + "America/Kralendijk": "SA Western Standard Time", + "America/La_Paz": "SA Western Standard Time", + "America/Lima": "SA Pacific Standard Time", + "America/Los_Angeles": "Pacific Standard Time", + "America/Louisville": "Eastern Standard Time", + "America/Lower_Princes": "SA Western Standard Time", + "America/Maceio": "SA Eastern Standard Time", + "America/Managua": "Central America Standard Time", + "America/Manaus": "SA Western Standard Time", + "America/Marigot": "SA Western Standard Time", + "America/Martinique": "SA Western Standard Time", + "America/Matamoros": "Central Standard Time", + "America/Mazatlan": "Mountain Standard Time (Mexico)", + "America/Mendoza": "Argentina Standard Time", + "America/Menominee": "Central Standard Time", + "America/Merida": "Central Standard Time (Mexico)", + "America/Metlakatla": "Pacific Standard Time", + "America/Mexico_City": "Central Standard Time (Mexico)", + "America/Miquelon": "Saint Pierre Standard Time", + "America/Moncton": "Atlantic Standard Time", + "America/Monterrey": "Central Standard Time (Mexico)", + "America/Montevideo": "Montevideo Standard Time", + "America/Montreal": "Eastern Standard Time", + "America/Montserrat": "SA Western Standard Time", + "America/Nassau": "Eastern Standard Time", + "America/New_York": "Eastern Standard Time", + "America/Nipigon": "Eastern Standard Time", + "America/Nome": "Alaskan Standard Time", + "America/Noronha": "UTC-02", + "America/North_Dakota/Beulah": "Central Standard Time", + "America/North_Dakota/Center": "Central Standard Time", + "America/North_Dakota/New_Salem": "Central Standard Time", + "America/Ojinaga": "Mountain Standard Time", + "America/Panama": "SA Pacific Standard Time", + "America/Pangnirtung": "Eastern Standard Time", + "America/Paramaribo": "SA Eastern Standard Time", + "America/Phoenix": "US Mountain Standard Time", + "America/Port-au-Prince": "Haiti Standard Time", + "America/Port_of_Spain": "SA Western Standard Time", + "America/Porto_Acre": "SA Pacific Standard Time", + "America/Porto_Velho": "SA Western Standard Time", + "America/Puerto_Rico": "SA Western Standard Time", + "America/Punta_Arenas": "Magallanes Standard Time", + "America/Rainy_River": "Central Standard Time", + "America/Rankin_Inlet": "Central Standard Time", + "America/Recife": "SA Eastern Standard Time", + "America/Regina": "Canada Central Standard Time", + "America/Resolute": "Central Standard Time", + "America/Rio_Branco": "SA Pacific Standard Time", + "America/Santa_Isabel": "Pacific Standard Time (Mexico)", + "America/Santarem": "SA Eastern Standard Time", + "America/Santiago": "Pacific SA Standard Time", + "America/Santo_Domingo": "SA Western Standard Time", + "America/Sao_Paulo": "E. South America Standard Time", + "America/Scoresbysund": "Azores Standard Time", + "America/Shiprock": "Mountain Standard Time", + "America/Sitka": "Alaskan Standard Time", + "America/St_Barthelemy": "SA Western Standard Time", + "America/St_Johns": "Newfoundland Standard Time", + "America/St_Kitts": "SA Western Standard Time", + "America/St_Lucia": "SA Western Standard Time", + "America/St_Thomas": "SA Western Standard Time", + "America/St_Vincent": "SA Western Standard Time", + "America/Swift_Current": "Canada Central Standard Time", + "America/Tegucigalpa": "Central America Standard Time", + "America/Thule": "Atlantic Standard Time", + "America/Thunder_Bay": "Eastern Standard Time", + "America/Tijuana": "Pacific Standard Time (Mexico)", + "America/Toronto": "Eastern Standard Time", + "America/Tortola": "SA Western Standard Time", + "America/Vancouver": "Pacific Standard Time", + "America/Virgin": "SA Western Standard Time", + "America/Whitehorse": "Pacific Standard Time", + "America/Winnipeg": "Central Standard Time", + "America/Yakutat": "Alaskan Standard Time", + "America/Yellowknife": "Mountain Standard Time", + "Antarctica/Casey": "W. Australia Standard Time", + "Antarctica/Davis": "SE Asia Standard Time", + "Antarctica/DumontDUrville": "West Pacific Standard Time", + "Antarctica/Macquarie": "Central Pacific Standard Time", + "Antarctica/Mawson": "West Asia Standard Time", + "Antarctica/McMurdo": "New Zealand Standard Time", + "Antarctica/Palmer": "Magallanes Standard Time", + "Antarctica/Rothera": "SA Eastern Standard Time", + "Antarctica/South_Pole": "New Zealand Standard Time", + "Antarctica/Syowa": "E. Africa Standard Time", + "Antarctica/Vostok": "Central Asia Standard Time", + "Arctic/Longyearbyen": "W. Europe Standard Time", + "Asia/Aden": "Arab Standard Time", + "Asia/Almaty": "Central Asia Standard Time", + "Asia/Amman": "Jordan Standard Time", + "Asia/Anadyr": "Russia Time Zone 11", + "Asia/Aqtau": "West Asia Standard Time", + "Asia/Aqtobe": "West Asia Standard Time", + "Asia/Ashgabat": "West Asia Standard Time", + "Asia/Ashkhabad": "West Asia Standard Time", + "Asia/Atyrau": "West Asia Standard Time", + "Asia/Baghdad": "Arabic Standard Time", + "Asia/Bahrain": "Arab Standard Time", + "Asia/Baku": "Azerbaijan Standard Time", + "Asia/Bangkok": "SE Asia Standard Time", + "Asia/Barnaul": "Altai Standard Time", + "Asia/Beirut": "Middle East Standard Time", + "Asia/Bishkek": "Central Asia Standard Time", + "Asia/Brunei": "Singapore Standard Time", + "Asia/Calcutta": "India Standard Time", + "Asia/Chita": "Transbaikal Standard Time", + "Asia/Choibalsan": "Ulaanbaatar Standard Time", + "Asia/Chongqing": "China Standard Time", + "Asia/Chungking": "China Standard Time", + "Asia/Colombo": "Sri Lanka Standard Time", + "Asia/Dacca": "Bangladesh Standard Time", + "Asia/Damascus": "Syria Standard Time", + "Asia/Dhaka": "Bangladesh Standard Time", + "Asia/Dili": "Tokyo Standard Time", + "Asia/Dubai": "Arabian Standard Time", + "Asia/Dushanbe": "West Asia Standard Time", + "Asia/Famagusta": "GTB Standard Time", + "Asia/Gaza": "West Bank Standard Time", + "Asia/Harbin": "China Standard Time", + "Asia/Hebron": "West Bank Standard Time", + "Asia/Hong_Kong": "China Standard Time", + "Asia/Hovd": "W. Mongolia Standard Time", + "Asia/Irkutsk": "North Asia East Standard Time", + "Asia/Jakarta": "SE Asia Standard Time", + "Asia/Jayapura": "Tokyo Standard Time", + "Asia/Jerusalem": "Israel Standard Time", + "Asia/Kabul": "Afghanistan Standard Time", + "Asia/Kamchatka": "Russia Time Zone 11", + "Asia/Karachi": "Pakistan Standard Time", + "Asia/Kashgar": "Central Asia Standard Time", + "Asia/Katmandu": "Nepal Standard Time", + "Asia/Khandyga": "Yakutsk Standard Time", + "Asia/Krasnoyarsk": "North Asia Standard Time", + "Asia/Kuala_Lumpur": "Singapore Standard Time", + "Asia/Kuching": "Singapore Standard Time", + "Asia/Kuwait": "Arab Standard Time", + "Asia/Macao": "China Standard Time", + "Asia/Macau": "China Standard Time", + "Asia/Magadan": "Magadan Standard Time", + "Asia/Makassar": "Singapore Standard Time", + "Asia/Manila": "Singapore Standard Time", + "Asia/Muscat": "Arabian Standard Time", + "Asia/Nicosia": "GTB Standard Time", + "Asia/Novokuznetsk": "North Asia Standard Time", + "Asia/Novosibirsk": "N. Central Asia Standard Time", + "Asia/Omsk": "Omsk Standard Time", + "Asia/Oral": "West Asia Standard Time", + "Asia/Phnom_Penh": "SE Asia Standard Time", + "Asia/Pontianak": "SE Asia Standard Time", + "Asia/Pyongyang": "North Korea Standard Time", + "Asia/Qatar": "Arab Standard Time", + "Asia/Qostanay": "Central Asia Standard Time", + "Asia/Qyzylorda": "West Asia Standard Time", + "Asia/Rangoon": "Myanmar Standard Time", + "Asia/Riyadh": "Arab Standard Time", + "Asia/Saigon": "SE Asia Standard Time", + "Asia/Sakhalin": "Sakhalin Standard Time", + "Asia/Samarkand": "West Asia Standard Time", + "Asia/Seoul": "Korea Standard Time", + "Asia/Shanghai": "China Standard Time", + "Asia/Singapore": "Singapore Standard Time", + "Asia/Srednekolymsk": "Russia Time Zone 10", + "Asia/Taipei": "Taipei Standard Time", + "Asia/Tashkent": "West Asia Standard Time", + "Asia/Tbilisi": "Georgian Standard Time", + "Asia/Tehran": "Iran Standard Time", + "Asia/Tel_Aviv": "Israel Standard Time", + "Asia/Thimbu": "Bangladesh Standard Time", + "Asia/Thimphu": "Bangladesh Standard Time", + "Asia/Tokyo": "Tokyo Standard Time", + "Asia/Tomsk": "Tomsk Standard Time", + "Asia/Ujung_Pandang": "Singapore Standard Time", + "Asia/Ulaanbaatar": "Ulaanbaatar Standard Time", + "Asia/Ulan_Bator": "Ulaanbaatar Standard Time", + "Asia/Urumqi": "Central Asia Standard Time", + "Asia/Ust-Nera": "Vladivostok Standard Time", + "Asia/Vientiane": "SE Asia Standard Time", + "Asia/Vladivostok": "Vladivostok Standard Time", + "Asia/Yakutsk": "Yakutsk Standard Time", + "Asia/Yekaterinburg": "Ekaterinburg Standard Time", + "Asia/Yerevan": "Caucasus Standard Time", + "Atlantic/Azores": "Azores Standard Time", + "Atlantic/Bermuda": "Atlantic Standard Time", + "Atlantic/Canary": "GMT Standard Time", + "Atlantic/Cape_Verde": "Cape Verde Standard Time", + "Atlantic/Faeroe": "GMT Standard Time", + "Atlantic/Jan_Mayen": "W. Europe Standard Time", + "Atlantic/Madeira": "GMT Standard Time", + "Atlantic/Reykjavik": "Greenwich Standard Time", + "Atlantic/South_Georgia": "UTC-02", + "Atlantic/St_Helena": "Greenwich Standard Time", + "Atlantic/Stanley": "SA Eastern Standard Time", + "Australia/ACT": "AUS Eastern Standard Time", + "Australia/Adelaide": "Cen. Australia Standard Time", + "Australia/Brisbane": "E. Australia Standard Time", + "Australia/Broken_Hill": "Cen. Australia Standard Time", + "Australia/Canberra": "AUS Eastern Standard Time", + "Australia/Currie": "Tasmania Standard Time", + "Australia/Darwin": "AUS Central Standard Time", + "Australia/Eucla": "Aus Central W. Standard Time", + "Australia/Hobart": "Tasmania Standard Time", + "Australia/LHI": "Lord Howe Standard Time", + "Australia/Lindeman": "E. Australia Standard Time", + "Australia/Lord_Howe": "Lord Howe Standard Time", + "Australia/Melbourne": "AUS Eastern Standard Time", + "Australia/NSW": "AUS Eastern Standard Time", + "Australia/North": "AUS Central Standard Time", + "Australia/Perth": "W. Australia Standard Time", + "Australia/Queensland": "E. Australia Standard Time", + "Australia/South": "Cen. Australia Standard Time", + "Australia/Sydney": "AUS Eastern Standard Time", + "Australia/Tasmania": "Tasmania Standard Time", + "Australia/Victoria": "AUS Eastern Standard Time", + "Australia/West": "W. Australia Standard Time", + "Australia/Yancowinna": "Cen. Australia Standard Time", + "Brazil/Acre": "SA Pacific Standard Time", + "Brazil/DeNoronha": "UTC-02", + "Brazil/East": "E. South America Standard Time", + "Brazil/West": "SA Western Standard Time", + "CST6CDT": "Central Standard Time", + "Canada/Atlantic": "Atlantic Standard Time", + "Canada/Central": "Central Standard Time", + "Canada/Eastern": "Eastern Standard Time", + "Canada/Mountain": "Mountain Standard Time", + "Canada/Newfoundland": "Newfoundland Standard Time", + "Canada/Pacific": "Pacific Standard Time", + "Canada/Saskatchewan": "Canada Central Standard Time", + "Canada/Yukon": "Pacific Standard Time", + "Chile/Continental": "Pacific SA Standard Time", + "Chile/EasterIsland": "Easter Island Standard Time", + "Cuba": "Cuba Standard Time", + "EST5EDT": "Eastern Standard Time", + "Egypt": "Egypt Standard Time", + "Eire": "GMT Standard Time", + "Etc/GMT": "UTC", + "Etc/GMT+1": "Cape Verde Standard Time", + "Etc/GMT+10": "Hawaiian Standard Time", + "Etc/GMT+11": "UTC-11", + "Etc/GMT+12": "Dateline Standard Time", + "Etc/GMT+2": "UTC-02", + "Etc/GMT+3": "SA Eastern Standard Time", + "Etc/GMT+4": "SA Western Standard Time", + "Etc/GMT+5": "SA Pacific Standard Time", + "Etc/GMT+6": "Central America Standard Time", + "Etc/GMT+7": "US Mountain Standard Time", + "Etc/GMT+8": "UTC-08", + "Etc/GMT+9": "UTC-09", + "Etc/GMT-1": "W. Central Africa Standard Time", + "Etc/GMT-10": "West Pacific Standard Time", + "Etc/GMT-11": "Central Pacific Standard Time", + "Etc/GMT-12": "UTC+12", + "Etc/GMT-13": "UTC+13", + "Etc/GMT-14": "Line Islands Standard Time", + "Etc/GMT-2": "South Africa Standard Time", + "Etc/GMT-3": "E. Africa Standard Time", + "Etc/GMT-4": "Arabian Standard Time", + "Etc/GMT-5": "West Asia Standard Time", + "Etc/GMT-6": "Central Asia Standard Time", + "Etc/GMT-7": "SE Asia Standard Time", + "Etc/GMT-8": "Singapore Standard Time", + "Etc/GMT-9": "Tokyo Standard Time", + "Etc/UCT": "UTC", + "Etc/UTC": "UTC", + "Europe/Amsterdam": "W. Europe Standard Time", + "Europe/Andorra": "W. Europe Standard Time", + "Europe/Astrakhan": "Astrakhan Standard Time", + "Europe/Athens": "GTB Standard Time", + "Europe/Belfast": "GMT Standard Time", + "Europe/Belgrade": "Central Europe Standard Time", + "Europe/Berlin": "W. Europe Standard Time", + "Europe/Bratislava": "Central Europe Standard Time", + "Europe/Brussels": "Romance Standard Time", + "Europe/Bucharest": "GTB Standard Time", + "Europe/Budapest": "Central Europe Standard Time", + "Europe/Busingen": "W. Europe Standard Time", + "Europe/Chisinau": "E. Europe Standard Time", + "Europe/Copenhagen": "Romance Standard Time", + "Europe/Dublin": "GMT Standard Time", + "Europe/Gibraltar": "W. Europe Standard Time", + "Europe/Guernsey": "GMT Standard Time", + "Europe/Helsinki": "FLE Standard Time", + "Europe/Isle_of_Man": "GMT Standard Time", + "Europe/Istanbul": "Turkey Standard Time", + "Europe/Jersey": "GMT Standard Time", + "Europe/Kaliningrad": "Kaliningrad Standard Time", + "Europe/Kiev": "FLE Standard Time", + "Europe/Kirov": "Russian Standard Time", + "Europe/Lisbon": "GMT Standard Time", + "Europe/Ljubljana": "Central Europe Standard Time", + "Europe/London": "GMT Standard Time", + "Europe/Luxembourg": "W. Europe Standard Time", + "Europe/Madrid": "Romance Standard Time", + "Europe/Malta": "W. Europe Standard Time", + "Europe/Mariehamn": "FLE Standard Time", + "Europe/Minsk": "Belarus Standard Time", + "Europe/Monaco": "W. Europe Standard Time", + "Europe/Moscow": "Russian Standard Time", + "Europe/Oslo": "W. Europe Standard Time", + "Europe/Paris": "Romance Standard Time", + "Europe/Podgorica": "Central Europe Standard Time", + "Europe/Prague": "Central Europe Standard Time", + "Europe/Riga": "FLE Standard Time", + "Europe/Rome": "W. Europe Standard Time", + "Europe/Samara": "Russia Time Zone 3", + "Europe/San_Marino": "W. Europe Standard Time", + "Europe/Sarajevo": "Central European Standard Time", + "Europe/Saratov": "Saratov Standard Time", + "Europe/Simferopol": "Russian Standard Time", + "Europe/Skopje": "Central European Standard Time", + "Europe/Sofia": "FLE Standard Time", + "Europe/Stockholm": "W. Europe Standard Time", + "Europe/Tallinn": "FLE Standard Time", + "Europe/Tirane": "Central Europe Standard Time", + "Europe/Tiraspol": "E. Europe Standard Time", + "Europe/Ulyanovsk": "Astrakhan Standard Time", + "Europe/Uzhgorod": "FLE Standard Time", + "Europe/Vaduz": "W. Europe Standard Time", + "Europe/Vatican": "W. Europe Standard Time", + "Europe/Vienna": "W. Europe Standard Time", + "Europe/Vilnius": "FLE Standard Time", + "Europe/Volgograd": "Russian Standard Time", + "Europe/Warsaw": "Central European Standard Time", + "Europe/Zagreb": "Central European Standard Time", + "Europe/Zaporozhye": "FLE Standard Time", + "Europe/Zurich": "W. Europe Standard Time", + "GB": "GMT Standard Time", + "GB-Eire": "GMT Standard Time", + "GMT+0": "UTC", + "GMT-0": "UTC", + "GMT0": "UTC", + "Greenwich": "UTC", + "Hongkong": "China Standard Time", + "Iceland": "Greenwich Standard Time", + "Indian/Antananarivo": "E. Africa Standard Time", + "Indian/Chagos": "Central Asia Standard Time", + "Indian/Christmas": "SE Asia Standard Time", + "Indian/Cocos": "Myanmar Standard Time", + "Indian/Comoro": "E. Africa Standard Time", + "Indian/Kerguelen": "West Asia Standard Time", + "Indian/Mahe": "Mauritius Standard Time", + "Indian/Maldives": "West Asia Standard Time", + "Indian/Mauritius": "Mauritius Standard Time", + "Indian/Mayotte": "E. Africa Standard Time", + "Indian/Reunion": "Mauritius Standard Time", + "Iran": "Iran Standard Time", + "Israel": "Israel Standard Time", + "Jamaica": "SA Pacific Standard Time", + "Japan": "Tokyo Standard Time", + "Kwajalein": "UTC+12", + "Libya": "Libya Standard Time", + "MST7MDT": "Mountain Standard Time", + "Mexico/BajaNorte": "Pacific Standard Time (Mexico)", + "Mexico/BajaSur": "Mountain Standard Time (Mexico)", + "Mexico/General": "Central Standard Time (Mexico)", + "NZ": "New Zealand Standard Time", + "NZ-CHAT": "Chatham Islands Standard Time", + "Navajo": "Mountain Standard Time", + "PRC": "China Standard Time", + "PST8PDT": "Pacific Standard Time", + "Pacific/Apia": "Samoa Standard Time", + "Pacific/Auckland": "New Zealand Standard Time", + "Pacific/Bougainville": "Bougainville Standard Time", + "Pacific/Chatham": "Chatham Islands Standard Time", + "Pacific/Easter": "Easter Island Standard Time", + "Pacific/Efate": "Central Pacific Standard Time", + "Pacific/Enderbury": "UTC+13", + "Pacific/Fakaofo": "UTC+13", + "Pacific/Fiji": "Fiji Standard Time", + "Pacific/Funafuti": "UTC+12", + "Pacific/Galapagos": "Central America Standard Time", + "Pacific/Gambier": "UTC-09", + "Pacific/Guadalcanal": "Central Pacific Standard Time", + "Pacific/Guam": "West Pacific Standard Time", + "Pacific/Honolulu": "Hawaiian Standard Time", + "Pacific/Johnston": "Hawaiian Standard Time", + "Pacific/Kiritimati": "Line Islands Standard Time", + "Pacific/Kosrae": "Central Pacific Standard Time", + "Pacific/Kwajalein": "UTC+12", + "Pacific/Majuro": "UTC+12", + "Pacific/Marquesas": "Marquesas Standard Time", + "Pacific/Midway": "UTC-11", + "Pacific/Nauru": "UTC+12", + "Pacific/Niue": "UTC-11", + "Pacific/Norfolk": "Norfolk Standard Time", + "Pacific/Noumea": "Central Pacific Standard Time", + "Pacific/Pago_Pago": "UTC-11", + "Pacific/Palau": "Tokyo Standard Time", + "Pacific/Pitcairn": "UTC-08", + "Pacific/Ponape": "Central Pacific Standard Time", + "Pacific/Port_Moresby": "West Pacific Standard Time", + "Pacific/Rarotonga": "Hawaiian Standard Time", + "Pacific/Saipan": "West Pacific Standard Time", + "Pacific/Samoa": "UTC-11", + "Pacific/Tahiti": "Hawaiian Standard Time", + "Pacific/Tarawa": "UTC+12", + "Pacific/Tongatapu": "Tonga Standard Time", + "Pacific/Truk": "West Pacific Standard Time", + "Pacific/Wake": "UTC+12", + "Pacific/Wallis": "UTC+12", + "Poland": "Central European Standard Time", + "Portugal": "GMT Standard Time", + "ROC": "Taipei Standard Time", + "ROK": "Korea Standard Time", + "Singapore": "Singapore Standard Time", + "Turkey": "Turkey Standard Time", + "UCT": "UTC", + "US/Alaska": "Alaskan Standard Time", + "US/Aleutian": "Aleutian Standard Time", + "US/Arizona": "US Mountain Standard Time", + "US/Central": "Central Standard Time", + "US/Eastern": "Eastern Standard Time", + "US/Hawaii": "Hawaiian Standard Time", + "US/Indiana-Starke": "Central Standard Time", + "US/Michigan": "Eastern Standard Time", + "US/Mountain": "Mountain Standard Time", + "US/Pacific": "Pacific Standard Time", + "US/Samoa": "UTC-11", + "UTC": "UTC", + "Universal": "UTC", + "W-SU": "Russian Standard Time", + "Zulu": "UTC", } diff --git a/nikola/plugin_categories.py b/nikola/plugin_categories.py index ecbbb82..f6c1def 100644 --- a/nikola/plugin_categories.py +++ b/nikola/plugin_categories.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,15 +26,21 @@ """Nikola plugin categories.""" -from __future__ import absolute_import -import sys +import io +import logging import os -import re +import sys +import typing -from yapsy.IPlugin import IPlugin +import doit from doit.cmd_base import Command as DoitCommand +from yapsy.IPlugin import IPlugin + +from .utils import LOGGER, first_line, get_logger, req_missing -from .utils import LOGGER, first_line +if typing.TYPE_CHECKING: + import nikola + import nikola.post __all__ = ( 'Command', @@ -42,23 +48,29 @@ __all__ = ( 'PageCompiler', 'RestExtension', 'MarkdownExtension', + 'MetadataExtractor', 'Task', 'TaskMultiplier', 'TemplateSystem', 'SignalHandler', 'ConfigPlugin', 'PostScanner', + 'Taxonomy', ) class BasePlugin(IPlugin): - """Base plugin class.""" + logger = None + def set_site(self, site): """Set site, which is a Nikola instance.""" self.site = site self.inject_templates() + self.logger = get_logger(self.name) + if not site.debug: + self.logger.level = logging.INFO def inject_templates(self): """Inject 'templates/<engine>' (if exists) very early in the theme chain.""" @@ -82,18 +94,24 @@ class BasePlugin(IPlugin): """Add 'dependency' to the target task's task_deps.""" self.site.injected_deps[target].append(dependency) + def get_deps(self, filename): + """Find the dependencies for a file.""" + return [] -class PostScanner(BasePlugin): +class PostScanner(BasePlugin): """The scan method of these plugins is called by Nikola.scan_posts.""" - def scan(self): + def scan(self) -> 'typing.List[nikola.post.Post]': """Create a list of posts from some source. Returns a list of Post objects.""" raise NotImplementedError() + def supported_extensions(self) -> 'typing.Optional[typing.List]': + """Return a list of supported file extensions, or None if such a list isn't known beforehand.""" + return None -class Command(BasePlugin, DoitCommand): +class Command(BasePlugin, DoitCommand): """Doit command implementation.""" name = "dummy_command" @@ -116,7 +134,7 @@ class Command(BasePlugin, DoitCommand): DoitCommand.__init__(self, config, **kwargs) return self - def execute(self, options=None, args=None): + def execute(self, options=None, args=None) -> int: """Check if the command can run in the current environment, fail if needed, or call _execute.""" options = options or {} args = args or [] @@ -126,7 +144,7 @@ class Command(BasePlugin, DoitCommand): return False return self._execute(options, args) - def _execute(self, options, args): + def _execute(self, options, args) -> int: """Do whatever this command does. @param options (dict) with values from cmd_options @@ -152,11 +170,13 @@ def help(self): text.append(self.doc_description) return "\n".join(text) -DoitCommand.help = help +# we need to patch DoitCommand.help with doit <0.31.0 +if doit.__version__ < (0, 31, 0): + DoitCommand.help = help -class BaseTask(BasePlugin): +class BaseTask(BasePlugin): """Base for task generators.""" name = "dummy_task" @@ -165,11 +185,11 @@ class BaseTask(BasePlugin): # the others have to be specifie in the command line. is_default = True - def gen_tasks(self): + def gen_tasks(self) -> 'typing.List[dict]': """Generate tasks.""" raise NotImplementedError() - def group_task(self): + def group_task(self) -> dict: """Return dict for group task.""" return { 'basename': self.name, @@ -179,34 +199,39 @@ class BaseTask(BasePlugin): class Task(BaseTask): - """Task generator.""" name = "dummy_task" class LateTask(BaseTask): - """Late task generator (plugin executed after all Task plugins).""" name = "dummy_latetask" class TemplateSystem(BasePlugin): - """Provide support for templating systems.""" name = "dummy_templates" - def set_directories(self, directories, cache_folder): + def set_directories(self, directories: 'typing.List[str]', cache_folder: str): """Set the list of folders where templates are located and cache.""" raise NotImplementedError() - def template_deps(self, template_name): + def template_deps(self, template_name: str): """Return filenames which are dependencies for a template.""" raise NotImplementedError() - def render_template(self, template_name, output_name, context): + def get_deps(self, filename: str): + """Return paths to dependencies for the template loaded from filename.""" + raise NotImplementedError() + + def get_string_deps(self, text: str): + """Find dependencies for a template string.""" + raise NotImplementedError() + + def render_template(self, template_name: str, output_name: str, context: 'typing.Dict[str, str]'): """Render template to a file using context. This must save the data to output_name *and* return it @@ -214,34 +239,39 @@ class TemplateSystem(BasePlugin): """ raise NotImplementedError() - def render_template_to_string(self, template, context): + def render_template_to_string(self, template: str, context: 'typing.Dict[str, str]') -> str: """Render template to a string using context.""" raise NotImplementedError() - def inject_directory(self, directory): + def inject_directory(self, directory: str): """Inject the directory with the lowest priority in the template search mechanism.""" raise NotImplementedError() + def get_template_path(self, template_name: str) -> str: + """Get the path to a template or return None.""" + raise NotImplementedError() -class TaskMultiplier(BasePlugin): +class TaskMultiplier(BasePlugin): """Take a task and return *more* tasks.""" name = "dummy multiplier" - def process(self, task): + def process(self, task) -> list: """Examine task and create more tasks. Returns extra tasks only.""" return [] class PageCompiler(BasePlugin): - """Compile text files into HTML.""" name = "dummy_compiler" friendly_name = '' demote_headers = False supports_onefile = True + use_dep_file = True # If set to false, the .dep file is never written and not automatically added as a target + supports_metadata = False + metadata_conditions = [] default_metadata = { 'title': '', 'slug': '', @@ -254,43 +284,75 @@ class PageCompiler(BasePlugin): } config_dependencies = [] - def register_extra_dependencies(self, post): - """Add additional dependencies to the post object. + def get_dep_filename(self, post: 'nikola.post.Post', lang: str) -> str: + """Return the .dep file's name for the given post and language.""" + return post.translated_base_path(lang) + '.dep' + + def _read_extra_deps(self, post: 'nikola.post.Post', lang: str) -> 'typing.List[str]': + """Read contents of .dep file and return them as a list.""" + dep_path = self.get_dep_filename(post, lang) + if os.path.isfile(dep_path): + with io.open(dep_path, 'r+', encoding='utf-8-sig') as depf: + deps = [l.strip() for l in depf.readlines()] + return deps + return [] - Current main use is the ReST page compiler, which puts extra - dependencies into a .dep file. - """ - pass + def register_extra_dependencies(self, post: 'nikola.post.Post'): + """Add dependency to post object to check .dep file.""" + def create_lambda(lang: str) -> 'typing.Callable': + # We create a lambda like this so we can pass `lang` to it, because if we didn’t + # add that function, `lang` would always be the last language in TRANSLATIONS. + # (See http://docs.python-guide.org/en/latest/writing/gotchas/#late-binding-closures) + return lambda: self._read_extra_deps(post, lang) + + for lang in self.site.config['TRANSLATIONS']: + post.add_dependency(create_lambda(lang), 'fragment', lang=lang) + + def get_extra_targets(self, post: 'nikola.post.Post', lang: str, dest: str) -> 'typing.List[str]': + """Return a list of extra targets for the render_posts task when compiling the post for the specified language.""" + if self.use_dep_file: + return [self.get_dep_filename(post, lang)] + else: + return [] + + def compile(self, source: str, dest: str, is_two_file=True, post=None, lang=None): + """Compile the source file into HTML and save as dest.""" + raise NotImplementedError() - def compile_html(self, source, dest, is_two_file=False): - """Compile the source, save it on dest.""" + def compile_string(self, data: str, source_path=None, is_two_file=True, post=None, lang=None) -> str: + """Compile the source file into HTML strings (with shortcode support). + + Returns a tuple of at least two elements: HTML string [0] and shortcode dependencies [last]. + """ + # This function used to have some different APIs in different places. raise NotImplementedError() - def create_post(self, path, content=None, onefile=False, is_page=False, **kw): + def create_post(self, path: str, content=None, onefile=False, is_page=False, **kw): """Create post file with optional metadata.""" raise NotImplementedError() - def extension(self): - """The preferred extension for the output of this compiler.""" + def extension(self) -> str: + """Return the preferred extension for the output of this compiler.""" return ".html" - def read_metadata(self, post, file_metadata_regexp=None, unslugify_titles=False, lang=None): + def read_metadata(self, post: 'nikola.post.Post', lang=None) -> 'typing.Dict[str, str]': """Read the metadata from a post, and return a metadata dict.""" return {} - def split_metadata(self, data): - """Split data from metadata in the raw post content. + def split_metadata(self, data: str, post=None, lang=None) -> (str, str): + """Split data from metadata in the raw post content.""" + if lang and post: + extractor = post.used_extractor[lang] + else: + import nikola.metadata_extractors + extractor = nikola.metadata_extractors.DEFAULT_EXTRACTOR - This splits in the first empty line that is NOT at the beginning - of the document. - """ - split_result = re.split('(\n\n|\r\n\r\n)', data.lstrip(), maxsplit=1) - if len(split_result) == 1: - return '', split_result[0] - # ['metadata', '\n\n', 'post content'] - return split_result[0], split_result[-1] + if isinstance(extractor, MetadataExtractor): + return extractor.split_metadata_from_text(data) + # Ouch! + return data, data - def get_compiler_extensions(self): + def get_compiler_extensions(self) -> list: """Activate all the compiler extension plugins for a given compiler and return them.""" plugins = [] for plugin_info in self.site.compiler_extensions: @@ -300,7 +362,6 @@ class PageCompiler(BasePlugin): class CompilerExtension(BasePlugin): - """An extension for a Nikola compiler. If you intend to implement those in your own compiler, you can: @@ -319,7 +380,6 @@ class CompilerExtension(BasePlugin): class RestExtension(CompilerExtension): - """Extensions for reStructuredText.""" name = "dummy_rest_extension" @@ -327,29 +387,104 @@ class RestExtension(CompilerExtension): class MarkdownExtension(CompilerExtension): - """Extensions for Markdown.""" name = "dummy_markdown_extension" compiler_name = "markdown" -class SignalHandler(BasePlugin): +class MetadataExtractor(BasePlugin): + """Plugins that can extract meta information from post files.""" + + # Name of the extractor. (required) + name = "unknown" + # Where to get metadata from. (MetaSource; required) + source = None + # Priority of extractor. (MetaPriority; required) + priority = None + # List of tuples (MetaCondition, arg) with conditions used to select this extractor. + conditions = [] + # Regular expression used for splitting metadata, or None if not applicable. + split_metadata_re = None + # List of tuples (import name, pip name, friendly name) of Python packages required for this extractor. + requirements = [] + # Name of METADATA_MAPPING to use, if any. + map_from = None + # Whether or not the extractor supports writing metadata. + supports_write = False + + def _extract_metadata_from_text(self, source_text: str) -> 'typing.Dict[str, str]': + """Extract metadata from text.""" + raise NotImplementedError() + + def split_metadata_from_text(self, source_text: str) -> (str, str): + """Split text into metadata and content (both strings).""" + if self.split_metadata_re is None: + return source_text + else: + split_result = self.split_metadata_re.split(source_text.lstrip(), maxsplit=1) + if len(split_result) == 1: + return split_result[0], split_result[0] + else: + # Necessary? + return split_result[0], split_result[-1] + + def extract_text(self, source_text: str) -> 'typing.Dict[str, str]': + """Split file, return metadata and the content.""" + split = self.split_metadata_from_text(source_text) + if not split: + return {} + meta = self._extract_metadata_from_text(split[0]) + return meta + + def extract_filename(self, filename: str, lang: str) -> 'typing.Dict[str, str]': + """Extract metadata from filename.""" + return {} + + def write_metadata(self, metadata: 'typing.Dict[str, str]', comment_wrap=False) -> str: + """Write metadata in this extractor’s format. + + ``comment_wrap`` is either True, False, or a 2-tuple of comments to use for wrapping, if necessary. + If it’s set to True, defaulting to ``('<!--', '-->')`` is recommended. + This function should insert comment markers (if applicable) and must insert trailing newlines. + """ + raise NotImplementedError() + + def check_requirements(self): + """Check if requirements for an extractor are satisfied.""" + for import_name, pip_name, friendly_name in self.requirements: + try: + __import__(import_name) + except ImportError: + req_missing([pip_name], "use {0} metadata".format(friendly_name), python=True, optional=False) + + +class SignalHandler(BasePlugin): """Signal handlers.""" name = "dummy_signal_handler" class ConfigPlugin(BasePlugin): - """A plugin that can edit config (or modify the site) on-the-fly.""" name = "dummy_config_plugin" -class Importer(Command): +class ShortcodePlugin(BasePlugin): + """A plugin that adds a shortcode.""" + name = "dummy_shortcode_plugin" + + def set_site(self, site): + """Set Nikola site.""" + self.site = site + site.register_shortcode(self.name, self.handler) + return super().set_site(site) + + +class Importer(Command): """Basic structure for importing data into Nikola. The flow is: @@ -380,7 +515,7 @@ class Importer(Command): """Import the data into Nikola.""" raise NotImplementedError() - def generate_base_site(self, path): + def generate_base_site(self, path: str): """Create the base site.""" raise NotImplementedError() @@ -412,7 +547,7 @@ class Importer(Command): """Go through self.items and save them.""" def import_story(self): - """Create a story.""" + """Create a page.""" raise NotImplementedError() def import_post(self): @@ -430,3 +565,333 @@ class Importer(Command): def save_post(self): """Save a post to disk.""" raise NotImplementedError() + + +class Taxonomy(BasePlugin): + """Taxonomy for posts. + + A taxonomy plugin allows to classify posts (see #2107) by + classification strings. Classification plugins must adjust + a set of options to determine certain aspects. + + The following options are class attributes with their default + values. These variables should be set in the class definition, + in the constructor or latest in the `set_site` function. + + classification_name = "taxonomy": + The classification name to be used for path handlers. + Must be overridden! + + overview_page_items_variable_name = "items": + When rendering the overview page, its template will have a list + of pairs + (friendly_name, link) + for the classifications available in a variable by this name. + + The template will also have a list + (friendly_name, link, post_count) + for the classifications available in a variable by the name + `overview_page_items_variable_name + '_with_postcount'`. + + overview_page_variable_name = "taxonomy": + When rendering the overview page, its template will have a list + of classifications available in a variable by this name. + + overview_page_hierarchy_variable_name = "taxonomy_hierarchy": + When rendering the overview page, its template will have a list + of tuples + (friendly_name, classification, classification_path, link, + indent_levels, indent_change_before, indent_change_after) + available in a variable by this name. These tuples can be used + to render the hierarchy as a tree. + + The template will also have a list + (friendly_name, classification, classification_path, link, + indent_levels, indent_change_before, indent_change_after, + number_of_children, post_count) + available in the variable by the name + `overview_page_hierarchy_variable_name + '_with_postcount'`. + + more_than_one_classifications_per_post = False: + If True, there can be more than one classification per post; in that case, + the classification data in the metadata is stored as a list. If False, + the classification data in the metadata is stored as a string, or None + when no classification is given. + + has_hierarchy = False: + Whether the classification has a hierarchy. + + include_posts_from_subhierarchies = False: + If True, the post list for a classification includes all posts with a + sub-classification (in case has_hierarchy is True). + + include_posts_into_hierarchy_root = False: + If True, include_posts_from_subhierarchies == True will also insert + posts into the post list for the empty hierarchy []. + + show_list_as_subcategories_list = False: + If True, for every classification which has at least one + subclassification, create a list of subcategories instead of a list/index + of posts. This is only used when has_hierarchy = True. The template + specified in subcategories_list_template will be used. If this is set + to True, it is recommended to set include_posts_from_subhierarchies to + True to get correct post counts. + + show_list_as_index = False: + Whether to show the posts for one classification as an index or + as a post list. + + subcategories_list_template = "taxonomy_list.tmpl": + The template to use for the subcategories list when + show_list_as_subcategories_list is True. + + template_for_single_list = "tagindex.tmpl": + The template to use for the post list for one classification. + + template_for_classification_overview = "list.tmpl": + The template to use for the classification overview page. + Set to None to avoid generating overviews. + + always_disable_atom = False: + Whether to always disable Atom feed generation. + + always_disable_rss = False: + Whether to always disable RSS feed generation. + + apply_to_posts = True: + Whether this classification applies to posts. + + apply_to_pages = False: + Whether this classification applies to pages. + + minimum_post_count_per_classification_in_overview = 1: + The minimum number of posts a classification must have to be listed in + the overview. + + omit_empty_classifications = False: + Whether post lists resp. indexes should be created for empty + classifications. + + add_other_languages_variable = False: + In case this is `True`, each classification page will get a list + of triples `(other_lang, other_classification, title)` of classifications + in other languages which should be linked. The list will be stored in the + variable `other_languages`. + + path_handler_docstrings: + A dictionary of docstrings for path handlers. See eg. nikola.py for + examples. Must be overridden, keys are "taxonomy_index", "taxonomy", + "taxonomy_atom", "taxonomy_rss" (but using classification_name instead + of "taxonomy"). If one of the values is False, the corresponding path + handler will not be created. + """ + + name = "dummy_taxonomy" + + # Adjust the following values in your plugin! + classification_name = "taxonomy" + overview_page_variable_name = "taxonomy" + overview_page_items_variable_name = "items" + overview_page_hierarchy_variable_name = "taxonomy_hierarchy" + more_than_one_classifications_per_post = False + has_hierarchy = False + include_posts_from_subhierarchies = False + include_posts_into_hierarchy_root = False + show_list_as_subcategories_list = False + show_list_as_index = False + subcategories_list_template = "taxonomy_list.tmpl" + template_for_single_list = "tagindex.tmpl" + template_for_classification_overview = "list.tmpl" + always_disable_atom = False + always_disable_rss = False + apply_to_posts = True + apply_to_pages = False + minimum_post_count_per_classification_in_overview = 1 + omit_empty_classifications = False + add_other_languages_variable = False + path_handler_docstrings = { + 'taxonomy_index': '', + 'taxonomy': '', + 'taxonomy_atom': '', + 'taxonomy_rss': '', + } + + def is_enabled(self, lang=None) -> bool: + """Return True if this taxonomy is enabled, or False otherwise. + + If lang is None, this determins whether the classification is + made at all. If lang is not None, this determines whether the + overview page and the classification lists are created for this + language. + """ + return True + + def get_implicit_classifications(self, lang: str) -> 'typing.List[str]': + """Return a list of classification strings which should always appear in posts_per_classification.""" + return [] + + def classify(self, post: 'nikola.post.Post', lang: str) -> 'typing.Iterable[str]': + """Classify the given post for the given language. + + Must return a list or tuple of strings. + """ + raise NotImplementedError() + + def sort_posts(self, posts: 'typing.List[nikola.post.Post]', classification: str, lang: str): + """Sort the given list of posts. + + Allows the plugin to order the posts per classification as it wants. + The posts will be ordered by date (latest first) before calling + this function. This function must sort in-place. + """ + pass + + def sort_classifications(self, classifications: 'typing.List[str]', lang: str, level=None): + """Sort the given list of classification strings. + + Allows the plugin to order the classifications as it wants. The + classifications will be ordered by `natsort` before calling this + function. This function must sort in-place. + + For hierarchical taxonomies, the elements of the list are a single + path element of the path returned by `extract_hierarchy()`. The index + of the path element in the path will be provided in `level`. + """ + pass + + def get_classification_friendly_name(self, classification: str, lang: str, only_last_component=False) -> str: + """Extract a friendly name from the classification. + + The result of this function is usually displayed to the user, instead + of using the classification string. + + The argument `only_last_component` is only relevant to hierarchical + taxonomies. If it is set, the printable name should only describe the + last component of `classification` if possible. + """ + raise NotImplementedError() + + def get_overview_path(self, lang: str, dest_type='page') -> str: + """Return path for classification overview. + + This path handler for the classification overview must return one or + two values (in this order): + * a list or tuple of strings: the path relative to OUTPUT_DIRECTORY; + * a string with values 'auto', 'always' or 'never', indicating whether + INDEX_FILE should be added or not. + + Note that this function must always return a list or tuple of strings; + the other return value is optional with default value `'auto'`. + + In case INDEX_FILE should potentially be added, the last element in the + returned path must have no extension, and the PRETTY_URLS config must + be ignored by this handler. The return value will be modified based on + the PRETTY_URLS and INDEX_FILE settings. + + `dest_type` can be either 'page', 'feed' (for Atom feed) or 'rss'. + """ + raise NotImplementedError() + + def get_path(self, classification: str, lang: str, dest_type='page') -> str: + """Return path to the classification page. + + This path handler for the given classification must return one to + three values (in this order): + * a list or tuple of strings: the path relative to OUTPUT_DIRECTORY; + * a string with values 'auto', 'always' or 'never', indicating whether + INDEX_FILE should be added or not; + * an integer if a specific page of the index is to be targeted (will be + ignored for post lists), or `None` if the most current page is targeted. + + Note that this function must always return a list or tuple of strings; + the other two return values are optional with default values `'auto'` and + `None`. + + In case INDEX_FILE should potentially be added, the last element in the + returned path must have no extension, and the PRETTY_URLS config must + be ignored by this handler. The return value will be modified based on + the PRETTY_URLS and INDEX_FILE settings. + + `dest_type` can be either 'page', 'feed' (for Atom feed) or 'rss'. + + For hierarchical taxonomies, the result of extract_hierarchy is provided + as `classification`. For non-hierarchical taxonomies, the classification + string itself is provided as `classification`. + """ + raise NotImplementedError() + + def extract_hierarchy(self, classification: str) -> 'typing.List[str]': + """Given a classification, return a list of parts in the hierarchy. + + For non-hierarchical taxonomies, it usually suffices to return + `[classification]`. + """ + return [classification] + + def recombine_classification_from_hierarchy(self, hierarchy: 'typing.List[str]') -> str: + """Given a list of parts in the hierarchy, return the classification string. + + For non-hierarchical taxonomies, it usually suffices to return hierarchy[0]. + """ + return hierarchy[0] + + def provide_overview_context_and_uptodate(self, lang: str) -> str: + """Provide data for the context and the uptodate list for the classification overview. + + Must return a tuple of two dicts. The first is merged into the page's context, + the second will be put into the uptodate list of all generated tasks. + + Context must contain `title`. + """ + raise NotImplementedError() + + def provide_context_and_uptodate(self, classification: str, lang: str, node=None) -> 'typing.Tuple[typing.Dict]': + """Provide data for the context and the uptodate list for the list of the given classification. + + Must return a tuple of two dicts. The first is merged into the page's context, + the second will be put into the uptodate list of all generated tasks. + + For hierarchical taxonomies, node is the `hierarchy_utils.TreeNode` element + corresponding to the classification. + + Context must contain `title`, which should be something like 'Posts about <classification>'. + """ + raise NotImplementedError() + + def should_generate_classification_page(self, classification: str, post_list: 'typing.List[nikola.post.Post]', lang: str) -> bool: + """Only generates list of posts for classification if this function returns True.""" + return True + + def should_generate_atom_for_classification_page(self, classification: str, post_list: 'typing.List[nikola.post.Post]', lang: str) -> bool: + """Only generates Atom feed for list of posts for classification if this function returns True.""" + return self.should_generate_classification_page(classification, post_list, lang) + + def should_generate_rss_for_classification_page(self, classification: str, post_list: 'typing.List[nikola.post.Post]', lang: str) -> bool: + """Only generates RSS feed for list of posts for classification if this function returns True.""" + return self.should_generate_classification_page(classification, post_list, lang) + + def postprocess_posts_per_classification(self, posts_per_classification_per_language: 'typing.List[nikola.post.Post]', flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None) -> 'typing.List[nikola.post.Post]': + """Rearrange, modify or otherwise use the list of posts per classification and per language. + + For compatibility reasons, the list could be stored somewhere else as well. + + In case `has_hierarchy` is `True`, `flat_hierarchy_per_lang` is the flat + hierarchy consisting of `hierarchy_utils.TreeNode` elements, and + `hierarchy_lookup_per_lang` is the corresponding hierarchy lookup mapping + classification strings to `hierarchy_utils.TreeNode` objects. + """ + pass + + def get_other_language_variants(self, classification: str, lang: str, classifications_per_language: 'typing.List[str]') -> 'typing.List[str]': + """Return a list of variants of the same classification in other languages. + + Given a `classification` in a language `lang`, return a list of pairs + `(other_lang, other_classification)` with `lang != other_lang` such that + `classification` should be linked to `other_classification`. + + Classifications where links to other language versions makes no sense + should simply return an empty list. + + Provided is a set of classifications per language (`classifications_per_language`). + """ + return [] diff --git a/nikola/plugins/__init__.py b/nikola/plugins/__init__.py index b83f43f..70c8c0d 100644 --- a/nikola/plugins/__init__.py +++ b/nikola/plugins/__init__.py @@ -1,5 +1,3 @@ # -*- coding: utf-8 -*- """Plugins for Nikola.""" - -from __future__ import absolute_import diff --git a/nikola/plugins/basic_import.py b/nikola/plugins/basic_import.py index 073a539..3e6e21e 100644 --- a/nikola/plugins/basic_import.py +++ b/nikola/plugins/basic_import.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,21 +26,15 @@ """Mixin for importer plugins.""" -from __future__ import unicode_literals, print_function import io import csv import datetime import os -import sys -from pkg_resources import resource_filename - -try: - from urlparse import urlparse -except ImportError: - from urllib.parse import urlparse # NOQA +from urllib.parse import urlparse from lxml import etree, html from mako.template import Template +from pkg_resources import resource_filename from nikola import utils @@ -48,7 +42,6 @@ links = {} class ImportMixin(object): - """Mixin with common used methods.""" name = "import_mixin" @@ -77,8 +70,11 @@ class ImportMixin(object): return channel @staticmethod - def configure_redirections(url_map): + def configure_redirections(url_map, base_dir=''): """Configure redirections from an url_map.""" + index = base_dir + 'index.html' + if index.startswith('/'): + index = index[1:] redirections = [] for k, v in url_map.items(): if not k[-1] == '/': @@ -87,11 +83,10 @@ class ImportMixin(object): # remove the initial "/" because src is a relative file path src = (urlparse(k).path + 'index.html')[1:] dst = (urlparse(v).path) - if src == 'index.html': - utils.LOGGER.warn("Can't do a redirect for: {0!r}".format(k)) + if src == index: + utils.LOGGER.warning("Can't do a redirect for: {0!r}".format(k)) else: redirections.append((src, dst)) - return redirections def generate_base_site(self): @@ -100,8 +95,8 @@ class ImportMixin(object): os.system('nikola init -q ' + self.output_folder) else: self.import_into_existing_site = True - utils.LOGGER.notice('The folder {0} already exists - assuming that this is a ' - 'already existing Nikola site.'.format(self.output_folder)) + utils.LOGGER.warning('The folder {0} already exists - assuming that this is a ' + 'already existing Nikola site.'.format(self.output_folder)) filename = resource_filename('nikola', 'conf.py.in') # The 'strict_undefined=True' will give the missing symbol name if any, @@ -126,9 +121,12 @@ class ImportMixin(object): def write_content(cls, filename, content, rewrite_html=True): """Write content to file.""" if rewrite_html: - doc = html.document_fromstring(content) - doc.rewrite_links(replacer) - content = html.tostring(doc, encoding='utf8') + try: + doc = html.document_fromstring(content) + doc.rewrite_links(replacer) + content = html.tostring(doc, encoding='utf8') + except etree.ParserError: + content = content.encode('utf-8') else: content = content.encode('utf-8') @@ -136,8 +134,25 @@ class ImportMixin(object): with open(filename, "wb+") as fd: fd.write(content) - @staticmethod - def write_metadata(filename, title, slug, post_date, description, tags, **kwargs): + @classmethod + def write_post(cls, filename, content, headers, compiler, rewrite_html=True): + """Ask the specified compiler to write the post to disk.""" + if rewrite_html: + try: + doc = html.document_fromstring(content) + doc.rewrite_links(replacer) + content = html.tostring(doc, encoding='utf8') + except etree.ParserError: + pass + if isinstance(content, bytes): + content = content.decode('utf-8') + compiler.create_post( + filename, + content=content, + onefile=True, + **headers) + + def write_metadata(self, filename, title, slug, post_date, description, tags, **kwargs): """Write metadata to meta file.""" if not description: description = "" @@ -146,13 +161,13 @@ class ImportMixin(object): with io.open(filename, "w+", encoding="utf8") as fd: data = {'title': title, 'slug': slug, 'date': post_date, 'tags': ','.join(tags), 'description': description} data.update(kwargs) - fd.write(utils.write_metadata(data)) + fd.write(utils.write_metadata(data, site=self.site, comment_wrap=False)) @staticmethod def write_urlmap_csv(output_file, url_map): """Write urlmap to csv file.""" utils.makedirs(os.path.dirname(output_file)) - fmode = 'wb+' if sys.version_info[0] == 2 else 'w+' + fmode = 'w+' with io.open(output_file, fmode) as fd: csv_writer = csv.writer(fd) for item in url_map.items(): diff --git a/nikola/plugins/command/__init__.py b/nikola/plugins/command/__init__.py index 2aa5267..cdd1560 100644 --- a/nikola/plugins/command/__init__.py +++ b/nikola/plugins/command/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated diff --git a/nikola/plugins/command/auto.plugin b/nikola/plugins/command/auto.plugin index 3e2b17d..a847e14 100644 --- a/nikola/plugins/command/auto.plugin +++ b/nikola/plugins/command/auto.plugin @@ -5,9 +5,9 @@ module = auto [Documentation] author = Roberto Alsina version = 2.1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Automatically detect site changes, rebuild and optionally refresh a browser. [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/auto/__init__.py b/nikola/plugins/command/auto/__init__.py index 71f9624..6bedcac 100644 --- a/nikola/plugins/command/auto/__init__.py +++ b/nikola/plugins/command/auto/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Chris Warrick, Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,66 +26,56 @@ """Automatic rebuilds for Nikola.""" -from __future__ import print_function - -import json +import asyncio +import datetime import mimetypes import os import re +import stat import subprocess import sys -import time -try: - from urlparse import urlparse - from urllib2 import unquote -except ImportError: - from urllib.parse import urlparse, unquote # NOQA +import typing import webbrowser -from wsgiref.simple_server import make_server -import wsgiref.util -from blinker import signal +import pkg_resources + +from nikola.plugin_categories import Command +from nikola.utils import dns_sd, req_missing, get_theme_path, makedirs + try: - from ws4py.websocket import WebSocket - from ws4py.server.wsgirefserver import WSGIServer, WebSocketWSGIRequestHandler, WebSocketWSGIHandler - from ws4py.server.wsgiutils import WebSocketWSGIApplication - from ws4py.messaging import TextMessage + import aiohttp + from aiohttp import web + from aiohttp.web_urldispatcher import StaticResource + from aiohttp.web_exceptions import HTTPNotFound, HTTPForbidden, HTTPMovedPermanently + from aiohttp.web_response import Response + from aiohttp.web_fileresponse import FileResponse except ImportError: - WebSocket = object + aiohttp = web = None + StaticResource = HTTPNotFound = HTTPForbidden = Response = FileResponse = object + try: - import watchdog from watchdog.observers import Observer - from watchdog.events import FileSystemEventHandler, PatternMatchingEventHandler except ImportError: - watchdog = None - FileSystemEventHandler = object - PatternMatchingEventHandler = object - + Observer = None -from nikola.plugin_categories import Command -from nikola.utils import req_missing, get_logger, get_theme_path, STDERR_HANDLER LRJS_PATH = os.path.join(os.path.dirname(__file__), 'livereload.js') -error_signal = signal('error') -refresh_signal = signal('refresh') +REBUILDING_REFRESH_DELAY = 0.35 +IDLE_REFRESH_DELAY = 0.05 -ERROR_N = '''<html> -<head> -</head> -<boody> -ERROR {} -</body> -</html> -''' +if sys.platform == 'win32': + asyncio.set_event_loop(asyncio.ProactorEventLoop()) class CommandAuto(Command): - """Automatic rebuilds for Nikola.""" name = "auto" - logger = None has_server = True doc_purpose = "builds and serves a site; automatically detects site changes, rebuilds, and optionally refreshes a browser" + dns_sd = None + delta_last_rebuild = datetime.timedelta(milliseconds=100) + web_runner = None # type: web.AppRunner + cmd_options = [ { 'name': 'port', @@ -93,7 +83,7 @@ class CommandAuto(Command): 'long': 'port', 'default': 8000, 'type': int, - 'help': 'Port nummber (default: 8000)', + 'help': 'Port number', }, { 'name': 'address', @@ -101,7 +91,7 @@ class CommandAuto(Command): 'long': 'address', 'type': str, 'default': '127.0.0.1', - 'help': 'Address to bind (default: 127.0.0.1 – localhost)', + 'help': 'Address to bind', }, { 'name': 'browser', @@ -126,26 +116,50 @@ class CommandAuto(Command): 'type': bool, 'help': 'Disable the server, automate rebuilds only' }, + { + 'name': 'process', + 'short': 'n', + 'long': 'process', + 'default': 0, + 'type': int, + 'help': 'Number of subprocesses (nikola build argument)' + }, + { + 'name': 'parallel-type', + 'short': 'P', + 'long': 'parallel-type', + 'default': 'process', + 'type': str, + 'help': "Parallelization mode ('process' or 'thread', nikola build argument)" + }, ] def _execute(self, options, args): """Start the watcher.""" - self.logger = get_logger('auto', STDERR_HANDLER) - LRSocket.logger = self.logger - - if WebSocket is object and watchdog is None: - req_missing(['ws4py', 'watchdog'], 'use the "auto" command') - elif WebSocket is object: - req_missing(['ws4py'], 'use the "auto" command') - elif watchdog is None: + self.sockets = [] + self.rebuild_queue = asyncio.Queue() + self.reload_queue = asyncio.Queue() + self.last_rebuild = datetime.datetime.now() + self.is_rebuilding = False + + if aiohttp is None and Observer is None: + req_missing(['aiohttp', 'watchdog'], 'use the "auto" command') + elif aiohttp is None: + req_missing(['aiohttp'], 'use the "auto" command') + elif Observer is None: req_missing(['watchdog'], 'use the "auto" command') - self.cmd_arguments = ['nikola', 'build'] + if sys.argv[0].endswith('__main__.py'): + self.nikola_cmd = [sys.executable, '-m', 'nikola', 'build'] + else: + self.nikola_cmd = [sys.argv[0], 'build'] + if self.site.configuration_filename != 'conf.py': - self.cmd_arguments = ['--conf=' + self.site.configuration_filename] + self.cmd_arguments + self.nikola_cmd.append('--conf=' + self.site.configuration_filename) - # Run an initial build so we are up-to-date - subprocess.call(self.cmd_arguments) + if options and options.get('process'): + self.nikola_cmd += ['--process={}'.format(options['process']), + '--parallel-type={}'.format(options['parallel-type'])] port = options and options.get('port') self.snippet = '''<script>document.write('<script src="http://' @@ -154,9 +168,9 @@ class CommandAuto(Command): + 'script>')</script> </head>'''.format(port) - # Do not duplicate entries -- otherwise, multiple rebuilds are triggered + # Deduplicate entries by using a set -- otherwise, multiple rebuilds are triggered watched = set([ - 'templates/', + 'templates/' ] + [get_theme_path(name) for name in self.site.THEMES]) for item in self.site.config['post_pages']: watched.add(os.path.dirname(item[0])) @@ -166,8 +180,17 @@ class CommandAuto(Command): watched.add(item) for item in self.site.config['LISTINGS_FOLDERS']: watched.add(item) + for item in self.site.config['IMAGE_FOLDERS']: + watched.add(item) + for item in self.site._plugin_places: + watched.add(item) + # Nikola itself (useful for developers) + watched.add(pkg_resources.resource_filename('nikola', '')) out_folder = self.site.config['OUTPUT_FOLDER'] + if not os.path.exists(out_folder): + makedirs(out_folder) + if options and options.get('browser'): browser = True else: @@ -176,285 +199,387 @@ class CommandAuto(Command): if options['ipv6']: dhost = '::' else: - dhost = None + dhost = '0.0.0.0' host = options['address'].strip('[').strip(']') or dhost + # Prepare asyncio event loop + # Required for subprocessing to work + loop = asyncio.get_event_loop() + + # Set debug setting + loop.set_debug(self.site.debug) + # Server can be disabled (Issue #1883) self.has_server = not options['no-server'] - # Instantiate global observer - observer = Observer() if self.has_server: - # Watch output folders and trigger reloads - observer.schedule(OurWatchHandler(self.do_refresh), out_folder, recursive=True) + loop.run_until_complete(self.set_up_server(host, port, out_folder)) + + # Run an initial build so we are up-to-date. The server is running, but we are not watching yet. + loop.run_until_complete(self.run_initial_rebuild()) + + self.wd_observer = Observer() + # Watch output folders and trigger reloads + if self.has_server: + self.wd_observer.schedule(NikolaEventHandler(self.reload_page, loop), out_folder, recursive=True) # Watch input folders and trigger rebuilds for p in watched: if os.path.exists(p): - observer.schedule(OurWatchHandler(self.do_rebuild), p, recursive=True) + self.wd_observer.schedule(NikolaEventHandler(self.queue_rebuild, loop), p, recursive=True) # Watch config file (a bit of a hack, but we need a directory) _conf_fn = os.path.abspath(self.site.configuration_filename or 'conf.py') _conf_dn = os.path.dirname(_conf_fn) - observer.schedule(ConfigWatchHandler(_conf_fn, self.do_rebuild), _conf_dn, recursive=False) + self.wd_observer.schedule(ConfigEventHandler(_conf_fn, self.queue_rebuild, loop), _conf_dn, recursive=False) + self.wd_observer.start() - try: - self.logger.info("Watching files for changes...") - observer.start() - except KeyboardInterrupt: - pass + win_sleeper = None + # https://bugs.python.org/issue23057 (fixed in Python 3.8) + if sys.platform == 'win32' and sys.version_info < (3, 8): + win_sleeper = asyncio.ensure_future(windows_ctrlc_workaround()) - parent = self - - class Mixed(WebSocketWSGIApplication): - - """A class that supports WS and HTTP protocols on the same port.""" + if not self.has_server: + self.logger.info("Watching for changes...") + # Run the event loop forever (no server mode). + try: + # Run rebuild queue + loop.run_until_complete(self.run_rebuild_queue()) - def __call__(self, environ, start_response): - if environ.get('HTTP_UPGRADE') is None: - return parent.serve_static(environ, start_response) - return super(Mixed, self).__call__(environ, start_response) + loop.run_forever() + except KeyboardInterrupt: + pass + finally: + if win_sleeper: + win_sleeper.cancel() + self.wd_observer.stop() + self.wd_observer.join() + loop.close() + return - if self.has_server: - ws = make_server( - host, port, server_class=WSGIServer, - handler_class=WebSocketWSGIRequestHandler, - app=Mixed(handler_cls=LRSocket) - ) - ws.initialize_websockets_manager() - self.logger.info("Serving HTTP on {0} port {1}...".format(host, port)) - if browser: - if options['ipv6'] or '::' in host: - server_url = "http://[{0}]:{1}/".format(host, port) - else: - server_url = "http://{0}:{1}/".format(host, port) + if options['ipv6'] or '::' in host: + server_url = "http://[{0}]:{1}/".format(host, port) + else: + server_url = "http://{0}:{1}/".format(host, port) + self.logger.info("Serving on {0} ...".format(server_url)) - self.logger.info("Opening {0} in the default web browser...".format(server_url)) - # Yes, this is racy - webbrowser.open('http://{0}:{1}'.format(host, port)) + if browser: + # Some browsers fail to load 0.0.0.0 (Issue #2755) + if host == '0.0.0.0': + server_url = "http://127.0.0.1:{0}/".format(port) + self.logger.info("Opening {0} in the default web browser...".format(server_url)) + webbrowser.open(server_url) - try: - ws.serve_forever() - except KeyboardInterrupt: - self.logger.info("Server is shutting down.") - # This is a hack, but something is locking up in a futex - # and exit() doesn't work. - os.kill(os.getpid(), 15) - else: - # Workaround: can’t have nothing running (instant exit) - # but also can’t join threads (no way to exit) - # The joys of threading. - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - self.logger.info("Shutting down.") - # This is a hack, but something is locking up in a futex - # and exit() doesn't work. - os.kill(os.getpid(), 15) + # Run the event loop forever and handle shutdowns. + try: + # Run rebuild queue + rebuild_queue_fut = asyncio.ensure_future(self.run_rebuild_queue()) + reload_queue_fut = asyncio.ensure_future(self.run_reload_queue()) - def do_rebuild(self, event): + self.dns_sd = dns_sd(port, (options['ipv6'] or '::' in host)) + loop.run_forever() + except KeyboardInterrupt: + pass + finally: + self.logger.info("Server is shutting down.") + if win_sleeper: + win_sleeper.cancel() + if self.dns_sd: + self.dns_sd.Reset() + rebuild_queue_fut.cancel() + reload_queue_fut.cancel() + loop.run_until_complete(self.web_runner.cleanup()) + self.wd_observer.stop() + self.wd_observer.join() + loop.close() + + async def set_up_server(self, host: str, port: int, out_folder: str) -> None: + """Set up aiohttp server and start it.""" + webapp = web.Application() + webapp.router.add_get('/livereload.js', self.serve_livereload_js) + webapp.router.add_get('/robots.txt', self.serve_robots_txt) + webapp.router.add_route('*', '/livereload', self.websocket_handler) + resource = IndexHtmlStaticResource(True, self.snippet, '', out_folder) + webapp.router.register_resource(resource) + webapp.on_shutdown.append(self.remove_websockets) + + self.web_runner = web.AppRunner(webapp) + await self.web_runner.setup() + website = web.TCPSite(self.web_runner, host, port) + await website.start() + + async def run_initial_rebuild(self) -> None: + """Run an initial rebuild.""" + await self._rebuild_site() + # If there are any clients, have them reload the root. + await self._send_reload_command(self.site.config['INDEX_FILE']) + + async def queue_rebuild(self, event) -> None: """Rebuild the site.""" # Move events have a dest_path, some editors like gedit use a # move on larger save operations for write protection event_path = event.dest_path if hasattr(event, 'dest_path') else event.src_path - fname = os.path.basename(event_path) - if (fname.endswith('~') or - fname.startswith('.') or - os.path.isdir(event_path)): # Skip on folders, these are usually duplicates + if sys.platform == 'win32': + # Windows hidden files support + is_hidden = os.stat(event_path).st_file_attributes & stat.FILE_ATTRIBUTE_HIDDEN + else: + is_hidden = False + has_hidden_component = any(p.startswith('.') for p in event_path.split(os.sep)) + if (is_hidden or has_hidden_component or + '__pycache__' in event_path or + event_path.endswith(('.pyc', '.pyo', '.pyd', '_bak', '~')) or + event.is_directory): # Skip on folders, these are usually duplicates return - self.logger.info('REBUILDING SITE (from {0})'.format(event_path)) - p = subprocess.Popen(self.cmd_arguments, stderr=subprocess.PIPE) - error = p.stderr.read() - errord = error.decode('utf-8') - if p.wait() != 0: - self.logger.error(errord) - error_signal.send(error=errord) + + self.logger.debug('Queuing rebuild from {0}'.format(event_path)) + await self.rebuild_queue.put((datetime.datetime.now(), event_path)) + + async def run_rebuild_queue(self) -> None: + """Run rebuilds from a queue (Nikola can only build in a single instance).""" + while True: + date, event_path = await self.rebuild_queue.get() + if date < (self.last_rebuild + self.delta_last_rebuild): + self.logger.debug("Skipping rebuild from {0} (within delta)".format(event_path)) + continue + await self._rebuild_site(event_path) + + async def _rebuild_site(self, event_path: typing.Optional[str] = None) -> None: + """Rebuild the site.""" + self.is_rebuilding = True + self.last_rebuild = datetime.datetime.now() + if event_path: + self.logger.info('REBUILDING SITE (from {0})'.format(event_path)) else: - print(errord) + self.logger.info('REBUILDING SITE') + + p = await asyncio.create_subprocess_exec(*self.nikola_cmd, stderr=subprocess.PIPE) + exit_code = await p.wait() + out = (await p.stderr.read()).decode('utf-8') - def do_refresh(self, event): - """Refresh the page.""" + if exit_code != 0: + self.logger.error("Rebuild failed\n" + out) + await self.send_to_websockets({'command': 'alert', 'message': out}) + else: + self.logger.info("Rebuild successful\n" + out) + + self.is_rebuilding = False + + async def run_reload_queue(self) -> None: + """Send reloads from a queue to limit CPU usage.""" + while True: + p = await self.reload_queue.get() + self.logger.info('REFRESHING: {0}'.format(p)) + await self._send_reload_command(p) + if self.is_rebuilding: + await asyncio.sleep(REBUILDING_REFRESH_DELAY) + else: + await asyncio.sleep(IDLE_REFRESH_DELAY) + + async def _send_reload_command(self, path: str) -> None: + """Send a reload command.""" + await self.send_to_websockets({'command': 'reload', 'path': path, 'liveCSS': True}) + + async def reload_page(self, event) -> None: + """Reload the page.""" # Move events have a dest_path, some editors like gedit use a # move on larger save operations for write protection - event_path = event.dest_path if hasattr(event, 'dest_path') else event.src_path - self.logger.info('REFRESHING: {0}'.format(event_path)) - p = os.path.relpath(event_path, os.path.abspath(self.site.config['OUTPUT_FOLDER'])) - refresh_signal.send(path=p) - - def serve_static(self, environ, start_response): - """Trivial static file server.""" - uri = wsgiref.util.request_uri(environ) - p_uri = urlparse(uri) - f_path = os.path.join(self.site.config['OUTPUT_FOLDER'], *[unquote(x) for x in p_uri.path.split('/')]) - - # ‘Pretty’ URIs and root are assumed to be HTML - mimetype = 'text/html' if uri.endswith('/') else mimetypes.guess_type(uri)[0] or 'application/octet-stream' - - if os.path.isdir(f_path): - if not f_path.endswith('/'): # Redirect to avoid breakage - start_response('301 Redirect', [('Location', p_uri.path + '/')]) - return [] - f_path = os.path.join(f_path, self.site.config['INDEX_FILE']) - mimetype = 'text/html' - - if p_uri.path == '/robots.txt': - start_response('200 OK', [('Content-type', 'text/plain')]) - return ['User-Agent: *\nDisallow: /\n'.encode('utf-8')] - elif os.path.isfile(f_path): - with open(f_path, 'rb') as fd: - start_response('200 OK', [('Content-type', mimetype)]) - return [self.file_filter(mimetype, fd.read())] - elif p_uri.path == '/livereload.js': - with open(LRJS_PATH, 'rb') as fd: - start_response('200 OK', [('Content-type', mimetype)]) - return [self.file_filter(mimetype, fd.read())] - start_response('404 ERR', []) - return [self.file_filter('text/html', ERROR_N.format(404).format(uri).encode('utf-8'))] - - def file_filter(self, mimetype, data): - """Apply necessary changes to document before serving.""" - if mimetype == 'text/html': - data = data.decode('utf8') - data = self.remove_base_tag(data) - data = self.inject_js(data) - data = data.encode('utf8') - return data - - def inject_js(self, data): - """Inject livereload.js.""" - data = re.sub('</head>', self.snippet, data, 1, re.IGNORECASE) - return data - - def remove_base_tag(self, data): - """Comment out any <base> to allow local resolution of relative URLs.""" - data = re.sub(r'<base\s([^>]*)>', '<!--base \g<1>-->', data, re.IGNORECASE) - return data - - -pending = [] - - -class LRSocket(WebSocket): - - """Speak Livereload protocol.""" - - def __init__(self, *a, **kw): - """Initialize protocol handler.""" - refresh_signal.connect(self.notify) - error_signal.connect(self.send_error) - super(LRSocket, self).__init__(*a, **kw) - - def received_message(self, message): - """Handle received message.""" - message = json.loads(message.data.decode('utf8')) - self.logger.info('<--- {0}'.format(message)) - response = None - if message['command'] == 'hello': # Handshake - response = { - 'command': 'hello', - 'protocols': [ - 'http://livereload.com/protocols/official-7', - ], - 'serverName': 'nikola-livereload', - } - elif message['command'] == 'info': # Someone connected - self.logger.info('****** Browser connected: {0}'.format(message.get('url'))) - self.logger.info('****** sending {0} pending messages'.format(len(pending))) - while pending: - msg = pending.pop() - self.logger.info('---> {0}'.format(msg.data)) - self.send(msg, msg.is_binary) + if event: + event_path = event.dest_path if hasattr(event, 'dest_path') else event.src_path else: - response = { - 'command': 'alert', - 'message': 'HEY', - } - if response is not None: - response = json.dumps(response) - self.logger.info('---> {0}'.format(response)) - response = TextMessage(response) - self.send(response, response.is_binary) - - def notify(self, sender, path): - """Send reload requests to the client.""" - p = os.path.join('/', path) - message = { - 'command': 'reload', - 'liveCSS': True, - 'path': p, - } - response = json.dumps(message) - self.logger.info('---> {0}'.format(p)) - response = TextMessage(response) - if self.stream is None: # No client connected or whatever - pending.append(response) - else: - self.send(response, response.is_binary) + event_path = self.site.config['OUTPUT_FOLDER'] + p = os.path.relpath(event_path, os.path.abspath(self.site.config['OUTPUT_FOLDER'])).replace(os.sep, '/') + await self.reload_queue.put(p) + + async def serve_livereload_js(self, request): + """Handle requests to /livereload.js and serve the JS file.""" + return FileResponse(LRJS_PATH) + + async def serve_robots_txt(self, request): + """Handle requests to /robots.txt.""" + return Response(body=b'User-Agent: *\nDisallow: /\n', content_type='text/plain', charset='utf-8') + + async def websocket_handler(self, request): + """Handle requests to /livereload and initiate WebSocket communication.""" + ws = web.WebSocketResponse() + await ws.prepare(request) + self.sockets.append(ws) + + while True: + msg = await ws.receive() + + self.logger.debug("Received message: {0}".format(msg)) + if msg.type == aiohttp.WSMsgType.TEXT: + message = msg.json() + if message['command'] == 'hello': + response = { + 'command': 'hello', + 'protocols': [ + 'http://livereload.com/protocols/official-7', + ], + 'serverName': 'Nikola Auto (livereload)', + } + await ws.send_json(response) + elif message['command'] != 'info': + self.logger.warning("Unknown command in message: {0}".format(message)) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING): + break + elif msg.type == aiohttp.WSMsgType.CLOSE: + self.logger.debug("Closing WebSocket") + await ws.close() + break + elif msg.type == aiohttp.WSMsgType.ERROR: + self.logger.error('WebSocket connection closed with exception {0}'.format(ws.exception())) + break + else: + self.logger.warning("Received unknown message: {0}".format(msg)) + + self.sockets.remove(ws) + self.logger.debug("WebSocket connection closed: {0}".format(ws)) + + return ws + + async def remove_websockets(self, app) -> None: + """Remove all websockets.""" + for ws in self.sockets: + await ws.close() + self.sockets.clear() + + async def send_to_websockets(self, message: dict) -> None: + """Send a message to all open WebSockets.""" + to_delete = [] + for ws in self.sockets: + if ws.closed: + to_delete.append(ws) + continue - def send_error(self, sender, error=None): - """Send reload requests to the client.""" - if self.stream is None: # No client connected or whatever - return - message = { - 'command': 'alert', - 'message': error, - } - response = json.dumps(message) - response = TextMessage(response) - if self.stream is None: # No client connected or whatever - pending.append(response) + try: + await ws.send_json(message) + if ws._close_code: + await ws.close() + to_delete.append(ws) + except RuntimeError as e: + if 'closed' in e.args[0]: + self.logger.warning("WebSocket {0} closed uncleanly".format(ws)) + to_delete.append(ws) + else: + raise + + for ws in to_delete: + self.sockets.remove(ws) + + +async def windows_ctrlc_workaround() -> None: + """Work around bpo-23057.""" + # https://bugs.python.org/issue23057 + while True: + await asyncio.sleep(1) + + +class IndexHtmlStaticResource(StaticResource): + """A StaticResource implementation that serves /index.html in directory roots.""" + + modify_html = True + snippet = "</head>" + + def __init__(self, modify_html=True, snippet="</head>", *args, **kwargs): + """Initialize a resource.""" + self.modify_html = modify_html + self.snippet = snippet + super().__init__(*args, **kwargs) + + async def _handle(self, request: 'web.Request') -> 'web.Response': + """Handle incoming requests (pass to handle_file).""" + filename = request.match_info['filename'] + return await self.handle_file(request, filename) + + async def handle_file(self, request: 'web.Request', filename: str, from_index=None) -> 'web.Response': + """Handle file requests.""" + try: + filepath = self._directory.joinpath(filename).resolve() + if not self._follow_symlinks: + filepath.relative_to(self._directory) + except (ValueError, FileNotFoundError) as error: + # relatively safe + raise HTTPNotFound() from error + except Exception as error: + # perm error or other kind! + request.app.logger.exception(error) + raise HTTPNotFound() from error + + # on opening a dir, load it's contents if allowed + if filepath.is_dir(): + if filename.endswith('/') or not filename: + ret = await self.handle_file(request, filename + 'index.html', from_index=filename) + else: + # Redirect and add trailing slash so relative links work (Issue #3140) + new_url = request.rel_url.path + '/' + if request.rel_url.query_string: + new_url += '?' + request.rel_url.query_string + raise HTTPMovedPermanently(new_url) + elif filepath.is_file(): + ct, encoding = mimetypes.guess_type(str(filepath)) + encoding = encoding or 'utf-8' + if ct == 'text/html' and self.modify_html: + if sys.version_info[0] == 3 and sys.version_info[1] <= 5: + # Python 3.4 and 3.5 do not accept pathlib.Path objects in calls to open() + filepath = str(filepath) + with open(filepath, 'r', encoding=encoding) as fh: + text = fh.read() + text = self.transform_html(text) + ret = Response(text=text, content_type=ct, charset=encoding) + else: + ret = FileResponse(filepath, chunk_size=self._chunk_size) + elif from_index: + filepath = self._directory.joinpath(from_index).resolve() + try: + return Response(text=self._directory_as_html(filepath), + content_type="text/html") + except PermissionError: + raise HTTPForbidden else: - self.send(response, response.is_binary) + raise HTTPNotFound + return ret -class OurWatchHandler(FileSystemEventHandler): + def transform_html(self, text: str) -> str: + """Apply some transforms to HTML content.""" + # Inject livereload.js + text = text.replace('</head>', self.snippet, 1) + # Disable <base> tag + text = re.sub(r'<base\s([^>]*)>', r'<!--base \g<1>-->', text, flags=re.IGNORECASE) + return text - """A Nikola-specific handler for Watchdog.""" - def __init__(self, function): +# Based on code from the 'hachiko' library by John Biesnecker — thanks! +# https://github.com/biesnecker/hachiko +class NikolaEventHandler: + """A Nikola-specific event handler for Watchdog. Based on code from hachiko.""" + + def __init__(self, function, loop): """Initialize the handler.""" self.function = function - super(OurWatchHandler, self).__init__() + self.loop = loop - def on_any_event(self, event): - """Call the provided function on any event.""" - self.function(event) + async def on_any_event(self, event): + """Handle all file events.""" + await self.function(event) + def dispatch(self, event): + """Dispatch events to handler.""" + self.loop.call_soon_threadsafe(asyncio.ensure_future, self.on_any_event(event)) -class ConfigWatchHandler(FileSystemEventHandler): +class ConfigEventHandler(NikolaEventHandler): """A Nikola-specific handler for Watchdog that handles the config file (as a workaround).""" - def __init__(self, configuration_filename, function): + def __init__(self, configuration_filename, function, loop): """Initialize the handler.""" self.configuration_filename = configuration_filename self.function = function + self.loop = loop - def on_any_event(self, event): - """Call the provided function on any event.""" + async def on_any_event(self, event): + """Handle file events if they concern the configuration file.""" if event._src_path == self.configuration_filename: - self.function(event) - - -try: - # Monkeypatch to hide Broken Pipe Errors - f = WebSocketWSGIHandler.finish_response - - if sys.version_info[0] == 3: - EX = BrokenPipeError # NOQA - else: - EX = IOError - - def finish_response(self): - """Monkeypatched finish_response that ignores broken pipes.""" - try: - f(self) - except EX: # Client closed the connection, not a real error - pass - - WebSocketWSGIHandler.finish_response = finish_response -except NameError: - # In case there is no WebSocketWSGIHandler because of a failed import. - pass + await self.function(event) diff --git a/nikola/plugins/command/auto/livereload.js b/nikola/plugins/command/auto/livereload.js index b4cafb3..282dce5 120000 --- a/nikola/plugins/command/auto/livereload.js +++ b/nikola/plugins/command/auto/livereload.js @@ -1 +1 @@ -../../../../bower_components/livereload-js/dist/livereload.js \ No newline at end of file +../../../../npm_assets/node_modules/livereload-js/dist/livereload.js \ No newline at end of file diff --git a/nikola/plugins/command/bootswatch_theme.plugin b/nikola/plugins/command/bootswatch_theme.plugin deleted file mode 100644 index fc25045..0000000 --- a/nikola/plugins/command/bootswatch_theme.plugin +++ /dev/null @@ -1,13 +0,0 @@ -[Core] -name = bootswatch_theme -module = bootswatch_theme - -[Documentation] -author = Roberto Alsina -version = 1.0 -website = http://getnikola.com -description = Given a swatch name and a parent theme, creates a custom theme. - -[Nikola] -plugincategory = Command - diff --git a/nikola/plugins/command/bootswatch_theme.py b/nikola/plugins/command/bootswatch_theme.py deleted file mode 100644 index b5644a1..0000000 --- a/nikola/plugins/command/bootswatch_theme.py +++ /dev/null @@ -1,106 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright © 2012-2015 Roberto Alsina and others. - -# Permission is hereby granted, free of charge, to any -# person obtaining a copy of this software and associated -# documentation files (the "Software"), to deal in the -# Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the -# Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice -# shall be included in all copies or substantial portions of -# the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY -# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE -# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR -# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS -# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR -# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -"""Given a swatch name from bootswatch.com and a parent theme, creates a custom theme.""" - -from __future__ import print_function -import os -import requests - -from nikola.plugin_categories import Command -from nikola import utils - -LOGGER = utils.get_logger('bootswatch_theme', utils.STDERR_HANDLER) - - -class CommandBootswatchTheme(Command): - - """Given a swatch name from bootswatch.com and a parent theme, creates a custom theme.""" - - name = "bootswatch_theme" - doc_usage = "[options]" - doc_purpose = "given a swatch name from bootswatch.com and a parent theme, creates a custom"\ - " theme" - cmd_options = [ - { - 'name': 'name', - 'short': 'n', - 'long': 'name', - 'default': 'custom', - 'type': str, - 'help': 'New theme name (default: custom)', - }, - { - 'name': 'swatch', - 'short': 's', - 'default': '', - 'type': str, - 'help': 'Name of the swatch from bootswatch.com.' - }, - { - 'name': 'parent', - 'short': 'p', - 'long': 'parent', - 'default': 'bootstrap3', - 'help': 'Parent theme name (default: bootstrap3)', - }, - ] - - def _execute(self, options, args): - """Given a swatch name and a parent theme, creates a custom theme.""" - name = options['name'] - swatch = options['swatch'] - if not swatch: - LOGGER.error('The -s option is mandatory') - return 1 - parent = options['parent'] - version = '' - - # See if we need bootswatch for bootstrap v2 or v3 - themes = utils.get_theme_chain(parent) - if 'bootstrap3' not in themes and 'bootstrap3-jinja' not in themes: - version = '2' - elif 'bootstrap' not in themes and 'bootstrap-jinja' not in themes: - LOGGER.warn('"bootswatch_theme" only makes sense for themes that use bootstrap') - elif 'bootstrap3-gradients' in themes or 'bootstrap3-gradients-jinja' in themes: - LOGGER.warn('"bootswatch_theme" doesn\'t work well with the bootstrap3-gradients family') - - LOGGER.info("Creating '{0}' theme from '{1}' and '{2}'".format(name, swatch, parent)) - utils.makedirs(os.path.join('themes', name, 'assets', 'css')) - for fname in ('bootstrap.min.css', 'bootstrap.css'): - url = 'http://bootswatch.com' - if version: - url += '/' + version - url = '/'.join((url, swatch, fname)) - LOGGER.info("Downloading: " + url) - data = requests.get(url).text - with open(os.path.join('themes', name, 'assets', 'css', fname), - 'wb+') as output: - output.write(data.encode('utf-8')) - - with open(os.path.join('themes', name, 'parent'), 'wb+') as output: - output.write(parent.encode('utf-8')) - LOGGER.notice('Theme created. Change the THEME setting to "{0}" to use it.'.format(name)) diff --git a/nikola/plugins/command/check.plugin b/nikola/plugins/command/check.plugin index e380e64..bc6ede3 100644 --- a/nikola/plugins/command/check.plugin +++ b/nikola/plugins/command/check.plugin @@ -5,9 +5,9 @@ module = check [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Check the generated site [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/check.py b/nikola/plugins/command/check.py index abf183e..cac6000 100644 --- a/nikola/plugins/command/check.py +++ b/nikola/plugins/command/check.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,27 +26,25 @@ """Check the generated site.""" -from __future__ import print_function -from collections import defaultdict +import logging import os import re import sys import time -try: - from urllib import unquote - from urlparse import urlparse, urljoin, urldefrag -except ImportError: - from urllib.parse import unquote, urlparse, urljoin, urldefrag # NOQA +from collections import defaultdict +from urllib.parse import unquote, urlparse, urljoin, urldefrag -from doit.loader import generate_tasks import lxml.html import requests +from doit.loader import generate_tasks from nikola.plugin_categories import Command -from nikola.utils import get_logger, STDERR_HANDLER -def _call_nikola_list(site): +def _call_nikola_list(site, cache=None): + if cache is not None: + if 'files' in cache and 'deps' in cache: + return cache['files'], cache['deps'] files = [] deps = defaultdict(list) for task in generate_tasks('render_site', site.gen_tasks('render_site', "Task", '')): @@ -57,16 +55,19 @@ def _call_nikola_list(site): files.extend(task.targets) for target in task.targets: deps[target].extend(task.file_dep) + if cache is not None: + cache['files'] = files + cache['deps'] = deps return files, deps -def real_scan_files(site): +def real_scan_files(site, cache=None): """Scan for files.""" task_fnames = set([]) real_fnames = set([]) output_folder = site.config['OUTPUT_FOLDER'] # First check that all targets are generated in the right places - for fname in _call_nikola_list(site)[0]: + for fname in _call_nikola_list(site, cache)[0]: fname = fname.strip() if fname.startswith(output_folder): task_fnames.add(fname) @@ -94,11 +95,9 @@ def fs_relpath_from_url_path(url_path): class CommandCheck(Command): - """Check the generated site.""" name = "check" - logger = None doc_usage = "[-v] (-l [--find-sources] [-r] | -f [--clean-files])" doc_purpose = "check links and files in the generated site" @@ -153,39 +152,41 @@ class CommandCheck(Command): def _execute(self, options, args): """Check the generated site.""" - self.logger = get_logger('check', STDERR_HANDLER) - if not options['links'] and not options['files'] and not options['clean']: print(self.help()) - return False + return 1 if options['verbose']: - self.logger.level = 1 + self.logger.level = logging.DEBUG else: - self.logger.level = 4 + self.logger.level = logging.WARNING + failure = False if options['links']: - failure = self.scan_links(options['find_sources'], options['remote']) + failure |= self.scan_links(options['find_sources'], options['remote']) if options['files']: - failure = self.scan_files() + failure |= self.scan_files() if options['clean']: - failure = self.clean_files() + failure |= self.clean_files() if failure: return 1 existing_targets = set([]) checked_remote_targets = {} + cache = {} def analyze(self, fname, find_sources=False, check_remote=False): """Analyze links on a page.""" rv = False self.whitelist = [re.compile(x) for x in self.site.config['LINK_CHECK_WHITELIST']] + self.internal_redirects = [urljoin('/', _[0]) for _ in self.site.config['REDIRECTIONS']] base_url = urlparse(self.site.config['BASE_URL']) self.existing_targets.add(self.site.config['SITE_URL']) self.existing_targets.add(self.site.config['BASE_URL']) url_type = self.site.config['URL_TYPE'] + atom_extension = self.site.config['ATOM_EXTENSION'] deps = {} if find_sources: - deps = _call_nikola_list(self.site)[1] + deps = _call_nikola_list(self.site, self.cache)[1] if url_type in ('absolute', 'full_path'): url_netloc_to_root = urlparse(self.site.config['BASE_URL']).path @@ -196,24 +197,66 @@ class CommandCheck(Command): # Do not look at links in the cache, which are not parsed by # anyone and may result in false positives. Problems arise # with galleries, for example. Full rationale: (Issue #1447) - self.logger.notice("Ignoring {0} (in cache, links may be incorrect)".format(filename)) + self.logger.warning("Ignoring {0} (in cache, links may be incorrect)".format(filename)) return False if not os.path.exists(fname): # Quietly ignore files that don’t exist; use `nikola check -f` instead (Issue #1831) return False - d = lxml.html.fromstring(open(filename, 'rb').read()) - for l in d.iterlinks(): + if '.html' == fname[-5:]: + with open(filename, 'rb') as inf: + d = lxml.html.fromstring(inf.read()) + extra_objs = lxml.html.fromstring('<html/>') + + # Turn elements with a srcset attribute into individual img elements with src attributes + for obj in list(d.xpath('(*//img|*//source)')): + if 'srcset' in obj.attrib: + for srcset_item in obj.attrib['srcset'].split(','): + extra_objs.append(lxml.etree.Element('img', src=srcset_item.strip().split(' ')[0])) + link_elements = list(d.iterlinks()) + list(extra_objs.iterlinks()) + # Extract links from XML formats to minimal HTML, allowing those to go through the link checks + elif atom_extension == filename[-len(atom_extension):]: + d = lxml.etree.parse(filename) + link_elements = lxml.html.fromstring('<html/>') + for elm in d.findall('*//{http://www.w3.org/2005/Atom}link'): + feed_link = elm.attrib['href'].split('?')[0].strip() # strip FEED_LINKS_APPEND_QUERY + link_elements.append(lxml.etree.Element('a', href=feed_link)) + link_elements = list(link_elements.iterlinks()) + elif filename.endswith('sitemap.xml') or filename.endswith('sitemapindex.xml'): + d = lxml.etree.parse(filename) + link_elements = lxml.html.fromstring('<html/>') + for elm in d.getroot().findall("*//{http://www.sitemaps.org/schemas/sitemap/0.9}loc"): + link_elements.append(lxml.etree.Element('a', href=elm.text.strip())) + link_elements = list(link_elements.iterlinks()) + else: # unsupported file type + return False + + for l in link_elements: target = l[2] if target == "#": continue - target, _ = urldefrag(target) + target = urldefrag(target)[0] + + if any([urlparse(target).netloc.endswith(_) for _ in ['example.com', 'example.net', 'example.org']]): + self.logger.debug("Not testing example address \"{0}\".".format(target)) + continue + + # absolute URL to root-relative + if target.startswith(base_url.geturl()): + target = target.replace(base_url.geturl(), '/') + parsed = urlparse(target) # Warn about links from https to http (mixed-security) if base_url.netloc == parsed.netloc and base_url.scheme == "https" and parsed.scheme == "http": - self.logger.warn("Mixed-content security for link in {0}: {1}".format(filename, target)) + self.logger.warning("Mixed-content security for link in {0}: {1}".format(filename, target)) + + # Link to an internal REDIRECTIONS page + if target in self.internal_redirects: + redir_status_code = 301 + redir_target = [_dest for _target, _dest in self.site.config['REDIRECTIONS'] if urljoin('/', _target) == target][0] + self.logger.warning("Remote link moved PERMANENTLY to \"{0}\" and should be updated in {1}: {2} [HTTP: 301]".format(redir_target, filename, target)) # Absolute links to other domains, skip # Absolute links when using only paths, skip. @@ -221,19 +264,17 @@ class CommandCheck(Command): ((parsed.scheme or target.startswith('//')) and url_type in ('rel_path', 'full_path')): if not check_remote or parsed.scheme not in ["http", "https"]: continue - if parsed.netloc == base_url.netloc: # absolute URL to self.site - continue if target in self.checked_remote_targets: # already checked this exact target - if self.checked_remote_targets[target] in [301, 307]: - self.logger.warn("Remote link PERMANENTLY redirected in {0}: {1} [Error {2}]".format(filename, target, self.checked_remote_targets[target])) - elif self.checked_remote_targets[target] in [302, 308]: - self.logger.info("Remote link temporarily redirected in {1}: {2} [HTTP: {3}]".format(filename, target, self.checked_remote_targets[target])) + if self.checked_remote_targets[target] in [301, 308]: + self.logger.warning("Remote link PERMANENTLY redirected in {0}: {1} [Error {2}]".format(filename, target, self.checked_remote_targets[target])) + elif self.checked_remote_targets[target] in [302, 307]: + self.logger.debug("Remote link temporarily redirected in {0}: {1} [HTTP: {2}]".format(filename, target, self.checked_remote_targets[target])) elif self.checked_remote_targets[target] > 399: self.logger.error("Broken link in {0}: {1} [Error {2}]".format(filename, target, self.checked_remote_targets[target])) continue # Skip whitelisted targets - if any(re.search(_, target) for _ in self.whitelist): + if any(pattern.search(target) for pattern in self.whitelist): continue # Check the remote link works @@ -253,9 +294,9 @@ class CommandCheck(Command): resp = requests.get(target, headers=req_headers, allow_redirects=True) # Permanent redirects should be updated if redir_status_code in [301, 308]: - self.logger.warn("Remote link moved PERMANENTLY to \"{0}\" and should be updated in {1}: {2} [HTTP: {3}]".format(resp.url, filename, target, redir_status_code)) + self.logger.warning("Remote link moved PERMANENTLY to \"{0}\" and should be updated in {1}: {2} [HTTP: {3}]".format(resp.url, filename, target, redir_status_code)) if redir_status_code in [302, 307]: - self.logger.info("Remote link temporarily redirected to \"{0}\" in {1}: {2} [HTTP: {3}]".format(resp.url, filename, target, redir_status_code)) + self.logger.debug("Remote link temporarily redirected to \"{0}\" in {1}: {2} [HTTP: {3}]".format(resp.url, filename, target, redir_status_code)) self.checked_remote_targets[resp.url] = resp.status_code self.checked_remote_targets[target] = redir_status_code else: @@ -267,7 +308,7 @@ class CommandCheck(Command): elif resp.status_code <= 399: # The address leads *somewhere* that is not an error self.logger.debug("Successfully checked remote link in {0}: {1} [HTTP: {2}]".format(filename, target, resp.status_code)) continue - self.logger.warn("Could not check remote link in {0}: {1} [Unknown problem]".format(filename, target)) + self.logger.warning("Could not check remote link in {0}: {1} [Unknown problem]".format(filename, target)) continue if url_type == 'rel_path': @@ -275,60 +316,95 @@ class CommandCheck(Command): target_filename = os.path.abspath( os.path.join(self.site.config['OUTPUT_FOLDER'], unquote(target.lstrip('/')))) else: # Relative path + unquoted_target = unquote(target).encode('utf-8') target_filename = os.path.abspath( - os.path.join(os.path.dirname(filename), unquote(target))) + os.path.join(os.path.dirname(filename).encode('utf-8'), unquoted_target)) - elif url_type in ('full_path', 'absolute'): + else: + relative = False if url_type == 'absolute': # convert to 'full_path' case, ie url relative to root - url_rel_path = parsed.path[len(url_netloc_to_root):] + if parsed.path.startswith(url_netloc_to_root): + url_rel_path = parsed.path[len(url_netloc_to_root):] + else: + url_rel_path = parsed.path + if not url_rel_path.startswith('/'): + relative = True else: # convert to relative to base path - url_rel_path = target[len(url_netloc_to_root):] + if target.startswith(url_netloc_to_root): + url_rel_path = target[len(url_netloc_to_root):] + else: + url_rel_path = target + if not url_rel_path.startswith('/'): + relative = True if url_rel_path == '' or url_rel_path.endswith('/'): url_rel_path = urljoin(url_rel_path, self.site.config['INDEX_FILE']) - fs_rel_path = fs_relpath_from_url_path(url_rel_path) - target_filename = os.path.join(self.site.config['OUTPUT_FOLDER'], fs_rel_path) + if relative: + unquoted_target = unquote(target).encode('utf-8') + target_filename = os.path.abspath( + os.path.join(os.path.dirname(filename).encode('utf-8'), unquoted_target)) + else: + fs_rel_path = fs_relpath_from_url_path(url_rel_path) + target_filename = os.path.join(self.site.config['OUTPUT_FOLDER'], fs_rel_path) - if any(re.search(x, target_filename) for x in self.whitelist): + if isinstance(target_filename, str): + target_filename_str = target_filename + else: + target_filename_str = target_filename.decode("utf-8", errors="surrogateescape") + + if any(pattern.search(target_filename_str) for pattern in self.whitelist): continue + elif target_filename not in self.existing_targets: if os.path.exists(target_filename): - self.logger.notice("Good link {0} => {1}".format(target, target_filename)) + self.logger.info("Good link {0} => {1}".format(target, target_filename)) self.existing_targets.add(target_filename) else: rv = True - self.logger.warn("Broken link in {0}: {1}".format(filename, target)) + self.logger.warning("Broken link in {0}: {1}".format(filename, target)) if find_sources: - self.logger.warn("Possible sources:") - self.logger.warn("\n".join(deps[filename])) - self.logger.warn("===============================\n") + self.logger.warning("Possible sources:") + self.logger.warning("\n".join(deps[filename])) + self.logger.warning("===============================\n") except Exception as exc: - self.logger.error("Error with: {0} {1}".format(filename, exc)) + self.logger.error(u"Error with: {0} {1}".format(filename, exc)) return rv def scan_links(self, find_sources=False, check_remote=False): """Check links on the site.""" - self.logger.info("Checking Links:") - self.logger.info("===============\n") - self.logger.notice("{0} mode".format(self.site.config['URL_TYPE'])) + self.logger.debug("Checking Links:") + self.logger.debug("===============\n") + self.logger.debug("{0} mode".format(self.site.config['URL_TYPE'])) failure = False + atom_extension = self.site.config['ATOM_EXTENSION'] # Maybe we should just examine all HTML files output_folder = self.site.config['OUTPUT_FOLDER'] - for fname in _call_nikola_list(self.site)[0]: - if fname.startswith(output_folder) and '.html' == fname[-5:]: - if self.analyze(fname, find_sources, check_remote): - failure = True + + if urlparse(self.site.config['BASE_URL']).netloc == 'example.com': + self.logger.error("You've not changed the SITE_URL (or BASE_URL) setting from \"example.com\"!") + + for fname in _call_nikola_list(self.site, self.cache)[0]: + if fname.startswith(output_folder): + if '.html' == fname[-5:]: + if self.analyze(fname, find_sources, check_remote): + failure = True + if atom_extension == fname[-len(atom_extension):]: + if self.analyze(fname, find_sources, False): + failure = True + if fname.endswith('sitemap.xml') or fname.endswith('sitemapindex.xml'): + if self.analyze(fname, find_sources, False): + failure = True if not failure: - self.logger.info("All links checked.") + self.logger.debug("All links checked.") return failure def scan_files(self): """Check files in the site, find missing and orphaned files.""" failure = False - self.logger.info("Checking Files:") - self.logger.info("===============\n") - only_on_output, only_on_input = real_scan_files(self.site) + self.logger.debug("Checking Files:") + self.logger.debug("===============\n") + only_on_output, only_on_input = real_scan_files(self.site, self.cache) # Ignore folders only_on_output = [p for p in only_on_output if not os.path.isdir(p)] @@ -336,26 +412,28 @@ class CommandCheck(Command): if only_on_output: only_on_output.sort() - self.logger.warn("Files from unknown origins (orphans):") + self.logger.warning("Files from unknown origins (orphans):") for f in only_on_output: - self.logger.warn(f) + self.logger.warning(f) failure = True if only_on_input: only_on_input.sort() - self.logger.warn("Files not generated:") + self.logger.warning("Files not generated:") for f in only_on_input: - self.logger.warn(f) + self.logger.warning(f) if not failure: - self.logger.info("All files checked.") + self.logger.debug("All files checked.") return failure def clean_files(self): """Remove orphaned files.""" - only_on_output, _ = real_scan_files(self.site) + only_on_output, _ = real_scan_files(self.site, self.cache) for f in only_on_output: - self.logger.info('removed: {0}'.format(f)) + self.logger.debug('removed: {0}'.format(f)) os.unlink(f) + warn_flag = bool(only_on_output) + # Find empty directories and remove them output_folder = self.site.config['OUTPUT_FOLDER'] all_dirs = [] @@ -365,7 +443,13 @@ class CommandCheck(Command): for d in all_dirs: try: os.rmdir(d) - self.logger.info('removed: {0}/'.format(d)) + self.logger.debug('removed: {0}/'.format(d)) + warn_flag = True except OSError: pass - return True + + if warn_flag: + self.logger.warning('Some files or directories have been removed, your site may need rebuilding') + return True + + return False diff --git a/nikola/plugins/command/console.plugin b/nikola/plugins/command/console.plugin index 333762c..35e3585 100644 --- a/nikola/plugins/command/console.plugin +++ b/nikola/plugins/command/console.plugin @@ -5,9 +5,9 @@ module = console [Documentation] author = Chris Warrick, Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Start a debugging python console [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/console.py b/nikola/plugins/command/console.py index 539fa08..b4342b4 100644 --- a/nikola/plugins/command/console.py +++ b/nikola/plugins/command/console.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Chris Warrick, Roberto Alsina and others. +# Copyright © 2012-2020 Chris Warrick, Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,28 +26,26 @@ """Start debugging console.""" -from __future__ import print_function, unicode_literals import os from nikola import __version__ from nikola.plugin_categories import Command -from nikola.utils import get_logger, STDERR_HANDLER, req_missing, Commands +from nikola.utils import get_logger, req_missing, Commands -LOGGER = get_logger('console', STDERR_HANDLER) +LOGGER = get_logger('console') class CommandConsole(Command): - """Start debugging console.""" name = "console" shells = ['ipython', 'bpython', 'plain'] doc_purpose = "start an interactive Python console with access to your site" doc_description = """\ -The site engine is accessible as `site`, the config file as `conf`, and commands are available as `commands`. +The site engine is accessible as `site` and `nikola_site`, the config file as `conf`, and commands are available as `commands`. If there is no console to use specified (as -b, -i, -p) it tries IPython, then falls back to bpython, and finally falls back to the plain Python console.""" - header = "Nikola v" + __version__ + " -- {0} Console (conf = configuration file, site = site engine, commands = nikola commands)" + header = "Nikola v" + __version__ + " -- {0} Console (conf = configuration file, site, nikola_site = site engine, commands = nikola commands)" cmd_options = [ { 'name': 'bpython', @@ -73,35 +71,52 @@ If there is no console to use specified (as -b, -i, -p) it tries IPython, then f 'default': False, 'help': 'Use the plain Python interpreter', }, + { + 'name': 'command', + 'short': 'c', + 'long': 'command', + 'type': str, + 'default': None, + 'help': 'Run a single command', + }, + { + 'name': 'script', + 'short': 's', + 'long': 'script', + 'type': str, + 'default': None, + 'help': 'Execute a python script in the console context', + }, ] def ipython(self, willful=True): - """IPython shell.""" + """Run an IPython shell.""" try: import IPython - except ImportError as e: + except ImportError: if willful: req_missing(['IPython'], 'use the IPython console') - raise e # That’s how _execute knows whether to try something else. + raise # That’s how _execute knows whether to try something else. else: site = self.context['site'] # NOQA + nikola_site = self.context['nikola_site'] # NOQA conf = self.context['conf'] # NOQA commands = self.context['commands'] # NOQA IPython.embed(header=self.header.format('IPython')) def bpython(self, willful=True): - """bpython shell.""" + """Run a bpython shell.""" try: import bpython - except ImportError as e: + except ImportError: if willful: req_missing(['bpython'], 'use the bpython console') - raise e # That’s how _execute knows whether to try something else. + raise # That’s how _execute knows whether to try something else. else: bpython.embed(banner=self.header.format('bpython'), locals_=self.context) def plain(self, willful=True): - """Plain Python shell.""" + """Run a plain Python shell.""" import code try: import readline @@ -131,9 +146,16 @@ If there is no console to use specified (as -b, -i, -p) it tries IPython, then f self.context = { 'conf': self.site.config, 'site': self.site, + 'nikola_site': self.site, 'commands': self.site.commands, } - if options['bpython']: + if options['command']: + exec(options['command'], None, self.context) + elif options['script']: + with open(options['script']) as inf: + code = compile(inf.read(), options['script'], 'exec') + exec(code, None, self.context) + elif options['bpython']: self.bpython(True) elif options['ipython']: self.ipython(True) diff --git a/nikola/plugins/command/default_config.plugin b/nikola/plugins/command/default_config.plugin new file mode 100644 index 0000000..af279f6 --- /dev/null +++ b/nikola/plugins/command/default_config.plugin @@ -0,0 +1,13 @@ +[Core] +name = default_config +module = default_config + +[Documentation] +author = Roberto Alsina +version = 1.0 +website = https://getnikola.com/ +description = Show the default configuration. + +[Nikola] +PluginCategory = Command + diff --git a/nikola/plugins/command/default_config.py b/nikola/plugins/command/default_config.py new file mode 100644 index 0000000..036f4d1 --- /dev/null +++ b/nikola/plugins/command/default_config.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Show the default configuration.""" + +import sys + +import nikola.plugins.command.init +from nikola.plugin_categories import Command +from nikola.utils import get_logger + + +LOGGER = get_logger('default_config') + + +class CommandShowConfig(Command): + """Show the default configuration.""" + + name = "default_config" + + doc_usage = "" + needs_config = False + doc_purpose = "Print the default Nikola configuration." + cmd_options = [] + + def _execute(self, options=None, args=None): + """Show the default configuration.""" + try: + print(nikola.plugins.command.init.CommandInit.create_configuration_to_string()) + except Exception: + sys.stdout.buffer.write(nikola.plugins.command.init.CommandInit.create_configuration_to_string().encode('utf-8')) diff --git a/nikola/plugins/command/deploy.plugin b/nikola/plugins/command/deploy.plugin index 4743ca2..7cff28d 100644 --- a/nikola/plugins/command/deploy.plugin +++ b/nikola/plugins/command/deploy.plugin @@ -5,9 +5,9 @@ module = deploy [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Deploy the site [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/deploy.py b/nikola/plugins/command/deploy.py index 821ea11..5273b58 100644 --- a/nikola/plugins/command/deploy.py +++ b/nikola/plugins/command/deploy.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,57 +26,48 @@ """Deploy site.""" -from __future__ import print_function -import io -from datetime import datetime -from dateutil.tz import gettz -import os import subprocess import time +from datetime import datetime +import dateutil from blinker import signal +from dateutil.tz import gettz from nikola.plugin_categories import Command -from nikola.utils import get_logger, remove_file, unicode_str, makedirs, STDERR_HANDLER +from nikola.utils import clean_before_deployment class CommandDeploy(Command): - """Deploy site.""" name = "deploy" - doc_usage = "[[preset [preset...]]" + doc_usage = "[preset [preset...]]" doc_purpose = "deploy the site" doc_description = "Deploy the site by executing deploy commands from the presets listed on the command line. If no presets are specified, `default` is executed." - logger = None def _execute(self, command, args): """Execute the deploy command.""" - self.logger = get_logger('deploy', STDERR_HANDLER) - # Get last successful deploy date - timestamp_path = os.path.join(self.site.config['CACHE_FOLDER'], 'lastdeploy') - if self.site.config['COMMENT_SYSTEM_ID'] == 'nikolademo': - self.logger.warn("\nWARNING WARNING WARNING WARNING\n" - "You are deploying using the nikolademo Disqus account.\n" - "That means you will not be able to moderate the comments in your own site.\n" - "And is probably not what you want to do.\n" - "Think about it for 5 seconds, I'll wait :-)\n\n") + # Get last-deploy from persistent state + last_deploy = self.site.state.get('last_deploy') + if last_deploy is not None: + last_deploy = dateutil.parser.parse(last_deploy) + clean = False + + if self.site.config['COMMENT_SYSTEM'] and self.site.config['COMMENT_SYSTEM_ID'] == 'nikolademo': + self.logger.warning("\nWARNING WARNING WARNING WARNING\n" + "You are deploying using the nikolademo Disqus account.\n" + "That means you will not be able to moderate the comments in your own site.\n" + "And is probably not what you want to do.\n" + "Think about it for 5 seconds, I'll wait :-)\n" + "(press Ctrl+C to abort)\n") time.sleep(5) - deploy_drafts = self.site.config.get('DEPLOY_DRAFTS', True) - deploy_future = self.site.config.get('DEPLOY_FUTURE', False) - undeployed_posts = [] - if not (deploy_drafts and deploy_future): - # Remove drafts and future posts - out_dir = self.site.config['OUTPUT_FOLDER'] - self.site.scan_posts() - for post in self.site.timeline: - if (not deploy_drafts and post.is_draft) or \ - (not deploy_future and post.publish_later): - remove_file(os.path.join(out_dir, post.destination_path())) - remove_file(os.path.join(out_dir, post.source_path)) - undeployed_posts.append(post) + # Remove drafts and future posts if requested + undeployed_posts = clean_before_deployment(self.site) + if undeployed_posts: + self.logger.warning("Deleted {0} posts due to DEPLOY_* settings".format(len(undeployed_posts))) if args: presets = args @@ -87,7 +78,7 @@ class CommandDeploy(Command): for preset in presets: try: self.site.config['DEPLOY_COMMANDS'][preset] - except: + except KeyError: self.logger.error('No such preset: {0}'.format(preset)) return 255 @@ -98,27 +89,22 @@ class CommandDeploy(Command): try: subprocess.check_call(command, shell=True) except subprocess.CalledProcessError as e: - self.logger.error('Failed deployment — command {0} ' + self.logger.error('Failed deployment -- command {0} ' 'returned {1}'.format(e.cmd, e.returncode)) return e.returncode self.logger.info("Successful deployment") - try: - with io.open(timestamp_path, 'r', encoding='utf8') as inf: - last_deploy = datetime.strptime(inf.read().strip(), "%Y-%m-%dT%H:%M:%S.%f") - clean = False - except (IOError, Exception) as e: - self.logger.debug("Problem when reading `{0}`: {1}".format(timestamp_path, e)) - last_deploy = datetime(1970, 1, 1) - clean = True new_deploy = datetime.utcnow() self._emit_deploy_event(last_deploy, new_deploy, clean, undeployed_posts) - makedirs(self.site.config['CACHE_FOLDER']) # Store timestamp of successful deployment - with io.open(timestamp_path, 'w+', encoding='utf8') as outf: - outf.write(unicode_str(new_deploy.isoformat())) + self.site.state.set('last_deploy', new_deploy.isoformat()) + if clean: + self.logger.info( + 'Looks like this is the first time you deployed this site. ' + 'Let us know you are using Nikola ' + 'at <https://users.getnikola.com/add/> if you want!') def _emit_deploy_event(self, last_deploy, new_deploy, clean=False, undeployed=None): """Emit events for all timeline entries newer than last deploy. diff --git a/nikola/plugins/command/github_deploy.plugin b/nikola/plugins/command/github_deploy.plugin index e793548..fbdd3bf 100644 --- a/nikola/plugins/command/github_deploy.plugin +++ b/nikola/plugins/command/github_deploy.plugin @@ -5,9 +5,9 @@ module = github_deploy [Documentation] author = Puneeth Chaganti version = 1,0 -website = http://getnikola.com +website = https://getnikola.com/ description = Deploy the site to GitHub pages. [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/github_deploy.py b/nikola/plugins/command/github_deploy.py index 0ab9332..d2c1f3f 100644 --- a/nikola/plugins/command/github_deploy.py +++ b/nikola/plugins/command/github_deploy.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2014-2015 Puneeth Chaganti and others. +# Copyright © 2014-2020 Puneeth Chaganti and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,16 +26,13 @@ """Deploy site to GitHub Pages.""" -from __future__ import print_function -from datetime import datetime -import io import os import subprocess from textwrap import dedent from nikola.plugin_categories import Command from nikola.plugins.command.check import real_scan_files -from nikola.utils import get_logger, req_missing, makedirs, unicode_str, STDERR_HANDLER +from nikola.utils import req_missing, clean_before_deployment from nikola.__main__ import main from nikola import __version__ @@ -53,32 +50,41 @@ def check_ghp_import_installed(): except OSError: # req_missing defaults to `python=True` — and it’s meant to be like this. # `ghp-import` is installed via pip, but the only way to use it is by executing the script it installs. - req_missing(['ghp-import'], 'deploy the site to GitHub Pages') + req_missing(['ghp-import2'], 'deploy the site to GitHub Pages') -class CommandGitHubDeploy(Command): +class DeployFailedException(Exception): + """An internal exception for deployment errors.""" + + pass + +class CommandGitHubDeploy(Command): """Deploy site to GitHub Pages.""" name = 'github_deploy' - doc_usage = '' + doc_usage = '[-m COMMIT_MESSAGE]' doc_purpose = 'deploy the site to GitHub Pages' doc_description = dedent( """\ - This command can be used to deploy your site to GitHub Pages. + This command can be used to deploy your site to GitHub Pages. It uses ghp-import to do this task. It also optionally commits to the source branch. - It uses ghp-import to do this task. - - """ + Configuration help: https://getnikola.com/handbook.html#deploying-to-github""" ) - - logger = None - - def _execute(self, command, args): + cmd_options = [ + { + 'name': 'commit_message', + 'short': 'm', + 'long': 'message', + 'default': 'Nikola auto commit.', + 'type': str, + 'help': 'Commit message', + }, + ] + + def _execute(self, options, args): """Run the deployment.""" - self.logger = get_logger(CommandGitHubDeploy.name, STDERR_HANDLER) - # Check if ghp-import is installed check_ghp_import_installed() @@ -93,41 +99,74 @@ class CommandGitHubDeploy(Command): for f in only_on_output: os.unlink(f) - # Commit and push - self._commit_and_push() - - return + # Remove drafts and future posts if requested (Issue #2406) + undeployed_posts = clean_before_deployment(self.site) + if undeployed_posts: + self.logger.warning("Deleted {0} posts due to DEPLOY_* settings".format(len(undeployed_posts))) - def _commit_and_push(self): - """Commit all the files and push.""" - source = self.site.config['GITHUB_SOURCE_BRANCH'] - deploy = self.site.config['GITHUB_DEPLOY_BRANCH'] - remote = self.site.config['GITHUB_REMOTE_NAME'] - source_commit = uni_check_output(['git', 'rev-parse', source]) - commit_message = ( - 'Nikola auto commit.\n\n' - 'Source commit: %s' - 'Nikola version: %s' % (source_commit, __version__) - ) - output_folder = self.site.config['OUTPUT_FOLDER'] - - command = ['ghp-import', '-n', '-m', commit_message, '-p', '-r', remote, '-b', deploy, output_folder] + # Commit and push + return self._commit_and_push(options['commit_message']) + def _run_command(self, command, xfail=False): + """Run a command that may or may not fail.""" self.logger.info("==> {0}".format(command)) try: subprocess.check_call(command) + return 0 except subprocess.CalledProcessError as e: + if xfail: + return e.returncode self.logger.error( - 'Failed GitHub deployment — command {0} ' + 'Failed GitHub deployment -- command {0} ' 'returned {1}'.format(e.cmd, e.returncode) ) - return e.returncode + raise DeployFailedException(e.returncode) - self.logger.info("Successful deployment") + def _commit_and_push(self, commit_first_line): + """Commit all the files and push.""" + source = self.site.config['GITHUB_SOURCE_BRANCH'] + deploy = self.site.config['GITHUB_DEPLOY_BRANCH'] + remote = self.site.config['GITHUB_REMOTE_NAME'] + autocommit = self.site.config['GITHUB_COMMIT_SOURCE'] + try: + if autocommit: + commit_message = ( + '{0}\n\n' + 'Nikola version: {1}'.format(commit_first_line, __version__) + ) + e = self._run_command(['git', 'checkout', source], True) + if e != 0: + self._run_command(['git', 'checkout', '-b', source]) + self._run_command(['git', 'add', '.']) + # Figure out if there is anything to commit + e = self._run_command(['git', 'diff-index', '--quiet', 'HEAD'], True) + if e != 0: + self._run_command(['git', 'commit', '-am', commit_message]) + else: + self.logger.info('Nothing to commit to source branch.') + + try: + source_commit = uni_check_output(['git', 'rev-parse', source]) + except subprocess.CalledProcessError: + try: + source_commit = uni_check_output(['git', 'rev-parse', 'HEAD']) + except subprocess.CalledProcessError: + source_commit = '?' + + commit_message = ( + '{0}\n\n' + 'Source commit: {1}' + 'Nikola version: {2}'.format(commit_first_line, source_commit, __version__) + ) + output_folder = self.site.config['OUTPUT_FOLDER'] + + command = ['ghp-import', '-n', '-m', commit_message, '-p', '-r', remote, '-b', deploy, output_folder] - # Store timestamp of successful deployment - timestamp_path = os.path.join(self.site.config["CACHE_FOLDER"], "lastdeploy") - new_deploy = datetime.utcnow() - makedirs(self.site.config["CACHE_FOLDER"]) - with io.open(timestamp_path, "w+", encoding="utf8") as outf: - outf.write(unicode_str(new_deploy.isoformat())) + self._run_command(command) + + if autocommit: + self._run_command(['git', 'push', '-u', remote, source]) + except DeployFailedException as e: + return e.args[0] + + self.logger.info("Successful deployment") diff --git a/nikola/plugins/command/import_wordpress.plugin b/nikola/plugins/command/import_wordpress.plugin index 6c4384e..46df1ef 100644 --- a/nikola/plugins/command/import_wordpress.plugin +++ b/nikola/plugins/command/import_wordpress.plugin @@ -5,9 +5,9 @@ module = import_wordpress [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Import a wordpress site from a XML dump (requires markdown). [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/import_wordpress.py b/nikola/plugins/command/import_wordpress.py index a652ec8..5e2aee6 100644 --- a/nikola/plugins/command/import_wordpress.py +++ b/nikola/plugins/command/import_wordpress.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,41 +26,45 @@ """Import a WordPress dump.""" -from __future__ import unicode_literals, print_function -import os -import re -import sys import datetime import io import json +import os +import re +import sys +from collections import defaultdict +from urllib.parse import urlparse, unquote + import requests from lxml import etree -from collections import defaultdict + +from nikola.plugin_categories import Command +from nikola import utils, hierarchy_utils +from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN +from nikola.utils import req_missing +from nikola.plugins.basic_import import ImportMixin, links +from nikola.plugins.command.init import ( + SAMPLE_CONF, prepare_config, + format_default_translations_config, + get_default_translations_dict +) try: - from urlparse import urlparse - from urllib import unquote + import html2text except ImportError: - from urllib.parse import urlparse, unquote # NOQA + html2text = None try: import phpserialize except ImportError: - phpserialize = None # NOQA + phpserialize = None -from nikola.plugin_categories import Command -from nikola import utils -from nikola.utils import req_missing -from nikola.plugins.basic_import import ImportMixin, links -from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN -from nikola.plugins.command.init import SAMPLE_CONF, prepare_config, format_default_translations_config - -LOGGER = utils.get_logger('import_wordpress', utils.STDERR_HANDLER) +LOGGER = utils.get_logger('import_wordpress') def install_plugin(site, plugin_name, output_dir=None, show_install_notes=False): """Install a Nikola plugin.""" - LOGGER.notice("Installing plugin '{0}'".format(plugin_name)) + LOGGER.info("Installing plugin '{0}'".format(plugin_name)) # Get hold of the 'plugin' plugin plugin_installer_info = site.plugin_manager.getPluginByName('plugin', 'Command') if plugin_installer_info is None: @@ -88,7 +92,6 @@ def install_plugin(site, plugin_name, output_dir=None, show_install_notes=False) class CommandImportWordpress(Command, ImportMixin): - """Import a WordPress dump.""" name = "import_wordpress" @@ -144,15 +147,22 @@ class CommandImportWordpress(Command, ImportMixin): 'long': 'qtranslate', 'default': False, 'type': bool, - 'help': "Look for translations generated by qtranslate plugin", - # WARNING: won't recover translated titles that actually - # don't seem to be part of the wordpress XML export at the - # time of writing :( + 'help': """Look for translations generated by qtranslate plugin. +WARNING: a default wordpress export won't allow to recover title translations. +For this to be possible consider applying the hack suggested at +https://github.com/qtranslate/qtranslate-xt/issues/199 : + +In wp-admin/includes/export.php change +`echo apply_filters( 'the_title_rss', $post->post_title ); + +to +`echo apply_filters( 'the_title_export', $post->post_title ); +""" }, { 'name': 'translations_pattern', 'long': 'translations_pattern', - 'default': None, + 'default': DEFAULT_TRANSLATIONS_PATTERN, 'type': str, 'help': "The pattern for translation files names", }, @@ -170,6 +180,20 @@ class CommandImportWordpress(Command, ImportMixin): 'type': bool, 'help': "Export comments as .wpcomment files", }, + { + 'name': 'html2text', + 'long': 'html2text', + 'default': False, + 'type': bool, + 'help': "Uses html2text (needs to be installed with pip) to transform WordPress posts to MarkDown during import", + }, + { + 'name': 'transform_to_markdown', + 'long': 'transform-to-markdown', + 'default': False, + 'type': bool, + 'help': "Uses WordPress page compiler to transform WordPress posts to HTML and then use html2text to transform them to MarkDown during import", + }, { 'name': 'transform_to_html', 'long': 'transform-to-html', @@ -191,9 +215,36 @@ class CommandImportWordpress(Command, ImportMixin): 'type': bool, 'help': "Automatically installs the WordPress page compiler (either locally or in the new site) if required by other options.\nWarning: the compiler is GPL software!", }, + { + 'name': 'tag_sanitizing_strategy', + 'long': 'tag-sanitizing-strategy', + 'default': 'first', + 'help': 'lower: Convert all tag and category names to lower case\nfirst: Keep first spelling of tag or category name', + }, + { + 'name': 'one_file', + 'long': 'one-file', + 'default': False, + 'type': bool, + 'help': "Save imported posts in the more modern one-file format.", + }, ] all_tags = set([]) + def _get_compiler(self): + """Return whatever compiler we will use.""" + self._find_wordpress_compiler() + if self.wordpress_page_compiler is not None: + return self.wordpress_page_compiler + plugin_info = self.site.plugin_manager.getPluginByName('markdown', 'PageCompiler') + if plugin_info is not None: + if not plugin_info.is_activated: + self.site.plugin_manager.activatePluginByName(plugin_info.name) + plugin_info.plugin_object.set_site(self.site) + return plugin_info.plugin_object + else: + LOGGER.error("Can't find markdown post compiler.") + def _find_wordpress_compiler(self): """Find WordPress compiler plugin.""" if self.wordpress_page_compiler is not None: @@ -214,9 +265,11 @@ class CommandImportWordpress(Command, ImportMixin): options['output_folder'] = args.pop(0) if args: - LOGGER.warn('You specified additional arguments ({0}). Please consider ' - 'putting these arguments before the filename if you ' - 'are running into problems.'.format(args)) + LOGGER.warning('You specified additional arguments ({0}). Please consider ' + 'putting these arguments before the filename if you ' + 'are running into problems.'.format(args)) + + self.onefile = options.get('one_file', False) self.import_into_existing_site = False self.url_map = {} @@ -234,11 +287,16 @@ class CommandImportWordpress(Command, ImportMixin): self.export_categories_as_categories = options.get('export_categories_as_categories', False) self.export_comments = options.get('export_comments', False) + self.html2text = options.get('html2text', False) + self.transform_to_markdown = options.get('transform_to_markdown', False) + self.transform_to_html = options.get('transform_to_html', False) self.use_wordpress_compiler = options.get('use_wordpress_compiler', False) self.install_wordpress_compiler = options.get('install_wordpress_compiler', False) self.wordpress_page_compiler = None + self.tag_saniziting_strategy = options.get('tag_saniziting_strategy', 'first') + self.auth = None if options.get('download_auth') is not None: username_password = options.get('download_auth') @@ -250,10 +308,18 @@ class CommandImportWordpress(Command, ImportMixin): self.separate_qtranslate_content = options.get('separate_qtranslate_content') self.translations_pattern = options.get('translations_pattern') - if self.transform_to_html and self.use_wordpress_compiler: - LOGGER.warn("It does not make sense to combine --transform-to-html with --use-wordpress-compiler, as the first converts all posts to HTML and the latter option affects zero posts.") + count = (1 if self.html2text else 0) + (1 if self.transform_to_html else 0) + (1 if self.transform_to_markdown else 0) + if count > 1: + LOGGER.error("You can use at most one of the options --html2text, --transform-to-html and --transform-to-markdown.") + return False + if (self.html2text or self.transform_to_html or self.transform_to_markdown) and self.use_wordpress_compiler: + LOGGER.warning("It does not make sense to combine --use-wordpress-compiler with any of --html2text, --transform-to-html and --transform-to-markdown, as the latter convert all posts to HTML and the first option then affects zero posts.") + + if (self.html2text or self.transform_to_markdown) and not html2text: + LOGGER.error("You need to install html2text via 'pip install html2text' before you can use the --html2text and --transform-to-markdown options.") + return False - if self.transform_to_html: + if self.transform_to_html or self.transform_to_markdown: self._find_wordpress_compiler() if not self.wordpress_page_compiler and self.install_wordpress_compiler: if not install_plugin(self.site, 'wordpress_compiler', output_dir='plugins'): # local install @@ -279,14 +345,14 @@ class CommandImportWordpress(Command, ImportMixin): # cat_id = get_text_tag(cat, '{{{0}}}term_id'.format(wordpress_namespace), None) cat_slug = get_text_tag(cat, '{{{0}}}category_nicename'.format(wordpress_namespace), None) cat_parent_slug = get_text_tag(cat, '{{{0}}}category_parent'.format(wordpress_namespace), None) - cat_name = get_text_tag(cat, '{{{0}}}cat_name'.format(wordpress_namespace), None) + cat_name = utils.html_unescape(get_text_tag(cat, '{{{0}}}cat_name'.format(wordpress_namespace), None)) cat_path = [cat_name] if cat_parent_slug in cat_map: cat_path = cat_map[cat_parent_slug] + cat_path cat_map[cat_slug] = cat_path self._category_paths = dict() for cat, path in cat_map.items(): - self._category_paths[cat] = utils.join_hierarchical_category_path(path) + self._category_paths[cat] = hierarchy_utils.join_hierarchical_category_path(path) def _execute(self, options={}, args=[]): """Import a WordPress blog from an export file into a Nikola site.""" @@ -313,21 +379,16 @@ class CommandImportWordpress(Command, ImportMixin): if phpserialize is None: req_missing(['phpserialize'], 'import WordPress dumps without --no-downloads') - channel = self.get_channel_from_file(self.wordpress_export_file) + export_file_preprocessor = modernize_qtranslate_tags if self.separate_qtranslate_content else None + channel = self.get_channel_from_file(self.wordpress_export_file, export_file_preprocessor) self._prepare(channel) conf_template = self.generate_base_site() - # If user has specified a custom pattern for translation files we - # need to fix the config - if self.translations_pattern: - self.context['TRANSLATIONS_PATTERN'] = self.translations_pattern - self.import_posts(channel) - self.context['TRANSLATIONS'] = format_default_translations_config( self.extra_languages) self.context['REDIRECTIONS'] = self.configure_redirections( - self.url_map) + self.url_map, self.base_dir) if self.timezone: self.context['TIMEZONE'] = self.timezone if self.export_categories_as_categories: @@ -337,10 +398,13 @@ class CommandImportWordpress(Command, ImportMixin): # Add tag redirects for tag in self.all_tags: try: - tag_str = tag.decode('utf8') + if isinstance(tag, bytes): + tag_str = tag.decode('utf8', 'replace') + else: + tag_str = tag except AttributeError: tag_str = tag - tag = utils.slugify(tag_str) + tag = utils.slugify(tag_str, self.lang) src_url = '{}tag/{}'.format(self.context['SITE_URL'], tag) dst_url = self.site.link('tag', tag) if src_url != dst_url: @@ -357,9 +421,9 @@ class CommandImportWordpress(Command, ImportMixin): if not install_plugin(self.site, 'wordpress_compiler', output_dir=os.path.join(self.output_folder, 'plugins')): return False else: - LOGGER.warn("Make sure to install the WordPress page compiler via") - LOGGER.warn(" nikola plugin -i wordpress_compiler") - LOGGER.warn("in your imported blog's folder ({0}), if you haven't installed it system-wide or user-wide. Otherwise, your newly imported blog won't compile.".format(self.output_folder)) + LOGGER.warning("Make sure to install the WordPress page compiler via") + LOGGER.warning(" nikola plugin -i wordpress_compiler") + LOGGER.warning("in your imported blog's folder ({0}), if you haven't installed it system-wide or user-wide. Otherwise, your newly imported blog won't compile.".format(self.output_folder)) @classmethod def read_xml_file(cls, filename): @@ -372,12 +436,19 @@ class CommandImportWordpress(Command, ImportMixin): if b'<atom:link rel=' in line: continue xml.append(line) - return b'\n'.join(xml) + return b''.join(xml) @classmethod - def get_channel_from_file(cls, filename): - """Get channel from XML file.""" - tree = etree.fromstring(cls.read_xml_file(filename)) + def get_channel_from_file(cls, filename, xml_preprocessor=None): + """Get channel from XML file. + + An optional 'xml_preprocessor' allows to modify the xml + (typically to deal with variations in tags injected by some WP plugin) + """ + xml_string = cls.read_xml_file(filename) + if xml_preprocessor: + xml_string = xml_preprocessor(xml_string) + tree = etree.fromstring(xml_string) channel = tree.find('channel') return channel @@ -386,8 +457,12 @@ class CommandImportWordpress(Command, ImportMixin): wordpress_namespace = channel.nsmap['wp'] context = SAMPLE_CONF.copy() - context['DEFAULT_LANG'] = get_text_tag(channel, 'language', 'en')[:2] - context['TRANSLATIONS_PATTERN'] = DEFAULT_TRANSLATIONS_PATTERN + self.lang = get_text_tag(channel, 'language', 'en')[:2] + context['DEFAULT_LANG'] = self.lang + # If user has specified a custom pattern for translation files we + # need to fix the config + context['TRANSLATIONS_PATTERN'] = self.translations_pattern + context['BLOG_TITLE'] = get_text_tag(channel, 'title', 'PUT TITLE HERE') context['BLOG_DESCRIPTION'] = get_text_tag( @@ -418,17 +493,17 @@ class CommandImportWordpress(Command, ImportMixin): PAGES = '(\n' for extension in extensions: POSTS += ' ("posts/*.{0}", "posts", "post.tmpl"),\n'.format(extension) - PAGES += ' ("stories/*.{0}", "stories", "story.tmpl"),\n'.format(extension) + PAGES += ' ("pages/*.{0}", "pages", "page.tmpl"),\n'.format(extension) POSTS += ')\n' PAGES += ')\n' context['POSTS'] = POSTS context['PAGES'] = PAGES COMPILERS = '{\n' - COMPILERS += ''' "rest": ('.txt', '.rst'),''' + '\n' - COMPILERS += ''' "markdown": ('.md', '.mdown', '.markdown'),''' + '\n' - COMPILERS += ''' "html": ('.html', '.htm'),''' + '\n' + COMPILERS += ''' "rest": ['.txt', '.rst'],''' + '\n' + COMPILERS += ''' "markdown": ['.md', '.mdown', '.markdown'],''' + '\n' + COMPILERS += ''' "html": ['.html', '.htm'],''' + '\n' if self.use_wordpress_compiler: - COMPILERS += ''' "wordpress": ('.wp'),''' + '\n' + COMPILERS += ''' "wordpress": ['.wp'],''' + '\n' COMPILERS += '}' context['COMPILERS'] = COMPILERS @@ -436,18 +511,15 @@ class CommandImportWordpress(Command, ImportMixin): def download_url_content_to_file(self, url, dst_path): """Download some content (attachments) to a file.""" - if self.no_downloads: - return - try: request = requests.get(url, auth=self.auth) if request.status_code >= 400: - LOGGER.warn("Downloading {0} to {1} failed with HTTP status code {2}".format(url, dst_path, request.status_code)) + LOGGER.warning("Downloading {0} to {1} failed with HTTP status code {2}".format(url, dst_path, request.status_code)) return with open(dst_path, 'wb+') as fd: fd.write(request.content) except requests.exceptions.ConnectionError as err: - LOGGER.warn("Downloading {0} to {1} failed: {2}".format(url, dst_path, err)) + LOGGER.warning("Downloading {0} to {1} failed: {2}".format(url, dst_path, err)) def import_attachment(self, item, wordpress_namespace): """Import an attachment to the site.""" @@ -458,10 +530,13 @@ class CommandImportWordpress(Command, ImportMixin): 'foo') path = urlparse(url).path dst_path = os.path.join(*([self.output_folder, 'files'] + list(path.split('/')))) - dst_dir = os.path.dirname(dst_path) - utils.makedirs(dst_dir) - LOGGER.info("Downloading {0} => {1}".format(url, dst_path)) - self.download_url_content_to_file(url, dst_path) + if self.no_downloads: + LOGGER.info("Skipping downloading {0} => {1}".format(url, dst_path)) + else: + dst_dir = os.path.dirname(dst_path) + utils.makedirs(dst_dir) + LOGGER.info("Downloading {0} => {1}".format(url, dst_path)) + self.download_url_content_to_file(url, dst_path) dst_url = '/'.join(dst_path.split(os.sep)[2:]) links[link] = '/' + dst_url links[url] = '/' + dst_url @@ -485,14 +560,7 @@ class CommandImportWordpress(Command, ImportMixin): # that the export should give you the power to insert # your blogging into another site or system its not. # Why don't they just use JSON? - if sys.version_info[0] == 2: - try: - metadata = phpserialize.loads(utils.sys_encode(meta_value.text)) - except ValueError: - # local encoding might be wrong sometimes - metadata = phpserialize.loads(meta_value.text.encode('utf-8')) - else: - metadata = phpserialize.loads(meta_value.text.encode('utf-8')) + metadata = phpserialize.loads(meta_value.text.encode('utf-8')) meta_key = b'image_meta' size_key = b'sizes' @@ -507,6 +575,8 @@ class CommandImportWordpress(Command, ImportMixin): if meta_key in metadata: image_meta = metadata[meta_key] + if not image_meta: + continue dst_meta = {} def add(our_key, wp_key, is_int=False, ignore_zero=False, is_float=False): @@ -517,6 +587,9 @@ class CommandImportWordpress(Command, ImportMixin): if ignore_zero and value == 0: return elif is_float: + # in some locales (like fr) and for old posts there may be a comma here. + if isinstance(value, bytes): + value = value.replace(b",", b".") value = float(value) if ignore_zero and value == 0: return @@ -552,15 +625,18 @@ class CommandImportWordpress(Command, ImportMixin): meta = {} meta['size'] = size.decode('utf-8') if width_key in metadata[size_key][size] and height_key in metadata[size_key][size]: - meta['width'] = metadata[size_key][size][width_key] - meta['height'] = metadata[size_key][size][height_key] + meta['width'] = int(metadata[size_key][size][width_key]) + meta['height'] = int(metadata[size_key][size][height_key]) path = urlparse(url).path dst_path = os.path.join(*([self.output_folder, 'files'] + list(path.split('/')))) - dst_dir = os.path.dirname(dst_path) - utils.makedirs(dst_dir) - LOGGER.info("Downloading {0} => {1}".format(url, dst_path)) - self.download_url_content_to_file(url, dst_path) + if self.no_downloads: + LOGGER.info("Skipping downloading {0} => {1}".format(url, dst_path)) + else: + dst_dir = os.path.dirname(dst_path) + utils.makedirs(dst_dir) + LOGGER.info("Downloading {0} => {1}".format(url, dst_path)) + self.download_url_content_to_file(url, dst_path) dst_url = '/'.join(dst_path.split(os.sep)[2:]) links[url] = '/' + dst_url @@ -604,7 +680,7 @@ class CommandImportWordpress(Command, ImportMixin): def transform_code(self, content): """Transform code blocks.""" - # http://en.support.wordpress.com/code/posting-source-code/. There are + # https://en.support.wordpress.com/code/posting-source-code/. There are # a ton of things not supported here. We only do a basic [code # lang="x"] -> ```x translation, and remove quoted html entities (<, # >, &, and "). @@ -628,10 +704,10 @@ class CommandImportWordpress(Command, ImportMixin): return content @staticmethod - def transform_caption(content): + def transform_caption(content, use_html=False): """Transform captions.""" - new_caption = re.sub(r'\[/caption\]', '', content) - new_caption = re.sub(r'\[caption.*\]', '', new_caption) + new_caption = re.sub(r'\[/caption\]', '</h1>' if use_html else '', content) + new_caption = re.sub(r'\[caption.*\]', '<h1>' if use_html else '', new_caption) return new_caption @@ -654,6 +730,26 @@ class CommandImportWordpress(Command, ImportMixin): except TypeError: # old versions of the plugin don't support the additional argument content = self.wordpress_page_compiler.compile_to_string(content) return content, 'html', True + elif self.transform_to_markdown: + # First convert to HTML with WordPress plugin + additional_data = {} + if attachments is not None: + additional_data['attachments'] = attachments + try: + content = self.wordpress_page_compiler.compile_to_string(content, additional_data=additional_data) + except TypeError: # old versions of the plugin don't support the additional argument + content = self.wordpress_page_compiler.compile_to_string(content) + # Now convert to MarkDown with html2text + h = html2text.HTML2Text() + content = h.handle(content) + return content, 'md', False + elif self.html2text: + # TODO: what to do with [code] blocks? + # content = self.transform_code(content) + content = self.transform_caption(content, use_html=True) + h = html2text.HTML2Text() + content = h.handle(content) + return content, 'md', False elif self.use_wordpress_compiler: return content, 'wp', False else: @@ -686,7 +782,7 @@ class CommandImportWordpress(Command, ImportMixin): elif approved == 'spam' or approved == 'trash': pass else: - LOGGER.warn("Unknown comment approved status: " + str(approved)) + LOGGER.warning("Unknown comment approved status: {0}".format(approved)) parent = int(get_text_tag(comment, "{{{0}}}comment_parent".format(wordpress_namespace), 0)) if parent == 0: parent = None @@ -724,6 +820,16 @@ class CommandImportWordpress(Command, ImportMixin): write_header_line(fd, "wordpress_user_id", comment["user_id"]) fd.write(('\n' + comment['content']).encode('utf8')) + def _create_meta_and_content_filenames(self, slug, extension, lang, default_language, translations_config): + out_meta_filename = slug + '.meta' + out_content_filename = slug + '.' + extension + if lang and lang != default_language: + out_meta_filename = utils.get_translation_candidate(translations_config, + out_meta_filename, lang) + out_content_filename = utils.get_translation_candidate(translations_config, + out_content_filename, lang) + return out_meta_filename, out_content_filename + def _create_metadata(self, status, excerpt, tags, categories, post_name=None): """Create post metadata.""" other_meta = {'wp-status': status} @@ -735,24 +841,48 @@ class CommandImportWordpress(Command, ImportMixin): if text in self._category_paths: cats.append(self._category_paths[text]) else: - cats.append(utils.join_hierarchical_category_path([text])) + cats.append(hierarchy_utils.join_hierarchical_category_path([utils.html_unescape(text)])) other_meta['categories'] = ','.join(cats) if len(cats) > 0: other_meta['category'] = cats[0] if len(cats) > 1: - LOGGER.warn(('Post "{0}" has more than one category! ' + - 'Will only use the first one.').format(post_name)) - tags_cats = tags + LOGGER.warning(('Post "{0}" has more than one category! ' + + 'Will only use the first one.').format(post_name)) + tags_cats = [utils.html_unescape(tag) for tag in tags] else: - tags_cats = tags + categories + tags_cats = [utils.html_unescape(tag) for tag in tags + categories] return tags_cats, other_meta + _tag_sanitize_map = {True: {}, False: {}} + + def _sanitize(self, tag, is_category): + if self.tag_saniziting_strategy == 'lower': + return tag.lower() + if tag.lower() not in self._tag_sanitize_map[is_category]: + self._tag_sanitize_map[is_category][tag.lower()] = [tag] + return tag + previous = self._tag_sanitize_map[is_category][tag.lower()] + if self.tag_saniziting_strategy == 'first': + if tag != previous[0]: + LOGGER.warning("Changing spelling of {0} name '{1}' to {2}.".format('category' if is_category else 'tag', tag, previous[0])) + return previous[0] + else: + LOGGER.error("Unknown tag sanitizing strategy '{0}'!".format(self.tag_saniziting_strategy)) + sys.exit(1) + return tag + def import_postpage_item(self, item, wordpress_namespace, out_folder=None, attachments=None): """Take an item from the feed and creates a post file.""" if out_folder is None: out_folder = 'posts' title = get_text_tag(item, 'title', 'NO TITLE') + + # titles can have line breaks in them, particularly when they are + # created by third-party tools that post to Wordpress. + # Handle windows-style and unix-style line endings. + title = title.replace('\r\n', ' ').replace('\n', ' ') + # link is something like http://foo.com/2012/09/01/hello-world/ # So, take the path, utils.slugify it, and that's our slug link = get_text_tag(item, 'link', None) @@ -760,7 +890,10 @@ class CommandImportWordpress(Command, ImportMixin): path = unquote(parsed.path.strip('/')) try: - path = path.decode('utf8') + if isinstance(path, bytes): + path = path.decode('utf8', 'replace') + else: + path = path except AttributeError: pass @@ -782,7 +915,7 @@ class CommandImportWordpress(Command, ImportMixin): else: if len(pathlist) > 1: out_folder = os.path.join(*([out_folder] + pathlist[:-1])) - slug = utils.slugify(pathlist[-1]) + slug = utils.slugify(pathlist[-1], self.lang) description = get_text_tag(item, 'description', '') post_date = get_text_tag( @@ -809,17 +942,19 @@ class CommandImportWordpress(Command, ImportMixin): tags = [] categories = [] + post_status = 'published' + has_math = "no" if status == 'trash': - LOGGER.warn('Trashed post "{0}" will not be imported.'.format(title)) + LOGGER.warning('Trashed post "{0}" will not be imported.'.format(title)) return False elif status == 'private': - tags.append('private') is_draft = False is_private = True + post_status = 'private' elif status != 'publish': - tags.append('draft') is_draft = True is_private = False + post_status = 'draft' else: is_draft = False is_private = False @@ -831,14 +966,23 @@ class CommandImportWordpress(Command, ImportMixin): type = tag.attrib['domain'] if text == 'Uncategorized' and type == 'category': continue - self.all_tags.add(text) if type == 'category': - categories.append(type) + categories.append(text) else: tags.append(text) if '$latex' in content: - tags.append('mathjax') + has_math = "yes" + + for i, cat in enumerate(categories[:]): + cat = self._sanitize(cat, True) + categories[i] = cat + self.all_tags.add(cat) + + for i, tag in enumerate(tags[:]): + tag = self._sanitize(tag, False) + tags[i] = tag + self.all_tags.add(tag) # Find post format if it's there post_format = 'wp' @@ -849,53 +993,75 @@ class CommandImportWordpress(Command, ImportMixin): post_format = 'wp' if is_draft and self.exclude_drafts: - LOGGER.notice('Draft "{0}" will not be imported.'.format(title)) + LOGGER.warning('Draft "{0}" will not be imported.'.format(title)) return False elif is_private and self.exclude_privates: - LOGGER.notice('Private post "{0}" will not be imported.'.format(title)) + LOGGER.warning('Private post "{0}" will not be imported.'.format(title)) return False elif content.strip() or self.import_empty_items: # If no content is found, no files are written. self.url_map[link] = (self.context['SITE_URL'] + out_folder.rstrip('/') + '/' + slug + '.html').replace(os.sep, '/') - if hasattr(self, "separate_qtranslate_content") \ - and self.separate_qtranslate_content: - content_translations = separate_qtranslate_content(content) + default_language = self.context["DEFAULT_LANG"] + if self.separate_qtranslate_content: + content_translations = separate_qtranslate_tagged_langs(content) + title_translations = separate_qtranslate_tagged_langs(title) else: content_translations = {"": content} - default_language = self.context["DEFAULT_LANG"] + title_translations = {"": title} + # in case of mistmatch between the languages found in the title and in the content + default_title = title_translations.get(default_language, title) + extra_languages = [lang for lang in content_translations.keys() if lang not in ("", default_language)] + for extra_lang in extra_languages: + self.extra_languages.add(extra_lang) + translations_dict = get_default_translations_dict(default_language, extra_languages) + current_translations_config = { + "DEFAULT_LANG": default_language, + "TRANSLATIONS": translations_dict, + "TRANSLATIONS_PATTERN": self.context["TRANSLATIONS_PATTERN"] + } for lang, content in content_translations.items(): try: content, extension, rewrite_html = self.transform_content(content, post_format, attachments) - except: + except Exception: LOGGER.error(('Cannot interpret post "{0}" (language {1}) with post ' + 'format {2}!').format(os.path.join(out_folder, slug), lang, post_format)) return False - if lang: - out_meta_filename = slug + '.meta' - if lang == default_language: - out_content_filename = slug + '.' + extension - else: - out_content_filename \ - = utils.get_translation_candidate(self.context, - slug + "." + extension, lang) - self.extra_languages.add(lang) - meta_slug = slug - else: - out_meta_filename = slug + '.meta' - out_content_filename = slug + '.' + extension - meta_slug = slug + + out_meta_filename, out_content_filename = self._create_meta_and_content_filenames( + slug, extension, lang, default_language, current_translations_config) + tags, other_meta = self._create_metadata(status, excerpt, tags, categories, post_name=os.path.join(out_folder, slug)) - self.write_metadata(os.path.join(self.output_folder, out_folder, - out_meta_filename), - title, meta_slug, post_date, description, tags, **other_meta) - self.write_content( - os.path.join(self.output_folder, - out_folder, out_content_filename), - content, - rewrite_html) + current_title = title_translations.get(lang, default_title) + meta = { + "title": current_title, + "slug": slug, + "date": post_date, + "description": description, + "tags": ','.join(tags), + "status": post_status, + "has_math": has_math, + } + meta.update(other_meta) + if self.onefile: + self.write_post( + os.path.join(self.output_folder, + out_folder, out_content_filename), + content, + meta, + self._get_compiler(), + rewrite_html) + else: + self.write_metadata(os.path.join(self.output_folder, out_folder, + out_meta_filename), + current_title, slug, post_date, description, tags, **other_meta) + self.write_content( + os.path.join(self.output_folder, + out_folder, out_content_filename), + content, + rewrite_html) if self.export_comments: comments = [] @@ -905,13 +1071,13 @@ class CommandImportWordpress(Command, ImportMixin): comments.append(comment) for comment in comments: - comment_filename = slug + "." + str(comment['id']) + ".wpcomment" + comment_filename = "{0}.{1}.wpcomment".format(slug, comment['id']) self._write_comment(os.path.join(self.output_folder, out_folder, comment_filename), comment) return (out_folder, slug) else: - LOGGER.warn(('Not going to import "{0}" because it seems to contain' - ' no content.').format(title)) + LOGGER.warning(('Not going to import "{0}" because it seems to contain' + ' no content.').format(title)) return False def _extract_item_info(self, item): @@ -937,7 +1103,7 @@ class CommandImportWordpress(Command, ImportMixin): if parent_id is not None and int(parent_id) != 0: self.attachments[int(parent_id)][post_id] = data else: - LOGGER.warn("Attachment #{0} ({1}) has no parent!".format(post_id, data['files'])) + LOGGER.warning("Attachment #{0} ({1}) has no parent!".format(post_id, data['files'])) def write_attachments_info(self, path, attachments): """Write attachments info file.""" @@ -955,7 +1121,7 @@ class CommandImportWordpress(Command, ImportMixin): if post_type == 'post': out_folder_slug = self.import_postpage_item(item, wordpress_namespace, 'posts', attachments) else: - out_folder_slug = self.import_postpage_item(item, wordpress_namespace, 'stories', attachments) + out_folder_slug = self.import_postpage_item(item, wordpress_namespace, 'pages', attachments) # Process attachment data if attachments is not None: # If post was exported, store data @@ -975,8 +1141,8 @@ class CommandImportWordpress(Command, ImportMixin): self.process_item_if_post_or_page(item) # Assign attachments to posts for post_id in self.attachments: - LOGGER.warn(("Found attachments for post or page #{0}, but didn't find post or page. " + - "(Attachments: {1})").format(post_id, [e['files'][0] for e in self.attachments[post_id].values()])) + LOGGER.warning(("Found attachments for post or page #{0}, but didn't find post or page. " + + "(Attachments: {1})").format(post_id, [e['files'][0] for e in self.attachments[post_id].values()])) def get_text_tag(tag, name, default): @@ -990,15 +1156,20 @@ def get_text_tag(tag, name, default): return default -def separate_qtranslate_content(text): - """Parse the content of a wordpress post or page and separate qtranslate languages. +def separate_qtranslate_tagged_langs(text): + """Parse the content of a wordpress post or page and separate languages. + + For qtranslateX tags: [:LL]blabla[:] - qtranslate tags: <!--:LL-->blabla<!--:--> + Note: qtranslate* plugins had a troubled history and used various + tags over time, application of the 'modernize_qtranslate_tags' + function is required for this function to handle most of the legacy + cases. """ - # TODO: uniformize qtranslate tags <!--/en--> => <!--:--> - qt_start = "<!--:" - qt_end = "-->" - qt_end_with_lang_len = 5 + qt_start = "[:" + qt_end = "]" + qt_end_len = len(qt_end) + qt_end_with_lang_len = qt_end_len + 2 qt_chunks = text.split(qt_start) content_by_lang = {} common_txt_list = [] @@ -1010,9 +1181,9 @@ def separate_qtranslate_content(text): # be some piece of common text or tags, or just nothing lang = "" # default language c = c.lstrip(qt_end) - if not c: + if not c.strip(): continue - elif c[2:].startswith(qt_end): + elif c[2:qt_end_with_lang_len].startswith(qt_end): # a language specific section (with language code at the begining) lang = c[:2] c = c[qt_end_with_lang_len:] @@ -1033,3 +1204,26 @@ def separate_qtranslate_content(text): for l in content_by_lang.keys(): content_by_lang[l] = " ".join(content_by_lang[l]) return content_by_lang + + +def modernize_qtranslate_tags(xml_bytes): + """ + Uniformize the "tag" used by various version of qtranslate. + + The resulting byte string will only contain one set of qtranslate tags + (namely [:LG] and [:]), older ones being converted to new ones. + """ + old_start_lang = re.compile(b"<!--:?(\\w{2})-->") + new_start_lang = b"[:\\1]" + old_end_lang = re.compile(b"<!--(/\\w{2}|:)-->") + new_end_lang = b"[:]" + title_match = re.compile(b"<title>(.*?)") + modern_starts = old_start_lang.sub(new_start_lang, xml_bytes) + modernized_bytes = old_end_lang.sub(new_end_lang, modern_starts) + + def title_escape(match): + title = match.group(1) + title = title.replace(b"&", b"&").replace(b"<", b"<").replace(b">", b">") + return b"" + title + b"" + fixed_bytes = title_match.sub(title_escape, modernized_bytes) + return fixed_bytes diff --git a/nikola/plugins/command/init.plugin b/nikola/plugins/command/init.plugin index a5404c4..6ee27d3 100644 --- a/nikola/plugins/command/init.plugin +++ b/nikola/plugins/command/init.plugin @@ -5,9 +5,9 @@ module = init [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Create a new site. [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/init.py b/nikola/plugins/command/init.py index 91ccdb4..0026edc 100644 --- a/nikola/plugins/command/init.py +++ b/nikola/plugins/command/init.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,28 +26,28 @@ """Create a new site.""" -from __future__ import print_function, unicode_literals -import os -import shutil +import datetime import io import json +import os +import shutil import textwrap -import datetime import unidecode +from urllib.parse import urlsplit, urlunsplit + import dateutil.tz import dateutil.zoneinfo from mako.template import Template from pkg_resources import resource_filename -import tarfile import nikola -from nikola.nikola import DEFAULT_TRANSLATIONS_PATTERN, DEFAULT_INDEX_READ_MORE_LINK, DEFAULT_RSS_READ_MORE_LINK, LEGAL_VALUES, urlsplit, urlunsplit +from nikola.nikola import DEFAULT_INDEX_READ_MORE_LINK, DEFAULT_FEED_READ_MORE_LINK, LEGAL_VALUES from nikola.plugin_categories import Command -from nikola.utils import ask, ask_yesno, get_logger, makedirs, STDERR_HANDLER, load_messages +from nikola.utils import ask, ask_yesno, get_logger, makedirs, load_messages from nikola.packages.tzlocal import get_localzone -LOGGER = get_logger('init', STDERR_HANDLER) +LOGGER = get_logger('init') SAMPLE_CONF = { 'BLOG_AUTHOR': "Your Name", @@ -55,48 +55,51 @@ SAMPLE_CONF = { 'SITE_URL': "https://example.com/", 'BLOG_EMAIL': "joe@demo.site", 'BLOG_DESCRIPTION': "This is a demo site for Nikola.", - 'PRETTY_URLS': False, - 'STRIP_INDEXES': False, + 'PRETTY_URLS': True, + 'STRIP_INDEXES': True, 'DEFAULT_LANG': "en", 'TRANSLATIONS': """{ DEFAULT_LANG: "", # Example for another language: # "es": "./es", }""", - 'THEME': 'bootstrap3', + 'THEME': LEGAL_VALUES['DEFAULT_THEME'], 'TIMEZONE': 'UTC', 'COMMENT_SYSTEM': 'disqus', 'COMMENT_SYSTEM_ID': 'nikolademo', 'CATEGORY_ALLOW_HIERARCHIES': False, 'CATEGORY_OUTPUT_FLAT_HIERARCHY': False, - 'TRANSLATIONS_PATTERN': DEFAULT_TRANSLATIONS_PATTERN, 'INDEX_READ_MORE_LINK': DEFAULT_INDEX_READ_MORE_LINK, - 'RSS_READ_MORE_LINK': DEFAULT_RSS_READ_MORE_LINK, + 'FEED_READ_MORE_LINK': DEFAULT_FEED_READ_MORE_LINK, 'POSTS': """( ("posts/*.rst", "posts", "post.tmpl"), + ("posts/*.md", "posts", "post.tmpl"), ("posts/*.txt", "posts", "post.tmpl"), + ("posts/*.html", "posts", "post.tmpl"), )""", 'PAGES': """( - ("stories/*.rst", "stories", "story.tmpl"), - ("stories/*.txt", "stories", "story.tmpl"), + ("pages/*.rst", "pages", "page.tmpl"), + ("pages/*.md", "pages", "page.tmpl"), + ("pages/*.txt", "pages", "page.tmpl"), + ("pages/*.html", "pages", "page.tmpl"), )""", 'COMPILERS': """{ - "rest": ('.rst', '.txt'), - "markdown": ('.md', '.mdown', '.markdown'), - "textile": ('.textile',), - "txt2tags": ('.t2t',), - "bbcode": ('.bb',), - "wiki": ('.wiki',), - "ipynb": ('.ipynb',), - "html": ('.html', '.htm'), + "rest": ['.rst', '.txt'], + "markdown": ['.md', '.mdown', '.markdown'], + "textile": ['.textile'], + "txt2tags": ['.t2t'], + "bbcode": ['.bb'], + "wiki": ['.wiki'], + "ipynb": ['.ipynb'], + "html": ['.html', '.htm'], # PHP files are rendered the usual way (i.e. with the full templates). # The resulting files have .php extensions, making it possible to run # them without reconfiguring your server to recognize them. - "php": ('.php',), + "php": ['.php'], # Pandoc detects the input from the source filename # but is disabled by default as it would conflict # with many of the others. - # "pandoc": ('.rst', '.md', '.txt'), + # "pandoc": ['.rst', '.md', '.txt'], }""", 'NAVIGATION_LINKS': """{ DEFAULT_LANG: ( @@ -106,6 +109,7 @@ SAMPLE_CONF = { ), }""", 'REDIRECTIONS': [], + '_METADATA_MAPPING_FORMATS': ', '.join(LEGAL_VALUES['METADATA_MAPPING']) } @@ -169,6 +173,14 @@ def format_default_translations_config(additional_languages): return "{{\n{0}\n}}".format("\n".join(lang_paths)) +def get_default_translations_dict(default_lang, additional_languages): + """Generate a TRANSLATIONS dict matching the config from 'format_default_translations_config'.""" + tr = {default_lang: ''} + for l in additional_languages: + tr[l] = './' + l + return tr + + def format_navigation_links(additional_languages, default_lang, messages, strip_indexes=False): """Return the string to configure NAVIGATION_LINKS.""" f = u"""\ @@ -210,17 +222,28 @@ def prepare_config(config): """Parse sample config with JSON.""" p = config.copy() p.update({k: json.dumps(v, ensure_ascii=False) for k, v in p.items() - if k not in ('POSTS', 'PAGES', 'COMPILERS', 'TRANSLATIONS', 'NAVIGATION_LINKS', '_SUPPORTED_LANGUAGES', '_SUPPORTED_COMMENT_SYSTEMS', 'INDEX_READ_MORE_LINK', 'RSS_READ_MORE_LINK')}) + if k not in ('POSTS', 'PAGES', 'COMPILERS', 'TRANSLATIONS', 'NAVIGATION_LINKS', '_SUPPORTED_LANGUAGES', '_SUPPORTED_COMMENT_SYSTEMS', 'INDEX_READ_MORE_LINK', 'FEED_READ_MORE_LINK', '_METADATA_MAPPING_FORMATS')}) # READ_MORE_LINKs require some special treatment. p['INDEX_READ_MORE_LINK'] = "'" + p['INDEX_READ_MORE_LINK'].replace("'", "\\'") + "'" - p['RSS_READ_MORE_LINK'] = "'" + p['RSS_READ_MORE_LINK'].replace("'", "\\'") + "'" + p['FEED_READ_MORE_LINK'] = "'" + p['FEED_READ_MORE_LINK'].replace("'", "\\'") + "'" # fix booleans and None p.update({k: str(v) for k, v in config.items() if isinstance(v, bool) or v is None}) return p -class CommandInit(Command): +def test_destination(destination, demo=False): + """Check if the destination already exists, which can break demo site creation.""" + # Issue #2214 + if demo and os.path.exists(destination): + LOGGER.warning("The directory {0} already exists, and a new demo site cannot be initialized in an existing directory.".format(destination)) + LOGGER.warning("Please remove the directory and try again, or use another directory.") + LOGGER.info("Hint: If you want to initialize a git repository in this directory, run `git init` in the directory after creating a Nikola site.") + return False + else: + return True + +class CommandInit(Command): """Create a new site.""" name = "init" @@ -272,11 +295,11 @@ class CommandInit(Command): @classmethod def create_empty_site(cls, target): """Create an empty site with directories only.""" - for folder in ('files', 'galleries', 'listings', 'posts', 'stories'): + for folder in ('files', 'galleries', 'images', 'listings', 'posts', 'pages'): makedirs(os.path.join(target, folder)) @staticmethod - def ask_questions(target): + def ask_questions(target, demo=False): """Ask some questions about Nikola.""" def urlhandler(default, toconf): answer = ask('Site URL', 'https://example.com/') @@ -310,7 +333,6 @@ class CommandInit(Command): def prettyhandler(default, toconf): SAMPLE_CONF['PRETTY_URLS'] = ask_yesno('Enable pretty URLs (/page/ instead of /page.html) that don\'t need web server configuration?', default=True) - SAMPLE_CONF['STRIP_INDEXES'] = SAMPLE_CONF['PRETTY_URLS'] def lhandler(default, toconf, show_header=True): if show_header: @@ -341,13 +363,12 @@ class CommandInit(Command): # Get messages for navigation_links. In order to do this, we need # to generate a throwaway TRANSLATIONS dict. - tr = {default: ''} - for l in langs: - tr[l] = './' + l + tr = get_default_translations_dict(default, langs) + # Assuming that base contains all the locales, and that base does # not inherit from anywhere. try: - messages = load_messages(['base'], tr, default) + messages = load_messages(['base'], tr, default, themes_dirs=['themes']) SAMPLE_CONF['NAVIGATION_LINKS'] = format_navigation_links(langs, default, messages, SAMPLE_CONF['STRIP_INDEXES']) except nikola.utils.LanguageNotFoundError as e: print(" ERROR: the language '{0}' is not supported.".format(e.lang)) @@ -358,28 +379,28 @@ class CommandInit(Command): def tzhandler(default, toconf): print("\nPlease choose the correct time zone for your blog. Nikola uses the tz database.") print("You can find your time zone here:") - print("http://en.wikipedia.org/wiki/List_of_tz_database_time_zones") + print("https://en.wikipedia.org/wiki/List_of_tz_database_time_zones") print("") answered = False while not answered: try: lz = get_localzone() - except: + except Exception: lz = None answer = ask('Time zone', lz if lz else "UTC") tz = dateutil.tz.gettz(answer) if tz is None: print(" WARNING: Time zone not found. Searching list of time zones for a match.") - zonesfile = tarfile.open(fileobj=dateutil.zoneinfo.getzoneinfofile_stream()) - zonenames = [zone for zone in zonesfile.getnames() if answer.lower() in zone.lower()] - if len(zonenames) == 1: - tz = dateutil.tz.gettz(zonenames[0]) - answer = zonenames[0] + all_zones = dateutil.zoneinfo.get_zonefile_instance().zones + matching_zones = [zone for zone in all_zones if answer.lower() in zone.lower()] + if len(matching_zones) == 1: + tz = dateutil.tz.gettz(matching_zones[0]) + answer = matching_zones[0] print(" Picking '{0}'.".format(answer)) - elif len(zonenames) > 1: + elif len(matching_zones) > 1: print(" The following time zones match your query:") - print(' ' + '\n '.join(zonenames)) + print(' ' + '\n '.join(matching_zones)) continue if tz is not None: @@ -441,7 +462,7 @@ class CommandInit(Command): print("If you do not want to answer and want to go with the defaults instead, simply restart with the `-q` parameter.") for query, default, toconf, destination in questions: - if target and destination == '!target': + if target and destination == '!target' and test_destination(target, demo): # Skip the destination question if we know it already pass else: @@ -458,8 +479,9 @@ class CommandInit(Command): if toconf: SAMPLE_CONF[destination] = answer if destination == '!target': - while not answer: - print(' ERROR: you need to specify a target directory.\n') + while not answer or not test_destination(answer, demo): + if not answer: + print(' ERROR: you need to specify a target directory.\n') answer = ask(query, default) STORAGE['target'] = answer @@ -475,7 +497,7 @@ class CommandInit(Command): except IndexError: target = None if not options.get('quiet'): - st = self.ask_questions(target=target) + st = self.ask_questions(target=target, demo=options.get('demo')) try: if not target: target = st['target'] @@ -488,11 +510,13 @@ class CommandInit(Command): Options: -q, --quiet Do not ask questions about config. -d, --demo Create a site filled with example data.""") - return False + return 1 if not options.get('demo'): self.create_empty_site(target) LOGGER.info('Created empty site at {0}.'.format(target)) else: + if not test_destination(target, True): + return 2 self.copy_sample_site(target) LOGGER.info("A new site with example data has been created at " "{0}.".format(target)) diff --git a/nikola/plugins/command/install_theme.plugin b/nikola/plugins/command/install_theme.plugin deleted file mode 100644 index 8434f2e..0000000 --- a/nikola/plugins/command/install_theme.plugin +++ /dev/null @@ -1,13 +0,0 @@ -[Core] -name = install_theme -module = install_theme - -[Documentation] -author = Roberto Alsina -version = 1.0 -website = http://getnikola.com -description = Install a theme into the current site. - -[Nikola] -plugincategory = Command - diff --git a/nikola/plugins/command/install_theme.py b/nikola/plugins/command/install_theme.py deleted file mode 100644 index f02252e..0000000 --- a/nikola/plugins/command/install_theme.py +++ /dev/null @@ -1,172 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright © 2012-2015 Roberto Alsina and others. - -# Permission is hereby granted, free of charge, to any -# person obtaining a copy of this software and associated -# documentation files (the "Software"), to deal in the -# Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the -# Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice -# shall be included in all copies or substantial portions of -# the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY -# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE -# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR -# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS -# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR -# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -"""Install a theme.""" - -from __future__ import print_function -import os -import io -import time -import requests - -import pygments -from pygments.lexers import PythonLexer -from pygments.formatters import TerminalFormatter - -from nikola.plugin_categories import Command -from nikola import utils - -LOGGER = utils.get_logger('install_theme', utils.STDERR_HANDLER) - - -class CommandInstallTheme(Command): - - """Install a theme.""" - - name = "install_theme" - doc_usage = "[[-u] theme_name] | [[-u] -l]" - doc_purpose = "install theme into current site" - output_dir = 'themes' - cmd_options = [ - { - 'name': 'list', - 'short': 'l', - 'long': 'list', - 'type': bool, - 'default': False, - 'help': 'Show list of available themes.' - }, - { - 'name': 'url', - 'short': 'u', - 'long': 'url', - 'type': str, - 'help': "URL for the theme repository (default: " - "https://themes.getnikola.com/v7/themes.json)", - 'default': 'https://themes.getnikola.com/v7/themes.json' - }, - { - 'name': 'getpath', - 'short': 'g', - 'long': 'get-path', - 'type': bool, - 'default': False, - 'help': "Print the path for installed theme", - }, - ] - - def _execute(self, options, args): - """Install theme into current site.""" - listing = options['list'] - url = options['url'] - if args: - name = args[0] - else: - name = None - - if options['getpath'] and name: - path = utils.get_theme_path(name) - if path: - print(path) - else: - print('not installed') - return 0 - - if name is None and not listing: - LOGGER.error("This command needs either a theme name or the -l option.") - return False - try: - data = requests.get(url).json() - except requests.exceptions.SSLError: - LOGGER.warning("SSL error, using http instead of https (press ^C to abort)") - time.sleep(1) - url = url.replace('https', 'http', 1) - data = requests.get(url).json() - if listing: - print("Themes:") - print("-------") - for theme in sorted(data.keys()): - print(theme) - return True - else: - # `name` may be modified by the while loop. - origname = name - installstatus = self.do_install(name, data) - # See if the theme's parent is available. If not, install it - while True: - parent_name = utils.get_parent_theme_name(name) - if parent_name is None: - break - try: - utils.get_theme_path(parent_name) - break - except: # Not available - self.do_install(parent_name, data) - name = parent_name - if installstatus: - LOGGER.notice('Remember to set THEME="{0}" in conf.py to use this theme.'.format(origname)) - - def do_install(self, name, data): - """Download and install a theme.""" - if name in data: - utils.makedirs(self.output_dir) - url = data[name] - LOGGER.info("Downloading '{0}'".format(url)) - try: - zip_data = requests.get(url).content - except requests.exceptions.SSLError: - LOGGER.warning("SSL error, using http instead of https (press ^C to abort)") - time.sleep(1) - url = url.replace('https', 'http', 1) - zip_data = requests.get(url).content - - zip_file = io.BytesIO() - zip_file.write(zip_data) - LOGGER.info("Extracting '{0}' into themes/".format(name)) - utils.extract_all(zip_file) - dest_path = os.path.join(self.output_dir, name) - else: - dest_path = os.path.join(self.output_dir, name) - try: - theme_path = utils.get_theme_path(name) - LOGGER.error("Theme '{0}' is already installed in {1}".format(name, theme_path)) - except Exception: - LOGGER.error("Can't find theme {0}".format(name)) - - return False - - confpypath = os.path.join(dest_path, 'conf.py.sample') - if os.path.exists(confpypath): - LOGGER.notice('This theme has a sample config file. Integrate it with yours in order to make this theme work!') - print('Contents of the conf.py.sample file:\n') - with io.open(confpypath, 'r', encoding='utf-8') as fh: - if self.site.colorful: - print(utils.indent(pygments.highlight( - fh.read(), PythonLexer(), TerminalFormatter()), - 4 * ' ')) - else: - print(utils.indent(fh.read(), 4 * ' ')) - return True diff --git a/nikola/plugins/command/new_page.plugin b/nikola/plugins/command/new_page.plugin index 145a419..8734805 100644 --- a/nikola/plugins/command/new_page.plugin +++ b/nikola/plugins/command/new_page.plugin @@ -5,9 +5,9 @@ module = new_page [Documentation] author = Roberto Alsina, Chris Warrick version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Create a new page. [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/new_page.py b/nikola/plugins/command/new_page.py index 811e28b..0f7996a 100644 --- a/nikola/plugins/command/new_page.py +++ b/nikola/plugins/command/new_page.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina, Chris Warrick and others. +# Copyright © 2012-2020 Roberto Alsina, Chris Warrick and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,13 +26,11 @@ """Create a new page.""" -from __future__ import unicode_literals, print_function from nikola.plugin_categories import Command class CommandNewPage(Command): - """Create a new page.""" name = "new_page" @@ -108,6 +106,7 @@ class CommandNewPage(Command): options['tags'] = '' options['schedule'] = False options['is_page'] = True + options['date-path'] = False # Even though stuff was split into `new_page`, it’s easier to do it # there not to duplicate the code. p = self.site.plugin_manager.getPluginByName('new_post', 'Command').plugin_object diff --git a/nikola/plugins/command/new_post.plugin b/nikola/plugins/command/new_post.plugin index d88469f..efdeb58 100644 --- a/nikola/plugins/command/new_post.plugin +++ b/nikola/plugins/command/new_post.plugin @@ -5,9 +5,9 @@ module = new_post [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Create a new post. [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/new_post.py b/nikola/plugins/command/new_post.py index f9fe3ff..e6eabbd 100644 --- a/nikola/plugins/command/new_post.py +++ b/nikola/plugins/command/new_post.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,23 +26,23 @@ """Create a new post.""" -from __future__ import unicode_literals, print_function import io import datetime +import operator import os -import sys +import shutil import subprocess -import operator +import sys -from blinker import signal import dateutil.tz +from blinker import signal from nikola.plugin_categories import Command from nikola import utils COMPILERS_DOC_LINK = 'https://getnikola.com/handbook.html#configuring-other-input-formats' -POSTLOGGER = utils.get_logger('new_post', utils.STDERR_HANDLER) -PAGELOGGER = utils.get_logger('new_page', utils.STDERR_HANDLER) +POSTLOGGER = utils.get_logger('new_post') +PAGELOGGER = utils.get_logger('new_page') LOGGER = POSTLOGGER @@ -89,7 +89,7 @@ def get_date(schedule=False, rule=None, last_date=None, tz=None, iso8601=False): except ImportError: LOGGER.error('To use the --schedule switch of new_post, ' 'you have to install the "dateutil" package.') - rrule = None # NOQA + rrule = None if schedule and rrule and rule: try: rule_ = rrule.rrulestr(rule, dtstart=last_date or date) @@ -110,11 +110,10 @@ def get_date(schedule=False, rule=None, last_date=None, tz=None, iso8601=False): else: tz_str = ' UTC' - return date.strftime('%Y-%m-%d %H:%M:%S') + tz_str + return (date.strftime('%Y-%m-%d %H:%M:%S') + tz_str, date) class CommandNewPost(Command): - """Create a new post.""" name = "new_post" @@ -204,7 +203,14 @@ class CommandNewPost(Command): 'default': '', 'help': 'Import an existing file instead of creating a placeholder' }, - + { + 'name': 'date-path', + 'short': 'd', + 'long': 'date-path', + 'type': bool, + 'default': False, + 'help': 'Create post with date path (eg. year/month/day, see NEW_POST_DATE_PATH_FORMAT in config)' + }, ] def _execute(self, options, args): @@ -234,6 +240,10 @@ class CommandNewPost(Command): twofile = options['twofile'] import_file = options['import'] wants_available = options['available-formats'] + date_path_opt = options['date-path'] + date_path_auto = self.site.config['NEW_POST_DATE_PATH'] and content_type == 'post' + date_path_format = self.site.config['NEW_POST_DATE_PATH_FORMAT'].strip('/') + post_type = options.get('type', 'text') if wants_available: self.print_compilers() @@ -255,16 +265,39 @@ class CommandNewPost(Command): if "@" in content_format: content_format, content_subformat = content_format.split("@") - if not content_format: # Issue #400 + if not content_format and path and not os.path.isdir(path): + # content_format not specified. If path was given, use + # it to guess (Issue #2798) + extension = os.path.splitext(path)[-1] + for compiler, extensions in self.site.config['COMPILERS'].items(): + if extension in extensions: + content_format = compiler + if not content_format: + LOGGER.error("Unknown {0} extension {1}, maybe you need to install a plugin or enable an existing one?".format(content_type, extension)) + return + + elif not content_format and import_file: + # content_format not specified. If import_file was given, use + # it to guess (Issue #2798) + extension = os.path.splitext(import_file)[-1] + for compiler, extensions in self.site.config['COMPILERS'].items(): + if extension in extensions: + content_format = compiler + if not content_format: + LOGGER.error("Unknown {0} extension {1}, maybe you need to install a plugin or enable an existing one?".format(content_type, extension)) + return + + elif not content_format: # Issue #400 content_format = get_default_compiler( is_post, self.site.config['COMPILERS'], self.site.config['post_pages']) - if content_format not in compiler_names: - LOGGER.error("Unknown {0} format {1}, maybe you need to install a plugin?".format(content_type, content_format)) + elif content_format not in compiler_names: + LOGGER.error("Unknown {0} format {1}, maybe you need to install a plugin or enable an existing one?".format(content_type, content_format)) self.print_compilers() return + compiler_plugin = self.site.plugin_manager.getPluginByName( content_format, "PageCompiler").plugin_object @@ -286,7 +319,7 @@ class CommandNewPost(Command): while not title: title = utils.ask('Title') - if isinstance(title, utils.bytes_str): + if isinstance(title, bytes): try: title = title.decode(sys.stdin.encoding) except (AttributeError, TypeError): # for tests @@ -294,28 +327,36 @@ class CommandNewPost(Command): title = title.strip() if not path: - slug = utils.slugify(title) + slug = utils.slugify(title, lang=self.site.default_lang) else: - if isinstance(path, utils.bytes_str): + if isinstance(path, bytes): try: path = path.decode(sys.stdin.encoding) except (AttributeError, TypeError): # for tests path = path.decode('utf-8') - slug = utils.slugify(os.path.splitext(os.path.basename(path))[0]) + if os.path.isdir(path): + # If the user provides a directory, add the file name generated from title (Issue #2651) + slug = utils.slugify(title, lang=self.site.default_lang) + pattern = os.path.basename(entry[0]) + suffix = pattern[1:] + path = os.path.join(path, slug + suffix) + else: + slug = utils.slugify(os.path.splitext(os.path.basename(path))[0], lang=self.site.default_lang) - if isinstance(author, utils.bytes_str): - try: - author = author.decode(sys.stdin.encoding) - except (AttributeError, TypeError): # for tests - author = author.decode('utf-8') + if isinstance(author, bytes): + try: + author = author.decode(sys.stdin.encoding) + except (AttributeError, TypeError): # for tests + author = author.decode('utf-8') # Calculate the date to use for the content - schedule = options['schedule'] or self.site.config['SCHEDULE_ALL'] + # SCHEDULE_ALL is post-only (Issue #2921) + schedule = options['schedule'] or (self.site.config['SCHEDULE_ALL'] and is_post) rule = self.site.config['SCHEDULE_RULE'] self.site.scan_posts() timeline = self.site.timeline last_date = None if not timeline else timeline[0].date - date = get_date(schedule, rule, last_date, self.site.tzinfo, self.site.config['FORCE_ISO8601']) + date, dateobj = get_date(schedule, rule, last_date, self.site.tzinfo, self.site.config['FORCE_ISO8601']) data = { 'title': title, 'slug': slug, @@ -323,16 +364,23 @@ class CommandNewPost(Command): 'tags': tags, 'link': '', 'description': '', - 'type': 'text', + 'type': post_type, } - output_path = os.path.dirname(entry[0]) - meta_path = os.path.join(output_path, slug + ".meta") - pattern = os.path.basename(entry[0]) - suffix = pattern[1:] + if not path: + pattern = os.path.basename(entry[0]) + suffix = pattern[1:] + output_path = os.path.dirname(entry[0]) + if date_path_auto or date_path_opt: + output_path += os.sep + dateobj.strftime(date_path_format) + txt_path = os.path.join(output_path, slug + suffix) + meta_path = os.path.join(output_path, slug + ".meta") else: + if date_path_opt: + LOGGER.warning("A path has been specified, ignoring -d") txt_path = os.path.join(self.site.original_cwd, path) + meta_path = os.path.splitext(txt_path)[0] + ".meta" if (not onefile and os.path.isfile(meta_path)) or \ os.path.isfile(txt_path): @@ -344,6 +392,9 @@ class CommandNewPost(Command): signal('existing_' + content_type).send(self, **event) LOGGER.error("The title already exists!") + LOGGER.info("Existing {0}'s text is at: {1}".format(content_type, txt_path)) + if not onefile: + LOGGER.info("Existing {0}'s metadata is at: {1}".format(content_type, meta_path)) return 8 d_name = os.path.dirname(txt_path) @@ -354,33 +405,38 @@ class CommandNewPost(Command): metadata.update(self.site.config['ADDITIONAL_METADATA']) data.update(metadata) - # ipynb plugin needs the ipython kernel info. We get the kernel name + # ipynb plugin needs the Jupyter kernel info. We get the kernel name # from the content_subformat and pass it to the compiler in the metadata if content_format == "ipynb" and content_subformat is not None: - metadata["ipython_kernel"] = content_subformat + metadata["jupyter_kernel"] = content_subformat # Override onefile if not really supported. if not compiler_plugin.supports_onefile and onefile: onefile = False - LOGGER.warn('This compiler does not support one-file posts.') + LOGGER.warning('This compiler does not support one-file posts.') - if import_file: - with io.open(import_file, 'r', encoding='utf-8') as fh: + if onefile and import_file: + with io.open(import_file, 'r', encoding='utf-8-sig') as fh: content = fh.read() - else: + elif not import_file: if is_page: content = self.site.MESSAGES[self.site.default_lang]["Write your page here."] else: content = self.site.MESSAGES[self.site.default_lang]["Write your post here."] - compiler_plugin.create_post( - txt_path, content=content, onefile=onefile, title=title, - slug=slug, date=date, tags=tags, is_page=is_page, **metadata) + + if (not onefile) and import_file: + # Two-file posts are copied on import (Issue #2380) + shutil.copy(import_file, txt_path) + else: + compiler_plugin.create_post( + txt_path, content=content, onefile=onefile, title=title, + slug=slug, date=date, tags=tags, is_page=is_page, type=post_type, **metadata) event = dict(path=txt_path) if not onefile: # write metadata file with io.open(meta_path, "w+", encoding="utf8") as fd: - fd.write(utils.write_metadata(data)) + fd.write(utils.write_metadata(data, comment_wrap=False, site=self.site)) LOGGER.info("Your {0}'s metadata is at: {1}".format(content_type, meta_path)) event['meta_path'] = meta_path LOGGER.info("Your {0}'s text is at: {1}".format(content_type, txt_path)) @@ -395,7 +451,7 @@ class CommandNewPost(Command): if editor: subprocess.call(to_run) else: - LOGGER.error('$EDITOR not set, cannot edit the post. Please do it manually.') + LOGGER.error('The $EDITOR environment variable is not set, cannot edit the post with \'-e\'. Please edit the post manually.') def filter_post_pages(self, compiler, is_post): """Return the correct entry from post_pages. @@ -512,6 +568,6 @@ class CommandNewPost(Command): More compilers are available in the Plugins Index. Compilers marked with ! and ~ require additional configuration: - ! not in the PAGES/POSTS tuples (unused) + ! not in the POSTS/PAGES tuples and any post scanners (unused) ~ not in the COMPILERS dict (disabled) Read more: {0}""".format(COMPILERS_DOC_LINK)) diff --git a/nikola/plugins/command/orphans.plugin b/nikola/plugins/command/orphans.plugin index 669429d..5107032 100644 --- a/nikola/plugins/command/orphans.plugin +++ b/nikola/plugins/command/orphans.plugin @@ -5,9 +5,9 @@ module = orphans [Documentation] author = Roberto Alsina, Chris Warrick version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = List all orphans [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/orphans.py b/nikola/plugins/command/orphans.py index b12cc67..0cf2e63 100644 --- a/nikola/plugins/command/orphans.py +++ b/nikola/plugins/command/orphans.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina, Chris Warrick and others. +# Copyright © 2012-2020 Roberto Alsina, Chris Warrick and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,7 +26,6 @@ """List all orphans.""" -from __future__ import print_function import os from nikola.plugin_categories import Command @@ -34,7 +33,6 @@ from nikola.plugins.command.check import real_scan_files class CommandOrphans(Command): - """List all orphans.""" name = "orphans" diff --git a/nikola/plugins/command/plugin.plugin b/nikola/plugins/command/plugin.plugin index d44dcf3..db99ceb 100644 --- a/nikola/plugins/command/plugin.plugin +++ b/nikola/plugins/command/plugin.plugin @@ -5,9 +5,9 @@ module = plugin [Documentation] author = Roberto Alsina and Chris Warrick version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Manage Nikola plugins [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/plugin.py b/nikola/plugins/command/plugin.py index f892ee9..33dee23 100644 --- a/nikola/plugins/command/plugin.py +++ b/nikola/plugins/command/plugin.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,9 +26,10 @@ """Manage plugins.""" -from __future__ import print_function import io +import json.decoder import os +import sys import shutil import subprocess import time @@ -41,16 +42,15 @@ from pygments.formatters import TerminalFormatter from nikola.plugin_categories import Command from nikola import utils -LOGGER = utils.get_logger('plugin', utils.STDERR_HANDLER) +LOGGER = utils.get_logger('plugin') class CommandPlugin(Command): - """Manage plugins.""" json = None name = "plugin" - doc_usage = "[[-u][--user] --install name] | [[-u] [-l |--upgrade|--list-installed] | [--uninstall name]]" + doc_usage = "[-u url] [--user] [-i name] [-r name] [--upgrade] [-l] [--list-installed]" doc_purpose = "manage plugins" output_dir = None needs_config = False @@ -84,9 +84,8 @@ class CommandPlugin(Command): 'short': 'u', 'long': 'url', 'type': str, - 'help': "URL for the plugin repository (default: " - "https://plugins.getnikola.com/v7/plugins.json)", - 'default': 'https://plugins.getnikola.com/v7/plugins.json' + 'help': "URL for the plugin repository", + 'default': 'https://plugins.getnikola.com/v8/plugins.json' }, { 'name': 'user', @@ -137,11 +136,11 @@ class CommandPlugin(Command): self.output_dir = options.get('output_dir') else: if not self.site.configured and not user_mode and install: - LOGGER.notice('No site found, assuming --user') + LOGGER.warning('No site found, assuming --user') user_mode = True if user_mode: - self.output_dir = os.path.expanduser('~/.nikola/plugins') + self.output_dir = os.path.expanduser(os.path.join('~', '.nikola', 'plugins')) else: self.output_dir = 'plugins' @@ -177,8 +176,20 @@ class CommandPlugin(Command): plugins.append([plugin.name, p]) plugins.sort() + print('Installed Plugins:') + print('------------------') + maxlength = max(len(i[0]) for i in plugins) + if self.site.colorful: + formatstring = '\x1b[1m{0:<{2}}\x1b[0m at {1}' + else: + formatstring = '{0:<{2}} at {1}' for name, path in plugins: - print('{0} at {1}'.format(name, path)) + print(formatstring.format(name, path, maxlength)) + dp = self.site.config['DISABLED_PLUGINS'] + if dp: + print('\n\nAlso, you have disabled these plugins: {}'.format(', '.join(dp))) + else: + print('\n\nNo plugins are disabled.') return 0 def do_upgrade(self, url): @@ -232,43 +243,32 @@ class CommandPlugin(Command): utils.extract_all(zip_file, self.output_dir) dest_path = os.path.join(self.output_dir, name) else: - try: - plugin_path = utils.get_plugin_path(name) - except: - LOGGER.error("Can't find plugin " + name) - return 1 - - utils.makedirs(self.output_dir) - dest_path = os.path.join(self.output_dir, name) - if os.path.exists(dest_path): - LOGGER.error("{0} is already installed".format(name)) - return 1 - - LOGGER.info('Copying {0} into plugins'.format(plugin_path)) - shutil.copytree(plugin_path, dest_path) + LOGGER.error("Can't find plugin " + name) + return 1 reqpath = os.path.join(dest_path, 'requirements.txt') if os.path.exists(reqpath): - LOGGER.notice('This plugin has Python dependencies.') + LOGGER.warning('This plugin has Python dependencies.') LOGGER.info('Installing dependencies with pip...') try: - subprocess.check_call(('pip', 'install', '-r', reqpath)) + subprocess.check_call((sys.executable, '-m', 'pip', 'install', '-r', reqpath)) except subprocess.CalledProcessError: LOGGER.error('Could not install the dependencies.') print('Contents of the requirements.txt file:\n') - with io.open(reqpath, 'r', encoding='utf-8') as fh: + with io.open(reqpath, 'r', encoding='utf-8-sig') as fh: print(utils.indent(fh.read(), 4 * ' ')) print('You have to install those yourself or through a ' 'package manager.') else: LOGGER.info('Dependency installation succeeded.') + reqnpypath = os.path.join(dest_path, 'requirements-nonpy.txt') if os.path.exists(reqnpypath): - LOGGER.notice('This plugin has third-party ' - 'dependencies you need to install ' - 'manually.') + LOGGER.warning('This plugin has third-party ' + 'dependencies you need to install ' + 'manually.') print('Contents of the requirements-nonpy.txt file:\n') - with io.open(reqnpypath, 'r', encoding='utf-8') as fh: + with io.open(reqnpypath, 'r', encoding='utf-8-sig') as fh: for l in fh.readlines(): i, j = l.split('::') print(utils.indent(i.strip(), 4 * ' ')) @@ -277,28 +277,50 @@ class CommandPlugin(Command): print('You have to install those yourself or through a package ' 'manager.') + + req_plug_path = os.path.join(dest_path, 'requirements-plugins.txt') + if os.path.exists(req_plug_path): + LOGGER.info('This plugin requires other Nikola plugins.') + LOGGER.info('Installing plugins...') + plugin_failure = False + try: + with io.open(req_plug_path, 'r', encoding='utf-8-sig') as inf: + for plugname in inf.readlines(): + plugin_failure = self.do_install(url, plugname.strip(), show_install_notes) != 0 + except Exception: + plugin_failure = True + if plugin_failure: + LOGGER.error('Could not install a plugin.') + print('Contents of the requirements-plugins.txt file:\n') + with io.open(req_plug_path, 'r', encoding='utf-8-sig') as fh: + print(utils.indent(fh.read(), 4 * ' ')) + print('You have to install those yourself manually.') + else: + LOGGER.info('Dependency installation succeeded.') + confpypath = os.path.join(dest_path, 'conf.py.sample') if os.path.exists(confpypath) and show_install_notes: - LOGGER.notice('This plugin has a sample config file. Integrate it with yours in order to make this plugin work!') + LOGGER.warning('This plugin has a sample config file. Integrate it with yours in order to make this plugin work!') print('Contents of the conf.py.sample file:\n') - with io.open(confpypath, 'r', encoding='utf-8') as fh: + with io.open(confpypath, 'r', encoding='utf-8-sig') as fh: if self.site.colorful: - print(utils.indent(pygments.highlight( - fh.read(), PythonLexer(), TerminalFormatter()), - 4 * ' ')) + print(pygments.highlight(fh.read(), PythonLexer(), TerminalFormatter())) else: - print(utils.indent(fh.read(), 4 * ' ')) + print(fh.read()) return 0 def do_uninstall(self, name): """Uninstall a plugin.""" for plugin in self.site.plugin_manager.getAllPlugins(): # FIXME: this is repeated thrice - p = plugin.path - if os.path.isdir(p): - p = p + os.sep - else: - p = os.path.dirname(p) if name == plugin.name: # Uninstall this one + p = plugin.path + if os.path.isdir(p): + # Plugins that have a package in them need to delete parent + # Issue #2356 + p = p + os.sep + p = os.path.abspath(os.path.join(p, os.pardir)) + else: + p = os.path.dirname(p) LOGGER.warning('About to uninstall plugin: {0}'.format(name)) LOGGER.warning('This will delete {0}'.format(p)) sure = utils.ask_yesno('Are you sure?') @@ -314,10 +336,19 @@ class CommandPlugin(Command): """Download the JSON file with all plugins.""" if self.json is None: try: - self.json = requests.get(url).json() - except requests.exceptions.SSLError: - LOGGER.warning("SSL error, using http instead of https (press ^C to abort)") - time.sleep(1) - url = url.replace('https', 'http', 1) - self.json = requests.get(url).json() + try: + self.json = requests.get(url).json() + except requests.exceptions.SSLError: + LOGGER.warning("SSL error, using http instead of https (press ^C to abort)") + time.sleep(1) + url = url.replace('https', 'http', 1) + self.json = requests.get(url).json() + except json.decoder.JSONDecodeError as e: + LOGGER.error("Failed to decode JSON data in response from server.") + LOGGER.error("JSON error encountered: " + str(e)) + LOGGER.error("This issue might be caused by server-side issues, or by to unusual activity in your " + "network (as determined by CloudFlare). Please visit https://plugins.getnikola.com/ in " + "a browser.") + sys.exit(2) + return self.json diff --git a/nikola/plugins/command/rst2html.plugin b/nikola/plugins/command/rst2html.plugin index 02c9276..6f2fb25 100644 --- a/nikola/plugins/command/rst2html.plugin +++ b/nikola/plugins/command/rst2html.plugin @@ -5,9 +5,9 @@ module = rst2html [Documentation] author = Chris Warrick version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Compile reStructuredText to HTML using the Nikola architecture [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/rst2html/__init__.py b/nikola/plugins/command/rst2html/__init__.py index 06afffd..5576b35 100644 --- a/nikola/plugins/command/rst2html/__init__.py +++ b/nikola/plugins/command/rst2html/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2015 Chris Warrick and others. +# Copyright © 2015-2020 Chris Warrick and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,7 +26,6 @@ """Compile reStructuredText to HTML, using Nikola architecture.""" -from __future__ import unicode_literals, print_function import io import lxml.html @@ -36,7 +35,6 @@ from nikola.plugin_categories import Command class CommandRst2Html(Command): - """Compile reStructuredText to HTML, using Nikola architecture.""" name = "rst2html" @@ -51,12 +49,12 @@ class CommandRst2Html(Command): print("This command takes only one argument (input file name).") return 2 source = args[0] - with io.open(source, "r", encoding="utf8") as in_file: + with io.open(source, "r", encoding="utf-8-sig") as in_file: data = in_file.read() - output, error_level, deps = compiler.compile_html_string(data, source, True) + output, error_level, deps, shortcode_deps = compiler.compile_string(data, source, True) - rstcss_path = resource_filename('nikola', 'data/themes/base/assets/css/rst.css') - with io.open(rstcss_path, "r", encoding="utf8") as fh: + rstcss_path = resource_filename('nikola', 'data/themes/base/assets/css/rst_base.css') + with io.open(rstcss_path, "r", encoding="utf-8-sig") as fh: rstcss = fh.read() template_path = resource_filename('nikola', 'plugins/command/rst2html/rst2html.tmpl') @@ -65,7 +63,7 @@ class CommandRst2Html(Command): parser = lxml.html.HTMLParser(remove_blank_text=True) doc = lxml.html.document_fromstring(template_output, parser) html = b'\n' + lxml.html.tostring(doc, encoding='utf8', method='html', pretty_print=True) - print(html) + print(html.decode('utf-8')) if error_level < 3: return 0 else: diff --git a/nikola/plugins/command/serve.plugin b/nikola/plugins/command/serve.plugin index aca71ec..aa40073 100644 --- a/nikola/plugins/command/serve.plugin +++ b/nikola/plugins/command/serve.plugin @@ -5,9 +5,9 @@ module = serve [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Start test server. [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/serve.py b/nikola/plugins/command/serve.py index 0441c93..ede5179 100644 --- a/nikola/plugins/command/serve.py +++ b/nikola/plugins/command/serve.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,43 +26,33 @@ """Start test server.""" -from __future__ import print_function import os +import sys import re +import signal import socket import webbrowser -try: - from BaseHTTPServer import HTTPServer - from SimpleHTTPServer import SimpleHTTPRequestHandler -except ImportError: - from http.server import HTTPServer # NOQA - from http.server import SimpleHTTPRequestHandler # NOQA - -try: - from StringIO import StringIO -except ImportError: - from io import BytesIO as StringIO # NOQA - +from http.server import HTTPServer +from http.server import SimpleHTTPRequestHandler +from io import BytesIO as StringIO from nikola.plugin_categories import Command -from nikola.utils import get_logger, STDERR_HANDLER +from nikola.utils import dns_sd class IPv6Server(HTTPServer): - """An IPv6 HTTPServer.""" address_family = socket.AF_INET6 class CommandServe(Command): - """Start test server.""" name = "serve" doc_usage = "[options]" doc_purpose = "start the test webserver" - logger = None + dns_sd = None cmd_options = ( { @@ -71,7 +61,7 @@ class CommandServe(Command): 'long': 'port', 'default': 8000, 'type': int, - 'help': 'Port number (default: 8000)', + 'help': 'Port number', }, { 'name': 'address', @@ -79,7 +69,7 @@ class CommandServe(Command): 'long': 'address', 'type': str, 'default': '', - 'help': 'Address to bind (default: 0.0.0.0 – all local IPv4 interfaces)', + 'help': 'Address to bind, defaults to all local IPv4 interfaces', }, { 'name': 'detach', @@ -107,13 +97,24 @@ class CommandServe(Command): }, ) + def shutdown(self, signum=None, _frame=None): + """Shut down the server that is running detached.""" + if self.dns_sd: + self.dns_sd.Reset() + if os.path.exists(self.serve_pidfile): + os.remove(self.serve_pidfile) + if not self.detached: + self.logger.info("Server is shutting down.") + if signum: + sys.exit(0) + def _execute(self, options, args): """Start test server.""" - self.logger = get_logger('serve', STDERR_HANDLER) out_dir = self.site.config['OUTPUT_FOLDER'] if not os.path.isdir(out_dir): self.logger.error("Missing '{0}' folder?".format(out_dir)) else: + self.serve_pidfile = os.path.abspath('nikolaserve.pid') os.chdir(out_dir) if '[' in options['address']: options['address'] = options['address'].strip('[').strip(']') @@ -129,37 +130,47 @@ class CommandServe(Command): httpd = OurHTTP((options['address'], options['port']), OurHTTPRequestHandler) sa = httpd.socket.getsockname() - self.logger.info("Serving HTTP on {0} port {1}...".format(*sa)) + if ipv6: + server_url = "http://[{0}]:{1}/".format(*sa) + else: + server_url = "http://{0}:{1}/".format(*sa) + self.logger.info("Serving on {0} ...".format(server_url)) + if options['browser']: - if ipv6: - server_url = "http://[{0}]:{1}/".format(*sa) - else: - server_url = "http://{0}:{1}/".format(*sa) + # Some browsers fail to load 0.0.0.0 (Issue #2755) + if sa[0] == '0.0.0.0': + server_url = "http://127.0.0.1:{1}/".format(*sa) self.logger.info("Opening {0} in the default web browser...".format(server_url)) webbrowser.open(server_url) if options['detach']: + self.detached = True OurHTTPRequestHandler.quiet = True try: pid = os.fork() if pid == 0: + signal.signal(signal.SIGTERM, self.shutdown) httpd.serve_forever() else: - self.logger.info("Detached with PID {0}. Run `kill {0}` to stop the server.".format(pid)) - except AttributeError as e: + with open(self.serve_pidfile, 'w') as fh: + fh.write('{0}\n'.format(pid)) + self.logger.info("Detached with PID {0}. Run `kill {0}` or `kill $(cat nikolaserve.pid)` to stop the server.".format(pid)) + except AttributeError: if os.name == 'nt': self.logger.warning("Detaching is not available on Windows, server is running in the foreground.") else: - raise e + raise else: + self.detached = False try: + self.dns_sd = dns_sd(options['port'], (options['ipv6'] or '::' in options['address'])) + signal.signal(signal.SIGTERM, self.shutdown) httpd.serve_forever() except KeyboardInterrupt: - self.logger.info("Server is shutting down.") + self.shutdown() return 130 class OurHTTPRequestHandler(SimpleHTTPRequestHandler): - """A request handler, modified for Nikola.""" extensions_map = dict(SimpleHTTPRequestHandler.extensions_map) @@ -171,8 +182,7 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler): if self.quiet: return else: - # Old-style class in Python 2.7, cannot use super() - return SimpleHTTPRequestHandler.log_message(self, *args) + return super().log_message(*args) # NOTICE: this is a patched version of send_head() to disable all sorts of # caching. `nikola serve` is a development server, hence caching should @@ -184,9 +194,9 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler): # Note that it might break in future versions of Python, in which case we # would need to do even more magic. def send_head(self): - """Common code for GET and HEAD commands. + """Send response code and MIME header. - This sends the response code and MIME headers. + This is common code for GET and HEAD commands. Return value is either a file object (which has to be copied to the outputfile by the caller unless the command was HEAD, @@ -197,10 +207,12 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler): path = self.translate_path(self.path) f = None if os.path.isdir(path): - if not self.path.endswith('/'): + path_parts = list(self.path.partition('?')) + if not path_parts[0].endswith('/'): # redirect browser - doing basically what apache does + path_parts[0] += '/' self.send_response(301) - self.send_header("Location", self.path + "/") + self.send_header("Location", ''.join(path_parts)) # begin no-cache patch # For redirects. With redirects, caching is even worse and can # break more. Especially with 301 Moved Permanently redirects, @@ -226,7 +238,7 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler): # transmitted *less* than the content-length! f = open(path, 'rb') except IOError: - self.send_error(404, "File not found") + self.send_error(404, "File not found: {}".format(path)) return None filtered_bytes = None @@ -234,7 +246,7 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler): # Comment out any to allow local resolution of relative URLs. data = f.read().decode('utf8') f.close() - data = re.sub(r']*)>', '', data, re.IGNORECASE) + data = re.sub(r']*)>', r'', data, flags=re.IGNORECASE) data = data.encode('utf8') f = StringIO() f.write(data) @@ -242,7 +254,10 @@ class OurHTTPRequestHandler(SimpleHTTPRequestHandler): f.seek(0) self.send_response(200) - self.send_header("Content-type", ctype) + if ctype.startswith('text/') or ctype.endswith('+xml'): + self.send_header("Content-Type", "{0}; charset=UTF-8".format(ctype)) + else: + self.send_header("Content-Type", ctype) if os.path.splitext(path)[1] == '.svgz': # Special handling for svgz to make it work nice with browsers. self.send_header("Content-Encoding", 'gzip') diff --git a/nikola/plugins/command/status.plugin b/nikola/plugins/command/status.plugin index 91390d2..7e2bd96 100644 --- a/nikola/plugins/command/status.plugin +++ b/nikola/plugins/command/status.plugin @@ -9,5 +9,5 @@ website = https://getnikola.com description = Site status [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/status.py b/nikola/plugins/command/status.py index 55e7f95..c96d13f 100644 --- a/nikola/plugins/command/status.py +++ b/nikola/plugins/command/status.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,8 +26,6 @@ """Display site status.""" -from __future__ import print_function -import io import os from datetime import datetime from dateutil.tz import gettz, tzlocal @@ -36,14 +34,13 @@ from nikola.plugin_categories import Command class CommandStatus(Command): - """Display site status.""" name = "status" doc_purpose = "display site status" doc_description = "Show information about the posts and site deployment." - doc_usage = '[-l|--list-drafts] [-m|--list-modified] [-s|--list-scheduled]' + doc_usage = '[-d|--list-drafts] [-m|--list-modified] [-p|--list-private] [-P|--list-published] [-s|--list-scheduled]' logger = None cmd_options = [ { @@ -62,6 +59,22 @@ class CommandStatus(Command): 'default': False, 'help': 'List all modified files since last deployment', }, + { + 'name': 'list_private', + 'short': 'p', + 'long': 'list-private', + 'type': bool, + 'default': False, + 'help': 'List all private posts', + }, + { + 'name': 'list_published', + 'short': 'P', + 'long': 'list-published', + 'type': bool, + 'default': False, + 'help': 'List all published posts', + }, { 'name': 'list_scheduled', 'short': 's', @@ -76,16 +89,12 @@ class CommandStatus(Command): """Display site status.""" self.site.scan_posts() - timestamp_path = os.path.join(self.site.config["CACHE_FOLDER"], "lastdeploy") - - last_deploy = None - - try: - with io.open(timestamp_path, "r", encoding="utf8") as inf: - last_deploy = datetime.strptime(inf.read().strip(), "%Y-%m-%dT%H:%M:%S.%f") - last_deploy_offset = datetime.utcnow() - last_deploy - except (IOError, Exception): - print("It does not seem like you’ve ever deployed the site (or cache missing).") + last_deploy = self.site.state.get('last_deploy') + if last_deploy is not None: + last_deploy = datetime.strptime(last_deploy, "%Y-%m-%dT%H:%M:%S.%f") + last_deploy_offset = datetime.utcnow() - last_deploy + else: + print("It does not seem like you've ever deployed the site (or cache missing).") if last_deploy: @@ -111,12 +120,23 @@ class CommandStatus(Command): posts_count = len(self.site.all_posts) + # find all published posts + posts_published = [post for post in self.site.all_posts if post.use_in_feeds] + posts_published = sorted(posts_published, key=lambda post: post.source_path) + + # find all private posts + posts_private = [post for post in self.site.all_posts if post.is_private] + posts_private = sorted(posts_private, key=lambda post: post.source_path) + # find all drafts posts_drafts = [post for post in self.site.all_posts if post.is_draft] posts_drafts = sorted(posts_drafts, key=lambda post: post.source_path) # find all scheduled posts with offset from now until publishing time - posts_scheduled = [(post.date - now, post) for post in self.site.all_posts if post.publish_later] + posts_scheduled = [ + (post.date - now, post) for post in self.site.all_posts + if post.publish_later and not (post.is_draft or post.is_private) + ] posts_scheduled = sorted(posts_scheduled, key=lambda offset_post: (offset_post[0], offset_post[1].source_path)) if len(posts_scheduled) > 0: @@ -129,7 +149,13 @@ class CommandStatus(Command): if options['list_drafts']: for post in posts_drafts: print("Draft: '{0}' ({1}; source: {2})".format(post.meta('title'), post.permalink(), post.source_path)) - print("{0} posts in total, {1} scheduled, and {2} drafts.".format(posts_count, len(posts_scheduled), len(posts_drafts))) + if options['list_private']: + for post in posts_private: + print("Private: '{0}' ({1}; source: {2})".format(post.meta('title'), post.permalink(), post.source_path)) + if options['list_published']: + for post in posts_published: + print("Published: '{0}' ({1}; source: {2})".format(post.meta('title'), post.permalink(), post.source_path)) + print("{0} posts in total, {1} scheduled, {2} drafts, {3} private and {4} published.".format(posts_count, len(posts_scheduled), len(posts_drafts), len(posts_private), len(posts_published))) def human_time(self, dt): """Translate time into a human-friendly representation.""" diff --git a/nikola/plugins/command/subtheme.plugin b/nikola/plugins/command/subtheme.plugin new file mode 100644 index 0000000..d377e22 --- /dev/null +++ b/nikola/plugins/command/subtheme.plugin @@ -0,0 +1,13 @@ +[Core] +name = subtheme +module = subtheme + +[Documentation] +author = Roberto Alsina +version = 1.1 +website = https://getnikola.com/ +description = Given a swatch name and a parent theme, creates a custom subtheme. + +[Nikola] +PluginCategory = Command + diff --git a/nikola/plugins/command/subtheme.py b/nikola/plugins/command/subtheme.py new file mode 100644 index 0000000..554a241 --- /dev/null +++ b/nikola/plugins/command/subtheme.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Given a swatch name from bootswatch.com or hackerthemes.com and a parent theme, creates a custom theme.""" + +import configparser +import os + +import requests + +from nikola import utils +from nikola.plugin_categories import Command + +LOGGER = utils.get_logger('subtheme') + + +def _check_for_theme(theme, themes): + for t in themes: + if t.endswith(os.sep + theme): + return True + return False + + +class CommandSubTheme(Command): + """Given a swatch name from bootswatch.com and a parent theme, creates a custom theme.""" + + name = "subtheme" + doc_usage = "[options]" + doc_purpose = "given a swatch name from bootswatch.com or hackerthemes.com and a parent theme, creates a custom"\ + " theme" + cmd_options = [ + { + 'name': 'name', + 'short': 'n', + 'long': 'name', + 'default': 'custom', + 'type': str, + 'help': 'New theme name', + }, + { + 'name': 'swatch', + 'short': 's', + 'default': '', + 'type': str, + 'help': 'Name of the swatch from bootswatch.com.' + }, + { + 'name': 'parent', + 'short': 'p', + 'long': 'parent', + 'default': 'bootstrap4', + 'help': 'Parent theme name', + }, + ] + + def _execute(self, options, args): + """Given a swatch name and a parent theme, creates a custom theme.""" + name = options['name'] + swatch = options['swatch'] + if not swatch: + LOGGER.error('The -s option is mandatory') + return 1 + parent = options['parent'] + version = '4' + + # Check which Bootstrap version to use + themes = utils.get_theme_chain(parent, self.site.themes_dirs) + if _check_for_theme('bootstrap', themes) or _check_for_theme('bootstrap-jinja', themes): + version = '2' + elif _check_for_theme('bootstrap3', themes) or _check_for_theme('bootstrap3-jinja', themes): + version = '3' + elif _check_for_theme('bootstrap4', themes) or _check_for_theme('bootstrap4-jinja', themes): + version = '4' + elif not _check_for_theme('bootstrap4', themes) and not _check_for_theme('bootstrap4-jinja', themes): + LOGGER.warning( + '"subtheme" only makes sense for themes that use bootstrap') + elif _check_for_theme('bootstrap3-gradients', themes) or _check_for_theme('bootstrap3-gradients-jinja', themes): + LOGGER.warning( + '"subtheme" doesn\'t work well with the bootstrap3-gradients family') + + LOGGER.info("Creating '{0}' theme from '{1}' and '{2}'".format( + name, swatch, parent)) + utils.makedirs(os.path.join('themes', name, 'assets', 'css')) + for fname in ('bootstrap.min.css', 'bootstrap.css'): + if swatch in [ + 'bubblegum', 'business-tycoon', 'charming', 'daydream', + 'executive-suite', 'good-news', 'growth', 'harbor', 'hello-world', + 'neon-glow', 'pleasant', 'retro', 'vibrant-sea', 'wizardry']: # Hackerthemes + LOGGER.info( + 'Hackertheme-based subthemes often require you use a custom font for full effect.') + if version != '4': + LOGGER.error( + 'The hackertheme subthemes are only available for Bootstrap 4.') + return 1 + if fname == 'bootstrap.css': + url = 'https://raw.githubusercontent.com/HackerThemes/theme-machine/master/dist/{swatch}/css/bootstrap4-{swatch}.css'.format( + swatch=swatch) + else: + url = 'https://raw.githubusercontent.com/HackerThemes/theme-machine/master/dist/{swatch}/css/bootstrap4-{swatch}.min.css'.format( + swatch=swatch) + else: # Bootswatch + url = 'https://bootswatch.com' + if version: + url += '/' + version + url = '/'.join((url, swatch, fname)) + LOGGER.info("Downloading: " + url) + r = requests.get(url) + if r.status_code > 299: + LOGGER.error('Error {} getting {}', r.status_code, url) + return 1 + data = r.text + + with open(os.path.join('themes', name, 'assets', 'css', fname), + 'w+') as output: + output.write(data) + + with open(os.path.join('themes', name, '%s.theme' % name), 'w+') as output: + parent_theme_data_path = utils.get_asset_path( + '%s.theme' % parent, themes) + cp = configparser.ConfigParser() + cp.read(parent_theme_data_path) + cp['Theme']['parent'] = parent + cp['Family'] = {'family': cp['Family']['family']} + cp.write(output) + + LOGGER.info( + 'Theme created. Change the THEME setting to "{0}" to use it.'.format(name)) diff --git a/nikola/plugins/command/theme.plugin b/nikola/plugins/command/theme.plugin new file mode 100644 index 0000000..421d027 --- /dev/null +++ b/nikola/plugins/command/theme.plugin @@ -0,0 +1,13 @@ +[Core] +name = theme +module = theme + +[Documentation] +author = Roberto Alsina and Chris Warrick +version = 1.0 +website = https://getnikola.com/ +description = Manage Nikola themes + +[Nikola] +PluginCategory = Command + diff --git a/nikola/plugins/command/theme.py b/nikola/plugins/command/theme.py new file mode 100644 index 0000000..6f4339a --- /dev/null +++ b/nikola/plugins/command/theme.py @@ -0,0 +1,393 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina, Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Manage themes.""" + +import configparser +import io +import json.decoder +import os +import shutil +import sys +import time + +import requests +import pygments +from pygments.lexers import PythonLexer +from pygments.formatters import TerminalFormatter +from pkg_resources import resource_filename + +from nikola.plugin_categories import Command +from nikola import utils + +LOGGER = utils.get_logger('theme') + + +class CommandTheme(Command): + """Manage themes.""" + + json = None + name = "theme" + doc_usage = "[-u url] [-i theme_name] [-r theme_name] [-l] [--list-installed] [-g] [-n theme_name] [-c template_name]" + doc_purpose = "manage themes" + output_dir = 'themes' + cmd_options = [ + { + 'name': 'install', + 'short': 'i', + 'long': 'install', + 'type': str, + 'default': '', + 'help': 'Install a theme.' + }, + { + 'name': 'uninstall', + 'long': 'uninstall', + 'short': 'r', + 'type': str, + 'default': '', + 'help': 'Uninstall a theme.' + }, + { + 'name': 'list', + 'short': 'l', + 'long': 'list', + 'type': bool, + 'default': False, + 'help': 'Show list of available themes.' + }, + { + 'name': 'list_installed', + 'long': 'list-installed', + 'type': bool, + 'help': "List the installed themes with their location.", + 'default': False + }, + { + 'name': 'url', + 'short': 'u', + 'long': 'url', + 'type': str, + 'help': "URL for the theme repository", + 'default': 'https://themes.getnikola.com/v8/themes.json' + }, + { + 'name': 'getpath', + 'short': 'g', + 'long': 'get-path', + 'type': str, + 'default': '', + 'help': "Print the path for installed theme", + }, + { + 'name': 'copy-template', + 'short': 'c', + 'long': 'copy-template', + 'type': str, + 'default': '', + 'help': 'Copy a built-in template into templates/ or your theme', + }, + { + 'name': 'new', + 'short': 'n', + 'long': 'new', + 'type': str, + 'default': '', + 'help': 'Create a new theme', + }, + { + 'name': 'new_engine', + 'long': 'engine', + 'type': str, + 'default': 'mako', + 'help': 'Engine to use for new theme (mako or jinja)', + }, + { + 'name': 'new_parent', + 'long': 'parent', + 'type': str, + 'default': 'base', + 'help': 'Parent to use for new theme', + }, + { + 'name': 'new_legacy_meta', + 'long': 'legacy-meta', + 'type': bool, + 'default': False, + 'help': 'Create legacy meta files for new theme', + }, + ] + + def _execute(self, options, args): + """Install theme into current site.""" + url = options['url'] + + # See the "mode" we need to operate in + install = options.get('install') + uninstall = options.get('uninstall') + list_available = options.get('list') + list_installed = options.get('list_installed') + get_path = options.get('getpath') + copy_template = options.get('copy-template') + new = options.get('new') + new_engine = options.get('new_engine') + new_parent = options.get('new_parent') + new_legacy_meta = options.get('new_legacy_meta') + command_count = [bool(x) for x in ( + install, + uninstall, + list_available, + list_installed, + get_path, + copy_template, + new)].count(True) + if command_count > 1 or command_count == 0: + print(self.help()) + return 2 + + if list_available: + return self.list_available(url) + elif list_installed: + return self.list_installed() + elif install: + return self.do_install_deps(url, install) + elif uninstall: + return self.do_uninstall(uninstall) + elif get_path: + return self.get_path(get_path) + elif copy_template: + return self.copy_template(copy_template) + elif new: + return self.new_theme(new, new_engine, new_parent, new_legacy_meta) + + def do_install_deps(self, url, name): + """Install themes and their dependencies.""" + data = self.get_json(url) + # `name` may be modified by the while loop. + origname = name + installstatus = self.do_install(name, data) + # See if the theme's parent is available. If not, install it + while True: + parent_name = utils.get_parent_theme_name(utils.get_theme_path_real(name, self.site.themes_dirs)) + if parent_name is None: + break + try: + utils.get_theme_path_real(parent_name, self.site.themes_dirs) + break + except Exception: # Not available + self.do_install(parent_name, data) + name = parent_name + if installstatus: + LOGGER.info('Remember to set THEME="{0}" in conf.py to use this theme.'.format(origname)) + + def do_install(self, name, data): + """Download and install a theme.""" + if name in data: + utils.makedirs(self.output_dir) + url = data[name] + LOGGER.info("Downloading '{0}'".format(url)) + try: + zip_data = requests.get(url).content + except requests.exceptions.SSLError: + LOGGER.warning("SSL error, using http instead of https (press ^C to abort)") + time.sleep(1) + url = url.replace('https', 'http', 1) + zip_data = requests.get(url).content + + zip_file = io.BytesIO() + zip_file.write(zip_data) + LOGGER.info("Extracting '{0}' into themes/".format(name)) + utils.extract_all(zip_file) + dest_path = os.path.join(self.output_dir, name) + else: + dest_path = os.path.join(self.output_dir, name) + try: + theme_path = utils.get_theme_path_real(name, self.site.themes_dirs) + LOGGER.error("Theme '{0}' is already installed in {1}".format(name, theme_path)) + except Exception: + LOGGER.error("Can't find theme {0}".format(name)) + + return False + + confpypath = os.path.join(dest_path, 'conf.py.sample') + if os.path.exists(confpypath): + LOGGER.warning('This theme has a sample config file. Integrate it with yours in order to make this theme work!') + print('Contents of the conf.py.sample file:\n') + with io.open(confpypath, 'r', encoding='utf-8-sig') as fh: + if self.site.colorful: + print(pygments.highlight(fh.read(), PythonLexer(), TerminalFormatter())) + else: + print(fh.read()) + return True + + def do_uninstall(self, name): + """Uninstall a theme.""" + try: + path = utils.get_theme_path_real(name, self.site.themes_dirs) + except Exception: + LOGGER.error('Unknown theme: {0}'.format(name)) + return 1 + # Don't uninstall builtin themes (Issue #2510) + blocked = os.path.dirname(utils.__file__) + if path.startswith(blocked): + LOGGER.error("Can't delete builtin theme: {0}".format(name)) + return 1 + LOGGER.warning('About to uninstall theme: {0}'.format(name)) + LOGGER.warning('This will delete {0}'.format(path)) + sure = utils.ask_yesno('Are you sure?') + if sure: + LOGGER.warning('Removing {0}'.format(path)) + shutil.rmtree(path) + return 0 + return 1 + + def get_path(self, name): + """Get path for an installed theme.""" + try: + path = utils.get_theme_path_real(name, self.site.themes_dirs) + print(path) + except Exception: + print("not installed") + return 0 + + def list_available(self, url): + """List all available themes.""" + data = self.get_json(url) + print("Available Themes:") + print("-----------------") + for theme in sorted(data.keys()): + print(theme) + return 0 + + def list_installed(self): + """List all installed themes.""" + print("Installed Themes:") + print("-----------------") + themes = [] + themes_dirs = self.site.themes_dirs + [resource_filename('nikola', os.path.join('data', 'themes'))] + for tdir in themes_dirs: + if os.path.isdir(tdir): + themes += [(i, os.path.join(tdir, i)) for i in os.listdir(tdir)] + + for tname, tpath in sorted(set(themes)): + if os.path.isdir(tpath): + print("{0} at {1}".format(tname, tpath)) + + def copy_template(self, template): + """Copy the named template file from the parent to a local theme or to templates/.""" + # Find template + t = self.site.template_system.get_template_path(template) + if t is None: + LOGGER.error("Cannot find template {0} in the lookup.".format(template)) + return 2 + + # Figure out where to put it. + # Check if a local theme exists. + theme_path = utils.get_theme_path(self.site.THEMES[0]) + if theme_path.startswith('themes' + os.sep): + # Theme in local themes/ directory + base = os.path.join(theme_path, 'templates') + else: + # Put it in templates/ + base = 'templates' + + if not os.path.exists(base): + os.mkdir(base) + LOGGER.info("Created directory {0}".format(base)) + + try: + out = shutil.copy(t, base) + LOGGER.info("Copied template from {0} to {1}".format(t, out)) + except shutil.SameFileError: + LOGGER.error("This file already exists in your templates directory ({0}).".format(base)) + return 3 + + def new_theme(self, name, engine, parent, create_legacy_meta=False): + """Create a new theme.""" + base = 'themes' + themedir = os.path.join(base, name) + LOGGER.info("Creating theme {0} with parent {1} and engine {2} in {3}".format(name, parent, engine, themedir)) + if not os.path.exists(base): + os.mkdir(base) + LOGGER.info("Created directory {0}".format(base)) + + # Check if engine and parent match + parent_engine = utils.get_template_engine(utils.get_theme_chain(parent, self.site.themes_dirs)) + + if parent_engine != engine: + LOGGER.error("Cannot use engine {0} because parent theme '{1}' uses {2}".format(engine, parent, parent_engine)) + return 2 + + # Create theme + if not os.path.exists(themedir): + os.mkdir(themedir) + LOGGER.info("Created directory {0}".format(themedir)) + else: + LOGGER.error("Theme already exists") + return 2 + + cp = configparser.ConfigParser() + cp['Theme'] = { + 'engine': engine, + 'parent': parent + } + + theme_meta_path = os.path.join(themedir, name + '.theme') + with io.open(theme_meta_path, 'w', encoding='utf-8') as fh: + cp.write(fh) + LOGGER.info("Created file {0}".format(theme_meta_path)) + + if create_legacy_meta: + with io.open(os.path.join(themedir, 'parent'), 'w', encoding='utf-8') as fh: + fh.write(parent + '\n') + LOGGER.info("Created file {0}".format(os.path.join(themedir, 'parent'))) + with io.open(os.path.join(themedir, 'engine'), 'w', encoding='utf-8') as fh: + fh.write(engine + '\n') + LOGGER.info("Created file {0}".format(os.path.join(themedir, 'engine'))) + + LOGGER.info("Theme {0} created successfully.".format(themedir)) + LOGGER.info('Remember to set THEME="{0}" in conf.py to use this theme.'.format(name)) + + def get_json(self, url): + """Download the JSON file with all plugins.""" + if self.json is None: + try: + try: + self.json = requests.get(url).json() + except requests.exceptions.SSLError: + LOGGER.warning("SSL error, using http instead of https (press ^C to abort)") + time.sleep(1) + url = url.replace('https', 'http', 1) + self.json = requests.get(url).json() + except json.decoder.JSONDecodeError as e: + LOGGER.error("Failed to decode JSON data in response from server.") + LOGGER.error("JSON error encountered:" + str(e)) + LOGGER.error("This issue might be caused by server-side issues, or by to unusual activity in your " + "network (as determined by CloudFlare). Please visit https://themes.getnikola.com/ in " + "a browser.") + sys.exit(2) + + return self.json diff --git a/nikola/plugins/command/version.plugin b/nikola/plugins/command/version.plugin index 4708bdb..a172e28 100644 --- a/nikola/plugins/command/version.plugin +++ b/nikola/plugins/command/version.plugin @@ -5,9 +5,9 @@ module = version [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Show nikola version [Nikola] -plugincategory = Command +PluginCategory = Command diff --git a/nikola/plugins/command/version.py b/nikola/plugins/command/version.py index ad08f64..9b81343 100644 --- a/nikola/plugins/command/version.py +++ b/nikola/plugins/command/version.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,19 +26,16 @@ """Print Nikola version.""" -from __future__ import print_function -import lxml import requests from nikola.plugin_categories import Command from nikola import __version__ -URL = 'https://pypi.python.org/pypi?:action=doap&name=Nikola' +URL = 'https://pypi.org/pypi/Nikola/json' class CommandVersion(Command): - """Print Nikola version.""" name = "version" @@ -61,10 +58,11 @@ class CommandVersion(Command): """Print the version number.""" print("Nikola v" + __version__) if options.get('check'): - data = requests.get(URL).text - doc = lxml.etree.fromstring(data.encode('utf8')) - revision = doc.findall('*//{http://usefulinc.com/ns/doap#}revision')[0].text - if revision == __version__: + data = requests.get(URL).json() + pypi_version = data['info']['version'] + if pypi_version == __version__: print("Nikola is up-to-date") else: - print("The latest version of Nikola is v{0} -- please upgrade using `pip install --upgrade Nikola=={0}` or your system package manager".format(revision)) + print("The latest version of Nikola is v{0}. Please upgrade " + "using `pip install --upgrade Nikola=={0}` or your " + "system package manager.".format(pypi_version)) diff --git a/nikola/plugins/compile/__init__.py b/nikola/plugins/compile/__init__.py index 60f1919..db78fce 100644 --- a/nikola/plugins/compile/__init__.py +++ b/nikola/plugins/compile/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated diff --git a/nikola/plugins/compile/html.plugin b/nikola/plugins/compile/html.plugin index 53ade61..be1f876 100644 --- a/nikola/plugins/compile/html.plugin +++ b/nikola/plugins/compile/html.plugin @@ -5,9 +5,9 @@ module = html [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Compile HTML into HTML (just copy) [Nikola] -plugincategory = Compiler +PluginCategory = Compiler friendlyname = HTML diff --git a/nikola/plugins/compile/html.py b/nikola/plugins/compile/html.py index 5f8b244..80b6713 100644 --- a/nikola/plugins/compile/html.py +++ b/nikola/plugins/compile/html.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -24,33 +24,48 @@ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -"""Implementation of compile_html for HTML source files.""" +"""Page compiler plugin for HTML source files.""" -from __future__ import unicode_literals -import os import io +import os + +import lxml.html +from nikola import shortcodes as sc from nikola.plugin_categories import PageCompiler -from nikola.utils import makedirs, write_metadata +from nikola.utils import LocaleBorg, makedirs, map_metadata, write_metadata class CompileHtml(PageCompiler): - """Compile HTML into HTML.""" name = "html" friendly_name = "HTML" + supports_metadata = True + + def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None): + """Compile HTML into HTML strings, with shortcode support.""" + if not is_two_file: + _, data = self.split_metadata(data, post, lang) + new_data, shortcodes = sc.extract_shortcodes(data) + return self.site.apply_shortcodes_uuid(new_data, shortcodes, filename=source_path, extra_context={'post': post}) - def compile_html(self, source, dest, is_two_file=True): - """Compile source file into HTML and save as dest.""" + def compile(self, source, dest, is_two_file=True, post=None, lang=None): + """Compile the source file into HTML and save as dest.""" makedirs(os.path.dirname(dest)) - with io.open(dest, "w+", encoding="utf8") as out_file: - with io.open(source, "r", encoding="utf8") as in_file: + with io.open(dest, "w+", encoding="utf-8") as out_file: + with io.open(source, "r", encoding="utf-8-sig") as in_file: data = in_file.read() - if not is_two_file: - _, data = self.split_metadata(data) + data, shortcode_deps = self.compile_string(data, source, is_two_file, post, lang) out_file.write(data) + if post is None: + if shortcode_deps: + self.logger.error( + "Cannot save dependencies for post {0} (post unknown)", + source) + else: + post._depfile[dest] += shortcode_deps return True def create_post(self, path, **kw): @@ -65,9 +80,41 @@ class CompileHtml(PageCompiler): makedirs(os.path.dirname(path)) if not content.endswith('\n'): content += '\n' - with io.open(path, "w+", encoding="utf8") as fd: + with io.open(path, "w+", encoding="utf-8") as fd: if onefile: - fd.write('\n\n') + fd.write(write_metadata(metadata, comment_wrap=True, site=self.site, compiler=self)) fd.write(content) + + def read_metadata(self, post, file_metadata_regexp=None, unslugify_titles=False, lang=None): + """Read the metadata from a post's meta tags, and return a metadata dict.""" + if lang is None: + lang = LocaleBorg().current_lang + source_path = post.translated_source_path(lang) + + with io.open(source_path, 'r', encoding='utf-8-sig') as inf: + data = inf.read() + + metadata = {} + try: + doc = lxml.html.document_fromstring(data) + except lxml.etree.ParserError as e: + # Issue #374 -> #2851 + if str(e) == "Document is empty": + return {} + # let other errors raise + raise + title_tag = doc.find('*//title') + if title_tag is not None and title_tag.text: + metadata['title'] = title_tag.text + meta_tags = doc.findall('*//meta') + for tag in meta_tags: + k = tag.get('name', '').lower() + if not k: + continue + elif k == 'keywords': + k = 'tags' + content = tag.get('content') + if content: + metadata[k] = content + map_metadata(metadata, 'html_metadata', self.site.config) + return metadata diff --git a/nikola/plugins/compile/ipynb.plugin b/nikola/plugins/compile/ipynb.plugin index c369ab2..c146172 100644 --- a/nikola/plugins/compile/ipynb.plugin +++ b/nikola/plugins/compile/ipynb.plugin @@ -6,8 +6,8 @@ module = ipynb author = Damian Avila, Chris Warrick and others version = 2.0.0 website = http://www.damian.oquanta.info/ -description = Compile IPython notebooks into Nikola posts +description = Compile Jupyter notebooks into Nikola posts [Nikola] -plugincategory = Compiler -friendlyname = Jupyter/IPython Notebook +PluginCategory = Compiler +friendlyname = Jupyter Notebook diff --git a/nikola/plugins/compile/ipynb.py b/nikola/plugins/compile/ipynb.py index a9dedde..039604b 100644 --- a/nikola/plugins/compile/ipynb.py +++ b/nikola/plugins/compile/ipynb.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2013-2015 Damián Avila, Chris Warrick and others. +# Copyright © 2013-2020 Damián Avila, Chris Warrick and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -24,76 +24,95 @@ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -"""Implementation of compile_html based on nbconvert.""" +"""Page compiler plugin for nbconvert.""" -from __future__ import unicode_literals, print_function import io +import json import os -import sys try: - import IPython - from IPython.nbconvert.exporters import HTMLExporter - if IPython.version_info[0] >= 3: # API changed with 3.0.0 - from IPython import nbformat - current_nbformat = nbformat.current_nbformat - from IPython.kernel import kernelspec - else: - import IPython.nbformat.current as nbformat - current_nbformat = 'json' - kernelspec = None - - from IPython.config import Config + import nbconvert + from nbconvert.exporters import HTMLExporter + import nbformat + current_nbformat = nbformat.current_nbformat + from jupyter_client import kernelspec + from traitlets.config import Config + NBCONVERT_VERSION_MAJOR = int(nbconvert.__version__.partition(".")[0]) flag = True except ImportError: flag = None +from nikola import shortcodes as sc from nikola.plugin_categories import PageCompiler -from nikola.utils import makedirs, req_missing, get_logger, STDERR_HANDLER +from nikola.utils import makedirs, req_missing, LocaleBorg class CompileIPynb(PageCompiler): - """Compile IPynb into HTML.""" name = "ipynb" - friendly_name = "Jupyter/IPython Notebook" + friendly_name = "Jupyter Notebook" demote_headers = True - default_kernel = 'python2' if sys.version_info[0] == 2 else 'python3' - - def set_site(self, site): - """Set Nikola site.""" - self.logger = get_logger('compile_ipynb', STDERR_HANDLER) - super(CompileIPynb, self).set_site(site) + default_kernel = 'python3' + supports_metadata = True - def compile_html_string(self, source, is_two_file=True): + def _compile_string(self, nb_json): """Export notebooks as HTML strings.""" - if flag is None: - req_missing(['ipython[notebook]>=2.0.0'], 'build this site (compile ipynb)') - HTMLExporter.default_template = 'basic' - c = Config(self.site.config['IPYNB_CONFIG']) + self._req_missing_ipynb() + c = Config(get_default_jupyter_config()) + c.merge(Config(self.site.config['IPYNB_CONFIG'])) + if 'template_file' not in self.site.config['IPYNB_CONFIG'].get('Exporter', {}): + if NBCONVERT_VERSION_MAJOR >= 6: + c['Exporter']['template_file'] = 'classic/base.html.j2' + else: + c['Exporter']['template_file'] = 'basic.tpl' # not a typo exportHtml = HTMLExporter(config=c) - with io.open(source, "r", encoding="utf8") as in_file: - nb_json = nbformat.read(in_file, current_nbformat) - (body, resources) = exportHtml.from_notebook_node(nb_json) + body, _ = exportHtml.from_notebook_node(nb_json) return body - def compile_html(self, source, dest, is_two_file=True): - """Compile source file into HTML and save as dest.""" + @staticmethod + def _nbformat_read(in_file): + return nbformat.read(in_file, current_nbformat) + + def _req_missing_ipynb(self): + if flag is None: + req_missing(['notebook>=4.0.0'], 'build this site (compile ipynb)') + + def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None): + """Compile notebooks into HTML strings.""" + new_data, shortcodes = sc.extract_shortcodes(data) + output = self._compile_string(nbformat.reads(new_data, current_nbformat)) + return self.site.apply_shortcodes_uuid(output, shortcodes, filename=source_path, extra_context={'post': post}) + + def compile(self, source, dest, is_two_file=False, post=None, lang=None): + """Compile the source file into HTML and save as dest.""" makedirs(os.path.dirname(dest)) - with io.open(dest, "w+", encoding="utf8") as out_file: - out_file.write(self.compile_html_string(source, is_two_file)) + with io.open(dest, "w+", encoding="utf-8") as out_file: + with io.open(source, "r", encoding="utf-8-sig") as in_file: + nb_str = in_file.read() + output, shortcode_deps = self.compile_string(nb_str, source, + is_two_file, post, + lang) + out_file.write(output) + if post is None: + if shortcode_deps: + self.logger.error( + "Cannot save dependencies for post {0} (post unknown)", + source) + else: + post._depfile[dest] += shortcode_deps - def read_metadata(self, post, file_metadata_regexp=None, unslugify_titles=False, lang=None): + def read_metadata(self, post, lang=None): """Read metadata directly from ipynb file. - As ipynb file support arbitrary metadata as json, the metadata used by Nikola + As ipynb files support arbitrary metadata as json, the metadata used by Nikola will be assume to be in the 'nikola' subfield. """ - if flag is None: - req_missing(['ipython[notebook]>=2.0.0'], 'build this site (compile ipynb)') - source = post.source_path - with io.open(source, "r", encoding="utf8") as in_file: + self._req_missing_ipynb() + if lang is None: + lang = LocaleBorg().current_lang + source = post.translated_source_path(lang) + with io.open(source, "r", encoding="utf-8-sig") as in_file: nb_json = nbformat.read(in_file, current_nbformat) # Metadata might not exist in two-file posts or in hand-crafted # .ipynb files. @@ -101,11 +120,10 @@ class CompileIPynb(PageCompiler): def create_post(self, path, **kw): """Create a new post.""" - if flag is None: - req_missing(['ipython[notebook]>=2.0.0'], 'build this site (compile ipynb)') + self._req_missing_ipynb() content = kw.pop('content', None) onefile = kw.pop('onefile', False) - kernel = kw.pop('ipython_kernel', None) + kernel = kw.pop('jupyter_kernel', None) # is_page is not needed to create the file kw.pop('is_page', False) @@ -119,40 +137,52 @@ class CompileIPynb(PageCompiler): # imported .ipynb file, guaranteed to start with "{" because it’s JSON. nb = nbformat.reads(content, current_nbformat) else: - if IPython.version_info[0] >= 3: - nb = nbformat.v4.new_notebook() - nb["cells"] = [nbformat.v4.new_markdown_cell(content)] - else: - nb = nbformat.new_notebook() - nb["worksheets"] = [nbformat.new_worksheet(cells=[nbformat.new_text_cell('markdown', [content])])] - - if kernelspec is not None: - if kernel is None: - kernel = self.default_kernel - self.logger.notice('No kernel specified, assuming "{0}".'.format(kernel)) - - IPYNB_KERNELS = {} - ksm = kernelspec.KernelSpecManager() - for k in ksm.find_kernel_specs(): - IPYNB_KERNELS[k] = ksm.get_kernel_spec(k).to_dict() - IPYNB_KERNELS[k]['name'] = k - del IPYNB_KERNELS[k]['argv'] - - if kernel not in IPYNB_KERNELS: - self.logger.error('Unknown kernel "{0}". Maybe you mispelled it?'.format(kernel)) - self.logger.info("Available kernels: {0}".format(", ".join(sorted(IPYNB_KERNELS)))) - raise Exception('Unknown kernel "{0}"'.format(kernel)) - - nb["metadata"]["kernelspec"] = IPYNB_KERNELS[kernel] - else: - # Older IPython versions don’t need kernelspecs. - pass + nb = nbformat.v4.new_notebook() + nb["cells"] = [nbformat.v4.new_markdown_cell(content)] + + if kernel is None: + kernel = self.default_kernel + self.logger.warning('No kernel specified, assuming "{0}".'.format(kernel)) + + IPYNB_KERNELS = {} + ksm = kernelspec.KernelSpecManager() + for k in ksm.find_kernel_specs(): + IPYNB_KERNELS[k] = ksm.get_kernel_spec(k).to_dict() + IPYNB_KERNELS[k]['name'] = k + del IPYNB_KERNELS[k]['argv'] + + if kernel not in IPYNB_KERNELS: + self.logger.error('Unknown kernel "{0}". Maybe you mispelled it?'.format(kernel)) + self.logger.info("Available kernels: {0}".format(", ".join(sorted(IPYNB_KERNELS)))) + raise Exception('Unknown kernel "{0}"'.format(kernel)) + + nb["metadata"]["kernelspec"] = IPYNB_KERNELS[kernel] if onefile: nb["metadata"]["nikola"] = metadata - with io.open(path, "w+", encoding="utf8") as fd: - if IPython.version_info[0] >= 3: - nbformat.write(nb, fd, 4) - else: - nbformat.write(nb, fd, 'ipynb') + with io.open(path, "w+", encoding="utf-8") as fd: + nbformat.write(nb, fd, 4) + + +def get_default_jupyter_config(): + """Search default jupyter configuration location paths. + + Return dictionary from configuration json files. + """ + config = {} + from jupyter_core.paths import jupyter_config_path + + for parent in jupyter_config_path(): + try: + for file in os.listdir(parent): + if 'nbconvert' in file and file.endswith('.json'): + abs_path = os.path.join(parent, file) + with open(abs_path) as config_file: + config.update(json.load(config_file)) + except OSError: + # some paths jupyter uses to find configurations + # may not exist + pass + + return config diff --git a/nikola/plugins/compile/markdown.plugin b/nikola/plugins/compile/markdown.plugin index f7d11b1..85c67c3 100644 --- a/nikola/plugins/compile/markdown.plugin +++ b/nikola/plugins/compile/markdown.plugin @@ -5,9 +5,9 @@ module = markdown [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Compile Markdown into HTML [Nikola] -plugincategory = Compiler +PluginCategory = Compiler friendlyname = Markdown diff --git a/nikola/plugins/compile/markdown/__init__.py b/nikola/plugins/compile/markdown/__init__.py index c1425a1..74e8c75 100644 --- a/nikola/plugins/compile/markdown/__init__.py +++ b/nikola/plugins/compile/markdown/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -24,59 +24,110 @@ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -"""Implementation of compile_html based on markdown.""" - -from __future__ import unicode_literals +"""Page compiler plugin for Markdown.""" import io +import json import os +import threading + +from nikola import shortcodes as sc +from nikola.plugin_categories import PageCompiler +from nikola.utils import makedirs, req_missing, write_metadata, LocaleBorg, map_metadata try: - from markdown import markdown + from markdown import Markdown except ImportError: - markdown = None # NOQA - nikola_extension = None - gist_extension = None - podcast_extension = None + Markdown = None -from nikola.plugin_categories import PageCompiler -from nikola.utils import makedirs, req_missing, write_metadata +class ThreadLocalMarkdown(threading.local): + """Convert Markdown to HTML using per-thread Markdown objects. -class CompileMarkdown(PageCompiler): + See discussion in #2661. + """ + + def __init__(self, extensions, extension_configs): + """Create a Markdown instance.""" + self.markdown = Markdown(extensions=extensions, extension_configs=extension_configs, output_format="html5") + + def convert(self, data): + """Convert data to HTML and reset internal state.""" + result = self.markdown.convert(data) + try: + meta = {} + for k in self.markdown.Meta: # This reads everything as lists + meta[k.lower()] = ','.join(self.markdown.Meta[k]) + except Exception: + meta = {} + self.markdown.reset() + return result, meta + +class CompileMarkdown(PageCompiler): """Compile Markdown into HTML.""" name = "markdown" friendly_name = "Markdown" demote_headers = True - extensions = [] site = None + supports_metadata = False def set_site(self, site): """Set Nikola site.""" - super(CompileMarkdown, self).set_site(site) + super().set_site(site) self.config_dependencies = [] + extensions = [] for plugin_info in self.get_compiler_extensions(): self.config_dependencies.append(plugin_info.name) - self.extensions.append(plugin_info.plugin_object) + extensions.append(plugin_info.plugin_object) plugin_info.plugin_object.short_help = plugin_info.description - self.config_dependencies.append(str(sorted(site.config.get("MARKDOWN_EXTENSIONS")))) - - def compile_html(self, source, dest, is_two_file=True): - """Compile source file into HTML and save as dest.""" - if markdown is None: + site_extensions = self.site.config.get("MARKDOWN_EXTENSIONS") + self.config_dependencies.append(str(sorted(site_extensions))) + extensions.extend(site_extensions) + + site_extension_configs = self.site.config.get("MARKDOWN_EXTENSION_CONFIGS") + if site_extension_configs: + self.config_dependencies.append(json.dumps(site_extension_configs.values, sort_keys=True)) + + if Markdown is not None: + self.converters = {} + for lang in self.site.config['TRANSLATIONS']: + lang_extension_configs = site_extension_configs(lang) if site_extension_configs else {} + self.converters[lang] = ThreadLocalMarkdown(extensions, lang_extension_configs) + self.supports_metadata = 'markdown.extensions.meta' in extensions + + def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None): + """Compile Markdown into HTML strings.""" + if lang is None: + lang = LocaleBorg().current_lang + if Markdown is None: + req_missing(['markdown'], 'build this site (compile Markdown)') + if not is_two_file: + _, data = self.split_metadata(data, post, lang) + new_data, shortcodes = sc.extract_shortcodes(data) + output, _ = self.converters[lang].convert(new_data) + output, shortcode_deps = self.site.apply_shortcodes_uuid(output, shortcodes, filename=source_path, extra_context={'post': post}) + return output, shortcode_deps + + def compile(self, source, dest, is_two_file=True, post=None, lang=None): + """Compile the source file into HTML and save as dest.""" + if Markdown is None: req_missing(['markdown'], 'build this site (compile Markdown)') makedirs(os.path.dirname(dest)) - self.extensions += self.site.config.get("MARKDOWN_EXTENSIONS") - with io.open(dest, "w+", encoding="utf8") as out_file: - with io.open(source, "r", encoding="utf8") as in_file: + with io.open(dest, "w+", encoding="utf-8") as out_file: + with io.open(source, "r", encoding="utf-8-sig") as in_file: data = in_file.read() - if not is_two_file: - _, data = self.split_metadata(data) - output = markdown(data, self.extensions) + output, shortcode_deps = self.compile_string(data, source, is_two_file, post, lang) out_file.write(output) + if post is None: + if shortcode_deps: + self.logger.error( + "Cannot save dependencies for post {0} (post unknown)", + source) + else: + post._depfile[dest] += shortcode_deps def create_post(self, path, **kw): """Create a new post.""" @@ -91,9 +142,30 @@ class CompileMarkdown(PageCompiler): makedirs(os.path.dirname(path)) if not content.endswith('\n'): content += '\n' - with io.open(path, "w+", encoding="utf8") as fd: + with io.open(path, "w+", encoding="utf-8") as fd: if onefile: - fd.write('\n\n') + fd.write(write_metadata(metadata, comment_wrap=True, site=self.site, compiler=self)) fd.write(content) + + def read_metadata(self, post, lang=None): + """Read the metadata from a post, and return a metadata dict.""" + lang = lang or self.site.config['DEFAULT_LANG'] + if not self.supports_metadata: + return {} + if Markdown is None: + req_missing(['markdown'], 'build this site (compile Markdown)') + if lang is None: + lang = LocaleBorg().current_lang + source = post.translated_source_path(lang) + with io.open(source, 'r', encoding='utf-8-sig') as inf: + # Note: markdown meta returns lowercase keys + data = inf.read() + # If the metadata starts with "---" it's actually YAML and + # we should not let markdown parse it, because it will do + # bad things like setting empty tags to "''" + if data.startswith('---\n'): + return {} + _, meta = self.converters[lang].convert(data) + # Map metadata from other platforms to names Nikola expects (Issue #2817) + map_metadata(meta, 'markdown_metadata', self.site.config) + return meta diff --git a/nikola/plugins/compile/markdown/mdx_gist.plugin b/nikola/plugins/compile/markdown/mdx_gist.plugin index 7fe676c..f962cb7 100644 --- a/nikola/plugins/compile/markdown/mdx_gist.plugin +++ b/nikola/plugins/compile/markdown/mdx_gist.plugin @@ -4,11 +4,11 @@ module = mdx_gist [Nikola] compiler = markdown -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Roberto Alsina version = 0.1 -website = http://getnikola.com +website = https://getnikola.com/ description = Extension for embedding gists diff --git a/nikola/plugins/compile/markdown/mdx_gist.py b/nikola/plugins/compile/markdown/mdx_gist.py index f439fa2..f6ce20a 100644 --- a/nikola/plugins/compile/markdown/mdx_gist.py +++ b/nikola/plugins/compile/markdown/mdx_gist.py @@ -22,7 +22,7 @@ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # # Warning: URL formats of "raw" gists are undocummented and subject to change. -# See also: http://developer.github.com/v3/gists/ +# See also: https://developer.github.com/v3/gists/ # # Inspired by "[Python] reStructuredText GitHub Gist directive" # (https://gist.github.com/brianhsu/1407759), public domain by Brian Hsu @@ -31,164 +31,54 @@ Extension to Python Markdown for Embedded Gists (gist.github.com). Basic Example: - >>> import markdown - >>> text = ''' - ... Text of the gist: - ... [:gist: 4747847] - ... ''' - >>> html = markdown.markdown(text, [GistExtension()]) - >>> print(html) -

    Text of the gist: -

    - - -
    -

    + Text of the gist: + [:gist: 4747847] Example with filename: - >>> import markdown - >>> text = ''' - ... Text of the gist: - ... [:gist: 4747847 zen.py] - ... ''' - >>> html = markdown.markdown(text, [GistExtension()]) - >>> print(html) -

    Text of the gist: -

    - - -
    -

    + Text of the gist: + [:gist: 4747847 zen.py] Basic Example with hexidecimal id: - >>> import markdown - >>> text = ''' - ... Text of the gist: - ... [:gist: c4a43d6fdce612284ac0] - ... ''' - >>> html = markdown.markdown(text, [GistExtension()]) - >>> print(html) -

    Text of the gist: -

    - - -
    -

    + Text of the gist: + [:gist: c4a43d6fdce612284ac0] Example with hexidecimal id filename: - >>> import markdown - >>> text = ''' - ... Text of the gist: - ... [:gist: c4a43d6fdce612284ac0 cow.txt] - ... ''' - >>> html = markdown.markdown(text, [GistExtension()]) - >>> print(html) -

    Text of the gist: -

    - - -
    -

    + Text of the gist: + [:gist: c4a43d6fdce612284ac0 cow.txt] Example using reStructuredText syntax: - >>> import markdown - >>> text = ''' - ... Text of the gist: - ... .. gist:: 4747847 zen.py - ... ''' - >>> html = markdown.markdown(text, [GistExtension()]) - >>> print(html) -

    Text of the gist: -

    - - -
    -

    + Text of the gist: + .. gist:: 4747847 zen.py Example using hexidecimal ID with reStructuredText syntax: - >>> import markdown - >>> text = ''' - ... Text of the gist: - ... .. gist:: c4a43d6fdce612284ac0 - ... ''' - >>> html = markdown.markdown(text, [GistExtension()]) - >>> print(html) -

    Text of the gist: -

    - - -
    -

    + Text of the gist: + .. gist:: c4a43d6fdce612284ac0 Example using hexidecimal ID and filename with reStructuredText syntax: - >>> import markdown - >>> text = ''' - ... Text of the gist: - ... .. gist:: c4a43d6fdce612284ac0 cow.txt - ... ''' - >>> html = markdown.markdown(text, [GistExtension()]) - >>> print(html) -

    Text of the gist: -

    - - -
    -

    + Text of the gist: + .. gist:: c4a43d6fdce612284ac0 cow.txt Error Case: non-existent Gist ID: - >>> import markdown - >>> text = ''' - ... Text of the gist: - ... [:gist: 0] - ... ''' - >>> html = markdown.markdown(text, [GistExtension()]) - >>> print(html) -

    Text of the gist: -

    - - -
    -

    - -Error Case: non-existent file: - - >>> import markdown - >>> text = ''' - ... Text of the gist: - ... [:gist: 4747847 doesntexist.py] - ... ''' - >>> html = markdown.markdown(text, [GistExtension()]) - >>> print(html) -

    Text of the gist: -

    - - -
    -

    + Text of the gist: + [:gist: 0] + +Error Case: non-existent file: + + Text of the gist: + [:gist: 4747847 doesntexist.py] """ -from __future__ import unicode_literals, print_function +import requests + +from nikola.plugin_categories import MarkdownExtension +from nikola.utils import get_logger try: from markdown.extensions import Extension @@ -200,12 +90,8 @@ except ImportError: # the markdown compiler will fail first Extension = Pattern = object -from nikola.plugin_categories import MarkdownExtension -from nikola.utils import get_logger, STDERR_HANDLER - -import requests -LOGGER = get_logger('compile_markdown.mdx_gist', STDERR_HANDLER) +LOGGER = get_logger('compile_markdown.mdx_gist') GIST_JS_URL = "https://gist.github.com/{0}.js" GIST_FILE_JS_URL = "https://gist.github.com/{0}.js?file={1}" @@ -217,7 +103,6 @@ GIST_RST_RE = r'(?m)^\.\.\s*gist::\s*(?P[^\]\s]+)(?:\s*(?P.+? class GistFetchException(Exception): - """Raised when attempt to fetch content of a Gist from github.com fails.""" def __init__(self, url, status_code): @@ -228,7 +113,6 @@ class GistFetchException(Exception): class GistPattern(Pattern): - """InlinePattern for footnote markers in a document's body text.""" def __init__(self, pattern, configs): @@ -282,7 +166,7 @@ class GistPattern(Pattern): pre_elem.text = AtomicString(raw_gist) except GistFetchException as e: - LOGGER.warn(e.message) + LOGGER.warning(e.message) warning_comment = etree.Comment(' WARNING: {0} '.format(e.message)) noscript_elem.append(warning_comment) @@ -290,7 +174,6 @@ class GistPattern(Pattern): class GistExtension(MarkdownExtension, Extension): - """Gist extension for Markdown.""" def __init__(self, configs={}): @@ -302,15 +185,15 @@ class GistExtension(MarkdownExtension, Extension): for key, value in configs: self.setConfig(key, value) - def extendMarkdown(self, md, md_globals): + def extendMarkdown(self, md, md_globals=None): """Extend Markdown.""" gist_md_pattern = GistPattern(GIST_MD_RE, self.getConfigs()) gist_md_pattern.md = md - md.inlinePatterns.add('gist', gist_md_pattern, "gist") + md.inlinePatterns.register(gist_rst_pattern, 'gist-rst', 176) md.registerExtension(self) @@ -319,6 +202,7 @@ def makeExtension(configs=None): # pragma: no cover """Make Markdown extension.""" return GistExtension(configs) + if __name__ == '__main__': import doctest diff --git a/nikola/plugins/compile/markdown/mdx_nikola.plugin b/nikola/plugins/compile/markdown/mdx_nikola.plugin index 12e4fb6..9751598 100644 --- a/nikola/plugins/compile/markdown/mdx_nikola.plugin +++ b/nikola/plugins/compile/markdown/mdx_nikola.plugin @@ -4,11 +4,11 @@ module = mdx_nikola [Nikola] compiler = markdown -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Roberto Alsina version = 0.1 -website = http://getnikola.com +website = https://getnikola.com/ description = Nikola-specific Markdown extensions diff --git a/nikola/plugins/compile/markdown/mdx_nikola.py b/nikola/plugins/compile/markdown/mdx_nikola.py index 54cc18c..06a6d9a 100644 --- a/nikola/plugins/compile/markdown/mdx_nikola.py +++ b/nikola/plugins/compile/markdown/mdx_nikola.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -24,25 +24,31 @@ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -"""Markdown Extension for Nikola-specific post-processing.""" +"""Markdown Extension for Nikola. + +- Specific post-processing. +- Strikethrough inline patterns. +""" -from __future__ import unicode_literals import re + +from nikola.plugin_categories import MarkdownExtension + try: from markdown.postprocessors import Postprocessor + from markdown.inlinepatterns import SimpleTagPattern from markdown.extensions import Extension except ImportError: # No need to catch this, if you try to use this without Markdown, # the markdown compiler will fail first - Postprocessor = Extension = object + Postprocessor = SimpleTagPattern = Extension = object -from nikola.plugin_categories import MarkdownExtension CODERE = re.compile('
    (.*?)
    ', flags=re.MULTILINE | re.DOTALL) +STRIKE_RE = r"(~{2})(.+?)(~{2})" # ~~strike~~ class NikolaPostProcessor(Postprocessor): - """Nikola-specific post-processing for Markdown.""" def run(self, text): @@ -57,13 +63,22 @@ class NikolaPostProcessor(Postprocessor): class NikolaExtension(MarkdownExtension, Extension): + """Nikola Markdown extensions.""" - """Extension for injecting the postprocessor.""" - - def extendMarkdown(self, md, md_globals): + def _add_nikola_post_processor(self, md): """Extend Markdown with the postprocessor.""" pp = NikolaPostProcessor() - md.postprocessors.add('nikola_post_processor', pp, '_end') + md.postprocessors.register(pp, 'nikola_post_processor', 1) + + def _add_strikethrough_inline_pattern(self, md): + """Support PHP-Markdown style strikethrough, for example: ``~~strike~~``.""" + pattern = SimpleTagPattern(STRIKE_RE, 'del') + md.inlinePatterns.register(pattern, 'strikethrough', 175) + + def extendMarkdown(self, md, md_globals=None): + """Extend markdown to Nikola flavours.""" + self._add_nikola_post_processor(md) + self._add_strikethrough_inline_pattern(md) md.registerExtension(self) diff --git a/nikola/plugins/compile/markdown/mdx_podcast.plugin b/nikola/plugins/compile/markdown/mdx_podcast.plugin index c92a8a0..df5260d 100644 --- a/nikola/plugins/compile/markdown/mdx_podcast.plugin +++ b/nikola/plugins/compile/markdown/mdx_podcast.plugin @@ -4,11 +4,11 @@ module = mdx_podcast [Nikola] compiler = markdown -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Roberto Alsina version = 0.1 -website = http://getnikola.com +website = https://getnikola.com/ description = Markdown extensions for embedding podcasts and other audio files diff --git a/nikola/plugins/compile/markdown/mdx_podcast.py b/nikola/plugins/compile/markdown/mdx_podcast.py index 61afdbf..5090407 100644 --- a/nikola/plugins/compile/markdown/mdx_podcast.py +++ b/nikola/plugins/compile/markdown/mdx_podcast.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright © 2013-2015 Michael Rabbitt, Roberto Alsina and others. +# Copyright © 2013-2020 Michael Rabbitt, Roberto Alsina and others. # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the @@ -30,13 +30,12 @@ Extension to Python Markdown for Embedded Audio. Basic Example: >>> import markdown ->>> text = "[podcast]http://archive.org/download/Rebeldes_Stereotipos/rs20120609_1.mp3[/podcast]" +>>> text = "[podcast]https://archive.org/download/Rebeldes_Stereotipos/rs20120609_1.mp3[/podcast]" >>> html = markdown.markdown(text, [PodcastExtension()]) >>> print(html) -

    +

    """ -from __future__ import print_function, unicode_literals from nikola.plugin_categories import MarkdownExtension try: from markdown.extensions import Extension @@ -51,7 +50,6 @@ PODCAST_RE = r'\[podcast\](?P.+)\[/podcast\]' class PodcastPattern(Pattern): - """InlinePattern for footnote markers in a document's body text.""" def __init__(self, pattern, configs): @@ -70,8 +68,7 @@ class PodcastPattern(Pattern): class PodcastExtension(MarkdownExtension, Extension): - - """"Podcast extension for Markdown.""" + """Podcast extension for Markdown.""" def __init__(self, configs={}): """Initialize extension.""" @@ -82,11 +79,11 @@ class PodcastExtension(MarkdownExtension, Extension): for key, value in configs: self.setConfig(key, value) - def extendMarkdown(self, md, md_globals): + def extendMarkdown(self, md, md_globals=None): """Extend Markdown.""" podcast_md_pattern = PodcastPattern(PODCAST_RE, self.getConfigs()) podcast_md_pattern.md = md - md.inlinePatterns.add('podcast', podcast_md_pattern, "\n\n') + fd.write(write_metadata(metadata, comment_wrap=True, site=self.site, compiler=self)) fd.write(content) diff --git a/nikola/plugins/compile/php.plugin b/nikola/plugins/compile/php.plugin index 151c022..13384bd 100644 --- a/nikola/plugins/compile/php.plugin +++ b/nikola/plugins/compile/php.plugin @@ -5,9 +5,9 @@ module = php [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Compile PHP into HTML (just copy and name the file .php) [Nikola] -plugincategory = Compiler +PluginCategory = Compiler friendlyname = PHP diff --git a/nikola/plugins/compile/php.py b/nikola/plugins/compile/php.py index 28f4923..818e10d 100644 --- a/nikola/plugins/compile/php.py +++ b/nikola/plugins/compile/php.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -24,27 +24,24 @@ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -"""Implementation of compile_html for HTML+php.""" +"""Page compiler plugin for PHP.""" -from __future__ import unicode_literals - -import os import io +import os +from hashlib import md5 from nikola.plugin_categories import PageCompiler from nikola.utils import makedirs, write_metadata -from hashlib import md5 class CompilePhp(PageCompiler): - """Compile PHP into PHP.""" name = "php" friendly_name = "PHP" - def compile_html(self, source, dest, is_two_file=True): - """Compile source file into HTML and save as dest.""" + def compile(self, source, dest, is_two_file=True, post=None, lang=None): + """Compile the source file into HTML and save as dest.""" makedirs(os.path.dirname(dest)) with io.open(dest, "w+", encoding="utf8") as out_file: with open(source, "rb") as in_file: @@ -52,6 +49,10 @@ class CompilePhp(PageCompiler): out_file.write(''.format(source, hash)) return True + def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None): + """Compile PHP into HTML strings.""" + return data, [] + def create_post(self, path, **kw): """Create a new post.""" content = kw.pop('content', None) @@ -77,9 +78,7 @@ class CompilePhp(PageCompiler): content += '\n' with io.open(path, "w+", encoding="utf8") as fd: if onefile: - fd.write('\n\n') + fd.write(write_metadata(metadata, comment_wrap=True, site=self.site, compiler=self)) fd.write(content) def extension(self): diff --git a/nikola/plugins/compile/rest.plugin b/nikola/plugins/compile/rest.plugin index cf842c7..43bdf2d 100644 --- a/nikola/plugins/compile/rest.plugin +++ b/nikola/plugins/compile/rest.plugin @@ -5,9 +5,9 @@ module = rest [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com -description = Compile reSt into HTML +website = https://getnikola.com/ +description = Compile reST into HTML [Nikola] -plugincategory = Compiler +PluginCategory = Compiler friendlyname = reStructuredText diff --git a/nikola/plugins/compile/rest/__init__.py b/nikola/plugins/compile/rest/__init__.py index b99e872..44da076 100644 --- a/nikola/plugins/compile/rest/__init__.py +++ b/nikola/plugins/compile/rest/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,87 +26,138 @@ """reStructuredText compiler for Nikola.""" -from __future__ import unicode_literals import io +import logging import os import docutils.core import docutils.nodes +import docutils.transforms import docutils.utils import docutils.io import docutils.readers.standalone -import docutils.writers.html4css1 +import docutils.writers.html5_polyglot +import docutils.parsers.rst.directives +from docutils.parsers.rst import roles +from nikola.nikola import LEGAL_VALUES +from nikola.metadata_extractors import MetaCondition from nikola.plugin_categories import PageCompiler -from nikola.utils import unicode_str, get_logger, makedirs, write_metadata, STDERR_HANDLER +from nikola.utils import ( + makedirs, + write_metadata, + LocaleBorg, + map_metadata +) class CompileRest(PageCompiler): - """Compile reStructuredText into HTML.""" name = "rest" friendly_name = "reStructuredText" demote_headers = True logger = None - - def _read_extra_deps(self, post): - """Read contents of .dep file and returns them as a list.""" - dep_path = post.base_path + '.dep' - if os.path.isfile(dep_path): - with io.open(dep_path, 'r+', encoding='utf8') as depf: - deps = [l.strip() for l in depf.readlines()] - return deps - return [] - - def register_extra_dependencies(self, post): - """Add dependency to post object to check .dep file.""" - post.add_dependency(lambda: self._read_extra_deps(post), 'fragment') - - def compile_html_string(self, data, source_path=None, is_two_file=True): + supports_metadata = True + metadata_conditions = [(MetaCondition.config_bool, "USE_REST_DOCINFO_METADATA")] + + def read_metadata(self, post, lang=None): + """Read the metadata from a post, and return a metadata dict.""" + if lang is None: + lang = LocaleBorg().current_lang + source_path = post.translated_source_path(lang) + + # Silence reST errors, some of which are due to a different + # environment. Real issues will be reported while compiling. + null_logger = logging.getLogger('NULL') + null_logger.setLevel(1000) + with io.open(source_path, 'r', encoding='utf-8-sig') as inf: + data = inf.read() + _, _, _, document = rst2html(data, logger=null_logger, source_path=source_path, transforms=self.site.rst_transforms) + meta = {} + if 'title' in document: + meta['title'] = document['title'] + for docinfo in document.traverse(docutils.nodes.docinfo): + for element in docinfo.children: + if element.tagname == 'field': # custom fields (e.g. summary) + name_elem, body_elem = element.children + name = name_elem.astext() + value = body_elem.astext() + elif element.tagname == 'authors': # author list + name = element.tagname + value = [element.astext() for element in element.children] + else: # standard fields (e.g. address) + name = element.tagname + value = element.astext() + name = name.lower() + + meta[name] = value + + # Put 'authors' meta field contents in 'author', too + if 'authors' in meta and 'author' not in meta: + meta['author'] = '; '.join(meta['authors']) + + # Map metadata from other platforms to names Nikola expects (Issue #2817) + map_metadata(meta, 'rest_docinfo', self.site.config) + return meta + + def compile_string(self, data, source_path=None, is_two_file=True, post=None, lang=None): """Compile reST into HTML strings.""" # If errors occur, this will be added to the line number reported by # docutils so the line number matches the actual line number (off by # 7 with default metadata, could be more or less depending on the post). add_ln = 0 if not is_two_file: - m_data, data = self.split_metadata(data) + m_data, data = self.split_metadata(data, post, lang) add_ln = len(m_data.splitlines()) + 1 default_template_path = os.path.join(os.path.dirname(__file__), 'template.txt') - output, error_level, deps = rst2html( - data, settings_overrides={ - 'initial_header_level': 1, - 'record_dependencies': True, - 'stylesheet_path': None, - 'link_stylesheet': True, - 'syntax_highlight': 'short', - 'math_output': 'mathjax', - 'template': default_template_path, - }, logger=self.logger, source_path=source_path, l_add_ln=add_ln, transforms=self.site.rst_transforms) - if not isinstance(output, unicode_str): + settings_overrides = { + 'initial_header_level': 1, + 'record_dependencies': True, + 'stylesheet_path': None, + 'link_stylesheet': True, + 'syntax_highlight': 'short', + # This path is not used by Nikola, but we need something to silence + # warnings about it from reST. + 'math_output': 'mathjax /assets/js/mathjax.js', + 'template': default_template_path, + 'language_code': LEGAL_VALUES['DOCUTILS_LOCALES'].get(LocaleBorg().current_lang, 'en'), + 'doctitle_xform': self.site.config.get('USE_REST_DOCINFO_METADATA'), + 'file_insertion_enabled': self.site.config.get('REST_FILE_INSERTION_ENABLED'), + } + + from nikola import shortcodes as sc + new_data, shortcodes = sc.extract_shortcodes(data) + if self.site.config.get('HIDE_REST_DOCINFO', False): + self.site.rst_transforms.append(RemoveDocinfo) + output, error_level, deps, _ = rst2html( + new_data, settings_overrides=settings_overrides, logger=self.logger, source_path=source_path, l_add_ln=add_ln, transforms=self.site.rst_transforms) + if not isinstance(output, str): # To prevent some weird bugs here or there. # Original issue: empty files. `output` became a bytestring. output = output.decode('utf-8') - return output, error_level, deps - def compile_html(self, source, dest, is_two_file=True): - """Compile source file into HTML and save as dest.""" + output, shortcode_deps = self.site.apply_shortcodes_uuid(output, shortcodes, filename=source_path, extra_context={'post': post}) + return output, error_level, deps, shortcode_deps + + def compile(self, source, dest, is_two_file=True, post=None, lang=None): + """Compile the source file into HTML and save as dest.""" makedirs(os.path.dirname(dest)) error_level = 100 - with io.open(dest, "w+", encoding="utf8") as out_file: - with io.open(source, "r", encoding="utf8") as in_file: + with io.open(dest, "w+", encoding="utf-8") as out_file: + with io.open(source, "r", encoding="utf-8-sig") as in_file: data = in_file.read() - output, error_level, deps = self.compile_html_string(data, source, is_two_file) + output, error_level, deps, shortcode_deps = self.compile_string(data, source, is_two_file, post, lang) out_file.write(output) - deps_path = dest + '.dep' - if deps.list: - deps.list = [p for p in deps.list if p != dest] # Don't depend on yourself (#1671) - with io.open(deps_path, "w+", encoding="utf8") as deps_file: - deps_file.write('\n'.join(deps.list)) + if post is None: + if deps.list: + self.logger.error( + "Cannot save dependencies for post {0} (post unknown)", + source) else: - if os.path.isfile(deps_path): - os.unlink(deps_path) + post._depfile[dest] += deps.list + post._depfile[dest] += shortcode_deps if error_level < 3: return True else: @@ -124,23 +175,21 @@ class CompileRest(PageCompiler): makedirs(os.path.dirname(path)) if not content.endswith('\n'): content += '\n' - with io.open(path, "w+", encoding="utf8") as fd: + with io.open(path, "w+", encoding="utf-8") as fd: if onefile: - fd.write(write_metadata(metadata)) - fd.write('\n') + fd.write(write_metadata(metadata, comment_wrap=False, site=self.site, compiler=self)) fd.write(content) def set_site(self, site): """Set Nikola site.""" - super(CompileRest, self).set_site(site) + super().set_site(site) self.config_dependencies = [] for plugin_info in self.get_compiler_extensions(): self.config_dependencies.append(plugin_info.name) plugin_info.plugin_object.short_help = plugin_info.description - self.logger = get_logger('compile_rest', STDERR_HANDLER) if not site.debug: - self.logger.level = 4 + self.logger.level = logging.WARNING def get_observer(settings): @@ -150,19 +199,25 @@ def get_observer(settings): Error code mapping: - +------+---------+------+----------+ - | dNUM | dNAME | lNUM | lNAME | d = docutils, l = logbook - +------+---------+------+----------+ - | 0 | DEBUG | 1 | DEBUG | - | 1 | INFO | 2 | INFO | - | 2 | WARNING | 4 | WARNING | - | 3 | ERROR | 5 | ERROR | - | 4 | SEVERE | 6 | CRITICAL | - +------+---------+------+----------+ + +----------+----------+ + | docutils | logging | + +----------+----------+ + | DEBUG | DEBUG | + | INFO | INFO | + | WARNING | WARNING | + | ERROR | ERROR | + | SEVERE | CRITICAL | + +----------+----------+ """ - errormap = {0: 1, 1: 2, 2: 4, 3: 5, 4: 6} + errormap = { + docutils.utils.Reporter.DEBUG_LEVEL: logging.DEBUG, + docutils.utils.Reporter.INFO_LEVEL: logging.INFO, + docutils.utils.Reporter.WARNING_LEVEL: logging.WARNING, + docutils.utils.Reporter.ERROR_LEVEL: logging.ERROR, + docutils.utils.Reporter.SEVERE_LEVEL: logging.CRITICAL + } text = docutils.nodes.Element.astext(msg) - line = msg['line'] + settings['add_ln'] if 'line' in msg else 0 + line = msg['line'] + settings['add_ln'] if 'line' in msg else '' out = '[{source}{colon}{line}] {text}'.format( source=settings['source'], colon=(':' if line else ''), line=line, text=text) @@ -172,12 +227,14 @@ def get_observer(settings): class NikolaReader(docutils.readers.standalone.Reader): - """Nikola-specific docutils reader.""" + config_section = 'nikola' + def __init__(self, *args, **kwargs): """Initialize the reader.""" self.transforms = kwargs.pop('transforms', []) + self.logging_settings = kwargs.pop('nikola_logging_settings', {}) docutils.readers.standalone.Reader.__init__(self, *args, **kwargs) def get_transforms(self): @@ -188,15 +245,26 @@ class NikolaReader(docutils.readers.standalone.Reader): """Create and return a new empty document tree (root node).""" document = docutils.utils.new_document(self.source.source_path, self.settings) document.reporter.stream = False - document.reporter.attach_observer(get_observer(self.l_settings)) + document.reporter.attach_observer(get_observer(self.logging_settings)) return document +def shortcode_role(name, rawtext, text, lineno, inliner, + options={}, content=[]): + """Return a shortcode role that passes through raw inline HTML.""" + return [docutils.nodes.raw('', text, format='html')], [] + + +roles.register_canonical_role('raw-html', shortcode_role) +roles.register_canonical_role('html', shortcode_role) +roles.register_canonical_role('sc', shortcode_role) + + def add_node(node, visit_function=None, depart_function=None): """Register a Docutils node class. This function is completely optional. It is a same concept as - `Sphinx add_node function `_. + `Sphinx add_node function `_. For example:: @@ -208,7 +276,7 @@ def add_node(node, visit_function=None, depart_function=None): self.site = site directives.register_directive('math', MathDirective) add_node(MathBlock, visit_Math, depart_Math) - return super(Plugin, self).set_site(site) + return super().set_site(site) class MathDirective(Directive): def run(self): @@ -227,16 +295,52 @@ def add_node(node, visit_function=None, depart_function=None): """ docutils.nodes._add_node_class_names([node.__name__]) if visit_function: - setattr(docutils.writers.html4css1.HTMLTranslator, 'visit_' + node.__name__, visit_function) + setattr(docutils.writers.html5_polyglot.HTMLTranslator, 'visit_' + node.__name__, visit_function) if depart_function: - setattr(docutils.writers.html4css1.HTMLTranslator, 'depart_' + node.__name__, depart_function) + setattr(docutils.writers.html5_polyglot.HTMLTranslator, 'depart_' + node.__name__, depart_function) + + +# Output for ``double backticks``. (Code and extra logic based on html4css1 translator) +def visit_literal(self, node): + """Output for double backticks.""" + # special case: "code" role + classes = node.get('classes', []) + if 'code' in classes: + # filter 'code' from class arguments + node['classes'] = [cls for cls in classes if cls != 'code'] + self.body.append(self.starttag(node, 'code', '')) + return + self.body.append( + self.starttag(node, 'code', '', CLASS='docutils literal')) + text = node.astext() + for token in self.words_and_spaces.findall(text): + if token.strip(): + # Protect text like "--an-option" and the regular expression + # ``[+]?(\d+(\.\d*)?|\.\d+)`` from bad line wrapping + if self.in_word_wrap_point.search(token): + self.body.append('%s' + % self.encode(token)) + else: + self.body.append(self.encode(token)) + elif token in ('\n', ' '): + # Allow breaks at whitespace: + self.body.append(token) + else: + # Protect runs of multiple spaces; the last space can wrap: + self.body.append(' ' * (len(token) - 1) + ' ') + self.body.append('') + # Content already processed: + raise docutils.nodes.SkipNode + + +setattr(docutils.writers.html5_polyglot.HTMLTranslator, 'visit_literal', visit_literal) def rst2html(source, source_path=None, source_class=docutils.io.StringInput, destination_path=None, reader=None, parser=None, parser_name='restructuredtext', writer=None, - writer_name='html', settings=None, settings_spec=None, - settings_overrides=None, config_section=None, + writer_name='html5_polyglot', settings=None, settings_spec=None, + settings_overrides=None, config_section='nikola', enable_exit_status=None, logger=None, l_add_ln=0, transforms=None): """Set up & run a ``Publisher``, and return a dictionary of document parts. @@ -249,20 +353,22 @@ def rst2html(source, source_path=None, source_class=docutils.io.StringInput, publish_parts(..., settings_overrides={'input_encoding': 'unicode'}) - Parameters: see `publish_programmatically`. + For a description of the parameters, see `publish_programmatically`. WARNING: `reader` should be None (or NikolaReader()) if you want Nikola to report reStructuredText syntax errors. """ if reader is None: - reader = NikolaReader(transforms=transforms) # For our custom logging, we have special needs and special settings we # specify here. # logger a logger from Nikola # source source filename (docutils gets a string) - # add_ln amount of metadata lines (see comment in compile_html above) - reader.l_settings = {'logger': logger, 'source': source_path, - 'add_ln': l_add_ln} + # add_ln amount of metadata lines (see comment in CompileRest.compile above) + reader = NikolaReader(transforms=transforms, + nikola_logging_settings={ + 'logger': logger, 'source': source_path, + 'add_ln': l_add_ln + }) pub = docutils.core.Publisher(reader, parser, writer, settings=settings, source_class=source_class, @@ -275,4 +381,23 @@ def rst2html(source, source_path=None, source_class=docutils.io.StringInput, pub.set_destination(None, destination_path) pub.publish(enable_exit_status=enable_exit_status) - return pub.writer.parts['docinfo'] + pub.writer.parts['fragment'], pub.document.reporter.max_level, pub.settings.record_dependencies + return pub.writer.parts['docinfo'] + pub.writer.parts['fragment'], pub.document.reporter.max_level, pub.settings.record_dependencies, pub.document + + +# Alignment helpers for extensions +_align_options_base = ('left', 'center', 'right') + + +def _align_choice(argument): + return docutils.parsers.rst.directives.choice(argument, _align_options_base + ("none", "")) + + +class RemoveDocinfo(docutils.transforms.Transform): + """Remove docinfo nodes.""" + + default_priority = 870 + + def apply(self): + """Remove docinfo nodes.""" + for node in self.document.traverse(docutils.nodes.docinfo): + node.parent.remove(node) diff --git a/nikola/plugins/compile/rest/chart.plugin b/nikola/plugins/compile/rest/chart.plugin index 438abe4..4434477 100644 --- a/nikola/plugins/compile/rest/chart.plugin +++ b/nikola/plugins/compile/rest/chart.plugin @@ -4,11 +4,11 @@ module = chart [Nikola] compiler = rest -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Roberto Alsina version = 0.1 -website = http://getnikola.com +website = https://getnikola.com/ description = Chart directive based in PyGal diff --git a/nikola/plugins/compile/rest/chart.py b/nikola/plugins/compile/rest/chart.py index 88fdff3..17363cb 100644 --- a/nikola/plugins/compile/rest/chart.py +++ b/nikola/plugins/compile/rest/chart.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -23,27 +23,22 @@ # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - """Chart directive for reSTructuredText.""" -from ast import literal_eval - from docutils import nodes from docutils.parsers.rst import Directive, directives +from nikola.plugin_categories import RestExtension + try: import pygal except ImportError: - pygal = None # NOQA - -from nikola.plugin_categories import RestExtension -from nikola.utils import req_missing + pygal = None _site = None class Plugin(RestExtension): - """Plugin for chart role.""" name = "rest_chart" @@ -53,11 +48,10 @@ class Plugin(RestExtension): global _site _site = self.site = site directives.register_directive('chart', Chart) - return super(Plugin, self).set_site(site) + return super().set_site(site) class Chart(Directive): - """reStructuredText extension for inserting charts as SVG. Usage: @@ -74,52 +68,69 @@ class Chart(Directive): has_content = True required_arguments = 1 option_spec = { - "copy": directives.unchanged, + "box_mode": directives.unchanged, + "classes": directives.unchanged, "css": directives.unchanged, + "defs": directives.unchanged, + "data_file": directives.unchanged, "disable_xml_declaration": directives.unchanged, "dots_size": directives.unchanged, + "dynamic_print_values": directives.unchanged, "explicit_size": directives.unchanged, "fill": directives.unchanged, - "font_sizes": directives.unchanged, + "force_uri_protocol": directives.unchanged, + "half_pie": directives.unchanged, "height": directives.unchanged, "human_readable": directives.unchanged, "include_x_axis": directives.unchanged, + "inner_radius": directives.unchanged, "interpolate": directives.unchanged, "interpolation_parameters": directives.unchanged, "interpolation_precision": directives.unchanged, + "inverse_y_axis": directives.unchanged, "js": directives.unchanged, - "label_font_size": directives.unchanged, "legend_at_bottom": directives.unchanged, + "legend_at_bottom_columns": directives.unchanged, "legend_box_size": directives.unchanged, - "legend_font_size": directives.unchanged, "logarithmic": directives.unchanged, - "major_label_font_size": directives.unchanged, "margin": directives.unchanged, - "no_data_font_size": directives.unchanged, + "margin_bottom": directives.unchanged, + "margin_left": directives.unchanged, + "margin_right": directives.unchanged, + "margin_top": directives.unchanged, + "max_scale": directives.unchanged, + "min_scale": directives.unchanged, + "missing_value_fill_truncation": directives.unchanged, "no_data_text": directives.unchanged, "no_prefix": directives.unchanged, "order_min": directives.unchanged, "pretty_print": directives.unchanged, + "print_labels": directives.unchanged, "print_values": directives.unchanged, + "print_values_position": directives.unchanged, "print_zeroes": directives.unchanged, "range": directives.unchanged, "rounded_bars": directives.unchanged, + "secondary_range": directives.unchanged, "show_dots": directives.unchanged, "show_legend": directives.unchanged, "show_minor_x_labels": directives.unchanged, + "show_minor_y_labels": directives.unchanged, + "show_only_major_dots": directives.unchanged, + "show_x_guides": directives.unchanged, + "show_x_labels": directives.unchanged, + "show_y_guides": directives.unchanged, "show_y_labels": directives.unchanged, "spacing": directives.unchanged, + "stack_from_top": directives.unchanged, "strict": directives.unchanged, "stroke": directives.unchanged, + "stroke_style": directives.unchanged, "style": directives.unchanged, "title": directives.unchanged, - "title_font_size": directives.unchanged, - "to_dict": directives.unchanged, "tooltip_border_radius": directives.unchanged, - "tooltip_font_size": directives.unchanged, "truncate_label": directives.unchanged, "truncate_legend": directives.unchanged, - "value_font_size": directives.unchanged, "value_formatter": directives.unchanged, "width": directives.unchanged, "x_label_rotation": directives.unchanged, @@ -128,37 +139,23 @@ class Chart(Directive): "x_labels_major_count": directives.unchanged, "x_labels_major_every": directives.unchanged, "x_title": directives.unchanged, + "x_value_formatter": directives.unchanged, + "xrange": directives.unchanged, "y_label_rotation": directives.unchanged, "y_labels": directives.unchanged, + "y_labels_major": directives.unchanged, + "y_labels_major_count": directives.unchanged, + "y_labels_major_every": directives.unchanged, "y_title": directives.unchanged, "zero": directives.unchanged, } def run(self): """Run the directive.""" - if pygal is None: - msg = req_missing(['pygal'], 'use the Chart directive', optional=True) - return [nodes.raw('', '
    {0}
    '.format(msg), format='html')] - options = {} - if 'style' in self.options: - style_name = self.options.pop('style') - else: - style_name = 'BlueStyle' - if '(' in style_name: # Parametric style - style = eval('pygal.style.' + style_name) - else: - style = getattr(pygal.style, style_name) - for k, v in self.options.items(): - options[k] = literal_eval(v) - - chart = getattr(pygal, self.arguments[0])(style=style) - chart.config(**options) - for line in self.content: - label, series = literal_eval('({0})'.format(line)) - chart.add(label, series) - data = chart.render().decode('utf8') - if _site and _site.invariant: - import re - data = re.sub('id="chart-[a-f0-9\-]+"', 'id="chart-foobar"', data) - data = re.sub('#chart-[a-f0-9\-]+', '#chart-foobar', data) - return [nodes.raw('', data, format='html')] + self.options['site'] = None + html = _site.plugin_manager.getPluginByName( + 'chart', 'ShortcodePlugin').plugin_object.handler( + self.arguments[0], + data='\n'.join(self.content), + **self.options) + return [nodes.raw('', html, format='html')] diff --git a/nikola/plugins/compile/rest/doc.plugin b/nikola/plugins/compile/rest/doc.plugin index facdd03..3b5c9c7 100644 --- a/nikola/plugins/compile/rest/doc.plugin +++ b/nikola/plugins/compile/rest/doc.plugin @@ -4,11 +4,11 @@ module = doc [Nikola] compiler = rest -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Manuel Kaufmann version = 0.1 -website = http://getnikola.com +website = https://getnikola.com/ description = Role to link another page / post from the blog diff --git a/nikola/plugins/compile/rest/doc.py b/nikola/plugins/compile/rest/doc.py index 99cce81..705c0bc 100644 --- a/nikola/plugins/compile/rest/doc.py +++ b/nikola/plugins/compile/rest/doc.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -29,12 +29,11 @@ from docutils import nodes from docutils.parsers.rst import roles -from nikola.utils import split_explicit_title +from nikola.utils import split_explicit_title, LOGGER, slugify from nikola.plugin_categories import RestExtension class Plugin(RestExtension): - """Plugin for doc role.""" name = 'rest_doc' @@ -43,16 +42,13 @@ class Plugin(RestExtension): """Set Nikola site.""" self.site = site roles.register_canonical_role('doc', doc_role) + self.site.register_shortcode('doc', doc_shortcode) doc_role.site = site - return super(Plugin, self).set_site(site) + return super().set_site(site) -def doc_role(name, rawtext, text, lineno, inliner, - options={}, content=[]): - """Handle the doc role.""" - # split link's text and post's slug in role content - has_explicit_title, title, slug = split_explicit_title(text) - # check if the slug given is part of our blog posts/pages +def _find_post(slug): + """Find a post with the given slug in posts or pages.""" twin_slugs = False post = None for p in doc_role.site.timeline: @@ -62,27 +58,72 @@ def doc_role(name, rawtext, text, lineno, inliner, else: twin_slugs = True break + return post, twin_slugs + + +def _doc_link(rawtext, text, options={}, content=[]): + """Handle the doc role.""" + # split link's text and post's slug in role content + has_explicit_title, title, slug = split_explicit_title(text) + if '#' in slug: + slug, fragment = slug.split('#', 1) + else: + fragment = None + + # Look for the unslugified input first, then try to slugify (Issue #3450) + post, twin_slugs = _find_post(slug) + if post is None: + slug = slugify(slug) + post, twin_slugs = _find_post(slug) try: if post is None: - raise ValueError + raise ValueError("No post with matching slug found.") except ValueError: + return False, False, None, None, slug + + if not has_explicit_title: + # use post's title as link's text + title = post.title() + permalink = post.permalink() + if fragment: + permalink += '#' + fragment + + return True, twin_slugs, title, permalink, slug + + +def doc_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + """Handle the doc role.""" + success, twin_slugs, title, permalink, slug = _doc_link(rawtext, text, options, content) + if success: + if twin_slugs: + inliner.reporter.warning( + 'More than one post with the same slug. Using "{0}"'.format(permalink)) + LOGGER.warning( + 'More than one post with the same slug. Using "{0}" for doc role'.format(permalink)) + node = make_link_node(rawtext, title, permalink, options) + return [node], [] + else: msg = inliner.reporter.error( '"{0}" slug doesn\'t exist.'.format(slug), line=lineno) prb = inliner.problematic(rawtext, rawtext, msg) return [prb], [msg] - if not has_explicit_title: - # use post's title as link's text - title = post.title() - permalink = post.permalink() - if twin_slugs: - msg = inliner.reporter.warning( - 'More than one post with the same slug. Using "{0}"'.format(permalink)) - node = make_link_node(rawtext, title, permalink, options) - return [node], [] +def doc_shortcode(*args, **kwargs): + """Implement the doc shortcode.""" + text = kwargs['data'] + success, twin_slugs, title, permalink, slug = _doc_link(text, text, LOGGER) + if success: + if twin_slugs: + LOGGER.warning( + 'More than one post with the same slug. Using "{0}" for doc shortcode'.format(permalink)) + return '{1}'.format(permalink, title) + else: + LOGGER.error( + '"{0}" slug doesn\'t exist.'.format(slug)) + return 'Invalid link: {0}'.format(text) def make_link_node(rawtext, text, url, options): diff --git a/nikola/plugins/compile/rest/gist.plugin b/nikola/plugins/compile/rest/gist.plugin index 9fa2e82..4a8a3a7 100644 --- a/nikola/plugins/compile/rest/gist.plugin +++ b/nikola/plugins/compile/rest/gist.plugin @@ -4,11 +4,11 @@ module = gist [Nikola] compiler = rest -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Roberto Alsina version = 0.1 -website = http://getnikola.com +website = https://getnikola.com/ description = Gist directive diff --git a/nikola/plugins/compile/rest/gist.py b/nikola/plugins/compile/rest/gist.py index 736ee37..08aa46d 100644 --- a/nikola/plugins/compile/rest/gist.py +++ b/nikola/plugins/compile/rest/gist.py @@ -11,7 +11,6 @@ from nikola.plugin_categories import RestExtension class Plugin(RestExtension): - """Plugin for gist directive.""" name = "rest_gist" @@ -20,11 +19,10 @@ class Plugin(RestExtension): """Set Nikola site.""" self.site = site directives.register_directive('gist', GitHubGist) - return super(Plugin, self).set_site(site) + return super().set_site(site) class GitHubGist(Directive): - """Embed GitHub Gist. Usage: diff --git a/nikola/plugins/compile/rest/listing.plugin b/nikola/plugins/compile/rest/listing.plugin index 85c780f..5239f92 100644 --- a/nikola/plugins/compile/rest/listing.plugin +++ b/nikola/plugins/compile/rest/listing.plugin @@ -4,11 +4,11 @@ module = listing [Nikola] compiler = rest -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Roberto Alsina version = 0.1 -website = http://getnikola.com +website = https://getnikola.com/ description = Extension for source listings diff --git a/nikola/plugins/compile/rest/listing.py b/nikola/plugins/compile/rest/listing.py index 4871bf3..e5a73fa 100644 --- a/nikola/plugins/compile/rest/listing.py +++ b/nikola/plugins/compile/rest/listing.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -28,26 +28,21 @@ """Define and register a listing directive using the existing CodeBlock.""" -from __future__ import unicode_literals import io import os import uuid -try: - from urlparse import urlunsplit -except ImportError: - from urllib.parse import urlunsplit # NOQA +from urllib.parse import urlunsplit import docutils.parsers.rst.directives.body import docutils.parsers.rst.directives.misc +import pygments +import pygments.util from docutils import core from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.parsers.rst.roles import set_classes from docutils.parsers.rst.directives.misc import Include - from pygments.lexers import get_lexer_by_name -import pygments -import pygments.util from nikola import utils from nikola.plugin_categories import RestExtension @@ -55,7 +50,6 @@ from nikola.plugin_categories import RestExtension # A sanitized version of docutils.parsers.rst.directives.body.CodeBlock. class CodeBlock(Directive): - """Parse and mark up content of a code block.""" optional_arguments = 1 @@ -120,13 +114,13 @@ class CodeBlock(Directive): return [node] + # Monkey-patch: replace insane docutils CodeBlock with our implementation. docutils.parsers.rst.directives.body.CodeBlock = CodeBlock docutils.parsers.rst.directives.misc.CodeBlock = CodeBlock class Plugin(RestExtension): - """Plugin for listing directive.""" name = "rest_listing" @@ -138,12 +132,13 @@ class Plugin(RestExtension): # leaving these to make the code directive work with # docutils < 0.9 CodeBlock.site = site + Listing.site = site directives.register_directive('code', CodeBlock) directives.register_directive('code-block', CodeBlock) directives.register_directive('sourcecode', CodeBlock) directives.register_directive('listing', Listing) Listing.folders = site.config['LISTINGS_FOLDERS'] - return super(Plugin, self).set_site(site) + return super().set_site(site) # Add sphinx compatibility option @@ -152,7 +147,6 @@ listing_spec['linenos'] = directives.unchanged class Listing(Include): - """Create a highlighted block of code from a file in listings/. Usage: @@ -171,7 +165,12 @@ class Listing(Include): """Run listing directive.""" _fname = self.arguments.pop(0) fname = _fname.replace('/', os.sep) - lang = self.arguments.pop(0) + try: + lang = self.arguments.pop(0) + self.options['code'] = lang + except IndexError: + self.options['literal'] = True + if len(self.folders) == 1: listings_folder = next(iter(self.folders.keys())) if fname.startswith(listings_folder): @@ -181,22 +180,27 @@ class Listing(Include): else: fpath = os.path.join(fname) # must be new syntax: specify folder name self.arguments.insert(0, fpath) - self.options['code'] = lang if 'linenos' in self.options: self.options['number-lines'] = self.options['linenos'] - with io.open(fpath, 'r+', encoding='utf8') as fileobject: + with io.open(fpath, 'r+', encoding='utf-8-sig') as fileobject: self.content = fileobject.read().splitlines() self.state.document.settings.record_dependencies.add(fpath) target = urlunsplit(("link", 'listing', fpath.replace('\\', '/'), '', '')) + src_target = urlunsplit(("link", 'listing_source', fpath.replace('\\', '/'), '', '')) + src_label = self.site.MESSAGES('Source') generated_nodes = ( - [core.publish_doctree('`{0} <{1}>`_'.format(_fname, target))[0]]) + [core.publish_doctree('`{0} <{1}>`_ `({2}) <{3}>`_' .format( + _fname, target, src_label, src_target))[0]]) generated_nodes += self.get_code_from_file(fileobject) return generated_nodes def get_code_from_file(self, data): """Create CodeBlock nodes from file object content.""" - return super(Listing, self).run() + return super().run() def assert_has_content(self): - """Listing has no content, override check from superclass.""" + """Override check from superclass with nothing. + + Listing has no content, override check from superclass. + """ pass diff --git a/nikola/plugins/compile/rest/media.plugin b/nikola/plugins/compile/rest/media.plugin index 9803c8f..396c2f9 100644 --- a/nikola/plugins/compile/rest/media.plugin +++ b/nikola/plugins/compile/rest/media.plugin @@ -4,11 +4,11 @@ module = media [Nikola] compiler = rest -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Roberto Alsina version = 0.1 -website = http://getnikola.com +website = https://getnikola.com/ description = Directive to support oembed via micawber diff --git a/nikola/plugins/compile/rest/media.py b/nikola/plugins/compile/rest/media.py index 345e331..d29d0a2 100644 --- a/nikola/plugins/compile/rest/media.py +++ b/nikola/plugins/compile/rest/media.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -29,18 +29,16 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives +from nikola.plugin_categories import RestExtension +from nikola.utils import req_missing + try: import micawber except ImportError: - micawber = None # NOQA - - -from nikola.plugin_categories import RestExtension -from nikola.utils import req_missing + micawber = None class Plugin(RestExtension): - """Plugin for reST media directive.""" name = "rest_media" @@ -49,11 +47,11 @@ class Plugin(RestExtension): """Set Nikola site.""" self.site = site directives.register_directive('media', Media) - return super(Plugin, self).set_site(site) + self.site.register_shortcode('media', _gen_media_embed) + return super().set_site(site) class Media(Directive): - """reST extension for inserting any sort of media using micawber.""" has_content = False @@ -62,9 +60,13 @@ class Media(Directive): def run(self): """Run media directive.""" - if micawber is None: - msg = req_missing(['micawber'], 'use the media directive', optional=True) - return [nodes.raw('', '
    {0}
    '.format(msg), format='html')] + html = _gen_media_embed(" ".join(self.arguments)) + return [nodes.raw('', html, format='html')] + - providers = micawber.bootstrap_basic() - return [nodes.raw('', micawber.parse_text(" ".join(self.arguments), providers), format='html')] +def _gen_media_embed(url, *q, **kw): + if micawber is None: + msg = req_missing(['micawber'], 'use the media directive', optional=True) + return '
    {0}
    '.format(msg) + providers = micawber.bootstrap_basic() + return micawber.parse_text(url, providers) diff --git a/nikola/plugins/compile/rest/post_list.plugin b/nikola/plugins/compile/rest/post_list.plugin index 48969bf..68abaef 100644 --- a/nikola/plugins/compile/rest/post_list.plugin +++ b/nikola/plugins/compile/rest/post_list.plugin @@ -4,11 +4,11 @@ module = post_list [Nikola] compiler = rest -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Udo Spallek -version = 0.1 -website = http://getnikola.com -description = Includes a list of posts with tag and slide based filters. +version = 0.2 +website = https://getnikola.com/ +description = Includes a list of posts with tag and slice based filters. diff --git a/nikola/plugins/compile/rest/post_list.py b/nikola/plugins/compile/rest/post_list.py index a22ee85..f7e95ed 100644 --- a/nikola/plugins/compile/rest/post_list.py +++ b/nikola/plugins/compile/rest/post_list.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2013-2015 Udo Spallek, Roberto Alsina and others. +# Copyright © 2013-2020 Udo Spallek, Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -23,15 +23,8 @@ # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - """Post list directive for reStructuredText.""" -from __future__ import unicode_literals - -import os -import uuid -import natsort - from docutils import nodes from docutils.parsers.rst import Directive, directives @@ -43,7 +36,6 @@ from nikola.plugin_categories import RestExtension class Plugin(RestExtension): - """Plugin for reST post-list directive.""" name = "rest_post_list" @@ -51,74 +43,14 @@ class Plugin(RestExtension): def set_site(self, site): """Set Nikola site.""" self.site = site - directives.register_directive('post-list', PostList) - PostList.site = site - return super(Plugin, self).set_site(site) - - -class PostList(Directive): - - """Provide a reStructuredText directive to create a list of posts. - - Post List - ========= - :Directive Arguments: None. - :Directive Options: lang, start, stop, reverse, sort, tags, categories, slugs, all, template, id - :Directive Content: None. - - The posts appearing in the list can be filtered by options. - *List slicing* is provided with the *start*, *stop* and *reverse* options. - - The following not required options are recognized: - - ``start`` : integer - The index of the first post to show. - A negative value like ``-3`` will show the *last* three posts in the - post-list. - Defaults to None. - - ``stop`` : integer - The index of the last post to show. - A value negative value like ``-1`` will show every post, but not the - *last* in the post-list. - Defaults to None. - - ``reverse`` : flag - Reverse the order of the post-list. - Defaults is to not reverse the order of posts. - - ``sort``: string - Sort post list by one of each post's attributes, usually ``title`` or a - custom ``priority``. Defaults to None (chronological sorting). - - ``tags`` : string [, string...] - Filter posts to show only posts having at least one of the ``tags``. - Defaults to None. - - ``categories`` : string [, string...] - Filter posts to show only posts having one of the ``categories``. - Defaults to None. + directives.register_directive('post-list', PostListDirective) + directives.register_directive('post_list', PostListDirective) + PostListDirective.site = site + return super().set_site(site) - ``slugs`` : string [, string...] - Filter posts to show only posts having at least one of the ``slugs``. - Defaults to None. - ``all`` : flag - Shows all posts and pages in the post list. - Defaults to show only posts with set *use_in_feeds*. - - ``lang`` : string - The language of post *titles* and *links*. - Defaults to default language. - - ``template`` : string - The name of an alternative template to render the post-list. - Defaults to ``post_list_directive.tmpl`` - - ``id`` : string - A manual id for the post list. - Defaults to a random name composed by 'post_list_' + uuid.uuid4().hex. - """ +class PostListDirective(Directive): + """Provide a reStructuredText directive to create a list of posts.""" option_spec = { 'start': int, @@ -126,12 +58,16 @@ class PostList(Directive): 'reverse': directives.flag, 'sort': directives.unchanged, 'tags': directives.unchanged, + 'require_all_tags': directives.flag, 'categories': directives.unchanged, + 'sections': directives.unchanged, 'slugs': directives.unchanged, - 'all': directives.flag, + 'post_type': directives.unchanged, + 'type': directives.unchanged, 'lang': directives.unchanged, 'template': directives.path, 'id': directives.unchanged, + 'date': directives.unchanged, } def run(self): @@ -140,73 +76,42 @@ class PostList(Directive): stop = self.options.get('stop') reverse = self.options.get('reverse', False) tags = self.options.get('tags') - tags = [t.strip().lower() for t in tags.split(',')] if tags else [] + require_all_tags = 'require_all_tags' in self.options categories = self.options.get('categories') - categories = [c.strip().lower() for c in categories.split(',')] if categories else [] + sections = self.options.get('sections') slugs = self.options.get('slugs') - slugs = [s.strip() for s in slugs.split(',')] if slugs else [] - show_all = self.options.get('all', False) + post_type = self.options.get('post_type') + type = self.options.get('type', False) lang = self.options.get('lang', utils.LocaleBorg().current_lang) template = self.options.get('template', 'post_list_directive.tmpl') sort = self.options.get('sort') - if self.site.invariant: # for testing purposes - post_list_id = self.options.get('id', 'post_list_' + 'fixedvaluethatisnotauuid') - else: - post_list_id = self.options.get('id', 'post_list_' + uuid.uuid4().hex) - - filtered_timeline = [] - posts = [] - step = -1 if reverse is None else None - if show_all is None: - timeline = [p for p in self.site.timeline] + date = self.options.get('date') + filename = self.state.document.settings._nikola_source_path + + output, deps = self.site.plugin_manager.getPluginByName( + 'post_list', 'ShortcodePlugin').plugin_object.handler( + start, + stop, + reverse, + tags, + require_all_tags, + categories, + sections, + slugs, + post_type, + type, + lang, + template, + sort, + state=self.state, + site=self.site, + date=date, + filename=filename) + self.state.document.settings.record_dependencies.add( + "####MAGIC####TIMELINE") + for d in deps: + self.state.document.settings.record_dependencies.add(d) + if output: + return [nodes.raw('', output, format='html')] else: - timeline = [p for p in self.site.timeline if p.use_in_feeds] - - if categories: - timeline = [p for p in timeline if p.meta('category', lang=lang).lower() in categories] - - for post in timeline: - if tags: - cont = True - tags_lower = [t.lower() for t in post.tags] - for tag in tags: - if tag in tags_lower: - cont = False - - if cont: - continue - - filtered_timeline.append(post) - - if sort: - filtered_timeline = natsort.natsorted(filtered_timeline, key=lambda post: post.meta[lang][sort], alg=natsort.ns.F | natsort.ns.IC) - - for post in filtered_timeline[start:stop:step]: - if slugs: - cont = True - for slug in slugs: - if slug == post.meta('slug'): - cont = False - - if cont: - continue - - bp = post.translated_base_path(lang) - if os.path.exists(bp): - self.state.document.settings.record_dependencies.add(bp) - - posts += [post] - - if not posts: return [] - self.state.document.settings.record_dependencies.add("####MAGIC####TIMELINE") - - template_data = { - 'lang': lang, - 'posts': posts, - 'date_format': self.site.GLOBAL_CONTEXT.get('date_format'), - 'post_list_id': post_list_id, - } - output = self.site.template_system.render_template( - template, None, template_data) - return [nodes.raw('', output, format='html')] diff --git a/nikola/plugins/compile/rest/slides.plugin b/nikola/plugins/compile/rest/slides.plugin deleted file mode 100644 index 5c05b89..0000000 --- a/nikola/plugins/compile/rest/slides.plugin +++ /dev/null @@ -1,14 +0,0 @@ -[Core] -name = rest_slides -module = slides - -[Nikola] -compiler = rest -plugincategory = CompilerExtension - -[Documentation] -author = Roberto Alsina -version = 0.1 -website = http://getnikola.com -description = Slides directive - diff --git a/nikola/plugins/compile/rest/slides.py b/nikola/plugins/compile/rest/slides.py deleted file mode 100644 index 2522e55..0000000 --- a/nikola/plugins/compile/rest/slides.py +++ /dev/null @@ -1,80 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright © 2012-2015 Roberto Alsina and others. - -# Permission is hereby granted, free of charge, to any -# person obtaining a copy of this software and associated -# documentation files (the "Software"), to deal in the -# Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the -# Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice -# shall be included in all copies or substantial portions of -# the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY -# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE -# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR -# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS -# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR -# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -"""Slides directive for reStructuredText.""" - -from __future__ import unicode_literals - -import uuid - -from docutils import nodes -from docutils.parsers.rst import Directive, directives - -from nikola.plugin_categories import RestExtension - - -class Plugin(RestExtension): - - """Plugin for reST slides directive.""" - - name = "rest_slides" - - def set_site(self, site): - """Set Nikola site.""" - self.site = site - directives.register_directive('slides', Slides) - Slides.site = site - return super(Plugin, self).set_site(site) - - -class Slides(Directive): - - """reST extension for inserting slideshows.""" - - has_content = True - - def run(self): - """Run the slides directive.""" - if len(self.content) == 0: # pragma: no cover - return - - if self.site.invariant: # for testing purposes - carousel_id = 'slides_' + 'fixedvaluethatisnotauuid' - else: - carousel_id = 'slides_' + uuid.uuid4().hex - - output = self.site.template_system.render_template( - 'slides.tmpl', - None, - { - 'slides_content': self.content, - 'carousel_id': carousel_id, - } - ) - return [nodes.raw('', output, format='html')] - - -directives.register_directive('slides', Slides) diff --git a/nikola/plugins/compile/rest/soundcloud.plugin b/nikola/plugins/compile/rest/soundcloud.plugin index 75469e4..f85a964 100644 --- a/nikola/plugins/compile/rest/soundcloud.plugin +++ b/nikola/plugins/compile/rest/soundcloud.plugin @@ -4,11 +4,11 @@ module = soundcloud [Nikola] compiler = rest -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Roberto Alsina version = 0.1 -website = http://getnikola.com +website = https://getnikola.com/ description = Soundcloud directive diff --git a/nikola/plugins/compile/rest/soundcloud.py b/nikola/plugins/compile/rest/soundcloud.py index 30134a9..5dbcfc3 100644 --- a/nikola/plugins/compile/rest/soundcloud.py +++ b/nikola/plugins/compile/rest/soundcloud.py @@ -1,16 +1,39 @@ # -*- coding: utf-8 -*- +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """SoundCloud directive for reStructuredText.""" from docutils import nodes from docutils.parsers.rst import Directive, directives - +from nikola.plugins.compile.rest import _align_choice, _align_options_base from nikola.plugin_categories import RestExtension class Plugin(RestExtension): - """Plugin for soundclound directive.""" name = "rest_soundcloud" @@ -20,18 +43,19 @@ class Plugin(RestExtension): self.site = site directives.register_directive('soundcloud', SoundCloud) directives.register_directive('soundcloud_playlist', SoundCloudPlaylist) - return super(Plugin, self).set_site(site) + return super().set_site(site) -CODE = ("""""") +src="https://w.soundcloud.com/player/?url=http://api.soundcloud.com/{preslug}/{sid}"> + +
    """ class SoundCloud(Directive): - """reST extension for inserting SoundCloud embedded music. Usage: @@ -46,6 +70,7 @@ class SoundCloud(Directive): option_spec = { 'width': directives.positive_int, 'height': directives.positive_int, + "align": _align_choice } preslug = "tracks" @@ -59,6 +84,10 @@ class SoundCloud(Directive): 'preslug': self.preslug, } options.update(self.options) + if self.options.get('align') in _align_options_base: + options['align'] = ' align-' + self.options['align'] + else: + options['align'] = '' return [nodes.raw('', CODE.format(**options), format='html')] def check_content(self): @@ -70,7 +99,6 @@ class SoundCloud(Directive): class SoundCloudPlaylist(SoundCloud): - """reST directive for SoundCloud playlists.""" preslug = "playlists" diff --git a/nikola/plugins/compile/rest/thumbnail.plugin b/nikola/plugins/compile/rest/thumbnail.plugin index 0084310..e7b649d 100644 --- a/nikola/plugins/compile/rest/thumbnail.plugin +++ b/nikola/plugins/compile/rest/thumbnail.plugin @@ -4,11 +4,11 @@ module = thumbnail [Nikola] compiler = rest -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] author = Pelle Nilsson version = 0.1 -website = http://getnikola.com +website = https://getnikola.com/ description = reST directive to facilitate enlargeable images with thumbnails diff --git a/nikola/plugins/compile/rest/thumbnail.py b/nikola/plugins/compile/rest/thumbnail.py index 1fae06c..06ca9e4 100644 --- a/nikola/plugins/compile/rest/thumbnail.py +++ b/nikola/plugins/compile/rest/thumbnail.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2014-2015 Pelle Nilsson and others. +# Copyright © 2014-2020 Pelle Nilsson and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -35,7 +35,6 @@ from nikola.plugin_categories import RestExtension class Plugin(RestExtension): - """Plugin for thumbnail directive.""" name = "rest_thumbnail" @@ -44,11 +43,10 @@ class Plugin(RestExtension): """Set Nikola site.""" self.site = site directives.register_directive('thumbnail', Thumbnail) - return super(Plugin, self).set_site(site) + return super().set_site(site) class Thumbnail(Figure): - """Thumbnail directive for reST.""" def align(argument): @@ -70,8 +68,12 @@ class Thumbnail(Figure): def run(self): """Run the thumbnail directive.""" uri = directives.uri(self.arguments[0]) + if uri.endswith('.svg'): + # the ? at the end makes docutil output an instead of an object for the svg, which lightboxes may require + self.arguments[0] = '.thumbnail'.join(os.path.splitext(uri)) + '?' + else: + self.arguments[0] = '.thumbnail'.join(os.path.splitext(uri)) self.options['target'] = uri - self.arguments[0] = '.thumbnail'.join(os.path.splitext(uri)) if self.content: (node,) = Figure.run(self) else: diff --git a/nikola/plugins/compile/rest/vimeo.plugin b/nikola/plugins/compile/rest/vimeo.plugin index 688f981..89b171b 100644 --- a/nikola/plugins/compile/rest/vimeo.plugin +++ b/nikola/plugins/compile/rest/vimeo.plugin @@ -4,7 +4,7 @@ module = vimeo [Nikola] compiler = rest -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] description = Vimeo directive diff --git a/nikola/plugins/compile/rest/vimeo.py b/nikola/plugins/compile/rest/vimeo.py index c694a87..7047b03 100644 --- a/nikola/plugins/compile/rest/vimeo.py +++ b/nikola/plugins/compile/rest/vimeo.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,18 +26,17 @@ """Vimeo directive for reStructuredText.""" -from docutils import nodes -from docutils.parsers.rst import Directive, directives - -import requests import json +import requests +from docutils import nodes +from docutils.parsers.rst import Directive, directives from nikola.plugin_categories import RestExtension +from nikola.plugins.compile.rest import _align_choice, _align_options_base class Plugin(RestExtension): - """Plugin for vimeo reST directive.""" name = "rest_vimeo" @@ -46,13 +45,15 @@ class Plugin(RestExtension): """Set Nikola site.""" self.site = site directives.register_directive('vimeo', Vimeo) - return super(Plugin, self).set_site(site) + return super().set_site(site) -CODE = """ + """ VIDEO_DEFAULT_HEIGHT = 500 @@ -60,7 +61,6 @@ VIDEO_DEFAULT_WIDTH = 281 class Vimeo(Directive): - """reST extension for inserting vimeo embedded videos. Usage: @@ -75,6 +75,7 @@ class Vimeo(Directive): option_spec = { "width": directives.positive_int, "height": directives.positive_int, + "align": _align_choice } # set to False for not querying the vimeo api for size @@ -94,6 +95,10 @@ class Vimeo(Directive): return err self.set_video_size() options.update(self.options) + if self.options.get('align') in _align_options_base: + options['align'] = ' align-' + self.options['align'] + else: + options['align'] = '' return [nodes.raw('', CODE.format(**options), format='html')] def check_modules(self): @@ -109,7 +114,7 @@ class Vimeo(Directive): if json: # we can attempt to retrieve video attributes from vimeo try: - url = ('//vimeo.com/api/v2/video/{0}' + url = ('https://vimeo.com/api/v2/video/{0}' '.json'.format(self.arguments[0])) data = requests.get(url).text video_attributes = json.loads(data)[0] diff --git a/nikola/plugins/compile/rest/youtube.plugin b/nikola/plugins/compile/rest/youtube.plugin index 5fbd67b..d83d0f8 100644 --- a/nikola/plugins/compile/rest/youtube.plugin +++ b/nikola/plugins/compile/rest/youtube.plugin @@ -4,7 +4,7 @@ module = youtube [Nikola] compiler = rest -plugincategory = CompilerExtension +PluginCategory = CompilerExtension [Documentation] version = 0.1 diff --git a/nikola/plugins/compile/rest/youtube.py b/nikola/plugins/compile/rest/youtube.py index 6c5c211..d52ec64 100644 --- a/nikola/plugins/compile/rest/youtube.py +++ b/nikola/plugins/compile/rest/youtube.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -29,12 +29,11 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives - +from nikola.plugins.compile.rest import _align_choice, _align_options_base from nikola.plugin_categories import RestExtension class Plugin(RestExtension): - """Plugin for the youtube directive.""" name = "rest_youtube" @@ -43,18 +42,19 @@ class Plugin(RestExtension): """Set Nikola site.""" self.site = site directives.register_directive('youtube', Youtube) - return super(Plugin, self).set_site(site) + return super().set_site(site) CODE = """\ -""" +
    + +
    """ class Youtube(Directive): - """reST extension for inserting youtube embedded videos. Usage: @@ -67,8 +67,9 @@ class Youtube(Directive): has_content = True required_arguments = 1 option_spec = { - "width": directives.positive_int, - "height": directives.positive_int, + "width": directives.unchanged, + "height": directives.unchanged, + "align": _align_choice } def run(self): @@ -76,10 +77,14 @@ class Youtube(Directive): self.check_content() options = { 'yid': self.arguments[0], - 'width': 425, - 'height': 344, + 'width': 560, + 'height': 315, } - options.update(self.options) + options.update({k: v for k, v in self.options.items() if v}) + if self.options.get('align') in _align_options_base: + options['align'] = ' align-' + self.options['align'] + else: + options['align'] = '' return [nodes.raw('', CODE.format(**options), format='html')] def check_content(self): diff --git a/nikola/plugins/misc/__init__.py b/nikola/plugins/misc/__init__.py index c0d8961..1e7e6e1 100644 --- a/nikola/plugins/misc/__init__.py +++ b/nikola/plugins/misc/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated diff --git a/nikola/plugins/misc/scan_posts.plugin b/nikola/plugins/misc/scan_posts.plugin index 6d2351f..f4af811 100644 --- a/nikola/plugins/misc/scan_posts.plugin +++ b/nikola/plugins/misc/scan_posts.plugin @@ -5,6 +5,6 @@ Module = scan_posts [Documentation] Author = Roberto Alsina Version = 1.0 -Website = http://getnikola.com +Website = https://getnikola.com/ Description = Scan posts and create timeline diff --git a/nikola/plugins/misc/scan_posts.py b/nikola/plugins/misc/scan_posts.py index 1f4f995..8812779 100644 --- a/nikola/plugins/misc/scan_posts.py +++ b/nikola/plugins/misc/scan_posts.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,7 +26,6 @@ """The default post scanner.""" -from __future__ import unicode_literals, print_function import glob import os import sys @@ -35,9 +34,10 @@ from nikola.plugin_categories import PostScanner from nikola import utils from nikola.post import Post +LOGGER = utils.get_logger('scan_posts') -class ScanPosts(PostScanner): +class ScanPosts(PostScanner): """Scan posts in the site.""" name = "scan_posts" @@ -54,10 +54,10 @@ class ScanPosts(PostScanner): self.site.config['post_pages']: if not self.site.quiet: print(".", end='', file=sys.stderr) + destination_translatable = utils.TranslatableSetting('destination', destination, self.site.config['TRANSLATIONS']) dirname = os.path.dirname(wildcard) for dirpath, _, _ in os.walk(dirname, followlinks=True): - dest_dir = os.path.normpath(os.path.join(destination, - os.path.relpath(dirpath, dirname))) # output/destination/foo/ + rel_dest_dir = os.path.relpath(dirpath, dirname) # Get all the untranslated paths dir_glob = os.path.join(dirpath, os.path.basename(wildcard)) # posts/foo/*.rst untranslated = glob.glob(dir_glob) @@ -83,20 +83,30 @@ class ScanPosts(PostScanner): if not any([x.startswith('.') for x in p.split(os.sep)])] - for base_path in full_list: + for base_path in sorted(full_list): if base_path in seen: continue - else: - seen.add(base_path) - post = Post( - base_path, - self.site.config, - dest_dir, - use_in_feeds, - self.site.MESSAGES, - template_name, - self.site.get_compiler(base_path) - ) - timeline.append(post) + try: + post = Post( + base_path, + self.site.config, + rel_dest_dir, + use_in_feeds, + self.site.MESSAGES, + template_name, + self.site.get_compiler(base_path), + destination_base=destination_translatable, + metadata_extractors_by=self.site.metadata_extractors_by + ) + for lang in post.translated_to: + seen.add(post.translated_source_path(lang)) + timeline.append(post) + except Exception: + LOGGER.error('Error reading post {}'.format(base_path)) + raise return timeline + + def supported_extensions(self): + """Return a list of supported file extensions, or None if such a list isn't known beforehand.""" + return list({os.path.splitext(x[0])[1] for x in self.site.config['post_pages']}) diff --git a/nikola/plugins/misc/taxonomies_classifier.plugin b/nikola/plugins/misc/taxonomies_classifier.plugin new file mode 100644 index 0000000..55c59af --- /dev/null +++ b/nikola/plugins/misc/taxonomies_classifier.plugin @@ -0,0 +1,12 @@ +[Core] +name = classify_taxonomies +module = taxonomies_classifier + +[Documentation] +author = Roberto Alsina +version = 1.0 +website = https://getnikola.com/ +description = Classifies the timeline into taxonomies. + +[Nikola] +PluginCategory = SignalHandler diff --git a/nikola/plugins/misc/taxonomies_classifier.py b/nikola/plugins/misc/taxonomies_classifier.py new file mode 100644 index 0000000..da8045b --- /dev/null +++ b/nikola/plugins/misc/taxonomies_classifier.py @@ -0,0 +1,335 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Render the taxonomy overviews, classification pages and feeds.""" + +import functools +import os +import sys +from collections import defaultdict + +import blinker +import natsort + +from nikola.plugin_categories import SignalHandler +from nikola import utils, hierarchy_utils + + +class TaxonomiesClassifier(SignalHandler): + """Classify posts and pages by taxonomies.""" + + name = "classify_taxonomies" + + def _do_classification(self, site): + # Needed to avoid strange errors during tests + if site is not self.site: + return + + # Get list of enabled taxonomy plugins and initialize data structures + taxonomies = site.taxonomy_plugins.values() + site.posts_per_classification = {} + for taxonomy in taxonomies: + site.posts_per_classification[taxonomy.classification_name] = { + lang: defaultdict(set) for lang in site.config['TRANSLATIONS'].keys() + } + + # Classify posts + for post in site.timeline: + # Do classify pages, but don’t classify posts that are hidden + # (draft/private/future) + if post.is_post and not post.use_in_feeds: + continue + for taxonomy in taxonomies: + if taxonomy.apply_to_posts if post.is_post else taxonomy.apply_to_pages: + classifications = {} + for lang in site.config['TRANSLATIONS'].keys(): + # Extract classifications for this language + classifications[lang] = taxonomy.classify(post, lang) + if not taxonomy.more_than_one_classifications_per_post and len(classifications[lang]) > 1: + raise ValueError("Too many {0} classifications for post {1}".format(taxonomy.classification_name, post.source_path)) + # Add post to sets + for classification in classifications[lang]: + while True: + site.posts_per_classification[taxonomy.classification_name][lang][classification].add(post) + if not taxonomy.include_posts_from_subhierarchies or not taxonomy.has_hierarchy: + break + classification_path = taxonomy.extract_hierarchy(classification) + if len(classification_path) <= 1: + if len(classification_path) == 0 or not taxonomy.include_posts_into_hierarchy_root: + break + classification = taxonomy.recombine_classification_from_hierarchy(classification_path[:-1]) + + # Sort everything. + site.page_count_per_classification = {} + site.hierarchy_per_classification = {} + site.flat_hierarchy_per_classification = {} + site.hierarchy_lookup_per_classification = {} + for taxonomy in taxonomies: + site.page_count_per_classification[taxonomy.classification_name] = {} + # Sort post lists + for lang, posts_per_classification in site.posts_per_classification[taxonomy.classification_name].items(): + # Ensure implicit classifications are inserted + for classification in taxonomy.get_implicit_classifications(lang): + if classification not in posts_per_classification: + posts_per_classification[classification] = [] + site.page_count_per_classification[taxonomy.classification_name][lang] = {} + # Convert sets to lists and sort them + for classification in list(posts_per_classification.keys()): + posts = list(posts_per_classification[classification]) + posts = self.site.sort_posts_chronologically(posts, lang) + taxonomy.sort_posts(posts, classification, lang) + posts_per_classification[classification] = posts + # Create hierarchy information + if taxonomy.has_hierarchy: + site.hierarchy_per_classification[taxonomy.classification_name] = {} + site.flat_hierarchy_per_classification[taxonomy.classification_name] = {} + site.hierarchy_lookup_per_classification[taxonomy.classification_name] = {} + for lang, posts_per_classification in site.posts_per_classification[taxonomy.classification_name].items(): + # Compose hierarchy + hierarchy = {} + for classification in posts_per_classification.keys(): + hier = taxonomy.extract_hierarchy(classification) + node = hierarchy + for he in hier: + if he not in node: + node[he] = {} + node = node[he] + hierarchy_lookup = {} + + def create_hierarchy(hierarchy, parent=None, level=0): + """Create hierarchy.""" + result = {} + for name, children in hierarchy.items(): + node = hierarchy_utils.TreeNode(name, parent) + node.children = create_hierarchy(children, node, level + 1) + node.classification_path = [pn.name for pn in node.get_path()] + node.classification_name = taxonomy.recombine_classification_from_hierarchy(node.classification_path) + hierarchy_lookup[node.classification_name] = node + result[node.name] = node + classifications = natsort.natsorted(result.keys(), alg=natsort.ns.F | natsort.ns.IC) + taxonomy.sort_classifications(classifications, lang, level=level) + return [result[classification] for classification in classifications] + + root_list = create_hierarchy(hierarchy) + if '' in posts_per_classification: + node = hierarchy_utils.TreeNode('', parent=None) + node.children = root_list + node.classification_path = [] + node.classification_name = '' + hierarchy_lookup[node.name] = node + root_list = [node] + flat_hierarchy = hierarchy_utils.flatten_tree_structure(root_list) + # Store result + site.hierarchy_per_classification[taxonomy.classification_name][lang] = root_list + site.flat_hierarchy_per_classification[taxonomy.classification_name][lang] = flat_hierarchy + site.hierarchy_lookup_per_classification[taxonomy.classification_name][lang] = hierarchy_lookup + taxonomy.postprocess_posts_per_classification(site.posts_per_classification[taxonomy.classification_name], + site.flat_hierarchy_per_classification[taxonomy.classification_name], + site.hierarchy_lookup_per_classification[taxonomy.classification_name]) + else: + taxonomy.postprocess_posts_per_classification(site.posts_per_classification[taxonomy.classification_name]) + + # Check for valid paths and for collisions + taxonomy_outputs = {lang: dict() for lang in site.config['TRANSLATIONS'].keys()} + quit = False + for taxonomy in taxonomies: + # Check for collisions (per language) + for lang in site.config['TRANSLATIONS'].keys(): + if not taxonomy.is_enabled(lang): + continue + for classification, posts in site.posts_per_classification[taxonomy.classification_name][lang].items(): + # Do we actually generate this classification page? + filtered_posts = [x for x in posts if self.site.config["SHOW_UNTRANSLATED_POSTS"] or x.is_translation_available(lang)] + generate_list = taxonomy.should_generate_classification_page(classification, filtered_posts, lang) + if not generate_list: + continue + # Obtain path as tuple + path = site.path_handlers[taxonomy.classification_name](classification, lang) + # Check that path is OK + for path_element in path: + if len(path_element) == 0: + utils.LOGGER.error("{0} {1} yields invalid path '{2}'!".format(taxonomy.classification_name.title(), classification, '/'.join(path))) + quit = True + # Combine path + path = os.path.join(*[os.path.normpath(p) for p in path if p != '.']) + # Determine collisions + if path in taxonomy_outputs[lang]: + other_classification_name, other_classification, other_posts = taxonomy_outputs[lang][path] + if other_classification_name == taxonomy.classification_name and other_classification == classification: + taxonomy_outputs[lang][path][2].extend(filtered_posts) + else: + utils.LOGGER.error('You have classifications that are too similar: {0} "{1}" and {2} "{3}" both result in output path {4} for language {5}.'.format( + taxonomy.classification_name, classification, other_classification_name, other_classification, path, lang)) + utils.LOGGER.error('{0} "{1}" is used in: {2}'.format( + taxonomy.classification_name.title(), classification, ', '.join(sorted([p.source_path for p in filtered_posts])))) + utils.LOGGER.error('{0} "{1}" is used in: {2}'.format( + other_classification_name.title(), other_classification, ', '.join(sorted([p.source_path for p in other_posts])))) + quit = True + else: + taxonomy_outputs[lang][path] = (taxonomy.classification_name, classification, list(posts)) + if quit: + sys.exit(1) + blinker.signal('taxonomies_classified').send(site) + + def _get_filtered_list(self, taxonomy, classification, lang): + """Return the filtered list of posts for this classification and language.""" + post_list = self.site.posts_per_classification[taxonomy.classification_name][lang].get(classification, []) + if self.site.config["SHOW_UNTRANSLATED_POSTS"]: + return post_list + else: + return [x for x in post_list if x.is_translation_available(lang)] + + @staticmethod + def _compute_number_of_pages(filtered_posts, posts_count): + """Given a list of posts and the maximal number of posts per page, computes the number of pages needed.""" + return min(1, (len(filtered_posts) + posts_count - 1) // posts_count) + + def _postprocess_path(self, path, lang, append_index='auto', dest_type='page', page_info=None, alternative_path=False): + """Postprocess a generated path. + + Takes the path `path` for language `lang`, and postprocesses it. + + It appends `site.config['INDEX_FILE']` depending on `append_index` + (which can have the values `'always'`, `'never'` and `'auto'`) and + `site.config['PRETTY_URLS']`. + + It also modifies/adds the extension of the last path element resp. + `site.config['INDEX_FILE']` depending on `dest_type`, which can be + `'feed'`, `'rss'` or `'page'`. + + If `dest_type` is `'page'`, `page_info` can be `None` or a tuple + of two integers: the page number and the number of pages. This will + be used to append the correct page number by calling + `utils.adjust_name_for_index_path_list` and + `utils.get_displayed_page_number`. + + If `alternative_path` is set to `True`, `utils.adjust_name_for_index_path_list` + is called with `force_addition=True`, resulting in an alternative path for the + first page of an index or Atom feed by including the page number into the path. + """ + # Forcing extension for Atom feeds and RSS feeds + force_extension = None + if dest_type == 'feed': + force_extension = self.site.config['ATOM_EXTENSION'] + elif dest_type == 'rss': + force_extension = self.site.config['RSS_EXTENSION'] + # Determine how to extend path + path = [_f for _f in path if _f] + if force_extension is not None: + if len(path) == 0 and dest_type == 'rss': + path = [self.site.config['RSS_FILENAME_BASE'](lang)] + elif len(path) == 0 and dest_type == 'feed': + path = [self.site.config['ATOM_FILENAME_BASE'](lang)] + elif len(path) == 0 or append_index == 'always': + path = path + [os.path.splitext(self.site.config['INDEX_FILE'])[0]] + elif len(path) > 0 and append_index == 'never': + path[-1] = os.path.splitext(path[-1])[0] + path[-1] += force_extension + elif (self.site.config['PRETTY_URLS'] and append_index != 'never') or len(path) == 0 or append_index == 'always': + path = path + [self.site.config['INDEX_FILE']] + elif append_index != 'never': + path[-1] += '.html' + # Create path + result = [_f for _f in [self.site.config['TRANSLATIONS'][lang]] + path if _f] + if page_info is not None and dest_type in ('page', 'feed'): + result = utils.adjust_name_for_index_path_list(result, + page_info[0], + utils.get_displayed_page_number(page_info[0], page_info[1], self.site), + lang, + self.site, force_addition=alternative_path, extension=force_extension) + return result + + @staticmethod + def _parse_path_result(result): + """Interpret the return values of taxonomy.get_path() and taxonomy.get_overview_path() as if all three return values were given.""" + if not isinstance(result[0], (list, tuple)): + # The result must be a list or tuple of strings. Wrap into a tuple + result = (result, ) + path = result[0] + append_index = result[1] if len(result) > 1 else 'auto' + page_info = result[2] if len(result) > 2 else None + return path, append_index, page_info + + def _taxonomy_index_path(self, name, lang, taxonomy): + """Return path to the classification overview.""" + result = taxonomy.get_overview_path(lang) + path, append_index, _ = self._parse_path_result(result) + return self._postprocess_path(path, lang, append_index=append_index, dest_type='list') + + def _taxonomy_path(self, name, lang, taxonomy, dest_type='page', page=None, alternative_path=False): + """Return path to a classification.""" + if taxonomy.has_hierarchy: + result = taxonomy.get_path(taxonomy.extract_hierarchy(name), lang, dest_type=dest_type) + else: + result = taxonomy.get_path(name, lang, dest_type=dest_type) + path, append_index, page_ = self._parse_path_result(result) + + if page is not None: + page = int(page) + else: + page = page_ + + page_info = None + if taxonomy.show_list_as_index and page is not None: + number_of_pages = self.site.page_count_per_classification[taxonomy.classification_name][lang].get(name) + if number_of_pages is None: + number_of_pages = self._compute_number_of_pages(self._get_filtered_list(taxonomy, name, lang), self.site.config['INDEX_DISPLAY_POST_COUNT']) + self.site.page_count_per_classification[taxonomy.classification_name][lang][name] = number_of_pages + page_info = (page, number_of_pages) + return self._postprocess_path(path, lang, append_index=append_index, dest_type=dest_type, page_info=page_info) + + def _taxonomy_atom_path(self, name, lang, taxonomy, page=None, alternative_path=False): + """Return path to a classification Atom feed.""" + return self._taxonomy_path(name, lang, taxonomy, dest_type='feed', page=page, alternative_path=alternative_path) + + def _taxonomy_rss_path(self, name, lang, taxonomy): + """Return path to a classification RSS feed.""" + return self._taxonomy_path(name, lang, taxonomy, dest_type='rss') + + def _register_path_handlers(self, taxonomy): + functions = ( + ('{0}_index', self._taxonomy_index_path), + ('{0}', self._taxonomy_path), + ('{0}_atom', self._taxonomy_atom_path), + ('{0}_rss', self._taxonomy_rss_path), + ) + + for name, function in functions: + name = name.format(taxonomy.classification_name) + p = functools.partial(function, taxonomy=taxonomy) + doc = taxonomy.path_handler_docstrings[name] + if doc is not False: + p.__doc__ = doc + self.site.register_path_handler(name, p) + + def set_site(self, site): + """Set site, which is a Nikola instance.""" + super().set_site(site) + # Add hook for after post scanning + blinker.signal("scanned").connect(self._do_classification) + # Register path handlers + for taxonomy in site.taxonomy_plugins.values(): + self._register_path_handlers(taxonomy) diff --git a/nikola/plugins/shortcode/chart.plugin b/nikola/plugins/shortcode/chart.plugin new file mode 100644 index 0000000..edcbc13 --- /dev/null +++ b/nikola/plugins/shortcode/chart.plugin @@ -0,0 +1,13 @@ +[Core] +name = chart +module = chart + +[Nikola] +PluginCategory = Shortcode + +[Documentation] +author = Roberto Alsina +version = 0.1 +website = https://getnikola.com/ +description = Chart directive based in PyGal + diff --git a/nikola/plugins/shortcode/chart.py b/nikola/plugins/shortcode/chart.py new file mode 100644 index 0000000..64341e8 --- /dev/null +++ b/nikola/plugins/shortcode/chart.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +"""Chart shortcode.""" + +from ast import literal_eval + +from nikola.plugin_categories import ShortcodePlugin +from nikola.utils import req_missing, load_data + +try: + import pygal +except ImportError: + pygal = None + +_site = None + + +class ChartShortcode(ShortcodePlugin): + """Plugin for chart shortcode.""" + + name = "chart" + + def handler(self, chart_type, **_options): + """Generate chart using Pygal.""" + if pygal is None: + msg = req_missing( + ['pygal'], 'use the Chart directive', optional=True) + return '
    {0}
    '.format(msg) + options = {} + chart_data = [] + _options.pop('post', None) + _options.pop('site') + data = _options.pop('data') + + for line in data.splitlines(): + line = line.strip() + if line: + chart_data.append(literal_eval('({0})'.format(line))) + if 'data_file' in _options: + options = load_data(_options['data_file']) + _options.pop('data_file') + if not chart_data: # If there is data in the document, it wins + for k, v in options.pop('data', {}).items(): + chart_data.append((k, v)) + + options.update(_options) + + style_name = options.pop('style', 'BlueStyle') + if '(' in style_name: # Parametric style + style = eval('pygal.style.' + style_name) + else: + style = getattr(pygal.style, style_name) + for k, v in options.items(): + try: + options[k] = literal_eval(v) + except Exception: + options[k] = v + chart = pygal + for o in chart_type.split('.'): + chart = getattr(chart, o) + chart = chart(style=style) + if _site and _site.invariant: + chart.no_prefix = True + chart.config(**options) + for label, series in chart_data: + chart.add(label, series) + return chart.render().decode('utf8') diff --git a/nikola/plugins/shortcode/emoji.plugin b/nikola/plugins/shortcode/emoji.plugin new file mode 100644 index 0000000..c9a272c --- /dev/null +++ b/nikola/plugins/shortcode/emoji.plugin @@ -0,0 +1,13 @@ +[Core] +name = emoji +module = emoji + +[Nikola] +PluginCategory = Shortcode + +[Documentation] +author = Roberto Alsina +version = 0.1 +website = https://getnikola.com/ +description = emoji shortcode + diff --git a/nikola/plugins/shortcode/emoji/__init__.py b/nikola/plugins/shortcode/emoji/__init__.py new file mode 100644 index 0000000..9ae2228 --- /dev/null +++ b/nikola/plugins/shortcode/emoji/__init__.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# This file is public domain according to its author, Roberto Alsina + +"""Emoji directive for reStructuredText.""" + +import glob +import json +import os + +from nikola.plugin_categories import ShortcodePlugin +from nikola import utils + +TABLE = {} + +LOGGER = utils.get_logger('scan_posts') + + +def _populate(): + for fname in glob.glob(os.path.join(os.path.dirname(__file__), 'data', '*.json')): + with open(fname, encoding="utf-8-sig") as inf: + data = json.load(inf) + data = data[list(data.keys())[0]] + data = data[list(data.keys())[0]] + for item in data: + if item['key'] in TABLE: + LOGGER.warning('Repeated emoji {}'.format(item['key'])) + else: + TABLE[item['key']] = item['value'] + + +class Plugin(ShortcodePlugin): + """Plugin for gist directive.""" + + name = "emoji" + + def handler(self, name, filename=None, site=None, data=None, lang=None, post=None): + """Create HTML for emoji.""" + if not TABLE: + _populate() + try: + output = u'''{}'''.format(TABLE[name]) + except KeyError: + LOGGER.warning('Unknown emoji {}'.format(name)) + output = u'''{}'''.format(name) + + return output, [] diff --git a/nikola/plugins/shortcode/emoji/data/Activity.json b/nikola/plugins/shortcode/emoji/data/Activity.json new file mode 100644 index 0000000..1461f19 --- /dev/null +++ b/nikola/plugins/shortcode/emoji/data/Activity.json @@ -0,0 +1,418 @@ +{ + "activities": { + "activity": [ + { + "key": "soccer_ball", + "value": "⚽" + }, + { + "key": "basket_ball", + "value": "🏀" + }, + { + "key": "american_football", + "value": "🏈" + }, + { + "key": "baseball", + "value": "⚾" + }, + { + "key": "tennis_racquet_ball", + "value": "🎾" + }, + { + "key": "volley_ball", + "value": "🏐" + }, + { + "key": "rugby_football", + "value": "🏉" + }, + { + "key": "billiards", + "value": "🎱" + }, + { + "key": "activity_in_hole", + "value": "⛳" + }, + { + "key": "golfer", + "value": "🏌" + }, + { + "key": "table_tennis_paddle_ball", + "value": "🏓" + }, + { + "key": "badminton_racquet_shuttle_cock", + "value": "🏸" + }, + { + "key": "ice_hockey_stick_puck", + "value": "🏒" + }, + { + "key": "field_hockey_stick_ball", + "value": "🏑" + }, + { + "key": "cricket_bat_ball", + "value": "🏏" + }, + { + "key": "ski_and_ski_boot", + "value": "🎿" + }, + { + "key": "skier", + "value": "⛷" + }, + { + "key": "snow_boarder", + "value": "🏂" + }, + { + "key": "ice_skate", + "value": "⛸" + }, + { + "key": "bow_and_arrow", + "value": "🏹" + }, + { + "key": "fishing_pole_and_fish", + "value": "🎣" + }, + { + "key": "row_boat", + "value": "🚣" + }, + { + "key": "row_boat_type_1_2", + "value": "🚣🏻" + }, + { + "key": "row_boat_type_3", + "value": "🚣🏼" + }, + { + "key": "row_boat_type_4", + "value": "🚣🏽" + }, + { + "key": "row_boat_type_5", + "value": "🚣🏾" + }, + { + "key": "row_boat_type_6", + "value": "🚣🏿" + }, + { + "key": "swimmer", + "value": "🏊" + }, + { + "key": "swimmer_type_1_2", + "value": "🏊🏻" + }, + { + "key": "swimmer_type_3", + "value": "🏊🏼" + }, + { + "key": "swimmer_type_4", + "value": "🏊🏽" + }, + { + "key": "swimmer_type_5", + "value": "🏊🏾" + }, + { + "key": "swimmer_type_6", + "value": "🏊🏿" + }, + { + "key": "surfer", + "value": "🏄" + }, + { + "key": "surfer_type_1_2", + "value": "🏄🏻" + }, + { + "key": "surfer_type_3", + "value": "🏄🏼" + }, + { + "key": "surfer_type_4", + "value": "🏄🏽" + }, + { + "key": "surfer_type_5", + "value": "🏄🏾" + }, + { + "key": "surfer_type_6", + "value": "🏄🏿" + }, + { + "key": "bath", + "value": "🛀" + }, + { + "key": "bath_type_1_2", + "value": "🛀🏻" + }, + { + "key": "bath_type_3", + "value": "🛀🏼" + }, + { + "key": "bath_type_4", + "value": "🛀🏽" + }, + { + "key": "bath_type_5", + "value": "🛀🏾" + }, + { + "key": "bath_type_6", + "value": "🛀🏿" + }, + { + "key": "person_with_ball", + "value": "⛹" + }, + { + "key": "person_with_ball_type_1_2", + "value": "⛹🏻" + }, + { + "key": "person_with_ball_type_3", + "value": "⛹🏼" + }, + { + "key": "person_with_ball_type_4", + "value": "⛹🏽" + }, + { + "key": "person_with_ball_type_5", + "value": "⛹🏾" + }, + { + "key": "person_with_ball_type_6", + "value": "⛹🏿" + }, + { + "key": "weight_lifter", + "value": "🏋" + }, + { + "key": "weight_lifter_type_1_2", + "value": "🏋🏻" + }, + { + "key": "weight_lifter_type_3", + "value": "🏋🏼" + }, + { + "key": "weight_lifter_type_4", + "value": "🏋🏽" + }, + { + "key": "weight_lifter_type_5", + "value": "🏋🏾" + }, + { + "key": "weight_lifter_type_6", + "value": "🏋🏿" + }, + { + "key": "bicyclist", + "value": "🚴" + }, + { + "key": "bicyclist_type_1_2", + "value": "🚴🏻" + }, + { + "key": "bicyclist_type_3", + "value": "🚴🏼" + }, + { + "key": "bicyclist_type_4", + "value": "🚴🏽" + }, + { + "key": "bicyclist_type_5", + "value": "🚴🏾" + }, + { + "key": "bicyclist_type_6", + "value": "🚴🏿" + }, + { + "key": "mountain_bicyclist", + "value": "🚵" + }, + { + "key": "mountain_bicyclist_type_1_2", + "value": "🚵🏻" + }, + { + "key": "mountain_bicyclist_type_3", + "value": "🚵🏼" + }, + { + "key": "mountain_bicyclist_type_4", + "value": "🚵🏽" + }, + { + "key": "mountain_bicyclist_type_5", + "value": "🚵🏾" + }, + { + "key": "mountain_bicyclist_type_6", + "value": "🚵🏿" + }, + { + "key": "horse_racing", + "value": "🏇" + }, + { + "key": "horse_racing_type_1_2", + "value": "🏇🏻" + }, + { + "key": "horse_racing_type_3", + "value": "🏇🏻" + }, + { + "key": "horse_racing_type_4", + "value": "🏇🏽" + }, + { + "key": "horse_racing_type_5", + "value": "🏇🏾" + }, + { + "key": "horse_racing_type_6", + "value": "🏇🏿" + }, + { + "key": "main_business_suit_levitating", + "value": "🕴" + }, + { + "key": "trophy", + "value": "🏆" + }, + { + "key": "running_shirt_with_sash", + "value": "🎽" + }, + { + "key": "sports_medal", + "value": "🏅" + }, + { + "key": "military_medal", + "value": "🎖" + }, + { + "key": "reminder_ribbon", + "value": "🎗" + }, + { + "key": "rosette", + "value": "🏵" + }, + { + "key": "ticket", + "value": "🎫" + }, + { + "key": "admission_tickets", + "value": "🎟" + }, + { + "key": "performing_arts", + "value": "🎭" + }, + { + "key": "artist_palette", + "value": "🎨" + }, + { + "key": "circus_tent", + "value": "🎪" + }, + { + "key": "microphone", + "value": "🎤" + }, + { + "key": "headphone", + "value": "🎧" + }, + { + "key": "musical_score", + "value": "🎼" + }, + { + "key": "musical_keyboard", + "value": "🎹" + }, + { + "key": "saxophone", + "value": "🎷" + }, + { + "key": "trumpet", + "value": "🎺" + }, + { + "key": "guitar", + "value": "🎸" + }, + { + "key": "violin", + "value": "🎻" + }, + { + "key": "clapper_board", + "value": "🎬" + }, + { + "key": "video_game", + "value": "🎮" + }, + { + "key": "alien_monster", + "value": "👾" + }, + { + "key": "direct_hit", + "value": "🎯" + }, + { + "key": "game_die", + "value": "🎲" + }, + { + "key": "slot_machine", + "value": "🎰" + }, + { + "key": "bowling", + "value": "🎳" + }, + { + "key": "olympic_rings", + "value": "◯‍◯‍◯‍◯‍◯" + } + ] + } +} \ No newline at end of file diff --git a/nikola/plugins/shortcode/emoji/data/Flags.json b/nikola/plugins/shortcode/emoji/data/Flags.json new file mode 100644 index 0000000..d1d4bdc --- /dev/null +++ b/nikola/plugins/shortcode/emoji/data/Flags.json @@ -0,0 +1,998 @@ +{ + "flags": { + "flag": [ + { + "key": "afghanistan", + "value": "🇦🇫" + }, + { + "key": "land_island", + "value": "🇦🇽" + }, + { + "key": "albania", + "value": "🇦🇱" + }, + { + "key": "algeria", + "value": "🇩🇿" + }, + { + "key": "american_samoa", + "value": "🇦🇸" + }, + { + "key": "andorra", + "value": "🇦🇩" + }, + { + "key": "angola", + "value": "🇦🇴" + }, + { + "key": "anguilla", + "value": "🇦🇮" + }, + { + "key": "antarctica", + "value": "🇦🇶" + }, + { + "key": "antigua_and_barbuda", + "value": "🇦🇬" + }, + { + "key": "argentina", + "value": "🇦🇷" + }, + { + "key": "armenia", + "value": "🇦🇲" + }, + { + "key": "aruba", + "value": "🇦🇼" + }, + { + "key": "australia", + "value": "🇦🇺" + }, + { + "key": "austria", + "value": "🇦🇹" + }, + { + "key": "azerbaijan", + "value": "🇦🇿" + }, + { + "key": "bahamas", + "value": "🇧🇸" + }, + { + "key": "bahrain", + "value": "🇧🇭" + }, + { + "key": "bangladesh", + "value": "🇧🇩" + }, + { + "key": "barbados", + "value": "🇧🇧" + }, + { + "key": "belarus", + "value": "🇧🇾" + }, + { + "key": "belgium", + "value": "🇧🇪" + }, + { + "key": "belize", + "value": "🇧🇿" + }, + { + "key": "benin", + "value": "🇧🇯" + }, + { + "key": "bermuda", + "value": "🇧🇲" + }, + { + "key": "bhutan", + "value": "🇧🇹" + }, + { + "key": "bolivia", + "value": "🇧🇴" + }, + { + "key": "caribbean_netherlands", + "value": "🇧🇶" + }, + { + "key": "bosnia_and_herzegovina", + "value": "🇧🇦" + }, + { + "key": "botswana", + "value": "🇧🇼" + }, + { + "key": "brazil", + "value": "🇧🇷" + }, + { + "key": "british_indian_ocean_territory", + "value": "🇮🇴" + }, + { + "key": "british_virgin_islands", + "value": "🇻🇬" + }, + { + "key": "brunei", + "value": "🇧🇳" + }, + { + "key": "bulgaria", + "value": "🇧🇬" + }, + { + "key": "burkina_faso", + "value": "🇧🇫" + }, + { + "key": "burundi", + "value": "🇧🇮" + }, + { + "key": "cape_verde", + "value": "🇨🇻" + }, + { + "key": "cambodia", + "value": "🇰🇭" + }, + { + "key": "cameroon", + "value": "🇨🇲" + }, + { + "key": "canada", + "value": "🇨🇦" + }, + { + "key": "canary_islands", + "value": "🇮🇨" + }, + { + "key": "cayman_islands", + "value": "🇰🇾" + }, + { + "key": "central_african_republic", + "value": "🇨🇫" + }, + { + "key": "chad", + "value": "🇹🇩" + }, + { + "key": "chile", + "value": "🇨🇱" + }, + { + "key": "china", + "value": "🇨🇳" + }, + { + "key": "christmas_island", + "value": "🇨🇽" + }, + { + "key": "cocos_keeling_island", + "value": "🇨🇨" + }, + { + "key": "colombia", + "value": "🇨🇴" + }, + { + "key": "comoros", + "value": "🇰🇲" + }, + { + "key": "congo_brazzaville", + "value": "🇨🇬" + }, + { + "key": "congo_kingshasa", + "value": "🇨🇩" + }, + { + "key": "cook_islands", + "value": "🇨🇰" + }, + { + "key": "costa_rica", + "value": "🇨🇷" + }, + { + "key": "croatia", + "value": "🇭🇷" + }, + { + "key": "cuba", + "value": "🇨🇺" + }, + { + "key": "curaao", + "value": "🇨🇼" + }, + { + "key": "cyprus", + "value": "🇨🇾" + }, + { + "key": "czech_republic", + "value": "🇨🇿" + }, + { + "key": "denmark", + "value": "🇩🇰" + }, + { + "key": "djibouti", + "value": "🇩🇯" + }, + { + "key": "dominica", + "value": "🇩🇲" + }, + { + "key": "dominican_republic", + "value": "🇩🇴" + }, + { + "key": "ecuador", + "value": "🇪🇨" + }, + { + "key": "egypt", + "value": "🇪🇬" + }, + { + "key": "el_salvador", + "value": "🇸🇻" + }, + { + "key": "equatorial_guinea", + "value": "🇬🇶" + }, + { + "key": "eritrea", + "value": "🇪🇷" + }, + { + "key": "estonia", + "value": "🇪🇪" + }, + { + "key": "ethiopia", + "value": "🇪🇹" + }, + { + "key": "european_union", + "value": "🇪🇺" + }, + { + "key": "falkland_islands", + "value": "🇫🇰" + }, + { + "key": "faroe_islands", + "value": "🇫🇴" + }, + { + "key": "fiji", + "value": "🇫🇯" + }, + { + "key": "finland", + "value": "🇫🇮" + }, + { + "key": "france", + "value": "🇫🇷" + }, + { + "key": "french_guiana", + "value": "🇬🇫" + }, + { + "key": "french_polynesia", + "value": "🇵🇫" + }, + { + "key": "french_southern_territories", + "value": "🇹🇫" + }, + { + "key": "gabon", + "value": "🇬🇦" + }, + { + "key": "gambia", + "value": "🇬🇲" + }, + { + "key": "georgia", + "value": "🇬🇪" + }, + { + "key": "germany", + "value": "🇩🇪" + }, + { + "key": "ghana", + "value": "🇬🇭" + }, + { + "key": "gibraltar", + "value": "🇬🇮" + }, + { + "key": "greece", + "value": "🇬🇷" + }, + { + "key": "greenland", + "value": "🇬🇱" + }, + { + "key": "grenada", + "value": "🇬🇩" + }, + { + "key": "guadeloupe", + "value": "🇬🇵" + }, + { + "key": "guam", + "value": "🇬🇺" + }, + { + "key": "guatemala", + "value": "🇬🇹" + }, + { + "key": "guernsey", + "value": "🇬🇬" + }, + { + "key": "guinea", + "value": "🇬🇳" + }, + { + "key": "guinea_bissau", + "value": "🇬🇼" + }, + { + "key": "guyana", + "value": "🇬🇾" + }, + { + "key": "haiti", + "value": "🇭🇹" + }, + { + "key": "honduras", + "value": "🇭🇳" + }, + { + "key": "hong_kong", + "value": "🇭🇰" + }, + { + "key": "hungary", + "value": "🇭🇺" + }, + { + "key": "iceland", + "value": "🇮🇸" + }, + { + "key": "india", + "value": "🇮🇳" + }, + { + "key": "indonesia", + "value": "🇮🇩" + }, + { + "key": "iran", + "value": "🇮🇷" + }, + { + "key": "iraq", + "value": "🇮🇶" + }, + { + "key": "ireland", + "value": "🇮🇪" + }, + { + "key": "isle_of_man", + "value": "🇮🇲" + }, + { + "key": "israel", + "value": "🇮🇱" + }, + { + "key": "italy", + "value": "🇮🇹" + }, + { + "key": "ctedivoire", + "value": "🇨🇮" + }, + { + "key": "jamaica", + "value": "🇯🇲" + }, + { + "key": "japan", + "value": "🇯🇵" + }, + { + "key": "jersey", + "value": "🇯🇪" + }, + { + "key": "jordan", + "value": "🇯🇴" + }, + { + "key": "kazakhstan", + "value": "🇰🇿" + }, + { + "key": "kenya", + "value": "🇰🇪" + }, + { + "key": "kiribati", + "value": "🇰🇮" + }, + { + "key": "kosovo", + "value": "🇽🇰" + }, + { + "key": "kuwait", + "value": "🇰🇼" + }, + { + "key": "kyrgyzstan", + "value": "🇰🇬" + }, + { + "key": "laos", + "value": "🇱🇦" + }, + { + "key": "latvia", + "value": "🇱🇻" + }, + { + "key": "lebanon", + "value": "🇱🇧" + }, + { + "key": "lesotho", + "value": "🇱🇸" + }, + { + "key": "liberia", + "value": "🇱🇷" + }, + { + "key": "libya", + "value": "🇱🇾" + }, + { + "key": "liechtenstein", + "value": "🇱🇮" + }, + { + "key": "lithuania", + "value": "🇱🇹" + }, + { + "key": "luxembourg", + "value": "🇱🇺" + }, + { + "key": "macau", + "value": "🇲🇴" + }, + { + "key": "macedonia", + "value": "🇲🇰" + }, + { + "key": "madagascar", + "value": "🇲🇬" + }, + { + "key": "malawi", + "value": "🇲🇼" + }, + { + "key": "malaysia", + "value": "🇲🇾" + }, + { + "key": "maldives", + "value": "🇲🇻" + }, + { + "key": "mali", + "value": "🇲🇱" + }, + { + "key": "malta", + "value": "🇲🇹" + }, + { + "key": "marshall_islands", + "value": "🇲🇭" + }, + { + "key": "martinique", + "value": "🇲🇶" + }, + { + "key": "mauritania", + "value": "🇲🇷" + }, + { + "key": "mauritius", + "value": "🇲🇺" + }, + { + "key": "mayotte", + "value": "🇾🇹" + }, + { + "key": "mexico", + "value": "🇲🇽" + }, + { + "key": "micronesia", + "value": "🇫🇲" + }, + { + "key": "moldova", + "value": "🇲🇩" + }, + { + "key": "monaco", + "value": "🇲🇨" + }, + { + "key": "mongolia", + "value": "🇲🇳" + }, + { + "key": "montenegro", + "value": "🇲🇪" + }, + { + "key": "montserrat", + "value": "🇲🇸" + }, + { + "key": "morocco", + "value": "🇲🇦" + }, + { + "key": "mozambique", + "value": "🇲🇿" + }, + { + "key": "myanmar_burma", + "value": "🇲🇲" + }, + { + "key": "namibia", + "value": "🇳🇦" + }, + { + "key": "nauru", + "value": "🇳🇷" + }, + { + "key": "nepal", + "value": "🇳🇵" + }, + { + "key": "netherlands", + "value": "🇳🇱" + }, + { + "key": "new_caledonia", + "value": "🇳🇨" + }, + { + "key": "new_zealand", + "value": "🇳🇿" + }, + { + "key": "nicaragua", + "value": "🇳🇮" + }, + { + "key": "niger", + "value": "🇳🇪" + }, + { + "key": "nigeria", + "value": "🇳🇬" + }, + { + "key": "niue", + "value": "🇳🇺" + }, + { + "key": "norfolk_island", + "value": "🇳🇫" + }, + { + "key": "northern_mariana_islands", + "value": "🇲🇵" + }, + { + "key": "north_korea", + "value": "🇰🇵" + }, + { + "key": "norway", + "value": "🇳🇴" + }, + { + "key": "oman", + "value": "🇴🇲" + }, + { + "key": "pakistan", + "value": "🇵🇰" + }, + { + "key": "palau", + "value": "🇵🇼" + }, + { + "key": "palestinian_territories", + "value": "🇵🇸" + }, + { + "key": "panama", + "value": "🇵🇦" + }, + { + "key": "papua_new_guinea", + "value": "🇵🇬" + }, + { + "key": "paraguay", + "value": "🇵🇾" + }, + { + "key": "peru", + "value": "🇵🇪" + }, + { + "key": "philippines", + "value": "🇵🇭" + }, + { + "key": "pitcairn_islands", + "value": "🇵🇳" + }, + { + "key": "poland", + "value": "🇵🇱" + }, + { + "key": "portugal", + "value": "🇵🇹" + }, + { + "key": "puerto_rico", + "value": "🇵🇷" + }, + { + "key": "qatar", + "value": "🇶🇦" + }, + { + "key": "reunion", + "value": "🇷🇪" + }, + { + "key": "romania", + "value": "🇷🇴" + }, + { + "key": "russia", + "value": "🇷🇺" + }, + { + "key": "rwanda", + "value": "🇷🇼" + }, + { + "key": "saint_barthlemy", + "value": "🇧🇱" + }, + { + "key": "saint_helena", + "value": "🇸🇭" + }, + { + "key": "saint_kitts_and_nevis", + "value": "🇰🇳" + }, + { + "key": "saint_lucia", + "value": "🇱🇨" + }, + { + "key": "saint_pierre_and_miquelon", + "value": "🇵🇲" + }, + { + "key": "st_vincent_grenadines", + "value": "🇻🇨" + }, + { + "key": "samoa", + "value": "🇼🇸" + }, + { + "key": "san_marino", + "value": "🇸🇲" + }, + { + "key": "sotom_and_prncipe", + "value": "🇸🇹" + }, + { + "key": "saudi_arabia", + "value": "🇸🇦" + }, + { + "key": "senegal", + "value": "🇸🇳" + }, + { + "key": "serbia", + "value": "🇷🇸" + }, + { + "key": "seychelles", + "value": "🇸🇨" + }, + { + "key": "sierra_leone", + "value": "🇸🇱" + }, + { + "key": "singapore", + "value": "🇸🇬" + }, + { + "key": "sint_maarten", + "value": "🇸🇽" + }, + { + "key": "slovakia", + "value": "🇸🇰" + }, + { + "key": "slovenia", + "value": "🇸🇮" + }, + { + "key": "solomon_islands", + "value": "🇸🇧" + }, + { + "key": "somalia", + "value": "🇸🇴" + }, + { + "key": "south_africa", + "value": "🇿🇦" + }, + { + "key": "south_georgia_south_sandwich_islands", + "value": "🇬🇸" + }, + { + "key": "south_korea", + "value": "🇰🇷" + }, + { + "key": "south_sudan", + "value": "🇸🇸" + }, + { + "key": "spain", + "value": "🇪🇸" + }, + { + "key": "sri_lanka", + "value": "🇱🇰" + }, + { + "key": "sudan", + "value": "🇸🇩" + }, + { + "key": "suriname", + "value": "🇸🇷" + }, + { + "key": "swaziland", + "value": "🇸🇿" + }, + { + "key": "sweden", + "value": "🇸🇪" + }, + { + "key": "switzerland", + "value": "🇨🇭" + }, + { + "key": "syria", + "value": "🇸🇾" + }, + { + "key": "taiwan", + "value": "🇹🇼" + }, + { + "key": "tajikistan", + "value": "🇹🇯" + }, + { + "key": "tanzania", + "value": "🇹🇿" + }, + { + "key": "thailand", + "value": "🇹🇭" + }, + { + "key": "timorleste", + "value": "🇹🇱" + }, + { + "key": "togo", + "value": "🇹🇬" + }, + { + "key": "tokelau", + "value": "🇹🇰" + }, + { + "key": "tonga", + "value": "🇹🇴" + }, + { + "key": "trinidad_and_tobago", + "value": "🇹🇹" + }, + { + "key": "tunisia", + "value": "🇹🇳" + }, + { + "key": "turkey", + "value": "🇹🇷" + }, + { + "key": "turkmenistan", + "value": "🇹🇲" + }, + { + "key": "turks_and_caicos_islands", + "value": "🇹🇨" + }, + { + "key": "tuvalu", + "value": "🇹🇻" + }, + { + "key": "uganda", + "value": "🇺🇬" + }, + { + "key": "ukraine", + "value": "🇺🇦" + }, + { + "key": "united_arab_emirates", + "value": "🇦🇪" + }, + { + "key": "united_kingdom", + "value": "🇬🇧" + }, + { + "key": "united_states", + "value": "🇺🇸" + }, + { + "key": "us_virgin_islands", + "value": "🇻🇮" + }, + { + "key": "uruguay", + "value": "🇺🇾" + }, + { + "key": "uzbekistan", + "value": "🇺🇿" + }, + { + "key": "vanuatu", + "value": "🇻🇺" + }, + { + "key": "vatican_city", + "value": "🇻🇦" + }, + { + "key": "venezuela", + "value": "🇻🇪" + }, + { + "key": "vietnam", + "value": "🇻🇳" + }, + { + "key": "wallis_and_futuna", + "value": "🇼🇫" + }, + { + "key": "western_sahara", + "value": "🇪🇭" + }, + { + "key": "yemen", + "value": "🇾🇪" + }, + { + "key": "zambia", + "value": "🇿🇲" + }, + { + "key": "zimbabwe", + "value": "🇿🇼" + }, + { + "key": "england", + "value": "🇽🇪" + } + ] + } +} \ No newline at end of file diff --git a/nikola/plugins/shortcode/emoji/data/Food.json b/nikola/plugins/shortcode/emoji/data/Food.json new file mode 100644 index 0000000..c755a20 --- /dev/null +++ b/nikola/plugins/shortcode/emoji/data/Food.json @@ -0,0 +1,274 @@ +{ + "foods": { + "food": [ + { + "key": "green_apple", + "value": "🍏" + }, + { + "key": "red_apple", + "value": "🍎" + }, + { + "key": "pear", + "value": "🍐" + }, + { + "key": "tangerine", + "value": "🍊" + }, + { + "key": "lemon", + "value": "🍋" + }, + { + "key": "banana", + "value": "🍌" + }, + { + "key": "watermelon", + "value": "🍉" + }, + { + "key": "grapes", + "value": "🍇" + }, + { + "key": "strawberry", + "value": "🍓" + }, + { + "key": "melon", + "value": "🍈" + }, + { + "key": "cherry", + "value": "🍒" + }, + { + "key": "peach", + "value": "🍑" + }, + { + "key": "pineapple", + "value": "🍍" + }, + { + "key": "tomato", + "value": "🍅" + }, + { + "key": "egg_plant", + "value": "🍆" + }, + { + "key": "hot_pepper", + "value": "🌶" + }, + { + "key": "ear_of_maize", + "value": "🌽" + }, + { + "key": "roasted_sweet_potato", + "value": "🍠" + }, + { + "key": "honey_pot", + "value": "🍯" + }, + { + "key": "bread", + "value": "🍞" + }, + { + "key": "cheese", + "value": "🧀" + }, + { + "key": "poultry_leg", + "value": "🍗" + }, + { + "key": "meat_on_bone", + "value": "🍖" + }, + { + "key": "fried_shrimp", + "value": "🍤" + }, + { + "key": "cooking", + "value": "🍳" + }, + { + "key": "hamburger", + "value": "🍔" + }, + { + "key": "french_fries", + "value": "🍟" + }, + { + "key": "hot_dog", + "value": "🌭" + }, + { + "key": "slice_of_pizza", + "value": "🍕" + }, + { + "key": "spaghetti", + "value": "🍝" + }, + { + "key": "taco", + "value": "🌮" + }, + { + "key": "burrito", + "value": "🌯" + }, + { + "key": "steaming_bowl", + "value": "🍜" + }, + { + "key": "pot_of_food", + "value": "🍲" + }, + { + "key": "fish_cake", + "value": "🍥" + }, + { + "key": "sushi", + "value": "🍣" + }, + { + "key": "bento_box", + "value": "🍱" + }, + { + "key": "curry_and_rice", + "value": "🍛" + }, + { + "key": "rice_ball", + "value": "🍙" + }, + { + "key": "cooked_rice", + "value": "🍚" + }, + { + "key": "rice_cracker", + "value": "🍘" + }, + { + "key": "oden", + "value": "🍢" + }, + { + "key": "dango", + "value": "🍡" + }, + { + "key": "shaved_ice", + "value": "🍧" + }, + { + "key": "ice_cream", + "value": "🍨" + }, + { + "key": "soft_ice_cream", + "value": "🍦" + }, + { + "key": "short_cake", + "value": "🍰" + }, + { + "key": "birthday_cake", + "value": "🎂" + }, + { + "key": "custard", + "value": "🍮" + }, + { + "key": "candy", + "value": "🍬" + }, + { + "key": "lollipop", + "value": "🍭" + }, + { + "key": "chocolate_bar", + "value": "🍫" + }, + { + "key": "popcorn", + "value": "🍿" + }, + { + "key": "doughnut", + "value": "🍩" + }, + { + "key": "cookie", + "value": "🍪" + }, + { + "key": "bear_mug", + "value": "🍺" + }, + { + "key": "clinking_beer_mugs", + "value": "🍻" + }, + { + "key": "wine_glass", + "value": "🍷" + }, + { + "key": "cocktail_glass", + "value": "🍸" + }, + { + "key": "tropical_drink", + "value": "🍹" + }, + { + "key": "bottle_with_popping_cork", + "value": "🍾" + }, + { + "key": "sake_bottle_and_cup", + "value": "🍶" + }, + { + "key": "tea_cup_without_handle", + "value": "🍵" + }, + { + "key": "hot_beverage", + "value": "☕" + }, + { + "key": "baby_bottle", + "value": "🍼" + }, + { + "key": "fork_and_knife", + "value": "🍴" + }, + { + "key": "fork_and_knife_with_plate", + "value": "🍽" + } + ] + } +} \ No newline at end of file diff --git a/nikola/plugins/shortcode/emoji/data/LICENSE b/nikola/plugins/shortcode/emoji/data/LICENSE new file mode 100644 index 0000000..c7bf1f4 --- /dev/null +++ b/nikola/plugins/shortcode/emoji/data/LICENSE @@ -0,0 +1,25 @@ +The MIT License (MIT) + +Copyright (c) 2016 -2017 Shayan Rais + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +------------ + +Copied from https://github.com/shanraisshan/EmojiCodeSheet diff --git a/nikola/plugins/shortcode/emoji/data/Nature.json b/nikola/plugins/shortcode/emoji/data/Nature.json new file mode 100644 index 0000000..f845a64 --- /dev/null +++ b/nikola/plugins/shortcode/emoji/data/Nature.json @@ -0,0 +1,594 @@ +{ + "natures": { + "nature": [ + { + "key": "dog_face", + "value": "🐶" + }, + { + "key": "cat_face", + "value": "🐱" + }, + { + "key": "mouse_face", + "value": "🐭" + }, + { + "key": "hamster_face", + "value": "🐹" + }, + { + "key": "rabbit_face", + "value": "🐰" + }, + { + "key": "bear_face", + "value": "🐻" + }, + { + "key": "panda_face", + "value": "🐼" + }, + { + "key": "koala_face", + "value": "🐨" + }, + { + "key": "lion_face", + "value": "🦁" + }, + { + "key": "cow_face", + "value": "🐮" + }, + { + "key": "pig_face", + "value": "🐷" + }, + { + "key": "pig_nose", + "value": "🐽" + }, + { + "key": "frog_face", + "value": "🐸" + }, + { + "key": "octopus", + "value": "🐙" + }, + { + "key": "monkey_face", + "value": "🐵" + }, + { + "key": "tiger_face", + "value": "🐯" + }, + { + "key": "see_no_evil_monkey", + "value": "🙈" + }, + { + "key": "hear_no_evil_monkey", + "value": "🙉" + }, + { + "key": "speak_no_evil_monkey", + "value": "🙊" + }, + { + "key": "monkey", + "value": "🐒" + }, + { + "key": "chicken", + "value": "🐔" + }, + { + "key": "penguin", + "value": "🐧" + }, + { + "key": "bird", + "value": "🐦" + }, + { + "key": "baby_chick", + "value": "🐤" + }, + { + "key": "hatching_chick", + "value": "🐣" + }, + { + "key": "front_face_chick", + "value": "🐥" + }, + { + "key": "wolf_face", + "value": "🐺" + }, + { + "key": "boar", + "value": "🐗" + }, + { + "key": "horse_face", + "value": "🐴" + }, + { + "key": "unicorn_face", + "value": "🦄" + }, + { + "key": "honey_bee", + "value": "🐝" + }, + { + "key": "bug", + "value": "🐛" + }, + { + "key": "snail", + "value": "🐌" + }, + { + "key": "lady_beetle", + "value": "🐞" + }, + { + "key": "ant", + "value": "🐜" + }, + { + "key": "spider", + "value": "🕷" + }, + { + "key": "scorpion", + "value": "🦂" + }, + { + "key": "crab", + "value": "🦀" + }, + { + "key": "snake", + "value": "🐍" + }, + { + "key": "turtle", + "value": "🐢" + }, + { + "key": "tropical_fish", + "value": "🐠" + }, + { + "key": "fish", + "value": "🐟" + }, + { + "key": "blow_fish", + "value": "🐡" + }, + { + "key": "dolphin", + "value": "🐬" + }, + { + "key": "spouting_whale", + "value": "🐳" + }, + { + "key": "whale", + "value": "🐋" + }, + { + "key": "crocodile", + "value": "🐊" + }, + { + "key": "leopard", + "value": "🐆" + }, + { + "key": "tiger", + "value": "🐅" + }, + { + "key": "water_buffalo", + "value": "🐃" + }, + { + "key": "ox", + "value": "🐂" + }, + { + "key": "cow", + "value": "🐄" + }, + { + "key": "dromedary_camel", + "value": "🐪" + }, + { + "key": "bactrian_camel", + "value": "🐫" + }, + { + "key": "elephant", + "value": "🐘" + }, + { + "key": "goat", + "value": "🐐" + }, + { + "key": "ram", + "value": "🐏" + }, + { + "key": "sheep", + "value": "🐑" + }, + { + "key": "horse", + "value": "🐎" + }, + { + "key": "pig", + "value": "🐖" + }, + { + "key": "rat", + "value": "🐀" + }, + { + "key": "mouse", + "value": "🐁" + }, + { + "key": "rooster", + "value": "🐓" + }, + { + "key": "turkey", + "value": "🦃" + }, + { + "key": "dove", + "value": "🕊" + }, + { + "key": "dog", + "value": "🐕" + }, + { + "key": "poodle", + "value": "🐩" + }, + { + "key": "cat", + "value": "🐈" + }, + { + "key": "rabbit", + "value": "🐇" + }, + { + "key": "chipmunk", + "value": "🐿" + }, + { + "key": "paw_prints", + "value": "🐾" + }, + { + "key": "dragon", + "value": "🐉" + }, + { + "key": "dragon_face", + "value": "🐲" + }, + { + "key": "cactus", + "value": "🌵" + }, + { + "key": "christmas_tree", + "value": "🎄" + }, + { + "key": "ever_green_tree", + "value": "🌲" + }, + { + "key": "deciduous_tree", + "value": "🌳" + }, + { + "key": "palm_tree", + "value": "🌴" + }, + { + "key": "seedling", + "value": "🌱" + }, + { + "key": "herb", + "value": "🌿" + }, + { + "key": "shamrock", + "value": "☘" + }, + { + "key": "four_leaf", + "value": "🍀" + }, + { + "key": "pine_decoration", + "value": "🎍" + }, + { + "key": "tanabata_tree", + "value": "🎋" + }, + { + "key": "leaf_wind", + "value": "🍃" + }, + { + "key": "fallen_leaf", + "value": "🍂" + }, + { + "key": "maple_leaf", + "value": "🍁" + }, + { + "key": "ear_of_rice", + "value": "🌾" + }, + { + "key": "hibiscus", + "value": "🌺" + }, + { + "key": "sunflower", + "value": "🌻" + }, + { + "key": "rose", + "value": "🌹" + }, + { + "key": "tulip", + "value": "🌷" + }, + { + "key": "blossom", + "value": "🌼" + }, + { + "key": "cherry_blossom", + "value": "🌸" + }, + { + "key": "bouquet", + "value": "💐" + }, + { + "key": "mushroom", + "value": "🍄" + }, + { + "key": "chestnut", + "value": "🌰" + }, + { + "key": "jack_o_lantern", + "value": "🎃" + }, + { + "key": "spiral_shell", + "value": "🐚" + }, + { + "key": "spider_web", + "value": "🕸" + }, + { + "key": "earth_america", + "value": "🌎" + }, + { + "key": "earth_europe", + "value": "🌍" + }, + { + "key": "earth_australia", + "value": "🌏" + }, + { + "key": "full_moon", + "value": "🌕" + }, + { + "key": "waning_gibbous_moon", + "value": "🌖" + }, + { + "key": "last_quarter_moon", + "value": "🌗" + }, + { + "key": "waning_crescent_moon", + "value": "🌘" + }, + { + "key": "new_moon_symbol", + "value": "🌑" + }, + { + "key": "waxing_crescent_moon", + "value": "🌒" + }, + { + "key": "first_quarter_moon", + "value": "🌓" + }, + { + "key": "waxing_gibbous_moon", + "value": "🌔" + }, + { + "key": "new_moon_with_face", + "value": "🌚" + }, + { + "key": "full_moon_face", + "value": "🌝" + }, + { + "key": "first_quarter_moon_face", + "value": "🌛" + }, + { + "key": "last_quarter_moon_face", + "value": "🌜" + }, + { + "key": "sun_face", + "value": "🌞" + }, + { + "key": "crescent_moon", + "value": "🌙" + }, + { + "key": "white_star", + "value": "⭐" + }, + { + "key": "glowing_star", + "value": "🌟" + }, + { + "key": "dizzy_symbol", + "value": "💫" + }, + { + "key": "sparkles", + "value": "✨" + }, + { + "key": "comet", + "value": "☄" + }, + { + "key": "black_sun_with_rays", + "value": "☀" + }, + { + "key": "white_sun_small_cloud", + "value": "🌤" + }, + { + "key": "sun_behind_cloud", + "value": "⛅" + }, + { + "key": "white_sun_behind_cloud", + "value": "🌥" + }, + { + "key": "white_sun_behind_cloud_rain", + "value": "🌦" + }, + { + "key": "cloud", + "value": "☁" + }, + { + "key": "cloud_with_rain", + "value": "🌧" + }, + { + "key": "thunder_cloud_rain", + "value": "⛈" + }, + { + "key": "cloud_lightening", + "value": "🌩" + }, + { + "key": "high_voltage", + "value": "⚡" + }, + { + "key": "fire", + "value": "🔥" + }, + { + "key": "collision", + "value": "💥" + }, + { + "key": "snow_flake", + "value": "❄" + }, + { + "key": "cloud_with_snow", + "value": "🌨" + }, + { + "key": "snowman", + "value": "☃" + }, + { + "key": "snowman_without_snow", + "value": "⛄" + }, + { + "key": "wind_blowing_face", + "value": "🌬" + }, + { + "key": "dash_symbol", + "value": "💨" + }, + { + "key": "cloud_with_tornado", + "value": "🌪" + }, + { + "key": "fog", + "value": "🌫" + }, + { + "key": "umbrella", + "value": "☂" + }, + { + "key": "umbrella_with_rain_drops", + "value": "☔" + }, + { + "key": "droplet", + "value": "💧" + }, + { + "key": "splashing_sweat", + "value": "💦" + }, + { + "key": "water_wave", + "value": "🌊" + } + ] + } +} \ No newline at end of file diff --git a/nikola/plugins/shortcode/emoji/data/Objects.json b/nikola/plugins/shortcode/emoji/data/Objects.json new file mode 100644 index 0000000..5f13056 --- /dev/null +++ b/nikola/plugins/shortcode/emoji/data/Objects.json @@ -0,0 +1,718 @@ +{ + "objects": { + "object": [ + { + "key": "watch", + "value": "⌚" + }, + { + "key": "mobile_phone", + "value": "📱" + }, + { + "key": "mobile_phone_with_right_arrow", + "value": "📲" + }, + { + "key": "personal_computer", + "value": "💻" + }, + { + "key": "keyboard", + "value": "⌨" + }, + { + "key": "desktop_computer", + "value": "🖥" + }, + { + "key": "printer", + "value": "🖨" + }, + { + "key": "three_button_mouse", + "value": "🖱" + }, + { + "key": "track_ball", + "value": "🖲" + }, + { + "key": "joystick", + "value": "🕹" + }, + { + "key": "compression", + "value": "🗜" + }, + { + "key": "mini_disc", + "value": "💽" + }, + { + "key": "floppy_disk", + "value": "💾" + }, + { + "key": "optical_disc", + "value": "💿" + }, + { + "key": "dvd", + "value": "📀" + }, + { + "key": "video_cassette", + "value": "📼" + }, + { + "key": "camera", + "value": "📷" + }, + { + "key": "camera_with_flash", + "value": "📸" + }, + { + "key": "video_camera", + "value": "📹" + }, + { + "key": "movie_camera", + "value": "🎥" + }, + { + "key": "film_projector", + "value": "📽" + }, + { + "key": "film_frames", + "value": "🎞" + }, + { + "key": "telephone_receiver", + "value": "📞" + }, + { + "key": "black_telephone", + "value": "☎" + }, + { + "key": "pager", + "value": "📟" + }, + { + "key": "fax_machine", + "value": "📠" + }, + { + "key": "television", + "value": "📺" + }, + { + "key": "radio", + "value": "📻" + }, + { + "key": "studio_microphone", + "value": "🎙" + }, + { + "key": "level_slider", + "value": "🎚" + }, + { + "key": "control_knobs", + "value": "🎛" + }, + { + "key": "stop_watch", + "value": "⏱" + }, + { + "key": "timer_clock", + "value": "⏲" + }, + { + "key": "alarm_clock", + "value": "⏰" + }, + { + "key": "mantel_piece_clock", + "value": "🕰" + }, + { + "key": "hour_glass_with_flowing_stand", + "value": "⏳" + }, + { + "key": "hour_glass", + "value": "⌛" + }, + { + "key": "satellite_antenna", + "value": "📡" + }, + { + "key": "battery", + "value": "🔋" + }, + { + "key": "electric_plug", + "value": "🔌" + }, + { + "key": "electric_light_bulb", + "value": "💡" + }, + { + "key": "electric_torch", + "value": "🔦" + }, + { + "key": "candle", + "value": "🕯" + }, + { + "key": "waste_basket", + "value": "🗑" + }, + { + "key": "oil_drum", + "value": "🛢" + }, + { + "key": "money_with_wings", + "value": "💸" + }, + { + "key": "bank_note_with_dollar_sign", + "value": "💵" + }, + { + "key": "bank_note_with_yen_sign", + "value": "💴" + }, + { + "key": "bank_note_with_euro_sign", + "value": "💶" + }, + { + "key": "bank_note_with_pounds_sign", + "value": "💷" + }, + { + "key": "money_bag", + "value": "💰" + }, + { + "key": "credit_card", + "value": "💳" + }, + { + "key": "gem_stone", + "value": "💎" + }, + { + "key": "scales", + "value": "⚖" + }, + { + "key": "wrench", + "value": "🔧" + }, + { + "key": "hammer", + "value": "🔨" + }, + { + "key": "hammer_and_pick", + "value": "⚒" + }, + { + "key": "hammer_and_wrench", + "value": "🛠" + }, + { + "key": "pick", + "value": "⛏" + }, + { + "key": "nut_and_bolt", + "value": "🔩" + }, + { + "key": "gear", + "value": "⚙" + }, + { + "key": "chains", + "value": "⛓" + }, + { + "key": "pistol", + "value": "🔫" + }, + { + "key": "bomb", + "value": "💣" + }, + { + "key": "hocho", + "value": "🔪" + }, + { + "key": "dagger_knife", + "value": "🗡" + }, + { + "key": "crossed_words", + "value": "⚔" + }, + { + "key": "shield", + "value": "🛡" + }, + { + "key": "smoking_symbol", + "value": "🚬" + }, + { + "key": "skull_and_cross_bones", + "value": "☠" + }, + { + "key": "coffin", + "value": "⚰" + }, + { + "key": "funeral_urn", + "value": "⚱" + }, + { + "key": "amphora", + "value": "🏺" + }, + { + "key": "crystal_ball", + "value": "🔮" + }, + { + "key": "prayer_beads", + "value": "📿" + }, + { + "key": "barber_pole", + "value": "💈" + }, + { + "key": "alembic", + "value": "⚗" + }, + { + "key": "telescope", + "value": "🔭" + }, + { + "key": "microscope", + "value": "🔬" + }, + { + "key": "hole", + "value": "🕳" + }, + { + "key": "pill", + "value": "💊" + }, + { + "key": "syringe", + "value": "💉" + }, + { + "key": "thermometer", + "value": "🌡" + }, + { + "key": "label", + "value": "🏷" + }, + { + "key": "bookmark", + "value": "🔖" + }, + { + "key": "toilet", + "value": "🚽" + }, + { + "key": "shower", + "value": "🚿" + }, + { + "key": "bath_tub", + "value": "🛁" + }, + { + "key": "key", + "value": "🔑" + }, + { + "key": "old_key", + "value": "🗝" + }, + { + "key": "couch_and_lamp", + "value": "🛋" + }, + { + "key": "sleeping_accommodation", + "value": "🛌" + }, + { + "key": "bed", + "value": "🛏" + }, + { + "key": "door", + "value": "🚪" + }, + { + "key": "bell_hop_bell", + "value": "🛎" + }, + { + "key": "frame_with_picture", + "value": "🖼" + }, + { + "key": "world_map", + "value": "🗺" + }, + { + "key": "umbrella_on_ground", + "value": "⛱" + }, + { + "key": "moyai", + "value": "🗿" + }, + { + "key": "shopping_bags", + "value": "🛍" + }, + { + "key": "balloon", + "value": "🎈" + }, + { + "key": "carp_streamer", + "value": "🎏" + }, + { + "key": "ribbon", + "value": "🎀" + }, + { + "key": "wrapped_present", + "value": "🎁" + }, + { + "key": "confetti_ball", + "value": "🎊" + }, + { + "key": "party_popper", + "value": "🎉" + }, + { + "key": "japanese_dolls", + "value": "🎎" + }, + { + "key": "wind_chime", + "value": "🎐" + }, + { + "key": "crossed_flags", + "value": "🎌" + }, + { + "key": "izakaya_lantern", + "value": "🏮" + }, + { + "key": "envelope", + "value": "✉" + }, + { + "key": "envelope_with_down_arrow", + "value": "📩" + }, + { + "key": "incoming_envelope", + "value": "📨" + }, + { + "key": "email_symbol", + "value": "📧" + }, + { + "key": "love_letter", + "value": "💌" + }, + { + "key": "post_box", + "value": "📮" + }, + { + "key": "closed_mail_box_with_lowered_flag", + "value": "📪" + }, + { + "key": "closed_mail_box_with_raised_flag", + "value": "📫" + }, + { + "key": "open_mail_box_with_raised_flag", + "value": "📬" + }, + { + "key": "open_mail_box_with_lowered_flag", + "value": "📭" + }, + { + "key": "package", + "value": "📦" + }, + { + "key": "postal_horn", + "value": "📯" + }, + { + "key": "inbox_tray", + "value": "📥" + }, + { + "key": "outbox_tray", + "value": "📤" + }, + { + "key": "scroll", + "value": "📜" + }, + { + "key": "page_with_curl", + "value": "📃" + }, + { + "key": "bookmark_tabs", + "value": "📑" + }, + { + "key": "bar_chart", + "value": "📊" + }, + { + "key": "chart_with_upwards_trend", + "value": "📈" + }, + { + "key": "chart_with_downwards_trend", + "value": "📉" + }, + { + "key": "page_facing_up", + "value": "📄" + }, + { + "key": "calender", + "value": "📅" + }, + { + "key": "tear_off_calendar", + "value": "📆" + }, + { + "key": "spiral_calendar_pad", + "value": "🗓" + }, + { + "key": "card_index", + "value": "📇" + }, + { + "key": "card_file_box", + "value": "🗃" + }, + { + "key": "ballot_box_with_ballot", + "value": "🗳" + }, + { + "key": "file_cabinet", + "value": "🗄" + }, + { + "key": "clip_board", + "value": "📋" + }, + { + "key": "spiral_notepad", + "value": "🗒" + }, + { + "key": "file_folder", + "value": "📁" + }, + { + "key": "open_file_folder", + "value": "📂" + }, + { + "key": "card_index_dividers", + "value": "🗂" + }, + { + "key": "rolled_up_newspaper", + "value": "🗞" + }, + { + "key": "newspaper", + "value": "📰" + }, + { + "key": "notebook", + "value": "📓" + }, + { + "key": "closed_book", + "value": "📕" + }, + { + "key": "green_book", + "value": "📗" + }, + { + "key": "blue_book", + "value": "📘" + }, + { + "key": "orange_book", + "value": "📙" + }, + { + "key": "notebook_with_decorative_cover", + "value": "📔" + }, + { + "key": "ledger", + "value": "📒" + }, + { + "key": "books", + "value": "📚" + }, + { + "key": "open_book", + "value": "📖" + }, + { + "key": "link_symbol", + "value": "🔗" + }, + { + "key": "paper_clip", + "value": "📎" + }, + { + "key": "linked_paper_clips", + "value": "🖇" + }, + { + "key": "black_scissors", + "value": "✂" + }, + { + "key": "triangular_ruler", + "value": "📐" + }, + { + "key": "straight_ruler", + "value": "📏" + }, + { + "key": "pushpin", + "value": "📌" + }, + { + "key": "round_pushpin", + "value": "📍" + }, + { + "key": "triangular_flag_post", + "value": "🚩" + }, + { + "key": "waving_white_flag", + "value": "🏳" + }, + { + "key": "waving_black_flag", + "value": "🏴" + }, + { + "key": "closed_lock_with_key", + "value": "🔐" + }, + { + "key": "lock", + "value": "🔒" + }, + { + "key": "open_lock", + "value": "🔓" + }, + { + "key": "lock_with_ink_pen", + "value": "🔏" + }, + { + "key": "lower_left_ball_point_pen", + "value": "🖊" + }, + { + "key": "lower_left_fountain_pen", + "value": "🖋" + }, + { + "key": "black_nib", + "value": "✒" + }, + { + "key": "memo", + "value": "📝" + }, + { + "key": "pencil", + "value": "✏" + }, + { + "key": "lower_left_crayon", + "value": "🖍" + }, + { + "key": "lower_left_paint_brush", + "value": "🖌" + }, + { + "key": "left_pointing_magnifying_glass", + "value": "🔍" + }, + { + "key": "right_pointing_magnifying_glass", + "value": "🔎" + } + ] + } +} \ No newline at end of file diff --git a/nikola/plugins/shortcode/emoji/data/People.json b/nikola/plugins/shortcode/emoji/data/People.json new file mode 100644 index 0000000..a5fb88f --- /dev/null +++ b/nikola/plugins/shortcode/emoji/data/People.json @@ -0,0 +1,1922 @@ +{ + "peoples": { + "people": [ + { + "key": "grinning_face", + "value": "😀" + }, + { + "key": "grimacing_face", + "value": "😬" + }, + { + "key": "grimacing_face_with_smile_eyes", + "value": "😁" + }, + { + "key": "face_with_tear_of_joy", + "value": "😂" + }, + { + "key": "smiling_face_with_open_mouth", + "value": "😃" + }, + { + "key": "smiling_face_with_open_mouth_eyes", + "value": "😄" + }, + { + "key": "smiling_face_with_open_mouth_cold_sweat", + "value": "😅" + }, + { + "key": "smiling_face_with_open_mouth_hand_tight", + "value": "😆" + }, + { + "key": "smiling_face_with_halo", + "value": "😇" + }, + { + "key": "winking_face", + "value": "😉" + }, + { + "key": "black_smiling_face", + "value": "😊" + }, + { + "key": "slightly_smiling_face", + "value": "🙂" + }, + { + "key": "upside_down_face", + "value": "🙃" + }, + { + "key": "white_smiling_face", + "value": "☺" + }, + { + "key": "face_savouring_delicious_food", + "value": "😋" + }, + { + "key": "relieved_face", + "value": "😌" + }, + { + "key": "smiling_face_heart_eyes", + "value": "😍" + }, + { + "key": "face_throwing_kiss", + "value": "😘" + }, + { + "key": "kissing_face", + "value": "😗" + }, + { + "key": "kissing_face_with_smile_eyes", + "value": "😙" + }, + { + "key": "kissing_face_with_closed_eyes", + "value": "😚" + }, + { + "key": "face_with_tongue_wink_eye", + "value": "😜" + }, + { + "key": "face_with_tongue_closed_eye", + "value": "😝" + }, + { + "key": "face_with_stuck_out_tongue", + "value": "😛" + }, + { + "key": "money_mouth_face", + "value": "🤑" + }, + { + "key": "nerd_face", + "value": "🤓" + }, + { + "key": "smiling_face_with_sun_glass", + "value": "😎" + }, + { + "key": "hugging_face", + "value": "🤗" + }, + { + "key": "smirking_face", + "value": "😏" + }, + { + "key": "face_without_mouth", + "value": "😶" + }, + { + "key": "neutral_face", + "value": "😐" + }, + { + "key": "expressionless_face", + "value": "😑" + }, + { + "key": "unamused_face", + "value": "😒" + }, + { + "key": "face_with_rolling_eyes", + "value": "🙄" + }, + { + "key": "thinking_face", + "value": "🤔" + }, + { + "key": "flushed_face", + "value": "😳" + }, + { + "key": "disappointed_face", + "value": "😞" + }, + { + "key": "worried_face", + "value": "😟" + }, + { + "key": "angry_face", + "value": "😠" + }, + { + "key": "pouting_face", + "value": "😡" + }, + { + "key": "pensive_face", + "value": "😔" + }, + { + "key": "confused_face", + "value": "😕" + }, + { + "key": "slightly_frowning_face", + "value": "🙁" + }, + { + "key": "white_frowning_face", + "value": "☹" + }, + { + "key": "persevering_face", + "value": "😣" + }, + { + "key": "confounded_face", + "value": "😖" + }, + { + "key": "tired_face", + "value": "😫" + }, + { + "key": "weary_face", + "value": "😩" + }, + { + "key": "face_with_look_of_triumph", + "value": "😤" + }, + { + "key": "face_with_open_mouth", + "value": "😮" + }, + { + "key": "face_screaming_in_fear", + "value": "😱" + }, + { + "key": "fearful_face", + "value": "😨" + }, + { + "key": "face_with_open_mouth_cold_sweat", + "value": "😰" + }, + { + "key": "hushed_face", + "value": "😯" + }, + { + "key": "frowning_face_with_open_mouth", + "value": "😦" + }, + { + "key": "anguished_face", + "value": "😧" + }, + { + "key": "crying_face", + "value": "😢" + }, + { + "key": "disappointed_but_relieved_face", + "value": "😥" + }, + { + "key": "sleepy_face", + "value": "😪" + }, + { + "key": "face_with_cold_sweat", + "value": "😓" + }, + { + "key": "loudly_crying_face", + "value": "😭" + }, + { + "key": "dizzy_face", + "value": "😵" + }, + { + "key": "astonished_face", + "value": "😲" + }, + { + "key": "zipper_mouth_face", + "value": "🤐" + }, + { + "key": "face_with_medical_mask", + "value": "😷" + }, + { + "key": "face_with_thermometer", + "value": "🤒" + }, + { + "key": "face_with_head_bandage", + "value": "🤕" + }, + { + "key": "sleeping_face", + "value": "😴" + }, + { + "key": "sleeping_symbol", + "value": "💤" + }, + { + "key": "pile_of_poo", + "value": "💩" + }, + { + "key": "smiling_face_with_horns", + "value": "😈" + }, + { + "key": "imp", + "value": "👿" + }, + { + "key": "japanese_ogre", + "value": "👹" + }, + { + "key": "japanese_goblin", + "value": "👺" + }, + { + "key": "skull", + "value": "💀" + }, + { + "key": "ghost", + "value": "👻" + }, + { + "key": "extra_terrestrial_alien", + "value": "👽" + }, + { + "key": "robot_face", + "value": "🤖" + }, + { + "key": "smiling_cat_face_open_mouth", + "value": "😺" + }, + { + "key": "grinning_cat_face_smile_eyes", + "value": "😸" + }, + { + "key": "cat_face_tears_of_joy", + "value": "😹" + }, + { + "key": "smiling_cat_face_heart_shaped_eyes", + "value": "😻" + }, + { + "key": "cat_face_wry_smile", + "value": "😼" + }, + { + "key": "kissing_cat_face_closed_eyes", + "value": "😽" + }, + { + "key": "weary_cat_face", + "value": "🙀" + }, + { + "key": "crying_cat_face", + "value": "😿" + }, + { + "key": "pouting_cat_face", + "value": "😾" + }, + { + "key": "person_both_hand_celebration", + "value": "🙌" + }, + { + "key": "person_both_hand_celebration_type_1_2", + "value": "🙌🏻" + }, + { + "key": "person_both_hand_celebration_type_3", + "value": "🙌🏼" + }, + { + "key": "person_both_hand_celebration_type_4", + "value": "🙌🏽" + }, + { + "key": "person_both_hand_celebration_type_5", + "value": "🙌🏾" + }, + { + "key": "person_both_hand_celebration_type_6", + "value": "🙌🏿" + }, + { + "key": "clapping_hand", + "value": "👏" + }, + { + "key": "clapping_hand_type_1_2", + "value": "👏🏼" + }, + { + "key": "clapping_hand_type_3", + "value": "👏🏼" + }, + { + "key": "clapping_hand_type_4", + "value": "👏🏽" + }, + { + "key": "clapping_hand_type_5", + "value": "👏🏾" + }, + { + "key": "clapping_hand_type_6", + "value": "👏🏿" + }, + { + "key": "waving_hands", + "value": "👋" + }, + { + "key": "waving_hands_type_1_2", + "value": "👋🏻" + }, + { + "key": "waving_hands_type_3", + "value": "👋🏼" + }, + { + "key": "waving_hands_type_4", + "value": "👋🏽" + }, + { + "key": "waving_hands_type_5", + "value": "👋🏾" + }, + { + "key": "waving_hands_type_6", + "value": "👋🏿" + }, + { + "key": "thumbs_up", + "value": "👍" + }, + { + "key": "thumbs_up_type_1_2", + "value": "👍🏻" + }, + { + "key": "thumbs_up_type_3", + "value": "👍🏼" + }, + { + "key": "thumbs_up_type_4", + "value": "👍🏽" + }, + { + "key": "thumbs_up_type_5", + "value": "👍🏾" + }, + { + "key": "thumbs_up_type_6", + "value": "👍🏿" + }, + { + "key": "thumbs_down", + "value": "👎" + }, + { + "key": "thumbs_down_type_1_2", + "value": "👎🏻" + }, + { + "key": "thumbs_down_type_3", + "value": "👎🏼" + }, + { + "key": "thumbs_down_type_4", + "value": "👎🏽" + }, + { + "key": "thumbs_down_type_5", + "value": "👎🏾" + }, + { + "key": "thumbs_down_type_6", + "value": "👎🏿" + }, + { + "key": "fist_hand", + "value": "👊" + }, + { + "key": "fist_hand_type_1_2", + "value": "👊🏻" + }, + { + "key": "fist_hand_type_3", + "value": "👊🏼" + }, + { + "key": "fist_hand_type_4", + "value": "👊🏽" + }, + { + "key": "fist_hand_type_5", + "value": "👊🏾" + }, + { + "key": "fist_hand_type_6", + "value": "👊🏿" + }, + { + "key": "raised_fist", + "value": "✊" + }, + { + "key": "raised_fist_type_1_2", + "value": "✊🏻" + }, + { + "key": "raised_fist_type_3", + "value": "✊🏼" + }, + { + "key": "raised_fist_type_4", + "value": "✊🏽" + }, + { + "key": "raised_fist_type_5", + "value": "✊🏾" + }, + { + "key": "raised_fist_type_6", + "value": "✊🏿" + }, + { + "key": "victory_hand", + "value": "✌" + }, + { + "key": "victory_hand_type_1_2", + "value": "✌🏻" + }, + { + "key": "victory_hand_type_3", + "value": "✌🏼" + }, + { + "key": "victory_hand_type_4", + "value": "✌🏽" + }, + { + "key": "victory_hand_type_5", + "value": "✌🏾" + }, + { + "key": "victory_hand_type_6", + "value": "✌🏿" + }, + { + "key": "ok_hand", + "value": "👌" + }, + { + "key": "ok_hand_type_1_2", + "value": "👌🏻" + }, + { + "key": "ok_hand_type_3", + "value": "👌🏼" + }, + { + "key": "ok_hand_type_4", + "value": "👌🏽" + }, + { + "key": "ok_hand_type_5", + "value": "👌🏾" + }, + { + "key": "ok_hand_type_6", + "value": "👌🏿" + }, + { + "key": "raised_hand", + "value": "✋" + }, + { + "key": "raised_hand_type_1_2", + "value": "✋🏻" + }, + { + "key": "raised_hand_type_3", + "value": "✋🏼" + }, + { + "key": "raised_hand_type_4", + "value": "✋🏽" + }, + { + "key": "raised_hand_type_5", + "value": "✋🏾" + }, + { + "key": "raised_hand_type_6", + "value": "✋🏿" + }, + { + "key": "open_hand", + "value": "👐" + }, + { + "key": "open_hand_type_1_2", + "value": "👐🏻" + }, + { + "key": "open_hand_type_3", + "value": "👐🏼" + }, + { + "key": "open_hand_type_4", + "value": "👐🏽" + }, + { + "key": "open_hand_type_5", + "value": "👐🏾" + }, + { + "key": "open_hand_type_6", + "value": "👐🏿" + }, + { + "key": "flexed_biceps", + "value": "💪" + }, + { + "key": "flexed_biceps_type_1_2", + "value": "💪🏻" + }, + { + "key": "flexed_biceps_type_3", + "value": "💪🏼" + }, + { + "key": "flexed_biceps_type_4", + "value": "💪🏽" + }, + { + "key": "flexed_biceps_type_5", + "value": "💪🏾" + }, + { + "key": "flexed_biceps_type_6", + "value": "💪🏿" + }, + { + "key": "folded_hands", + "value": "🙏" + }, + { + "key": "folded_hands_type_1_2", + "value": "🙏🏻" + }, + { + "key": "folded_hands_type_3", + "value": "🙏🏼" + }, + { + "key": "folded_hands_type_4", + "value": "🙏🏽" + }, + { + "key": "folded_hands_type_5", + "value": "🙏🏾" + }, + { + "key": "folded_hands_type_6", + "value": "🙏🏿" + }, + { + "key": "up_pointing_index", + "value": "☝" + }, + { + "key": "up_pointing_index_type_1_2", + "value": "☝🏻" + }, + { + "key": "up_pointing_index_type_3", + "value": "☝🏼" + }, + { + "key": "up_pointing_index_type_4", + "value": "☝🏽" + }, + { + "key": "up_pointing_index_type_5", + "value": "☝🏾" + }, + { + "key": "up_pointing_index_type_6", + "value": "☝🏿" + }, + { + "key": "up_pointing_backhand_index", + "value": "👆" + }, + { + "key": "up_pointing_backhand_index_type_1_2", + "value": "👆🏻" + }, + { + "key": "up_pointing_backhand_index_type_3", + "value": "👆🏼" + }, + { + "key": "up_pointing_backhand_index_type_4", + "value": "👆🏽" + }, + { + "key": "up_pointing_backhand_index_type_5", + "value": "👆🏾" + }, + { + "key": "up_pointing_backhand_index_type_6", + "value": "👆🏿" + }, + { + "key": "down_pointing_backhand_index", + "value": "👇" + }, + { + "key": "down_pointing_backhand_index_type_1_2", + "value": "👇🏻" + }, + { + "key": "down_pointing_backhand_index_type_3", + "value": "👇🏼" + }, + { + "key": "down_pointing_backhand_index_type_4", + "value": "👇🏽" + }, + { + "key": "down_pointing_backhand_index_type_5", + "value": "👇🏾" + }, + { + "key": "down_pointing_backhand_index_type_6", + "value": "👇🏿" + }, + { + "key": "left_pointing_backhand_index", + "value": "👈" + }, + { + "key": "left_pointing_backhand_index_type_1_2", + "value": "👈🏻" + }, + { + "key": "left_pointing_backhand_index_type_3", + "value": "👈🏼" + }, + { + "key": "left_pointing_backhand_index_type_4", + "value": "👈🏽" + }, + { + "key": "left_pointing_backhand_index_type_5", + "value": "👈🏾" + }, + { + "key": "left_pointing_backhand_index_type_6", + "value": "👈🏿" + }, + { + "key": "right_pointing_backhand_index", + "value": "👉" + }, + { + "key": "right_pointing_backhand_index_type_1_2", + "value": "👉🏻" + }, + { + "key": "right_pointing_backhand_index_type_3", + "value": "👉🏼" + }, + { + "key": "right_pointing_backhand_index_type_4", + "value": "👉🏽" + }, + { + "key": "right_pointing_backhand_index_type_5", + "value": "👉🏾" + }, + { + "key": "right_pointing_backhand_index_type_6", + "value": "👉🏿" + }, + { + "key": "reverse_middle_finger", + "value": "🖕" + }, + { + "key": "reverse_middle_finger_type_1_2", + "value": "🖕🏻" + }, + { + "key": "reverse_middle_finger_type_3", + "value": "🖕🏼" + }, + { + "key": "reverse_middle_finger_type_4", + "value": "🖕🏽" + }, + { + "key": "reverse_middle_finger_type_5", + "value": "🖕🏾" + }, + { + "key": "reverse_middle_finger_type_6", + "value": "🖕🏿" + }, + { + "key": "raised_hand_fingers_splayed", + "value": "🖐" + }, + { + "key": "raised_hand_fingers_splayed_type_1_2", + "value": "🖐🏻" + }, + { + "key": "raised_hand_fingers_splayed_type_3", + "value": "🖐🏼" + }, + { + "key": "raised_hand_fingers_splayed_type_4", + "value": "🖐🏽" + }, + { + "key": "raised_hand_fingers_splayed_type_5", + "value": "🖐🏾" + }, + { + "key": "raised_hand_fingers_splayed_type_6", + "value": "🖐🏿" + }, + { + "key": "sign_of_horn", + "value": "🤘" + }, + { + "key": "sign_of_horn_type_1_2", + "value": "🤘🏻" + }, + { + "key": "sign_of_horn_type_3", + "value": "🤘🏼" + }, + { + "key": "sign_of_horn_type_4", + "value": "🤘🏽" + }, + { + "key": "sign_of_horn_type_5", + "value": "🤘🏾" + }, + { + "key": "sign_of_horn_type_6", + "value": "🤘🏿" + }, + { + "key": "raised_hand_part_between_middle_ring", + "value": "🖖" + }, + { + "key": "raised_hand_part_between_middle_ring_type_1_2", + "value": "🖖🏻" + }, + { + "key": "raised_hand_part_between_middle_ring_type_3", + "value": "🖖🏼" + }, + { + "key": "raised_hand_part_between_middle_ring_type_4", + "value": "🖖🏽" + }, + { + "key": "raised_hand_part_between_middle_ring_type_5", + "value": "🖖🏾" + }, + { + "key": "raised_hand_part_between_middle_ring_type_6", + "value": "🖖🏿" + }, + { + "key": "writing_hand", + "value": "✍" + }, + { + "key": "writing_hand_type_1_2", + "value": "✍🏻" + }, + { + "key": "writing_hand_type_3", + "value": "✍🏼" + }, + { + "key": "writing_hand_type_4", + "value": "✍🏽" + }, + { + "key": "writing_hand_type_5", + "value": "✍🏾" + }, + { + "key": "writing_hand_type_6", + "value": "✍🏿" + }, + { + "key": "nail_polish", + "value": "💅" + }, + { + "key": "nail_polish_type_1_2", + "value": "💅🏻" + }, + { + "key": "nail_polish_type_3", + "value": "💅🏼" + }, + { + "key": "nail_polish_type_4", + "value": "💅🏽" + }, + { + "key": "nail_polish_type_5", + "value": "💅🏾" + }, + { + "key": "nail_polish_type_6", + "value": "💅🏿" + }, + { + "key": "mouth", + "value": "👄" + }, + { + "key": "tongue", + "value": "👅" + }, + { + "key": "ear", + "value": "👂" + }, + { + "key": "ear_type_1_2", + "value": "👂🏻" + }, + { + "key": "ear_type_3", + "value": "👂🏼" + }, + { + "key": "ear_type_4", + "value": "👂🏽" + }, + { + "key": "ear_type_5", + "value": "👂🏾" + }, + { + "key": "ear_type_6", + "value": "👂🏿" + }, + { + "key": "nose", + "value": "👃" + }, + { + "key": "nose_type_1_2", + "value": "👃🏻" + }, + { + "key": "nose_type_3", + "value": "👃🏼" + }, + { + "key": "nose_type_4", + "value": "👃🏽" + }, + { + "key": "nose_type_5", + "value": "👃🏾" + }, + { + "key": "nose_type_6", + "value": "👃🏿" + }, + { + "key": "eye", + "value": "👁" + }, + { + "key": "eyes", + "value": "👀" + }, + { + "key": "bust_in_silhouette", + "value": "👤" + }, + { + "key": "busts_in_silhouette", + "value": "👥" + }, + { + "key": "speaking_head_in_silhouette", + "value": "🗣" + }, + { + "key": "baby", + "value": "👶" + }, + { + "key": "baby_type_1_2", + "value": "👶🏻" + }, + { + "key": "baby_type_3", + "value": "👶🏼" + }, + { + "key": "baby_type_4", + "value": "👶🏽" + }, + { + "key": "baby_type_5", + "value": "👶🏾" + }, + { + "key": "baby_type_6", + "value": "👶🏿" + }, + { + "key": "boy", + "value": "👦" + }, + { + "key": "boy_type_1_2", + "value": "👦🏻" + }, + { + "key": "boy_type_3", + "value": "👦🏼" + }, + { + "key": "boy_type_4", + "value": "👦🏽" + }, + { + "key": "boy_type_5", + "value": "👦🏾" + }, + { + "key": "boy_type_6", + "value": "👦🏿" + }, + { + "key": "girl", + "value": "👧" + }, + { + "key": "girl_type_1_2", + "value": "👧🏻" + }, + { + "key": "girl_type_3", + "value": "👧🏼" + }, + { + "key": "girl_type_4", + "value": "👧🏽" + }, + { + "key": "girl_type_5", + "value": "👧🏾" + }, + { + "key": "girl_type_6", + "value": "👧🏿" + }, + { + "key": "man", + "value": "👨" + }, + { + "key": "man_type_1_2", + "value": "👨🏻" + }, + { + "key": "man_type_3", + "value": "👨🏼" + }, + { + "key": "man_type_4", + "value": "👨🏽" + }, + { + "key": "man_type_5", + "value": "👨🏾" + }, + { + "key": "man_type_6", + "value": "👨🏿" + }, + { + "key": "women", + "value": "👩" + }, + { + "key": "women_type_1_2", + "value": "👩🏻" + }, + { + "key": "women_type_3", + "value": "👩🏼" + }, + { + "key": "women_type_4", + "value": "👩🏽" + }, + { + "key": "women_type_5", + "value": "👩🏾" + }, + { + "key": "women_type_6", + "value": "👩🏿" + }, + { + "key": "person_with_blond_hair", + "value": "👱" + }, + { + "key": "person_with_blond_hair_type_1_2", + "value": "👱🏻" + }, + { + "key": "person_with_blond_hair_type_3", + "value": "👱🏼" + }, + { + "key": "person_with_blond_hair_type_4", + "value": "👱🏽" + }, + { + "key": "person_with_blond_hair_type_5", + "value": "👱🏾" + }, + { + "key": "person_with_blond_hair_type_6", + "value": "👱🏿" + }, + { + "key": "older_man", + "value": "👴" + }, + { + "key": "older_man_type_1_2", + "value": "👴🏻" + }, + { + "key": "older_man_type_3", + "value": "👴🏼" + }, + { + "key": "older_man_type_4", + "value": "👴🏽" + }, + { + "key": "older_man_type_5", + "value": "👴🏾" + }, + { + "key": "older_man_type_6", + "value": "👴🏿" + }, + { + "key": "older_women", + "value": "👵" + }, + { + "key": "older_women_type_1_2", + "value": "👵🏻" + }, + { + "key": "older_women_type_3", + "value": "👵🏼" + }, + { + "key": "older_women_type_4", + "value": "👵🏽" + }, + { + "key": "older_women_type_5", + "value": "👵🏾" + }, + { + "key": "older_women_type_6", + "value": "👵🏿" + }, + { + "key": "man_with_gua_pi_mao", + "value": "👲" + }, + { + "key": "man_with_gua_pi_mao_type_1_2", + "value": "👲🏼" + }, + { + "key": "man_with_gua_pi_mao_type_3", + "value": "👲🏼" + }, + { + "key": "man_with_gua_pi_mao_type_4", + "value": "👲🏽" + }, + { + "key": "man_with_gua_pi_mao_type_5", + "value": "👲🏾" + }, + { + "key": "man_with_gua_pi_mao_type_6", + "value": "👲🏿" + }, + { + "key": "man_with_turban", + "value": "👳" + }, + { + "key": "man_with_turban_type_1_2", + "value": "👳🏻" + }, + { + "key": "man_with_turban_type_3", + "value": "👳🏼" + }, + { + "key": "man_with_turban_type_4", + "value": "👳🏽" + }, + { + "key": "man_with_turban_type_5", + "value": "👳🏾" + }, + { + "key": "man_with_turban_type_6", + "value": "👳🏿" + }, + { + "key": "police_officer", + "value": "👮" + }, + { + "key": "police_officer_type_1_2", + "value": "👮🏻" + }, + { + "key": "police_officer_type_3", + "value": "👮🏼" + }, + { + "key": "police_officer_type_4", + "value": "👮🏽" + }, + { + "key": "police_officer_type_5", + "value": "👮🏾" + }, + { + "key": "police_officer_type_6", + "value": "👮🏿" + }, + { + "key": "construction_worker", + "value": "👷" + }, + { + "key": "construction_worker_type_1_2", + "value": "👷🏻" + }, + { + "key": "construction_worker_type_3", + "value": "👷🏼" + }, + { + "key": "construction_worker_type_4", + "value": "👷🏽" + }, + { + "key": "construction_worker_type_5", + "value": "👷🏾" + }, + { + "key": "construction_worker_type_6", + "value": "👷🏿" + }, + { + "key": "guards_man", + "value": "💂" + }, + { + "key": "guards_man_type_1_2", + "value": "💂🏻" + }, + { + "key": "guards_man_type_3", + "value": "💂🏼" + }, + { + "key": "guards_man_type_4", + "value": "💂🏽" + }, + { + "key": "guards_man_type_5", + "value": "💂🏾" + }, + { + "key": "guards_man_type_6", + "value": "💂🏿" + }, + { + "key": "spy", + "value": "🕵" + }, + { + "key": "father_christmas", + "value": "🎅" + }, + { + "key": "father_christmas_type_1_2", + "value": "🎅🏻" + }, + { + "key": "father_christmas_type_3", + "value": "🎅🏼" + }, + { + "key": "father_christmas_type_4", + "value": "🎅🏽" + }, + { + "key": "father_christmas_type_5", + "value": "🎅🏾" + }, + { + "key": "father_christmas_type_6", + "value": "🎅🏿" + }, + { + "key": "baby_angel", + "value": "👼" + }, + { + "key": "baby_angel_type_1_2", + "value": "👼🏻" + }, + { + "key": "baby_angel_type_3", + "value": "👼🏼" + }, + { + "key": "baby_angel_type_4", + "value": "👼🏽" + }, + { + "key": "baby_angel_type_5", + "value": "👼🏾" + }, + { + "key": "baby_angel_type_6", + "value": "👼🏿" + }, + { + "key": "princess", + "value": "👸" + }, + { + "key": "princess_type_1_2", + "value": "👸🏻" + }, + { + "key": "princess_type_3", + "value": "👸🏼" + }, + { + "key": "princess_type_4", + "value": "👸🏽" + }, + { + "key": "princess_type_5", + "value": "👸🏾" + }, + { + "key": "princess_type_6", + "value": "👸🏿" + }, + { + "key": "bride_with_veil", + "value": "👰" + }, + { + "key": "bride_with_veil_type_1_2", + "value": "👰🏻" + }, + { + "key": "bride_with_veil_type_3", + "value": "👰🏼" + }, + { + "key": "bride_with_veil_type_4", + "value": "👰🏽" + }, + { + "key": "bride_with_veil_type_5", + "value": "👰🏾" + }, + { + "key": "bride_with_veil_type_6", + "value": "👰🏿" + }, + { + "key": "pedestrian", + "value": "🚶" + }, + { + "key": "pedestrian_type_1_2", + "value": "🚶🏻" + }, + { + "key": "pedestrian_type_3", + "value": "🚶🏼" + }, + { + "key": "pedestrian_type_4", + "value": "🚶🏽" + }, + { + "key": "pedestrian_type_5", + "value": "🚶🏾" + }, + { + "key": "pedestrian_type_6", + "value": "🚶🏿" + }, + { + "key": "runner", + "value": "🏃" + }, + { + "key": "runner_type_1_2", + "value": "🏃🏻" + }, + { + "key": "runner_type_3", + "value": "🏃🏼" + }, + { + "key": "runner_type_4", + "value": "🏃🏽" + }, + { + "key": "runner_type_5", + "value": "🏃🏾" + }, + { + "key": "runner_type_6", + "value": "🏃🏿" + }, + { + "key": "dancer", + "value": "💃" + }, + { + "key": "dancer_type_1_2", + "value": "💃🏻" + }, + { + "key": "dancer_type_3", + "value": "💃🏼" + }, + { + "key": "dancer_type_4", + "value": "💃🏽" + }, + { + "key": "dancer_type_5", + "value": "💃🏾" + }, + { + "key": "dancer_type_6", + "value": "💃🏿" + }, + { + "key": "women_with_bunny_years", + "value": "👯" + }, + { + "key": "man_women_holding_hands", + "value": "👫" + }, + { + "key": "two_man_holding_hands", + "value": "👬" + }, + { + "key": "two_women_holding_hands", + "value": "👭" + }, + { + "key": "person_bowing_deeply", + "value": "🙇" + }, + { + "key": "person_bowing_deeply_type_1_2", + "value": "🙇🏻" + }, + { + "key": "person_bowing_deeply_type_3", + "value": "🙇🏼" + }, + { + "key": "person_bowing_deeply_type_4", + "value": "🙇🏽" + }, + { + "key": "person_bowing_deeply_type_5", + "value": "🙇🏾" + }, + { + "key": "person_bowing_deeply_type_6", + "value": "🙇🏿" + }, + { + "key": "information_desk_person", + "value": "💁" + }, + { + "key": "information_desk_person_type_1_2", + "value": "💁🏻" + }, + { + "key": "information_desk_person_type_3", + "value": "💁🏼" + }, + { + "key": "information_desk_person_type_4", + "value": "💁🏽" + }, + { + "key": "information_desk_person_type_5", + "value": "💁🏾" + }, + { + "key": "information_desk_person_type_6", + "value": "💁🏿" + }, + { + "key": "face_with_no_good_gesture", + "value": "🙅" + }, + { + "key": "face_with_no_good_gesture_type_1_2", + "value": "🙅🏻" + }, + { + "key": "face_with_no_good_gesture_type_3", + "value": "🙅🏼" + }, + { + "key": "face_with_no_good_gesture_type_4", + "value": "🙅🏽" + }, + { + "key": "face_with_no_good_gesture_type_5", + "value": "🙅🏾" + }, + { + "key": "face_with_no_good_gesture_type_6", + "value": "🙅🏿" + }, + { + "key": "face_with_ok_gesture", + "value": "🙆" + }, + { + "key": "face_with_ok_gesture_type_1_2", + "value": "🙆🏻" + }, + { + "key": "face_with_ok_gesture_type_3", + "value": "🙆🏼" + }, + { + "key": "face_with_ok_gesture_type_4", + "value": "🙆🏽" + }, + { + "key": "face_with_ok_gesture_type_5", + "value": "🙆🏾" + }, + { + "key": "face_with_ok_gesture_type_6", + "value": "🙆🏿" + }, + { + "key": "happy_person_raise_one_hand", + "value": "🙋" + }, + { + "key": "happy_person_raise_one_hand_type_1_2", + "value": "🙋🏻" + }, + { + "key": "happy_person_raise_one_hand_type_3", + "value": "🙋🏼" + }, + { + "key": "happy_person_raise_one_hand_type_4", + "value": "🙋🏽" + }, + { + "key": "happy_person_raise_one_hand_type_5", + "value": "🙋🏾" + }, + { + "key": "happy_person_raise_one_hand_type_6", + "value": "🙋🏿" + }, + { + "key": "person_with_pouting_face", + "value": "🙎" + }, + { + "key": "person_with_pouting_face_type_1_2", + "value": "🙎🏻" + }, + { + "key": "person_with_pouting_face_type_3", + "value": "🙎🏼" + }, + { + "key": "person_with_pouting_face_type_4", + "value": "🙎🏽" + }, + { + "key": "person_with_pouting_face_type_5", + "value": "🙎🏾" + }, + { + "key": "person_with_pouting_face_type_6", + "value": "🙎🏿" + }, + { + "key": "person_frowning", + "value": "🙍" + }, + { + "key": "person_frowning_type_1_2", + "value": "🙍🏻" + }, + { + "key": "person_frowning_type_3", + "value": "🙍🏼" + }, + { + "key": "person_frowning_type_4", + "value": "🙍🏽" + }, + { + "key": "person_frowning_type_5", + "value": "🙍🏾" + }, + { + "key": "person_frowning_type_6", + "value": "🙍🏿" + }, + { + "key": "haircut", + "value": "💇" + }, + { + "key": "haircut_type_1_2", + "value": "💇🏻" + }, + { + "key": "haircut_type_3", + "value": "💇🏼" + }, + { + "key": "haircut_type_4", + "value": "💇🏽" + }, + { + "key": "haircut_type_5", + "value": "💇🏾" + }, + { + "key": "haircut_type_6", + "value": "💇🏿" + }, + { + "key": "face_massage", + "value": "💆" + }, + { + "key": "face_massage_type_1_2", + "value": "💆🏻" + }, + { + "key": "face_massage_type_3", + "value": "💆🏻" + }, + { + "key": "face_massage_type_4", + "value": "💆🏽" + }, + { + "key": "face_massage_type_5", + "value": "💆🏾" + }, + { + "key": "face_massage_type_6", + "value": "💆🏿" + }, + { + "key": "couple_with_heart", + "value": "💑" + }, + { + "key": "couple_with_heart_woman", + "value": "👩‍❤️‍👩" + }, + { + "key": "couple_with_heart_man", + "value": "👨‍❤️‍👨" + }, + { + "key": "kiss", + "value": "💏" + }, + { + "key": "kiss_woman", + "value": "👩‍❤️‍💋‍👩" + }, + { + "key": "kiss_man", + "value": "👨‍❤️‍💋‍👨" + }, + { + "key": "family", + "value": "👪" + }, + { + "key": "family_man_women_girl", + "value": "👨‍👩‍👧" + }, + { + "key": "family_man_women_girl_boy", + "value": "👨‍👩‍👧‍👦" + }, + { + "key": "family_man_women_boy_boy", + "value": "👨‍👩‍👦‍👦" + }, + { + "key": "family_man_women_girl_girl", + "value": "👨‍👩‍👧‍👧" + }, + { + "key": "family_woman_women_boy", + "value": "👩‍👩‍👦" + }, + { + "key": "family_woman_women_girl", + "value": "👩‍👩‍👧" + }, + { + "key": "family_woman_women_girl_boy", + "value": "👩‍👩‍👧‍👦" + }, + { + "key": "family_woman_women_boy_boy", + "value": "👩‍👩‍👦‍👦" + }, + { + "key": "family_woman_women_girl_girl", + "value": "👩‍👩‍👧‍👧" + }, + { + "key": "family_man_man_boy", + "value": "👨‍👨‍👦" + }, + { + "key": "family_man_man_girl", + "value": "👨‍👨‍👧" + }, + { + "key": "family_man_man_girl_boy", + "value": "👨‍👨‍👧‍👦" + }, + { + "key": "family_man_man_boy_boy", + "value": "👨‍👨‍👦‍👦" + }, + { + "key": "family_man_man_girl_girl", + "value": "👨‍👨‍👧‍👧" + }, + { + "key": "woman_clothes", + "value": "👚" + }, + { + "key": "t_shirt", + "value": "👕" + }, + { + "key": "jeans", + "value": "👖" + }, + { + "key": "necktie", + "value": "👔" + }, + { + "key": "dress", + "value": "👗" + }, + { + "key": "bikini", + "value": "👙" + }, + { + "key": "kimono", + "value": "👘" + }, + { + "key": "lipstick", + "value": "💄" + }, + { + "key": "kiss_mark", + "value": "💋" + }, + { + "key": "footprints", + "value": "👣" + }, + { + "key": "high_heeled_shoe", + "value": "👠" + }, + { + "key": "woman_sandal", + "value": "👡" + }, + { + "key": "woman_boots", + "value": "👢" + }, + { + "key": "man_shoe", + "value": "👞" + }, + { + "key": "athletic_shoe", + "value": "👟" + }, + { + "key": "woman_hat", + "value": "👒" + }, + { + "key": "top_hat", + "value": "🎩" + }, + { + "key": "graduation_cap", + "value": "🎓" + }, + { + "key": "crown", + "value": "👑" + }, + { + "key": "helmet_with_white_cross", + "value": "⛑" + }, + { + "key": "school_satchel", + "value": "🎒" + }, + { + "key": "pouch", + "value": "👝" + }, + { + "key": "purse", + "value": "👛" + }, + { + "key": "handbag", + "value": "👜" + }, + { + "key": "briefcase", + "value": "💼" + }, + { + "key": "eye_glasses", + "value": "👓" + }, + { + "key": "dark_sun_glasses", + "value": "🕶" + }, + { + "key": "ring", + "value": "💍" + }, + { + "key": "closed_umbrella", + "value": "🌂" + } + ] + } +} \ No newline at end of file diff --git a/nikola/plugins/shortcode/emoji/data/Symbols.json b/nikola/plugins/shortcode/emoji/data/Symbols.json new file mode 100644 index 0000000..2dd5454 --- /dev/null +++ b/nikola/plugins/shortcode/emoji/data/Symbols.json @@ -0,0 +1,1082 @@ +{ + "symbols": { + "symbol": [ + { + "key": "heavy_black_heart", + "value": "❤" + }, + { + "key": "yellow_heart", + "value": "💛" + }, + { + "key": "green_heart", + "value": "💚" + }, + { + "key": "blue_heart", + "value": "💙" + }, + { + "key": "purple_heart", + "value": "💜" + }, + { + "key": "broken_heart", + "value": "💔" + }, + { + "key": "heavy_heart_exclamation_mark_ornament", + "value": "❣" + }, + { + "key": "two_hearts", + "value": "💕" + }, + { + "key": "revolving_hearts", + "value": "💞" + }, + { + "key": "beating_heart", + "value": "💓" + }, + { + "key": "growing_heart", + "value": "💗" + }, + { + "key": "sparkling_heart", + "value": "💖" + }, + { + "key": "heart_with_arrow", + "value": "💘" + }, + { + "key": "heart_with_ribbon", + "value": "💝" + }, + { + "key": "heart_decoration", + "value": "💟" + }, + { + "key": "peace_symbol", + "value": "☮" + }, + { + "key": "latin_cross", + "value": "✝" + }, + { + "key": "star_and_crescent", + "value": "☪" + }, + { + "key": "om_symbol", + "value": "🕉" + }, + { + "key": "wheel_of_dharma", + "value": "☸" + }, + { + "key": "star_of_david", + "value": "✡" + }, + { + "key": "six_pointed_star_with_middle_dot", + "value": "🔯" + }, + { + "key": "menorah_with_nine_branches", + "value": "🕎" + }, + { + "key": "yin_yang", + "value": "☯" + }, + { + "key": "orthodox_cross", + "value": "☦" + }, + { + "key": "place_of_worship", + "value": "🛐" + }, + { + "key": "ophiuchus", + "value": "⛎" + }, + { + "key": "aries", + "value": "♈" + }, + { + "key": "taurus", + "value": "♉" + }, + { + "key": "gemini", + "value": "♊" + }, + { + "key": "cancer", + "value": "♋" + }, + { + "key": "leo", + "value": "♌" + }, + { + "key": "virgo", + "value": "♍" + }, + { + "key": "libra", + "value": "♎" + }, + { + "key": "scorpius", + "value": "♏" + }, + { + "key": "sagittarius", + "value": "♐" + }, + { + "key": "capricorn", + "value": "♑" + }, + { + "key": "aquarius", + "value": "♒" + }, + { + "key": "pisces", + "value": "♓" + }, + { + "key": "squared_id", + "value": "🆔" + }, + { + "key": "atom_symbol", + "value": "⚛" + }, + { + "key": "squared_cjk_unified_ideograph_7a7a", + "value": "🈳" + }, + { + "key": "squared_cjk_unified_ideograph_5272", + "value": "🈹" + }, + { + "key": "radioactive_sign", + "value": "☢" + }, + { + "key": "biohazard_sign", + "value": "☣" + }, + { + "key": "mobile_phone_off", + "value": "📴" + }, + { + "key": "vibration_mode", + "value": "📳" + }, + { + "key": "squared_cjk_unified_ideograph_6709", + "value": "🈶" + }, + { + "key": "squared_cjk_unified_ideograph_7121", + "value": "🈚" + }, + { + "key": "squared_cjk_unified_ideograph_7533", + "value": "🈸" + }, + { + "key": "squared_cjk_unified_ideograph_55b6", + "value": "🈺" + }, + { + "key": "squared_cjk_unified_ideograph_6708", + "value": "🈷" + }, + { + "key": "eight_pointed_black_star", + "value": "✴" + }, + { + "key": "squared_vs", + "value": "🆚" + }, + { + "key": "circled_ideograph_accept", + "value": "🉑" + }, + { + "key": "white_flower", + "value": "💮" + }, + { + "key": "circled_ideograph_advantage", + "value": "🉐" + }, + { + "key": "circled_ideograph_secret", + "value": "㊙" + }, + { + "key": "circled_ideograph_congratulation", + "value": "㊗" + }, + { + "key": "squared_cjk_unified_ideograph_5408", + "value": "🈴" + }, + { + "key": "squared_cjk_unified_ideograph_6e80", + "value": "🈵" + }, + { + "key": "squared_cjk_unified_ideograph_7981", + "value": "🈲" + }, + { + "key": "negative_squared_latin_capital_letter_a", + "value": "🅰" + }, + { + "key": "negative_squared_latin_capital_letter_b", + "value": "🅱" + }, + { + "key": "negative_squared_ab", + "value": "🆎" + }, + { + "key": "squared_cl", + "value": "🆑" + }, + { + "key": "negative_squared_latin_capital_letter_o", + "value": "🅾" + }, + { + "key": "squared_sos", + "value": "🆘" + }, + { + "key": "no_entry", + "value": "⛔" + }, + { + "key": "name_badge", + "value": "📛" + }, + { + "key": "no_entry_sign", + "value": "🚫" + }, + { + "key": "cross_mark", + "value": "❌" + }, + { + "key": "heavy_large_circle", + "value": "⭕" + }, + { + "key": "anger_symbol", + "value": "💢" + }, + { + "key": "hot_springs", + "value": "♨" + }, + { + "key": "no_pedestrians", + "value": "🚷" + }, + { + "key": "do_not_litter_symbol", + "value": "🚯" + }, + { + "key": "no_bi_cycles", + "value": "🚳" + }, + { + "key": "non_potable_water_symbol", + "value": "🚱" + }, + { + "key": "no_one_under_eighteen_symbol", + "value": "🔞" + }, + { + "key": "no_mobile_phones", + "value": "📵" + }, + { + "key": "heavy_exclamation_mark_symbol", + "value": "❗" + }, + { + "key": "white_exclamation_mark_ornament", + "value": "❕" + }, + { + "key": "black_question_mark_ornament", + "value": "❓" + }, + { + "key": "white_question_mark_ornament", + "value": "❔" + }, + { + "key": "double_exclamation_mark", + "value": "‼" + }, + { + "key": "exclamation_question_mark", + "value": "⁉" + }, + { + "key": "hundred_points_symbol", + "value": "💯" + }, + { + "key": "low_brightness_symbol", + "value": "🔅" + }, + { + "key": "high_brightness_symbol", + "value": "🔆" + }, + { + "key": "trident_emblem", + "value": "🔱" + }, + { + "key": "fleur_de_lis", + "value": "⚜" + }, + { + "key": "part_alternation_mark", + "value": "〽" + }, + { + "key": "warning_sign", + "value": "⚠" + }, + { + "key": "children_crossing", + "value": "🚸" + }, + { + "key": "japanese_symbol_for_beginner", + "value": "🔰" + }, + { + "key": "black_universal_recycling_symbol", + "value": "♻" + }, + { + "key": "squared_cjk_unified_ideograph_6307", + "value": "🈯" + }, + { + "key": "chart_with_upwards_trend_and_yen_sign", + "value": "💹" + }, + { + "key": "sparkle", + "value": "❇" + }, + { + "key": "eight_spoked_asterisk", + "value": "✳" + }, + { + "key": "negative_squared_crossmark", + "value": "❎" + }, + { + "key": "white_heavy_checkmark", + "value": "✅" + }, + { + "key": "diamond_shape_with_a_dot_inside", + "value": "💠" + }, + { + "key": "cyclone", + "value": "🌀" + }, + { + "key": "double_curly_loop", + "value": "➿" + }, + { + "key": "globe_with_meridians", + "value": "🌐" + }, + { + "key": "circled_latin_capital_letter_m", + "value": "ⓜ" + }, + { + "key": "automated_teller_machine", + "value": "🏧" + }, + { + "key": "squared_katakanasa", + "value": "🈂" + }, + { + "key": "passport_control", + "value": "🛂" + }, + { + "key": "customs", + "value": "🛃" + }, + { + "key": "baggage_claim", + "value": "🛄" + }, + { + "key": "left_luggage", + "value": "🛅" + }, + { + "key": "wheel_chair_symbol", + "value": "♿" + }, + { + "key": "no_smoking_symbol", + "value": "🚭" + }, + { + "key": "water_closet", + "value": "🚾" + }, + { + "key": "negative_squared_letter_p", + "value": "🅿" + }, + { + "key": "potable_water_symbol", + "value": "🚰" + }, + { + "key": "mens_symbol", + "value": "🚹" + }, + { + "key": "womens_symbol", + "value": "🚺" + }, + { + "key": "baby_symbol", + "value": "🚼" + }, + { + "key": "restroom", + "value": "🚻" + }, + { + "key": "put_litter_in_its_place", + "value": "🚮" + }, + { + "key": "cinema", + "value": "🎦" + }, + { + "key": "antenna_with_bars", + "value": "📶" + }, + { + "key": "squared_katakana_koko", + "value": "🈁" + }, + { + "key": "squared_ng", + "value": "🆖" + }, + { + "key": "squared_ok", + "value": "🆗" + }, + { + "key": "squared_exclamation_mark", + "value": "🆙" + }, + { + "key": "squared_cool", + "value": "🆒" + }, + { + "key": "squared_new", + "value": "🆕" + }, + { + "key": "squared_free", + "value": "🆓" + }, + { + "key": "keycap_digit_zero", + "value": "0⃣" + }, + { + "key": "keycap_digit_one", + "value": "1⃣" + }, + { + "key": "keycap_digit_two", + "value": "2⃣" + }, + { + "key": "keycap_digit_three", + "value": "3⃣" + }, + { + "key": "keycap_digit_four", + "value": "4⃣" + }, + { + "key": "keycap_digit_five", + "value": "5⃣" + }, + { + "key": "keycap_digit_six", + "value": "6⃣" + }, + { + "key": "keycap_digit_seven", + "value": "7⃣" + }, + { + "key": "keycap_digit_eight", + "value": "8⃣" + }, + { + "key": "keycap_digit_nine", + "value": "9⃣" + }, + { + "key": "keycap_ten", + "value": "🔟" + }, + { + "key": "input_symbol_for_numbers", + "value": "🔢" + }, + { + "key": "black_right_pointing_triangle", + "value": "▶" + }, + { + "key": "double_vertical_bar", + "value": "⏸" + }, + { + "key": "blk_rgt_point_triangle_dbl_vertical_bar", + "value": "⏯" + }, + { + "key": "black_square_for_stop", + "value": "⏹" + }, + { + "key": "black_circle_for_record", + "value": "⏺" + }, + { + "key": "blk_rgt_point_dbl_triangle_vertical_bar", + "value": "⏭" + }, + { + "key": "blk_lft_point_dbl_triangle_vertical_bar", + "value": "⏮" + }, + { + "key": "blk_rgt_point_dbl_triangle", + "value": "⏩" + }, + { + "key": "blk_lft_point_dbl_triangle", + "value": "⏪" + }, + { + "key": "twisted_rightwards_arrows", + "value": "🔀" + }, + { + "key": "cwise_rgt_lft_open_circle_arrow", + "value": "🔁" + }, + { + "key": "cwise_rgt_lft_open_circle_arrow_overlay", + "value": "🔂" + }, + { + "key": "blk_lft_point_triangle", + "value": "◀" + }, + { + "key": "up_point_small_red_triangle", + "value": "🔼" + }, + { + "key": "down_point_small_red_triangle", + "value": "🔽" + }, + { + "key": "blk_up_point_double_triangle", + "value": "⏫" + }, + { + "key": "blk_down_point_double_triangle", + "value": "⏬" + }, + { + "key": "black_rightwards_arrow", + "value": "➡" + }, + { + "key": "leftwards_black_arrow", + "value": "⬅" + }, + { + "key": "upwards_black_arrow", + "value": "⬆" + }, + { + "key": "downwards_black_arrow", + "value": "⬇" + }, + { + "key": "northeast_arrow", + "value": "↗" + }, + { + "key": "southeast_arrow", + "value": "↘" + }, + { + "key": "south_west_arrow", + "value": "↙" + }, + { + "key": "north_west_arrow", + "value": "↖" + }, + { + "key": "up_down_arrow", + "value": "↕" + }, + { + "key": "left_right_arrow", + "value": "↔" + }, + { + "key": "acwise_down_up_open_circle_arrow", + "value": "🔄" + }, + { + "key": "rightwards_arrow_with_hook", + "value": "↪" + }, + { + "key": "leftwards_arrow_with_hook", + "value": "↩" + }, + { + "key": "arrow_point_rgt_then_curving_up", + "value": "⤴" + }, + { + "key": "arrow_point_rgt_then_curving_down", + "value": "⤵" + }, + { + "key": "keycap_number_sign", + "value": "#⃣" + }, + { + "key": "keycap_asterisk", + "value": "*⃣" + }, + { + "key": "information_source", + "value": "ℹ" + }, + { + "key": "input_symbol_for_latin_letters", + "value": "🔤" + }, + { + "key": "input_symbol_latin_small_letters", + "value": "🔡" + }, + { + "key": "input_symbol_latin_capital_letters", + "value": "🔠" + }, + { + "key": "input_symbol_symbols", + "value": "🔣" + }, + { + "key": "musical_note", + "value": "🎵" + }, + { + "key": "multiple_musical_notes", + "value": "🎶" + }, + { + "key": "wavy_dash", + "value": "〰" + }, + { + "key": "curly_loop", + "value": "➰" + }, + { + "key": "heavy_check_mark", + "value": "✔" + }, + { + "key": "cwise_down_up_open_circle_arrows", + "value": "🔃" + }, + { + "key": "heavy_plus_sign", + "value": "➕" + }, + { + "key": "heavy_minus_sign", + "value": "➖" + }, + { + "key": "heavy_division_sign", + "value": "➗" + }, + { + "key": "heavy_multiplication_x", + "value": "✖" + }, + { + "key": "heavy_dollar_sign", + "value": "💲" + }, + { + "key": "currency_exchange", + "value": "💱" + }, + { + "key": "copyright_sign", + "value": "©" + }, + { + "key": "registered_sign", + "value": "®" + }, + { + "key": "trademark_sign", + "value": "™" + }, + { + "key": "end_with_lft_arrow_above", + "value": "🔚" + }, + { + "key": "back_with_lft_arrow_above", + "value": "🔙" + }, + { + "key": "on_exclamation_lft_rgt_arrow", + "value": "🔛" + }, + { + "key": "top_with_up_arrow_above", + "value": "🔝" + }, + { + "key": "soon_right_arrow_above", + "value": "🔜" + }, + { + "key": "ballot_box_with_check", + "value": "☑" + }, + { + "key": "radio_button", + "value": "🔘" + }, + { + "key": "medium_white_circle", + "value": "⚪" + }, + { + "key": "medium_black_circle", + "value": "⚫" + }, + { + "key": "large_red_circle", + "value": "🔴" + }, + { + "key": "large_blue_circle", + "value": "🔵" + }, + { + "key": "small_orange_diamond", + "value": "🔸" + }, + { + "key": "small_blue_diamond", + "value": "🔹" + }, + { + "key": "large_orange_diamond", + "value": "🔶" + }, + { + "key": "large_blue_diamond", + "value": "🔷" + }, + { + "key": "up_point_red_triangle", + "value": "🔺" + }, + { + "key": "black_small_square", + "value": "▪" + }, + { + "key": "white_small_square", + "value": "▫" + }, + { + "key": "black_large_square", + "value": "⬛" + }, + { + "key": "white_large_square", + "value": "⬜" + }, + { + "key": "down_point_red_triangle", + "value": "🔻" + }, + { + "key": "black_medium_square", + "value": "◼" + }, + { + "key": "white_medium_square", + "value": "◻" + }, + { + "key": "black_medium_small_square", + "value": "◾" + }, + { + "key": "white_medium_small_square", + "value": "◽" + }, + { + "key": "black_square_button", + "value": "🔲" + }, + { + "key": "white_square_button", + "value": "🔳" + }, + { + "key": "speaker", + "value": "🔈" + }, + { + "key": "speaker_one_sound_wave", + "value": "🔉" + }, + { + "key": "speaker_three_sound_waves", + "value": "🔊" + }, + { + "key": "speaker_cancellation_stroke", + "value": "🔇" + }, + { + "key": "cheering_megaphone", + "value": "📣" + }, + { + "key": "public_address_loudspeaker", + "value": "📢" + }, + { + "key": "bell", + "value": "🔔" + }, + { + "key": "bell_with_cancellation_stroke", + "value": "🔕" + }, + { + "key": "playing_card_black_joker", + "value": "🃏" + }, + { + "key": "mahjong_tile_red_dragon", + "value": "🀄" + }, + { + "key": "black_spade_suit", + "value": "♠" + }, + { + "key": "black_club_suit", + "value": "♣" + }, + { + "key": "black_heart_suit", + "value": "♥" + }, + { + "key": "black_diamond_suit", + "value": "♦" + }, + { + "key": "flower_playing_cards", + "value": "🎴" + }, + { + "key": "eye_in_speech_bubble", + "value": "👁‍🗨" + }, + { + "key": "thought_balloon", + "value": "💭" + }, + { + "key": "right_anger_bubble", + "value": "🗯" + }, + { + "key": "speech_balloon", + "value": "💬" + }, + { + "key": "clock_face_one_o_clock", + "value": "🕐" + }, + { + "key": "clock_face_two_o_clock", + "value": "🕑" + }, + { + "key": "clock_face_three_o_clock", + "value": "🕒" + }, + { + "key": "clock_face_four_o_clock", + "value": "🕓" + }, + { + "key": "clock_face_five_o_clock", + "value": "🕔" + }, + { + "key": "clock_face_six_o_clock", + "value": "🕕" + }, + { + "key": "clock_face_seven_o_clock", + "value": "🕖" + }, + { + "key": "clock_face_eight_o_clock", + "value": "🕗" + }, + { + "key": "clock_face_nine_o_clock", + "value": "🕘" + }, + { + "key": "clock_face_ten_o_clock", + "value": "🕙" + }, + { + "key": "clock_face_eleven_o_clock", + "value": "🕚" + }, + { + "key": "clock_face_twelve_o_clock", + "value": "🕛" + }, + { + "key": "clock_face_one_thirty", + "value": "🕜" + }, + { + "key": "clock_face_two_thirty", + "value": "🕝" + }, + { + "key": "clock_face_three_thirty", + "value": "🕞" + }, + { + "key": "clock_face_four_thirty", + "value": "🕟" + }, + { + "key": "clock_face_five_thirty", + "value": "🕠" + }, + { + "key": "clock_face_six_thirty", + "value": "🕡" + }, + { + "key": "clock_face_seven_thirty", + "value": "🕢" + }, + { + "key": "clock_face_eight_thirty", + "value": "🕣" + }, + { + "key": "clock_face_nine_thirty", + "value": "🕤" + }, + { + "key": "clock_face_ten_thirty", + "value": "🕥" + }, + { + "key": "clock_face_eleven_thirty", + "value": "🕦" + }, + { + "key": "clock_face_twelve_thirty", + "value": "🕧" + } + ] + } +} \ No newline at end of file diff --git a/nikola/plugins/shortcode/emoji/data/Travel.json b/nikola/plugins/shortcode/emoji/data/Travel.json new file mode 100644 index 0000000..e38b84f --- /dev/null +++ b/nikola/plugins/shortcode/emoji/data/Travel.json @@ -0,0 +1,466 @@ +{ + "travels": { + "travel": [ + { + "key": "automobile", + "value": "🚗" + }, + { + "key": "taxi", + "value": "🚕" + }, + { + "key": "recreational_vehicle", + "value": "🚙" + }, + { + "key": "bus", + "value": "🚌" + }, + { + "key": "trolley_bus", + "value": "🚎" + }, + { + "key": "racing_car", + "value": "🏎" + }, + { + "key": "police_car", + "value": "🚓" + }, + { + "key": "ambulance", + "value": "🚑" + }, + { + "key": "fire_engine", + "value": "🚒" + }, + { + "key": "minibus", + "value": "🚐" + }, + { + "key": "delivery_truck", + "value": "🚚" + }, + { + "key": "articulated_lorry", + "value": "🚛" + }, + { + "key": "tractor", + "value": "🚜" + }, + { + "key": "racing_motorcycle", + "value": "🏍" + }, + { + "key": "bicycle", + "value": "🚲" + }, + { + "key": "police_light", + "value": "🚨" + }, + { + "key": "on_coming_police_car", + "value": "🚔" + }, + { + "key": "on_coming_bus", + "value": "🚍" + }, + { + "key": "on_coming_automobile", + "value": "🚘" + }, + { + "key": "on_coming_taxi", + "value": "🚖" + }, + { + "key": "aerial_tramway", + "value": "🚡" + }, + { + "key": "mountain_cableway", + "value": "🚠" + }, + { + "key": "suspension_railway", + "value": "🚟" + }, + { + "key": "railway_car", + "value": "🚃" + }, + { + "key": "tramcar", + "value": "🚋" + }, + { + "key": "monorail", + "value": "🚝" + }, + { + "key": "high_speed_train", + "value": "🚄" + }, + { + "key": "high_speed_train_bullet_nose", + "value": "🚅" + }, + { + "key": "light_rail", + "value": "🚈" + }, + { + "key": "mountain_railway", + "value": "🚞" + }, + { + "key": "steam_locomotive", + "value": "🚂" + }, + { + "key": "train", + "value": "🚆" + }, + { + "key": "metro", + "value": "🚇" + }, + { + "key": "tram", + "value": "🚊" + }, + { + "key": "station", + "value": "🚉" + }, + { + "key": "helicopter", + "value": "🚁" + }, + { + "key": "small_airplane", + "value": "🛩" + }, + { + "key": "airplane", + "value": "✈" + }, + { + "key": "airplane_departure", + "value": "🛫" + }, + { + "key": "airplane_arriving", + "value": "🛬" + }, + { + "key": "sailboat", + "value": "⛵" + }, + { + "key": "motorboat", + "value": "🛥" + }, + { + "key": "speedboat", + "value": "🚤" + }, + { + "key": "ferry", + "value": "⛴" + }, + { + "key": "passenger_ship", + "value": "🛳" + }, + { + "key": "rocket", + "value": "🚀" + }, + { + "key": "satellite", + "value": "🛰" + }, + { + "key": "seat", + "value": "💺" + }, + { + "key": "anchor", + "value": "⚓" + }, + { + "key": "construction_sign", + "value": "🚧" + }, + { + "key": "fuel_pump", + "value": "⛽" + }, + { + "key": "bus_stop", + "value": "🚏" + }, + { + "key": "vertical_traffic_light", + "value": "🚦" + }, + { + "key": "horizontal_traffic_light", + "value": "🚥" + }, + { + "key": "chequered_flag", + "value": "🏁" + }, + { + "key": "ship", + "value": "🚢" + }, + { + "key": "ferris_wheel", + "value": "🎡" + }, + { + "key": "roller_coaster", + "value": "🎢" + }, + { + "key": "carousel_horse", + "value": "🎠" + }, + { + "key": "building_construction", + "value": "🏗" + }, + { + "key": "foggy", + "value": "🌁" + }, + { + "key": "tokyo_tower", + "value": "🗼" + }, + { + "key": "factory", + "value": "🏭" + }, + { + "key": "fountain", + "value": "⛲" + }, + { + "key": "moon_viewing_ceremony", + "value": "🎑" + }, + { + "key": "mountain", + "value": "⛰" + }, + { + "key": "snow_capped_mountain", + "value": "🏔" + }, + { + "key": "mount_fuji", + "value": "🗻" + }, + { + "key": "volcano", + "value": "🌋" + }, + { + "key": "silhouette_of_japan", + "value": "🗾" + }, + { + "key": "camping", + "value": "🏕" + }, + { + "key": "tent", + "value": "⛺" + }, + { + "key": "national_park", + "value": "🏞" + }, + { + "key": "motorway", + "value": "🛣" + }, + { + "key": "railway_track", + "value": "🛤" + }, + { + "key": "sunrise", + "value": "🌅" + }, + { + "key": "sunrise_over_mountain", + "value": "🌄" + }, + { + "key": "desert", + "value": "🏜" + }, + { + "key": "beach_with_umbrella", + "value": "🏖" + }, + { + "key": "desert_island", + "value": "🏝" + }, + { + "key": "sunset_over_buildings", + "value": "🌇" + }, + { + "key": "city_scape_at_dusk", + "value": "🌆" + }, + { + "key": "city_scape", + "value": "🏙" + }, + { + "key": "night_with_stars", + "value": "🌃" + }, + { + "key": "bridge_at_night", + "value": "🌉" + }, + { + "key": "milky_way", + "value": "🌌" + }, + { + "key": "shooting_star", + "value": "🌠" + }, + { + "key": "fire_work_sparkler", + "value": "🎇" + }, + { + "key": "fireworks", + "value": "🎆" + }, + { + "key": "rainbow", + "value": "🌈" + }, + { + "key": "house_buildings", + "value": "🏘" + }, + { + "key": "european_castle", + "value": "🏰" + }, + { + "key": "japanese_castle", + "value": "🏯" + }, + { + "key": "stadium", + "value": "🏟" + }, + { + "key": "statue_of_liberty", + "value": "🗽" + }, + { + "key": "house_building", + "value": "🏠" + }, + { + "key": "house_with_garden", + "value": "🏡" + }, + { + "key": "derelict_house_building", + "value": "🏚" + }, + { + "key": "office_building", + "value": "🏢" + }, + { + "key": "department_store", + "value": "🏬" + }, + { + "key": "japanese_post_office", + "value": "🏣" + }, + { + "key": "european_post_office", + "value": "🏤" + }, + { + "key": "hospital", + "value": "🏥" + }, + { + "key": "bank", + "value": "🏦" + }, + { + "key": "hotel", + "value": "🏨" + }, + { + "key": "convenience_store", + "value": "🏪" + }, + { + "key": "school", + "value": "🏫" + }, + { + "key": "love_hotel", + "value": "🏩" + }, + { + "key": "wedding", + "value": "💒" + }, + { + "key": "classical_building", + "value": "🏛" + }, + { + "key": "church", + "value": "⛪" + }, + { + "key": "mosque", + "value": "🕌" + }, + { + "key": "synagogue", + "value": "🕍" + }, + { + "key": "kaaba", + "value": "🕋" + }, + { + "key": "shinto_shrine", + "value": "⛩" + } + ] + } +} diff --git a/nikola/plugins/shortcode/gist.plugin b/nikola/plugins/shortcode/gist.plugin new file mode 100644 index 0000000..b610763 --- /dev/null +++ b/nikola/plugins/shortcode/gist.plugin @@ -0,0 +1,13 @@ +[Core] +name = gist +module = gist + +[Nikola] +PluginCategory = Shortcode + +[Documentation] +author = Roberto Alsina +version = 0.1 +website = https://getnikola.com/ +description = Gist shortcode + diff --git a/nikola/plugins/shortcode/gist.py b/nikola/plugins/shortcode/gist.py new file mode 100644 index 0000000..eb9e976 --- /dev/null +++ b/nikola/plugins/shortcode/gist.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This file is public domain according to its author, Brian Hsu + +"""Gist directive for reStructuredText.""" + +import requests + +from nikola.plugin_categories import ShortcodePlugin + + +class Plugin(ShortcodePlugin): + """Plugin for gist directive.""" + + name = "gist" + + def get_raw_gist_with_filename(self, gistID, filename): + """Get raw gist text for a filename.""" + url = '/'.join(("https://gist.github.com/raw", gistID, filename)) + return requests.get(url).text + + def get_raw_gist(self, gistID): + """Get raw gist text.""" + url = "https://gist.github.com/raw/{0}".format(gistID) + try: + return requests.get(url).text + except requests.exceptions.RequestException: + raise self.error('Cannot get gist for url={0}'.format(url)) + + def handler(self, gistID, filename=None, site=None, data=None, lang=None, post=None): + """Create HTML for gist.""" + if 'https://' in gistID: + gistID = gistID.split('/')[-1].strip() + else: + gistID = gistID.strip() + embedHTML = "" + rawGist = "" + + if filename is not None: + rawGist = (self.get_raw_gist_with_filename(gistID, filename)) + embedHTML = ('').format(gistID, filename) + else: + rawGist = (self.get_raw_gist(gistID)) + embedHTML = ('').format(gistID) + + output = '''{} + '''.format(embedHTML, rawGist) + + return output, [] diff --git a/nikola/plugins/shortcode/listing.plugin b/nikola/plugins/shortcode/listing.plugin new file mode 100644 index 0000000..90fb6eb --- /dev/null +++ b/nikola/plugins/shortcode/listing.plugin @@ -0,0 +1,13 @@ +[Core] +name = listing_shortcode +module = listing + +[Nikola] +PluginCategory = Shortcode + +[Documentation] +author = Roberto Alsina +version = 0.1 +website = https://getnikola.com/ +description = Listing shortcode + diff --git a/nikola/plugins/shortcode/listing.py b/nikola/plugins/shortcode/listing.py new file mode 100644 index 0000000..b51365a --- /dev/null +++ b/nikola/plugins/shortcode/listing.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2017-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Listing shortcode (equivalent to reST’s listing directive).""" + +import os +from urllib.parse import urlunsplit + +import pygments + +from nikola.plugin_categories import ShortcodePlugin + + +class Plugin(ShortcodePlugin): + """Plugin for listing shortcode.""" + + name = "listing" + + def set_site(self, site): + """Set Nikola site.""" + self.site = site + Plugin.folders = site.config['LISTINGS_FOLDERS'] + return super().set_site(site) + + def handler(self, fname, language='text', linenumbers=False, filename=None, site=None, data=None, lang=None, post=None): + """Create HTML for a listing.""" + fname = fname.replace('/', os.sep) + if len(self.folders) == 1: + listings_folder = next(iter(self.folders.keys())) + if fname.startswith(listings_folder): + fpath = os.path.join(fname) # new syntax: specify folder name + else: + # old syntax: don't specify folder name + fpath = os.path.join(listings_folder, fname) + else: + # must be new syntax: specify folder name + fpath = os.path.join(fname) + linenumbers = 'table' if linenumbers else False + deps = [fpath] + with open(fpath, 'r') as inf: + target = urlunsplit( + ("link", 'listing', fpath.replace('\\', '/'), '', '')) + src_target = urlunsplit( + ("link", 'listing_source', fpath.replace('\\', '/'), '', '')) + src_label = self.site.MESSAGES('Source') + + data = inf.read() + lexer = pygments.lexers.get_lexer_by_name(language) + formatter = pygments.formatters.get_formatter_by_name( + 'html', linenos=linenumbers) + output = '{0} ({2})' .format( + fname, target, src_label, src_target) + pygments.highlight(data, lexer, formatter) + + return output, deps diff --git a/nikola/plugins/shortcode/post_list.plugin b/nikola/plugins/shortcode/post_list.plugin new file mode 100644 index 0000000..494a1d8 --- /dev/null +++ b/nikola/plugins/shortcode/post_list.plugin @@ -0,0 +1,13 @@ +[Core] +name = post_list +module = post_list + +[Nikola] +PluginCategory = Shortcode + +[Documentation] +author = Udo Spallek +version = 0.2 +website = https://getnikola.com/ +description = Includes a list of posts with tag and slice based filters. + diff --git a/nikola/plugins/shortcode/post_list.py b/nikola/plugins/shortcode/post_list.py new file mode 100644 index 0000000..462984a --- /dev/null +++ b/nikola/plugins/shortcode/post_list.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2013-2020 Udo Spallek, Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Post list shortcode.""" + + +import operator +import os +import uuid + +import natsort + +from nikola import utils +from nikola.packages.datecond import date_in_range +from nikola.plugin_categories import ShortcodePlugin + + +class PostListShortcode(ShortcodePlugin): + """Provide a shortcode to create a list of posts. + + Post List + ========= + :Directive Arguments: None. + :Directive Options: lang, start, stop, reverse, sort, date, tags, categories, sections, slugs, post_type, template, id + :Directive Content: None. + + The posts appearing in the list can be filtered by options. + *List slicing* is provided with the *start*, *stop* and *reverse* options. + + The following not required options are recognized: + + ``start`` : integer + The index of the first post to show. + A negative value like ``-3`` will show the *last* three posts in the + post-list. + Defaults to None. + + ``stop`` : integer + The index of the last post to show. + A value negative value like ``-1`` will show every post, but not the + *last* in the post-list. + Defaults to None. + + ``reverse`` : flag + Reverse the order of the post-list. + Defaults is to not reverse the order of posts. + + ``sort`` : string + Sort post list by one of each post's attributes, usually ``title`` or a + custom ``priority``. Defaults to None (chronological sorting). + + ``date`` : string + Show posts that match date range specified by this option. Format: + + * comma-separated clauses (AND) + * clause: attribute comparison_operator value (spaces optional) + * attribute: year, month, day, hour, month, second, weekday, isoweekday; or empty for full datetime + * comparison_operator: == != <= >= < > + * value: integer, 'now', 'today', or dateutil-compatible date input + + ``tags`` : string [, string...] + Filter posts to show only posts having at least one of the ``tags``. + Defaults to None. + + ``require_all_tags`` : flag + Change tag filter behaviour to show only posts that have all specified ``tags``. + Defaults to False. + + ``categories`` : string [, string...] + Filter posts to show only posts having one of the ``categories``. + Defaults to None. + + ``sections`` : string [, string...] + Filter posts to show only posts having one of the ``sections``. + Defaults to None. + + ``slugs`` : string [, string...] + Filter posts to show only posts having at least one of the ``slugs``. + Defaults to None. + + ``post_type`` (or ``type``) : string + Show only ``posts``, ``pages`` or ``all``. + Replaces ``all``. Defaults to ``posts``. + + ``lang`` : string + The language of post *titles* and *links*. + Defaults to default language. + + ``template`` : string + The name of an alternative template to render the post-list. + Defaults to ``post_list_directive.tmpl`` + + ``id`` : string + A manual id for the post list. + Defaults to a random name composed by 'post_list_' + uuid.uuid4().hex. + """ + + name = "post_list" + + def set_site(self, site): + """Set the site.""" + super().set_site(site) + site.register_shortcode('post-list', self.handler) + + def handler(self, start=None, stop=None, reverse=False, tags=None, require_all_tags=False, categories=None, + sections=None, slugs=None, post_type='post', type=False, + lang=None, template='post_list_directive.tmpl', sort=None, + id=None, data=None, state=None, site=None, date=None, filename=None, post=None): + """Generate HTML for post-list.""" + if lang is None: + lang = utils.LocaleBorg().current_lang + if site.invariant: # for testing purposes + post_list_id = id or 'post_list_' + 'fixedvaluethatisnotauuid' + else: + post_list_id = id or 'post_list_' + uuid.uuid4().hex + + # Get post from filename if available + if filename: + self_post = site.post_per_input_file.get(filename) + else: + self_post = None + + if self_post: + self_post.register_depfile("####MAGIC####TIMELINE", lang=lang) + + # If we get strings for start/stop, make them integers + if start is not None: + start = int(start) + if stop is not None: + stop = int(stop) + + # Parse tags/categories/sections/slugs (input is strings) + categories = [c.strip().lower() for c in categories.split(',')] if categories else [] + sections = [s.strip().lower() for s in sections.split(',')] if sections else [] + slugs = [s.strip() for s in slugs.split(',')] if slugs else [] + + filtered_timeline = [] + posts = [] + step = None if reverse is False else -1 + + if type is not False: + post_type = type + + if post_type == 'page' or post_type == 'pages': + timeline = [p for p in site.timeline if not p.use_in_feeds] + elif post_type == 'all': + timeline = [p for p in site.timeline] + else: # post + timeline = [p for p in site.timeline if p.use_in_feeds] + + # self_post should be removed from timeline because this is redundant + timeline = [p for p in timeline if p.source_path != filename] + + if categories: + timeline = [p for p in timeline if p.meta('category', lang=lang).lower() in categories] + + if sections: + timeline = [p for p in timeline if p.section_name(lang).lower() in sections] + + if tags: + tags = {t.strip().lower() for t in tags.split(',')} + if require_all_tags: + compare = set.issubset + else: + compare = operator.and_ + for post in timeline: + post_tags = {t.lower() for t in post.tags} + if compare(tags, post_tags): + filtered_timeline.append(post) + else: + filtered_timeline = timeline + + if sort: + filtered_timeline = natsort.natsorted(filtered_timeline, key=lambda post: post.meta[lang][sort], alg=natsort.ns.F | natsort.ns.IC) + + if date: + _now = utils.current_time() + filtered_timeline = [p for p in filtered_timeline if date_in_range(utils.html_unescape(date), p.date, now=_now)] + + for post in filtered_timeline[start:stop:step]: + if slugs: + cont = True + for slug in slugs: + if slug == post.meta('slug'): + cont = False + + if cont: + continue + + bp = post.translated_base_path(lang) + if os.path.exists(bp) and state: + state.document.settings.record_dependencies.add(bp) + elif os.path.exists(bp) and self_post: + self_post.register_depfile(bp, lang=lang) + + posts += [post] + + template_deps = site.template_system.template_deps(template) + if state: + # Register template as a dependency (Issue #2391) + for d in template_deps: + state.document.settings.record_dependencies.add(d) + elif self_post: + for d in template_deps: + self_post.register_depfile(d, lang=lang) + + template_data = { + 'lang': lang, + 'posts': posts, + # Need to provide str, not TranslatableSetting (Issue #2104) + 'date_format': site.GLOBAL_CONTEXT.get('date_format')[lang], + 'post_list_id': post_list_id, + 'messages': site.MESSAGES, + '_link': site.link, + } + output = site.template_system.render_template( + template, None, template_data) + return output, template_deps + + +# Request file name from shortcode (Issue #2412) +PostListShortcode.handler.nikola_shortcode_pass_filename = True diff --git a/nikola/plugins/shortcode/thumbnail.plugin b/nikola/plugins/shortcode/thumbnail.plugin new file mode 100644 index 0000000..e55d34f --- /dev/null +++ b/nikola/plugins/shortcode/thumbnail.plugin @@ -0,0 +1,12 @@ +[Core] +name = thumbnail +module = thumbnail + +[Nikola] +PluginCategory = Shortcode + +[Documentation] +author = Chris Warrick +version = 0.1 +website = https://getnikola.com/ +description = Thumbnail shortcode diff --git a/nikola/plugins/shortcode/thumbnail.py b/nikola/plugins/shortcode/thumbnail.py new file mode 100644 index 0000000..feb731b --- /dev/null +++ b/nikola/plugins/shortcode/thumbnail.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2017-2020 Roberto Alsina, Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Thumbnail shortcode (equivalent to reST’s thumbnail directive).""" + +import os.path + +from nikola.plugin_categories import ShortcodePlugin + + +class ThumbnailShortcode(ShortcodePlugin): + """Plugin for thumbnail directive.""" + + name = "thumbnail" + + def handler(self, uri, alt=None, align=None, linktitle=None, title=None, imgclass=None, figclass=None, site=None, data=None, lang=None, post=None): + """Create HTML for thumbnail.""" + if uri.endswith('.svg'): + # the ? at the end makes docutil output an instead of an object for the svg, which lightboxes may require + src = '.thumbnail'.join(os.path.splitext(uri)) + '?' + else: + src = '.thumbnail'.join(os.path.splitext(uri)) + + if imgclass is None: + imgclass = '' + if figclass is None: + figclass = '' + + if align and data: + figclass += ' align-{0}'.format(align) + elif align: + imgclass += ' align-{0}'.format(align) + + output = ' /archives/2013/index.html""", + 'archive_atom': False, + 'archive_rss': False, + } def set_site(self, site): """Set Nikola site.""" - site.register_path_handler('archive', self.archive_path) - site.register_path_handler('archive_atom', self.archive_atom_path) - return super(Archive, self).set_site(site) - - def _prepare_task(self, kw, name, lang, posts, items, template_name, - title, deps_translatable=None): - """Prepare an archive task.""" - # name: used to build permalink and destination - # posts, items: posts or items; only one of them should be used, - # the other be None - # template_name: name of the template to use - # title: the (translated) title for the generated page - # deps_translatable: dependencies (None if not added) - assert posts is not None or items is not None - task_cfg = [copy.copy(kw)] - context = {} - context["lang"] = lang - context["title"] = title - context["permalink"] = self.site.link("archive", name, lang) - context["pagekind"] = ["list", "archive_page"] - if posts is not None: - context["posts"] = posts - # Depend on all post metadata because it can be used in templates (Issue #1931) - task_cfg.append([repr(p) for p in posts]) + # Sanity checks + if (site.config['CREATE_MONTHLY_ARCHIVE'] and site.config['CREATE_SINGLE_ARCHIVE']) and not site.config['CREATE_FULL_ARCHIVES']: + raise Exception('Cannot create monthly and single archives at the same time.') + # Finish setup + self.show_list_as_subcategories_list = not site.config['CREATE_FULL_ARCHIVES'] + self.show_list_as_index = site.config['ARCHIVES_ARE_INDEXES'] + self.template_for_single_list = "archiveindex.tmpl" if site.config['ARCHIVES_ARE_INDEXES'] else "archive.tmpl" + # Determine maximum hierarchy height + if site.config['CREATE_DAILY_ARCHIVE'] or site.config['CREATE_FULL_ARCHIVES']: + self.max_levels = 3 + elif site.config['CREATE_MONTHLY_ARCHIVE']: + self.max_levels = 2 + elif site.config['CREATE_SINGLE_ARCHIVE']: + self.max_levels = 0 + else: + self.max_levels = 1 + return super().set_site(site) + + def get_implicit_classifications(self, lang): + """Return a list of classification strings which should always appear in posts_per_classification.""" + return [''] + + def classify(self, post, lang): + """Classify the given post for the given language.""" + levels = [str(post.date.year).zfill(4), str(post.date.month).zfill(2), str(post.date.day).zfill(2)] + return ['/'.join(levels[:self.max_levels])] + + def sort_classifications(self, classifications, lang, level=None): + """Sort the given list of classification strings.""" + if level in (0, 1): + # Years or months: sort descending + classifications.sort() + classifications.reverse() + + def get_classification_friendly_name(self, classification, lang, only_last_component=False): + """Extract a friendly name from the classification.""" + classification = self.extract_hierarchy(classification) + if len(classification) == 0: + return self.site.MESSAGES[lang]['Archive'] + elif len(classification) == 1: + return classification[0] + elif len(classification) == 2: + if only_last_component: + date_str = "{month}" + else: + date_str = "{month_year}" + return nikola.utils.LocaleBorg().format_date_in_string( + date_str, + datetime.date(int(classification[0]), int(classification[1]), 1), + lang) else: - # Depend on the content of items, to rebuild if links change (Issue #1931) - context["items"] = items - task_cfg.append(items) - task = self.site.generic_post_list_renderer( - lang, - [], - os.path.join(kw['output_folder'], self.site.path("archive", name, lang)), - template_name, - kw['filters'], - context, - ) - - task_cfg = {i: x for i, x in enumerate(task_cfg)} - if deps_translatable is not None: - task_cfg[3] = deps_translatable - task['uptodate'] = task['uptodate'] + [config_changed(task_cfg, 'nikola.plugins.task.archive')] - task['basename'] = self.name - return task - - def _generate_posts_task(self, kw, name, lang, posts, title, deps_translatable=None): - """Genereate a task for an archive with posts.""" - posts = sorted(posts, key=lambda a: a.date) - posts.reverse() - if kw['archives_are_indexes']: - def page_link(i, displayed_i, num_pages, force_addition, extension=None): - feed = "_atom" if extension == ".atom" else "" - return adjust_name_for_index_link(self.site.link("archive" + feed, name, lang), i, displayed_i, - lang, self.site, force_addition, extension) - - def page_path(i, displayed_i, num_pages, force_addition, extension=None): - feed = "_atom" if extension == ".atom" else "" - return adjust_name_for_index_path(self.site.path("archive" + feed, name, lang), i, displayed_i, - lang, self.site, force_addition, extension) - - uptodate = [] - if deps_translatable is not None: - uptodate += [config_changed(deps_translatable, 'nikola.plugins.task.archive')] - context = {"archive_name": name, - "is_feed_stale": kw["is_feed_stale"], - "pagekind": ["index", "archive_page"]} - yield self.site.generic_index_renderer( - lang, - posts, - title, - "archiveindex.tmpl", - context, - kw, - str(self.name), - page_link, - page_path, - uptodate) + if only_last_component: + return str(classification[2]) + return nikola.utils.LocaleBorg().format_date_in_string( + "{month_day_year}", + datetime.date(int(classification[0]), int(classification[1]), int(classification[2])), + lang) + + def get_path(self, classification, lang, dest_type='page'): + """Return a path for the given classification.""" + components = [self.site.config['ARCHIVE_PATH'](lang)] + if classification: + components.extend(classification) + add_index = 'always' else: - yield self._prepare_task(kw, name, lang, posts, None, "list_post.tmpl", title, deps_translatable) + components.append(self.site.config['ARCHIVE_FILENAME'](lang)) + add_index = 'never' + return [_f for _f in components if _f], add_index + + def extract_hierarchy(self, classification): + """Given a classification, return a list of parts in the hierarchy.""" + return classification.split('/') if classification else [] - def gen_tasks(self): - """Generate archive tasks.""" + def recombine_classification_from_hierarchy(self, hierarchy): + """Given a list of parts in the hierarchy, return the classification string.""" + return '/'.join(hierarchy) + + def provide_context_and_uptodate(self, classification, lang, node=None): + """Provide data for the context and the uptodate list for the list of the given classifiation.""" + hierarchy = self.extract_hierarchy(classification) kw = { "messages": self.site.MESSAGES, - "translations": self.site.config['TRANSLATIONS'], - "output_folder": self.site.config['OUTPUT_FOLDER'], - "filters": self.site.config['FILTERS'], - "archives_are_indexes": self.site.config['ARCHIVES_ARE_INDEXES'], - "create_monthly_archive": self.site.config['CREATE_MONTHLY_ARCHIVE'], - "create_single_archive": self.site.config['CREATE_SINGLE_ARCHIVE'], - "show_untranslated_posts": self.site.config['SHOW_UNTRANSLATED_POSTS'], - "create_full_archives": self.site.config['CREATE_FULL_ARCHIVES'], - "create_daily_archive": self.site.config['CREATE_DAILY_ARCHIVE'], - "pretty_urls": self.site.config['PRETTY_URLS'], - "strip_indexes": self.site.config['STRIP_INDEXES'], - "index_file": self.site.config['INDEX_FILE'], - "generate_atom": self.site.config["GENERATE_ATOM"], } - self.site.scan_posts() - yield self.group_task() - # TODO add next/prev links for years - if (kw['create_monthly_archive'] and kw['create_single_archive']) and not kw['create_full_archives']: - raise Exception('Cannot create monthly and single archives at the same time.') - for lang in kw["translations"]: - if kw['create_single_archive'] and not kw['create_full_archives']: - # if we are creating one single archive - archdata = {} - else: - # if we are not creating one single archive, start with all years - archdata = self.site.posts_per_year.copy() - if kw['create_single_archive'] or kw['create_full_archives']: - # if we are creating one single archive, or full archives - archdata[None] = self.site.posts # for create_single_archive - - for year, posts in archdata.items(): - # Filter untranslated posts (Issue #1360) - if not kw["show_untranslated_posts"]: - posts = [p for p in posts if lang in p.translated_to] - - # Add archive per year or total archive - if year: - title = kw["messages"][lang]["Posts for year %s"] % year - kw["is_feed_stale"] = (datetime.datetime.utcnow().strftime("%Y") != year) - else: - title = kw["messages"][lang]["Archive"] - kw["is_feed_stale"] = False - deps_translatable = {} - for k in self.site._GLOBAL_CONTEXT_TRANSLATABLE: - deps_translatable[k] = self.site.GLOBAL_CONTEXT[k](lang) - if not kw["create_monthly_archive"] or kw["create_full_archives"]: - yield self._generate_posts_task(kw, year, lang, posts, title, deps_translatable) - else: - months = set([(m.split('/')[1], self.site.link("archive", m, lang)) for m in self.site.posts_per_month.keys() if m.startswith(str(year))]) - months = sorted(list(months)) - months.reverse() - items = [[nikola.utils.LocaleBorg().get_month_name(int(month), lang), link] for month, link in months] - yield self._prepare_task(kw, year, lang, None, items, "list.tmpl", title, deps_translatable) - - if not kw["create_monthly_archive"] and not kw["create_full_archives"] and not kw["create_daily_archive"]: - continue # Just to avoid nesting the other loop in this if - for yearmonth, posts in self.site.posts_per_month.items(): - # Add archive per month - year, month = yearmonth.split('/') - - kw["is_feed_stale"] = (datetime.datetime.utcnow().strftime("%Y/%m") != yearmonth) - - # Filter untranslated posts (via Issue #1360) - if not kw["show_untranslated_posts"]: - posts = [p for p in posts if lang in p.translated_to] - - if kw["create_monthly_archive"] or kw["create_full_archives"]: - title = kw["messages"][lang]["Posts for {month} {year}"].format( - year=year, month=nikola.utils.LocaleBorg().get_month_name(int(month), lang)) - yield self._generate_posts_task(kw, yearmonth, lang, posts, title) - - if not kw["create_full_archives"] and not kw["create_daily_archive"]: - continue # Just to avoid nesting the other loop in this if - # Add archive per day - days = dict() - for p in posts: - if p.date.day not in days: - days[p.date.day] = list() - days[p.date.day].append(p) - for day, posts in days.items(): - title = kw["messages"][lang]["Posts for {month} {day}, {year}"].format( - year=year, month=nikola.utils.LocaleBorg().get_month_name(int(month), lang), day=day) - yield self._generate_posts_task(kw, yearmonth + '/{0:02d}'.format(day), lang, posts, title) - - if not kw['create_single_archive'] and not kw['create_full_archives']: - # And an "all your years" page for yearly and monthly archives - if "is_feed_stale" in kw: - del kw["is_feed_stale"] - years = list(self.site.posts_per_year.keys()) - years.sort(reverse=True) - kw['years'] = years - for lang in kw["translations"]: - items = [(y, self.site.link("archive", y, lang)) for y in years] - yield self._prepare_task(kw, None, lang, None, items, "list.tmpl", kw["messages"][lang]["Archive"]) - - def archive_path(self, name, lang, is_feed=False): - """Return archive paths.""" - if is_feed: - extension = ".atom" - archive_file = os.path.splitext(self.site.config['ARCHIVE_FILENAME'])[0] + extension - index_file = os.path.splitext(self.site.config['INDEX_FILE'])[0] + extension - else: - archive_file = self.site.config['ARCHIVE_FILENAME'] - index_file = self.site.config['INDEX_FILE'] - if name: - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['ARCHIVE_PATH'], name, - index_file] if _f] + page_kind = "list" + if self.show_list_as_index: + if not self.show_list_as_subcategories_list or len(hierarchy) == self.max_levels: + page_kind = "index" + if len(hierarchy) == 0: + title = kw["messages"][lang]["Archive"] + elif len(hierarchy) == 1: + title = kw["messages"][lang]["Posts for year %s"] % hierarchy[0] + elif len(hierarchy) == 2: + title = nikola.utils.LocaleBorg().format_date_in_string( + kw["messages"][lang]["Posts for {month_year}"], + datetime.date(int(hierarchy[0]), int(hierarchy[1]), 1), + lang) + elif len(hierarchy) == 3: + title = nikola.utils.LocaleBorg().format_date_in_string( + kw["messages"][lang]["Posts for {month_day_year}"], + datetime.date(int(hierarchy[0]), int(hierarchy[1]), int(hierarchy[2])), + lang) else: - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['ARCHIVE_PATH'], - archive_file] if _f] + raise Exception("Cannot interpret classification {}!".format(repr(classification))) - def archive_atom_path(self, name, lang): - """Return Atom archive paths.""" - return self.archive_path(name, lang, is_feed=True) + context = { + "title": title, + "pagekind": [page_kind, "archive_page"], + "create_archive_navigation": self.site.config["CREATE_ARCHIVE_NAVIGATION"], + "archive_name": classification + } + + # Generate links for hierarchies + if context["create_archive_navigation"]: + if hierarchy: + # Up level link makes sense only if this is not the top-level + # page (hierarchy is empty) + parent = '/'.join(hierarchy[:-1]) + context["up_archive"] = self.site.link('archive', parent, lang) + context["up_archive_name"] = self.get_classification_friendly_name(parent, lang) + else: + context["up_archive"] = None + context["up_archive_name"] = None + + nodelevel = len(hierarchy) + flat_samelevel = self.archive_navigation[lang][nodelevel] + idx = flat_samelevel.index(classification) + if idx == -1: + raise Exception("Cannot find classification {0} in flat hierarchy!".format(classification)) + previdx, nextidx = idx - 1, idx + 1 + # If the previous index is -1, or the next index is 1, the previous/next archive does not exist. + context["previous_archive"] = self.site.link('archive', flat_samelevel[previdx], lang) if previdx != -1 else None + context["previous_archive_name"] = self.get_classification_friendly_name(flat_samelevel[previdx], lang) if previdx != -1 else None + context["next_archive"] = self.site.link('archive', flat_samelevel[nextidx], lang) if nextidx != len(flat_samelevel) else None + context["next_archive_name"] = self.get_classification_friendly_name(flat_samelevel[nextidx], lang) if nextidx != len(flat_samelevel) else None + context["archive_nodelevel"] = nodelevel + context["has_archive_navigation"] = bool(context["previous_archive"] or context["up_archive"] or context["next_archive"]) + else: + context["has_archive_navigation"] = False + kw.update(context) + return context, kw + + def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None): + """Rearrange, modify or otherwise use the list of posts per classification and per language.""" + # Build a lookup table for archive navigation, if we’ll need one. + if self.site.config['CREATE_ARCHIVE_NAVIGATION']: + if flat_hierarchy_per_lang is None: + raise ValueError('Archives need flat_hierarchy_per_lang') + self.archive_navigation = {} + for lang, flat_hierarchy in flat_hierarchy_per_lang.items(): + self.archive_navigation[lang] = defaultdict(list) + for node in flat_hierarchy: + if not self.site.config["SHOW_UNTRANSLATED_POSTS"]: + if not [x for x in posts_per_classification_per_language[lang][node.classification_name] if x.is_translation_available(lang)]: + continue + self.archive_navigation[lang][len(node.classification_path)].append(node.classification_name) + + # We need to sort it. Natsort means it’s year 10000 compatible! + for k, v in self.archive_navigation[lang].items(): + self.archive_navigation[lang][k] = natsort.natsorted(v, alg=natsort.ns.F | natsort.ns.IC) + + return super().postprocess_posts_per_classification(posts_per_classification_per_language, flat_hierarchy_per_lang, hierarchy_lookup_per_lang) + + def should_generate_classification_page(self, classification, post_list, lang): + """Only generates list of posts for classification if this function returns True.""" + return classification == '' or len(post_list) > 0 + + def get_other_language_variants(self, classification, lang, classifications_per_language): + """Return a list of variants of the same classification in other languages.""" + return [(other_lang, classification) for other_lang, lookup in classifications_per_language.items() if classification in lookup and other_lang != lang] diff --git a/nikola/plugins/task/authors.plugin b/nikola/plugins/task/authors.plugin new file mode 100644 index 0000000..19e687c --- /dev/null +++ b/nikola/plugins/task/authors.plugin @@ -0,0 +1,12 @@ +[Core] +Name = classify_authors +Module = authors + +[Documentation] +Author = Juanjo Conti +Version = 0.1 +Website = http://getnikola.com +Description = Render the author pages and feeds. + +[Nikola] +PluginCategory = Taxonomy diff --git a/nikola/plugins/task/authors.py b/nikola/plugins/task/authors.py new file mode 100644 index 0000000..24fe650 --- /dev/null +++ b/nikola/plugins/task/authors.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2015-2020 Juanjo Conti and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Render the author pages and feeds.""" + + +from nikola.plugin_categories import Taxonomy +from nikola import utils + + +class ClassifyAuthors(Taxonomy): + """Classify the posts by authors.""" + + name = "classify_authors" + + classification_name = "author" + overview_page_variable_name = "authors" + more_than_one_classifications_per_post = False + has_hierarchy = False + template_for_classification_overview = "authors.tmpl" + apply_to_posts = True + apply_to_pages = False + minimum_post_count_per_classification_in_overview = 1 + omit_empty_classifications = False + add_other_languages_variable = True + path_handler_docstrings = { + 'author_index': """ Link to the authors index. + + Example: + + link://authors/ => /authors/index.html""", + 'author': """Link to an author's page. + + Example: + + link://author/joe => /authors/joe.html""", + 'author_atom': """Link to an author's Atom feed. + +Example: + +link://author_atom/joe => /authors/joe.atom""", + 'author_rss': """Link to an author's RSS feed. + +Example: + +link://author_rss/joe => /authors/joe.xml""", + } + + def set_site(self, site): + """Set Nikola site.""" + super().set_site(site) + self.show_list_as_index = site.config['AUTHOR_PAGES_ARE_INDEXES'] + self.more_than_one_classifications_per_post = site.config.get('MULTIPLE_AUTHORS_PER_POST', False) + self.template_for_single_list = "authorindex.tmpl" if self.show_list_as_index else "author.tmpl" + self.translation_manager = utils.ClassificationTranslationManager() + + def is_enabled(self, lang=None): + """Return True if this taxonomy is enabled, or False otherwise.""" + if not self.site.config["ENABLE_AUTHOR_PAGES"]: + return False + if lang is not None: + return self.generate_author_pages + return True + + def classify(self, post, lang): + """Classify the given post for the given language.""" + if self.more_than_one_classifications_per_post: + return post.authors(lang=lang) + else: + return [post.author(lang=lang)] + + def get_classification_friendly_name(self, classification, lang, only_last_component=False): + """Extract a friendly name from the classification.""" + return classification + + def get_overview_path(self, lang, dest_type='page'): + """Return a path for the list of all classifications.""" + path = self.site.config['AUTHOR_PATH'](lang) + return [component for component in path.split('/') if component], 'always' + + def get_path(self, classification, lang, dest_type='page'): + """Return a path for the given classification.""" + if self.site.config['SLUG_AUTHOR_PATH']: + slug = utils.slugify(classification, lang) + else: + slug = classification + return [self.site.config['AUTHOR_PATH'](lang), slug], 'auto' + + def provide_overview_context_and_uptodate(self, lang): + """Provide data for the context and the uptodate list for the list of all classifiations.""" + kw = { + "messages": self.site.MESSAGES, + } + context = { + "title": kw["messages"][lang]["Authors"], + "description": kw["messages"][lang]["Authors"], + "permalink": self.site.link("author_index", None, lang), + "pagekind": ["list", "authors_page"], + } + kw.update(context) + return context, kw + + def provide_context_and_uptodate(self, classification, lang, node=None): + """Provide data for the context and the uptodate list for the list of the given classifiation.""" + descriptions = self.site.config['AUTHOR_PAGES_DESCRIPTIONS'] + kw = { + "messages": self.site.MESSAGES, + } + context = { + "author": classification, + "title": kw["messages"][lang]["Posts by %s"] % classification, + "description": descriptions[lang][classification] if lang in descriptions and classification in descriptions[lang] else None, + "pagekind": ["index" if self.show_list_as_index else "list", "author_page"], + } + kw.update(context) + return context, kw + + def get_other_language_variants(self, classification, lang, classifications_per_language): + """Return a list of variants of the same author in other languages.""" + return self.translation_manager.get_translations_as_list(classification, lang, classifications_per_language) + + def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None): + """Rearrange, modify or otherwise use the list of posts per classification and per language.""" + more_than_one = False + for lang, posts_per_author in posts_per_classification_per_language.items(): + authors = set() + for author, posts in posts_per_author.items(): + for post in posts: + if not self.site.config["SHOW_UNTRANSLATED_POSTS"] and not post.is_translation_available(lang): + continue + authors.add(author) + if len(authors) > 1: + more_than_one = True + self.generate_author_pages = self.site.config["ENABLE_AUTHOR_PAGES"] and more_than_one + self.site.GLOBAL_CONTEXT["author_pages_generated"] = self.generate_author_pages + self.translation_manager.add_defaults(posts_per_classification_per_language) diff --git a/nikola/plugins/task/bundles.plugin b/nikola/plugins/task/bundles.plugin index ca997d0..939065b 100644 --- a/nikola/plugins/task/bundles.plugin +++ b/nikola/plugins/task/bundles.plugin @@ -5,9 +5,9 @@ module = bundles [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com -description = Theme bundles using WebAssets +website = https://getnikola.com/ +description = Bundle assets [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/bundles.py b/nikola/plugins/task/bundles.py index b9c57b9..aa4ce78 100644 --- a/nikola/plugins/task/bundles.py +++ b/nikola/plugins/task/bundles.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -24,38 +24,26 @@ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -"""Bundle assets using WebAssets.""" +"""Bundle assets.""" -from __future__ import unicode_literals +import configparser +import io +import itertools import os - -try: - import webassets -except ImportError: - webassets = None # NOQA +import shutil from nikola.plugin_categories import LateTask from nikola import utils class BuildBundles(LateTask): - - """Bundle assets using WebAssets.""" + """Bundle assets.""" name = "create_bundles" - def set_site(self, site): - """Set Nikola site.""" - self.logger = utils.get_logger('bundles', utils.STDERR_HANDLER) - if webassets is None and site.config['USE_BUNDLES']: - utils.req_missing(['webassets'], 'USE_BUNDLES', optional=True) - self.logger.warn('Setting USE_BUNDLES to False.') - site.config['USE_BUNDLES'] = False - super(BuildBundles, self).set_site(site) - def gen_tasks(self): - """Bundle assets using WebAssets.""" + """Bundle assets.""" kw = { 'filters': self.site.config['FILTERS'], 'output_folder': self.site.config['OUTPUT_FOLDER'], @@ -69,28 +57,21 @@ class BuildBundles(LateTask): def build_bundle(output, inputs): out_dir = os.path.join(kw['output_folder'], os.path.dirname(output)) - inputs = [os.path.relpath(i, out_dir) for i in inputs if os.path.isfile(i)] - cache_dir = os.path.join(kw['cache_folder'], 'webassets') - utils.makedirs(cache_dir) - env = webassets.Environment(out_dir, os.path.dirname(output), - cache=cache_dir) - if inputs: - bundle = webassets.Bundle(*inputs, output=os.path.basename(output)) - env.register(output, bundle) - # This generates the file - try: - env[output].urls() - except Exception as e: - self.logger.error("Failed to build bundles.") - self.logger.exception(e) - self.logger.notice("Try running ``nikola clean`` and building again.") - else: - with open(os.path.join(out_dir, os.path.basename(output)), 'wb+'): - pass # Create empty file + inputs = [ + os.path.join( + out_dir, + os.path.relpath(i, out_dir)) + for i in inputs if os.path.isfile(i) + ] + with open(os.path.join(out_dir, os.path.basename(output)), 'wb+') as out_fh: + for i in inputs: + with open(i, 'rb') as in_fh: + shutil.copyfileobj(in_fh, out_fh) + out_fh.write(b'\n') yield self.group_task() - if (webassets is not None and self.site.config['USE_BUNDLES'] is not - False): + + if self.site.config['USE_BUNDLES']: for name, _files in kw['theme_bundles'].items(): output_path = os.path.join(kw['output_folder'], name) dname = os.path.dirname(name) @@ -100,7 +81,11 @@ class BuildBundles(LateTask): files.append(os.path.join(dname, fname)) file_dep = [os.path.join(kw['output_folder'], fname) for fname in files if - utils.get_asset_path(fname, self.site.THEMES, self.site.config['FILES_FOLDERS']) or fname == os.path.join('assets', 'css', 'code.css')] + utils.get_asset_path( + fname, + self.site.THEMES, + self.site.config['FILES_FOLDERS'], + output_dir=kw['output_folder']) or fname == os.path.join('assets', 'css', 'code.css')] # code.css will be generated by us if it does not exist in # FILES_FOLDERS or theme assets. It is guaranteed that the # generation will happen before this task. @@ -123,19 +108,17 @@ class BuildBundles(LateTask): def get_theme_bundles(themes): """Given a theme chain, return the bundle definitions.""" - bundles = {} for theme_name in themes: bundles_path = os.path.join( utils.get_theme_path(theme_name), 'bundles') if os.path.isfile(bundles_path): - with open(bundles_path) as fd: - for line in fd: - try: - name, files = line.split('=') - files = [f.strip() for f in files.split(',')] - bundles[name.strip().replace('/', os.sep)] = files - except ValueError: - # for empty lines - pass - break - return bundles + config = configparser.ConfigParser() + header = io.StringIO('[bundles]\n') + with open(bundles_path, 'rt') as fd: + config.read_file(itertools.chain(header, fd)) + bundles = {} + for name, files in config['bundles'].items(): + name = name.strip().replace('/', os.sep) + files = [f.strip() for f in files.split(',') if f.strip()] + bundles[name] = files + return bundles diff --git a/nikola/plugins/task/categories.plugin b/nikola/plugins/task/categories.plugin new file mode 100644 index 0000000..be2bb79 --- /dev/null +++ b/nikola/plugins/task/categories.plugin @@ -0,0 +1,12 @@ +[Core] +name = classify_categories +module = categories + +[Documentation] +author = Roberto Alsina +version = 1.0 +website = https://getnikola.com/ +description = Render the category pages and feeds. + +[Nikola] +PluginCategory = Taxonomy diff --git a/nikola/plugins/task/categories.py b/nikola/plugins/task/categories.py new file mode 100644 index 0000000..68f9caa --- /dev/null +++ b/nikola/plugins/task/categories.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Render the category pages and feeds.""" + +import os + +from nikola.plugin_categories import Taxonomy +from nikola import utils, hierarchy_utils + + +class ClassifyCategories(Taxonomy): + """Classify the posts by categories.""" + + name = "classify_categories" + + classification_name = "category" + overview_page_variable_name = "categories" + overview_page_items_variable_name = "cat_items" + overview_page_hierarchy_variable_name = "cat_hierarchy" + more_than_one_classifications_per_post = False + has_hierarchy = True + include_posts_from_subhierarchies = True + include_posts_into_hierarchy_root = False + show_list_as_subcategories_list = False + template_for_classification_overview = "tags.tmpl" + always_disable_rss = False + always_disable_atom = False + apply_to_posts = True + apply_to_pages = False + minimum_post_count_per_classification_in_overview = 1 + omit_empty_classifications = True + add_other_languages_variable = True + path_handler_docstrings = { + 'category_index': """A link to the category index. + +Example: + +link://category_index => /categories/index.html""", + 'category': """A link to a category. Takes page number as optional keyword argument. + +Example: + +link://category/dogs => /categories/dogs.html""", + 'category_atom': """A link to a category's Atom feed. + +Example: + +link://category_atom/dogs => /categories/dogs.atom""", + 'category_rss': """A link to a category's RSS feed. + +Example: + +link://category_rss/dogs => /categories/dogs.xml""", + } + + def set_site(self, site): + """Set site, which is a Nikola instance.""" + super().set_site(site) + self.show_list_as_index = self.site.config['CATEGORY_PAGES_ARE_INDEXES'] + self.template_for_single_list = "tagindex.tmpl" if self.show_list_as_index else "tag.tmpl" + self.translation_manager = utils.ClassificationTranslationManager() + + # Needed to undo names for CATEGORY_PAGES_FOLLOW_DESTPATH + self.destpath_names_reverse = {} + for lang in self.site.config['TRANSLATIONS']: + self.destpath_names_reverse[lang] = {} + for k, v in self.site.config['CATEGORY_DESTPATH_NAMES'](lang).items(): + self.destpath_names_reverse[lang][v] = k + self.destpath_names_reverse = utils.TranslatableSetting( + '_CATEGORY_DESTPATH_NAMES_REVERSE', self.destpath_names_reverse, + self.site.config['TRANSLATIONS']) + + def is_enabled(self, lang=None): + """Return True if this taxonomy is enabled, or False otherwise.""" + return True + + def classify(self, post, lang): + """Classify the given post for the given language.""" + cat = post.meta('category', lang=lang).strip() + return [cat] if cat else [] + + def get_classification_friendly_name(self, classification, lang, only_last_component=False): + """Extract a friendly name from the classification.""" + classification = self.extract_hierarchy(classification) + return classification[-1] if classification else '' + + def get_overview_path(self, lang, dest_type='page'): + """Return a path for the list of all classifications.""" + if self.site.config['CATEGORIES_INDEX_PATH'](lang): + path = self.site.config['CATEGORIES_INDEX_PATH'](lang) + append_index = 'never' + else: + path = self.site.config['CATEGORY_PATH'](lang) + append_index = 'always' + return [component for component in path.split('/') if component], append_index + + def slugify_tag_name(self, name, lang): + """Slugify a tag name.""" + if self.site.config['SLUG_TAG_PATH']: + name = utils.slugify(name, lang) + return name + + def slugify_category_name(self, path, lang): + """Slugify a category name.""" + if self.site.config['CATEGORY_OUTPUT_FLAT_HIERARCHY']: + path = path[-1:] # only the leaf + result = [self.slugify_tag_name(part, lang) for part in path] + result[0] = self.site.config['CATEGORY_PREFIX'] + result[0] + if not self.site.config['PRETTY_URLS']: + result = ['-'.join(result)] + return result + + def get_path(self, classification, lang, dest_type='page'): + """Return a path for the given classification.""" + cat_string = '/'.join(classification) + classification_raw = classification # needed to undo CATEGORY_DESTPATH_NAMES + destpath_names_reverse = self.destpath_names_reverse(lang) + if self.site.config['CATEGORY_PAGES_FOLLOW_DESTPATH']: + base_dir = None + for post in self.site.posts_per_category[cat_string]: + if post.category_from_destpath: + base_dir = post.folder_base(lang) + # Handle CATEGORY_DESTPATH_NAMES + if cat_string in destpath_names_reverse: + cat_string = destpath_names_reverse[cat_string] + classification_raw = cat_string.split('/') + break + + if not self.site.config['CATEGORY_DESTPATH_TRIM_PREFIX']: + # If prefixes are not trimmed, we'll already have the base_dir in classification_raw + base_dir = '' + + if base_dir is None: + # fallback: first POSTS entry + classification + base_dir = self.site.config['POSTS'][0][1] + base_dir_list = base_dir.split(os.sep) + sub_dir = [self.slugify_tag_name(part, lang) for part in classification_raw] + return [_f for _f in (base_dir_list + sub_dir) if _f], 'auto' + else: + return [_f for _f in [self.site.config['CATEGORY_PATH'](lang)] if _f] + self.slugify_category_name( + classification, lang), 'auto' + + def extract_hierarchy(self, classification): + """Given a classification, return a list of parts in the hierarchy.""" + return hierarchy_utils.parse_escaped_hierarchical_category_name(classification) + + def recombine_classification_from_hierarchy(self, hierarchy): + """Given a list of parts in the hierarchy, return the classification string.""" + return hierarchy_utils.join_hierarchical_category_path(hierarchy) + + def provide_overview_context_and_uptodate(self, lang): + """Provide data for the context and the uptodate list for the list of all classifiations.""" + kw = { + 'category_path': self.site.config['CATEGORY_PATH'], + 'category_prefix': self.site.config['CATEGORY_PREFIX'], + "category_pages_are_indexes": self.site.config['CATEGORY_PAGES_ARE_INDEXES'], + "tzinfo": self.site.tzinfo, + "category_descriptions": self.site.config['CATEGORY_DESCRIPTIONS'], + "category_titles": self.site.config['CATEGORY_TITLES'], + } + context = { + "title": self.site.MESSAGES[lang]["Categories"], + "description": self.site.MESSAGES[lang]["Categories"], + "pagekind": ["list", "tags_page"], + } + kw.update(context) + return context, kw + + def provide_context_and_uptodate(self, classification, lang, node=None): + """Provide data for the context and the uptodate list for the list of the given classifiation.""" + cat_path = self.extract_hierarchy(classification) + kw = { + 'category_path': self.site.config['CATEGORY_PATH'], + 'category_prefix': self.site.config['CATEGORY_PREFIX'], + "category_pages_are_indexes": self.site.config['CATEGORY_PAGES_ARE_INDEXES'], + "tzinfo": self.site.tzinfo, + "category_descriptions": self.site.config['CATEGORY_DESCRIPTIONS'], + "category_titles": self.site.config['CATEGORY_TITLES'], + } + posts = self.site.posts_per_classification[self.classification_name][lang] + if node is None: + children = [] + else: + children = [child for child in node.children if len([post for post in posts.get(child.classification_name, []) if self.site.config['SHOW_UNTRANSLATED_POSTS'] or post.is_translation_available(lang)]) > 0] + subcats = [(child.name, self.site.link(self.classification_name, child.classification_name, lang)) for child in children] + friendly_name = self.get_classification_friendly_name(classification, lang) + context = { + "title": self.site.config['CATEGORY_TITLES'].get(lang, {}).get(classification, self.site.MESSAGES[lang]["Posts about %s"] % friendly_name), + "description": self.site.config['CATEGORY_DESCRIPTIONS'].get(lang, {}).get(classification), + "pagekind": ["tag_page", "index" if self.show_list_as_index else "list"], + "tag": friendly_name, + "category": classification, + "category_path": cat_path, + "subcategories": subcats, + } + kw.update(context) + return context, kw + + def get_other_language_variants(self, classification, lang, classifications_per_language): + """Return a list of variants of the same category in other languages.""" + return self.translation_manager.get_translations_as_list(classification, lang, classifications_per_language) + + def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None): + """Rearrange, modify or otherwise use the list of posts per classification and per language.""" + self.translation_manager.read_from_config(self.site, 'CATEGORY', posts_per_classification_per_language, False) + + def should_generate_classification_page(self, classification, post_list, lang): + """Only generates list of posts for classification if this function returns True.""" + if self.site.config["CATEGORY_PAGES_FOLLOW_DESTPATH"]: + # In destpath mode, allow users to replace the default category index with a custom page. + classification_hierarchy = self.extract_hierarchy(classification) + dest_list, _ = self.get_path(classification_hierarchy, lang) + short_destination = os.sep.join(dest_list + [self.site.config["INDEX_FILE"]]) + if short_destination in self.site.post_per_file: + return False + return True + + def should_generate_atom_for_classification_page(self, classification, post_list, lang): + """Only generates Atom feed for list of posts for classification if this function returns True.""" + return True + + def should_generate_rss_for_classification_page(self, classification, post_list, lang): + """Only generates RSS feed for list of posts for classification if this function returns True.""" + return True diff --git a/nikola/plugins/task/copy_assets.plugin b/nikola/plugins/task/copy_assets.plugin index c182150..b63581d 100644 --- a/nikola/plugins/task/copy_assets.plugin +++ b/nikola/plugins/task/copy_assets.plugin @@ -5,9 +5,9 @@ module = copy_assets [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Copy theme assets into output. [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/copy_assets.py b/nikola/plugins/task/copy_assets.py index 58521d4..c6d32c7 100644 --- a/nikola/plugins/task/copy_assets.py +++ b/nikola/plugins/task/copy_assets.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,17 +26,16 @@ """Copy theme assets into output.""" -from __future__ import unicode_literals import io import os +from nikola.packages.pygments_better_html import BetterHtmlFormatter from nikola.plugin_categories import Task from nikola import utils class CopyAssets(Task): - """Copy theme assets into output.""" name = "copy_assets" @@ -49,50 +48,64 @@ class CopyAssets(Task): """ kw = { "themes": self.site.THEMES, + "translations": self.site.translations, "files_folders": self.site.config['FILES_FOLDERS'], "output_folder": self.site.config['OUTPUT_FOLDER'], "filters": self.site.config['FILTERS'], "code_color_scheme": self.site.config['CODE_COLOR_SCHEME'], - "code.css_selectors": 'pre.code', + "code.css_selectors": ['pre.code', '.code .codetable', '.highlight pre'], + "code.css_wrappers": ['.highlight', '.code'], "code.css_head": '/* code.css file generated by Nikola */\n', - "code.css_close": "\ntable.codetable { width: 100%;} td.linenos {text-align: right; width: 4em;}\n", + "code.css_close": ( + "\ntable.codetable, table.highlighttable { width: 100%;}\n" + ".codetable td.linenos, td.linenos { text-align: right; width: 3.5em; " + "padding-right: 0.5em; background: rgba(127, 127, 127, 0.2) }\n" + ".codetable td.code, td.code { padding-left: 0.5em; }\n"), } tasks = {} code_css_path = os.path.join(kw['output_folder'], 'assets', 'css', 'code.css') code_css_input = utils.get_asset_path('assets/css/code.css', themes=kw['themes'], - files_folders=kw['files_folders']) - - kw["code.css_input"] = code_css_input - + files_folders=kw['files_folders'], output_dir=None) yield self.group_task() + main_theme = utils.get_theme_path(kw['themes'][0]) + theme_ini = utils.parse_theme_meta(main_theme) + if theme_ini: + ignored_assets = theme_ini.get("Nikola", "ignored_assets", fallback='').split(',') + ignored_assets = [os.path.normpath(asset_name.strip()) for asset_name in ignored_assets] + else: + ignored_assets = [] + for theme_name in kw['themes']: src = os.path.join(utils.get_theme_path(theme_name), 'assets') dst = os.path.join(kw['output_folder'], 'assets') for task in utils.copy_tree(src, dst): - if task['name'] in tasks: + asset_name = os.path.relpath(task['name'], dst) + if task['name'] in tasks or asset_name in ignored_assets: continue tasks[task['name']] = task task['uptodate'] = [utils.config_changed(kw, 'nikola.plugins.task.copy_assets')] task['basename'] = self.name if code_css_input: - task['file_dep'] = [code_css_input] + if 'file_dep' not in task: + task['file_dep'] = [] + task['file_dep'].append(code_css_input) yield utils.apply_filters(task, kw['filters']) # Check whether or not there is a code.css file around. - if not code_css_input: + if not code_css_input and kw['code_color_scheme']: def create_code_css(): - from pygments.formatters import get_formatter_by_name - formatter = get_formatter_by_name('html', style=kw["code_color_scheme"]) + formatter = BetterHtmlFormatter(style=kw["code_color_scheme"]) utils.makedirs(os.path.dirname(code_css_path)) - with io.open(code_css_path, 'w+', encoding='utf8') as outf: + with io.open(code_css_path, 'w+', encoding='utf-8') as outf: outf.write(kw["code.css_head"]) - outf.write(formatter.get_style_defs(kw["code.css_selectors"])) + outf.write(formatter.get_style_defs( + kw["code.css_selectors"], kw["code.css_wrappers"])) outf.write(kw["code.css_close"]) if os.path.exists(code_css_path): - with io.open(code_css_path, 'r', encoding='utf-8') as fh: + with io.open(code_css_path, 'r', encoding='utf-8-sig') as fh: testcontents = fh.read(len(kw["code.css_head"])) == kw["code.css_head"] else: testcontents = False diff --git a/nikola/plugins/task/copy_files.plugin b/nikola/plugins/task/copy_files.plugin index ce8f5d0..45c2253 100644 --- a/nikola/plugins/task/copy_files.plugin +++ b/nikola/plugins/task/copy_files.plugin @@ -5,9 +5,9 @@ module = copy_files [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Copy static files into the output. [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/copy_files.py b/nikola/plugins/task/copy_files.py index 1232248..26364d4 100644 --- a/nikola/plugins/task/copy_files.py +++ b/nikola/plugins/task/copy_files.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -33,7 +33,6 @@ from nikola import utils class CopyFiles(Task): - """Copy static files into the output folder.""" name = "copy_files" diff --git a/nikola/plugins/task/galleries.plugin b/nikola/plugins/task/galleries.plugin index 9d3fa28..d06e117 100644 --- a/nikola/plugins/task/galleries.plugin +++ b/nikola/plugins/task/galleries.plugin @@ -5,9 +5,9 @@ module = galleries [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Create image galleries automatically. [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/galleries.py b/nikola/plugins/task/galleries.py index c0df4a4..b8ac9ee 100644 --- a/nikola/plugins/task/galleries.py +++ b/nikola/plugins/task/galleries.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,38 +26,33 @@ """Render image galleries.""" -from __future__ import unicode_literals import datetime import glob import io import json import mimetypes import os -import sys -try: - from urlparse import urljoin -except ImportError: - from urllib.parse import urljoin # NOQA +from collections import OrderedDict +from urllib.parse import urljoin import natsort -try: - from PIL import Image # NOQA -except ImportError: - import Image as _Image - Image = _Image - import PyRSS2Gen as rss +from PIL import Image from nikola.plugin_categories import Task from nikola import utils from nikola.image_processing import ImageProcessor from nikola.post import Post +try: + from ruamel.yaml import YAML +except ImportError: + YAML = None + _image_size_cache = {} class Galleries(Task, ImageProcessor): - """Render image galleries.""" name = 'render_galleries' @@ -65,12 +60,11 @@ class Galleries(Task, ImageProcessor): def set_site(self, site): """Set Nikola site.""" + super().set_site(site) site.register_path_handler('gallery', self.gallery_path) site.register_path_handler('gallery_global', self.gallery_global_path) site.register_path_handler('gallery_rss', self.gallery_rss_path) - self.logger = utils.get_logger('render_galleries', utils.STDERR_HANDLER) - self.kw = { 'thumbnail_size': site.config['THUMBNAIL_SIZE'], 'max_image_size': site.config['MAX_IMAGE_SIZE'], @@ -87,6 +81,13 @@ class Galleries(Task, ImageProcessor): 'tzinfo': site.tzinfo, 'comments_in_galleries': site.config['COMMENTS_IN_GALLERIES'], 'generate_rss': site.config['GENERATE_RSS'], + 'preserve_exif_data': site.config['PRESERVE_EXIF_DATA'], + 'exif_whitelist': site.config['EXIF_WHITELIST'], + 'preserve_icc_profiles': site.config['PRESERVE_ICC_PROFILES'], + 'index_path': site.config['INDEX_PATH'], + 'disable_indexes': site.config['DISABLE_INDEXES'], + 'galleries_use_thumbnail': site.config['GALLERIES_USE_THUMBNAIL'], + 'galleries_default_thumbnail': site.config['GALLERIES_DEFAULT_THUMBNAIL'], } # Verify that no folder in GALLERY_FOLDERS appears twice @@ -94,8 +95,8 @@ class Galleries(Task, ImageProcessor): for source, dest in self.kw['gallery_folders'].items(): if source in appearing_paths or dest in appearing_paths: problem = source if source in appearing_paths else dest - utils.LOGGER.error("The gallery input or output folder '{0}' appears in more than one entry in GALLERY_FOLDERS, exiting.".format(problem)) - sys.exit(1) + utils.LOGGER.error("The gallery input or output folder '{0}' appears in more than one entry in GALLERY_FOLDERS, ignoring.".format(problem)) + continue appearing_paths.add(source) appearing_paths.add(dest) @@ -104,8 +105,6 @@ class Galleries(Task, ImageProcessor): # Create self.gallery_links self.create_galleries_paths() - return super(Galleries, self).set_site(site) - def _find_gallery_path(self, name): # The system using self.proper_gallery_links and self.improper_gallery_links # is similar as in listings.py. @@ -116,30 +115,56 @@ class Galleries(Task, ImageProcessor): if len(candidates) == 1: return candidates[0] self.logger.error("Gallery name '{0}' is not unique! Possible output paths: {1}".format(name, candidates)) + raise RuntimeError("Gallery name '{0}' is not unique! Possible output paths: {1}".format(name, candidates)) else: self.logger.error("Unknown gallery '{0}'!".format(name)) self.logger.info("Known galleries: " + str(list(self.proper_gallery_links.keys()))) - sys.exit(1) + raise RuntimeError("Unknown gallery '{0}'!".format(name)) def gallery_path(self, name, lang): - """Return a gallery path.""" + """Link to an image gallery's path. + + It will try to find a gallery with that name if it's not ambiguous + or with that path. For example: + + link://gallery/london => /galleries/trips/london/index.html + + link://gallery/trips/london => /galleries/trips/london/index.html + """ gallery_path = self._find_gallery_path(name) return [_f for _f in [self.site.config['TRANSLATIONS'][lang]] + gallery_path.split(os.sep) + [self.site.config['INDEX_FILE']] if _f] def gallery_global_path(self, name, lang): - """Return the global gallery path, which contains images.""" + """Link to the global gallery path, which contains all the images in galleries. + + There is only one copy of an image on multilingual blogs, in the site root. + + link://gallery_global/london => /galleries/trips/london/index.html + + link://gallery_global/trips/london => /galleries/trips/london/index.html + + (a ``gallery`` link could lead to eg. /en/galleries/trips/london/index.html) + """ gallery_path = self._find_gallery_path(name) return [_f for _f in gallery_path.split(os.sep) + [self.site.config['INDEX_FILE']] if _f] def gallery_rss_path(self, name, lang): - """Return path to the RSS file for a gallery.""" + """Link to an image gallery's RSS feed. + + It will try to find a gallery with that name if it's not ambiguous + or with that path. For example: + + link://gallery_rss/london => /galleries/trips/london/rss.xml + + link://gallery_rss/trips/london => /galleries/trips/london/rss.xml + """ gallery_path = self._find_gallery_path(name) return [_f for _f in [self.site.config['TRANSLATIONS'][lang]] + gallery_path.split(os.sep) + - ['rss.xml'] if _f] + [self.site.config['RSS_FILENAME_BASE'](lang) + self.site.config['RSS_EXTENSION']] if _f] def gen_tasks(self): """Render image galleries.""" @@ -147,8 +172,9 @@ class Galleries(Task, ImageProcessor): self.image_ext_list.extend(self.site.config.get('EXTRA_IMAGE_EXTENSIONS', [])) for k, v in self.site.GLOBAL_CONTEXT['template_hooks'].items(): - self.kw['||template_hooks|{0}||'.format(k)] = v._items + self.kw['||template_hooks|{0}||'.format(k)] = v.calculate_deps() + self.site.scan_posts() yield self.group_task() template_name = "gallery.tmpl" @@ -170,13 +196,6 @@ class Galleries(Task, ImageProcessor): # Create image list, filter exclusions image_list = self.get_image_list(gallery) - # Sort as needed - # Sort by date - if self.kw['sort_by_date']: - image_list.sort(key=lambda a: self.image_date(a)) - else: # Sort by name - image_list.sort() - # Create thumbnails and large images in destination for image in image_list: for task in self.create_target_images(image, input_folder): @@ -187,8 +206,6 @@ class Galleries(Task, ImageProcessor): for task in self.remove_excluded_image(image, input_folder): yield task - crumbs = utils.get_crumbs(gallery, index_folder=self) - for lang in self.kw['translations']: # save navigation links as dependencies self.kw['navigation_links|{0}'.format(lang)] = self.kw['global_context']['navigation_links'](lang) @@ -205,6 +222,12 @@ class Galleries(Task, ImageProcessor): self.kw[k] = self.site.GLOBAL_CONTEXT[k](lang) context = {} + + # Do we have a metadata file? + meta_path, order, captions, img_metadata = self.find_metadata(gallery, lang) + context['meta_path'] = meta_path + context['order'] = order + context['captions'] = captions context["lang"] = lang if post: context["title"] = post.title(lang) @@ -214,11 +237,24 @@ class Galleries(Task, ImageProcessor): image_name_list = [os.path.basename(p) for p in image_list] - if self.kw['use_filename_as_title']: + if captions: + img_titles = [] + for fn in image_name_list: + if fn in captions: + img_titles.append(captions[fn]) + else: + if self.kw['use_filename_as_title']: + img_titles.append(fn) + else: + img_titles.append('') + self.logger.debug( + "Image {0} found in gallery but not listed in {1}". + format(fn, context['meta_path'])) + elif self.kw['use_filename_as_title']: img_titles = [] for fn in image_name_list: name_without_ext = os.path.splitext(os.path.basename(fn))[0] - img_titles.append(utils.unslugify(name_without_ext)) + img_titles.append(utils.unslugify(name_without_ext, lang)) else: img_titles = [''] * len(image_name_list) @@ -230,6 +266,7 @@ class Galleries(Task, ImageProcessor): folders = [] # Generate friendly gallery names + fpost_list = [] for path, folder in folder_list: fpost = self.parse_index(path, input_folder, output_folder) if fpost: @@ -238,15 +275,25 @@ class Galleries(Task, ImageProcessor): ft = folder if not folder.endswith('/'): folder += '/' - folders.append((folder, ft)) + # TODO: This is to keep compatibility with user's custom gallery.tmpl + # To be removed in v9 someday + if self.kw['galleries_use_thumbnail']: + folders.append((folder, ft, fpost)) + if fpost: + fpost_list.append(fpost.source_path) + else: + folders.append((folder, ft)) + + context["gallery_path"] = gallery context["folders"] = natsort.natsorted( folders, alg=natsort.ns.F | natsort.ns.IC) - context["crumbs"] = crumbs + context["crumbs"] = utils.get_crumbs(gallery, index_folder=self, lang=lang) context["permalink"] = self.site.link("gallery", gallery, lang) context["enable_comments"] = self.kw['comments_in_galleries'] context["thumbnail_size"] = self.kw["thumbnail_size"] context["pagekind"] = ["gallery_front"] + context["galleries_use_thumbnail"] = self.kw['galleries_use_thumbnail'] if post: yield { @@ -273,7 +320,7 @@ class Galleries(Task, ImageProcessor): yield utils.apply_filters({ 'basename': self.name, 'name': dst, - 'file_dep': file_dep, + 'file_dep': file_dep + dest_img_list + fpost_list, 'targets': [dst], 'actions': [ (self.render_gallery_index, ( @@ -283,7 +330,7 @@ class Galleries(Task, ImageProcessor): dest_img_list, img_titles, thumbs, - file_dep))], + img_metadata))], 'clean': True, 'uptodate': [utils.config_changed({ 1: self.kw.copy(), @@ -325,7 +372,14 @@ class Galleries(Task, ImageProcessor): self.gallery_list = [] for input_folder, output_folder in self.kw['gallery_folders'].items(): for root, dirs, files in os.walk(input_folder, followlinks=True): - self.gallery_list.append((root, input_folder, output_folder)) + # If output folder is empty, the top-level gallery + # index will collide with the main page for the site. + # Don't generate the top-level gallery index in that + # case. + # FIXME: also ignore pages named index + if (output_folder or root != input_folder and + (not self.kw['disable_indexes'] and self.kw['index_path'] == '')): + self.gallery_list.append((root, input_folder, output_folder)) def create_galleries_paths(self): """Given a list of galleries, put their paths into self.gallery_links.""" @@ -377,12 +431,73 @@ class Galleries(Task, ImageProcessor): 'uptodate': [utils.config_changed(self.kw.copy(), 'nikola.plugins.task.galleries:mkdir')], } + def find_metadata(self, gallery, lang): + """Search for a gallery metadata file. + + If there is an metadata file for the gallery, use that to determine + captions and the order in which images shall be displayed in the + gallery. You only need to list the images if a specific ordering or + caption is required. The metadata file is YAML-formatted, with field + names of + # + name: + caption: + order: + # + If a numeric order value is specified, we use that directly, otherwise + we depend on how the library returns the information - which may or may not + be in the same order as in the file itself. Non-numeric ordering is not + supported. If no caption is specified, then we return an empty string. + Returns a string (l18n'd filename), list (ordering), dict (captions), + dict (image metadata). + """ + base_meta_path = os.path.join(gallery, "metadata.yml") + localized_meta_path = utils.get_translation_candidate(self.site.config, + base_meta_path, lang) + order = [] + captions = {} + custom_metadata = {} + used_path = "" + + if os.path.isfile(localized_meta_path): + used_path = localized_meta_path + elif os.path.isfile(base_meta_path): + used_path = base_meta_path + else: + return "", [], {}, {} + + self.logger.debug("Using {0} for gallery {1}".format( + used_path, gallery)) + with open(used_path, "r", encoding='utf-8-sig') as meta_file: + if YAML is None: + utils.req_missing(['ruamel.yaml'], 'use metadata.yml files for galleries') + yaml = YAML(typ='safe') + meta = yaml.load_all(meta_file) + for img in meta: + # load_all and safe_load_all both return None as their + # final element, so skip it + if not img: + continue + if 'name' in img: + img_name = img.pop('name') + if 'caption' in img and img['caption']: + captions[img_name] = img.pop('caption') + + if 'order' in img and img['order'] is not None: + order.insert(img.pop('order'), img_name) + else: + order.append(img_name) + custom_metadata[img_name] = img + else: + self.logger.error("no 'name:' for ({0}) in {1}".format( + img, used_path)) + return used_path, order, captions, custom_metadata + def parse_index(self, gallery, input_folder, output_folder): """Return a Post object if there is an index.txt.""" index_path = os.path.join(gallery, "index.txt") - destination = os.path.join( - self.kw["output_folder"], output_folder, - os.path.relpath(gallery, input_folder)) + destination = os.path.join(output_folder, + os.path.relpath(gallery, input_folder)) if os.path.isfile(index_path): post = Post( index_path, @@ -390,15 +505,20 @@ class Galleries(Task, ImageProcessor): destination, False, self.site.MESSAGES, - 'story.tmpl', - self.site.get_compiler(index_path) + 'page.tmpl', + self.site.get_compiler(index_path), + None, + self.site.metadata_extractors_by ) # If this did not exist, galleries without a title in the # index.txt file would be errorneously named `index` # (warning: galleries titled index and filenamed differently # may break) - if post.title == 'index': - post.title = os.path.split(gallery)[1] + if post.title() == 'index': + for lang in post.meta.keys(): + post.meta[lang]['title'] = os.path.split(gallery)[1] + # Register the post (via #2417) + self.site.post_per_input_file[index_path] = post else: post = None return post @@ -408,8 +528,8 @@ class Galleries(Task, ImageProcessor): exclude_path = os.path.join(gallery_path, "exclude.meta") try: - f = open(exclude_path, 'r') - excluded_image_name_list = f.read().split() + with open(exclude_path, 'r') as f: + excluded_image_name_list = f.read().split() except IOError: excluded_image_name_list = [] @@ -451,34 +571,28 @@ class Galleries(Task, ImageProcessor): ".thumbnail".join([fname, ext])) # thumb_path is "output/GALLERY_PATH/name/image_name.jpg" orig_dest_path = os.path.join(output_gallery, img_name) - yield utils.apply_filters({ - 'basename': self.name, - 'name': thumb_path, - 'file_dep': [img], - 'targets': [thumb_path], - 'actions': [ - (self.resize_image, - (img, thumb_path, self.kw['thumbnail_size'])) - ], - 'clean': True, - 'uptodate': [utils.config_changed({ - 1: self.kw['thumbnail_size'] - }, 'nikola.plugins.task.galleries:resize_thumb')], - }, self.kw['filters']) - yield utils.apply_filters({ 'basename': self.name, 'name': orig_dest_path, 'file_dep': [img], - 'targets': [orig_dest_path], + 'targets': [thumb_path, orig_dest_path], 'actions': [ (self.resize_image, - (img, orig_dest_path, self.kw['max_image_size'])) - ], + [img], { + 'dst_paths': [thumb_path, orig_dest_path], + 'max_sizes': [self.kw['thumbnail_size'], self.kw['max_image_size']], + 'bigger_panoramas': True, + 'preserve_exif_data': self.kw['preserve_exif_data'], + 'exif_whitelist': self.kw['exif_whitelist'], + 'preserve_icc_profiles': self.kw['preserve_icc_profiles']})], 'clean': True, 'uptodate': [utils.config_changed({ - 1: self.kw['max_image_size'] - }, 'nikola.plugins.task.galleries:resize_max')], + 1: self.kw['thumbnail_size'], + 2: self.kw['max_image_size'], + 3: self.kw['preserve_exif_data'], + 4: self.kw['exif_whitelist'], + 5: self.kw['preserve_icc_profiles'], + }, 'nikola.plugins.task.galleries:resize_thumb')], }, self.kw['filters']) def remove_excluded_image(self, img, input_folder): @@ -524,7 +638,7 @@ class Galleries(Task, ImageProcessor): img_list, img_titles, thumbs, - file_dep): + img_metadata): """Build the gallery index.""" # The photo array needs to be created here, because # it relies on thumbnails already being created on @@ -534,15 +648,33 @@ class Galleries(Task, ImageProcessor): url = '/'.join(os.path.relpath(p, os.path.dirname(output_name) + os.sep).split(os.sep)) return url - photo_array = [] + all_data = list(zip(img_list, thumbs, img_titles)) + + if self.kw['sort_by_date']: + all_data.sort(key=lambda a: self.image_date(a[0])) + else: # Sort by name + all_data.sort(key=lambda a: a[0]) + + if all_data: + img_list, thumbs, img_titles = zip(*all_data) + else: + img_list, thumbs, img_titles = [], [], [] + + photo_info = OrderedDict() for img, thumb, title in zip(img_list, thumbs, img_titles): w, h = _image_size_cache.get(thumb, (None, None)) if w is None: - im = Image.open(thumb) - w, h = im.size - _image_size_cache[thumb] = w, h - # Thumbs are files in output, we need URLs - photo_array.append({ + if os.path.splitext(thumb)[1] in ['.svg', '.svgz']: + w, h = 200, 200 + else: + im = Image.open(thumb) + w, h = im.size + _image_size_cache[thumb] = w, h + im.close() + # Use basename to avoid issues with multilingual sites (Issue #3078) + img_basename = os.path.basename(img) + photo_info[img_basename] = { + # Thumbs are files in output, we need URLs 'url': url_from_path(img), 'url_thumb': url_from_path(thumb), 'title': title, @@ -550,9 +682,27 @@ class Galleries(Task, ImageProcessor): 'w': w, 'h': h }, - }) + 'width': w, + 'height': h + } + if img_basename in img_metadata: + photo_info[img_basename].update(img_metadata[img_basename]) + photo_array = [] + if context['order']: + for entry in context['order']: + photo_array.append(photo_info.pop(entry)) + # Do we have any orphan entries from metadata.yml, or + # are the files from the gallery not listed in metadata.yml? + if photo_info: + for entry in photo_info: + photo_array.append(photo_info[entry]) + else: + for entry in photo_info: + photo_array.append(photo_info[entry]) + context['photo_array'] = photo_array context['photo_array_json'] = json.dumps(photo_array, sort_keys=True) + self.site.render_template(template_name, output_name, context) def gallery_rss(self, img_list, dest_img_list, img_titles, lang, permalink, output_path, title): @@ -564,6 +714,18 @@ class Galleries(Task, ImageProcessor): def make_url(url): return urljoin(self.site.config['BASE_URL'], url.lstrip('/')) + all_data = list(zip(img_list, dest_img_list, img_titles)) + + if self.kw['sort_by_date']: + all_data.sort(key=lambda a: self.image_date(a[0])) + else: # Sort by name + all_data.sort(key=lambda a: a[0]) + + if all_data: + img_list, dest_img_list, img_titles = zip(*all_data) + else: + img_list, dest_img_list, img_titles = [], [], [] + items = [] for img, srcimg, title in list(zip(dest_img_list, img_list, img_titles))[:self.kw["feed_length"]]: img_size = os.stat( @@ -587,7 +749,7 @@ class Galleries(Task, ImageProcessor): description='', lastBuildDate=datetime.datetime.utcnow(), items=items, - generator='http://getnikola.com/', + generator='https://getnikola.com/', language=lang ) @@ -598,6 +760,6 @@ class Galleries(Task, ImageProcessor): utils.makedirs(dst_dir) with io.open(output_path, "w+", encoding="utf-8") as rss_file: data = rss_obj.to_xml(encoding='utf-8') - if isinstance(data, utils.bytes_str): + if isinstance(data, bytes): data = data.decode('utf-8') rss_file.write(data) diff --git a/nikola/plugins/task/gzip.plugin b/nikola/plugins/task/gzip.plugin index 7834d22..cc078b7 100644 --- a/nikola/plugins/task/gzip.plugin +++ b/nikola/plugins/task/gzip.plugin @@ -5,9 +5,9 @@ module = gzip [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Create gzipped copies of files [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/gzip.py b/nikola/plugins/task/gzip.py index cf16f63..ebd427f 100644 --- a/nikola/plugins/task/gzip.py +++ b/nikola/plugins/task/gzip.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -35,7 +35,6 @@ from nikola.plugin_categories import TaskMultiplier class GzipFiles(TaskMultiplier): - """If appropiate, create tasks to create gzipped versions of files.""" name = "gzip" diff --git a/nikola/plugins/task/indexes.plugin b/nikola/plugins/task/indexes.plugin index d9b0e5f..f4a8f05 100644 --- a/nikola/plugins/task/indexes.plugin +++ b/nikola/plugins/task/indexes.plugin @@ -1,13 +1,12 @@ [Core] -name = render_indexes +name = classify_indexes module = indexes [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Generates the blog's index pages. [Nikola] -plugincategory = Task - +PluginCategory = Taxonomy diff --git a/nikola/plugins/task/indexes.py b/nikola/plugins/task/indexes.py index c02818e..20491fb 100644 --- a/nikola/plugins/task/indexes.py +++ b/nikola/plugins/task/indexes.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -24,145 +24,114 @@ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -"""Render the blog indexes.""" +"""Render the blog's main index.""" -from __future__ import unicode_literals -from collections import defaultdict -import os -from nikola.plugin_categories import Task -from nikola import utils +from nikola.plugin_categories import Taxonomy -class Indexes(Task): +class Indexes(Taxonomy): + """Classify for the blog's main index.""" - """Render the blog indexes.""" + name = "classify_indexes" - name = "render_indexes" + classification_name = "index" + overview_page_variable_name = None + more_than_one_classifications_per_post = False + has_hierarchy = False + show_list_as_index = True + template_for_single_list = "index.tmpl" + template_for_classification_overview = None + apply_to_posts = True + apply_to_pages = False + omit_empty_classifications = False + path_handler_docstrings = { + 'index_index': False, + 'index': """Link to a numbered index. - def set_site(self, site): - """Set Nikola site.""" - site.register_path_handler('index', self.index_path) - site.register_path_handler('index_atom', self.index_atom_path) - return super(Indexes, self).set_site(site) +Example: - def gen_tasks(self): - """Render the blog indexes.""" - self.site.scan_posts() - yield self.group_task() +link://index/3 => /index-3.html""", + 'index_atom': """Link to a numbered Atom index. - kw = { - "translations": self.site.config['TRANSLATIONS'], - "messages": self.site.MESSAGES, - "output_folder": self.site.config['OUTPUT_FOLDER'], - "filters": self.site.config['FILTERS'], - "show_untranslated_posts": self.site.config['SHOW_UNTRANSLATED_POSTS'], - "index_display_post_count": self.site.config['INDEX_DISPLAY_POST_COUNT'], - "indexes_title": self.site.config['INDEXES_TITLE'], - "blog_title": self.site.config["BLOG_TITLE"], - "generate_atom": self.site.config["GENERATE_ATOM"], - } +Example: - template_name = "index.tmpl" - posts = self.site.posts - self.number_of_pages = dict() - for lang in kw["translations"]: - def page_link(i, displayed_i, num_pages, force_addition, extension=None): - feed = "_atom" if extension == ".atom" else "" - return utils.adjust_name_for_index_link(self.site.link("index" + feed, None, lang), i, displayed_i, - lang, self.site, force_addition, extension) +link://index_atom/3 => /index-3.atom""", + 'index_rss': """A link to the RSS feed path. - def page_path(i, displayed_i, num_pages, force_addition, extension=None): - feed = "_atom" if extension == ".atom" else "" - return utils.adjust_name_for_index_path(self.site.path("index" + feed, None, lang), i, displayed_i, - lang, self.site, force_addition, extension) +Example: - if kw["show_untranslated_posts"]: - filtered_posts = posts - else: - filtered_posts = [x for x in posts if x.is_translation_available(lang)] +link://rss => /blog/rss.xml""", + } - indexes_title = kw['indexes_title'](lang) or kw['blog_title'](lang) - self.number_of_pages[lang] = (len(filtered_posts) + kw['index_display_post_count'] - 1) // kw['index_display_post_count'] + def set_site(self, site): + """Set Nikola site.""" + # Redirect automatically generated 'index_rss' path handler to 'rss' for compatibility with old rss plugin + site.register_path_handler('rss', lambda name, lang: site.path_handlers['index_rss'](name, lang)) + site.path_handlers['rss'].__doc__ = """A link to the RSS feed path. + +Example: + + link://rss => /blog/rss.xml + """.strip() + return super().set_site(site) + + def get_implicit_classifications(self, lang): + """Return a list of classification strings which should always appear in posts_per_classification.""" + return [""] + + def classify(self, post, lang): + """Classify the given post for the given language.""" + return [""] + + def get_classification_friendly_name(self, classification, lang, only_last_component=False): + """Extract a friendly name from the classification.""" + return self.site.config["BLOG_TITLE"](lang) + + def get_path(self, classification, lang, dest_type='page'): + """Return a path for the given classification.""" + if dest_type == 'rss': + return [ + self.site.config['RSS_PATH'](lang), + self.site.config['RSS_FILENAME_BASE'](lang) + ], 'auto' + if dest_type == 'feed': + return [ + self.site.config['ATOM_PATH'](lang), + self.site.config['ATOM_FILENAME_BASE'](lang) + ], 'auto' + page_number = None + if dest_type == 'page': + # Interpret argument as page number + try: + page_number = int(classification) + except (ValueError, TypeError): + pass + return [self.site.config['INDEX_PATH'](lang)], 'always', page_number + + def provide_context_and_uptodate(self, classification, lang, node=None): + """Provide data for the context and the uptodate list for the list of the given classifiation.""" + kw = { + "show_untranslated_posts": self.site.config["SHOW_UNTRANSLATED_POSTS"], + } + context = { + "title": self.site.config["INDEXES_TITLE"](lang) or self.site.config["BLOG_TITLE"](lang), + "description": self.site.config["BLOG_DESCRIPTION"](lang), + "pagekind": ["main_index", "index"], + "featured": [p for p in self.site.posts if p.post_status == 'featured' and + (lang in p.translated_to or kw["show_untranslated_posts"])], + } + kw.update(context) + return context, kw - context = {} - context["pagekind"] = ["index"] + def should_generate_classification_page(self, classification, post_list, lang): + """Only generates list of posts for classification if this function returns True.""" + return not self.site.config["DISABLE_INDEXES"] - yield self.site.generic_index_renderer(lang, filtered_posts, indexes_title, template_name, context, kw, 'render_indexes', page_link, page_path) + def should_generate_atom_for_classification_page(self, classification, post_list, lang): + """Only generates Atom feed for list of posts for classification if this function returns True.""" + return not self.site.config["DISABLE_MAIN_ATOM_FEED"] - if not self.site.config["STORY_INDEX"]: - return - kw = { - "translations": self.site.config['TRANSLATIONS'], - "post_pages": self.site.config["post_pages"], - "output_folder": self.site.config['OUTPUT_FOLDER'], - "filters": self.site.config['FILTERS'], - "index_file": self.site.config['INDEX_FILE'], - "strip_indexes": self.site.config['STRIP_INDEXES'], - } - template_name = "list.tmpl" - index_len = len(kw['index_file']) - for lang in kw["translations"]: - # Need to group by folder to avoid duplicated tasks (Issue #758) - # Group all pages by path prefix - groups = defaultdict(list) - for p in self.site.timeline: - if not p.is_post: - destpath = p.destination_path(lang) - if destpath[-(1 + index_len):] == '/' + kw['index_file']: - destpath = destpath[:-(1 + index_len)] - dirname = os.path.dirname(destpath) - groups[dirname].append(p) - for dirname, post_list in groups.items(): - context = {} - context["items"] = [] - should_render = True - output_name = os.path.join(kw['output_folder'], dirname, kw['index_file']) - short_destination = os.path.join(dirname, kw['index_file']) - link = short_destination.replace('\\', '/') - if kw['strip_indexes'] and link[-(1 + index_len):] == '/' + kw['index_file']: - link = link[:-index_len] - context["permalink"] = link - context["pagekind"] = ["list"] - if dirname == "/": - context["pagekind"].append("front_page") - - for post in post_list: - # If there is an index.html pending to be created from - # a story, do not generate the STORY_INDEX - if post.destination_path(lang) == short_destination: - should_render = False - else: - context["items"].append((post.title(lang), - post.permalink(lang))) - - if should_render: - task = self.site.generic_post_list_renderer(lang, post_list, - output_name, - template_name, - kw['filters'], - context) - task['uptodate'] = task['uptodate'] + [utils.config_changed(kw, 'nikola.plugins.task.indexes')] - task['basename'] = self.name - yield task - - def index_path(self, name, lang, is_feed=False): - """Return path to an index.""" - extension = None - if is_feed: - extension = ".atom" - index_file = os.path.splitext(self.site.config['INDEX_FILE'])[0] + extension - else: - index_file = self.site.config['INDEX_FILE'] - return utils.adjust_name_for_index_path_list([_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['INDEX_PATH'], - index_file] if _f], - name, - utils.get_displayed_page_number(name, self.number_of_pages[lang], self.site), - lang, - self.site, - extension=extension) - - def index_atom_path(self, name, lang): - """Return path to an Atom index.""" - return self.index_path(name, lang, is_feed=True) + def should_generate_rss_for_classification_page(self, classification, post_list, lang): + """Only generates RSS feed for list of posts for classification if this function returns True.""" + return not self.site.config["DISABLE_MAIN_RSS_FEED"] diff --git a/nikola/plugins/task/listings.plugin b/nikola/plugins/task/listings.plugin index 435234b..03b67d2 100644 --- a/nikola/plugins/task/listings.plugin +++ b/nikola/plugins/task/listings.plugin @@ -5,9 +5,9 @@ module = listings [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Render code listings into output [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/listings.py b/nikola/plugins/task/listings.py index 5f79724..c946313 100644 --- a/nikola/plugins/task/listings.py +++ b/nikola/plugins/task/listings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,37 +26,32 @@ """Render code listings.""" -from __future__ import unicode_literals, print_function - -import sys import os -import lxml.html +from collections import defaultdict -from pygments import highlight -from pygments.lexers import get_lexer_for_filename, TextLexer import natsort +from pygments import highlight +from pygments.lexers import get_lexer_for_filename, guess_lexer, TextLexer from nikola.plugin_categories import Task from nikola import utils class Listings(Task): - """Render code listings.""" name = "render_listings" def register_output_name(self, input_folder, rel_name, rel_output_name): """Register proper and improper file mappings.""" - if rel_name not in self.improper_input_file_mapping: - self.improper_input_file_mapping[rel_name] = [] - self.improper_input_file_mapping[rel_name].append(rel_output_name) + self.improper_input_file_mapping[rel_name].add(rel_output_name) self.proper_input_file_mapping[os.path.join(input_folder, rel_name)] = rel_output_name self.proper_input_file_mapping[rel_output_name] = rel_output_name def set_site(self, site): """Set Nikola site.""" site.register_path_handler('listing', self.listing_path) + site.register_path_handler('listing_source', self.listing_source_path) # We need to prepare some things for the listings path handler to work. @@ -75,7 +70,7 @@ class Listings(Task): if source in appearing_paths or dest in appearing_paths: problem = source if source in appearing_paths else dest utils.LOGGER.error("The listings input or output folder '{0}' appears in more than one entry in LISTINGS_FOLDERS, exiting.".format(problem)) - sys.exit(1) + continue appearing_paths.add(source) appearing_paths.add(dest) @@ -85,7 +80,7 @@ class Listings(Task): # a list is needed. This is needed for compatibility to previous Nikola # versions, where there was no need to specify the input directory name # when asking for a link via site.link('listing', ...). - self.improper_input_file_mapping = {} + self.improper_input_file_mapping = defaultdict(set) # proper_input_file_mapping maps relative input file (relative to CWD) # to a generated output file. Since we don't allow an input directory @@ -94,7 +89,7 @@ class Listings(Task): self.proper_input_file_mapping = {} for input_folder, output_folder in self.kw['listings_folders'].items(): - for root, dirs, files in os.walk(input_folder, followlinks=True): + for root, _, files in os.walk(input_folder, followlinks=True): # Compute relative path; can't use os.path.relpath() here as it returns "." instead of "" rel_path = root[len(input_folder):] if rel_path[:1] == os.sep: @@ -106,7 +101,7 @@ class Listings(Task): # Register file names in the mapping. self.register_output_name(input_folder, rel_name, rel_output_name) - return super(Listings, self).set_site(site) + return super().set_site(site) def gen_tasks(self): """Render pretty code listings.""" @@ -117,20 +112,31 @@ class Listings(Task): needs_ipython_css = False if in_name and in_name.endswith('.ipynb'): # Special handling: render ipynbs in listings (Issue #1900) - ipynb_compiler = self.site.plugin_manager.getPluginByName("ipynb", "PageCompiler").plugin_object - ipynb_raw = ipynb_compiler.compile_html_string(in_name, True) - ipynb_html = lxml.html.fromstring(ipynb_raw) - # The raw HTML contains garbage (scripts and styles), we can’t leave it in - code = lxml.html.tostring(ipynb_html.xpath('//*[@id="notebook"]')[0], encoding='unicode') + ipynb_plugin = self.site.plugin_manager.getPluginByName("ipynb", "PageCompiler") + if ipynb_plugin is None: + msg = "To use .ipynb files as listings, you must set up the Jupyter compiler in COMPILERS and POSTS/PAGES." + utils.LOGGER.error(msg) + raise ValueError(msg) + + ipynb_compiler = ipynb_plugin.plugin_object + with open(in_name, "r", encoding="utf-8-sig") as in_file: + nb_json = ipynb_compiler._nbformat_read(in_file) + code = ipynb_compiler._compile_string(nb_json) title = os.path.basename(in_name) needs_ipython_css = True elif in_name: - with open(in_name, 'r') as fd: + with open(in_name, 'r', encoding='utf-8-sig') as fd: try: lexer = get_lexer_for_filename(in_name) - except: - lexer = TextLexer() - code = highlight(fd.read(), lexer, utils.NikolaPygmentsHTML(in_name)) + except Exception: + try: + lexer = guess_lexer(fd.read()) + except Exception: + lexer = TextLexer() + fd.seek(0) + code = highlight( + fd.read(), lexer, + utils.NikolaPygmentsHTML(in_name, linenos='table')) title = os.path.basename(in_name) else: code = '' @@ -147,7 +153,7 @@ class Listings(Task): os.path.join( self.kw['output_folder'], output_folder)))) - if self.site.config['COPY_SOURCES'] and in_name: + if in_name: source_link = permalink[:-5] # remove '.html' else: source_link = None @@ -182,7 +188,7 @@ class Listings(Task): uptodate = {'c': self.site.GLOBAL_CONTEXT} for k, v in self.site.GLOBAL_CONTEXT['template_hooks'].items(): - uptodate['||template_hooks|{0}||'.format(k)] = v._items + uptodate['||template_hooks|{0}||'.format(k)] = v.calculate_deps() for k in self.site._GLOBAL_CONTEXT_TRANSLATABLE: uptodate[k] = self.site.GLOBAL_CONTEXT[k](self.kw['default_lang']) @@ -218,6 +224,8 @@ class Listings(Task): 'clean': True, }, self.kw["filters"]) for f in files: + if f == '.DS_Store': + continue ext = os.path.splitext(f)[-1] if ext in ignored_extensions: continue @@ -240,22 +248,47 @@ class Listings(Task): 'uptodate': [utils.config_changed(uptodate, 'nikola.plugins.task.listings:source')], 'clean': True, }, self.kw["filters"]) - if self.site.config['COPY_SOURCES']: - rel_name = os.path.join(rel_path, f) - rel_output_name = os.path.join(output_folder, rel_path, f) - self.register_output_name(input_folder, rel_name, rel_output_name) - out_name = os.path.join(self.kw['output_folder'], rel_output_name) - yield utils.apply_filters({ - 'basename': self.name, - 'name': out_name, - 'file_dep': [in_name], - 'targets': [out_name], - 'actions': [(utils.copy_file, [in_name, out_name])], - 'clean': True, - }, self.kw["filters"]) + + rel_name = os.path.join(rel_path, f) + rel_output_name = os.path.join(output_folder, rel_path, f) + self.register_output_name(input_folder, rel_name, rel_output_name) + out_name = os.path.join(self.kw['output_folder'], rel_output_name) + yield utils.apply_filters({ + 'basename': self.name, + 'name': out_name, + 'file_dep': [in_name], + 'targets': [out_name], + 'actions': [(utils.copy_file, [in_name, out_name])], + 'clean': True, + }, self.kw["filters"]) + + def listing_source_path(self, name, lang): + """Return a link to the source code for a listing. + + It will try to use the file name if it's not ambiguous, or the file path. + + Example: + + link://listing_source/hello.py => /listings/tutorial/hello.py + + link://listing_source/tutorial/hello.py => /listings/tutorial/hello.py + """ + result = self.listing_path(name, lang) + if result[-1].endswith('.html'): + result[-1] = result[-1][:-5] + return result def listing_path(self, namep, lang): - """Return path to a listing.""" + """Return a link to a listing. + + It will try to use the file name if it's not ambiguous, or the file path. + + Example: + + link://listing/hello.py => /listings/tutorial/hello.py.html + + link://listing/tutorial/hello.py => /listings/tutorial/hello.py.html + """ namep = namep.replace('/', os.sep) nameh = namep + '.html' for name in (namep, nameh): @@ -268,14 +301,14 @@ class Listings(Task): # ambiguities. if len(self.improper_input_file_mapping[name]) > 1: utils.LOGGER.error("Using non-unique listing name '{0}', which maps to more than one listing name ({1})!".format(name, str(self.improper_input_file_mapping[name]))) - sys.exit(1) + return ["ERROR"] if len(self.site.config['LISTINGS_FOLDERS']) > 1: - utils.LOGGER.notice("Using listings names in site.link() without input directory prefix while configuration's LISTINGS_FOLDERS has more than one entry.") - name = self.improper_input_file_mapping[name][0] + utils.LOGGER.warning("Using listings names in site.link() without input directory prefix while configuration's LISTINGS_FOLDERS has more than one entry.") + name = list(self.improper_input_file_mapping[name])[0] break else: utils.LOGGER.error("Unknown listing name {0}!".format(namep)) - sys.exit(1) + return ["ERROR"] if not name.endswith(os.sep + self.site.config["INDEX_FILE"]): name += '.html' path_parts = name.split(os.sep) diff --git a/nikola/plugins/task/page_index.plugin b/nikola/plugins/task/page_index.plugin new file mode 100644 index 0000000..42c9288 --- /dev/null +++ b/nikola/plugins/task/page_index.plugin @@ -0,0 +1,12 @@ +[Core] +name = classify_page_index +module = page_index + +[Documentation] +author = Roberto Alsina +version = 1.0 +website = https://getnikola.com/ +description = Generates the blog's index pages. + +[Nikola] +PluginCategory = Taxonomy diff --git a/nikola/plugins/task/page_index.py b/nikola/plugins/task/page_index.py new file mode 100644 index 0000000..e7b33cf --- /dev/null +++ b/nikola/plugins/task/page_index.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Render the page index.""" + + +from nikola.plugin_categories import Taxonomy + + +class PageIndex(Taxonomy): + """Classify for the page index.""" + + name = "classify_page_index" + + classification_name = "page_index_folder" + overview_page_variable_name = "page_folder" + more_than_one_classifications_per_post = False + has_hierarchy = True + include_posts_from_subhierarchies = False + show_list_as_index = False + template_for_single_list = "list.tmpl" + template_for_classification_overview = None + always_disable_rss = True + always_disable_atom = True + apply_to_posts = False + apply_to_pages = True + omit_empty_classifications = True + path_handler_docstrings = { + 'page_index_folder_index': None, + 'page_index_folder': None, + 'page_index_folder_atom': None, + 'page_index_folder_rss': None, + } + + def is_enabled(self, lang=None): + """Return True if this taxonomy is enabled, or False otherwise.""" + return self.site.config["PAGE_INDEX"] + + def classify(self, post, lang): + """Classify the given post for the given language.""" + destpath = post.destination_path(lang, sep='/') + if post.has_pretty_url(lang): + idx = '/index.html' + if destpath.endswith(idx): + destpath = destpath[:-len(idx)] + i = destpath.rfind('/') + return [destpath[:i] if i >= 0 else ''] + + def get_classification_friendly_name(self, dirname, lang, only_last_component=False): + """Extract a friendly name from the classification.""" + return dirname + + def get_path(self, hierarchy, lang, dest_type='page'): + """Return a path for the given classification.""" + return hierarchy, 'always' + + def extract_hierarchy(self, dirname): + """Given a classification, return a list of parts in the hierarchy.""" + return dirname.split('/') if dirname else [] + + def recombine_classification_from_hierarchy(self, hierarchy): + """Given a list of parts in the hierarchy, return the classification string.""" + return '/'.join(hierarchy) + + def provide_context_and_uptodate(self, dirname, lang, node=None): + """Provide data for the context and the uptodate list for the list of the given classifiation.""" + kw = { + "translations": self.site.config['TRANSLATIONS'], + "filters": self.site.config['FILTERS'], + } + context = { + "title": self.site.config['BLOG_TITLE'](lang), + "pagekind": ["list", "front_page", "page_index"] if dirname == '' else ["list", "page_index"], + "kind": "page_index_folder", + "classification": dirname, + "has_no_feeds": True, + } + kw.update(context) + return context, kw + + def should_generate_classification_page(self, dirname, post_list, lang): + """Only generates list of posts for classification if this function returns True.""" + short_destination = dirname + '/' + self.site.config['INDEX_FILE'] + for post in post_list: + # If there is an index.html pending to be created from a page, do not generate the page index. + if post.destination_path(lang, sep='/') == short_destination: + return False + return True diff --git a/nikola/plugins/task/pages.plugin b/nikola/plugins/task/pages.plugin index 023d41b..a04cd05 100644 --- a/nikola/plugins/task/pages.plugin +++ b/nikola/plugins/task/pages.plugin @@ -5,9 +5,9 @@ module = pages [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Create pages in the output. [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/pages.py b/nikola/plugins/task/pages.py index e6a8a82..0c0bdd2 100644 --- a/nikola/plugins/task/pages.py +++ b/nikola/plugins/task/pages.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,13 +26,13 @@ """Render pages into output.""" -from __future__ import unicode_literals +import os + from nikola.plugin_categories import Task -from nikola.utils import config_changed +from nikola.utils import config_changed, LOGGER class RenderPages(Task): - """Render pages into output.""" name = "render_pages" @@ -48,6 +48,13 @@ class RenderPages(Task): } self.site.scan_posts() yield self.group_task() + index_paths = {} + for lang in kw["translations"]: + index_paths[lang] = False + if not self.site.config["DISABLE_INDEXES"]: + index_paths[lang] = os.path.normpath(os.path.join(self.site.config['OUTPUT_FOLDER'], + self.site.path('index', '', lang=lang))) + for lang in kw["translations"]: for post in self.site.timeline: if not kw["show_untranslated_posts"] and not post.is_translation_available(lang): @@ -55,8 +62,14 @@ class RenderPages(Task): if post.is_post: context = {'pagekind': ['post_page']} else: - context = {'pagekind': ['story_page']} + context = {'pagekind': ['story_page', 'page_page']} for task in self.site.generic_page_renderer(lang, post, kw["filters"], context): + if task['name'] == index_paths[lang]: + # Issue 3022 + LOGGER.error( + "Post {0!r}: output path ({1}) conflicts with the blog index ({2}). " + "Please change INDEX_PATH or disable index generation.".format( + post.source_path, task['name'], index_paths[lang])) task['uptodate'] = task['uptodate'] + [config_changed(kw, 'nikola.plugins.task.pages')] task['basename'] = self.name task['task_dep'] = ['render_posts'] diff --git a/nikola/plugins/task/posts.plugin b/nikola/plugins/task/posts.plugin index 79b7c51..6893472 100644 --- a/nikola/plugins/task/posts.plugin +++ b/nikola/plugins/task/posts.plugin @@ -5,9 +5,9 @@ module = posts [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Create HTML fragments out of posts. [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/posts.py b/nikola/plugins/task/posts.py index a3a8375..5f48165 100644 --- a/nikola/plugins/task/posts.py +++ b/nikola/plugins/task/posts.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,11 +26,11 @@ """Build HTML fragments from metadata and text.""" -from copy import copy import os +from copy import copy from nikola.plugin_categories import Task -from nikola import filters, utils +from nikola import utils def update_deps(post, lang, task): @@ -44,7 +44,6 @@ def update_deps(post, lang, task): class RenderPosts(Task): - """Build HTML fragments from metadata and text.""" name = "render_posts" @@ -77,6 +76,8 @@ class RenderPosts(Task): deps_dict = copy(kw) deps_dict.pop('timeline') for post in kw['timeline']: + if not post.is_translation_available(lang) and not self.site.config['SHOW_UNTRANSLATED_POSTS']: + continue # Extra config dependencies picked from config for p in post.fragment_deps(lang): if p.startswith('####MAGIC####CONFIG:'): @@ -84,11 +85,12 @@ class RenderPosts(Task): deps_dict[k] = self.site.config.get(k) dest = post.translated_base_path(lang) file_dep = [p for p in post.fragment_deps(lang) if not p.startswith("####MAGIC####")] + extra_targets = post.compiler.get_extra_targets(post, lang, dest) task = { 'basename': self.name, 'name': dest, 'file_dep': file_dep, - 'targets': [dest], + 'targets': [dest] + extra_targets, 'actions': [(post.compile, (lang, )), (update_deps, (post, lang, )), ], @@ -106,15 +108,12 @@ class RenderPosts(Task): for i, f in enumerate(ff): if not f: continue - if f.startswith('filters.'): # A function from the filters module - f = f[8:] - try: - flist.append(getattr(filters, f)) - except AttributeError: - pass + _f = self.site.filters.get(f) + if _f is not None: # A registered filter + flist.append(_f) else: flist.append(f) - yield utils.apply_filters(task, {os.path.splitext(dest): flist}) + yield utils.apply_filters(task, {os.path.splitext(dest)[-1]: flist}) def dependence_on_timeline(self, post, lang): """Check if a post depends on the timeline.""" diff --git a/nikola/plugins/task/redirect.plugin b/nikola/plugins/task/redirect.plugin index c3137b9..57bd0c0 100644 --- a/nikola/plugins/task/redirect.plugin +++ b/nikola/plugins/task/redirect.plugin @@ -5,9 +5,9 @@ module = redirect [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Create redirect pages. [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/redirect.py b/nikola/plugins/task/redirect.py index 8530f5e..a89fbd0 100644 --- a/nikola/plugins/task/redirect.py +++ b/nikola/plugins/task/redirect.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,7 +26,6 @@ """Generate redirections.""" -from __future__ import unicode_literals import os @@ -35,7 +34,6 @@ from nikola import utils class Redirect(Task): - """Generate redirections.""" name = "redirect" @@ -46,12 +44,15 @@ class Redirect(Task): 'redirections': self.site.config['REDIRECTIONS'], 'output_folder': self.site.config['OUTPUT_FOLDER'], 'filters': self.site.config['FILTERS'], + 'index_file': self.site.config['INDEX_FILE'], } yield self.group_task() if kw['redirections']: for src, dst in kw["redirections"]: - src_path = os.path.join(kw["output_folder"], src) + src_path = os.path.join(kw["output_folder"], src.lstrip('/')) + if src_path.endswith("/"): + src_path += kw['index_file'] yield utils.apply_filters({ 'basename': self.name, 'name': src_path, diff --git a/nikola/plugins/task/robots.plugin b/nikola/plugins/task/robots.plugin index 72ce31f..51f7781 100644 --- a/nikola/plugins/task/robots.plugin +++ b/nikola/plugins/task/robots.plugin @@ -5,9 +5,9 @@ module = robots [Documentation] author = Daniel Aleksandersen version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Generate /robots.txt exclusion file and promote sitemap. [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/robots.py b/nikola/plugins/task/robots.py index 65254b6..627d436 100644 --- a/nikola/plugins/task/robots.py +++ b/nikola/plugins/task/robots.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,20 +26,15 @@ """Generate a robots.txt file.""" -from __future__ import print_function, absolute_import, unicode_literals import io import os -try: - from urlparse import urljoin, urlparse -except ImportError: - from urllib.parse import urljoin, urlparse # NOQA +from urllib.parse import urljoin, urlparse from nikola.plugin_categories import LateTask from nikola import utils class RobotsFile(LateTask): - """Generate a robots.txt file.""" name = "robots_file" @@ -60,18 +55,20 @@ class RobotsFile(LateTask): def write_robots(): if kw["site_url"] != urljoin(kw["site_url"], "/"): - utils.LOGGER.warn('robots.txt not ending up in server root, will be useless') + utils.LOGGER.warning('robots.txt not ending up in server root, will be useless') + utils.LOGGER.info('Add "robots" to DISABLED_PLUGINS to disable this warning and robots.txt generation.') with io.open(robots_path, 'w+', encoding='utf8') as outf: outf.write("Sitemap: {0}\n\n".format(sitemapindex_url)) + outf.write("User-Agent: *\n") if kw["robots_exclusions"]: - outf.write("User-Agent: *\n") for loc in kw["robots_exclusions"]: outf.write("Disallow: {0}\n".format(loc)) + outf.write("Host: {0}\n".format(urlparse(kw["base_url"]).netloc)) yield self.group_task() - if not utils.get_asset_path("robots.txt", [], files_folders=kw["files_folders"]): + if not utils.get_asset_path("robots.txt", [], files_folders=kw["files_folders"], output_dir=False): yield utils.apply_filters({ "basename": self.name, "name": robots_path, @@ -82,6 +79,6 @@ class RobotsFile(LateTask): "task_dep": ["sitemap"] }, kw["filters"]) elif kw["robots_exclusions"]: - utils.LOGGER.warn('Did not generate robots.txt as one already exists in FILES_FOLDERS. ROBOTS_EXCLUSIONS will not have any affect on the copied fie.') + utils.LOGGER.warning('Did not generate robots.txt as one already exists in FILES_FOLDERS. ROBOTS_EXCLUSIONS will not have any affect on the copied file.') else: utils.LOGGER.debug('Did not generate robots.txt as one already exists in FILES_FOLDERS.') diff --git a/nikola/plugins/task/rss.plugin b/nikola/plugins/task/rss.plugin deleted file mode 100644 index cf9b7a7..0000000 --- a/nikola/plugins/task/rss.plugin +++ /dev/null @@ -1,13 +0,0 @@ -[Core] -name = generate_rss -module = rss - -[Documentation] -author = Roberto Alsina -version = 1.0 -website = http://getnikola.com -description = Generate RSS feeds. - -[Nikola] -plugincategory = Task - diff --git a/nikola/plugins/task/rss.py b/nikola/plugins/task/rss.py deleted file mode 100644 index 9020a06..0000000 --- a/nikola/plugins/task/rss.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright © 2012-2015 Roberto Alsina and others. - -# Permission is hereby granted, free of charge, to any -# person obtaining a copy of this software and associated -# documentation files (the "Software"), to deal in the -# Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the -# Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice -# shall be included in all copies or substantial portions of -# the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY -# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE -# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR -# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS -# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR -# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -"""Generate RSS feeds.""" - -from __future__ import unicode_literals, print_function -import os -try: - from urlparse import urljoin -except ImportError: - from urllib.parse import urljoin # NOQA - -from nikola import utils -from nikola.plugin_categories import Task - - -class GenerateRSS(Task): - - """Generate RSS feeds.""" - - name = "generate_rss" - - def set_site(self, site): - """Set Nikola site.""" - site.register_path_handler('rss', self.rss_path) - return super(GenerateRSS, self).set_site(site) - - def gen_tasks(self): - """Generate RSS feeds.""" - kw = { - "translations": self.site.config["TRANSLATIONS"], - "filters": self.site.config["FILTERS"], - "blog_title": self.site.config["BLOG_TITLE"], - "site_url": self.site.config["SITE_URL"], - "base_url": self.site.config["BASE_URL"], - "blog_description": self.site.config["BLOG_DESCRIPTION"], - "output_folder": self.site.config["OUTPUT_FOLDER"], - "rss_teasers": self.site.config["RSS_TEASERS"], - "rss_plain": self.site.config["RSS_PLAIN"], - "show_untranslated_posts": self.site.config['SHOW_UNTRANSLATED_POSTS'], - "feed_length": self.site.config['FEED_LENGTH'], - "tzinfo": self.site.tzinfo, - "rss_read_more_link": self.site.config["RSS_READ_MORE_LINK"], - "rss_links_append_query": self.site.config["RSS_LINKS_APPEND_QUERY"], - } - self.site.scan_posts() - # Check for any changes in the state of use_in_feeds for any post. - # Issue #934 - kw['use_in_feeds_status'] = ''.join( - ['T' if x.use_in_feeds else 'F' for x in self.site.timeline] - ) - yield self.group_task() - for lang in kw["translations"]: - output_name = os.path.join(kw['output_folder'], - self.site.path("rss", None, lang)) - deps = [] - deps_uptodate = [] - if kw["show_untranslated_posts"]: - posts = self.site.posts[:kw['feed_length']] - else: - posts = [x for x in self.site.posts if x.is_translation_available(lang)][:kw['feed_length']] - for post in posts: - deps += post.deps(lang) - deps_uptodate += post.deps_uptodate(lang) - - feed_url = urljoin(self.site.config['BASE_URL'], self.site.link("rss", None, lang).lstrip('/')) - - task = { - 'basename': 'generate_rss', - 'name': os.path.normpath(output_name), - 'file_dep': deps, - 'targets': [output_name], - 'actions': [(utils.generic_rss_renderer, - (lang, kw["blog_title"](lang), kw["site_url"], - kw["blog_description"](lang), posts, output_name, - kw["rss_teasers"], kw["rss_plain"], kw['feed_length'], feed_url, - None, kw["rss_links_append_query"]))], - - 'task_dep': ['render_posts'], - 'clean': True, - 'uptodate': [utils.config_changed(kw, 'nikola.plugins.task.rss')] + deps_uptodate, - } - yield utils.apply_filters(task, kw['filters']) - - def rss_path(self, name, lang): - """Return RSS path.""" - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['RSS_PATH'], 'rss.xml'] if _f] diff --git a/nikola/plugins/task/scale_images.plugin b/nikola/plugins/task/scale_images.plugin index d906b8c..332f583 100644 --- a/nikola/plugins/task/scale_images.plugin +++ b/nikola/plugins/task/scale_images.plugin @@ -5,9 +5,9 @@ module = scale_images [Documentation] author = Pelle Nilsson version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Create down-scaled images and thumbnails. [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/scale_images.py b/nikola/plugins/task/scale_images.py index 22ed2ab..fa3a67b 100644 --- a/nikola/plugins/task/scale_images.py +++ b/nikola/plugins/task/scale_images.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2014-2015 Pelle Nilsson and others. +# Copyright © 2014-2020 Pelle Nilsson and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -34,34 +34,28 @@ from nikola import utils class ScaleImage(Task, ImageProcessor): - """Resize images and create thumbnails for them.""" name = "scale_images" - def set_site(self, site): - """Set Nikola site.""" - self.logger = utils.get_logger('scale_images', utils.STDERR_HANDLER) - return super(ScaleImage, self).set_site(site) - def process_tree(self, src, dst): """Process all images in a src tree and put the (possibly) rescaled images in the dst folder.""" - ignore = set(['.svn']) + thumb_fmt = self.kw['image_thumbnail_format'] base_len = len(src.split(os.sep)) for root, dirs, files in os.walk(src, followlinks=True): root_parts = root.split(os.sep) - if set(root_parts) & ignore: - continue dst_dir = os.path.join(dst, *root_parts[base_len:]) utils.makedirs(dst_dir) for src_name in files: - if src_name in ('.DS_Store', 'Thumbs.db'): - continue if (not src_name.lower().endswith(tuple(self.image_ext_list)) and not src_name.upper().endswith(tuple(self.image_ext_list))): continue dst_file = os.path.join(dst_dir, src_name) src_file = os.path.join(root, src_name) - thumb_file = '.thumbnail'.join(os.path.splitext(dst_file)) + thumb_name, thumb_ext = os.path.splitext(src_name) + thumb_file = os.path.join(dst_dir, thumb_fmt.format( + name=thumb_name, + ext=thumb_ext, + )) yield { 'name': dst_file, 'file_dep': [src_file], @@ -72,17 +66,28 @@ class ScaleImage(Task, ImageProcessor): def process_image(self, src, dst, thumb): """Resize an image.""" - self.resize_image(src, dst, self.kw['max_image_size'], False) - self.resize_image(src, thumb, self.kw['image_thumbnail_size'], False) + self.resize_image( + src, + dst_paths=[dst, thumb], + max_sizes=[self.kw['max_image_size'], self.kw['image_thumbnail_size']], + bigger_panoramas=True, + preserve_exif_data=self.kw['preserve_exif_data'], + exif_whitelist=self.kw['exif_whitelist'], + preserve_icc_profiles=self.kw['preserve_icc_profiles'] + ) def gen_tasks(self): """Copy static files into the output folder.""" self.kw = { 'image_thumbnail_size': self.site.config['IMAGE_THUMBNAIL_SIZE'], + 'image_thumbnail_format': self.site.config['IMAGE_THUMBNAIL_FORMAT'], 'max_image_size': self.site.config['MAX_IMAGE_SIZE'], 'image_folders': self.site.config['IMAGE_FOLDERS'], 'output_folder': self.site.config['OUTPUT_FOLDER'], 'filters': self.site.config['FILTERS'], + 'preserve_exif_data': self.site.config['PRESERVE_EXIF_DATA'], + 'exif_whitelist': self.site.config['EXIF_WHITELIST'], + 'preserve_icc_profiles': self.site.config['PRESERVE_ICC_PROFILES'], } self.image_ext_list = self.image_ext_list_builtin diff --git a/nikola/plugins/task/sitemap.plugin b/nikola/plugins/task/sitemap.plugin index e3c991f..c8aa832 100644 --- a/nikola/plugins/task/sitemap.plugin +++ b/nikola/plugins/task/sitemap.plugin @@ -5,9 +5,9 @@ module = sitemap [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Generate google sitemap. [Nikola] -plugincategory = Task +PluginCategory = Task diff --git a/nikola/plugins/task/sitemap.py b/nikola/plugins/task/sitemap.py new file mode 100644 index 0000000..8bbaa63 --- /dev/null +++ b/nikola/plugins/task/sitemap.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Generate a sitemap.""" + +import datetime +import io +import os +import urllib.robotparser as robotparser +from urllib.parse import urljoin, urlparse + +import dateutil.tz + +from nikola.plugin_categories import LateTask +from nikola.utils import apply_filters, config_changed, encodelink + + +urlset_header = """ + +""" + +loc_format = """ + {0} + {1}{2} + +""" + +urlset_footer = "" + +sitemapindex_header = """ + +""" + +sitemap_format = """ + {0} + {1} + +""" + +alternates_format = """\n """ + + +sitemapindex_footer = "" + + +def get_base_path(base): + """Return the path of a base URL if it contains one. + + >>> get_base_path('http://some.site') == '/' + True + >>> get_base_path('http://some.site/') == '/' + True + >>> get_base_path('http://some.site/some/sub-path') == '/some/sub-path/' + True + >>> get_base_path('http://some.site/some/sub-path/') == '/some/sub-path/' + True + """ + # first parse the base_url for some path + base_parsed = urlparse(base) + + if not base_parsed.path: + sub_path = '' + else: + sub_path = base_parsed.path + if sub_path.endswith('/'): + return sub_path + else: + return sub_path + '/' + + +class Sitemap(LateTask): + """Generate a sitemap.""" + + name = "sitemap" + + def gen_tasks(self): + """Generate a sitemap.""" + kw = { + "base_url": self.site.config["BASE_URL"], + "site_url": self.site.config["SITE_URL"], + "output_folder": self.site.config["OUTPUT_FOLDER"], + "strip_indexes": self.site.config["STRIP_INDEXES"], + "index_file": self.site.config["INDEX_FILE"], + "mapped_extensions": self.site.config.get('MAPPED_EXTENSIONS', ['.atom', '.html', '.htm', '.php', '.xml', '.rss']), + "robots_exclusions": self.site.config["ROBOTS_EXCLUSIONS"], + "filters": self.site.config["FILTERS"], + "translations": self.site.config["TRANSLATIONS"], + "tzinfo": self.site.config['__tzinfo__'], + "sitemap_plugin_revision": 1, + } + + output = kw['output_folder'] + base_url = kw['base_url'] + mapped_exts = kw['mapped_extensions'] + + output_path = kw['output_folder'] + sitemapindex_path = os.path.join(output_path, "sitemapindex.xml") + sitemap_path = os.path.join(output_path, "sitemap.xml") + base_path = get_base_path(kw['base_url']) + sitemapindex = {} + urlset = {} + + def scan_locs(): + """Scan site locations.""" + for root, dirs, files in os.walk(output, followlinks=True): + if not dirs and not files: + continue # Totally empty, not on sitemap + path = os.path.relpath(root, output) + # ignore the current directory. + if path == '.': + path = syspath = '' + else: + syspath = path + os.sep + path = path.replace(os.sep, '/') + '/' + lastmod = self.get_lastmod(root) + loc = urljoin(base_url, base_path + path) + if kw['index_file'] in files and kw['strip_indexes']: # ignore folders when not stripping urls + post = self.site.post_per_file.get(syspath + kw['index_file']) + if post and (post.is_draft or post.is_private or post.publish_later): + continue + alternates = [] + if post: + for lang in post.translated_to: + alt_url = post.permalink(lang=lang, absolute=True) + if encodelink(loc) == alt_url: + continue + alternates.append(alternates_format.format(lang, alt_url)) + urlset[loc] = loc_format.format(encodelink(loc), lastmod, ''.join(alternates)) + for fname in files: + if kw['strip_indexes'] and fname == kw['index_file']: + continue # We already mapped the folder + if os.path.splitext(fname)[-1] in mapped_exts: + real_path = os.path.join(root, fname) + path = syspath = os.path.relpath(real_path, output) + if path.endswith(kw['index_file']) and kw['strip_indexes']: + # ignore index files when stripping urls + continue + if not robot_fetch(path): + continue + + # read in binary mode to make ancient files work + with open(real_path, 'rb') as fh: + filehead = fh.read(1024) + + if path.endswith('.html') or path.endswith('.htm') or path.endswith('.php'): + # Ignores "html" files without doctype + if b' - -""" - -loc_format = """ - {0} - {1}{2} - -""" - -urlset_footer = "" - -sitemapindex_header = """ - -""" - -sitemap_format = """ - {0} - {1} - -""" - -alternates_format = """\n """ - - -sitemapindex_footer = "" - - -def get_base_path(base): - """Return the path of a base URL if it contains one. - - >>> get_base_path('http://some.site') == '/' - True - >>> get_base_path('http://some.site/') == '/' - True - >>> get_base_path('http://some.site/some/sub-path') == '/some/sub-path/' - True - >>> get_base_path('http://some.site/some/sub-path/') == '/some/sub-path/' - True - """ - # first parse the base_url for some path - base_parsed = urlparse(base) - - if not base_parsed.path: - sub_path = '' - else: - sub_path = base_parsed.path - if sub_path.endswith('/'): - return sub_path - else: - return sub_path + '/' - - -class Sitemap(LateTask): - - """Generate a sitemap.""" - - name = "sitemap" - - def gen_tasks(self): - """Generate a sitemap.""" - kw = { - "base_url": self.site.config["BASE_URL"], - "site_url": self.site.config["SITE_URL"], - "output_folder": self.site.config["OUTPUT_FOLDER"], - "strip_indexes": self.site.config["STRIP_INDEXES"], - "index_file": self.site.config["INDEX_FILE"], - "sitemap_include_fileless_dirs": self.site.config["SITEMAP_INCLUDE_FILELESS_DIRS"], - "mapped_extensions": self.site.config.get('MAPPED_EXTENSIONS', ['.atom', '.html', '.htm', '.php', '.xml', '.rss']), - "robots_exclusions": self.site.config["ROBOTS_EXCLUSIONS"], - "filters": self.site.config["FILTERS"], - "translations": self.site.config["TRANSLATIONS"], - "tzinfo": self.site.config['__tzinfo__'], - "sitemap_plugin_revision": 1, - } - - output = kw['output_folder'] - base_url = kw['base_url'] - mapped_exts = kw['mapped_extensions'] - - output_path = kw['output_folder'] - sitemapindex_path = os.path.join(output_path, "sitemapindex.xml") - sitemap_path = os.path.join(output_path, "sitemap.xml") - base_path = get_base_path(kw['base_url']) - sitemapindex = {} - urlset = {} - - def scan_locs(): - """Scan site locations.""" - for root, dirs, files in os.walk(output, followlinks=True): - if not dirs and not files and not kw['sitemap_include_fileless_dirs']: - continue # Totally empty, not on sitemap - path = os.path.relpath(root, output) - # ignore the current directory. - path = (path.replace(os.sep, '/') + '/').replace('./', '') - lastmod = self.get_lastmod(root) - loc = urljoin(base_url, base_path + path) - if kw['index_file'] in files and kw['strip_indexes']: # ignore folders when not stripping urls - post = self.site.post_per_file.get(path + kw['index_file']) - if post and (post.is_draft or post.is_private or post.publish_later): - continue - alternates = [] - if post: - for lang in kw['translations']: - alt_url = post.permalink(lang=lang, absolute=True) - if loc == alt_url: - continue - alternates.append(alternates_format.format(lang, alt_url)) - urlset[loc] = loc_format.format(loc, lastmod, ''.join(alternates)) - for fname in files: - if kw['strip_indexes'] and fname == kw['index_file']: - continue # We already mapped the folder - if os.path.splitext(fname)[-1] in mapped_exts: - real_path = os.path.join(root, fname) - path = os.path.relpath(real_path, output) - if path.endswith(kw['index_file']) and kw['strip_indexes']: - # ignore index files when stripping urls - continue - if not robot_fetch(path): - continue - - # read in binary mode to make ancient files work - fh = open(real_path, 'rb') - filehead = fh.read(1024) - fh.close() - - if path.endswith('.html') or path.endswith('.htm') or path.endswith('.php'): - """ ignores "html" files without doctype """ - if b' 0: - for slug in intersect: - utils.LOGGER.error("Category '{0}' and tag '{1}' both have the same slug '{2}'!".format('/'.join(categories[slug]), tags[slug], slug)) - sys.exit(1) - - # Test for category slug clashes - categories = {} - for category in self.site.posts_per_category.keys(): - slug = tuple(self.slugify_category_name(category)) - for part in slug: - if len(part) == 0: - utils.LOGGER.error("Category '{0}' yields invalid slug '{1}'!".format(category, '/'.join(slug))) - sys.exit(1) - if slug in categories: - other_category = categories[slug] - utils.LOGGER.error('You have categories that are too similar: {0} and {1}'.format(category, other_category)) - utils.LOGGER.error('Category {0} is used in: {1}'.format(category, ', '.join([p.source_path for p in self.site.posts_per_category[category]]))) - utils.LOGGER.error('Category {0} is used in: {1}'.format(other_category, ', '.join([p.source_path for p in self.site.posts_per_category[other_category]]))) - sys.exit(1) - categories[slug] = category - - tag_list = list(self.site.posts_per_tag.items()) - cat_list = list(self.site.posts_per_category.items()) - - def render_lists(tag, posts, is_category=True): - """Render tag pages as RSS files and lists/indexes.""" - post_list = sorted(posts, key=lambda a: a.date) - post_list.reverse() - for lang in kw["translations"]: - if kw["show_untranslated_posts"]: - filtered_posts = post_list - else: - filtered_posts = [x for x in post_list if x.is_translation_available(lang)] - if kw["generate_rss"]: - yield self.tag_rss(tag, lang, filtered_posts, kw, is_category) - # Render HTML - if kw['category_pages_are_indexes'] if is_category else kw['tag_pages_are_indexes']: - yield self.tag_page_as_index(tag, lang, filtered_posts, kw, is_category) - else: - yield self.tag_page_as_list(tag, lang, filtered_posts, kw, is_category) - - for tag, posts in tag_list: - for task in render_lists(tag, posts, False): - yield task - - for path, posts in cat_list: - for task in render_lists(path, posts, True): - yield task - - # Tag cloud json file - tag_cloud_data = {} - for tag, posts in self.site.posts_per_tag.items(): - if tag in self.site.config['HIDDEN_TAGS']: - continue - tag_posts = dict(posts=[{'title': post.meta[post.default_lang]['title'], - 'date': post.date.strftime('%m/%d/%Y'), - 'isodate': post.date.isoformat(), - 'url': post.permalink(post.default_lang)} - for post in reversed(sorted(self.site.timeline, key=lambda post: post.date)) - if tag in post.alltags]) - tag_cloud_data[tag] = [len(posts), self.site.link( - 'tag', tag, self.site.config['DEFAULT_LANG']), tag_posts] - output_name = os.path.join(kw['output_folder'], - 'assets', 'js', 'tag_cloud_data.json') - - def write_tag_data(data): - """Write tag data into JSON file, for use in tag clouds.""" - utils.makedirs(os.path.dirname(output_name)) - with open(output_name, 'w+') as fd: - json.dump(data, fd) - - if self.site.config['WRITE_TAG_CLOUD']: - task = { - 'basename': str(self.name), - 'name': str(output_name) - } - - task['uptodate'] = [utils.config_changed(tag_cloud_data, 'nikola.plugins.task.tags:tagdata')] - task['targets'] = [output_name] - task['actions'] = [(write_tag_data, [tag_cloud_data])] - task['clean'] = True - yield utils.apply_filters(task, kw['filters']) - - def _create_tags_page(self, kw, include_tags=True, include_categories=True): - """Create a global "all your tags/categories" page for each language.""" - categories = [cat.category_name for cat in self.site.category_hierarchy] - has_categories = (categories != []) and include_categories - template_name = "tags.tmpl" - kw = kw.copy() - if include_categories: - kw['categories'] = categories - for lang in kw["translations"]: - tags = natsort.natsorted([tag for tag in self.site.tags_per_language[lang] - if len(self.site.posts_per_tag[tag]) >= kw["taglist_minimum_post_count"]], - alg=natsort.ns.F | natsort.ns.IC) - has_tags = (tags != []) and include_tags - if include_tags: - kw['tags'] = tags - output_name = os.path.join( - kw['output_folder'], self.site.path('tag_index' if has_tags else 'category_index', None, lang)) - output_name = output_name - context = {} - if has_categories and has_tags: - context["title"] = kw["messages"][lang]["Tags and Categories"] - elif has_categories: - context["title"] = kw["messages"][lang]["Categories"] - else: - context["title"] = kw["messages"][lang]["Tags"] - if has_tags: - context["items"] = [(tag, self.site.link("tag", tag, lang)) for tag - in tags] - else: - context["items"] = None - if has_categories: - context["cat_items"] = [(tag, self.site.link("category", tag, lang)) for tag - in categories] - context['cat_hierarchy'] = [(node.name, node.category_name, node.category_path, - self.site.link("category", node.category_name), - node.indent_levels, node.indent_change_before, - node.indent_change_after) - for node in self.site.category_hierarchy] - else: - context["cat_items"] = None - context["permalink"] = self.site.link("tag_index" if has_tags else "category_index", None, lang) - context["description"] = context["title"] - context["pagekind"] = ["list", "tags_page"] - task = self.site.generic_post_list_renderer( - lang, - [], - output_name, - template_name, - kw['filters'], - context, - ) - task['uptodate'] = task['uptodate'] + [utils.config_changed(kw, 'nikola.plugins.task.tags:page')] - task['basename'] = str(self.name) - yield task - - def list_tags_page(self, kw): - """Create a global "all your tags/categories" page for each language.""" - if self.site.config['TAG_PATH'] == self.site.config['CATEGORY_PATH']: - yield self._create_tags_page(kw, True, True) - else: - yield self._create_tags_page(kw, False, True) - yield self._create_tags_page(kw, True, False) - - def _get_title(self, tag, is_category): - if is_category: - return self.site.parse_category_name(tag)[-1] - else: - return tag - - def _get_description(self, tag, is_category, lang): - descriptions = self.site.config['CATEGORY_PAGES_DESCRIPTIONS'] if is_category else self.site.config['TAG_PAGES_DESCRIPTIONS'] - return descriptions[lang][tag] if lang in descriptions and tag in descriptions[lang] else None - - def _get_subcategories(self, category): - node = self.site.category_hierarchy_lookup[category] - return [(child.name, self.site.link("category", child.category_name)) for child in node.children] +Example: - def tag_page_as_index(self, tag, lang, post_list, kw, is_category): - """Render a sort of index page collection using only this tag's posts.""" - kind = "category" if is_category else "tag" +link://tag_index => /tags/index.html""", + 'tag': """A link to a tag's page. Takes page number as optional keyword argument. - def page_link(i, displayed_i, num_pages, force_addition, extension=None): - feed = "_atom" if extension == ".atom" else "" - return utils.adjust_name_for_index_link(self.site.link(kind + feed, tag, lang), i, displayed_i, lang, self.site, force_addition, extension) +Example: - def page_path(i, displayed_i, num_pages, force_addition, extension=None): - feed = "_atom" if extension == ".atom" else "" - return utils.adjust_name_for_index_path(self.site.path(kind + feed, tag, lang), i, displayed_i, lang, self.site, force_addition, extension) +link://tag/cats => /tags/cats.html""", + 'tag_atom': """A link to a tag's Atom feed. - context_source = {} - title = self._get_title(tag, is_category) - if kw["generate_rss"]: - # On a tag page, the feeds include the tag's feeds - rss_link = ("""""".format( - title, lang, self.site.link(kind + "_rss", tag, lang))) - context_source['rss_link'] = rss_link - if is_category: - context_source["category"] = tag - context_source["category_path"] = self.site.parse_category_name(tag) - context_source["tag"] = title - indexes_title = kw["messages"][lang]["Posts about %s"] % title - context_source["description"] = self._get_description(tag, is_category, lang) - if is_category: - context_source["subcategories"] = self._get_subcategories(tag) - context_source["pagekind"] = ["index", "tag_page"] - template_name = "tagindex.tmpl" +Example: - yield self.site.generic_index_renderer(lang, post_list, indexes_title, template_name, context_source, kw, str(self.name), page_link, page_path) +link://tag_atom/cats => /tags/cats.atom""", + 'tag_rss': """A link to a tag's RSS feed. - def tag_page_as_list(self, tag, lang, post_list, kw, is_category): - """Render a single flat link list with this tag's posts.""" - kind = "category" if is_category else "tag" - template_name = "tag.tmpl" - output_name = os.path.join(kw['output_folder'], self.site.path( - kind, tag, lang)) - context = {} - context["lang"] = lang - title = self._get_title(tag, is_category) - if is_category: - context["category"] = tag - context["category_path"] = self.site.parse_category_name(tag) - context["tag"] = title - context["title"] = kw["messages"][lang]["Posts about %s"] % title - context["posts"] = post_list - context["permalink"] = self.site.link(kind, tag, lang) - context["kind"] = kind - context["description"] = self._get_description(tag, is_category, lang) - if is_category: - context["subcategories"] = self._get_subcategories(tag) - context["pagekind"] = ["list", "tag_page"] - task = self.site.generic_post_list_renderer( - lang, - post_list, - output_name, - template_name, - kw['filters'], - context, - ) - task['uptodate'] = task['uptodate'] + [utils.config_changed(kw, 'nikola.plugins.task.tags:list')] - task['basename'] = str(self.name) - yield task +Example: - def tag_rss(self, tag, lang, posts, kw, is_category): - """Create a RSS feed for a single tag in a given language.""" - kind = "category" if is_category else "tag" - # Render RSS - output_name = os.path.normpath( - os.path.join(kw['output_folder'], - self.site.path(kind + "_rss", tag, lang))) - feed_url = urljoin(self.site.config['BASE_URL'], self.site.link(kind + "_rss", tag, lang).lstrip('/')) - deps = [] - deps_uptodate = [] - post_list = sorted(posts, key=lambda a: a.date) - post_list.reverse() - for post in post_list: - deps += post.deps(lang) - deps_uptodate += post.deps_uptodate(lang) - task = { - 'basename': str(self.name), - 'name': output_name, - 'file_dep': deps, - 'targets': [output_name], - 'actions': [(utils.generic_rss_renderer, - (lang, "{0} ({1})".format(kw["blog_title"](lang), self._get_title(tag, is_category)), - kw["site_url"], None, post_list, - output_name, kw["rss_teasers"], kw["rss_plain"], kw['feed_length'], - feed_url, None, kw["rss_link_append_query"]))], - 'clean': True, - 'uptodate': [utils.config_changed(kw, 'nikola.plugins.task.tags:rss')] + deps_uptodate, - 'task_dep': ['render_posts'], - } - return utils.apply_filters(task, kw['filters']) +link://tag_rss/cats => /tags/cats.xml""", + } - def slugify_tag_name(self, name): + def set_site(self, site): + """Set site, which is a Nikola instance.""" + super().set_site(site) + self.show_list_as_index = self.site.config['TAG_PAGES_ARE_INDEXES'] + self.template_for_single_list = "tagindex.tmpl" if self.show_list_as_index else "tag.tmpl" + self.minimum_post_count_per_classification_in_overview = self.site.config['TAGLIST_MINIMUM_POSTS'] + self.translation_manager = utils.ClassificationTranslationManager() + + def is_enabled(self, lang=None): + """Return True if this taxonomy is enabled, or False otherwise.""" + return True + + def classify(self, post, lang): + """Classify the given post for the given language.""" + return post.tags_for_language(lang) + + def get_classification_friendly_name(self, classification, lang, only_last_component=False): + """Extract a friendly name from the classification.""" + return classification + + def slugify_tag_name(self, name, lang): """Slugify a tag name.""" if self.site.config['SLUG_TAG_PATH']: - name = utils.slugify(name) + name = utils.slugify(name, lang) return name - def tag_index_path(self, name, lang): - """Return path to the tag index.""" - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['TAG_PATH'], - self.site.config['INDEX_FILE']] if _f] - - def category_index_path(self, name, lang): - """Return path to the category index.""" - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['CATEGORY_PATH'], - self.site.config['INDEX_FILE']] if _f] - - def tag_path(self, name, lang): - """Return path to a tag.""" - if self.site.config['PRETTY_URLS']: - return [_f for _f in [ - self.site.config['TRANSLATIONS'][lang], - self.site.config['TAG_PATH'], - self.slugify_tag_name(name), - self.site.config['INDEX_FILE']] if _f] + def get_overview_path(self, lang, dest_type='page'): + """Return a path for the list of all classifications.""" + if self.site.config['TAGS_INDEX_PATH'](lang): + path = self.site.config['TAGS_INDEX_PATH'](lang) + append_index = 'never' else: - return [_f for _f in [ - self.site.config['TRANSLATIONS'][lang], - self.site.config['TAG_PATH'], - self.slugify_tag_name(name) + ".html"] if _f] - - def tag_atom_path(self, name, lang): - """Return path to a tag Atom feed.""" - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['TAG_PATH'], self.slugify_tag_name(name) + ".atom"] if - _f] - - def tag_rss_path(self, name, lang): - """Return path to a tag RSS feed.""" - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['TAG_PATH'], self.slugify_tag_name(name) + ".xml"] if - _f] - - def slugify_category_name(self, name): - """Slugify a category name.""" - path = self.site.parse_category_name(name) - if self.site.config['CATEGORY_OUTPUT_FLAT_HIERARCHY']: - path = path[-1:] # only the leaf - result = [self.slugify_tag_name(part) for part in path] - result[0] = self.site.config['CATEGORY_PREFIX'] + result[0] - if not self.site.config['PRETTY_URLS']: - result = ['-'.join(result)] - return result - - def _add_extension(self, path, extension): - path[-1] += extension - return path + path = self.site.config['TAG_PATH'](lang) + append_index = 'always' + return [component for component in path.split('/') if component], append_index + + def get_path(self, classification, lang, dest_type='page'): + """Return a path for the given classification.""" + return [_f for _f in [ + self.site.config['TAG_PATH'](lang), + self.slugify_tag_name(classification, lang)] if _f], 'auto' + + def provide_overview_context_and_uptodate(self, lang): + """Provide data for the context and the uptodate list for the list of all classifiations.""" + kw = { + "tag_path": self.site.config['TAG_PATH'], + "tag_pages_are_indexes": self.site.config['TAG_PAGES_ARE_INDEXES'], + "taglist_minimum_post_count": self.site.config['TAGLIST_MINIMUM_POSTS'], + "tzinfo": self.site.tzinfo, + "tag_descriptions": self.site.config['TAG_DESCRIPTIONS'], + "tag_titles": self.site.config['TAG_TITLES'], + } + context = { + "title": self.site.MESSAGES[lang]["Tags"], + "description": self.site.MESSAGES[lang]["Tags"], + "pagekind": ["list", "tags_page"], + } + kw.update(context) + return context, kw - def category_path(self, name, lang): - """Return path to a category.""" - if self.site.config['PRETTY_URLS']: - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['CATEGORY_PATH']] if - _f] + self.slugify_category_name(name) + [self.site.config['INDEX_FILE']] - else: - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['CATEGORY_PATH']] if - _f] + self._add_extension(self.slugify_category_name(name), ".html") + def provide_context_and_uptodate(self, classification, lang, node=None): + """Provide data for the context and the uptodate list for the list of the given classifiation.""" + kw = { + "tag_path": self.site.config['TAG_PATH'], + "tag_pages_are_indexes": self.site.config['TAG_PAGES_ARE_INDEXES'], + "taglist_minimum_post_count": self.site.config['TAGLIST_MINIMUM_POSTS'], + "tzinfo": self.site.tzinfo, + "tag_descriptions": self.site.config['TAG_DESCRIPTIONS'], + "tag_titles": self.site.config['TAG_TITLES'], + } + context = { + "title": self.site.config['TAG_TITLES'].get(lang, {}).get(classification, self.site.MESSAGES[lang]["Posts about %s"] % classification), + "description": self.site.config['TAG_DESCRIPTIONS'].get(lang, {}).get(classification), + "pagekind": ["tag_page", "index" if self.show_list_as_index else "list"], + "tag": classification, + } + kw.update(context) + return context, kw - def category_atom_path(self, name, lang): - """Return path to a category Atom feed.""" - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['CATEGORY_PATH']] if - _f] + self._add_extension(self.slugify_category_name(name), ".atom") + def get_other_language_variants(self, classification, lang, classifications_per_language): + """Return a list of variants of the same tag in other languages.""" + return self.translation_manager.get_translations_as_list(classification, lang, classifications_per_language) - def category_rss_path(self, name, lang): - """Return path to a category RSS feed.""" - return [_f for _f in [self.site.config['TRANSLATIONS'][lang], - self.site.config['CATEGORY_PATH']] if - _f] + self._add_extension(self.slugify_category_name(name), ".xml") + def postprocess_posts_per_classification(self, posts_per_classification_per_language, flat_hierarchy_per_lang=None, hierarchy_lookup_per_lang=None): + """Rearrange, modify or otherwise use the list of posts per classification and per language.""" + self.translation_manager.read_from_config(self.site, 'TAG', posts_per_classification_per_language, False) diff --git a/nikola/plugins/task/taxonomies.plugin b/nikola/plugins/task/taxonomies.plugin new file mode 100644 index 0000000..5bda812 --- /dev/null +++ b/nikola/plugins/task/taxonomies.plugin @@ -0,0 +1,12 @@ +[Core] +name = render_taxonomies +module = taxonomies + +[Documentation] +author = Roberto Alsina +version = 1.0 +website = https://getnikola.com/ +description = Render the taxonomy overviews, classification pages and feeds. + +[Nikola] +PluginCategory = Task diff --git a/nikola/plugins/task/taxonomies.py b/nikola/plugins/task/taxonomies.py new file mode 100644 index 0000000..7dcf6ed --- /dev/null +++ b/nikola/plugins/task/taxonomies.py @@ -0,0 +1,459 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Render the taxonomy overviews, classification pages and feeds.""" + +import os +from collections import defaultdict +from copy import copy +from urllib.parse import urljoin + +import blinker +import natsort + +from nikola import utils, hierarchy_utils +from nikola.nikola import _enclosure +from nikola.plugin_categories import Task + + +class RenderTaxonomies(Task): + """Render taxonomy pages and feeds.""" + + name = "render_taxonomies" + + def _generate_classification_overview_kw_context(self, taxonomy, lang): + """Create context and kw for a classification overview page.""" + context, kw = taxonomy.provide_overview_context_and_uptodate(lang) + + context = copy(context) + context["kind"] = "{}_index".format(taxonomy.classification_name) + sorted_links = [] + for other_lang in sorted(self.site.config['TRANSLATIONS'].keys()): + if other_lang != lang: + sorted_links.append((other_lang, None, None)) + # Put the current language in front, so that it appears first in links + # (Issue #3248) + sorted_links_all = [(lang, None, None)] + sorted_links + context['has_other_languages'] = True + context['other_languages'] = sorted_links + context['all_languages'] = sorted_links_all + + kw = copy(kw) + kw["messages"] = self.site.MESSAGES + kw["translations"] = self.site.config['TRANSLATIONS'] + kw["filters"] = self.site.config['FILTERS'] + kw["minimum_post_count"] = taxonomy.minimum_post_count_per_classification_in_overview + kw["output_folder"] = self.site.config['OUTPUT_FOLDER'] + kw["pretty_urls"] = self.site.config['PRETTY_URLS'] + kw["strip_indexes"] = self.site.config['STRIP_INDEXES'] + kw["index_file"] = self.site.config['INDEX_FILE'] + + # Collect all relevant classifications + if taxonomy.has_hierarchy: + def acceptor(node): + return len(self._filter_list(self.site.posts_per_classification[taxonomy.classification_name][lang][node.classification_name], lang)) >= kw["minimum_post_count"] + + clipped_root_list = [hierarchy_utils.clone_treenode(node, parent=None, acceptor=acceptor) for node in self.site.hierarchy_per_classification[taxonomy.classification_name][lang]] + clipped_root_list = [node for node in clipped_root_list if node] + clipped_flat_hierarchy = hierarchy_utils.flatten_tree_structure(clipped_root_list) + + classifications = [cat.classification_name for cat in clipped_flat_hierarchy] + else: + classifications = natsort.natsorted([tag for tag, posts in self.site.posts_per_classification[taxonomy.classification_name][lang].items() + if len(self._filter_list(posts, lang)) >= kw["minimum_post_count"]], + alg=natsort.ns.F | natsort.ns.IC) + taxonomy.sort_classifications(classifications, lang) + + # Set up classifications in context + context[taxonomy.overview_page_variable_name] = classifications + context["has_hierarchy"] = taxonomy.has_hierarchy + if taxonomy.overview_page_items_variable_name: + items = [(classification, + self.site.link(taxonomy.classification_name, classification, lang)) + for classification in classifications] + items_with_postcount = [ + (classification, + self.site.link(taxonomy.classification_name, classification, lang), + len(self._filter_list(self.site.posts_per_classification[taxonomy.classification_name][lang][classification], lang))) + for classification in classifications + ] + context[taxonomy.overview_page_items_variable_name] = items + context[taxonomy.overview_page_items_variable_name + "_with_postcount"] = items_with_postcount + if taxonomy.has_hierarchy and taxonomy.overview_page_hierarchy_variable_name: + hier_items = [ + (node.name, node.classification_name, node.classification_path, + self.site.link(taxonomy.classification_name, node.classification_name, lang), + node.indent_levels, node.indent_change_before, + node.indent_change_after) + for node in clipped_flat_hierarchy + ] + hier_items_with_postcount = [ + (node.name, node.classification_name, node.classification_path, + self.site.link(taxonomy.classification_name, node.classification_name, lang), + node.indent_levels, node.indent_change_before, + node.indent_change_after, + len(node.children), + len(self._filter_list(self.site.posts_per_classification[taxonomy.classification_name][lang][node.classification_name], lang))) + for node in clipped_flat_hierarchy + ] + context[taxonomy.overview_page_hierarchy_variable_name] = hier_items + context[taxonomy.overview_page_hierarchy_variable_name + '_with_postcount'] = hier_items_with_postcount + return context, kw + + def _render_classification_overview(self, classification_name, template, lang, context, kw): + # Prepare rendering + context["permalink"] = self.site.link("{}_index".format(classification_name), None, lang) + if "pagekind" not in context: + context["pagekind"] = ["list", "tags_page"] + output_name = os.path.join(self.site.config['OUTPUT_FOLDER'], self.site.path('{}_index'.format(classification_name), None, lang)) + blinker.signal('generate_classification_overview').send({ + 'site': self.site, + 'classification_name': classification_name, + 'lang': lang, + 'context': context, + 'kw': kw, + 'output_name': output_name, + }) + task = self.site.generic_post_list_renderer( + lang, + [], + output_name, + template, + kw['filters'], + context, + ) + task['uptodate'] = task['uptodate'] + [utils.config_changed(kw, 'nikola.plugins.task.taxonomies:page')] + task['basename'] = str(self.name) + yield task + + def _generate_classification_overview(self, taxonomy, lang): + """Create a global "all your tags/categories" page for a given language.""" + context, kw = self._generate_classification_overview_kw_context(taxonomy, lang) + for task in self._render_classification_overview(taxonomy.classification_name, taxonomy.template_for_classification_overview, lang, context, kw): + yield task + + def _generate_tag_and_category_overview(self, tag_taxonomy, category_taxonomy, lang): + """Create a global "all your tags/categories" page for a given language.""" + # Create individual contexts and kw dicts + tag_context, tag_kw = self._generate_classification_overview_kw_context(tag_taxonomy, lang) + cat_context, cat_kw = self._generate_classification_overview_kw_context(category_taxonomy, lang) + + # Combine resp. select dicts + if tag_context['items'] and cat_context['cat_items']: + # Combine contexts. We must merge the tag context into the category context + # so that tag_context['items'] makes it into the result. + context = cat_context + context.update(tag_context) + kw = cat_kw + kw.update(tag_kw) + + # Update title + title = self.site.MESSAGES[lang]["Tags and Categories"] + context['title'] = title + context['description'] = title + kw['title'] = title + kw['description'] = title + elif cat_context['cat_items']: + # Use category overview page + context = cat_context + kw = cat_kw + else: + # Use tag overview page + context = tag_context + kw = tag_kw + + # Render result + for task in self._render_classification_overview('tag', tag_taxonomy.template_for_classification_overview, lang, context, kw): + yield task + + def _generate_classification_page_as_rss(self, taxonomy, classification, filtered_posts, title, description, kw, lang): + """Create a RSS feed for a single classification in a given language.""" + kind = taxonomy.classification_name + # Render RSS + output_name = os.path.normpath(os.path.join(self.site.config['OUTPUT_FOLDER'], self.site.path(kind + "_rss", classification, lang))) + feed_url = urljoin(self.site.config['BASE_URL'], self.site.link(kind + "_rss", classification, lang).lstrip('/')) + deps = [] + deps_uptodate = [] + for post in filtered_posts: + deps += post.deps(lang) + deps_uptodate += post.deps_uptodate(lang) + blog_title = kw["blog_title"](lang) + task = { + 'basename': str(self.name), + 'name': output_name, + 'file_dep': deps, + 'targets': [output_name], + 'actions': [(utils.generic_rss_renderer, + (lang, "{0} ({1})".format(blog_title, title) if blog_title != title else blog_title, + kw["site_url"], description, filtered_posts, + output_name, kw["feed_teasers"], kw["feed_plain"], kw['feed_length'], + feed_url, _enclosure, kw["feed_links_append_query"]))], + 'clean': True, + 'uptodate': [utils.config_changed(kw, 'nikola.plugins.task.taxonomies:rss')] + deps_uptodate, + 'task_dep': ['render_posts'], + } + return utils.apply_filters(task, kw['filters']) + + def _generate_classification_page_as_index(self, taxonomy, classification, filtered_posts, context, kw, lang): + """Render an index page collection using only this classification's posts.""" + kind = taxonomy.classification_name + + def page_link(i, displayed_i, num_pages, force_addition, extension=None): + return self.site.link(kind, classification, lang, alternative_path=force_addition, page=i) + + def page_path(i, displayed_i, num_pages, force_addition, extension=None): + return self.site.path(kind, classification, lang, alternative_path=force_addition, page=i) + + context = copy(context) + context["kind"] = kind + if "pagekind" not in context: + context["pagekind"] = ["index", "tag_page"] + template_name = taxonomy.template_for_single_list + + yield self.site.generic_index_renderer(lang, filtered_posts, context['title'], template_name, context, kw, str(self.name), page_link, page_path) + + def _generate_classification_page_as_atom(self, taxonomy, classification, filtered_posts, context, kw, lang): + """Generate atom feeds for classification lists.""" + kind = taxonomy.classification_name + + context = copy(context) + context["kind"] = kind + + yield self.site.generic_atom_renderer(lang, filtered_posts, context, kw, str(self.name), classification, kind) + + def _generate_classification_page_as_list(self, taxonomy, classification, filtered_posts, context, kw, lang): + """Render a single flat link list with this classification's posts.""" + kind = taxonomy.classification_name + template_name = taxonomy.template_for_single_list + output_name = os.path.join(self.site.config['OUTPUT_FOLDER'], self.site.path(kind, classification, lang)) + context["lang"] = lang + # list.tmpl expects a different format than list_post.tmpl (Issue #2701) + if template_name == 'list.tmpl': + context["items"] = [(post.title(lang), post.permalink(lang), None) for post in filtered_posts] + else: + context["posts"] = filtered_posts + if "pagekind" not in context: + context["pagekind"] = ["list", "tag_page"] + task = self.site.generic_post_list_renderer(lang, filtered_posts, output_name, template_name, kw['filters'], context) + task['uptodate'] = task['uptodate'] + [utils.config_changed(kw, 'nikola.plugins.task.taxonomies:list')] + task['basename'] = str(self.name) + yield task + + def _filter_list(self, post_list, lang): + """Return only the posts which should be shown for this language.""" + if self.site.config["SHOW_UNTRANSLATED_POSTS"]: + return post_list + else: + return [x for x in post_list if x.is_translation_available(lang)] + + def _generate_subclassification_page(self, taxonomy, node, context, kw, lang): + """Render a list of subclassifications.""" + def get_subnode_data(subnode): + return [ + taxonomy.get_classification_friendly_name(subnode.classification_name, lang, only_last_component=True), + self.site.link(taxonomy.classification_name, subnode.classification_name, lang), + len(self._filter_list(self.site.posts_per_classification[taxonomy.classification_name][lang][subnode.classification_name], lang)) + ] + + items = [get_subnode_data(subnode) for subnode in node.children] + context = copy(context) + context["lang"] = lang + context["permalink"] = self.site.link(taxonomy.classification_name, node.classification_name, lang) + if "pagekind" not in context: + context["pagekind"] = ["list", "archive_page"] + context["items"] = items + task = self.site.generic_post_list_renderer( + lang, + [], + os.path.join(kw['output_folder'], self.site.path(taxonomy.classification_name, node.classification_name, lang)), + taxonomy.subcategories_list_template, + kw['filters'], + context, + ) + task_cfg = {1: kw, 2: items} + task['uptodate'] = task['uptodate'] + [utils.config_changed(task_cfg, 'nikola.plugins.task.taxonomy')] + task['basename'] = self.name + return task + + def _generate_classification_page(self, taxonomy, classification, filtered_posts, generate_list, generate_rss, generate_atom, lang, post_lists_per_lang, classification_set_per_lang=None): + """Render index or post list and associated feeds per classification.""" + # Should we create this list? + if not any((generate_list, generate_rss, generate_atom)): + return + # Get data + node = None + if taxonomy.has_hierarchy: + node = self.site.hierarchy_lookup_per_classification[taxonomy.classification_name][lang].get(classification) + context, kw = taxonomy.provide_context_and_uptodate(classification, lang, node) + kw = copy(kw) + kw["messages"] = self.site.MESSAGES + kw["translations"] = self.site.config['TRANSLATIONS'] + kw["filters"] = self.site.config['FILTERS'] + kw["site_url"] = self.site.config['SITE_URL'] + kw["blog_title"] = self.site.config['BLOG_TITLE'] + kw["generate_rss"] = self.site.config['GENERATE_RSS'] + kw["generate_atom"] = self.site.config['GENERATE_ATOM'] + kw["feed_teasers"] = self.site.config["FEED_TEASERS"] + kw["feed_plain"] = self.site.config["FEED_PLAIN"] + kw["feed_links_append_query"] = self.site.config["FEED_LINKS_APPEND_QUERY"] + kw["feed_length"] = self.site.config['FEED_LENGTH'] + kw["output_folder"] = self.site.config['OUTPUT_FOLDER'] + kw["pretty_urls"] = self.site.config['PRETTY_URLS'] + kw["strip_indexes"] = self.site.config['STRIP_INDEXES'] + kw["index_file"] = self.site.config['INDEX_FILE'] + context = copy(context) + context["permalink"] = self.site.link(taxonomy.classification_name, classification, lang) + context["kind"] = taxonomy.classification_name + # Get links to other language versions of this classification + if classification_set_per_lang is not None: + other_lang_links = taxonomy.get_other_language_variants(classification, lang, classification_set_per_lang) + # Collect by language + links_per_lang = defaultdict(list) + for other_lang, link in other_lang_links: + # Make sure we ignore the current language (in case the + # plugin accidentally returns links for it as well) + if other_lang != lang: + links_per_lang[other_lang].append(link) + # Sort first by language, then by classification + sorted_links = [] + sorted_links_all = [] + for other_lang in sorted(list(links_per_lang.keys()) + [lang]): + if other_lang == lang: + sorted_links_all.append((lang, classification, taxonomy.get_classification_friendly_name(classification, lang))) + else: + links = hierarchy_utils.sort_classifications(taxonomy, links_per_lang[other_lang], other_lang) + links = [(other_lang, other_classification, + taxonomy.get_classification_friendly_name(other_classification, other_lang)) + for other_classification in links if post_lists_per_lang[other_lang].get(other_classification, ('', False, False))[1]] + sorted_links.extend(links) + sorted_links_all.extend(links) + # Store result in context and kw + context['has_other_languages'] = True + context['other_languages'] = sorted_links + context['all_languages'] = sorted_links_all + kw['other_languages'] = sorted_links + kw['all_languages'] = sorted_links_all + else: + context['has_other_languages'] = False + # Allow other plugins to modify the result + blinker.signal('generate_classification_page').send({ + 'site': self.site, + 'taxonomy': taxonomy, + 'classification': classification, + 'lang': lang, + 'posts': filtered_posts, + 'context': context, + 'kw': kw, + }) + # Decide what to do + if taxonomy.has_hierarchy and taxonomy.show_list_as_subcategories_list: + # Determine whether there are subcategories + node = self.site.hierarchy_lookup_per_classification[taxonomy.classification_name][lang][classification] + # Are there subclassifications? + if len(node.children) > 0: + # Yes: create list with subclassifications instead of list of posts + if generate_list: + yield self._generate_subclassification_page(taxonomy, node, context, kw, lang) + return + # Generate RSS feed + if generate_rss and kw["generate_rss"] and not taxonomy.always_disable_rss: + yield self._generate_classification_page_as_rss(taxonomy, classification, filtered_posts, context['title'], context.get("description"), kw, lang) + + # Generate Atom feed + if generate_atom and kw["generate_atom"] and not taxonomy.always_disable_atom: + yield self._generate_classification_page_as_atom(taxonomy, classification, filtered_posts, context, kw, lang) + + # Render HTML + if generate_list and taxonomy.show_list_as_index: + yield self._generate_classification_page_as_index(taxonomy, classification, filtered_posts, context, kw, lang) + elif generate_list: + yield self._generate_classification_page_as_list(taxonomy, classification, filtered_posts, context, kw, lang) + + def gen_tasks(self): + """Render the tag pages and feeds.""" + self.site.scan_posts() + yield self.group_task() + + # Cache classification sets per language for taxonomies where + # add_other_languages_variable is True. + classification_set_per_lang = {} + for taxonomy in self.site.taxonomy_plugins.values(): + if taxonomy.add_other_languages_variable: + lookup = self.site.posts_per_classification[taxonomy.classification_name] + cspl = {lang: set(lookup[lang].keys()) for lang in lookup} + classification_set_per_lang[taxonomy.classification_name] = cspl + + # Collect post lists for classification pages and determine whether + # they should be generated. + post_lists_per_lang = {} + for taxonomy in self.site.taxonomy_plugins.values(): + plpl = {} + for lang in self.site.config["TRANSLATIONS"]: + result = {} + for classification, posts in self.site.posts_per_classification[taxonomy.classification_name][lang].items(): + # Filter list + filtered_posts = self._filter_list(posts, lang) + if len(filtered_posts) == 0 and taxonomy.omit_empty_classifications: + generate_list = generate_rss = generate_atom = False + else: + # Should we create this list? + generate_list = taxonomy.should_generate_classification_page(classification, filtered_posts, lang) + generate_rss = taxonomy.should_generate_rss_for_classification_page(classification, filtered_posts, lang) + generate_atom = taxonomy.should_generate_atom_for_classification_page(classification, filtered_posts, lang) + result[classification] = (filtered_posts, generate_list, generate_rss, generate_atom) + plpl[lang] = result + post_lists_per_lang[taxonomy.classification_name] = plpl + + # Now generate pages + for lang in self.site.config["TRANSLATIONS"]: + # To support that tag and category classifications share the same overview, + # we explicitly detect this case: + ignore_plugins_for_overview = set() + if 'tag' in self.site.taxonomy_plugins and 'category' in self.site.taxonomy_plugins and self.site.link("tag_index", None, lang) == self.site.link("category_index", None, lang): + # Block both plugins from creating overviews + ignore_plugins_for_overview.add(self.site.taxonomy_plugins['tag']) + ignore_plugins_for_overview.add(self.site.taxonomy_plugins['category']) + for taxonomy in self.site.taxonomy_plugins.values(): + if not taxonomy.is_enabled(lang): + continue + # Generate list of classifications (i.e. classification overview) + if taxonomy not in ignore_plugins_for_overview: + if taxonomy.template_for_classification_overview is not None: + for task in self._generate_classification_overview(taxonomy, lang): + yield task + + # Process classifications + for classification, (filtered_posts, generate_list, generate_rss, generate_atom) in post_lists_per_lang[taxonomy.classification_name][lang].items(): + for task in self._generate_classification_page(taxonomy, classification, filtered_posts, + generate_list, generate_rss, generate_atom, lang, + post_lists_per_lang[taxonomy.classification_name], + classification_set_per_lang.get(taxonomy.classification_name)): + yield task + # In case we are ignoring plugins for overview, we must have a collision for + # tags and categories. Handle this special case with extra code. + if ignore_plugins_for_overview: + for task in self._generate_tag_and_category_overview(self.site.taxonomy_plugins['tag'], self.site.taxonomy_plugins['category'], lang): + yield task diff --git a/nikola/plugins/template/__init__.py b/nikola/plugins/template/__init__.py index d416ad7..a530db4 100644 --- a/nikola/plugins/template/__init__.py +++ b/nikola/plugins/template/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated diff --git a/nikola/plugins/template/jinja.plugin b/nikola/plugins/template/jinja.plugin index cfe9fa8..629b20e 100644 --- a/nikola/plugins/template/jinja.plugin +++ b/nikola/plugins/template/jinja.plugin @@ -5,9 +5,9 @@ module = jinja [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Support for Jinja2 templates. [Nikola] -plugincategory = Template +PluginCategory = Template diff --git a/nikola/plugins/template/jinja.py b/nikola/plugins/template/jinja.py index b02d75c..7795739 100644 --- a/nikola/plugins/template/jinja.py +++ b/nikola/plugins/template/jinja.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -24,47 +24,51 @@ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - """Jinja template handler.""" -from __future__ import unicode_literals -import os +import io import json -from collections import deque +import os + +from nikola.plugin_categories import TemplateSystem +from nikola.utils import makedirs, req_missing, sort_posts, _smartjoin_filter + try: import jinja2 from jinja2 import meta except ImportError: - jinja2 = None # NOQA - -from nikola.plugin_categories import TemplateSystem -from nikola.utils import makedirs, req_missing + jinja2 = None class JinjaTemplates(TemplateSystem): - """Support for Jinja2 templates.""" name = "jinja" lookup = None dependency_cache = {} + per_file_cache = {} def __init__(self): """Initialize Jinja2 environment with extended set of filters.""" if jinja2 is None: return - self.lookup = jinja2.Environment() + + def set_directories(self, directories, cache_folder): + """Create a new template lookup with set directories.""" + if jinja2 is None: + req_missing(['jinja2'], 'use this theme') + cache_folder = os.path.join(cache_folder, 'jinja') + makedirs(cache_folder) + cache = jinja2.FileSystemBytecodeCache(cache_folder) + self.lookup = jinja2.Environment(bytecode_cache=cache) self.lookup.trim_blocks = True self.lookup.lstrip_blocks = True self.lookup.filters['tojson'] = json.dumps + self.lookup.filters['sort_posts'] = sort_posts + self.lookup.filters['smartjoin'] = _smartjoin_filter self.lookup.globals['enumerate'] = enumerate self.lookup.globals['isinstance'] = isinstance self.lookup.globals['tuple'] = tuple - - def set_directories(self, directories, cache_folder): - """Create a new template lookup with set directories.""" - if jinja2 is None: - req_missing(['jinja2'], 'use this theme') self.directories = directories self.create_lookup() @@ -89,36 +93,46 @@ class JinjaTemplates(TemplateSystem): if jinja2 is None: req_missing(['jinja2'], 'use this theme') template = self.lookup.get_template(template_name) - output = template.render(**context) + data = template.render(**context) if output_name is not None: makedirs(os.path.dirname(output_name)) - with open(output_name, 'w+') as output: - output.write(output.encode('utf8')) - return output + with io.open(output_name, 'w', encoding='utf-8') as output: + output.write(data) + return data def render_template_to_string(self, template, context): """Render template to a string using context.""" return self.lookup.from_string(template).render(**context) + def get_string_deps(self, text): + """Find dependencies for a template string.""" + deps = set([]) + ast = self.lookup.parse(text) + dep_names = [d for d in meta.find_referenced_templates(ast) if d] + for dep_name in dep_names: + filename = self.lookup.loader.get_source(self.lookup, dep_name)[1] + sub_deps = [filename] + self.get_deps(filename) + self.dependency_cache[dep_name] = sub_deps + deps |= set(sub_deps) + return list(deps) + + def get_deps(self, filename): + """Return paths to dependencies for the template loaded from filename.""" + with io.open(filename, 'r', encoding='utf-8-sig') as fd: + text = fd.read() + return self.get_string_deps(text) + def template_deps(self, template_name): """Generate list of dependencies for a template.""" - # Cache the lists of dependencies for each template name. if self.dependency_cache.get(template_name) is None: - # Use a breadth-first search to find all templates this one - # depends on. - queue = deque([template_name]) - visited_templates = set([template_name]) - deps = [] - while len(queue) > 0: - curr = queue.popleft() - source, filename = self.lookup.loader.get_source(self.lookup, - curr)[:2] - deps.append(filename) - ast = self.lookup.parse(source) - dep_names = meta.find_referenced_templates(ast) - for dep_name in dep_names: - if (dep_name not in visited_templates and dep_name is not None): - visited_templates.add(dep_name) - queue.append(dep_name) - self.dependency_cache[template_name] = deps + filename = self.lookup.loader.get_source(self.lookup, template_name)[1] + self.dependency_cache[template_name] = [filename] + self.get_deps(filename) return self.dependency_cache[template_name] + + def get_template_path(self, template_name): + """Get the path to a template or return None.""" + try: + t = self.lookup.get_template(template_name) + return t.filename + except jinja2.TemplateNotFound: + return None diff --git a/nikola/plugins/template/mako.plugin b/nikola/plugins/template/mako.plugin index d256faf..2d353bf 100644 --- a/nikola/plugins/template/mako.plugin +++ b/nikola/plugins/template/mako.plugin @@ -5,9 +5,9 @@ module = mako [Documentation] author = Roberto Alsina version = 1.0 -website = http://getnikola.com +website = https://getnikola.com/ description = Support for Mako templates. [Nikola] -plugincategory = Template +PluginCategory = Template diff --git a/nikola/plugins/template/mako.py b/nikola/plugins/template/mako.py index aed6596..30e2041 100644 --- a/nikola/plugins/template/mako.py +++ b/nikola/plugins/template/mako.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,25 +26,22 @@ """Mako template handler.""" -from __future__ import unicode_literals, print_function, absolute_import +import io import os import shutil -import sys -import tempfile -from mako import util, lexer, parsetree +from mako import exceptions, util, lexer, parsetree from mako.lookup import TemplateLookup from mako.template import Template from markupsafe import Markup # It's ok, Mako requires it from nikola.plugin_categories import TemplateSystem -from nikola.utils import makedirs, get_logger, STDERR_HANDLER +from nikola.utils import makedirs, get_logger -LOGGER = get_logger('mako', STDERR_HANDLER) +LOGGER = get_logger('mako') class MakoTemplates(TemplateSystem): - """Support for Mako templates.""" name = "mako" @@ -55,10 +52,9 @@ class MakoTemplates(TemplateSystem): directories = [] cache_dir = None - def get_deps(self, filename): - """Get dependencies for a template (internal function).""" - text = util.read_file(filename) - lex = lexer.Lexer(text=text, filename=filename) + def get_string_deps(self, text, filename=None): + """Find dependencies for a template string.""" + lex = lexer.Lexer(text=text, filename=filename, input_encoding='utf-8') lex.parse() deps = [] @@ -66,18 +62,25 @@ class MakoTemplates(TemplateSystem): keyword = getattr(n, 'keyword', None) if keyword in ["inherit", "namespace"] or isinstance(n, parsetree.IncludeTag): deps.append(n.attributes['file']) + # Some templates will include "foo.tmpl" and we need paths, so normalize them + # using the template lookup + for i, d in enumerate(deps): + dep = self.get_template_path(d) + if dep: + deps[i] = dep + else: + LOGGER.error("Cannot find template {0} referenced in {1}", + d, filename) return deps + def get_deps(self, filename): + """Get paths to dependencies for a template.""" + text = util.read_file(filename) + return self.get_string_deps(text, filename) + def set_directories(self, directories, cache_folder): """Create a new template lookup with set directories.""" cache_dir = os.path.join(cache_folder, '.mako.tmp') - # Workaround for a Mako bug, Issue #825 - if sys.version_info[0] == 2: - try: - os.path.abspath(cache_dir).decode('ascii') - except UnicodeEncodeError: - cache_dir = tempfile.mkdtemp() - LOGGER.warning('Because of a Mako bug, setting cache_dir to {0}'.format(cache_dir)) if os.path.exists(cache_dir): shutil.rmtree(cache_dir) self.directories = directories @@ -95,6 +98,7 @@ class MakoTemplates(TemplateSystem): self.lookup = TemplateLookup( directories=self.directories, module_directory=self.cache_dir, + input_encoding='utf-8', output_encoding='utf-8') def set_site(self, site): @@ -109,14 +113,14 @@ class MakoTemplates(TemplateSystem): data = template.render_unicode(**context) if output_name is not None: makedirs(os.path.dirname(output_name)) - with open(output_name, 'w+') as output: + with io.open(output_name, 'w', encoding='utf-8') as output: output.write(data) return data def render_template_to_string(self, template, context): """Render template to a string using context.""" context.update(self.filters) - return Template(template).render(**context) + return Template(template, lookup=self.lookup).render(**context) def template_deps(self, template_name): """Generate list of dependencies for a template.""" @@ -127,9 +131,18 @@ class MakoTemplates(TemplateSystem): dep_filenames = self.get_deps(template.filename) deps = [template.filename] for fname in dep_filenames: - deps += self.template_deps(fname) - self.cache[template_name] = tuple(deps) - return list(self.cache[template_name]) + # yes, it uses forward slashes on Windows + deps += self.template_deps(fname.split('/')[-1]) + self.cache[template_name] = list(set(deps)) + return self.cache[template_name] + + def get_template_path(self, template_name): + """Get the path to a template or return None.""" + try: + t = self.lookup.get_template(template_name) + return t.filename + except exceptions.TopLevelLookupException: + return None def striphtml(text): diff --git a/nikola/post.py b/nikola/post.py index 7badfc6..82d957d 100644 --- a/nikola/post.py +++ b/nikola/post.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,60 +26,63 @@ """The Post class.""" -from __future__ import unicode_literals, print_function, absolute_import - import io -from collections import defaultdict import datetime import hashlib import json import os import re -import string -try: - from urlparse import urljoin -except ImportError: - from urllib.parse import urljoin # NOQA - -from . import utils +from collections import defaultdict +from math import ceil # for reading time feature +from urllib.parse import urljoin import dateutil.tz import lxml.html import natsort -try: - import pyphen -except ImportError: - pyphen = None - -from math import ceil +from blinker import signal # for tearDown with _reload we cannot use 'from import' to get forLocaleBorg import nikola.utils +from . import metadata_extractors +from . import utils from .utils import ( - bytes_str, current_time, Functionary, LOGGER, LocaleBorg, slugify, to_datetime, - unicode_str, demote_headers, get_translation_candidate, - unslugify, + map_metadata ) -from .rc4 import rc4 + +try: + import pyphen +except ImportError: + pyphen = None + __all__ = ('Post',) -TEASER_REGEXP = re.compile('', re.IGNORECASE) -_UPGRADE_METADATA_ADVERTISED = False +TEASER_REGEXP = re.compile(r'', re.IGNORECASE) class Post(object): - """Represent a blog post or site page.""" + _prev_post = None + _next_post = None + is_draft = False + is_private = False + _is_two_file = None + _reading_time = None + _remaining_reading_time = None + _paragraph_count = None + _remaining_paragraph_count = None + post_status = 'published' + has_oldstyle_metadata_tags = False + def __init__( self, source_path, @@ -88,74 +91,249 @@ class Post(object): use_in_feeds, messages, template_name, - compiler + compiler, + destination_base=None, + metadata_extractors_by=None ): """Initialize post. The source path is the user created post file. From it we calculate the meta file, as well as any translations available, and the .html fragment file path. + + destination_base must be None or a TranslatableSetting instance. If + specified, it will be prepended to the destination path. """ - self.config = config + self._load_config(config) + self._set_paths(source_path) + self.compiler = compiler - self.compile_html = self.compiler.compile_html + self.is_post = use_in_feeds + self.messages = messages + self._template_name = template_name + self.compile_html = self.compiler.compile self.demote_headers = self.compiler.demote_headers and self.config['DEMOTE_HEADERS'] - tzinfo = self.config['__tzinfo__'] + self._dependency_file_fragment = defaultdict(list) + self._dependency_file_page = defaultdict(list) + self._dependency_uptodate_fragment = defaultdict(list) + self._dependency_uptodate_page = defaultdict(list) + self._depfile = defaultdict(list) + if metadata_extractors_by is None: + self.metadata_extractors_by = {'priority': {}, 'source': {}} + else: + self.metadata_extractors_by = metadata_extractors_by + + self._set_translated_to() + self._set_folders(destination, destination_base) + + # Load default metadata + default_metadata, default_used_extractor = get_meta(self, lang=None) + self.meta = Functionary(lambda: None, self.default_lang) + self.used_extractor = Functionary(lambda: None, self.default_lang) + self.meta[self.default_lang] = default_metadata + self.used_extractor[self.default_lang] = default_used_extractor + + self._set_date(default_metadata) + + # These are the required metadata fields + if 'title' not in default_metadata or 'slug' not in default_metadata: + raise ValueError("You must set a title (found '{0}') and a slug (found '{1}')! " + "[in file {2}]".format(default_metadata.get('title', None), + default_metadata.get('slug', None), + source_path)) + + if 'type' not in default_metadata: + default_metadata['type'] = 'text' + + self._load_translated_metadata(default_metadata) + self._load_data() + self.__migrate_section_to_category() + self._set_tags() + + self.publish_later = False if self.current_time is None else self.date >= self.current_time + + # While draft comes from the tags, it's not really a tag + self.use_in_feeds = self.is_post and not self.is_draft and not self.is_private and not self.publish_later + + # Allow overriding URL_TYPE via meta + # The check is done here so meta dicts won’t change inside of + # generic_post_renderer + self.url_type = self.meta('url_type') or None + # Register potential extra dependencies + self.compiler.register_extra_dependencies(self) + + def _load_config(self, config): + """Set members to configured values.""" + self.config = config if self.config['FUTURE_IS_NOW']: self.current_time = None else: - self.current_time = current_time(tzinfo) - self.translated_to = set([]) - self._prev_post = None - self._next_post = None + self.current_time = current_time(self.config['__tzinfo__']) self.base_url = self.config['BASE_URL'] - self.is_draft = False - self.is_private = False - self.is_mathjax = False self.strip_indexes = self.config['STRIP_INDEXES'] self.index_file = self.config['INDEX_FILE'] self.pretty_urls = self.config['PRETTY_URLS'] + self.default_lang = self.config['DEFAULT_LANG'] + self.translations = self.config['TRANSLATIONS'] + self.skip_untranslated = not self.config['SHOW_UNTRANSLATED_POSTS'] + self._default_preview_image = self.config['DEFAULT_PREVIEW_IMAGE'] + self.types_to_hide_title = self.config['TYPES_TO_HIDE_TITLE'] + + def _set_tags(self): + """Set post tags.""" + self._tags = {} + for lang in self.translated_to: + if isinstance(self.meta[lang]['tags'], (list, tuple, set)): + _tag_list = self.meta[lang]['tags'] + else: + _tag_list = self.meta[lang]['tags'].split(',') + self._tags[lang] = natsort.natsorted( + list(set([x.strip() for x in _tag_list])), + alg=natsort.ns.F | natsort.ns.IC) + self._tags[lang] = [t for t in self._tags[lang] if t] + + status = self.meta[lang].get('status') + if status: + if status == 'published': + pass # already set before, mixing published + something else should result in the other thing + elif status == 'featured': + self.post_status = status + elif status == 'private': + self.post_status = status + self.is_private = True + elif status == 'draft': + self.post_status = status + self.is_draft = True + else: + LOGGER.warning(('The post "{0}" has the unknown status "{1}". ' + 'Valid values are "published", "featured", "private" and "draft".').format(self.source_path, status)) + + if self.config['WARN_ABOUT_TAG_METADATA']: + show_warning = False + if 'draft' in [_.lower() for _ in self._tags[lang]]: + LOGGER.warning('The post "{0}" uses the "draft" tag.'.format(self.source_path)) + show_warning = True + if 'private' in self._tags[lang]: + LOGGER.warning('The post "{0}" uses the "private" tag.'.format(self.source_path)) + show_warning = True + if 'mathjax' in self._tags[lang]: + LOGGER.warning('The post "{0}" uses the "mathjax" tag.'.format(self.source_path)) + show_warning = True + if show_warning: + LOGGER.warning('It is suggested that you convert special tags to metadata and set ' + 'USE_TAG_METADATA to False. You can use the upgrade_metadata_v8 ' + 'command plugin for conversion (install with: nikola plugin -i ' + 'upgrade_metadata_v8). Change the WARN_ABOUT_TAG_METADATA ' + 'configuration to disable this warning.') + if self.config['USE_TAG_METADATA']: + if 'draft' in [_.lower() for _ in self._tags[lang]]: + self.is_draft = True + LOGGER.debug('The post "{0}" is a draft.'.format(self.source_path)) + self._tags[lang].remove('draft') + self.post_status = 'draft' + self.has_oldstyle_metadata_tags = True + + if 'private' in self._tags[lang]: + self.is_private = True + LOGGER.debug('The post "{0}" is private.'.format(self.source_path)) + self._tags[lang].remove('private') + self.post_status = 'private' + self.has_oldstyle_metadata_tags = True + + if 'mathjax' in self._tags[lang]: + self.has_oldstyle_metadata_tags = True + + def _set_paths(self, source_path): + """Set the various paths and the post_name. + + TODO: WTF is all this. + """ self.source_path = source_path # posts/blah.txt self.post_name = os.path.splitext(source_path)[0] # posts/blah + _relpath = os.path.relpath(self.post_name) + if _relpath != self.post_name: + self.post_name = _relpath.replace('..' + os.sep, '_..' + os.sep) # cache[\/]posts[\/]blah.html self.base_path = os.path.join(self.config['CACHE_FOLDER'], self.post_name + ".html") # cache/posts/blah.html self._base_path = self.base_path.replace('\\', '/') self.metadata_path = self.post_name + ".meta" # posts/blah.meta - self.folder = destination - self.translations = self.config['TRANSLATIONS'] - self.default_lang = self.config['DEFAULT_LANG'] - self.messages = messages - self.skip_untranslated = not self.config['SHOW_UNTRANSLATED_POSTS'] - self._template_name = template_name - self.is_two_file = True - self.newstylemeta = True - self.hyphenate = self.config['HYPHENATE'] - self._reading_time = None - self._remaining_reading_time = None - self._paragraph_count = None - self._remaining_paragraph_count = None - self._dependency_file_fragment = defaultdict(list) - self._dependency_file_page = defaultdict(list) - self._dependency_uptodate_fragment = defaultdict(list) - self._dependency_uptodate_page = defaultdict(list) - - default_metadata, self.newstylemeta = get_meta(self, self.config['FILE_METADATA_REGEXP'], self.config['UNSLUGIFY_TITLES']) - self.meta = Functionary(lambda: None, self.default_lang) - self.meta[self.default_lang] = default_metadata - - # Load internationalized metadata + def _set_translated_to(self): + """Find post's translations.""" + self.translated_to = set([]) for lang in self.translations: if os.path.isfile(get_translation_candidate(self.config, self.source_path, lang)): self.translated_to.add(lang) + + # If we don't have anything in translated_to, the file does not exist + if not self.translated_to and os.path.isfile(self.source_path): + raise Exception(("Could not find translations for {}, check your " + "TRANSLATIONS_PATTERN").format(self.source_path)) + elif not self.translated_to: + raise Exception(("Cannot use {} (not a file, perhaps a broken " + "symbolic link?)").format(self.source_path)) + + def _set_folders(self, destination, destination_base): + """Compose destination paths.""" + self.folder_relative = destination + self.folder_base = destination_base + + if self.folder_base is not None: + # Use translatable destination folders + self.folders = {} + for lang in self.config['TRANSLATIONS']: + if os.path.isabs(self.folder_base(lang)): # Issue 2982 + self.folder_base[lang] = os.path.relpath(self.folder_base(lang), '/') + self.folders[lang] = os.path.normpath(os.path.join(self.folder_base(lang), self.folder_relative)) + else: + # Old behavior (non-translatable destination path, normalized by scanner) + self.folders = {lang: self.folder_relative for lang in self.config['TRANSLATIONS'].keys()} + self.folder = self.folders[self.default_lang] + + def __migrate_section_to_category(self): + """TODO: remove in v9.""" + for lang, meta in self.meta.items(): + # Migrate section to category + # TODO: remove in v9 + if 'section' in meta: + if 'category' in meta: + LOGGER.warning("Post {0} has both 'category' and 'section' metadata. Section will be ignored.".format(self.source_path)) + else: + meta['category'] = meta['section'] + LOGGER.info("Post {0} uses 'section' metadata, setting its value to 'category'".format(self.source_path)) + + # Handle CATEGORY_DESTPATH_AS_DEFAULT + if 'category' not in meta and self.config['CATEGORY_DESTPATH_AS_DEFAULT']: + self.category_from_destpath = True + if self.config['CATEGORY_DESTPATH_TRIM_PREFIX'] and self.folder_relative != '.': + category = self.folder_relative + else: + category = self.folders[lang] + category = category.replace(os.sep, '/') + if self.config['CATEGORY_DESTPATH_FIRST_DIRECTORY_ONLY']: + category = category.split('/')[0] + meta['category'] = self.config['CATEGORY_DESTPATH_NAMES'](lang).get(category, category) + else: + self.category_from_destpath = False + + def _load_data(self): + """Load data field from metadata.""" + self.data = Functionary(lambda: None, self.default_lang) + for lang in self.translations: + if self.meta[lang].get('data') is not None: + self.data[lang] = utils.load_data(self.meta[lang]['data']) + + def _load_translated_metadata(self, default_metadata): + """Load metadata from all translation sources.""" + for lang in self.translations: if lang != self.default_lang: meta = defaultdict(lambda: '') meta.update(default_metadata) - _meta, _nsm = get_meta(self, self.config['FILE_METADATA_REGEXP'], self.config['UNSLUGIFY_TITLES'], lang) - self.newstylemeta = self.newstylemeta and _nsm + _meta, _extractors = get_meta(self, lang) meta.update(_meta) self.meta[lang] = meta + self.used_extractor[lang] = _extractors if not self.is_translation_available(self.default_lang): # Special case! (Issue #373) @@ -163,81 +341,80 @@ class Post(object): for lang in sorted(self.translated_to): default_metadata.update(self.meta[lang]) - if 'date' not in default_metadata and not use_in_feeds: - # For stories we don't *really* need a date + def _set_date(self, default_metadata): + """Set post date/updated based on metadata and configuration.""" + if 'date' not in default_metadata and not self.is_post: + # For pages we don't *really* need a date if self.config['__invariant__']: - default_metadata['date'] = datetime.datetime(2013, 12, 31, 23, 59, 59, tzinfo=tzinfo) + default_metadata['date'] = datetime.datetime(2013, 12, 31, 23, 59, 59, tzinfo=self.config['__tzinfo__']) else: default_metadata['date'] = datetime.datetime.utcfromtimestamp( - os.stat(self.source_path).st_ctime).replace(tzinfo=dateutil.tz.tzutc()).astimezone(tzinfo) + os.stat(self.source_path).st_ctime).replace(tzinfo=dateutil.tz.tzutc()).astimezone(self.config['__tzinfo__']) # If time zone is set, build localized datetime. - self.date = to_datetime(self.meta[self.default_lang]['date'], tzinfo) + try: + self.date = to_datetime(self.meta[self.default_lang]['date'], self.config['__tzinfo__']) + except ValueError: + if not self.meta[self.default_lang]['date']: + msg = 'Missing date in file {}'.format(self.source_path) + else: + msg = "Invalid date '{0}' in file {1}".format(self.meta[self.default_lang]['date'], self.source_path) + LOGGER.error(msg) + raise ValueError(msg) if 'updated' not in default_metadata: default_metadata['updated'] = default_metadata.get('date', None) - self.updated = to_datetime(default_metadata['updated']) - - if 'title' not in default_metadata or 'slug' not in default_metadata \ - or 'date' not in default_metadata: - raise OSError("You must set a title (found '{0}'), a slug (found " - "'{1}') and a date (found '{2}')! [in file " - "{3}]".format(default_metadata.get('title', None), - default_metadata.get('slug', None), - default_metadata.get('date', None), - source_path)) - - if 'type' not in default_metadata: - # default value is 'text' - default_metadata['type'] = 'text' + self.updated = to_datetime(default_metadata['updated'], self.config['__tzinfo__']) - self.publish_later = False if self.current_time is None else self.date >= self.current_time + @property + def hyphenate(self): + """Post is hyphenated.""" + return bool(self.config['HYPHENATE'] or self.meta('hyphenate')) - is_draft = False - is_private = False - self._tags = {} - for lang in self.translated_to: - self._tags[lang] = natsort.natsorted( - list(set([x.strip() for x in self.meta[lang]['tags'].split(',')])), - alg=natsort.ns.F | natsort.ns.IC) - self._tags[lang] = [t for t in self._tags[lang] if t] - if 'draft' in [_.lower() for _ in self._tags[lang]]: - is_draft = True - LOGGER.debug('The post "{0}" is a draft.'.format(self.source_path)) - self._tags[lang].remove('draft') - - # TODO: remove in v8 - if 'retired' in self._tags[lang]: - is_private = True - LOGGER.warning('The "retired" tag in post "{0}" is now deprecated and will be removed in v8. Use "private" instead.'.format(self.source_path)) - self._tags[lang].remove('retired') - # end remove in v8 - - if 'private' in self._tags[lang]: - is_private = True - LOGGER.debug('The post "{0}" is private.'.format(self.source_path)) - self._tags[lang].remove('private') + @property + def is_two_file(self): + """Post has a separate .meta file.""" + if self._is_two_file is None: + return True + return self._is_two_file - # While draft comes from the tags, it's not really a tag - self.is_draft = is_draft - self.is_private = is_private - self.is_post = use_in_feeds - self.use_in_feeds = use_in_feeds and not is_draft and not is_private \ - and not self.publish_later + @is_two_file.setter + def is_two_file(self, value): + """Set the is_two_file property, use with care. - # If mathjax is a tag, or it's a ipynb post, then enable mathjax rendering support - self.is_mathjax = ('mathjax' in self.tags) or (self.compiler.name == 'ipynb') + Caution: this MAY REWRITE THE POST FILE. + Only should happen if you effectively *change* the value. - # Register potential extra dependencies - self.compiler.register_extra_dependencies(self) + Arguments: + value {bool} -- Whether the post has a separate .meta file + """ + # for lang in self.translated_to: + + if self._is_two_file is None: + # Initial setting, this happens on post creation + self._is_two_file = value + elif value != self._is_two_file: + # Changing the value, this means you are transforming a 2-file + # into a 1-file or viceversa. + if value and not self.compiler.supports_metadata: + raise ValueError("Can't save metadata as 1-file using this compiler {}".format(self.compiler)) + for lang in self.translated_to: + source = self.source(lang) + meta = self.meta(lang) + self._is_two_file = value + self.save(lang=lang, source=source, meta=meta) + if not value: # Need to delete old meta file + meta_path = get_translation_candidate(self.config, self.metadata_path, lang) + if os.path.isfile(meta_path): + os.unlink(meta_path) def __repr__(self): """Provide a representation of the post object.""" # Calculate a hash that represents most data about the post m = hashlib.md5() # source_path modification date (to avoid reading it) - m.update(utils.unicode_str(os.stat(self.source_path).st_mtime).encode('utf-8')) + m.update(str(os.stat(self.source_path).st_mtime).encode('utf-8')) clean_meta = {} for k, v in self.meta.items(): sub_meta = {} @@ -245,16 +422,45 @@ class Post(object): for kk, vv in v.items(): if vv: sub_meta[kk] = vv - m.update(utils.unicode_str(json.dumps(clean_meta, cls=utils.CustomEncoder, sort_keys=True)).encode('utf-8')) + m.update(str(json.dumps(clean_meta, cls=utils.CustomEncoder, sort_keys=True)).encode('utf-8')) return ''.format(self.source_path, m.hexdigest()) + def has_pretty_url(self, lang): + """Check if this page has a pretty URL.""" + m = self.meta[lang].get('pretty_url', '') + if m: + # match is a non-empty string, overides anything + return m.lower() == 'true' or m.lower() == 'yes' + else: + # use PRETTY_URLS, unless the slug is 'index' + return self.pretty_urls and self.meta[lang]['slug'] != 'index' + def _has_pretty_url(self, lang): - if self.pretty_urls and \ - self.meta[lang].get('pretty_url', '') != 'False' and \ - self.meta[lang]['slug'] != 'index': + """Check if this page has a pretty URL.""" + return self.has_pretty_url(lang) + + @property + def has_math(self): + """Return True if this post has has_math set to True or is a python notebook. + + Alternatively, it will return True if it has set the mathjax tag in the + current language and the USE_TAG_METADATA config setting is True. + """ + if self.compiler.name == 'ipynb': return True - else: - return False + lang = nikola.utils.LocaleBorg().current_lang + if self.is_translation_available(lang): + if self.meta[lang].get('has_math') in ('true', 'True', 'yes', '1', 1, True): + return True + if self.config['USE_TAG_METADATA']: + return 'mathjax' in self.tags_for_language(lang) + # If it has math in ANY other language, enable it. Better inefficient than broken. + for lang in self.translated_to: + if self.meta[lang].get('has_math') in ('true', 'True', 'yes', '1', 1, True): + return True + if self.config['USE_TAG_METADATA']: + return 'mathjax' in self.alltags + return False @property def alltags(self): @@ -294,7 +500,7 @@ class Post(object): rv = rv._prev_post return rv - @prev_post.setter # NOQA + @prev_post.setter def prev_post(self, v): """Set previous post.""" self._prev_post = v @@ -312,7 +518,7 @@ class Post(object): rv = rv._next_post return rv - @next_post.setter # NOQA + @next_post.setter def next_post(self, v): """Set next post.""" self._next_post = v @@ -320,26 +526,15 @@ class Post(object): @property def template_name(self): """Return template name for this post.""" - return self.meta('template') or self._template_name + lang = nikola.utils.LocaleBorg().current_lang + return self.meta[lang]['template'] or self._template_name def formatted_date(self, date_format, date=None): - """Return the formatted date as unicode.""" - date = date if date else self.date - - if date_format == 'webiso': - # Formatted after RFC 3339 (web ISO 8501 profile) with Zulu - # zone desgignator for times in UTC and no microsecond precision. - fmt_date = date.replace(microsecond=0).isoformat().replace('+00:00', 'Z') - else: - fmt_date = date.strftime(date_format) - - # Issue #383, this changes from py2 to py3 - if isinstance(fmt_date, bytes_str): - fmt_date = fmt_date.decode('utf8') - return fmt_date + """Return the formatted date as string.""" + return utils.LocaleBorg().formatted_date(date_format, date if date else self.date) def formatted_updated(self, date_format): - """Return the updated date as unicode.""" + """Return the updated date as string.""" return self.formatted_date(date_format, self.updated) def title(self, lang=None): @@ -352,7 +547,7 @@ class Post(object): lang = nikola.utils.LocaleBorg().current_lang return self.meta[lang]['title'] - def author(self, lang=None): + def author(self, lang=None) -> str: """Return localized author or BLOG_AUTHOR if unspecified. If lang is not specified, it defaults to the current language from @@ -367,12 +562,38 @@ class Post(object): return author + def authors(self, lang=None) -> list: + """Return localized authors or BLOG_AUTHOR if unspecified. + + If lang is not specified, it defaults to the current language from + templates, as set in LocaleBorg. + """ + if lang is None: + lang = nikola.utils.LocaleBorg().current_lang + if self.meta[lang]['author']: + author = [i.strip() for i in self.meta[lang]['author'].split(",")] + else: + author = [self.config['BLOG_AUTHOR'](lang)] + + return author + def description(self, lang=None): """Return localized description.""" if lang is None: lang = nikola.utils.LocaleBorg().current_lang return self.meta[lang]['description'] + def guid(self, lang=None): + """Return localized GUID.""" + if lang is None: + lang = nikola.utils.LocaleBorg().current_lang + if self.meta[lang]['guid']: + guid = self.meta[lang]['guid'] + else: + guid = self.permalink(lang, absolute=True) + + return guid + def add_dependency(self, dependency, add='both', lang=None): """Add a file dependency for tasks using that post. @@ -425,6 +646,27 @@ class Post(object): if add == 'page' or add == 'both': self._dependency_uptodate_page[lang].append((is_callable, dependency)) + def register_depfile(self, dep, dest=None, lang=None): + """Register a dependency in the dependency file.""" + if not dest: + dest = self.translated_base_path(lang) + self._depfile[dest].append(dep) + + @staticmethod + def write_depfile(dest, deps_list, post=None, lang=None): + """Write a depfile for a given language.""" + if post is None or lang is None: + deps_path = dest + '.dep' + else: + deps_path = post.compiler.get_dep_filename(post, lang) + if deps_list or (post.compiler.use_dep_file if post else False): + deps_list = [p for p in deps_list if p != dest] # Don't depend on yourself (#1671) + with io.open(deps_path, "w+", encoding="utf-8") as deps_file: + deps_file.write('\n'.join(deps_list)) + else: + if os.path.isfile(deps_path): + os.unlink(deps_path) + def _get_dependencies(self, deps_list): deps = [] for dep in deps_list: @@ -444,17 +686,23 @@ class Post(object): def deps(self, lang): """Return a list of file dependencies to build this post's page.""" deps = [] - if self.default_lang in self.translated_to: - deps.append(self.base_path) - deps.append(self.source_path) + deps.append(self.base_path) + deps.append(self.source_path) + if os.path.exists(self.metadata_path): + deps.append(self.metadata_path) if lang != self.default_lang: cand_1 = get_translation_candidate(self.config, self.source_path, lang) cand_2 = get_translation_candidate(self.config, self.base_path, lang) if os.path.exists(cand_1): deps.extend([cand_1, cand_2]) + cand_3 = get_translation_candidate(self.config, self.metadata_path, lang) + if os.path.exists(cand_3): + deps.append(cand_3) + if self.meta('data', lang): + deps.append(self.meta('data', lang)) deps += self._get_dependencies(self._dependency_file_page[lang]) deps += self._get_dependencies(self._dependency_file_page[None]) - return sorted(deps) + return sorted(set(deps)) def deps_uptodate(self, lang): """Return a list of uptodate dependencies to build this post's page. @@ -470,14 +718,6 @@ class Post(object): def compile(self, lang): """Generate the cache/ file with the compiled post.""" - def wrap_encrypt(path, password): - """Wrap a post with encryption.""" - with io.open(path, 'r+', encoding='utf8') as inf: - data = inf.read() + "" - data = CRYPT.substitute(data=rc4(password, data)) - with io.open(path, 'w+', encoding='utf8') as outf: - outf.write(data) - dest = self.translated_base_path(lang) if not self.is_translation_available(lang) and not self.config['SHOW_UNTRANSLATED_POSTS']: return @@ -486,26 +726,25 @@ class Post(object): self.compile_html( self.translated_source_path(lang), dest, - self.is_two_file), - if self.meta('password'): - # TODO: get rid of this feature one day (v8?; warning added in v7.3.0.) - LOGGER.warn("The post {0} is using the `password` attribute, which may stop working in the future.") - LOGGER.warn("Please consider switching to a more secure method of encryption.") - LOGGER.warn("More details: https://github.com/getnikola/nikola/issues/1547") - wrap_encrypt(dest, self.meta('password')) + self.is_two_file, + self, + lang) + Post.write_depfile(dest, self._depfile[dest], post=self, lang=lang) + + signal('compiled').send({ + 'source': self.translated_source_path(lang), + 'dest': dest, + 'post': self, + 'lang': lang, + }) + if self.publish_later: - LOGGER.notice('{0} is scheduled to be published in the future ({1})'.format( + LOGGER.info('{0} is scheduled to be published in the future ({1})'.format( self.source_path, self.date)) def fragment_deps(self, lang): - """Return a list of uptodate dependencies to build this post's fragment. - - These dependencies should be included in ``uptodate`` for the task - which generates the fragment. - """ - deps = [] - if self.default_lang in self.translated_to: - deps.append(self.source_path) + """Return a list of dependencies to build this post's fragment.""" + deps = [self.source_path] if os.path.isfile(self.metadata_path): deps.append(self.metadata_path) lang_deps = [] @@ -546,25 +785,95 @@ class Post(object): return get_translation_candidate(self.config, self.base_path, lang) def _translated_file_path(self, lang): - """Return path to the translation's file, or to the original.""" + """Get path to a post's translation. + + Returns path to the translation's file, or to as good a file as it can + plus "real" language of the text. + """ if lang in self.translated_to: if lang == self.default_lang: - return self.base_path + return self.base_path, lang else: - return get_translation_candidate(self.config, self.base_path, lang) + return get_translation_candidate(self.config, self.base_path, lang), lang elif lang != self.default_lang: - return self.base_path + return self.base_path, self.default_lang else: - return get_translation_candidate(self.config, self.base_path, sorted(self.translated_to)[0]) + real_lang = sorted(self.translated_to)[0] + return get_translation_candidate(self.config, self.base_path, real_lang), real_lang + + def write_metadata(self, lang=None): + """Save the post's metadata. + + Keep in mind that this will save either in the + post file or in a .meta file, depending on self.is_two_file. + + metadata obtained from filenames or document contents will + be superseded by this, and becomes inaccessible. + + Post contents will **not** be modified. + + If you write to a language not in self.translated_to + an exception will be raised. + + Remember to scan_posts(really=True) after you update metadata if + you want the rest of the system to know about the change. + """ + if lang is None: + lang = nikola.utils.LocaleBorg().current_lang + if lang not in self.translated_to: + raise ValueError("Can't save post metadata to language [{}] it's not translated to.".format(lang)) + + source = self.source(lang) + source_path = self.translated_source_path(lang) + metadata = self.meta[lang] + self.compiler.create_post(source_path, content=source, onefile=not self.is_two_file, is_page=not self.is_post, **metadata) + + def save(self, lang=None, source=None, meta=None): + """Write post source to disk. + + Use this with utmost care, it may wipe out a post. + + Keyword Arguments: + lang str -- Language for this source. If set to None, + use current language. + source str -- The source text for the post in the + language. If set to None, use current source for + this language. + meta dict -- Metadata for this language, if not set, + use current metadata for this language. + """ + if lang is None: + lang = nikola.utils.LocaleBorg().current_lang + if source is None: + source = self.source(lang) + if meta is None: + metadata = self.meta[lang] + source_path = self.translated_source_path(lang) + metadata = self.meta[lang] + self.compiler.create_post(source_path, content=source, onefile=not self.is_two_file, is_page=not self.is_post, **metadata) + + def source(self, lang=None): + """Read the post and return its source.""" + if lang is None: + lang = nikola.utils.LocaleBorg().current_lang + + source = self.translated_source_path(lang) + with open(source, 'r', encoding='utf-8-sig') as inf: + data = inf.read() + if self.is_two_file: # Metadata is not here + source_data = data + else: + source_data = self.compiler.split_metadata(data, self, lang)[1] + return source_data def text(self, lang=None, teaser_only=False, strip_html=False, show_read_more_link=True, - rss_read_more_link=False, rss_links_append_query=None): - """Read the post file for that language and return its contents. + feed_read_more_link=False, feed_links_append_query=None): + """Read the post file for that language and return its compiled contents. teaser_only=True breaks at the teaser marker and returns only the teaser. strip_html=True removes HTML tags show_read_more_link=False does not add the Read more... link - rss_read_more_link=True uses RSS_READ_MORE_LINK instead of INDEX_READ_MORE_LINK + feed_read_more_link=True uses FEED_READ_MORE_LINK instead of INDEX_READ_MORE_LINK lang=None uses the last used to set locale All links in the returned HTML will be relative. @@ -572,7 +881,7 @@ class Post(object): """ if lang is None: lang = nikola.utils.LocaleBorg().current_lang - file_name = self._translated_file_path(lang) + file_name, real_lang = self._translated_file_path(lang) # Yes, we compile it and screw it. # This may be controversial, but the user (or someone) is asking for the post text @@ -580,7 +889,7 @@ class Post(object): if not os.path.isfile(file_name): self.compile(lang) - with io.open(file_name, "r", encoding="utf8") as post_file: + with io.open(file_name, "r", encoding="utf-8-sig") as post_file: data = post_file.read().strip() if self.compiler.extension() == '.php': @@ -592,16 +901,16 @@ class Post(object): if str(e) == "Document is empty": return "" # let other errors raise - raise(e) + raise base_url = self.permalink(lang=lang) document.make_links_absolute(base_url) if self.hyphenate: - hyphenate(document, lang) + hyphenate(document, real_lang) try: data = lxml.html.tostring(document.body, encoding='unicode') - except: + except Exception: data = lxml.html.tostring(document, encoding='unicode') if teaser_only: @@ -613,15 +922,16 @@ class Post(object): teaser_text = teaser_regexp.search(data).groups()[-1] else: teaser_text = self.messages[lang]["Read more"] - l = self.config['RSS_READ_MORE_LINK'](lang) if rss_read_more_link else self.config['INDEX_READ_MORE_LINK'](lang) + l = self.config['FEED_READ_MORE_LINK'](lang) if feed_read_more_link else self.config['INDEX_READ_MORE_LINK'](lang) teaser += l.format( - link=self.permalink(lang, query=rss_links_append_query), + link=self.permalink(lang, query=feed_links_append_query), read_more=teaser_text, min_remaining_read=self.messages[lang]["%d min remaining to read"] % (self.remaining_reading_time), reading_time=self.reading_time, remaining_reading_time=self.remaining_reading_time, paragraph_count=self.paragraph_count, - remaining_paragraph_count=self.remaining_paragraph_count) + remaining_paragraph_count=self.remaining_paragraph_count, + post_title=self.title(lang)) # This closes all open tags and sanitizes the broken HTML document = lxml.html.fromstring(teaser) try: @@ -634,7 +944,7 @@ class Post(object): # Not all posts have a body. For example, you may have a page statically defined in the template that does not take content as input. content = lxml.html.fromstring(data) data = content.text_content().strip() # No whitespace wanted. - except lxml.etree.ParserError: + except (lxml.etree.ParserError, ValueError): data = "" elif data: if self.demote_headers: @@ -650,7 +960,7 @@ class Post(object): @property def reading_time(self): - """Reading time based on length of text.""" + """Return reading time based on length of text.""" if self._reading_time is None: text = self.text(strip_html=True) words_per_minute = 220 @@ -679,8 +989,8 @@ class Post(object): if self._paragraph_count is None: # duplicated with Post.text() lang = nikola.utils.LocaleBorg().current_lang - file_name = self._translated_file_path(lang) - with io.open(file_name, "r", encoding="utf8") as post_file: + file_name, _ = self._translated_file_path(lang) + with io.open(file_name, "r", encoding="utf-8-sig") as post_file: data = post_file.read().strip() try: document = lxml.html.fragment_fromstring(data, "body") @@ -689,7 +999,7 @@ class Post(object): if str(e) == "Document is empty": return "" # let other errors raise - raise(e) + raise # output is a float, for no real reason at all self._paragraph_count = int(document.xpath('count(//p)')) @@ -707,7 +1017,7 @@ class Post(object): if str(e) == "Document is empty": return "" # let other errors raise - raise(e) + raise self._remaining_paragraph_count = self.paragraph_count - int(document.xpath('count(//p)')) return self._remaining_paragraph_count @@ -715,10 +1025,9 @@ class Post(object): def source_link(self, lang=None): """Return absolute link to the post's source.""" ext = self.source_ext(True) - return "/" + self.destination_path( - lang=lang, - extension=ext, - sep='/') + link = "/" + self.destination_path(lang=lang, extension=ext, sep='/') + link = utils.encodelink(link) + return link def destination_path(self, lang=None, extension='.html', sep=os.sep): """Destination path for this post, relative to output/. @@ -728,12 +1037,13 @@ class Post(object): """ if lang is None: lang = nikola.utils.LocaleBorg().current_lang - if self._has_pretty_url(lang): + folder = self.folders[lang] + if self.has_pretty_url(lang): path = os.path.join(self.translations[lang], - self.folder, self.meta[lang]['slug'], 'index' + extension) + folder, self.meta[lang]['slug'], 'index' + extension) else: path = os.path.join(self.translations[lang], - self.folder, self.meta[lang]['slug'] + extension) + folder, self.meta[lang]['slug'] + extension) if sep != os.sep: path = path.replace(os.sep, sep) if path.startswith('./'): @@ -750,8 +1060,8 @@ class Post(object): extension = self.compiler.extension() pieces = self.translations[lang].split(os.sep) - pieces += self.folder.split(os.sep) - if self._has_pretty_url(lang): + pieces += self.folders[lang].split(os.sep) + if self.has_pretty_url(lang): pieces += [self.meta[lang]['slug'], 'index' + extension] else: pieces += [self.meta[lang]['slug'] + extension] @@ -764,6 +1074,7 @@ class Post(object): link = link[:-index_len] if query: link = link + "?" + query + link = utils.encodelink(link) return link @property @@ -773,13 +1084,14 @@ class Post(object): lang = nikola.utils.LocaleBorg().current_lang image_path = self.meta[lang]['previewimage'] - if not image_path: - return None + image_path = self._default_preview_image - # This is further parsed by the template, because we don’t have access - # to the URL replacer here. (Issue #1473) - return image_path + if not image_path or image_path.startswith("/"): + # Paths starting with slashes are expected to be root-relative, pass them directly. + return image_path + # Other paths are relative to the permalink. The path will be made prettier by the URL replacer later. + return urljoin(self.permalink(lang), image_path) def source_ext(self, prefix=False): """Return the source file extension. @@ -795,227 +1107,143 @@ class Post(object): else: return ext -# Code that fetches metadata from different places - - -def re_meta(line, match=None): - """Find metadata using regular expressions.""" - if match: - reStr = re.compile('^\.\. {0}: (.*)'.format(re.escape(match))) - else: - reStr = re.compile('^\.\. (.*?): (.*)') - result = reStr.findall(line.strip()) - if match and result: - return (match, result[0]) - elif not match and result: - return (result[0][0], result[0][1].strip()) - else: - return (None,) + def should_hide_title(self): + """Return True if this post's title should be hidden. Use in templates to manage posts without titles.""" + return self.title().strip() in ('NO TITLE', '') or self.meta('hidetitle') or \ + self.meta('type').strip() in self.types_to_hide_title + def should_show_title(self): + """Return True if this post's title should be displayed. Use in templates to manage posts without titles.""" + return not self.should_hide_title() -def _get_metadata_from_filename_by_regex(filename, metadata_regexp, unslugify_titles): - """Try to reed the metadata from the filename based on the given re. - This requires to use symbolic group names in the pattern. - The part to read the metadata from the filename based on a regular - expression is taken from Pelican - pelican/readers.py - """ - match = re.match(metadata_regexp, filename) - meta = {} - - if match: - # .items() for py3k compat. - for key, value in match.groupdict().items(): - k = key.lower().strip() # metadata must be lowercase - if k == 'title' and unslugify_titles: - meta[k] = unslugify(value, discard_numbers=False) - else: - meta[k] = value - - return meta - - -def get_metadata_from_file(source_path, config=None, lang=None): +def get_metadata_from_file(source_path, post, config, lang, metadata_extractors_by): """Extract metadata from the file itself, by parsing contents.""" try: if lang and config: source_path = get_translation_candidate(config, source_path, lang) elif lang: source_path += '.' + lang - with io.open(source_path, "r", encoding="utf8") as meta_file: - meta_data = [x.strip() for x in meta_file.readlines()] - return _get_metadata_from_file(meta_data) + with io.open(source_path, "r", encoding="utf-8-sig") as meta_file: + source_text = meta_file.read() except (UnicodeDecodeError, UnicodeEncodeError): - raise ValueError('Error reading {0}: Nikola only supports UTF-8 files'.format(source_path)) + msg = 'Error reading {0}: Nikola only supports UTF-8 files'.format(source_path) + LOGGER.error(msg) + raise ValueError(msg) except Exception: # The file may not exist, for multilingual sites - return {} + return {}, None - -def _get_metadata_from_file(meta_data): - """Extract metadata from a post's source file.""" meta = {} - if not meta_data: - return meta - - re_md_title = re.compile(r'^{0}([^{0}].*)'.format(re.escape('#'))) - # Assuming rst titles are going to be at least 4 chars long - # otherwise this detects things like ''' wich breaks other markups. - re_rst_title = re.compile(r'^([{0}]{{4,}})'.format(re.escape( - string.punctuation))) - - # Skip up to one empty line at the beginning (for txt2tags) - if not meta_data[0]: - meta_data = meta_data[1:] - - # First, get metadata from the beginning of the file, - # up to first empty line - - for i, line in enumerate(meta_data): - if not line: - break - match = re_meta(line) - if match[0]: - meta[match[0]] = match[1] - - # If we have no title, try to get it from document - if 'title' not in meta: - piece = meta_data[:] - for i, line in enumerate(piece): - if re_rst_title.findall(line) and i > 0: - meta['title'] = meta_data[i - 1].strip() - break - if (re_rst_title.findall(line) and i >= 0 and - re_rst_title.findall(meta_data[i + 2])): - meta['title'] = meta_data[i + 1].strip() - break - if re_md_title.findall(line): - meta['title'] = re_md_title.findall(line)[0] + used_extractor = None + for priority in metadata_extractors.MetaPriority: + found_in_priority = False + for extractor in metadata_extractors_by['priority'].get(priority, []): + if not metadata_extractors.check_conditions(post, source_path, extractor.conditions, config, source_text): + continue + extractor.check_requirements() + new_meta = extractor.extract_text(source_text) + if new_meta: + found_in_priority = True + used_extractor = extractor + # Map metadata from other platforms to names Nikola expects (Issue #2817) + # Map metadata values (Issue #3025) + map_metadata(new_meta, extractor.map_from, config) + + meta.update(new_meta) break - return meta + if found_in_priority: + break + return meta, used_extractor -def get_metadata_from_meta_file(path, config=None, lang=None): +def get_metadata_from_meta_file(path, post, config, lang, metadata_extractors_by=None): """Take a post path, and gets data from a matching .meta file.""" - global _UPGRADE_METADATA_ADVERTISED meta_path = os.path.splitext(path)[0] + '.meta' if lang and config: meta_path = get_translation_candidate(config, meta_path, lang) elif lang: meta_path += '.' + lang if os.path.isfile(meta_path): - with io.open(meta_path, "r", encoding="utf8") as meta_file: - meta_data = meta_file.readlines() - - # Detect new-style metadata. - newstyleregexp = re.compile(r'\.\. .*?: .*') - newstylemeta = False - for l in meta_data: - if l.strip(): - if re.match(newstyleregexp, l): - newstylemeta = True - - if newstylemeta: - # New-style metadata is basically the same as reading metadata from - # a 1-file post. - return get_metadata_from_file(path, config, lang), newstylemeta - else: - if not _UPGRADE_METADATA_ADVERTISED: - LOGGER.warn("Some posts on your site have old-style metadata. You should upgrade them to the new format, with support for extra fields.") - LOGGER.warn("Install the 'upgrade_metadata' plugin (with 'nikola plugin -i upgrade_metadata') and run 'nikola upgrade_metadata'.") - _UPGRADE_METADATA_ADVERTISED = True - while len(meta_data) < 7: - meta_data.append("") - (title, slug, date, tags, link, description, _type) = [ - x.strip() for x in meta_data][:7] - - meta = {} - - if title: - meta['title'] = title - if slug: - meta['slug'] = slug - if date: - meta['date'] = date - if tags: - meta['tags'] = tags - if link: - meta['link'] = link - if description: - meta['description'] = description - if _type: - meta['type'] = _type - - return meta, newstylemeta - + return get_metadata_from_file(meta_path, post, config, lang, metadata_extractors_by) elif lang: # Metadata file doesn't exist, but not default language, # So, if default language metadata exists, return that. # This makes the 2-file format detection more reliable (Issue #525) - return get_metadata_from_meta_file(path, config, lang=None) - else: - return {}, True + return get_metadata_from_meta_file(meta_path, post, config, None, metadata_extractors_by) + else: # No 2-file metadata + return {}, None -def get_meta(post, file_metadata_regexp=None, unslugify_titles=False, lang=None): - """Get post's meta from source. - - If ``file_metadata_regexp`` is given it will be tried to read - metadata from the filename. - If ``unslugify_titles`` is True, the extracted title (if any) will be unslugified, as is done in galleries. - If any metadata is then found inside the file the metadata from the - file will override previous findings. - """ +def get_meta(post, lang): + """Get post meta from compiler or source file.""" meta = defaultdict(lambda: '') + used_extractor = None - try: - config = post.config - except AttributeError: - config = None + config = getattr(post, 'config', None) + metadata_extractors_by = getattr(post, 'metadata_extractors_by') + if metadata_extractors_by is None: + metadata_extractors_by = metadata_extractors.default_metadata_extractors_by() - _, newstylemeta = get_metadata_from_meta_file(post.metadata_path, config, lang) - meta.update(_) + # If meta file exists, use it + metafile_meta, used_extractor = get_metadata_from_meta_file(post.metadata_path, post, config, lang, metadata_extractors_by) - if not meta: - post.is_two_file = False + is_two_file = bool(metafile_meta) - if file_metadata_regexp is not None: - meta.update(_get_metadata_from_filename_by_regex(post.source_path, - file_metadata_regexp, - unslugify_titles)) + # Filename-based metadata extractors (priority 1). + if config.get('FILE_METADATA_REGEXP'): + extractors = metadata_extractors_by['source'].get(metadata_extractors.MetaSource.filename, []) + for extractor in extractors: + if not metadata_extractors.check_conditions(post, post.source_path, extractor.conditions, config, None): + continue + meta.update(extractor.extract_filename(post.source_path, lang)) + # Fetch compiler metadata (priority 2, overrides filename-based metadata). compiler_meta = {} - if getattr(post, 'compiler', None): - compiler_meta = post.compiler.read_metadata(post, file_metadata_regexp, unslugify_titles, lang) + if (getattr(post, 'compiler', None) and post.compiler.supports_metadata and + metadata_extractors.check_conditions(post, post.source_path, post.compiler.metadata_conditions, config, None)): + compiler_meta = post.compiler.read_metadata(post, lang=lang) + used_extractor = post.compiler meta.update(compiler_meta) - if not post.is_two_file and not compiler_meta: - # Meta file has precedence over file, which can contain garbage. - # Moreover, we should not to talk to the file if we have compiler meta. - meta.update(get_metadata_from_file(post.source_path, config, lang)) + # Meta files and inter-file metadata (priority 3, overrides compiler and filename-based metadata). + if not metafile_meta: + new_meta, used_extractor = get_metadata_from_file(post.source_path, post, config, lang, metadata_extractors_by) + meta.update(new_meta) + else: + meta.update(metafile_meta) if lang is None: # Only perform these checks for the default language - if 'slug' not in meta: # If no slug is found in the metadata use the filename - meta['slug'] = slugify(unicode_str(os.path.splitext( - os.path.basename(post.source_path))[0])) + meta['slug'] = slugify(os.path.splitext( + os.path.basename(post.source_path))[0], post.default_lang) if 'title' not in meta: # If no title is found, use the filename without extension meta['title'] = os.path.splitext( os.path.basename(post.source_path))[0] - return meta, newstylemeta + # Set one-file status basing on default language only (Issue #3191) + if is_two_file or lang is None: + # Direct access because setter is complicated + post._is_two_file = is_two_file + + return meta, used_extractor def hyphenate(dom, _lang): """Hyphenate a post.""" # circular import prevention from .nikola import LEGAL_VALUES - lang = LEGAL_VALUES['PYPHEN_LOCALES'].get(_lang, pyphen.language_fallback(_lang)) + lang = None + if pyphen is not None: + lang = LEGAL_VALUES['PYPHEN_LOCALES'].get(_lang, pyphen.language_fallback(_lang)) + else: + utils.req_missing(['pyphen'], 'hyphenate texts', optional=True) + hyphenator = None if pyphen is not None and lang is not None: # If pyphen does exist, we tell the user when configuring the site. # If it does not support a language, we ignore it quietly. @@ -1024,13 +1252,15 @@ def hyphenate(dom, _lang): except KeyError: LOGGER.error("Cannot find hyphenation dictoniaries for {0} (from {1}).".format(lang, _lang)) LOGGER.error("Pyphen cannot be installed to ~/.local (pip install --user).") + if hyphenator is not None: for tag in ('p', 'li', 'span'): for node in dom.xpath("//%s[not(parent::pre)]" % tag): skip_node = False - skippable_nodes = ['kbd', 'code', 'samp', 'mark', 'math', 'data', 'ruby', 'svg'] + skippable_nodes = ['kbd', 'pre', 'code', 'samp', 'mark', 'math', 'data', 'ruby', 'svg'] if node.getchildren(): for child in node.getchildren(): - if child.tag in skippable_nodes or (child.tag == 'span' and 'math' in child.get('class', [])): + if child.tag in skippable_nodes or (child.tag == 'span' and 'math' + in child.get('class', [])): skip_node = True elif 'math' in node.get('class', []): skip_node = True @@ -1049,8 +1279,14 @@ def insert_hyphens(node, hyphenator): text = getattr(node, attr) if not text: continue - new_data = ' '.join([hyphenator.inserted(w, hyphen='\u00AD') - for w in text.split(' ')]) + + lines = text.splitlines() + new_data = "\n".join( + [ + " ".join([hyphenator.inserted(w, hyphen="\u00AD") for w in line.split(" ")]) + for line in lines + ] + ) # Spaces are trimmed, we have to add them manually back if text[0].isspace(): new_data = ' ' + new_data @@ -1060,53 +1296,3 @@ def insert_hyphens(node, hyphenator): for child in node.iterchildren(): insert_hyphens(child, hyphenator) - - -CRYPT = string.Template("""\ - - - -
    - -
    -This post is password-protected. - - -
    - -
    """) diff --git a/nikola/rc4.py b/nikola/rc4.py deleted file mode 100644 index 93b660f..0000000 --- a/nikola/rc4.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -""" -A RC4 encryption library (used for password-protected posts). - -Original RC4 code license: - - Copyright (C) 2012 Bo Zhu http://about.bozhu.me - - Permission is hereby granted, free of charge, to any person obtaining a - copy of this software and associated documentation files (the "Software"), - to deal in the Software without restriction, including without limitation - the rights to use, copy, modify, merge, publish, distribute, sublicense, - and/or sell copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL - THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - DEALINGS IN THE SOFTWARE. -""" - -import base64 -import sys - - -def KSA(key): - """Run Key Scheduling Algorithm.""" - keylength = len(key) - - S = list(range(256)) - - j = 0 - for i in range(256): - j = (j + S[i] + key[i % keylength]) % 256 - S[i], S[j] = S[j], S[i] # swap - - return S - - -def PRGA(S): - """Run Pseudo-Random Generation Algorithm.""" - i = 0 - j = 0 - while True: - i = (i + 1) % 256 - j = (j + S[i]) % 256 - S[i], S[j] = S[j], S[i] # swap - - K = S[(S[i] + S[j]) % 256] - yield K - - -def RC4(key): - """Generate RC4 keystream.""" - S = KSA(key) - return PRGA(S) - - -def rc4(key, string): - """Encrypt things. - - >>> print(rc4("Key", "Plaintext")) - u/MW6NlArwrT - """ - string.encode('utf8') - key.encode('utf8') - - def convert_key(s): - return [ord(c) for c in s] - key = convert_key(key) - keystream = RC4(key) - r = b'' - for c in string: - if sys.version_info[0] == 3: - r += bytes([ord(c) ^ next(keystream)]) - else: - r += chr(ord(c) ^ next(keystream)) - return base64.b64encode(r).replace(b'\n', b'').decode('ascii') diff --git a/nikola/shortcodes.py b/nikola/shortcodes.py new file mode 100644 index 0000000..6116b98 --- /dev/null +++ b/nikola/shortcodes.py @@ -0,0 +1,387 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Support for Hugo-style shortcodes.""" + +import sys +import uuid + +from .utils import LOGGER + + +class ParsingError(Exception): + """Used for forwarding parsing error messages to apply_shortcodes.""" + + pass + + +def _format_position(data, pos): + """Return position formatted as line/column. + + This is used for prettier error messages. + """ + line = 0 + col = 0 + llb = '' # last line break + for c in data[:pos]: + if c == '\r' or c == '\n': + if llb and c != llb: + llb = '' + else: + line += 1 + col = 0 + llb = c + else: + col += 1 + llb = '' + return "line {0}, column {1}".format(line + 1, col + 1) + + +def _skip_whitespace(data, pos, must_be_nontrivial=False): + """Return first position after whitespace. + + If must_be_nontrivial is set to True, raises ParsingError + if no whitespace is found. + """ + if must_be_nontrivial: + if pos == len(data) or not data[pos].isspace(): + raise ParsingError("Expecting whitespace at {0}!".format(_format_position(data, pos))) + while pos < len(data): + if not data[pos].isspace(): + break + pos += 1 + return pos + + +def _skip_nonwhitespace(data, pos): + """Return first position not before pos which contains a non-whitespace character.""" + for i, x in enumerate(data[pos:]): + if x.isspace(): + return pos + i + return len(data) + + +def _parse_quoted_string(data, start): + """Parse a quoted string starting at position start in data. + + Returns the position after the string followed by the string itself. + """ + value = '' + qc = data[start] + pos = start + 1 + while pos < len(data): + char = data[pos] + if char == '\\': + if pos + 1 < len(data): + value += data[pos + 1] + pos += 2 + else: + raise ParsingError("Unexpected end of data while escaping ({0})".format(_format_position(data, pos))) + elif (char == "'" or char == '"') and char == qc: + return pos + 1, value + else: + value += char + pos += 1 + raise ParsingError("Unexpected end of unquoted string (started at {0})!".format(_format_position(data, start))) + + +def _parse_unquoted_string(data, start, stop_at_equals): + """Parse an unquoted string starting at position start in data. + + Returns the position after the string followed by the string itself. + In case stop_at_equals is set to True, an equal sign will terminate + the string. + """ + value = '' + pos = start + while pos < len(data): + char = data[pos] + if char == '\\': + if pos + 1 < len(data): + value += data[pos + 1] + pos += 2 + else: + raise ParsingError("Unexpected end of data while escaping ({0})".format(_format_position(data, pos))) + elif char.isspace(): + break + elif char == '=' and stop_at_equals: + break + elif char == "'" or char == '"': + raise ParsingError("Unexpected quotation mark in unquoted string ({0})".format(_format_position(data, pos))) + else: + value += char + pos += 1 + return pos, value + + +def _parse_string(data, start, stop_at_equals=False, must_have_content=False): + """Parse a string starting at position start in data. + + Returns the position after the string, followed by the string itself, and + followed by a flog indicating whether the following character is an equals + sign (only set if stop_at_equals is True). + + If must_have_content is set to True, no empty unquoted strings are accepted. + """ + if start == len(data): + raise ParsingError("Expecting string, but found end of input!") + char = data[start] + if char == '"' or char == "'": + end, value = _parse_quoted_string(data, start) + has_content = True + else: + end, value = _parse_unquoted_string(data, start, stop_at_equals) + has_content = len(value) > 0 + if must_have_content and not has_content: + raise ParsingError("String starting at {0} must be non-empty!".format(_format_position(data, start))) + + next_is_equals = False + if stop_at_equals and end + 1 < len(data): + next_is_equals = (data[end] == '=') + return end, value, next_is_equals + + +def _parse_shortcode_args(data, start, shortcode_name, start_pos): + """When pointed to after a shortcode's name in a shortcode tag, parses the shortcode's arguments until '%}}'. + + Returns the position after '%}}', followed by a tuple (args, kw). + + name and start_pos are only used for formatting error messages. + """ + args = [] + kwargs = {} + + pos = start + while True: + # Skip whitespaces + try: + pos = _skip_whitespace(data, pos, must_be_nontrivial=True) + except ParsingError: + if not args and not kwargs: + raise ParsingError("Shortcode '{0}' starting at {1} is not terminated correctly with '%}}}}'!".format(shortcode_name, _format_position(data, start_pos))) + else: + raise ParsingError("Syntax error in shortcode '{0}' at {1}: expecting whitespace!".format(shortcode_name, _format_position(data, pos))) + if pos == len(data): + break + # Check for end of shortcode + if pos + 3 <= len(data) and data[pos:pos + 3] == '%}}': + return pos + 3, (args, kwargs) + # Read name + pos, name, next_is_equals = _parse_string(data, pos, stop_at_equals=True, must_have_content=True) + if next_is_equals: + # Read value + pos, value, _ = _parse_string(data, pos + 1, stop_at_equals=False, must_have_content=False) + # Store keyword argument + kwargs[name] = value + else: + # Store positional argument + args.append(name) + + raise ParsingError("Shortcode '{0}' starting at {1} is not terminated correctly with '%}}}}'!".format(shortcode_name, _format_position(data, start_pos))) + + +def _new_sc_id(): + return str('SHORTCODE{0}REPLACEMENT'.format(str(uuid.uuid4()).replace('-', ''))) + + +def extract_shortcodes(data): + """ + Return data with replaced shortcodes, shortcodes. + + data is the original data, with the shortcodes replaced by UUIDs. + + a dictionary of shortcodes, where the keys are UUIDs and the values + are the shortcodes themselves ready to process. + """ + shortcodes = {} + splitted = _split_shortcodes(data) + + if not data: # Empty + return '', {} + + def extract_data_chunk(data): + """Take a list of splitted shortcodes and return a string and a tail. + + The string is data, the tail is ready for a new run of this same function. + """ + text = [] + for i, token in enumerate(data): + if token[0] == 'SHORTCODE_START': + name = token[3] + sc_id = _new_sc_id() + text.append(sc_id) + # See if this shortcode closes + for j in range(i, len(data)): + if data[j][0] == 'SHORTCODE_END' and data[j][3] == name: + # Extract this chunk + shortcodes[sc_id] = ''.join(t[1] for t in data[i:j + 1]) + return ''.join(text), data[j + 1:] + # Doesn't close + shortcodes[sc_id] = token[1] + return ''.join(text), data[i + 1:] + elif token[0] == 'TEXT': + text.append(token[1]) + return ''.join(text), data[1:] + elif token[0] == 'SHORTCODE_END': # This is malformed + raise Exception('Closing unopened shortcode {}'.format(token[3])) + + text = [] + tail = splitted + while True: + new_text, tail = extract_data_chunk(tail) + text.append(new_text) + if not tail: + break + return ''.join(text), shortcodes + + +def _split_shortcodes(data): + """Given input data, splits it into a sequence of texts, shortcode starts and shortcode ends. + + Returns a list of tuples of the following forms: + + 1. ("TEXT", text) + 2. ("SHORTCODE_START", text, start, name, args) + 3. ("SHORTCODE_END", text, start, name) + + Here, text is the raw text represented by the token; start is the starting position in data + of the token; name is the name of the shortcode; and args is a tuple (args, kw) as returned + by _parse_shortcode_args. + """ + pos = 0 + result = [] + while pos < len(data): + # Search for shortcode start + start = data.find('{{%', pos) + if start < 0: + result.append(("TEXT", data[pos:])) + break + result.append(("TEXT", data[pos:start])) + # Extract name + name_start = _skip_whitespace(data, start + 3) + name_end = _skip_nonwhitespace(data, name_start) + name = data[name_start:name_end] + if not name: + raise ParsingError("Syntax error: '{{{{%' must be followed by shortcode name ({0})!".format(_format_position(data, start))) + # Finish shortcode + if name[0] == '/': + # This is a closing shortcode + name = name[1:] + end_start = _skip_whitespace(data, name_end) # start of '%}}' + pos = end_start + 3 + # Must be followed by '%}}' + if pos > len(data) or data[end_start:pos] != '%}}': + raise ParsingError("Syntax error: '{{{{% /{0}' must be followed by ' %}}}}' ({1})!".format(name, _format_position(data, end_start))) + result.append(("SHORTCODE_END", data[start:pos], start, name)) + elif name == '%}}': + raise ParsingError("Syntax error: '{{{{%' must be followed by shortcode name ({0})!".format(_format_position(data, start))) + else: + # This is an opening shortcode + pos, args = _parse_shortcode_args(data, name_end, shortcode_name=name, start_pos=start) + result.append(("SHORTCODE_START", data[start:pos], start, name, args)) + return result + + +def apply_shortcodes(data, registry, site=None, filename=None, raise_exceptions=False, lang=None, extra_context=None): + """Apply Hugo-style shortcodes on data. + + {{% name parameters %}} will end up calling the registered "name" function with the given parameters. + {{% name parameters %}} something {{% /name %}} will call name with the parameters and + one extra "data" parameter containing " something ". + + If raise_exceptions is set to True, instead of printing error messages and terminating, errors are + passed on as exceptions to the caller. + + The site parameter is passed with the same name to the shortcodes so they can access Nikola state. + + >>> print(apply_shortcodes('==> {{% foo bar=baz %}} <==', {'foo': lambda *a, **k: k['bar']})) + ==> baz <== + >>> print(apply_shortcodes('==> {{% foo bar=baz %}}some data{{% /foo %}} <==', {'foo': lambda *a, **k: k['bar']+k['data']})) + ==> bazsome data <== + """ + if extra_context is None: + extra_context = {} + empty_string = '' + try: + # Split input data into text, shortcodes and shortcode endings + sc_data = _split_shortcodes(data) + # Now process data + result = [] + dependencies = [] + pos = 0 + while pos < len(sc_data): + current = sc_data[pos] + if current[0] == "TEXT": + result.append(current[1]) + pos += 1 + elif current[0] == "SHORTCODE_END": + raise ParsingError("Found shortcode ending '{{{{% /{0} %}}}}' which isn't closing a started shortcode ({1})!".format(current[3], _format_position(data, current[2]))) + elif current[0] == "SHORTCODE_START": + name = current[3] + # Check if we can find corresponding ending + found = None + for p in range(pos + 1, len(sc_data)): + if sc_data[p][0] == "SHORTCODE_END" and sc_data[p][3] == name: + found = p + break + if found: + # Found ending. Extract data argument: + data_arg = [] + for p in range(pos + 1, found): + data_arg.append(sc_data[p][1]) + data_arg = empty_string.join(data_arg) + pos = found + 1 + else: + # Single shortcode + pos += 1 + data_arg = '' + args, kw = current[4] + kw['site'] = site + kw['data'] = data_arg + kw['lang'] = lang + kw.update(extra_context) + if name in registry: + f = registry[name] + if getattr(f, 'nikola_shortcode_pass_filename', None): + kw['filename'] = filename + res = f(*args, **kw) + if not isinstance(res, tuple): # For backards compatibility + res = (res, []) + else: + LOGGER.error('Unknown shortcode %s (started at %s)', name, _format_position(data, current[2])) + res = ('', []) + result.append(res[0]) + dependencies += res[1] + return empty_string.join(result), dependencies + except ParsingError as e: + if raise_exceptions: + # Throw up + raise + if filename: + LOGGER.error("Shortcode error in file {0}: {1}".format(filename, e)) + else: + LOGGER.error("Shortcode error: {0}".format(e)) + sys.exit(1) diff --git a/nikola/state.py b/nikola/state.py new file mode 100644 index 0000000..4669d13 --- /dev/null +++ b/nikola/state.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2020 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""Persistent state implementation.""" + +import json +import os +import shutil +import tempfile +import threading + +from . import utils + + +class Persistor(): + """Persist stuff in a place. + + This is an intentionally dumb implementation. It is *not* meant to be + fast, or useful for arbitrarily large data. Use lightly. + + Intentionally it has no namespaces, sections, etc. Use as a + responsible adult. + """ + + def __init__(self, path): + """Where do you want it persisted.""" + self._path = path + self._local = threading.local() + self._local.data = {} + + def _set_site(self, site): + """Set site and create path directory.""" + self._site = site + utils.makedirs(os.path.dirname(self._path)) + + def get(self, key): + """Get data stored in key.""" + self._read() + return self._local.data.get(key) + + def set(self, key, value): + """Store value in key.""" + self._read() + self._local.data[key] = value + self._save() + + def delete(self, key): + """Delete key and the value it contains.""" + self._read() + if key in self._local.data: + self._local.data.pop(key) + self._save() + + def _read(self): + if os.path.isfile(self._path): + with open(self._path) as inf: + self._local.data = json.load(inf) + + def _save(self): + dname = os.path.dirname(self._path) + with tempfile.NamedTemporaryFile(dir=dname, delete=False, mode='w+', encoding='utf-8') as outf: + tname = outf.name + json.dump(self._local.data, outf, sort_keys=True, indent=2) + shutil.move(tname, self._path) diff --git a/nikola/utils.py b/nikola/utils.py index 3a268ff..d029b7f 100644 --- a/nikola/utils.py +++ b/nikola/utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,124 +26,99 @@ """Utility functions.""" -from __future__ import print_function, unicode_literals, absolute_import -import calendar +import configparser import datetime -import dateutil.tz import hashlib import io -import locale -import logging -import natsort +import operator import os import re import json import shutil +import socket import subprocess import sys +import threading +import typing +from collections import defaultdict, OrderedDict +from collections.abc import Callable, Iterable +from html import unescape as html_unescape +from importlib import reload as _reload +from unicodedata import normalize as unicodenormalize +from urllib.parse import quote as urlquote +from urllib.parse import unquote as urlunquote +from urllib.parse import urlparse, urlunparse +from zipfile import ZipFile as zipf + +import babel.dates import dateutil.parser import dateutil.tz -import logbook -import warnings +import pygments.formatters +import pygments.formatters._mapping import PyRSS2Gen as rss -from collections import defaultdict, Callable -from logbook.compat import redirect_logging -from logbook.more import ExceptionHandler, ColorizedStderrHandler -from pygments.formatters import HtmlFormatter -from zipfile import ZipFile as zipf +from blinker import signal from doit import tools -from unidecode import unidecode -from pkg_resources import resource_filename from doit.cmdparse import CmdParse +from pkg_resources import resource_filename +from nikola.packages.pygments_better_html import BetterHtmlFormatter +from unidecode import unidecode -from nikola import DEBUG - -__all__ = ('CustomEncoder', 'get_theme_path', 'get_theme_chain', 'load_messages', 'copy_tree', - 'copy_file', 'slugify', 'unslugify', 'to_datetime', 'apply_filters', +# Renames +from nikola import DEBUG # NOQA +from .log import LOGGER, get_logger # NOQA +from .hierarchy_utils import TreeNode, clone_treenode, flatten_tree_structure, sort_classifications +from .hierarchy_utils import join_hierarchical_category_path, parse_escaped_hierarchical_category_name + +try: + import toml +except ImportError: + toml = None + +try: + from ruamel.yaml import YAML +except ImportError: + YAML = None + +try: + import husl +except ImportError: + husl = None + +__all__ = ('CustomEncoder', 'get_theme_path', 'get_theme_path_real', + 'get_theme_chain', 'load_messages', 'copy_tree', 'copy_file', + 'slugify', 'unslugify', 'to_datetime', 'apply_filters', 'config_changed', 'get_crumbs', 'get_tzname', 'get_asset_path', - '_reload', 'unicode_str', 'bytes_str', 'unichr', 'Functionary', - 'TranslatableSetting', 'TemplateHookRegistry', 'LocaleBorg', + '_reload', 'Functionary', 'TranslatableSetting', + 'TemplateHookRegistry', 'LocaleBorg', 'sys_encode', 'sys_decode', 'makedirs', 'get_parent_theme_name', 'demote_headers', 'get_translation_candidate', 'write_metadata', 'ask', 'ask_yesno', 'options2docstring', 'os_path_split', 'get_displayed_page_number', 'adjust_name_for_index_path_list', 'adjust_name_for_index_path', 'adjust_name_for_index_link', - 'NikolaPygmentsHTML', 'create_redirect', 'TreeNode', - 'flatten_tree_structure', 'parse_escaped_hierarchical_category_name', - 'join_hierarchical_category_path', 'indent') + 'NikolaPygmentsHTML', 'create_redirect', 'clean_before_deployment', + 'sort_posts', 'smartjoin', 'indent', 'load_data', 'html_unescape', + 'rss_writer', 'map_metadata', 'req_missing', + # Deprecated, moved to hierarchy_utils: + 'TreeNode', 'clone_treenode', 'flatten_tree_structure', + 'sort_classifications', 'join_hierarchical_category_path', + 'parse_escaped_hierarchical_category_name',) # Are you looking for 'generic_rss_renderer'? # It's defined in nikola.nikola.Nikola (the site object). -if sys.version_info[0] == 3: - # Python 3 - bytes_str = bytes - unicode_str = str - unichr = chr - raw_input = input - from imp import reload as _reload -else: - bytes_str = str - unicode_str = unicode # NOQA - _reload = reload # NOQA - unichr = unichr - - -class ApplicationWarning(Exception): - pass - - -class ColorfulStderrHandler(ColorizedStderrHandler): +# Aliases, previously for Python 2/3 compatibility. +# TODO remove in v9 +bytes_str = bytes +unicode_str = str +unichr = chr - """Stream handler with colors.""" +# For compatibility with old logging setups. +# TODO remove in v9? +STDERR_HANDLER = None - _colorful = False - - def should_colorize(self, record): - """Inform about colorization using the value obtained from Nikola.""" - return self._colorful - - -def get_logger(name, handlers): - """Get a logger with handlers attached.""" - l = logbook.Logger(name) - for h in handlers: - if isinstance(h, list): - l.handlers += h - else: - l.handlers.append(h) - return l - - -STDERR_HANDLER = [ColorfulStderrHandler( - level=logbook.INFO if not DEBUG else logbook.DEBUG, - format_string=u'[{record.time:%Y-%m-%dT%H:%M:%SZ}] {record.level_name}: {record.channel}: {record.message}' -)] - - -LOGGER = get_logger('Nikola', STDERR_HANDLER) -STRICT_HANDLER = ExceptionHandler(ApplicationWarning, level='WARNING') USE_SLUGIFY = True -redirect_logging() - -if DEBUG: - logging.basicConfig(level=logging.DEBUG) -else: - logging.basicConfig(level=logging.INFO) - - -def showwarning(message, category, filename, lineno, file=None, line=None): - """Show a warning (from the warnings module) to the user.""" - try: - n = category.__name__ - except AttributeError: - n = str(category) - get_logger(n, STDERR_HANDLER).warn('{0}:{1}: {2}'.format(filename, lineno, message)) - -warnings.showwarning = showwarning - def req_missing(names, purpose, python=True, optional=False): """Log that we are missing some requirements. @@ -182,7 +157,7 @@ def req_missing(names, purpose, python=True, optional=False): purpose, pnames, whatarethey_p) if optional: - LOGGER.warn(msg) + LOGGER.warning(msg) else: LOGGER.error(msg) LOGGER.error('Exiting due to missing dependencies.') @@ -191,20 +166,19 @@ def req_missing(names, purpose, python=True, optional=False): return msg -from nikola import filters as task_filters # NOQA ENCODING = sys.getfilesystemencoding() or sys.stdin.encoding def sys_encode(thing): """Return bytes encoded in the system's encoding.""" - if isinstance(thing, unicode_str): + if isinstance(thing, str): return thing.encode(ENCODING) return thing def sys_decode(thing): """Return Unicode.""" - if isinstance(thing, bytes_str): + if isinstance(thing, bytes): return thing.decode(ENCODING) return thing @@ -228,12 +202,11 @@ def makedirs(path): class Functionary(defaultdict): - """Class that looks like a function, but is a defaultdict.""" def __init__(self, default, default_lang): """Initialize a functionary.""" - super(Functionary, self).__init__(default) + super().__init__(default) self.default_lang = default_lang def __call__(self, key, lang=None): @@ -244,7 +217,6 @@ class Functionary(defaultdict): class TranslatableSetting(object): - """A setting that can be translated. You can access it via: SETTING(lang). You can omit lang, in which @@ -271,7 +243,7 @@ class TranslatableSetting(object): def __getattribute__(self, attr): """Return attributes, falling back to string attributes.""" try: - return super(TranslatableSetting, self).__getattribute__(attr) + return super().__getattribute__(attr) except AttributeError: return self().__getattribute__(attr) @@ -294,7 +266,7 @@ class TranslatableSetting(object): self.overriden_default = False self.values = defaultdict() - if isinstance(inp, dict): + if isinstance(inp, dict) and inp: self.translated = True self.values.update(inp) if self.default_lang not in self.values.keys(): @@ -335,15 +307,11 @@ class TranslatableSetting(object): def __str__(self): """Return the value in the currently set language (deprecated).""" - return self.values[self.get_lang()] - - def __unicode__(self): - """Return the value in the currently set language (deprecated).""" - return self.values[self.get_lang()] + return str(self.values[self.get_lang()]) def __repr__(self): """Provide a representation for programmers.""" - return ''.format(self.name) + return ''.format(self.name, self._inp) def format(self, *args, **kwargs): """Format ALL the values in the setting the same way.""" @@ -423,15 +391,20 @@ class TranslatableSetting(object): def __eq__(self, other): """Test whether two TranslatableSettings are equal.""" - return self.values == other.values + try: + return self.values == other.values + except AttributeError: + return self(self.default_lang) == other def __ne__(self, other): """Test whether two TranslatableSettings are inequal.""" - return self.values != other.values + try: + return self.values != other.values + except AttributeError: + return self(self.default_lang) != other class TemplateHookRegistry(object): - r"""A registry for template hooks. Usage: @@ -439,9 +412,8 @@ class TemplateHookRegistry(object): >>> r = TemplateHookRegistry('foo', None) >>> r.append('Hello!') >>> r.append(lambda x: 'Hello ' + x + '!', False, 'world') - >>> str(r()) # str() call is not recommended in real use + >>> repr(r()) 'Hello!\nHello world!' - >>> """ def __init__(self, name, site): @@ -483,9 +455,23 @@ class TemplateHookRegistry(object): c = callable(inp) self._items.append((c, inp, wants_site_and_context, args, kwargs)) + def calculate_deps(self): + """Calculate dependencies for a registry.""" + deps = [] + for is_callable, inp, wants_site_and_context, args, kwargs in self._items: + if not is_callable: + name = inp + elif hasattr(inp, 'template_registry_identifier'): + name = inp.template_registry_identifier + elif hasattr(inp, '__doc__'): + name = inp.__doc__ + else: + name = '_undefined_callable_' + deps.append((is_callable, name, wants_site_and_context, args, kwargs)) + def __hash__(self): """Return hash of a registry.""" - return hash(config_changed({self.name: self._items})._calc_digest()) + return hash(config_changed({self.name: self.calculate_deps()})._calc_digest()) def __str__(self): """Stringify a registry.""" @@ -497,32 +483,55 @@ class TemplateHookRegistry(object): class CustomEncoder(json.JSONEncoder): - """Custom JSON encoder.""" def default(self, obj): - """Default encoding handler.""" + """Create default encoding handler.""" try: - return super(CustomEncoder, self).default(obj) + return super().default(obj) except TypeError: if isinstance(obj, (set, frozenset)): return self.encode(sorted(list(obj))) + elif isinstance(obj, TranslatableSetting): + s = json.dumps(obj._inp, cls=CustomEncoder, sort_keys=True) else: s = repr(obj).split('0x', 1)[0] return s class config_changed(tools.config_changed): - """A copy of doit's config_changed, using pickle instead of serializing manually.""" def __init__(self, config, identifier=None): """Initialize config_changed.""" - super(config_changed, self).__init__(config) + super().__init__(config) self.identifier = '_config_changed' if identifier is not None: self.identifier += ':' + identifier + # DEBUG (for unexpected rebuilds) + @classmethod + def _write_into_debug_db(cls, digest: str, data: str) -> None: # pragma: no cover + """Write full values of config_changed into a sqlite3 database.""" + import sqlite3 + try: + cls.debug_db_cursor + except AttributeError: + cls.debug_db_conn = sqlite3.connect("cc_debug.sqlite3") + cls.debug_db_id = datetime.datetime.now().isoformat() + cls.debug_db_cursor = cls.debug_db_conn.cursor() + cls.debug_db_cursor.execute(""" + CREATE TABLE IF NOT EXISTS hashes (hash CHARACTER(32) PRIMARY KEY, json_data TEXT); + """) + cls.debug_db_conn.commit() + + try: + cls.debug_db_cursor.execute("INSERT INTO hashes (hash, json_data) VALUES (?, ?);", (digest, data)) + cls.debug_db_conn.commit() + except sqlite3.IntegrityError: + # ON CONFLICT DO NOTHING, except Ubuntu 16.04’s sqlite3 is too ancient for this + cls.debug_db_conn.rollback() + def _calc_digest(self): """Calculate a config_changed digest.""" if isinstance(self.config, str): @@ -534,7 +543,14 @@ class config_changed(tools.config_changed): else: byte_data = data digest = hashlib.md5(byte_data).hexdigest() + + # DEBUG (for unexpected rebuilds) + # self._write_into_debug_db(digest, data) + # Alternative (without database): # LOGGER.debug('{{"{0}": {1}}}'.format(digest, byte_data)) + # Humanized format: + # LOGGER.debug('[Digest {0} for {2}]\n{1}\n[Digest {0} for {2}]'.format(digest, byte_data, self.identifier)) + return digest else: raise Exception('Invalid type of config_changed parameter -- got ' @@ -559,46 +575,80 @@ class config_changed(tools.config_changed): sort_keys=True)) -def get_theme_path(theme, _themes_dir='themes'): +def get_theme_path_real(theme, themes_dirs): """Return the path where the given theme's files are located. Looks in ./themes and in the place where themes go when installed. """ - dir_name = os.path.join(_themes_dir, theme) - if os.path.isdir(dir_name): - return dir_name + for themes_dir in themes_dirs: + dir_name = os.path.join(themes_dir, theme) + if os.path.isdir(dir_name): + return dir_name dir_name = resource_filename('nikola', os.path.join('data', 'themes', theme)) if os.path.isdir(dir_name): return dir_name raise Exception("Can't find theme '{0}'".format(theme)) -def get_template_engine(themes, _themes_dir='themes'): +def get_theme_path(theme): + """Return the theme's path, which equals the theme's name.""" + return theme + + +def parse_theme_meta(theme_dir): + """Parse a .theme meta file.""" + cp = configparser.ConfigParser() + # The `or` case is in case theme_dir ends with a trailing slash + theme_name = os.path.basename(theme_dir) or os.path.basename(os.path.dirname(theme_dir)) + theme_meta_path = os.path.join(theme_dir, theme_name + '.theme') + cp.read(theme_meta_path) + return cp if cp.has_section('Theme') else None + + +def get_template_engine(themes): """Get template engine used by a given theme.""" for theme_name in themes: - engine_path = os.path.join(get_theme_path(theme_name, _themes_dir), 'engine') - if os.path.isfile(engine_path): - with open(engine_path) as fd: - return fd.readlines()[0].strip() + meta = parse_theme_meta(theme_name) + if meta: + e = meta.get('Theme', 'engine', fallback=None) + if e: + return e + else: + # Theme still uses old-style parent/engine files + engine_path = os.path.join(theme_name, 'engine') + if os.path.isfile(engine_path): + with open(engine_path) as fd: + return fd.readlines()[0].strip() # default return 'mako' -def get_parent_theme_name(theme_name, _themes_dir='themes'): +def get_parent_theme_name(theme_name, themes_dirs=None): """Get name of parent theme.""" - parent_path = os.path.join(get_theme_path(theme_name, _themes_dir), 'parent') - if os.path.isfile(parent_path): - with open(parent_path) as fd: - return fd.readlines()[0].strip() - return None + meta = parse_theme_meta(theme_name) + if meta: + parent = meta.get('Theme', 'parent', fallback=None) + if themes_dirs and parent: + return get_theme_path_real(parent, themes_dirs) + return parent + else: + # Theme still uses old-style parent/engine files + parent_path = os.path.join(theme_name, 'parent') + if os.path.isfile(parent_path): + with open(parent_path) as fd: + parent = fd.readlines()[0].strip() + if themes_dirs: + return get_theme_path_real(parent, themes_dirs) + return parent + return None -def get_theme_chain(theme, _themes_dir='themes'): - """Create the full theme inheritance chain.""" - themes = [theme] +def get_theme_chain(theme, themes_dirs): + """Create the full theme inheritance chain including paths.""" + themes = [get_theme_path_real(theme, themes_dirs)] while True: - parent = get_parent_theme_name(themes[-1], _themes_dir) + parent = get_parent_theme_name(themes[-1], themes_dirs=themes_dirs) # Avoid silly loops if parent is None or parent in themes: break @@ -606,11 +656,10 @@ def get_theme_chain(theme, _themes_dir='themes'): return themes -language_incomplete_warned = [] +INCOMPLETE_LANGUAGES_WARNED = set() class LanguageNotFoundError(Exception): - """An exception thrown if language is not found.""" def __init__(self, lang, orig): @@ -623,7 +672,7 @@ class LanguageNotFoundError(Exception): return 'cannot find language {0}'.format(self.lang) -def load_messages(themes, translations, default_lang): +def load_messages(themes, translations, default_lang, themes_dirs): """Load theme's messages into context. All the messages from parent themes are loaded, @@ -631,35 +680,50 @@ def load_messages(themes, translations, default_lang): """ messages = Functionary(dict, default_lang) oldpath = list(sys.path) + found = {lang: False for lang in translations.keys()} + last_exception = None + completion_status = {lang: False for lang in translations.keys()} for theme_name in themes[::-1]: msg_folder = os.path.join(get_theme_path(theme_name), 'messages') - default_folder = os.path.join(get_theme_path('base'), 'messages') + default_folder = os.path.join(get_theme_path_real('base', themes_dirs), 'messages') sys.path.insert(0, default_folder) sys.path.insert(0, msg_folder) + english = __import__('messages_en') - for lang in list(translations.keys()): + # If we don't do the reload, the module is cached + _reload(english) + for lang in translations.keys(): try: translation = __import__('messages_' + lang) # If we don't do the reload, the module is cached _reload(translation) - if sorted(translation.MESSAGES.keys()) !=\ - sorted(english.MESSAGES.keys()) and \ - lang not in language_incomplete_warned: - language_incomplete_warned.append(lang) - LOGGER.warn("Incomplete translation for language " - "'{0}'.".format(lang)) + found[lang] = True + if sorted(translation.MESSAGES.keys()) != sorted(english.MESSAGES.keys()): + completion_status[lang] = completion_status[lang] or False + else: + completion_status[lang] = True + messages[lang].update(english.MESSAGES) for k, v in translation.MESSAGES.items(): if v: messages[lang][k] = v del(translation) except ImportError as orig: - raise LanguageNotFoundError(lang, orig) - sys.path = oldpath + last_exception = orig + del(english) + sys.path = oldpath + + if not all(found.values()): + raise LanguageNotFoundError(lang, last_exception) + for lang, status in completion_status.items(): + if not status and lang not in INCOMPLETE_LANGUAGES_WARNED: + LOGGER.warning("Incomplete translation for language '{0}'.".format(lang)) + INCOMPLETE_LANGUAGES_WARNED.add(lang) + return messages -def copy_tree(src, dst, link_cutoff=None): +def copy_tree(src, dst, link_cutoff=None, ignored_filenames=None): """Copy a src tree to the dst folder. Example: @@ -670,11 +734,13 @@ def copy_tree(src, dst, link_cutoff=None): should copy "themes/defauts/assets/foo/bar" to "output/assets/foo/bar" - if link_cutoff is set, then the links pointing at things + If link_cutoff is set, then the links pointing at things *inside* that folder will stay as links, and links pointing *outside* that folder will be copied. + + ignored_filenames is a set of file names that will be ignored. """ - ignore = set(['.svn']) + ignore = set(['.svn', '.git']) | (ignored_filenames or set()) base_len = len(src.split(os.sep)) for root, dirs, files in os.walk(src, followlinks=True): root_parts = root.split(os.sep) @@ -724,35 +790,36 @@ def remove_file(source): elif os.path.isfile(source) or os.path.islink(source): os.remove(source) -# slugify is copied from + +# slugify is adopted from # http://code.activestate.com/recipes/ # 577257-slugify-make-a-string-usable-in-a-url-or-filename/ -_slugify_strip_re = re.compile(r'[^+\w\s-]') -_slugify_hyphenate_re = re.compile(r'[-\s]+') +_slugify_strip_re = re.compile(r'[^+\w\s-]', re.UNICODE) +_slugify_hyphenate_re = re.compile(r'[-\s]+', re.UNICODE) -def slugify(value, force=False): +def slugify(value, lang=None, force=False): u"""Normalize string, convert to lowercase, remove non-alpha characters, convert spaces to hyphens. From Django's "django/template/defaultfilters.py". - >>> print(slugify('áéí.óú')) + >>> print(slugify('áéí.óú', lang='en')) aeiou - >>> print(slugify('foo/bar')) + >>> print(slugify('foo/bar', lang='en')) foobar - >>> print(slugify('foo bar')) + >>> print(slugify('foo bar', lang='en')) foo-bar """ - if not isinstance(value, unicode_str): + if not isinstance(value, str): raise ValueError("Not a unicode object: {0}".format(value)) if USE_SLUGIFY or force: # This is the standard state of slugify, which actually does some work. # It is the preferred style, especially for Western languages. - value = unicode_str(unidecode(value)) - value = _slugify_strip_re.sub('', value, re.UNICODE).strip().lower() - return _slugify_hyphenate_re.sub('-', value, re.UNICODE) + value = str(unidecode(value)) + value = _slugify_strip_re.sub('', value).strip().lower() + return _slugify_hyphenate_re.sub('-', value) else: # This is the “disarmed” state of slugify, which lets the user # have any character they please (be it regular ASCII with spaces, @@ -769,7 +836,7 @@ def slugify(value, force=False): return value -def unslugify(value, discard_numbers=True): +def unslugify(value, lang=None, discard_numbers=True): """Given a slug string (as a filename), return a human readable string. If discard_numbers is True, numbers right at the beginning of input @@ -777,16 +844,38 @@ def unslugify(value, discard_numbers=True): """ if discard_numbers: value = re.sub('^[0-9]+', '', value) - value = re.sub('([_\-\.])', ' ', value) + value = re.sub(r'([_\-\.])', ' ', value) value = value.strip().capitalize() return value +def encodelink(iri): + """Given an encoded or unencoded link string, return an encoded string suitable for use as a link in HTML and XML.""" + iri = unicodenormalize('NFC', iri) + link = OrderedDict(urlparse(iri)._asdict()) + link['path'] = urlquote(urlunquote(link['path']).encode('utf-8'), safe="/~") + try: + link['netloc'] = link['netloc'].encode('utf-8').decode('idna').encode('idna').decode('utf-8') + except UnicodeDecodeError: + link['netloc'] = link['netloc'].encode('idna').decode('utf-8') + encoded_link = urlunparse(link.values()) + return encoded_link + + +def full_path_from_urlparse(parsed) -> str: + """Given urlparse output, return the full path (with query and fragment).""" + dst = parsed.path + if parsed.query: + dst = "{0}?{1}".format(dst, parsed.query) + if parsed.fragment: + dst = "{0}#{1}".format(dst, parsed.fragment) + return dst + # A very slightly safer version of zip.extractall that works on # python < 2.6 -class UnsafeZipException(Exception): +class UnsafeZipException(Exception): """Exception for unsafe zip files.""" pass @@ -815,6 +904,8 @@ def extract_all(zipfile, path='themes'): def to_datetime(value, tzinfo=None): """Convert string to datetime.""" try: + if type(value) == datetime.date: + value = datetime.datetime.combine(value, datetime.time(0, 0)) if not isinstance(value, datetime.datetime): # dateutil does bad things with TZs like UTC-03:00. dateregexp = re.compile(r' UTC([+-][0-9][0-9]:[0-9][0-9])') @@ -845,6 +936,9 @@ def current_time(tzinfo=None): return dt +from nikola import filters as task_filters # NOQA + + def apply_filters(task, filters, skip_ext=None): """Apply filters to a task. @@ -863,11 +957,11 @@ def apply_filters(task, filters, skip_ext=None): if isinstance(key, (tuple, list)): if ext in key: return value - elif isinstance(key, (bytes_str, unicode_str)): + elif isinstance(key, (bytes, str)): if ext == key: return value else: - assert False, key + raise ValueError("Cannot find filter match for {0}".format(key)) for target in task.get('targets', []): ext = os.path.splitext(target)[-1].lower() @@ -887,7 +981,7 @@ def apply_filters(task, filters, skip_ext=None): return task -def get_crumbs(path, is_file=False, index_folder=None): +def get_crumbs(path, is_file=False, index_folder=None, lang=None): """Create proper links for a crumb bar. index_folder is used if you want to use title from index file @@ -896,26 +990,26 @@ def get_crumbs(path, is_file=False, index_folder=None): >>> crumbs = get_crumbs('galleries') >>> len(crumbs) 1 - >>> print('|'.join(crumbs[0])) - #|galleries + >>> crumbs[0] + ['#', 'galleries'] >>> crumbs = get_crumbs(os.path.join('galleries','demo')) >>> len(crumbs) 2 - >>> print('|'.join(crumbs[0])) - ..|galleries - >>> print('|'.join(crumbs[1])) - #|demo + >>> crumbs[0] + ['..', 'galleries'] + >>> crumbs[1] + ['#', 'demo'] >>> crumbs = get_crumbs(os.path.join('listings','foo','bar'), is_file=True) >>> len(crumbs) 3 - >>> print('|'.join(crumbs[0])) - ..|listings - >>> print('|'.join(crumbs[1])) - .|foo - >>> print('|'.join(crumbs[2])) - #|bar + >>> crumbs[0] + ['..', 'listings'] + >>> crumbs[1] + ['.', 'foo'] + >>> crumbs[2] + ['#', 'bar'] """ crumbs = path.split(os.sep) _crumbs = [] @@ -923,8 +1017,10 @@ def get_crumbs(path, is_file=False, index_folder=None): for i, crumb in enumerate(crumbs[-3::-1]): # Up to parent folder only _path = '/'.join(['..'] * (i + 1)) _crumbs.append([_path, crumb]) - _crumbs.insert(0, ['.', crumbs[-2]]) # file's folder - _crumbs.insert(0, ['#', crumbs[-1]]) # file itself + if len(crumbs) >= 2: + _crumbs.insert(0, ['.', crumbs[-2]]) # file's folder + if len(crumbs) >= 1: + _crumbs.insert(0, ['#', crumbs[-1]]) # file itself else: for i, crumb in enumerate(crumbs[::-1]): _path = '/'.join(['..'] * i) or '#' @@ -940,40 +1036,49 @@ def get_crumbs(path, is_file=False, index_folder=None): index_post = index_folder.parse_index(folder, '', '') folder = folder.replace(crumb, '') if index_post: - crumb = index_post.title() or crumb + crumb = index_post.title(lang) or crumb _crumbs[i][1] = crumb return list(reversed(_crumbs)) -def get_asset_path(path, themes, files_folders={'files': ''}, _themes_dir='themes'): +def get_asset_path(path, themes, files_folders={'files': ''}, output_dir='output'): """Return the "real", absolute path to the asset. By default, it checks which theme provides the asset. If the asset is not provided by a theme, then it will be checked for in the FILES_FOLDERS. + If it's not provided by either, it will be chacked in output, where + it may have been created by another plugin. - >>> print(get_asset_path('assets/css/rst.css', ['bootstrap3', 'base'])) - /.../nikola/data/themes/base/assets/css/rst.css + >>> print(get_asset_path('assets/css/nikola_rst.css', get_theme_chain('bootstrap3', ['themes']))) + /.../nikola/data/themes/base/assets/css/nikola_rst.css - >>> print(get_asset_path('assets/css/theme.css', ['bootstrap3', 'base'])) + >>> print(get_asset_path('assets/css/theme.css', get_theme_chain('bootstrap3', ['themes']))) /.../nikola/data/themes/bootstrap3/assets/css/theme.css - >>> print(get_asset_path('nikola.py', ['bootstrap3', 'base'], {'nikola': ''})) + >>> print(get_asset_path('nikola.py', get_theme_chain('bootstrap3', ['themes']), {'nikola': ''})) /.../nikola/nikola.py - >>> print(get_asset_path('nikola/nikola.py', ['bootstrap3', 'base'], {'nikola':'nikola'})) + >>> print(get_asset_path('nikola.py', get_theme_chain('bootstrap3', ['themes']), {'nikola': 'nikola'})) None + >>> print(get_asset_path('nikola/nikola.py', get_theme_chain('bootstrap3', ['themes']), {'nikola': 'nikola'})) + /.../nikola/nikola.py + """ for theme_name in themes: - candidate = os.path.join( - get_theme_path(theme_name, _themes_dir), - path - ) + candidate = os.path.join(get_theme_path(theme_name), path) if os.path.isfile(candidate): return candidate for src, rel_dst in files_folders.items(): - candidate = os.path.abspath(os.path.join(src, path)) + relpath = os.path.normpath(os.path.relpath(path, rel_dst)) + if not relpath.startswith('..' + os.path.sep): + candidate = os.path.abspath(os.path.join(src, relpath)) + if os.path.isfile(candidate): + return candidate + + if output_dir: + candidate = os.path.join(output_dir, path) if os.path.isfile(candidate): return candidate @@ -982,30 +1087,53 @@ def get_asset_path(path, themes, files_folders={'files': ''}, _themes_dir='theme class LocaleBorgUninitializedException(Exception): - """Exception for unitialized LocaleBorg.""" def __init__(self): """Initialize exception.""" - super(LocaleBorgUninitializedException, self).__init__("Attempt to use LocaleBorg before initialization") + super().__init__("Attempt to use LocaleBorg before initialization") + + +# Customized versions of babel.dates functions that don't do weird stuff with +# timezones. Without these fixes, DST would follow local settings (because +# dateutil’s timezones return stuff depending on their input, and datetime.time +# objects have no year/month/day to base the information on. +def format_datetime(datetime=None, format='medium', + locale=babel.dates.LC_TIME): + """Format a datetime object.""" + locale = babel.dates.Locale.parse(locale) + if format in ('full', 'long', 'medium', 'short'): + return babel.dates.get_datetime_format(format, locale=locale) \ + .replace("'", "") \ + .replace('{0}', format_time(datetime, format, locale=locale)) \ + .replace('{1}', babel.dates.format_date(datetime, format, locale=locale)) + else: + return babel.dates.parse_pattern(format).apply(datetime, locale) -class LocaleBorg(object): +def format_time(time=None, format='medium', locale=babel.dates.LC_TIME): + """Format time. Input can be datetime.time or datetime.datetime.""" + locale = babel.dates.Locale.parse(locale) + if format in ('full', 'long', 'medium', 'short'): + format = babel.dates.get_time_format(format, locale=locale) + return babel.dates.parse_pattern(format).apply(time, locale) - """Provide locale related services and autoritative current_lang. - current_lang is the last lang for which the locale was set - and is meant to be set only by LocaleBorg.set_locale. +def format_skeleton(skeleton, datetime=None, fo=None, fuzzy=True, + locale=babel.dates.LC_TIME): + """Format a datetime based on a skeleton.""" + locale = babel.dates.Locale.parse(locale) + if fuzzy and skeleton not in locale.datetime_skeletons: + skeleton = babel.dates.match_skeleton(skeleton, locale.datetime_skeletons) + format = locale.datetime_skeletons[skeleton] + return format_datetime(datetime, format, locale) - python's locale code should not be directly called from code outside of - LocaleBorg, they are compatibilty issues with py version and OS support - better handled at one central point, LocaleBorg. - In particular, don't call locale.setlocale outside of LocaleBorg. +class LocaleBorg(object): + """Provide locale related services and autoritative current_lang. - Assumptions: - We need locales only for the languages there is a nikola translation. - We don't need to support current_lang through nested contexts + This class stores information about the locales used and interfaces + with the Babel library to provide internationalization services. Usage: # early in cmd or test execution @@ -1015,97 +1143,132 @@ class LocaleBorg(object): lang = LocaleBorg(). Available services: - .current_lang : autoritative current_lang , the last seen in set_locale - .set_locale(lang) : sets current_lang and sets the locale for lang - .get_month_name(month_no, lang) : returns the localized month name - - NOTE: never use locale.getlocale() , it can return values that - locale.setlocale will not accept in Windows XP, 7 and pythons 2.6, 2.7, 3.3 - Examples: "Spanish", "French" can't do the full circle set / get / set - That used to break calendar, but now seems is not the case, with month at least + .current_lang: autoritative current_lang, the last seen in set_locale + .formatted_date: format a date(time) according to locale rules + .format_date_in_string: take a message and format the date in it + + The default implementation uses the Babel package and completely ignores + the Python `locale` module. If you wish to override this, write functions + and assign them to the appropriate names. The functions are: + + * LocaleBorg.datetime_formatter(date, date_format, lang, locale) + * LocaleBorg.in_string_formatter(date, mode, custom_format, lang, locale) """ initialized = False + # Can be used to override Babel + datetime_formatter = None + in_string_formatter = None + @classmethod - def initialize(cls, locales, initial_lang): + def initialize(cls, locales: 'typing.Dict[str, str]', initial_lang: str): """Initialize LocaleBorg. - locales : dict with lang: locale_n - the same keys as in nikola's TRANSLATIONS - locale_n a sanitized locale, meaning - locale.setlocale(locale.LC_ALL, locale_n) will succeed - locale_n expressed in the string form, like "en.utf8" + locales: dict with custom locale name overrides. """ - assert initial_lang is not None and initial_lang in locales + if not initial_lang: + raise ValueError("Unknown initial language {0}".format(initial_lang)) cls.reset() cls.locales = locales - - # needed to decode some localized output in py2x - encodings = {} - for lang in locales: - locale.setlocale(locale.LC_ALL, locales[lang]) - loc, encoding = locale.getlocale() - encodings[lang] = encoding - - cls.encodings = encodings - cls.__shared_state['current_lang'] = initial_lang + cls.__initial_lang = initial_lang cls.initialized = True + def __get_shared_state(self): + if not self.initialized: # pragma: no cover + raise LocaleBorgUninitializedException() + shared_state = getattr(self.__thread_local, 'shared_state', None) + if shared_state is None: + shared_state = {'current_lang': self.__initial_lang} + self.__thread_local.shared_state = shared_state + return shared_state + @classmethod def reset(cls): """Reset LocaleBorg. Used in testing to prevent leaking state between tests. """ + cls.__thread_local = threading.local() + cls.__thread_lock = threading.Lock() + cls.locales = {} - cls.encodings = {} - cls.__shared_state = {'current_lang': None} cls.initialized = False + cls.thread_local = None + cls.datetime_formatter = None + cls.in_string_formatter = None def __init__(self): """Initialize.""" if not self.initialized: raise LocaleBorgUninitializedException() - self.__dict__ = self.__shared_state - def set_locale(self, lang): - """Set the locale for language lang, returns an empty string. + @property + def current_lang(self) -> str: + """Return the current language.""" + return self.__get_shared_state()['current_lang'] + + def set_locale(self, lang: str) -> str: + """Set the current language and return an empty string (to make use in templates easier).""" + with self.__thread_lock: + self.__get_shared_state()['current_lang'] = lang + return '' + + def formatted_date(self, date_format: 'str', + date: 'typing.Union[datetime.date, datetime.datetime]', + lang: 'typing.Optional[str]' = None) -> str: + """Return the formatted date/datetime as a string.""" + if lang is None: + lang = self.current_lang + locale = self.locales.get(lang, lang) + # Get a string out of a TranslatableSetting + if isinstance(date_format, TranslatableSetting): + date_format = date_format(lang) + + # Always ask Python if the date_format is webiso + if date_format == 'webiso': + # Formatted after RFC 3339 (web ISO 8501 profile) with Zulu + # zone designator for times in UTC and no microsecond precision. + return date.replace(microsecond=0).isoformat().replace('+00:00', 'Z') + elif LocaleBorg.datetime_formatter is not None: + return LocaleBorg.datetime_formatter(date, date_format, lang, locale) + else: + return format_datetime(date, date_format, locale=locale) - in linux the locale encoding is set to utf8, - in windows that cannot be guaranted. - In either case, the locale encoding is available in cls.encodings[lang] + def format_date_in_string(self, message: str, date: datetime.date, lang: 'typing.Optional[str]' = None) -> str: + """Format date inside a string (message). + + Accepted modes: month, month_year, month_day_year. + Format: {month} for standard, {month:MMMM} for customization. """ - # intentional non try-except: templates must ask locales with a lang, - # let the code explode here and not hide the point of failure - # Also, not guarded with an if lang==current_lang because calendar may - # put that out of sync - locale_n = self.locales[lang] - self.__shared_state['current_lang'] = lang - locale.setlocale(locale.LC_ALL, locale_n) - return '' - - def get_month_name(self, month_no, lang): - """Return localized month name in an unicode string.""" - if sys.version_info[0] == 3: # Python 3 - with calendar.different_locale(self.locales[lang]): - s = calendar.month_name[month_no] - # for py3 s is unicode - else: # Python 2 - with calendar.TimeEncoding(self.locales[lang]): - s = calendar.month_name[month_no] - enc = self.encodings[lang] - if not enc: - enc = 'UTF-8' - - s = s.decode(enc) - # paranoid about calendar ending in the wrong locale (windows) - self.set_locale(self.current_lang) - return s + modes = { + 'month': ('date', 'LLLL'), + 'month_year': ('skeleton', 'yMMMM'), + 'month_day_year': ('date', 'long') + } + if lang is None: + lang = self.current_lang + locale = self.locales.get(lang, lang) + + def date_formatter(match: typing.Match) -> str: + """Format a date as requested.""" + mode, custom_format = match.groups() + if LocaleBorg.in_string_formatter is not None: + return LocaleBorg.in_string_formatter(date, mode, custom_format, lang, locale) + elif custom_format: + return babel.dates.format_date(date, custom_format, locale) + else: + function, fmt = modes[mode] + if function == 'skeleton': + return format_skeleton(fmt, date, locale=locale) + else: + return babel.dates.format_date(date, fmt, locale) -class ExtendedRSS2(rss.RSS2): + return re.sub(r'{(.*?)(?::(.*?))?}', date_formatter, message) + +class ExtendedRSS2(rss.RSS2): """Extended RSS class.""" xsl_stylesheet_href = None @@ -1114,8 +1277,7 @@ class ExtendedRSS2(rss.RSS2): """Publish a feed.""" if self.xsl_stylesheet_href: handler.processingInstruction("xml-stylesheet", 'type="text/xsl" href="{0}" media="all"'.format(self.xsl_stylesheet_href)) - # old-style class in py2 - rss.RSS2.publish(self, handler) + super().publish(handler) def publish_extensions(self, handler): """Publish extensions.""" @@ -1129,14 +1291,14 @@ class ExtendedRSS2(rss.RSS2): class ExtendedItem(rss.RSSItem): - """Extended RSS item.""" def __init__(self, **kw): """Initialize RSS item.""" - self.creator = kw.pop('creator') + self.creator = kw.pop('creator', None) + # It's an old style class - return rss.RSSItem.__init__(self, **kw) + rss.RSSItem.__init__(self, **kw) def publish_extensions(self, handler): """Publish extensions.""" @@ -1176,24 +1338,34 @@ def demote_headers(doc, level=1): if level == 0: return doc elif level > 0: - r = range(1, 7 - level) + levels = range(1, 7 - (level - 1)) + levels = reversed(levels) elif level < 0: - r = range(1 + level, 7) - for i in reversed(r): - # html headers go to 6, so we can’t “lower” beneath five - elements = doc.xpath('//h' + str(i)) - for e in elements: - e.tag = 'h' + str(i + level) + levels = range(2 + level, 7) + + for before in levels: + after = before + level + if after < 1: + # html headers can't go lower than 1 + after = 1 + elif after > 6: + # html headers go until 6 + after = 6 + + if before == after: + continue + + elements = doc.xpath('//h{}'.format(before)) + new_tag = 'h{}'.format(after) + for element in elements: + element.tag = new_tag def get_root_dir(): """Find root directory of nikola site by looking for conf.py.""" root = os.getcwd() - if sys.version_info[0] == 2: - confname = b'conf.py' - else: - confname = 'conf.py' + confname = 'conf.py' while True: if os.path.exists(os.path.join(root, confname)): @@ -1224,10 +1396,10 @@ def get_translation_candidate(config, path, lang): cache/posts/fancy.post.html >>> print(get_translation_candidate(config, 'cache/posts/fancy.post.html', 'es')) cache/posts/fancy.post.es.html - >>> print(get_translation_candidate(config, 'cache/stories/charts.html', 'es')) - cache/stories/charts.es.html - >>> print(get_translation_candidate(config, 'cache/stories/charts.html', 'en')) - cache/stories/charts.html + >>> print(get_translation_candidate(config, 'cache/pages/charts.html', 'es')) + cache/pages/charts.es.html + >>> print(get_translation_candidate(config, 'cache/pages/charts.html', 'en')) + cache/pages/charts.html >>> config = {'TRANSLATIONS_PATTERN': '{path}.{ext}.{lang}', 'DEFAULT_LANG': 'en', 'TRANSLATIONS': {'es':'1', 'en': 1}} >>> print(get_translation_candidate(config, '*.rst', 'es')) @@ -1247,7 +1419,7 @@ def get_translation_candidate(config, path, lang): # This will still break if the user has ?*[]\ in the pattern. But WHY WOULD HE? pattern = pattern.replace('.', r'\.') pattern = pattern.replace('{path}', '(?P.+?)') - pattern = pattern.replace('{ext}', '(?P[^\./]+)') + pattern = pattern.replace('{ext}', r'(?P[^\./]+)') pattern = pattern.replace('{lang}', '(?P{0})'.format('|'.join(config['TRANSLATIONS'].keys()))) m = re.match(pattern, path) if m and all(m.groups()): # It's a translated path @@ -1268,24 +1440,59 @@ def get_translation_candidate(config, path, lang): return config['TRANSLATIONS_PATTERN'].format(path=p, ext=e, lang=lang) -def write_metadata(data): - """Write metadata.""" - order = ('title', 'slug', 'date', 'tags', 'category', 'link', 'description', 'type') - f = '.. {0}: {1}' - meta = [] - for k in order: - try: - meta.append(f.format(k, data.pop(k))) - except KeyError: - pass - - # Leftover metadata (user-specified/non-default). - for k in natsort.natsorted(list(data.keys()), alg=natsort.ns.F | natsort.ns.IC): - meta.append(f.format(k, data[k])) +def write_metadata(data, metadata_format=None, comment_wrap=False, site=None, compiler=None): + """Write metadata. - meta.append('') - - return '\n'.join(meta) + Recommended usage: pass `site`, `comment_wrap` (True, False, or a 2-tuple of start/end markers), and optionally `compiler`. Other options are for backwards compatibility. + """ + # API compatibility + if metadata_format is None and site is not None: + metadata_format = site.config.get('METADATA_FORMAT', 'nikola').lower() + if metadata_format is None: + metadata_format = 'nikola' + + if site is None: + import nikola.metadata_extractors + metadata_extractors_by = nikola.metadata_extractors.default_metadata_extractors_by() + nikola.metadata_extractors.load_defaults(site, metadata_extractors_by) + else: + metadata_extractors_by = site.metadata_extractors_by + + # Pelican is mapped to rest_docinfo, markdown_meta, or nikola. + if metadata_format == 'pelican': + if compiler and compiler.name == 'rest': + metadata_format = 'rest_docinfo' + elif compiler and compiler.name == 'markdown': + metadata_format = 'markdown_meta' + else: + # Quiet fallback. + metadata_format = 'nikola' + + default_meta = ('nikola', 'rest_docinfo', 'markdown_meta') + extractor = metadata_extractors_by['name'].get(metadata_format) + if extractor and extractor.supports_write: + extractor.check_requirements() + return extractor.write_metadata(data, comment_wrap) + elif extractor and metadata_format not in default_meta: + LOGGER.warning('Writing METADATA_FORMAT {} is not supported, using "nikola" format'.format(metadata_format)) + elif metadata_format not in default_meta: + LOGGER.warning('Unknown METADATA_FORMAT {}, using "nikola" format'.format(metadata_format)) + + if metadata_format == 'rest_docinfo': + title = data['title'] + results = [ + '=' * len(title), + title, + '=' * len(title), + '' + ] + [':{0}: {1}'.format(k, v) for k, v in data.items() if v and k != 'title'] + [''] + return '\n'.join(results) + elif metadata_format == 'markdown_meta': + results = ['{0}: {1}'.format(k, v) for k, v in data.items() if v] + ['', ''] + return '\n'.join(results) + else: # Nikola, default + from nikola.metadata_extractors import DEFAULT_EXTRACTOR + return DEFAULT_EXTRACTOR.write_metadata(data, comment_wrap) def ask(query, default=None): @@ -1294,10 +1501,7 @@ def ask(query, default=None): default_q = ' [{0}]'.format(default) else: default_q = '' - if sys.version_info[0] == 3: - inp = raw_input("{query}{default_q}: ".format(query=query, default_q=default_q)).strip() - else: - inp = raw_input("{query}{default_q}: ".format(query=query, default_q=default_q).encode('utf-8')).strip() + inp = input("{query}{default_q}: ".format(query=query, default_q=default_q)).strip() if inp or default is None: return inp else: @@ -1312,10 +1516,7 @@ def ask_yesno(query, default=None): default_q = ' [Y/n]' elif default is False: default_q = ' [y/N]' - if sys.version_info[0] == 3: - inp = raw_input("{query}{default_q} ".format(query=query, default_q=default_q)).strip() - else: - inp = raw_input("{query}{default_q} ".format(query=query, default_q=default_q).encode('utf-8')).strip() + inp = input("{query}{default_q} ".format(query=query, default_q=default_q)).strip() if inp: return inp.lower().startswith('y') elif default is not None: @@ -1326,7 +1527,6 @@ def ask_yesno(query, default=None): class CommandWrapper(object): - """Converts commands into functions.""" def __init__(self, cmd, commands_object): @@ -1342,7 +1542,6 @@ class CommandWrapper(object): class Commands(object): - """Nikola Commands. Sample usage: @@ -1366,10 +1565,6 @@ class Commands(object): # cleanup: run is doit-only, init is useless in an existing site if k in ['run', 'init']: continue - if sys.version_info[0] == 2: - k2 = bytes(k) - else: - k2 = k self._cmdnames.append(k) @@ -1380,7 +1575,7 @@ class Commands(object): # doit command: needs some help opt = v(config=self._config, **self._doitargs).get_options() nc = type( - k2, + k, (CommandWrapper,), { '__doc__': options2docstring(k, opt) @@ -1432,18 +1627,27 @@ def options2docstring(name, options): return '\n'.join(result) -class NikolaPygmentsHTML(HtmlFormatter): - +class NikolaPygmentsHTML(BetterHtmlFormatter): """A Nikola-specific modification of Pygments' HtmlFormatter.""" - def __init__(self, anchor_ref, classes=None, linenos='table', linenostart=1): + def __init__(self, anchor_ref=None, classes=None, **kwargs): """Initialize formatter.""" if classes is None: classes = ['code', 'literal-block'] + if anchor_ref: + kwargs['lineanchors'] = slugify( + anchor_ref, lang=LocaleBorg().current_lang, force=True) self.nclasses = classes - super(NikolaPygmentsHTML, self).__init__( - cssclass='code', linenos=linenos, linenostart=linenostart, nowrap=False, - lineanchors=slugify(anchor_ref, force=True), anchorlinenos=True) + kwargs['cssclass'] = 'code' + if not kwargs.get('linenos'): + # Default to no line numbers (Issue #3426) + kwargs['linenos'] = False + if kwargs.get('linenos') not in {'table', 'inline', 'ol', False}: + # Map invalid values to table + kwargs['linenos'] = 'table' + kwargs['anchorlinenos'] = kwargs['linenos'] == 'table' + kwargs['nowrap'] = False + super().__init__(**kwargs) def wrap(self, source, outfile): """Wrap the ``source``, which is a generator yielding individual lines, in custom generators.""" @@ -1461,6 +1665,10 @@ class NikolaPygmentsHTML(HtmlFormatter): yield 0, '' +# For consistency, override the default formatter. +pygments.formatters._formatter_cache['HTML'] = NikolaPygmentsHTML + + def get_displayed_page_number(i, num_pages, site): """Get page number to be displayed for entry `i`.""" if not i: @@ -1486,7 +1694,7 @@ def adjust_name_for_index_path_list(path_list, i, displayed_i, lang, site, force path_list.append(index_file) if site.config["PRETTY_URLS"] and site.config["INDEXES_PRETTY_PAGE_URL"](lang) and path_list[-1] == index_file: path_schema = site.config["INDEXES_PRETTY_PAGE_URL"](lang) - if isinstance(path_schema, (bytes_str, unicode_str)): + if isinstance(path_schema, (bytes, str)): path_schema = [path_schema] else: path_schema = None @@ -1529,7 +1737,7 @@ def adjust_name_for_index_link(name, i, displayed_i, lang, site, force_addition= def create_redirect(src, dst): - """"Create a redirection.""" + """Create a redirection.""" makedirs(os.path.dirname(src)) with io.open(src, "w+", encoding="utf8") as fd: fd.write('\n\n\n' @@ -1539,138 +1747,156 @@ def create_redirect(src, dst): '
    here.

    \n'.format(dst)) -class TreeNode(object): - - """A tree node.""" - - indent_levels = None # use for formatting comments as tree - indent_change_before = 0 # use for formatting comments as tree - indent_change_after = 0 # use for formatting comments as tree - - # The indent levels and changes allow to render a tree structure - # without keeping track of all that information during rendering. - # - # The indent_change_before is the different between the current - # comment's level and the previous comment's level; if the number - # is positive, the current level is indented further in, and if it - # is negative, it is indented further out. Positive values can be - # used to open HTML tags for each opened level. - # - # The indent_change_after is the difference between the next - # comment's level and the current comment's level. Negative values - # can be used to close HTML tags for each closed level. - # - # The indent_levels list contains one entry (index, count) per - # level, informing about the index of the current comment on that - # level and the count of comments on that level (before a comment - # of a higher level comes). This information can be used to render - # tree indicators, for example to generate a tree such as: - # - # +--- [(0,3)] - # +-+- [(1,3)] - # | +--- [(1,3), (0,2)] - # | +-+- [(1,3), (1,2)] - # | +--- [(1,3), (1,2), (0, 1)] - # +-+- [(2,3)] - # +- [(2,3), (0,1)] - # - # (The lists used as labels represent the content of the - # indent_levels property for that node.) - - def __init__(self, name, parent=None): - """Initialize node.""" - self.name = name - self.parent = parent - self.children = [] - - def get_path(self): - """Get path.""" - path = [] - curr = self - while curr is not None: - path.append(curr) - curr = curr.parent - return reversed(path) - - def get_children(self): - """Get children of a node.""" - return self.children - - -def flatten_tree_structure(root_list): - """Flatten a tree.""" - elements = [] - - def generate(input_list, indent_levels_so_far): - for index, element in enumerate(input_list): - # add to destination - elements.append(element) - # compute and set indent levels - indent_levels = indent_levels_so_far + [(index, len(input_list))] - element.indent_levels = indent_levels - # add children - children = element.get_children() - element.children_count = len(children) - generate(children, indent_levels) - - generate(root_list, []) - # Add indent change counters - level = 0 - last_element = None - for element in elements: - new_level = len(element.indent_levels) - # Compute level change before this element - change = new_level - level - if last_element is not None: - last_element.indent_change_after = change - element.indent_change_before = change - # Update variables - level = new_level - last_element = element - # Set level change after last element - if last_element is not None: - last_element.indent_change_after = -level - return elements - - -def parse_escaped_hierarchical_category_name(category_name): - """Parse a category name.""" - result = [] - current = None - index = 0 - next_backslash = category_name.find('\\', index) - next_slash = category_name.find('/', index) - while index < len(category_name): - if next_backslash == -1 and next_slash == -1: - current = (current if current else "") + category_name[index:] - index = len(category_name) - elif next_slash >= 0 and (next_backslash == -1 or next_backslash > next_slash): - result.append((current if current else "") + category_name[index:next_slash]) - current = '' - index = next_slash + 1 - next_slash = category_name.find('/', index) +def colorize_str_from_base_color(string, base_color): + """Find a perceptual similar color from a base color based on the hash of a string. + + Make up to 16 attempts (number of bytes returned by hashing) at picking a + hue for our color at least 27 deg removed from the base color, leaving + lightness and saturation untouched using HUSL colorspace. + """ + def hash_str(string, pos): + return hashlib.md5(string.encode('utf-8')).digest()[pos] + + def degreediff(dega, degb): + return min(abs(dega - degb), abs((degb - dega) + 360)) + + if husl is None: + req_missing(['husl'], 'Use color mixing (section colors)', + optional=True) + return base_color + h, s, l = husl.hex_to_husl(base_color) + old_h = h + idx = 0 + while degreediff(old_h, h) < 27 and idx < 16: + h = 360.0 * (float(hash_str(string, idx)) / 255) + idx += 1 + return husl.husl_to_hex(h, s, l) + + +def colorize_str(string: str, base_color: str, presets: dict): + """Colorize a string by using a presets dict or generate one based on base_color.""" + if string in presets: + return presets[string] + return colorize_str_from_base_color(string, base_color) + + +def color_hsl_adjust_hex(hexstr, adjust_h=None, adjust_s=None, adjust_l=None): + """Adjust a hex color using HSL arguments, adjustments in percentages 1.0 to -1.0. Returns a hex color.""" + h, s, l = husl.hex_to_husl(hexstr) + + if adjust_h: + h = h + (adjust_h * 360.0) + + if adjust_s: + s = s + (adjust_s * 100.0) + + if adjust_l: + l = l + (adjust_l * 100.0) + + return husl.husl_to_hex(h, s, l) + + +def dns_sd(port, inet6): + """Optimistically publish a HTTP service to the local network over DNS-SD. + + Works only on Linux/FreeBSD. Requires the `avahi` and `dbus` modules (symlinks in virtualenvs) + """ + try: + import avahi + import dbus + inet = avahi.PROTO_INET6 if inet6 else avahi.PROTO_INET + name = "{0}'s Nikola Server on {1}".format(os.getlogin(), socket.gethostname()) + bus = dbus.SystemBus() + bus_server = dbus.Interface(bus.get_object(avahi.DBUS_NAME, + avahi.DBUS_PATH_SERVER), + avahi.DBUS_INTERFACE_SERVER) + bus_group = dbus.Interface(bus.get_object(avahi.DBUS_NAME, + bus_server.EntryGroupNew()), + avahi.DBUS_INTERFACE_ENTRY_GROUP) + bus_group.AddService(avahi.IF_UNSPEC, inet, dbus.UInt32(0), + name, '_http._tcp', '', '', + dbus.UInt16(port), '') + bus_group.Commit() + return bus_group # remember to bus_group.Reset() to unpublish + except Exception: + return None + + +def clean_before_deployment(site): + """Clean drafts and future posts before deployment.""" + undeployed_posts = [] + deploy_drafts = site.config.get('DEPLOY_DRAFTS', True) + deploy_future = site.config.get('DEPLOY_FUTURE', False) + if not (deploy_drafts and deploy_future): # == !drafts || !future + # Remove drafts and future posts + out_dir = site.config['OUTPUT_FOLDER'] + site.scan_posts() + for post in site.timeline: + if (not deploy_drafts and post.is_draft) or (not deploy_future and post.publish_later): + for lang in post.translated_to: + remove_file(os.path.join(out_dir, post.destination_path(lang))) + source_path = post.destination_path(lang, post.source_ext(True)) + remove_file(os.path.join(out_dir, source_path)) + undeployed_posts.append(post) + return undeployed_posts + + +def sort_posts(posts, *keys): + """Sort posts by a given predicate. Helper function for templates. + + If a key starts with '-', it is sorted in descending order. + + Usage examples:: + + sort_posts(timeline, 'title', 'date') + sort_posts(timeline, 'author', '-section_name') + """ + # We reverse the keys to get the usual ordering method: the first key + # provided is the most important sorting predicate (first by 'title', then + # by 'date' in the first example) + for key in reversed(keys): + if key.startswith('-'): + key = key[1:] + reverse = True else: - if len(category_name) == next_backslash + 1: - raise Exception("Unexpected '\\' in '{0}' at last position!".format(category_name)) - esc_ch = category_name[next_backslash + 1] - if esc_ch not in {'/', '\\'}: - raise Exception("Unknown escape sequence '\\{0}' in '{1}'!".format(esc_ch, category_name)) - current = (current if current else "") + category_name[index:next_backslash] + esc_ch - index = next_backslash + 2 - next_backslash = category_name.find('\\', index) - if esc_ch == '/': - next_slash = category_name.find('/', index) - if current is not None: - result.append(current) - return result + reverse = False + try: + # An attribute (or method) of the Post object + a = getattr(posts[0], key) + if callable(a): + keyfunc = operator.methodcaller(key) + else: + keyfunc = operator.attrgetter(key) + except AttributeError: + # Post metadata + keyfunc = operator.methodcaller('meta', key) + posts = sorted(posts, reverse=reverse, key=keyfunc) + return posts -def join_hierarchical_category_path(category_path): - """Join a category path.""" - def escape(s): - return s.replace('\\', '\\\\').replace('/', '\\/') - return '/'.join([escape(p) for p in category_path]) +def smartjoin(join_char: str, string_or_iterable) -> str: + """Join string_or_iterable with join_char if it is iterable; otherwise converts it to string. + + >>> smartjoin('; ', 'foo, bar') + 'foo, bar' + >>> smartjoin('; ', ['foo', 'bar']) + 'foo; bar' + >>> smartjoin(' to ', ['count', 42]) + 'count to 42' + """ + if isinstance(string_or_iterable, (str, bytes)): + return string_or_iterable + elif isinstance(string_or_iterable, Iterable): + return join_char.join([str(e) for e in string_or_iterable]) + else: + return str(string_or_iterable) + + +def _smartjoin_filter(string_or_iterable, join_char: str) -> str: + """Join stuff smartly, with reversed arguments for Jinja2 filters.""" + # http://jinja.pocoo.org/docs/2.10/api/#custom-filters + return smartjoin(join_char, string_or_iterable) # Stolen from textwrap in Python 3.4.3. @@ -1690,3 +1916,163 @@ def indent(text, prefix, predicate=None): for line in text.splitlines(True): yield (prefix + line if predicate(line) else line) return ''.join(prefixed_lines()) + + +def load_data(path): + """Given path to a file, load data from it.""" + ext = os.path.splitext(path)[-1] + loader = None + function = 'load' + if ext in {'.yml', '.yaml'}: + if YAML is None: + req_missing(['ruamel.yaml'], 'use YAML data files') + return {} + loader = YAML(typ='safe') + function = 'load' + elif ext in {'.json', '.js'}: + loader = json + elif ext in {'.toml', '.tml'}: + if toml is None: + req_missing(['toml'], 'use TOML data files') + return {} + loader = toml + if loader is None: + return + with io.open(path, 'r', encoding='utf-8-sig') as inf: + return getattr(loader, function)(inf) + + +def rss_writer(rss_obj, output_path): + """Write an RSS object to an xml file.""" + dst_dir = os.path.dirname(output_path) + makedirs(dst_dir) + with io.open(output_path, "w+", encoding="utf-8") as rss_file: + data = rss_obj.to_xml(encoding='utf-8') + if isinstance(data, bytes): + data = data.decode('utf-8') + rss_file.write(data) + + +def map_metadata(meta, key, config): + """Map metadata from other platforms to Nikola names. + + This uses the METADATA_MAPPING and METADATA_VALUE_MAPPING settings (via ``config``) and modifies the dict in place. + """ + for foreign, ours in config.get('METADATA_MAPPING', {}).get(key, {}).items(): + if foreign in meta: + meta[ours] = meta[foreign] + + for meta_key, hook in config.get('METADATA_VALUE_MAPPING', {}).get(key, {}).items(): + if meta_key in meta: + meta[meta_key] = hook(meta[meta_key]) + + +class ClassificationTranslationManager(object): + """Keeps track of which classifications could be translated as which others. + + The internal structure is as follows: + - per language, you have a map of classifications to maps + - the inner map is a map from other languages to sets of classifications + which are considered as translations + """ + + def __init__(self): + self._data = defaultdict(dict) + + def add_translation(self, translation_map): + """Add translation of one classification. + + ``translation_map`` must be a dictionary mapping languages to their + translations of the added classification. + """ + for lang, classification in translation_map.items(): + clmap = self._data[lang] + cldata = clmap.get(classification) + if cldata is None: + cldata = defaultdict(set) + clmap[classification] = cldata + for other_lang, other_classification in translation_map.items(): + if other_lang != lang: + cldata[other_lang].add(other_classification) + + def get_translations(self, classification, lang): + """Get a dict mapping other languages to (unsorted) lists of translated classifications.""" + clmap = self._data[lang] + cldata = clmap.get(classification) + if cldata is None: + return {} + else: + return {other_lang: list(classifications) for other_lang, classifications in cldata.items()} + + def get_translations_as_list(self, classification, lang, classifications_per_language): + """Get a list of pairs ``(other_lang, other_classification)`` which are translations of ``classification``. + + Avoid classifications not in ``classifications_per_language``. + """ + clmap = self._data[lang] + cldata = clmap.get(classification) + if cldata is None: + return [] + else: + result = [] + for other_lang, classifications in cldata.items(): + for other_classification in classifications: + if other_classification in classifications_per_language[other_lang]: + result.append((other_lang, other_classification)) + return result + + def has_translations(self, classification, lang): + """Return whether we know about the classification in that language. + + Note that this function returning ``True`` does not mean that + ``get_translations`` returns a non-empty dict or that + ``get_translations_as_list`` returns a non-empty list, but only + that this classification was explicitly added with + ``add_translation`` at some point. + """ + return self._data[lang].get(classification) is not None + + def add_defaults(self, posts_per_classification_per_language): + """Treat every classification as its own literal translation into every other language. + + ``posts_per_classification_per_language`` should be the first argument + to ``Taxonomy.postprocess_posts_per_classification``. + """ + # First collect all classifications from all languages + all_classifications = set() + for _, classifications in posts_per_classification_per_language.items(): + all_classifications.update(classifications.keys()) + # Next, add translation records for all of them + for classification in all_classifications: + record = {tlang: classification for tlang in posts_per_classification_per_language} + self.add_translation(record) + + def read_from_config(self, site, basename, posts_per_classification_per_language, add_defaults_default): + """Read translations from config. + + ``site`` should be the Nikola site object. Will consider + the variables ``_TRANSLATIONS`` and + ``_TRANSLATIONS_ADD_DEFAULTS``. + + ``posts_per_classification_per_language`` should be the first argument + to ``Taxonomy.postprocess_posts_per_classification``, i.e. this function + should be called from that function. ``add_defaults_default`` specifies + what the default value for ``_TRANSLATIONS_ADD_DEFAULTS`` is. + + Also sends signal via blinker to allow interested plugins to add + translations by themselves. The signal name used is + ``_translations_config``, and the argument is a dict + with entries ``translation_manager``, ``site`` and + ``posts_per_classification_per_language``. + """ + # Add translations + for record in site.config.get('{}_TRANSLATIONS'.format(basename), []): + self.add_translation(record) + # Add default translations + if site.config.get('{}_TRANSLATIONS_ADD_DEFAULTS'.format(basename), add_defaults_default): + self.add_defaults(posts_per_classification_per_language) + # Use blinker to inform interested parties (plugins) that they can add + # translations themselves + args = {'translation_manager': self, 'site': site, + 'posts_per_classification_per_language': posts_per_classification_per_language} + signal('{}_translations_config'.format(basename.lower())).send(args) diff --git a/nikola/winutils.py b/nikola/winutils.py index 3ea179b..a6506e6 100644 --- a/nikola/winutils.py +++ b/nikola/winutils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2012-2015 Roberto Alsina and others. +# Copyright © 2012-2020 Roberto Alsina and others. # Permission is hereby granted, free of charge, to any # person obtaining a copy of this software and associated @@ -26,7 +26,6 @@ """windows utilities to workaround problems with symlinks in a git clone.""" -from __future__ import print_function, unicode_literals import os import shutil import io @@ -67,11 +66,11 @@ def fix_all_git_symlinked(topdir): """ # Determine whether or not symlinks need fixing (they don’t if installing # from a .tar.gz file) - with io.open(topdir + r'\nikola\data\symlink-test-link.txt', 'r', encoding='utf-8') as f: + with io.open(topdir + r'\nikola\data\symlink-test-link.txt', 'r', encoding='utf-8-sig') as f: text = f.read() if text.startswith("NIKOLA_SYMLINKS=OK"): return -1 - with io.open(topdir + r'\nikola\data\symlinked.txt', 'r', encoding='utf-8') as f: + with io.open(topdir + r'\nikola\data\symlinked.txt', 'r', encoding='utf-8-sig') as f: text = f.read() # expect each line a relpath from git or zip root, # smoke test relpaths are relative to git root @@ -93,7 +92,7 @@ def fix_all_git_symlinked(topdir): continue # build src path and do some basic validation - with io.open(os.path.join(topdir, dst), 'r', encoding='utf-8') as f: + with io.open(os.path.join(topdir, dst), 'r', encoding='utf-8-sig') as f: text = f.read() dst_dir = os.path.dirname(dst) try: -- cgit v1.2.3