From 5f39dd3557fc577267372c3c847aa7a8fdb5dbc5 Mon Sep 17 00:00:00 2001 From: Nikita Menkovich Date: Sun, 12 Jul 2026 01:23:18 +0200 Subject: [PATCH 1/4] Modernize Pelican build and Markdown extensions --- Dockerfile | 17 +--- deploy.sh | 11 --- fabfile.py | 143 ----------------------------- localconf.py | 5 +- md_extensions/article_links.py | 7 +- md_extensions/escape_ext.py | 55 +++++++++-- md_extensions/etree_utils.py | 4 +- md_extensions/figures.py | 6 +- md_extensions/footnote_ext.py | 9 +- md_extensions/mathjax.py | 12 +-- md_extensions/md_utils.py | 6 +- md_extensions/question.py | 10 +- md_extensions/sub_super_script.py | 8 +- md_extensions/text_align.py | 4 +- plugins/assets/assets.py | 8 +- plugins/feed_compat/__init__.py | 1 + plugins/feed_compat/feed_compat.py | 32 +++++++ plugins/minify/__init__.py | 1 + plugins/minify/minify.py | 28 ++++++ plugins/neighbors/neighbors.py | 4 +- requirements.txt | 22 ++--- 21 files changed, 161 insertions(+), 232 deletions(-) delete mode 100644 deploy.sh delete mode 100644 fabfile.py create mode 100644 plugins/feed_compat/__init__.py create mode 100644 plugins/feed_compat/feed_compat.py create mode 100644 plugins/minify/__init__.py create mode 100644 plugins/minify/minify.py diff --git a/Dockerfile b/Dockerfile index 374f0ff6..c7a39587 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,13 @@ -FROM python:3.5-slim AS builder +FROM python:3.13-slim AS builder WORKDIR /site ARG BUILD_MODE=prod ENV BUILD_MODE=${BUILD_MODE} -RUN printf '%s\n' \ - 'deb https://archive.debian.org/debian buster main' \ - 'deb https://archive.debian.org/debian-security buster/updates main' \ - > /etc/apt/sources.list \ - && printf 'Acquire::Check-Valid-Until "false";\n' > /etc/apt/apt.conf.d/99archive \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential \ - libxml2-dev \ - libxslt1-dev \ - zlib1g-dev \ - openssh-client \ - && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . -RUN fab build:${BUILD_MODE} +RUN pelican -s pelicanconf-${BUILD_MODE}.py FROM alpine:latest AS final diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index cf2a37cf..00000000 --- a/deploy.sh +++ /dev/null @@ -1,11 +0,0 @@ -set -x - -[ "$TRAVIS_BRANCH" = "master" ] || exit 1 -[ "$TRAVIS_PULL_REQUEST" = "false" ] || exit 1 -[ "$TRAVIS_PYTHON_VERSION" = "3.5" ] || exit 1 - -eval "$(ssh-agent -s)" - -chmod 600 $TRAVIS_BUILD_DIR/deploy_key -ssh-add $TRAVIS_BUILD_DIR/deploy_key -fab publish:ci diff --git a/fabfile.py b/fabfile.py deleted file mode 100644 index 647d9c35..00000000 --- a/fabfile.py +++ /dev/null @@ -1,143 +0,0 @@ -# -*- coding: utf-8 -*- - -from fabric.api import env, local, puts -import fabric.contrib.project as project -import os - -import sys -PY3 = sys.version_info > (3,) - -# Local path configuration (can be absolute or relative to fabfile) -env.deploy_path = 'output' -DEPLOY_PATH = env.deploy_path - -# Remote server configuration -env.user = 'www-data' -env.hosts = ['chtoes.li'] -path = { - 'dev': '/var/www/dev.chtoes.li/public/', - 'prod': '/var/www/chtoes.li/public/', - 'ci': '/var/www/ci.chtoes.li/public/' -} - -TEMPLATE = 'Title:\n\ -Date: {{date}}\n\ -Slug: {{slug}}\n\ -Category: {{ category }}\n\ -Source: http://what-if.xkcd.com/{{num}}/\n\ -SourceNum: {{num}}\n\ -SourceTitle: {{title}}\n\ -Formulas: False\n\ -Description: \n\ -Image: https://chtoes.li/uploads/{{num}}-{{slug}}/front.png\n\ -\n\ -\n' - - -def new(num, title=None, category='What If?', overwrite='no'): - import datetime - - if title is None: - if PY3: - from urllib.request import urlopen - else: - from urllib2 import urlopen - from bs4 import BeautifulSoup - html = urlopen('http://what-if.xkcd.com/{}/'.format(num)) - title = BeautifulSoup(html, 'lxml').title.string - - slug = slugify(title) - - CATEGORIES = { - 'What If?': 'what-if', - 'Новости проекта': 'news', - 'Прочее': 'other', - } - - category_slug = CATEGORIES[category] - now = datetime.datetime.now() - post_date = now.strftime('%Y-%m-%d') - - params = dict( - date=post_date, - title=title, - slug=slug, - num=num, - category=category - ) - - out_file = 'content/{}/{:03d}-{}.md'.format(category_slug, int(num), slug) - local("mkdir -p '{}' || true".format(os.path.dirname(out_file))) - if not os.path.exists(out_file) or overwrite.lower() == 'yes': - render(TEMPLATE, out_file, **params) - else: - print("{} already exists. Pass 'overwrite=yes' to destroy it." - .format(out_file)) - - -def slugify(text): - import re - normalized = ''.join([c.lower() if c.isalnum() else '-' - for c in text]) - no_repetitions = re.sub(r'--+', '-', normalized) - clean_start = re.sub(r'^-+', '', no_repetitions) - clean_end = re.sub(r'-+$', '', clean_start) - return clean_end - - -def render(template, destination, **kwargs): - from jinja2 import Template - template = Template(TEMPLATE) - text = template.render(**kwargs) - with open(destination, 'w') as output: - puts('Rendering to {}'.format(destination)) - if PY3: - output.write(text) - else: - output.write(text.encode('utf-8')) - - -def clean(): - if os.path.isdir(DEPLOY_PATH): - local('rm -rf {deploy_path}'.format(**env)) - local('mkdir {deploy_path}'.format(**env)) - - -def build(environment): - local('pelican --debug -s pelicanconf-{}.py'.format(environment)) - - -def rebuild(environment): - clean() - build(environment) - - -def regenerate(environment): - local('pelican -r -s pelicanconf-{}.py'.format(environment)) - - -def serve(): - # Determine major version of python as the one executes this script now. - if PY3: - local('cd {deploy_path} && python3 -m http.server'.format(**env)) - else: - local('cd {deploy_path} && python2 -m SimpleHTTPServer'.format(**env)) - - -def reserve(environment): - build(environment) - serve() - - -def preview(environment): - local('pelican -s pelicanconf-{}.py'.format(environment)) - - -def publish(environment): - local('pelican -s pelicanconf-{}.py'.format(environment)) - project.rsync_project( - remote_dir=path[environment], - exclude='.DS_Store', - local_dir=DEPLOY_PATH.rstrip('/') + '/', - delete=True - ) diff --git a/localconf.py b/localconf.py index 6799444c..e4bc876a 100644 --- a/localconf.py +++ b/localconf.py @@ -13,7 +13,7 @@ PLUGIN_PATHS = ['plugins'] PLUGINS = ['neighbors', 'sitemap', 'assets', 'minify', - 'block', 'gzip_cache', 'preserve_old_feed_items', + 'block', 'feed_compat', 'gzip_cache', 'preserve_old_feed_items', 'feed_alter_settings'] THEME = 'themes/whatif' PATH = 'content' @@ -142,4 +142,5 @@ def FEED_ALTER_SETTINGS(settings): elif isinstance(ext, md_extensions.FootnoteExtExtension): markdown_opts['extensions'].remove(ext) # it's necessary to create instance to get unique_prefix being persistent - markdown_opts['extensions'].append(FootnoteExtension(UNIQUE_IDS=True)) + markdown_opts['extensions'].append( + FootnoteExtension(UNIQUE_IDS=True, SEPARATOR='-')) diff --git a/md_extensions/article_links.py b/md_extensions/article_links.py index c67b8d04..1883dec9 100644 --- a/md_extensions/article_links.py +++ b/md_extensions/article_links.py @@ -5,7 +5,7 @@ # Lean on the 'meta' extension. class ArticleLinksTreeprocessor(Treeprocessor): def run(self, root): - meta = self.markdown.Meta + meta = self.md.Meta if 'category' not in meta or meta['category'] != ['What If?']: return None links = root.findall('.//a[@href]') @@ -15,5 +15,6 @@ def run(self, root): class ArticleLinksExtension(Extension): - def extendMarkdown(self, md, md_globals): - md.treeprocessors['article_links'] = ArticleLinksTreeprocessor(md) + def extendMarkdown(self, md): + md.treeprocessors.register( + ArticleLinksTreeprocessor(md), 'article_links', 0) diff --git a/md_extensions/escape_ext.py b/md_extensions/escape_ext.py index d5da727a..5b66bbab 100644 --- a/md_extensions/escape_ext.py +++ b/md_extensions/escape_ext.py @@ -1,4 +1,8 @@ +import re + from markdown.extensions import Extension +from markdown.preprocessors import Preprocessor +from markdown.treeprocessors import Treeprocessor from .md_utils import TrivialPreprocessor, TrivialTextPattern @@ -8,7 +12,7 @@ class AbbrTitlePreprocessor(TrivialPreprocessor): def run(self, lines): - for k, v in self.markdown.inlinePatterns.items(): + for k, v in self.md.inlinePatterns._data.items(): if not k.startswith('abbr-'): continue v.title = self.compiled_re.sub(self.handleMatch, v.title) @@ -18,23 +22,53 @@ def run(self, lines): class ReferenceTitlePreprocessor(TrivialPreprocessor): def run(self, lines): new_ref = {} - for k, v in self.markdown.references.items(): + for k, v in self.md.references.items(): url, title = v if title: new_title = self.compiled_re.sub(self.handleMatch, title) new_ref[k] = (url, new_title) else: new_ref[k] = v - self.markdown.references = new_ref + self.md.references = new_ref return lines +class QuotedTitlePreprocessor(Preprocessor): + pattern = re.compile(r'''(\]\([^\n]*?")'([^\n]*?)'("\))''') + + def run(self, lines): + result = [] + for line in lines: + line = self.pattern.sub(r'\1'\2'\3', line) + line = line.replace('] (/', '](/') + line = line.replace(' " :', ' " :') + if line.startswith('[^') and line.endswith(' '): + line += ' ' + if result and result[-1].endswith(']') and line.startswith('(/'): + result[-1] += line + continue + result.append(line) + return result + + +class ReferenceTitleTreeprocessor(Treeprocessor): + def run(self, root): + for elem in root.iter(): + for name, value in elem.attrib.items(): + value = re.sub(r'(?<=\d)\\ (?=\d)', '\u2009', value) + value = value.replace(r'\ ', '\u00a0') + value = value.replace(r"\'", '\u02bc') + elem.set(name, value) + + class EscapeExtExtension(Extension): - def extendMarkdown(self, md, md_globals): + def extendMarkdown(self, md): + md.preprocessors.register( + QuotedTitlePreprocessor(md), 'quoted_titles', 28) processors = [ - (md.preprocessors, AbbrTitlePreprocessor, '>abbr'), - (md.preprocessors, ReferenceTitlePreprocessor, '>reference'), - (md.inlinePatterns, TrivialTextPattern, 'smth' used patterns_local = patterns - if order.startswith('>'): + if cls is not TrivialTextPattern: patterns_local = list(reversed(patterns)) for prefix, re, repl in patterns_local: name = prefix + cls_name inst = cls(md, re, repl) - parsers.add(name, inst, order) + parsers.register(inst, name, order) + + md.treeprocessors.register( + ReferenceTitleTreeprocessor(md), 'reference_titles', 1) diff --git a/md_extensions/etree_utils.py b/md_extensions/etree_utils.py index eedc6a8b..27aa10e0 100644 --- a/md_extensions/etree_utils.py +++ b/md_extensions/etree_utils.py @@ -1,4 +1,4 @@ -from markdown.util import etree +from xml.etree import ElementTree as etree from .six_mini import string_types @@ -58,7 +58,7 @@ def remove_suffix(elem, suffix): else: text = elem[-1].tail - if text.endswith(suffix): + if text and text.endswith(suffix): text = text[:-len(suffix)] if len(elem) == 0: elem.text = text diff --git a/md_extensions/figures.py b/md_extensions/figures.py index 5a5bb40e..05849053 100644 --- a/md_extensions/figures.py +++ b/md_extensions/figures.py @@ -1,6 +1,6 @@ from markdown.extensions import Extension from markdown.treeprocessors import Treeprocessor -from markdown.util import etree +from xml.etree import ElementTree as etree from .etree_utils import replace_element @@ -54,5 +54,5 @@ def run(self, root): class FiguresExtension(Extension): - def extendMarkdown(self, md, md_globals): - md.treeprocessors['figures'] = FiguresTreeprocessor() + def extendMarkdown(self, md): + md.treeprocessors.register(FiguresTreeprocessor(md), 'figures', 0) diff --git a/md_extensions/footnote_ext.py b/md_extensions/footnote_ext.py index a2ddff71..9bcec187 100644 --- a/md_extensions/footnote_ext.py +++ b/md_extensions/footnote_ext.py @@ -5,7 +5,7 @@ import re from markdown.extensions import Extension from markdown.treeprocessors import Treeprocessor -from markdown.util import etree +from xml.etree import ElementTree as etree from markdown.extensions.footnotes import NBSP_PLACEHOLDER from .etree_utils import replace_element_inplace, create_etree, \ remove_suffix, add_suffix @@ -111,7 +111,7 @@ def get_sup_parent(root, id_): if backref is not None: li[-1].remove(backref) # modify footnote - punctum = None + punctum = '' siblings = list(get_sup_parent(root, sup.get('id'))) has_text_after = siblings.index(sup) < len(siblings) - 1 if sup.tail is not None: @@ -130,5 +130,6 @@ def get_sup_parent(root, id_): class FootnoteExtExtension(Extension): - def extendMarkdown(self, md, md_globals): - md.treeprocessors['footnote_ext'] = FootnoteExtTreeprocessor() + def extendMarkdown(self, md): + md.treeprocessors.register( + FootnoteExtTreeprocessor(md), 'footnote_ext', 0) diff --git a/md_extensions/mathjax.py b/md_extensions/mathjax.py index 35b3d541..aa7c8b6a 100644 --- a/md_extensions/mathjax.py +++ b/md_extensions/mathjax.py @@ -12,23 +12,23 @@ def available(self, meta): return # ESCAPED_CHARS is class variable, so we copy the list and assing it to # the instance to prevent sharing between Markdown instances - chars = list(self.markdown.ESCAPED_CHARS) + chars = list(self.md.ESCAPED_CHARS) chars.append('$') - self.markdown.ESCAPED_CHARS = chars + self.md.ESCAPED_CHARS = chars class MathJaxExtension(Extension): - def extendMarkdown(self, md, md_globals): + def extendMarkdown(self, md): def repl(match): return ''.join(match.group('edge', 'body', 'edge')) # '\$' -> '$' when formulas disabled; # that needed for consistency: when formulas enabled, MathJax with # 'processEscapes: true' will do that - md.preprocessors.add( - 'maybe_escape_dollar', MaybeEscapeDollar(md), '>meta') + md.preprocessors.register( + MaybeEscapeDollar(md), 'maybe_escape_dollar', 26) # exclude formulas from processing as a markdown MATHJAX_RE = r'(?\$\$?)(?P.+?)(?P=edge)' mathJaxPattern = TrivialTextPattern(md, MATHJAX_RE, repl) - md.inlinePatterns.add('mathjax', mathJaxPattern, ']*/>', '', rss) + rss = rss.replace('', '') + rss_path.write_text(rss, encoding='utf-8') + + atom_path = output / pelican.settings['FEED_ALL_ATOM'] + atom = atom_path.read_text(encoding='utf-8') + atom = re.sub(r']*)/>', r'', atom) + atom = atom.replace( + '', + '', + 1, + ) + atom = re.sub(r'', '', atom) + atom_path.write_text(atom, encoding='utf-8') + + +def register(): + signals.finalized.connect(preserve_legacy_feeds) diff --git a/plugins/minify/__init__.py b/plugins/minify/__init__.py new file mode 100644 index 00000000..73ff73b8 --- /dev/null +++ b/plugins/minify/__init__.py @@ -0,0 +1 @@ +from .minify import register diff --git a/plugins/minify/minify.py b/plugins/minify/minify.py new file mode 100644 index 00000000..9caf3709 --- /dev/null +++ b/plugins/minify/minify.py @@ -0,0 +1,28 @@ +"""Preserve the output of the original pelican-minify 0.9 plugin.""" + +from pathlib import Path +import re + +from htmlmin import minify +from pelican import signals + + +def minify_html(pelican): + options = pelican.settings.get('MINIFY', {}) + output_path = Path(pelican.settings['OUTPUT_PATH']) + for path in output_path.rglob('*'): + if path.suffix not in ('.html', '.htm'): + continue + source = path.read_text(encoding='utf-8') + source = source.replace(''', "'").replace(' ', ' ') + source = re.sub( + r'href="([^"]*)"', + lambda match: 'href="{}"'.format( + re.sub(r'&(?!#?[A-Za-z0-9]+;)', '&', match.group(1))), + source, + ) + path.write_text(minify(source, **options), encoding='utf-8') + + +def register(): + signals.finalized.connect(minify_html) diff --git a/plugins/neighbors/neighbors.py b/plugins/neighbors/neighbors.py index b65fcfea..0195cc06 100644 --- a/plugins/neighbors/neighbors.py +++ b/plugins/neighbors/neighbors.py @@ -11,7 +11,9 @@ def iter3(seq): it = iter(seq) nxt = None - cur = next(it) + cur = next(it, None) + if cur is None: + return for prv in it: yield nxt, cur, prv nxt, cur = cur, prv diff --git a/requirements.txt b/requirements.txt index e277cea0..05bd62d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,7 @@ -Fabric3==1.12.post1 -pelican==3.7.1 -Markdown==2.6.7 -beautifulsoup4==4.4.1 -pycrypto==2.6.1 -webassets==0.12.1 - -# The following packages aren't necessary for `fab build:debug` target. +pelican==4.12.0 +Markdown==3.10.2 +webassets==3.0.0 cssmin==0.2.0 -jsmin==2.2.1 -pelican-minify==0.9 - -# The following packages are necessary only for CI. -flake8==3.0.4 - -# fab new:xxx -lxml +jsmin==3.0.1 +htmlmin2==0.1.13 +pytz==2026.2 From f2169223ff8c952a8a4e1d1c7eecc406cae51a88 Mon Sep 17 00:00:00 2001 From: Nikita Menkovich Date: Sun, 12 Jul 2026 02:36:53 +0200 Subject: [PATCH 2/4] Preserve legacy output with modern Pelican --- localconf.py | 22 +++--- md_extensions/footnote_ext.py | 74 ++++++++++++++++++- plugins/feed_compat/__init__.py | 1 - plugins/feed_compat/feed_compat.py | 32 -------- plugins/minify/__init__.py | 1 - plugins/minify/minify.py | 28 ------- plugins/preserve_old_feed_items/__init__.py | 1 - .../preserve_old_feed_items.py | 31 -------- requirements.txt | 2 +- 9 files changed, 83 insertions(+), 109 deletions(-) delete mode 100644 plugins/feed_compat/__init__.py delete mode 100644 plugins/feed_compat/feed_compat.py delete mode 100644 plugins/minify/__init__.py delete mode 100644 plugins/minify/minify.py delete mode 100644 plugins/preserve_old_feed_items/__init__.py delete mode 100644 plugins/preserve_old_feed_items/preserve_old_feed_items.py diff --git a/localconf.py b/localconf.py index e4bc876a..55e7b0b4 100644 --- a/localconf.py +++ b/localconf.py @@ -13,19 +13,13 @@ PLUGIN_PATHS = ['plugins'] PLUGINS = ['neighbors', 'sitemap', 'assets', 'minify', - 'block', 'feed_compat', 'gzip_cache', 'preserve_old_feed_items', + 'block', 'gzip_cache', 'feed_alter_settings'] THEME = 'themes/whatif' PATH = 'content' OUTPUT_PATH = 'output' DELETE_OUTPUT_DIRECTORY = True -MINIFY = { - 'remove_comments': True, - 'remove_empty_space': True, - 'remove_optional_attribute_quotes': True, -} - # RSS feeds FEED_ALL_RSS = 'feed/index.xml' CATEGORY_FEED_RSS = None @@ -64,11 +58,10 @@ 'rewrite.html': 'rewrite.map' } -SLUG_SUBSTITUTIONS = [ - ('what if?', 'what-if'), - ('novosti proekta', 'news'), - ('prochee', 'other'), - # ('',''), +SLUG_REGEX_SUBSTITUTIONS = [ + (r'^what if\?$', 'what-if'), + (r'^novosti proekta$', 'news'), + (r'^prochee$', 'other'), ] DEFAULT_PAGINATION = False @@ -111,7 +104,10 @@ MARKDOWN = { 'extensions': [ 'markdown.extensions.meta', - 'markdown.extensions.extra', + 'markdown.extensions.fenced_code', + 'markdown.extensions.attr_list', + 'markdown.extensions.def_list', + 'markdown.extensions.tables', 'markdown.extensions.abbr', 'markdown.extensions.footnotes', md_extensions.QuestionExtension(), diff --git a/md_extensions/footnote_ext.py b/md_extensions/footnote_ext.py index 9bcec187..88a4ff8b 100644 --- a/md_extensions/footnote_ext.py +++ b/md_extensions/footnote_ext.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import re from markdown.extensions import Extension +from markdown.postprocessors import Postprocessor from markdown.treeprocessors import Treeprocessor from xml.etree import ElementTree as etree from markdown.extensions.footnotes import NBSP_PLACEHOLDER @@ -111,7 +112,7 @@ def get_sup_parent(root, id_): if backref is not None: li[-1].remove(backref) # modify footnote - punctum = '' + punctum = None siblings = list(get_sup_parent(root, sup.get('id'))) has_text_after = siblings.index(sup) < len(siblings) - 1 if sup.tail is not None: @@ -129,7 +130,78 @@ def get_sup_parent(root, id_): root.remove(footnotes_div) +class ImageMapFootnotePostprocessor(Postprocessor): + """Restore inline footnotes attached to raw-HTML image maps. + + ``FootnoteExtTreeprocessor`` normally decides whether to emit the closing + ``ellipsis`` marker by looking for elements after the original ``sup`` in + its parent. That works for Markdown elements, but not for a raw-HTML + ``map``. Python-Markdown removes raw HTML into ``htmlStash`` before tree + processors run and restores it later in its ``raw_html`` postprocessor. + Consequently, while ``FootnoteExtTreeprocessor`` is running, it sees the + footnote as the final child of a paragraph: the following map is not an + ElementTree sibling, ``has_text_after`` is false, and both the closing + arrow and the empty punctuation anchor are omitted. When raw HTML is + restored, Markdown 3 also places the map after ``

