From: Vladimír Vondruš Date: Tue, 3 Sep 2019 21:38:47 +0000 (+0200) Subject: m.code: ability to apply filters before/after the code is highlighted. X-Git-Url: https://www.chiark.greenend.org.uk/ucgi/~cjwatson/git?a=commitdiff_plain;h=a2460403a9d364fc19224da78175a2320823eef3;p=blog.git m.code: ability to apply filters before/after the code is highlighted. --- diff --git a/doc/plugins/math-and-code.rst b/doc/plugins/math-and-code.rst index aa803507..dc5baf99 100644 --- a/doc/plugins/math-and-code.rst +++ b/doc/plugins/math-and-code.rst @@ -291,6 +291,8 @@ plugin assumes presence of `m.htmlsanity <{filename}/plugins/htmlsanity.rst>`_. .. code:: python PLUGINS += ['m-htmlsanity', 'm.code'] + M_CODE_FILTERS_PRE = [] + M_CODE_FILTERS_POST = [] For the Python doc theme, it's enough to mention it in :py:`PLUGINS`. The `m.htmlsanity`_ plugin is available always, no need to mention it explicitly: @@ -445,3 +447,81 @@ immediately followed by background color specification (the See the `m.components <{filename}/plugins/components.rst#code-math-and-graph-figure>`__ plugin for details about code figures using the :rst:`.. code-figure::` directive. + +`Filters`_ +---------- + +It's possible to supply filters that get applied both before and after a +code snippet is rendered using the :py:`M_CODE_FILTERS_PRE` and +:py:`M_CODE_FILTERS_POST` options. It's a dict with keys being the lexer +name [1]_ and values being filter functions. Each function that gets string as +an input and is expected to return a modified string. In the following example, +all CSS code snippets have the hexadecimal color literals annotated with a +`color swatch <{filename}/css/components.rst#color-swatches-in-code-snippets>`_: + +.. code:: py + :class: m-console-wrap + + import re + + _css_colors_src = re.compile(r"""#(?P[0-9a-f]{6})""") + _css_colors_dst = r"""#\g""" + + M_CODE_FILTERS_POST = { + 'CSS': lambda code: _css_colors_src.sub(_css_colors_dst, code) + } + +.. code-figure:: + + .. code:: rst + + .. code:: css + + p.green { + color: #3bd267; + } + + .. code:: css + + p.green { + color: #3bd267; + } + +In the above case, the filter gets applied globally to all code snippets of +given language. Sometimes it might be desirable to apply a filter only to +specific code snippet --- in that case, the dict key is a tuple of +:py:`(lexer, filter)` where the second item is a filter name. This filter name +is then referenced from the :rst:`:filters:` option of the :rst:`.. code::` and +:rst:`.. include::` directives as well as the inline :rst:`:code:` text role. +Multiple filters can be specified when separated by spaces. + +.. code:: py + + M_CODE_FILTERS_PRE = { + ('C++', 'codename'): lambda code: code.replace('DirtyMess', 'P300'), + ('C++', 'fix_typography'): lambda code: code.replace(' :', ':'), + } + +.. code-figure:: + + .. code:: rst + + .. code:: cpp + :filters: codename fix_typography + + for(auto& a : DirtyMess::managedEntities()) { + // ... + } + + .. code:: cpp + :filters: codename fix_typography + + for(auto& a : DirtyMess::managedEntities()) { + // ... + } + +.. [1] In order to have an unique mapping, the filters can't use the aliases + --- for example C++ code can be highlighted using either ``c++`` or ``cpp`` + as a language name and the dict would need to have an entry for each. An unique lexer name is the :py:`name` field used in the particular lexer + source, you can also see the names in the language dropdown on the + `official website `_. diff --git a/plugins/m/code.py b/plugins/m/code.py index 50ff5a2c..9142a9f2 100644 --- a/plugins/m/code.py +++ b/plugins/m/code.py @@ -42,7 +42,10 @@ logger = logging.getLogger(__name__) import ansilexer -def _highlight(code, language, options, is_block): +filters_pre = None +filters_post = None + +def _highlight(code, language, options, *, is_block, filters=[]): # Use our own lexer for ANSI if language == 'ansi': lexer = ansilexer.AnsiLexer() @@ -63,9 +66,28 @@ def _highlight(code, language, options, is_block): formatter = ansilexer.HtmlAnsiFormatter(**options) else: formatter = HtmlFormatter(nowrap=True, **options) + + global filters_pre + # First apply local pre filters, if any + for filter in filters: + f = filters_pre.get((lexer.name, filter)) + if f: code = f(code) + # Then a global pre filter, if any + f = filters_pre.get(lexer.name) + if f: code = f(code) + parsed = highlight(code, lexer, formatter).rstrip() if not is_block: parsed.lstrip() + global filters_post + # First apply local post filters, if any + for filter in filters: + f = filters_post.get((lexer.name, filter)) + if f: parsed = f(parsed) + # Then a global post filter, if any + f = filters_post.get(lexer.name) + if f: parsed = f(parsed) + return class_, parsed class Code(Directive): @@ -74,7 +96,8 @@ class Code(Directive): final_argument_whitespace = True option_spec = { 'hl_lines': directives.unchanged, - 'class': directives.class_option + 'class': directives.class_option, + 'filters': directives.unchanged } has_content = True @@ -87,7 +110,9 @@ class Code(Directive): classes += self.options['classes'] del self.options['classes'] - class_, highlighted = _highlight('\n'.join(self.content), self.arguments[0], self.options, is_block=True) + filters = self.options.pop('filters', '').split() + + class_, highlighted = _highlight('\n'.join(self.content), self.arguments[0], self.options, is_block=True, filters=filters) classes += [class_] content = nodes.raw('', highlighted, format='html') @@ -96,6 +121,11 @@ class Code(Directive): return [pre] class Include(docutils.parsers.rst.directives.misc.Include): + option_spec = { + **docutils.parsers.rst.directives.misc.Include.option_spec, + 'filters': directives.unchanged + } + def run(self): """ Verbatim copy of docutils.parsers.rst.directives.misc.Include.run() @@ -199,7 +229,9 @@ def code(role, rawtext, text, lineno, inliner, options={}, content=[]): # Not sure why language is duplicated in classes? if language in classes: classes.remove(language) - class_, highlighted = _highlight(utils.unescape(text), language, options, is_block=False) + filters = options.pop('filters', '').split() + + class_, highlighted = _highlight(utils.unescape(text), language, options, is_block=False, filters=filters) classes += [class_] content = nodes.raw('', highlighted, format='html') @@ -208,14 +240,29 @@ def code(role, rawtext, text, lineno, inliner, options={}, content=[]): return [node], [] code.options = {'class': directives.class_option, - 'language': directives.unchanged} + 'language': directives.unchanged, + 'filters': directives.unchanged} -def register_mcss(**kwargs): +def register_mcss(mcss_settings, **kwargs): rst.directives.register_directive('code', Code) rst.directives.register_directive('include', Include) rst.roles.register_canonical_role('code', code) + global filters_pre, filters_post + filters_pre = mcss_settings.get('M_CODE_FILTERS_PRE', {}) + filters_post = mcss_settings.get('M_CODE_FILTERS_POST', {}) + # Below is only Pelican-specific functionality. If Pelican is not found, these # do nothing. -register = register_mcss # for Pelican +def _pelican_configure(pelicanobj): + settings = {} + for key in ['M_CODE_FILTERS_PRE', 'M_CODE_FILTERS_POST']: + if key in pelicanobj.settings: settings[key] = pelicanobj.settings[key] + + register_mcss(mcss_settings=settings) + +def register(): # for Pelican + import pelican.signals + + pelican.signals.initialized.connect(_pelican_configure) diff --git a/plugins/m/test/code/page.html b/plugins/m/test/code/page.html index e806d052..ba58943b 100644 --- a/plugins/m/test/code/page.html +++ b/plugins/m/test/code/page.html @@ -47,6 +47,23 @@ Leading zeros: ‌▒
        nope();
     return false;
 }
