Added true recursive template interpolation and cleaned up

This commit is contained in:
Jim Shepich III 2026-01-31 14:02:22 -05:00
parent 239b3f1a84
commit f0b26fb2d5
3 changed files with 96 additions and 510 deletions

View File

@ -18,7 +18,7 @@ sites:
git_repo: ssh://gitea/jim/resume.git git_repo: ssh://gitea/jim/resume.git
build_cache: ./build/resume build_cache: ./build/resume
assets: assets:
- '{build_cache}/shepich_resume.pdf' - 'shepich_resume.pdf'
dogma_jimfinium: dogma_jimfinium:
base_url: http://localhost:8080/dogma-jimfinium base_url: http://localhost:8080/dogma-jimfinium
git_repo: ssh://gitea/jim/dogma-jimfinium.git git_repo: ssh://gitea/jim/dogma-jimfinium.git

View File

@ -1,4 +1,5 @@
import os import os
import re
import glob import glob
import shutil import shutil
import subprocess import subprocess
@ -23,6 +24,59 @@ def filepath_or_string(s: str) -> str:
return s return s
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\.]+)\}'
# Find all matches in the string.
matches = re.findall(placeholder_pattern, s)
# Return the set of distinct placeholders.
return set(matches)
def find_cyclical_placeholders(s: str, _parents: tuple = None, _cycles: set = None, **kwargs) -> set[tuple]:
'''Recursively interpolates supplied kwargs into a template string to validate
that there are no cyclical dependencies that would cause infinite recursion.
Returns a list of paths (expressed as tuples of nodes) of cyclical placeholders.
'''
# Track the lineage of each placeholder so we can see if it is its own ancestor.
if _parents is None:
_parents = tuple()
# Keep track of any cycles encountered.
if _cycles is None:
_cycles = set()
# Extract the placeholders from the input.
placeholders = extract_placeholders(s)
# Recursion will naturally end once there are no more nested placeholders.
for p in placeholders:
# Any placeholder that has itself in its ancestry forms a cycle.
if p in _parents:
_cycles.add(_parents + (p,))
# For placeholders that are not their own ancestor, recursively
# interpolate the kwargs into the nested placeholders until we reach
# strings without placeholders.
else:
find_cyclical_placeholders(
('{'+p+'}').format(**kwargs),
_parents = _parents+(p,),
_cycles = _cycles,
**kwargs
)
return _cycles
with open('config.yaml', 'r') as config_file: with open('config.yaml', 'r') as config_file:
config = yaml.safe_load(config_file.read()) config = yaml.safe_load(config_file.read())
@ -69,20 +123,36 @@ def load_markdown(md: str) -> tuple[ArticleMetadata|None, str]:
def format_html_template(template: str, **kwargs) -> str: def format_html_template(template: str, **kwargs) -> str:
'''Interpolates variables specified as keyword arguments '''Interpolates variables specified as keyword arguments
into the given HTML template.''' into the given HTML template.
# Example
```python
kwargs = {'a': '1', 'b': '2', 'c': '{d}+{e}', 'd': '3', 'e': '{c}'}
s = '{a} + {b} = {c}'
find_cyclical_placeholders(s, **kwargs)
>>> {('c', 'e', 'c')}
```
'''
# Load the template if a filepath is given. # Load the template if a filepath is given.
template = filepath_or_string(template) template = filepath_or_string(template)
# Interpolate the kwargs into the HTML template. # Ensure the template does not have cyclical placeholder references.
# Apply global variables twice in case a partial used cycles = find_cyclical_placeholders(template, globalvars = GlobalVars(), **kwargs)
# by the first call of .format() uses a variable.
html = template.format( if len(cycles) > 0:
globalvars = GlobalVars(), **kwargs raise ValueError('Template has cyclical dependencies: {cycles}')
).format(globalvars = GlobalVars())
# Iteratively interpolate global variables and the kwargs into the template until
# 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)
# Return the formatted HTML. # Return the formatted HTML.
return html return formatted_html
run = lambda cmd: subprocess.run(cmd.split(' '), stdout = subprocess.PIPE, stderr = subprocess.PIPE) run = lambda cmd: subprocess.run(cmd.split(' '), stdout = subprocess.PIPE, stderr = subprocess.PIPE)
@ -212,7 +282,7 @@ def build_index(site: SiteConfig) -> dict:
# Expand any globbed expressions. # Expand any globbed expressions.
expanded_article_list = [] expanded_article_list = []
for a in site.articles: for a in site.articles or {}:
expanded_article_list.extend( expanded_article_list.extend(
# Article paths are defined relative to the build cache; construct the full path. # Article paths are defined relative to the build cache; construct the full path.
glob.glob(f'{site.build_cache}/{a.lstrip("/")}') glob.glob(f'{site.build_cache}/{a.lstrip("/")}')
@ -259,10 +329,6 @@ def map_templates(dir: str, parent = '') -> DotMap:
with open(full_path, 'r') as file: with open(full_path, 'r') as file:
html = file.read() html = file.read()
# # Interpolate global variables into partials.
# if 'partials' in full_path:
# html = html.format(globalvars = GlobalVars())
output[filestem] = html output[filestem] = html
return DotMap(output) return DotMap(output)

File diff suppressed because one or more lines are too long