Added fenced code with syntax highlighting and auto-escaping braces

This commit is contained in:
Jim Shepich III 2026-02-09 16:51:09 -05:00
parent cabcda5b00
commit ae4d30ade9
11 changed files with 195 additions and 23 deletions

1
.gitattributes vendored
View File

@ -3,3 +3,4 @@
# Ignore Jupyter Notebooks in language stats. # Ignore Jupyter Notebooks in language stats.
*.ipynb linguist-vendored *.ipynb linguist-vendored
site/assets/css/codehilite.css linguist-vendored

View File

@ -8,19 +8,12 @@ sites:
build_cache: ./site build_cache: ./site
assets: assets:
- /assets - /assets
- robots.txt
articles: articles:
- '*.md' - '*.md'
template_selections: template_selections:
article: templates.components.simple_article 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: resume:
title: Resume title: Resume
base_url: http://localhost:8000 base_url: http://localhost:8000

View File

@ -5,6 +5,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 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 .common import filepath_or_string, GlobalVars, SiteConfig, dotmap_access
from .templating import format_html_template, map_templates, TemplateSelections from .templating import format_html_template, map_templates, TemplateSelections

View File

@ -2,6 +2,8 @@ import os
import glob import glob
import yaml import yaml
import markdown import markdown
from pygments.formatters import HtmlFormatter
from markdown.extensions.codehilite import CodeHiliteExtension
import pydantic import pydantic
import logging import logging
from typing import Optional, Union from typing import Optional, Union
@ -13,6 +15,29 @@ from .templating import format_html_template, TemplateSelections
logger = logging.getLogger(__name__) 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'<code class="{self.lang_str}">'
yield from source
yield 0, '</code>'
# Pick extensions to use for Markdown parsing.
MARKDOWN_EXTENSIONS = [
'footnotes',
'fenced_code',
CodeHiliteExtension(linenums = True, pygments_formatter = CustomHiliteFormatter)
]
class ArticleMetadata(pydantic.BaseModel): class ArticleMetadata(pydantic.BaseModel):
'''A model for the YAML frontmatter included with Markdown articles.''' '''A model for the YAML frontmatter included with Markdown articles.'''
title: str title: str
@ -51,7 +76,7 @@ def load_markdown(md: str) -> tuple[Optional[ArticleMetadata], str]:
metadata = yaml.safe_load(raw_metadata) metadata = yaml.safe_load(raw_metadata)
# Convert the contents to a HTML string. # Convert the contents to a HTML string.
content = markdown.markdown(raw_article) content = markdown.markdown(raw_article, extensions = MARKDOWN_EXTENSIONS)
return ArticleMetadata(**metadata), content return ArticleMetadata(**metadata), content

View File

@ -58,9 +58,9 @@ def build_blog_archive(
tag_selector_options = [] tag_selector_options = []
tag_selector_css_rules = [f''' 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; display: list-item!important;
}}}} }}
'''] ''']
# Add tag selector options in descending order of article count. # Add tag selector options in descending order of article count.
@ -75,12 +75,12 @@ def build_blog_archive(
if tag == '*': if tag == '*':
continue continue
tag_selector_css_rules.append(f''' 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; 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; display: list-item!important;
}}}} }}
''') ''')
# For Python 3.9-friendliness. # For Python 3.9-friendliness.

View File

@ -1,18 +1,52 @@
import os 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 from dotmap import DotMap
logger = logging.getLogger(__name__)
from .common import filepath_or_string, GlobalVars, SiteConfig, dotmap_access 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'(?<!{[A-Za-z_][\w.]+)}'
# Escape non-variable-enclosing braces with double braces.
return re.sub(
right_brace_pattern, '}}',
re.sub(
left_brace_pattern, '{{', template
)
)
def extract_placeholders(s: str) -> set: def extract_placeholders(s: str) -> set:
'''Extracts placeholder variables in the format `{variable}` from '''Extracts placeholder variables in the format `{variable}` from
an unformatted template string.''' an unformatted template string.'''
# Regex pattern to match placeholders with alphanumerics, dots, and underscores. # 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. # 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 the set of distinct placeholders.
return set(matches) 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. # there are no more placeholders. The loop is used to account for nested template references.
formatted_html = template formatted_html = template
while len(extract_placeholders(formatted_html)) > 0: 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 the formatted HTML.
return formatted_html return formatted_html

View File

@ -4,3 +4,5 @@ pyyaml
rfeed rfeed
dotmap dotmap
pydantic pydantic
pygments
pymdownextensions

75
site/assets/css/codehilite.css vendored Normal file
View File

@ -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 */

View File

@ -1,3 +1,5 @@
/* TODO: Reorganize CSS */
body{ body{
background: inherit; background: inherit;
background-color: white; background-color: white;
@ -297,3 +299,9 @@ p.socials{
article ul, article ol{ article ul, article ol{
margin-bottom: 1em; margin-bottom: 1em;
} }
pre:has(> code), mark{
font-family: 'Oxygen Mono', sans-serif;
font-display: swap;
background-color: #d8d8d8;
}

View File

@ -0,0 +1,20 @@
<article>
<span class="first-published-label">First published: </span>
<time class="first-published-date" pubdate data="{article.metadata.date}" datetime="{article.metadata.date}">{article.metadata.date}</time>
<span class="last-modified-label"> · Last modified: </span>
<time class="last-modified-date" data="{article.metadata.lastmod}" datetime="{article.metadata.lastmod}">{article.metadata.lastmod}</time>
<hr />
<h1 class="headline">{article.metadata.title}</h1>
<p class="byline">
<address class="author">By <a rel="author" href="mailto:admin@jimlab.io">Jim Shepich III</a></address>
</p>
{article.content}
<br /><hr /><br />
<p>
<!-- TODO: highlightjs.org -->
<a href="{site.base_url}/{previous_article.path}" title="{previous_article.metadata.title}" data="{previous_article.path}" class="previous-article-link">&lt; Previous</a>
&nbsp; <!-- This non-breaking space prevents the RSS icon from getting weird when previous-article-link is hidden. -->
<a href="{site.base_url}/{next_article.path}" title="{next_article.metadata.title}" data="{next_article.path}" class="next-article-link">Next &gt;</a>
</p>
<p>Tags: {blog_tags}{templates.components.rss_icon}</p>
</article>

View File

@ -1,5 +1,6 @@
<link rel="stylesheet" type="text/css" href="/assets/css/reset.css"> <link rel="stylesheet" type="text/css" href="/assets/css/reset.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Jacquard+12|Fira+Sans|UnifrakturMaguntia"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Jacquard+12|Fira+Sans|UnifrakturMaguntia|Oxygen+Mono">
<link rel="stylesheet" type="text/css" href="/assets/css/common.css"> <link rel="stylesheet" type="text/css" href="/assets/css/common.css">
<link rel="stylesheet" type="text/css" href="/assets/css/layout.css"> <link rel="stylesheet" type="text/css" href="/assets/css/layout.css">
<link rel="stylesheet" type="text/css" href="/assets/css/theme.css"> <link rel="stylesheet" type="text/css" href="/assets/css/theme.css">
<link rel="stylesheet" type="text/css" href="/assets/css/codehilite.css">