+
+

Filters

+

Applied by default, adding typographically correct spaces before and a color +swatch after --- and for inline as well: p { color: #ff3366; }

+
p {
+    color: #ff3366;
+}
+

Applied explicity and then by default --- and for inline as well: +p { color: #3bd267; }

+
p {
+    color: #3bd267;
+}
+

Includes too:

+
p {
+    color: #3bd267;
+}
+
diff --git a/plugins/m/test/code/page.rst b/plugins/m/test/code/page.rst index a4292a98..b548348f 100644 --- a/plugins/m/test/code/page.rst +++ b/plugins/m/test/code/page.rst @@ -47,3 +47,38 @@ Don't trim leading spaces in blocks: nope(); return false; } + +`Filters`_ +========== + +.. role:: css(code) + :language: css + +Applied by default, adding typographically correct spaces before and a color +swatch after --- and for inline as well: :css:`p{ color:#ff3366; }` + +.. code:: css + + p{ + color:#ff3366; + } + +.. role:: css-filtered(code) + :language: css + :filters: lowercase replace_colors + +Applied explicity and then by default --- and for inline as well: +:css-filtered:`P{ COLOR:#C0FFEE; }` + +.. code:: css + :filters: lowercase replace_colors + + P{ + COLOR:#C0FFEE; + } + +Includes too: + +.. include:: style.css + :code: css + :filters: lowercase replace_colors diff --git a/plugins/m/test/code/style.css b/plugins/m/test/code/style.css new file mode 100644 index 00000000..6d24c8ec --- /dev/null +++ b/plugins/m/test/code/style.css @@ -0,0 +1,3 @@ +P{ + COLOR:#C0FFEE; +} diff --git a/plugins/m/test/test_code.py b/plugins/m/test/test_code.py index b49998bb..2b6ed0c9 100644 --- a/plugins/m/test/test_code.py +++ b/plugins/m/test/test_code.py @@ -22,8 +22,16 @@ # DEALINGS IN THE SOFTWARE. # +import re + from . import PelicanPluginTestCase +_css_colors_src = re.compile(r"""#(?P[0-9a-f]{6})""") +_css_colors_dst = r"""#\g""" + +def _add_color_swatch(str): + return _css_colors_src.sub(_css_colors_dst, str) + class Code(PelicanPluginTestCase): def __init__(self, *args, **kwargs): super().__init__(__file__, '', *args, **kwargs) @@ -33,7 +41,16 @@ class Code(PelicanPluginTestCase): # Need Source Code Pro for code 'M_CSS_FILES': ['https://fonts.googleapis.com/css?family=Source+Code+Pro:400,400i,600%7CSource+Sans+Pro:400,400i,600,600i', 'static/m-dark.css'], - 'PLUGINS': ['m.htmlsanity', 'm.code'] + 'PLUGINS': ['m.htmlsanity', 'm.code'], + 'M_CODE_FILTERS_PRE': { + 'CSS': lambda str: str.replace(':', ': ').replace('{', ' {'), + ('CSS', 'lowercase'): lambda str: str.lower(), + ('CSS', 'uppercase'): lambda str: str.upper(), # not used + }, + 'M_CODE_FILTERS_POST': { + 'CSS': _add_color_swatch, + ('CSS', 'replace_colors'): lambda str: str.replace('#c0ffee', '#3bd267') + }, }) self.assertEqual(*self.actual_expected_contents('page.html')) diff --git a/site/pelicanconf.py b/site/pelicanconf.py index 19e9408a..3e6008c2 100644 --- a/site/pelicanconf.py +++ b/site/pelicanconf.py @@ -22,6 +22,7 @@ # DEALINGS IN THE SOFTWARE. # +import re import shutil import logging @@ -187,6 +188,19 @@ if not shutil.which('latex'): logging.warning("LaTeX not found, fallback to rendering math as code") M_MATH_RENDER_AS_CODE = True +# Used by the m.code plugin docs + +_css_colors_src = re.compile(r"""#(?P[0-9a-f]{6})""") +_css_colors_dst = r"""#\g""" + +M_CODE_FILTERS_PRE = { + ('C++', 'codename'): lambda code: code.replace('DirtyMess', 'P300::V1'), + ('C++', 'fix_typography'): lambda code: code.replace(' :', ':'), +} +M_CODE_FILTERS_POST = { + 'CSS': lambda code: _css_colors_src.sub(_css_colors_dst, code) +} + DIRECT_TEMPLATES = ['archives'] PAGE_URL = '{slug}/'