From ae4d30ade9f21691257c876a867be0702c2435a4 Mon Sep 17 00:00:00 2001 From: Jim Shepich III Date: Mon, 9 Feb 2026 16:51:09 -0500 Subject: [PATCH] Added fenced code with syntax highlighting and auto-escaping braces --- .gitattributes | 3 +- config.yaml | 9 +-- jimsite/__init__.py | 1 + jimsite/articles.py | 27 ++++++- jimsite/blog.py | 12 +-- jimsite/templating.py | 54 ++++++++++++- requirements.txt | 4 +- site/assets/css/codehilite.css | 75 +++++++++++++++++++ site/assets/css/theme.css | 10 ++- .../templates/components/jimoire-article.html | 20 +++++ site/templates/partials/default_css.html | 3 +- 11 files changed, 195 insertions(+), 23 deletions(-) create mode 100644 site/assets/css/codehilite.css create mode 100644 site/templates/components/jimoire-article.html diff --git a/.gitattributes b/.gitattributes index bc51cc8..4718622 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,4 +2,5 @@ * text=auto # Ignore Jupyter Notebooks in language stats. -*.ipynb linguist-vendored \ No newline at end of file +*.ipynb linguist-vendored +site/assets/css/codehilite.css linguist-vendored \ No newline at end of file diff --git a/config.yaml b/config.yaml index c02a28d..19dc521 100644 --- a/config.yaml +++ b/config.yaml @@ -8,18 +8,11 @@ sites: build_cache: ./site assets: - /assets + - robots.txt articles: - '*.md' template_selections: article: templates.components.simple_article - - robots_txt: - title: robots.txt - base_url: http://localhost:8000/robots.txt - web_root: ./dist - build_cache: ./site - assets: - - robots.txt resume: title: Resume diff --git a/jimsite/__init__.py b/jimsite/__init__.py index 76f716a..53d14a8 100644 --- a/jimsite/__init__.py +++ b/jimsite/__init__.py @@ -5,6 +5,7 @@ import logging logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +# TODO: add a way to change log level from .common import filepath_or_string, GlobalVars, SiteConfig, dotmap_access from .templating import format_html_template, map_templates, TemplateSelections diff --git a/jimsite/articles.py b/jimsite/articles.py index e370f36..816752f 100644 --- a/jimsite/articles.py +++ b/jimsite/articles.py @@ -2,6 +2,8 @@ import os import glob import yaml import markdown +from pygments.formatters import HtmlFormatter +from markdown.extensions.codehilite import CodeHiliteExtension import pydantic import logging from typing import Optional, Union @@ -13,6 +15,29 @@ from .templating import format_html_template, TemplateSelections logger = logging.getLogger(__name__) +class CustomHiliteFormatter(HtmlFormatter): + ''' + Override the default Pygments formatter (used by markdown.extensions.codehilite + to add syntax highlighting to fenced code) to include the `language-*` class. + + From https://python-markdown.github.io/extensions/code_hilite/ + ''' + def __init__(self, lang_str='', **options): + super().__init__(**options) + self.lang_str = lang_str + + def _wrap_code(self, source): + yield 0, f'' + yield from source + yield 0, '' + +# Pick extensions to use for Markdown parsing. +MARKDOWN_EXTENSIONS = [ + 'footnotes', + 'fenced_code', + CodeHiliteExtension(linenums = True, pygments_formatter = CustomHiliteFormatter) +] + class ArticleMetadata(pydantic.BaseModel): '''A model for the YAML frontmatter included with Markdown articles.''' title: str @@ -51,7 +76,7 @@ def load_markdown(md: str) -> tuple[Optional[ArticleMetadata], str]: metadata = yaml.safe_load(raw_metadata) # Convert the contents to a HTML string. - content = markdown.markdown(raw_article) + content = markdown.markdown(raw_article, extensions = MARKDOWN_EXTENSIONS) return ArticleMetadata(**metadata), content diff --git a/jimsite/blog.py b/jimsite/blog.py index 59229a3..231c8c4 100644 --- a/jimsite/blog.py +++ b/jimsite/blog.py @@ -58,9 +58,9 @@ def build_blog_archive( tag_selector_options = [] tag_selector_css_rules = [f''' - body:has(input[name="tag-selector"][value="*"]:checked) li:has(.blog-tag){{{{ + body:has(input[name="tag-selector"][value="*"]:checked) li:has(.blog-tag){{ display: list-item!important; - }}}} + }} '''] # Add tag selector options in descending order of article count. @@ -75,12 +75,12 @@ def build_blog_archive( if tag == '*': continue tag_selector_css_rules.append(f''' - body:has(input[name="tag-selector"]:not([value="{tag}"]):checked) li:has(.blog-tag[data="{tag}"]){{{{ + body:has(input[name="tag-selector"]:not([value="{tag}"]):checked) li:has(.blog-tag[data="{tag}"]){{ display: none; - }}}} - body:has(input[name="tag-selector"][value="{tag}"]:checked) li:has(.blog-tag[data="{tag}"]){{{{ + }} + body:has(input[name="tag-selector"][value="{tag}"]:checked) li:has(.blog-tag[data="{tag}"]){{ display: list-item!important; - }}}} + }} ''') # For Python 3.9-friendliness. diff --git a/jimsite/templating.py b/jimsite/templating.py index edefc19..189dd17 100644 --- a/jimsite/templating.py +++ b/jimsite/templating.py @@ -1,18 +1,52 @@ import os -import re + +# Use the community regex package instead of re for QoL +# features like variable-length lookbacks. +import regex as re +import logging from dotmap import DotMap + +logger = logging.getLogger(__name__) + from .common import filepath_or_string, GlobalVars, SiteConfig, dotmap_access +def escape_braces(template) -> str: + '''Escapes any curly braces that are unclosed or enclose + anything other than a pattern for a valid variable + (alphanumerics, underscores, and periods ONLY; + must start with alpha or underscore). + ''' + + # Match any left braces that are not followed by a valid variable + # name (with no whitespace) and a closing right brace. + left_brace_pattern = r'{(?![A-Za-z_][\w.]+})' + + # Match any right braces that are not preceded by an opening + # left brace and a valid variable name. + right_brace_pattern = r'(? set: '''Extracts placeholder variables in the format `{variable}` from an unformatted template string.''' # Regex pattern to match placeholders with alphanumerics, dots, and underscores. - placeholder_pattern = r'\{([\w\.]+)\}' + placeholder_pattern = r'\{([A-Za-z_][\w.]+)\}' + # Escape braces that are not used to enclose Python variables + # (e.g. those used in inline CSS or JavaScript) + escaped = escape_braces(s) + # Find all matches in the string. - matches = re.findall(placeholder_pattern, s) + matches = re.findall(placeholder_pattern, escaped) # Return the set of distinct placeholders. return set(matches) @@ -85,7 +119,19 @@ def format_html_template(template: str, **kwargs) -> str: # there are no more placeholders. The loop is used to account for nested template references. formatted_html = template while len(extract_placeholders(formatted_html)) > 0: - formatted_html = formatted_html.format(globalvars = GlobalVars(), **kwargs) + + # Escape non-variable-enclosing braces each iteration of the + # loop, because each time they'll be replaced with single braces. + try: + escaped = escape_braces(formatted_html) + formatted_html = escaped.format( + globalvars = GlobalVars(), + **kwargs + ) + except Exception as e: + logger.error('Encountered an exception while formatting the following:') + logger.error(escaped) + raise e # Return the formatted HTML. return formatted_html diff --git a/requirements.txt b/requirements.txt index c4b3774..b112f5e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,6 @@ markdown pyyaml rfeed dotmap -pydantic \ No newline at end of file +pydantic +pygments +pymdownextensions \ No newline at end of file diff --git a/site/assets/css/codehilite.css b/site/assets/css/codehilite.css new file mode 100644 index 0000000..96c6007 --- /dev/null +++ b/site/assets/css/codehilite.css @@ -0,0 +1,75 @@ +pre { line-height: 125%; } +td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.codehilite .hll { background-color: #ffffcc } +.codehilite { background: #f8f8f8; } +.codehilite .c { color: #3D7B7B; font-style: italic } /* Comment */ +.codehilite .err { border: 1px solid #F00 } /* Error */ +.codehilite .k { color: #008000; font-weight: bold } /* Keyword */ +.codehilite .o { color: #666 } /* Operator */ +.codehilite .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */ +.codehilite .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */ +.codehilite .cp { color: #9C6500 } /* Comment.Preproc */ +.codehilite .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */ +.codehilite .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */ +.codehilite .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */ +.codehilite .gd { color: #A00000 } /* Generic.Deleted */ +.codehilite .ge { font-style: italic } /* Generic.Emph */ +.codehilite .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +.codehilite .gr { color: #E40000 } /* Generic.Error */ +.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.codehilite .gi { color: #008400 } /* Generic.Inserted */ +.codehilite .go { color: #717171 } /* Generic.Output */ +.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.codehilite .gs { font-weight: bold } /* Generic.Strong */ +.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.codehilite .gt { color: #04D } /* Generic.Traceback */ +.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.codehilite .kp { color: #008000 } /* Keyword.Pseudo */ +.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.codehilite .kt { color: #B00040 } /* Keyword.Type */ +.codehilite .m { color: #666 } /* Literal.Number */ +.codehilite .s { color: #BA2121 } /* Literal.String */ +.codehilite .na { color: #687822 } /* Name.Attribute */ +.codehilite .nb { color: #008000 } /* Name.Builtin */ +.codehilite .nc { color: #00F; font-weight: bold } /* Name.Class */ +.codehilite .no { color: #800 } /* Name.Constant */ +.codehilite .nd { color: #A2F } /* Name.Decorator */ +.codehilite .ni { color: #717171; font-weight: bold } /* Name.Entity */ +.codehilite .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */ +.codehilite .nf { color: #00F } /* Name.Function */ +.codehilite .nl { color: #767600 } /* Name.Label */ +.codehilite .nn { color: #00F; font-weight: bold } /* Name.Namespace */ +.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.codehilite .nv { color: #19177C } /* Name.Variable */ +.codehilite .ow { color: #A2F; font-weight: bold } /* Operator.Word */ +.codehilite .w { color: #BBB } /* Text.Whitespace */ +.codehilite .mb { color: #666 } /* Literal.Number.Bin */ +.codehilite .mf { color: #666 } /* Literal.Number.Float */ +.codehilite .mh { color: #666 } /* Literal.Number.Hex */ +.codehilite .mi { color: #666 } /* Literal.Number.Integer */ +.codehilite .mo { color: #666 } /* Literal.Number.Oct */ +.codehilite .sa { color: #BA2121 } /* Literal.String.Affix */ +.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */ +.codehilite .sc { color: #BA2121 } /* Literal.String.Char */ +.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */ +.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */ +.codehilite .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */ +.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */ +.codehilite .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */ +.codehilite .sx { color: #008000 } /* Literal.String.Other */ +.codehilite .sr { color: #A45A77 } /* Literal.String.Regex */ +.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */ +.codehilite .ss { color: #19177C } /* Literal.String.Symbol */ +.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */ +.codehilite .fm { color: #00F } /* Name.Function.Magic */ +.codehilite .vc { color: #19177C } /* Name.Variable.Class */ +.codehilite .vg { color: #19177C } /* Name.Variable.Global */ +.codehilite .vi { color: #19177C } /* Name.Variable.Instance */ +.codehilite .vm { color: #19177C } /* Name.Variable.Magic */ +.codehilite .il { color: #666 } /* Literal.Number.Integer.Long */ diff --git a/site/assets/css/theme.css b/site/assets/css/theme.css index 31c38e2..2ce4a91 100644 --- a/site/assets/css/theme.css +++ b/site/assets/css/theme.css @@ -1,3 +1,5 @@ +/* TODO: Reorganize CSS */ + body{ background: inherit; background-color: white; @@ -296,4 +298,10 @@ p.socials{ article ul, article ol{ margin-bottom: 1em; -} \ No newline at end of file +} + +pre:has(> code), mark{ + font-family: 'Oxygen Mono', sans-serif; + font-display: swap; + background-color: #d8d8d8; +} diff --git a/site/templates/components/jimoire-article.html b/site/templates/components/jimoire-article.html new file mode 100644 index 0000000..304110d --- /dev/null +++ b/site/templates/components/jimoire-article.html @@ -0,0 +1,20 @@ +
+ First published: + + ยท Last modified: + +
+

{article.metadata.title}

+
By
+

+ {article.content} +


+

+ + +   + +

+

Tags: {blog_tags}{templates.components.rss_icon}

+
\ No newline at end of file diff --git a/site/templates/partials/default_css.html b/site/templates/partials/default_css.html index c34eeeb..2e85391 100644 --- a/site/templates/partials/default_css.html +++ b/site/templates/partials/default_css.html @@ -1,5 +1,6 @@ - + +