``. Markdown 2 kept + that map inside the paragraph, which is the DOM expected by the site's + inline-footnote CSS and JavaScript. + + This cannot be corrected reliably in ``FootnoteExtTreeprocessor`` without + depending on Python-Markdown's private stash-placeholder format and moving + opaque placeholder text between ElementTree ``text``/``tail`` fields. + Such a workaround would be more fragile than waiting until raw HTML has + been restored. This postprocessor therefore runs after ``raw_html`` (its + priority is lower), matches an image and map through their standard + ``usemap``/``name`` relationship, and restores only the legacy relationship + needed by the footnote widget: + + * the map is moved back inside the image paragraph; + * the empty ``punctum`` span is restored; and + * the closing ``ellipsis`` arrow is restored. + + Ordinary footnotes and raw-HTML blocks without a matching image map are + returned unchanged. + """ + + def run(self, source): + search_from = 0 + while True: + map_start = source.find('', usemap, map_start) + map_end = source.find('', map_start) + if usemap < 0 or paragraph_end < 0 or map_end < 0: + search_from = name_end + continue + + segment = source[usemap:paragraph_end] + segment = segment.replace( + '', + '' + '', + 1, + ) + final_span = segment.rfind('') + segment = (segment[:final_span] + + '↳' + + segment[final_span:]) + source = ( + source[:usemap] + segment + + source[paragraph_end + len('

'): + map_end + len('')] + + '

' + source[map_end + len(''):] + ) + search_from = map_end + len('') + + class FootnoteExtExtension(Extension): def extendMarkdown(self, md): md.treeprocessors.register( FootnoteExtTreeprocessor(md), 'footnote_ext', 0) + md.postprocessors.register( + ImageMapFootnotePostprocessor(md), 'image_map_footnotes', 0) diff --git a/plugins/feed_compat/__init__.py b/plugins/feed_compat/__init__.py deleted file mode 100644 index 8bcbd5ae..00000000 --- a/plugins/feed_compat/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .feed_compat import register diff --git a/plugins/feed_compat/feed_compat.py b/plugins/feed_compat/feed_compat.py deleted file mode 100644 index 84835a34..00000000 --- a/plugins/feed_compat/feed_compat.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Keep feedgenerator 1.x XML serialization on modern Pelican.""" - -from pathlib import Path -import re - -from pelican import signals - - -def preserve_legacy_feeds(pelican): - output = Path(pelican.settings['OUTPUT_PATH']) - - rss_path = output / pelican.settings['FEED_ALL_RSS'] - rss = rss_path.read_text(encoding='utf-8') - rss = rss.replace(' xmlns:atom="http://www.w3.org/2005/Atom"', '') - rss = re.sub(r']*/>', '', rss) - rss = rss.replace('', '') - rss_path.write_text(rss, encoding='utf-8') - - atom_path = output / pelican.settings['FEED_ALL_ATOM'] - atom = atom_path.read_text(encoding='utf-8') - atom = re.sub(r']*)/>', r'', atom) - atom = atom.replace( - '', - '', - 1, - ) - atom = re.sub(r'', '', atom) - atom_path.write_text(atom, encoding='utf-8') - - -def register(): - signals.finalized.connect(preserve_legacy_feeds) diff --git a/plugins/minify/__init__.py b/plugins/minify/__init__.py deleted file mode 100644 index 73ff73b8..00000000 --- a/plugins/minify/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .minify import register diff --git a/plugins/minify/minify.py b/plugins/minify/minify.py deleted file mode 100644 index 9caf3709..00000000 --- a/plugins/minify/minify.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Preserve the output of the original pelican-minify 0.9 plugin.""" - -from pathlib import Path -import re - -from htmlmin import minify -from pelican import signals - - -def minify_html(pelican): - options = pelican.settings.get('MINIFY', {}) - output_path = Path(pelican.settings['OUTPUT_PATH']) - for path in output_path.rglob('*'): - if path.suffix not in ('.html', '.htm'): - continue - source = path.read_text(encoding='utf-8') - source = source.replace(''', "'").replace(' ', ' ') - source = re.sub( - r'href="([^"]*)"', - lambda match: 'href="{}"'.format( - re.sub(r'&(?!#?[A-Za-z0-9]+;)', '&', match.group(1))), - source, - ) - path.write_text(minify(source, **options), encoding='utf-8') - - -def register(): - signals.finalized.connect(minify_html) diff --git a/plugins/preserve_old_feed_items/__init__.py b/plugins/preserve_old_feed_items/__init__.py deleted file mode 100644 index 82b04f35..00000000 --- a/plugins/preserve_old_feed_items/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .preserve_old_feed_items import * diff --git a/plugins/preserve_old_feed_items/preserve_old_feed_items.py b/plugins/preserve_old_feed_items/preserve_old_feed_items.py deleted file mode 100644 index 3ec067a6..00000000 --- a/plugins/preserve_old_feed_items/preserve_old_feed_items.py +++ /dev/null @@ -1,31 +0,0 @@ -import feedgenerator -from pelican import signals -from pelican import writers - - -# This temporary Pelican plugin created with intention to save IDs of RSS/Atom -# items for articles published before we moved to Pelican 3.7, which has new -# IDs format. It can be safely removed when 5 pages/articles will generated -# using Pelican 3.7. - - -def get_tag_uri_new(url, date): - tags_new_format = { - 'tag:chtoes.li,2016-10-24:/speedup-and-feedback/', - 'tag:chtoes.li,2016-10-24:/flood-death-valley/', - 'tag:chtoes.li,2016-07-27:/sun-bug/', - 'tag:chtoes.li,2016-05-30:/tatooine-rainbow/', - 'tag:chtoes.li,2016-04-18:/pizza-bird/', - } - tag = feedgenerator.get_tag_uri(url, date) - if tag in tags_new_format: - tag = tag.replace('/', '', 1) - return tag - - -def patch_get_tag_uri(pelican_object): - writers.__dict__['get_tag_uri'] = get_tag_uri_new - - -def register(): - signals.initialized.connect(patch_get_tag_uri) diff --git a/requirements.txt b/requirements.txt index 05bd62d4..6918b30c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,5 @@ Markdown==3.10.2 webassets==3.0.0 cssmin==0.2.0 jsmin==3.0.1 -htmlmin2==0.1.13 +pelican-minify==2.0.2 pytz==2026.2 From 1f167e4c980b5591de5c80b7814c41cffbc178d8 Mon Sep 17 00:00:00 2001 From: Nikita Menkovich Date: Sun, 12 Jul 2026 15:00:33 +0200 Subject: [PATCH 3/4] add diff workflow and some fixes --- .github/scripts/render-site-diff.sh | 74 ++++++++++++++++++++++++++++ .github/workflows/content-diff.yaml | 62 +++++++++++++++++++++++ .github/workflows/renderer-diff.yaml | 63 +++++++++++++++++++++++ md_extensions/escape_ext.py | 32 ++++++++++-- 4 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 .github/scripts/render-site-diff.sh create mode 100644 .github/workflows/content-diff.yaml create mode 100644 .github/workflows/renderer-diff.yaml diff --git a/.github/scripts/render-site-diff.sh b/.github/scripts/render-site-diff.sh new file mode 100644 index 00000000..11aa3887 --- /dev/null +++ b/.github/scripts/render-site-diff.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: $0 OLD_OUTPUT NEW_OUTPUT REPORT_DIR" >&2 + exit 2 +fi + +old_output=$(realpath "$1") +new_output=$(realpath "$2") +report=$(realpath -m "$3") +staging=$(mktemp -d) +trap 'rm -rf "$staging"' EXIT + +mkdir -p "$report" "$staging/old" "$staging/new" + +stage_output() { + local source=$1 destination=$2 + + # HTML is the review target. Feeds, gzip output and build_info.txt are + # intentionally absent, as their generated representation is not stable. + while IFS= read -r -d '' path; do + relative=${path#"$source"/} + mkdir -p "$destination/$(dirname "$relative")" + cp "$path" "$destination/$relative" + done < <(find "$source" -type f -name '*.html' -print0) + + # Binary files do not belong in a textual diff, but their checksums catch + # missing or changed uploads (the failure mode that prompted this check). + if [[ -d "$source/uploads" ]]; then + ( + cd "$source/uploads" + find . -type f -print0 | sort -z | xargs -0 sha256sum + ) > "$destination/uploads.sha256" + fi +} + +stage_output "$old_output" "$staging/old" +stage_output "$new_output" "$staging/new" + +set +e +diff -ruN -U 5 "$staging/old" "$staging/new" > "$report/site.diff" +diff_status=$? +set -e +if [[ $diff_status -gt 1 ]]; then + echo "diff failed with status $diff_status" >&2 + exit "$diff_status" +fi + +old_html=$(find "$staging/old" -type f -name '*.html' | wc -l) +new_html=$(find "$staging/new" -type f -name '*.html' | wc -l) +changed=$(diff -qr "$staging/old" "$staging/new" 2>/dev/null | wc -l || true) +cat > "$report/summary.md" < "$report/site-diff.html" <<'EOF' +No renderer differences +

No renderer differences

+

The staged HTML and upload checksum manifests are identical.

+EOF +fi diff --git a/.github/workflows/content-diff.yaml b/.github/workflows/content-diff.yaml new file mode 100644 index 00000000..dcdaa54c --- /dev/null +++ b/.github/workflows/content-diff.yaml @@ -0,0 +1,62 @@ +name: Compare rendering after content changes + +on: + pull_request: + paths: + - 'content/**' + workflow_dispatch: + inputs: + base_ref: + description: Git ref to compare against + required: true + default: master + +permissions: + contents: read + +jobs: + content-diff: + runs-on: ubuntu-latest + steps: + - name: Check out base revision + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha || inputs.base_ref }} + path: base + + - name: Check out branch revision + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: branch + + - name: Build unminified base output + run: docker build --target builder --build-arg BUILD_MODE=debug -t website-base base + + - name: Build unminified branch output + run: docker build --target builder --build-arg BUILD_MODE=debug -t website-branch branch + + - name: Export generated sites + run: | + mkdir -p output/base output/branch + docker create --name website-base-export website-base + docker create --name website-branch-export website-branch + docker cp website-base-export:/site/output/. output/base/ + docker cp website-branch-export:/site/output/. output/branch/ + + - name: Install HTML diff renderer + run: npm install --global diff2html-cli@5.1.2 + + - name: Generate browsable comparison + run: bash branch/.github/scripts/render-site-diff.sh output/base output/branch report + + - name: Add comparison summary + run: cat report/summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload HTML and raw diff + uses: actions/upload-artifact@v4 + with: + name: base-branch-content-diff + path: report/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/renderer-diff.yaml b/.github/workflows/renderer-diff.yaml new file mode 100644 index 00000000..3d224724 --- /dev/null +++ b/.github/workflows/renderer-diff.yaml @@ -0,0 +1,63 @@ +name: Compare rendering after code changes + +on: + pull_request: + paths-ignore: + - 'content/**' + workflow_dispatch: + inputs: + base_ref: + description: Git ref to compare against + required: true + default: master + +permissions: + contents: read + +jobs: + renderer-diff: + runs-on: ubuntu-latest + steps: + - name: Check out base revision + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha || inputs.base_ref }} + path: base + + - name: Check out branch revision + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: branch + + - name: Build unminified base output + run: docker build --target builder --build-arg BUILD_MODE=debug -t website-base base + + - name: Build unminified branch output + run: docker build --target builder --build-arg BUILD_MODE=debug -t website-branch branch + + - name: Export generated sites + run: | + mkdir -p output/base output/branch + docker create --name website-base-export website-base + docker create --name website-branch-export website-branch + docker cp website-base-export:/site/output/. output/base/ + docker cp website-branch-export:/site/output/. output/branch/ + + - name: Install HTML diff renderer + run: npm install --global diff2html-cli@5.1.2 + + - name: Generate browsable comparison + run: bash branch/.github/scripts/render-site-diff.sh output/base output/branch report + + - name: Add comparison summary + run: cat report/summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload HTML and raw diff + uses: actions/upload-artifact@v4 + with: + name: base-branch-renderer-diff + path: report/ + if-no-files-found: error + retention-days: 30 + diff --git a/md_extensions/escape_ext.py b/md_extensions/escape_ext.py index 5b66bbab..cfe44d33 100644 --- a/md_extensions/escape_ext.py +++ b/md_extensions/escape_ext.py @@ -1,9 +1,10 @@ import re from markdown.extensions import Extension +from markdown.postprocessors import Postprocessor from markdown.preprocessors import Preprocessor from markdown.treeprocessors import Treeprocessor -from .md_utils import TrivialPreprocessor, TrivialTextPattern +from .md_utils import TrivialPreprocessor, TrivialTextPattern, html_entity # Markdown extension @@ -43,7 +44,9 @@ def run(self, lines): line = line.replace('] (/', '](/') line = line.replace(' " :', ' " :') if line.startswith('[^') and line.endswith(' '): - line += ' ' + # Protect the existing trailing space; do not add a second + # one. Markdown 3 otherwise trims it before parsing the note. + line = line[:-1] + ' ' if result and result[-1].endswith(']') and line.startswith('(/'): result[-1] += line continue @@ -55,12 +58,29 @@ class ReferenceTitleTreeprocessor(Treeprocessor): def run(self, root): for elem in root.iter(): for name, value in elem.attrib.items(): - value = re.sub(r'(?<=\d)\\ (?=\d)', '\u2009', value) - value = value.replace(r'\ ', '\u00a0') - value = value.replace(r"\'", '\u02bc') + # Keep the legacy entity spelling, not merely the equivalent + # Unicode character. Besides making old/new output stable, + #   makes the typographic intent visible in HTML diffs. + value = re.sub( + r'(?<=\d)\\ (?=\d)', html_entity(' '), value) + value = value.replace(r'\ ', html_entity(' ')) + value = value.replace(r"\'", html_entity('ʼ')) elem.set(name, value) +class LegacyEntityPostprocessor(Postprocessor): + """Undo compatibility entities needed only while parsing Markdown. + + Modern Markdown interprets apostrophe-delimited text inside an image title + differently from the old release. QuotedTitlePreprocessor protects those + apostrophes until parsing is complete; the old renderer emitted them as + literal characters, so restore that representation in the final fragment. + """ + + def run(self, text): + return text.replace(''', "'").replace(' ', ' ') + + class EscapeExtExtension(Extension): def extendMarkdown(self, md): md.preprocessors.register( @@ -93,3 +113,5 @@ def extendMarkdown(self, md): md.treeprocessors.register( ReferenceTitleTreeprocessor(md), 'reference_titles', 1) + md.postprocessors.register( + LegacyEntityPostprocessor(md), 'legacy_entities', 0) From 45ac113f487620935d9534033245b6791f64f936 Mon Sep 17 00:00:00 2001 From: Nikita Menkovich Date: Sun, 12 Jul 2026 15:06:36 +0200 Subject: [PATCH 4/4] fix upload-artifact version --- .github/workflows/content-diff.yaml | 2 +- .github/workflows/renderer-diff.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/content-diff.yaml b/.github/workflows/content-diff.yaml index dcdaa54c..638d40e6 100644 --- a/.github/workflows/content-diff.yaml +++ b/.github/workflows/content-diff.yaml @@ -54,7 +54,7 @@ jobs: run: cat report/summary.md >> "$GITHUB_STEP_SUMMARY" - name: Upload HTML and raw diff - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: base-branch-content-diff path: report/ diff --git a/.github/workflows/renderer-diff.yaml b/.github/workflows/renderer-diff.yaml index 3d224724..8db5d159 100644 --- a/.github/workflows/renderer-diff.yaml +++ b/.github/workflows/renderer-diff.yaml @@ -54,7 +54,7 @@ jobs: run: cat report/summary.md >> "$GITHUB_STEP_SUMMARY" - name: Upload HTML and raw diff - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: base-branch-renderer-diff path: report/