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.
*.ipynb linguist-vendored
site/assets/css/codehilite.css linguist-vendored

View File

@ -8,19 +8,12 @@ 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
base_url: http://localhost:8000

View File

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

View File

@ -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'<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):
'''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

View File

@ -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.

View File

@ -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'(?<!{[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:
'''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

View File

@ -4,3 +4,5 @@ pyyaml
rfeed
dotmap
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{
background: inherit;
background-color: white;
@ -297,3 +299,9 @@ p.socials{
article ul, article ol{
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" 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/layout.css">
<link rel="stylesheet" type="text/css" href="/assets/css/theme.css">
<link rel="stylesheet" type="text/css" href="/assets/css/codehilite.css">