Standardizing templating

This commit is contained in:
Jim Shepich III 2026-02-01 14:10:32 -05:00
parent 2dfb9fa7ed
commit f933e47a04
15 changed files with 361 additions and 76 deletions

View File

@ -1,12 +1,8 @@
author: Jim Shepich III author: Jim Shepich III
templates_folder: ./templates templates_folder: ./site/templates
site_defaults:
templates:
partials: ./templates/partials
components: ./templates/components
pages: ./templates/pages
sites: sites:
main: main:
title: Jimlab
base_url: http://localhost:8000 base_url: http://localhost:8000
web_root: ./dist web_root: ./dist
build_cache: ./site build_cache: ./site
@ -14,14 +10,20 @@ sites:
- /assets - /assets
articles: articles:
- ./pages/*.md - ./pages/*.md
template_selections:
article: templates.components.simple_article
resume: resume:
title: Resume
base_url: http://localhost:8000 base_url: http://localhost:8000
web_root: ./dist web_root: ./dist
git_repo: ssh://gitea/jim/resume.git git_repo: ssh://gitea/jim/resume.git
build_cache: ./build/resume build_cache: ./build/resume
assets: assets:
- 'shepich_resume.pdf' - 'shepich_resume.pdf'
dogma_jimfinium: dogma_jimfinium:
title: 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
build_cache: ./build/dogma-jimfinium build_cache: ./build/dogma-jimfinium

View File

@ -10,11 +10,11 @@ from typing import Optional
from datetime import datetime, date from datetime import datetime, date
from dotmap import DotMap from dotmap import DotMap
from .common import filepath_or_string, GlobalVars, SiteConfig from .common import filepath_or_string, GlobalVars, SiteConfig, dotmap_access
from .templating import format_html_template, map_templates from .templating import format_html_template, map_templates, TemplateSelections
from .assets import pull_git_repo, copy_assets from .assets import pull_git_repo, copy_assets
from .articles import ArticleMetadata, load_markdown, build_articles, build_index from .articles import ArticleMetadata, load_markdown, build_articles, build_index
from .blog import build_blog_archive, build_rss_feed
@ -25,6 +25,10 @@ from .articles import ArticleMetadata, load_markdown, build_articles, build_inde
def build_site(site: SiteConfig, templates: DotMap): def build_site(site: SiteConfig, templates: DotMap):
# Do not build a site marked as unpublished.
if not site.published:
return None
# Initialize the build cache and web root, in case they do not exist. # Initialize the build cache and web root, in case they do not exist.
os.makedirs(site.build_cache, exist_ok = True) os.makedirs(site.build_cache, exist_ok = True)
os.makedirs(site.web_root, exist_ok = True) os.makedirs(site.web_root, exist_ok = True)
@ -39,8 +43,15 @@ def build_site(site: SiteConfig, templates: DotMap):
# Load the site's articles into an index. # Load the site's articles into an index.
index = build_index(site) index = build_index(site)
# Determine which templates are to be used for explicit applications, e.g.
# the tag component.
template_selections = TemplateSelections(site, templates)
# Generate HTML pages for the articles. # Generate HTML pages for the articles.
build_articles(site, index, templates) build_articles(site, index, templates, template_selections)
if len(site.articles or []):
build_blog_archive(site, index, template_selections, templates = templates)
def main(): def main():

View File

@ -8,7 +8,7 @@ from dotmap import DotMap
from datetime import date from datetime import date
from .common import filepath_or_string, SiteConfig from .common import filepath_or_string, SiteConfig
from .templating import format_html_template from .templating import format_html_template, TemplateSelections
class ArticleMetadata(pydantic.BaseModel): class ArticleMetadata(pydantic.BaseModel):
title: str title: str
@ -70,30 +70,39 @@ def build_index(site: SiteConfig) -> dict:
return index return index
def format_article_tags(tags: list[str], template = 'templates/components/blog_tag.html') -> list[str]: def format_article_tags(tags: list[str], tag_template, **kwargs) -> list[str]:
'''Generates HTML article tag components from a list of tag names.''' '''Generates HTML article tag components from a list of tag names.'''
return [ return [
format_html_template(template, tag_name = t) for t in tags format_html_template(tag_template, tag_name = t, **kwargs) for t in tags
] ]
def build_articles(site: SiteConfig, index: dict[str, tuple[ArticleMetadata, str]], templates: DotMap): def build_articles(
site: SiteConfig,
index: dict[str, tuple[ArticleMetadata, str]],
templates: DotMap,
template_selections: TemplateSelections
):
'''Generates HTML files for all of a given site's Markdown articles '''Generates HTML files for all of a given site's Markdown articles
by interpolating the contents and metadata into the HTML templates.''' by interpolating the contents and metadata into the HTML templates.'''
for filestem, (metadata, content) in index.items(): for filestem, (metadata, content) in index.items():
article = format_html_template( article = format_html_template(
templates.components.blog_article, template_selections['article'],
content = content, content = content,
blog_tags = ' '.join(format_article_tags(metadata.tags)), blog_tags = ' '.join(format_article_tags(
metadata.tags, template_selections['tag'], site = site
)),
metadata = metadata, metadata = metadata,
templates = templates templates = templates,
site = site
) )
page = format_html_template( page = format_html_template(
templates.pages.default, template_selections['page'],
content = article, content = article,
templates = templates templates = templates,
site = site
) )

View File

@ -4,14 +4,13 @@ import datetime
from .common import SiteConfig from .common import SiteConfig
from .articles import ArticleMetadata, format_article_tags from .articles import ArticleMetadata, format_article_tags
from .templating import format_html_template from .templating import format_html_template, TemplateSelections
def build_blog_archive( def build_blog_archive(
site: SiteConfig,
index: dict[str, tuple[str, str]], index: dict[str, tuple[str, str]],
page_template = 'templates/pages/default.html', template_selections: TemplateSelections,
li_template = 'templates/components/blog_archive_li.html',
**kwargs **kwargs
) -> str: ) -> str:
'''Converts an index, formatted as filestem: (metadata, contents) dict, '''Converts an index, formatted as filestem: (metadata, contents) dict,
@ -22,27 +21,50 @@ def build_blog_archive(
''' '''
# Add each article as a list item to an unordered list. # Add each article as a list item to an unordered list.
archive_html_content = '<ul>' archive_html_list = '<ul>'
for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0].date)[::-1]: for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0].date)[::-1]:
# Generate HTML for the article (including metadata tags). # Generate HTML for the article (including metadata tags).
archive_html_content += format_html_template( archive_html_list += format_html_template(
li_template, template_selections['archive_li'],
article_filestem = article, article_filestem = article,
blog_tags = ' '.join(format_article_tags(metadata.tags)), blog_tags = ' '.join(format_article_tags(metadata.tags, template_selections['tag'], site = site)),
metadata = metadata metadata = metadata,
site = site,
**kwargs
) )
archive_html_content +='</ul>' archive_html_list +='</ul>'
# Interpolate the article into the overall page template. # Generate the archive article.
archive_html_page = format_html_template( archive_html_article = format_html_template(
page_template, template_selections['archive_article'],
content = archive_html_content, content = archive_html_list,
site = site,
**kwargs **kwargs
) )
return archive_html_page # Interpolate the article into the overall page template.
archive_html_page = format_html_template(
template_selections['page'],
content = archive_html_article,
site = site,
**kwargs
)
with open(f'{site.web_root.rstrip('/')}/archive.html', 'w') as f:
f.write(archive_html_page)
def build_tag_reference(index: dict[str, tuple[str, str]]):
tag_index = {}
for article, (metadata, content) in index.items():
for tag in metadata.tags:
tag_index[tag] = (tag_index.get(tag,[])) + [article]
for tag, articles in tag_index.items():
pass
# TODO: Finish # TODO: Finish
def build_rss_feed(site: SiteConfig, index: dict[str, tuple[ArticleMetadata, str]]): def build_rss_feed(site: SiteConfig, index: dict[str, tuple[ArticleMetadata, str]]):

View File

@ -1,6 +1,8 @@
import os import os
import inspect
import subprocess import subprocess
import pydantic import pydantic
from dotmap import DotMap
from typing import Optional from typing import Optional
from datetime import date, datetime from datetime import date, datetime
@ -28,6 +30,42 @@ class SiteConfig(pydantic.BaseModel):
base_url: str base_url: str
web_root: str web_root: str
build_cache: str build_cache: str
title: str
published: Optional[bool] = True
git_repo: Optional[str] = None git_repo: Optional[str] = None
assets: Optional[list] = None assets: Optional[list] = None
articles: Optional[list] = None articles: Optional[list] = None
template_selections: Optional[dict] = {}
def get_var_names(var):
'''Gets the name(s) of an input variable.
From https://stackoverflow.com/questions/18425225/getting-the-name-of-a-variable-as-a-string.'''
callers_local_vars = inspect.currentframe().f_back.f_back.f_locals.items()
return [var_name for var_name, var_val in callers_local_vars if var_val is var]
def dotmap_access(d: DotMap, s: str):
'''Facilitates nested subscripting into a DotMap using a
string containing period-delimited keys.
# Example
```python
dd = DotMap({'a':{'b':{'c':42}}})
dotmap_access(dd, 'dd.a.b.c')
>>> 42
```
'''
keys = s.split('.')
# If the string starts with the dotmap's name, ignore it.
if keys[0] in get_var_names(d):
keys.pop(0)
# Iteratively subscript into the nested dotmap until it's done.
result = d
for k in keys:
result = result[k]
return result

View File

@ -1,7 +1,7 @@
import os import os
import re import re
from dotmap import DotMap from dotmap import DotMap
from .common import filepath_or_string, GlobalVars from .common import filepath_or_string, GlobalVars, SiteConfig, dotmap_access
def extract_placeholders(s: str) -> set: def extract_placeholders(s: str) -> set:
@ -121,3 +121,32 @@ def map_templates(dir: str, parent = '') -> DotMap:
output[filestem] = html output[filestem] = html
return DotMap(output) return DotMap(output)
# TODO: Come up with a more consistent naming scheme for defaults.
class TemplateSelections:
'''An interface for retrieving templates that are explicitly used in the
compiler, such as the article and tag components, as well as the default page.
Relies on `SiteConfig.template_selection` for custom overrides, falling back on
`config[template_default_selections]`, and finally on defaults hard-coded into
the definition of this class.
'''
def __init__(self, site: SiteConfig, templates: DotMap, config: dict = None):
self.site = site
self.templates = templates
self.defaults = dict(
tag = 'templates.components.blog_tag',
article = 'templates.components.blog_article',
page = 'templates.pages.default',
archive_li = 'templates.components.blog_archive_li',
archive_article = 'templates.components.blog_archive'
) | (config or {}).get('template_default_selections', {})
def __getitem__(self, key: str) -> str:
# Templates variable must be named `templates` for dotmap_access to work correctly.
templates = self.templates
return dotmap_access(templates, self.site.template_selections.get(key, self.defaults[key]))

129
site/assets/img/favicon.svg Normal file
View File

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="190.6763mm"
height="190.67599mm"
viewBox="0 0 190.67628 190.676"
version="1.1"
id="svg1"
sodipodi:docname="jimlab.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.46774"
inkscape:cx="153.93167"
inkscape:cy="366.65669"
inkscape:window-width="1280"
inkscape:window-height="727"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<linearGradient
id="linearGradient18"
inkscape:collect="always">
<stop
style="stop-color:#666666;stop-opacity:1;"
offset="0"
id="stop19" />
<stop
style="stop-color:#d9d9d9;stop-opacity:1;"
offset="1"
id="stop20" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient18"
id="linearGradient20"
x1="109.12461"
y1="131.25221"
x2="92.506081"
y2="321.20288"
gradientUnits="userSpaceOnUse" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-5.4771806,-130.88957)">
<path
id="rect1-6-0-6"
style="display:inline;fill:url(#linearGradient20);fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
inkscape:label="hourglass"
d="m 10.305609,180.9727 45.2546,45.25461 -45.254596,45.25512 h 90.509717 90.50972 l -45.2546,-45.25512 45.2546,-45.2546 -90.50921,-1e-5 h -5.1e-4 -5.2e-4 z" />
<g
id="g14"
inkscape:label="blue-diamond"
transform="rotate(45,-14.34779,124.80922)"
style="display:inline">
<rect
style="display:inline;fill:#39b8ab;fill-opacity:1;stroke:none;stroke-width:1.54489;stroke-dasharray:none;stroke-opacity:1"
id="rect1-6-0"
width="64"
height="64"
x="106.79844"
y="83.090195"
inkscape:label="big-trim" />
<rect
style="display:inline;fill:#47edda;fill-opacity:1;stroke:none;stroke-width:1.44834;stroke-dasharray:none;stroke-opacity:1"
id="rect1-6"
width="60"
height="60"
x="108.79844"
y="85.090195"
inkscape:label="big-fill" />
<rect
style="display:inline;fill:#39b8ab;fill-opacity:1;stroke:none;stroke-width:1.5831;stroke-dasharray:none;stroke-opacity:1"
id="rect1-6-7-0"
width="32"
height="32.000004"
x="122.79843"
y="99.090187"
inkscape:label="small-trim" />
<rect
style="display:inline;fill:#47edda;fill-opacity:1;stroke:none;stroke-width:1.3852;stroke-dasharray:none;stroke-opacity:1"
id="rect1-6-7"
width="28"
height="28.000002"
x="124.79843"
y="101.09019"
inkscape:label="small-fill" />
<path
style="fill:none;fill-opacity:1;stroke:#39b8ab;stroke-width:2.2595;stroke-dasharray:none;stroke-opacity:1"
d="m 108.89019,85.181941 15.81649,15.816499"
id="path11"
inkscape:label="top-trim" />
<path
style="fill:none;fill-opacity:1;stroke:#39b8ab;stroke-width:2.16775;stroke-dasharray:none;stroke-opacity:1"
d="M 153.56485,100.32377 168.73912,85.149504"
id="path12"
inkscape:label="right-trim" />
<path
style="fill:none;fill-opacity:1;stroke:#39b8ab;stroke-width:1.77778;stroke-dasharray:none;stroke-opacity:1"
d="m 152.79843,129.09019 16,16"
id="path13"
inkscape:label="bottom-trim" />
<path
style="fill:none;fill-opacity:1;stroke:#39b8ab;stroke-width:2;stroke-dasharray:none;stroke-opacity:1"
d="m 124.79843,129.09019 -15.99999,16"
id="path14"
inkscape:label="left-trim" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -1,22 +0,0 @@
<html>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<head>
{templates.partials.default_css}
{templates.partials.header}
{templates.partials.nav}
</head>
<body>
<main>
<article>
<h1 class="headline">{metadata.title}</h1>
<p class="byline">
<address class="author">By <a rel="author" href="mailto:admin@jimlab.io">Jim Shepich III</a></address>
<br/>First published: <time pubdate datetime="{metadata.date}">{metadata.date}</time>
<br/>Last modified: <time pubdate datetime="{metadata.lastmod}">{metadata.lastmod}</time>
</p>
{content}
</article>
</main>
{templates.partials.footer}
</body>

View File

@ -0,0 +1,5 @@
<article>
<h1 class="headline">{site.title} Archive</h1>
<hr />
{content}
</article>

View File

@ -1 +1 @@
<a href='tags/{tag_name}.html'><span class='blog-tag'>{tag_name}</span></a> <a href='{site.base_url}/tags/{tag_name}.html'><span class='blog-tag'>{tag_name}</span></a>

View File

@ -0,0 +1,5 @@
<article>
<h1 class="headline">{metadata.title}</h1>
<hr />
{content}
</article>

View File

@ -5,6 +5,8 @@
{templates.partials.default_css} {templates.partials.default_css}
{templates.partials.header} {templates.partials.header}
{templates.partials.nav} {templates.partials.nav}
<title>{site.title}</title>
<link rel="icon" type="image/svg" href="/assets/img/favicon.svg">
</head> </head>
<body> <body>
<main> <main>

View File

@ -1,5 +1,5 @@
<nav id="main-navbar" class="no-highlight"> <nav id="main-navbar" class="no-highlight">
<a href='/home.html' target='_self'><div class='nav-tab'><span class='nav-text'>Home</span></div></a> <a href='/home.html' target='_self'><div class='nav-tab'><span class='nav-text'>Home</span></div></a>
<a href='/shepich_resume.pdf' target='_blank'><div class='nav-tab'><span class='nav-text'>Resume</span></div></a> <a href='/shepich_resume.pdf' target='_blank'><div class='nav-tab'><span class='nav-text'>Resume</span></div></a>
<a href='/dogma-jimfinium/index.html' target='_self'><div class='nav-tab'><span class='nav-text'>Dogma Jimfinium</span></div></a> <a href='/dogma-jimfinium/archive.html' target='_self'><div class='nav-tab'><span class='nav-text'>Dogma Jimfinium</span></div></a>
</nav> </nav>

View File

@ -1,14 +0,0 @@
<html>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<head>
{templates.partials.default_css}
{templates.partials.header}
{templates.partials.nav}
</head>
<body>
<main>
{content}
</main>
{templates.partials.footer}
</body>

View File

@ -25,6 +25,14 @@
"from jimsite import *" "from jimsite import *"
] ]
}, },
{
"cell_type": "code",
"execution_count": null,
"id": "68b107f1",
"metadata": {},
"outputs": [],
"source": []
},
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 3, "execution_count": 3,
@ -120,13 +128,74 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 10, "execution_count": 4,
"id": "a28b95a6", "id": "a28b95a6",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [
{
"ename": "NameError",
"evalue": "name 'build_site' is not defined",
"output_type": "error",
"traceback": [
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
"\u001b[31mNameError\u001b[39m Traceback (most recent call last)",
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[4]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m \u001b[43mbuild_site\u001b[49m(sites[\u001b[33m'\u001b[39m\u001b[33mresume\u001b[39m\u001b[33m'\u001b[39m])\n",
"\u001b[31mNameError\u001b[39m: name 'build_site' is not defined"
]
}
],
"source": [ "source": [
"build_site(sites['resume'])" "build_site(sites['resume'])"
] ]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "60378277",
"metadata": {},
"outputs": [],
"source": [
"import inspect"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cd7fb9f3",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"DotMap(a=DotMap(b=DotMap(c=42)))\n",
"a\n",
"DotMap(b=DotMap(c=42))\n",
"b\n",
"DotMap(c=42)\n",
"c\n"
]
},
{
"data": {
"text/plain": [
"42"
]
},
"execution_count": 29,
"metadata": {},
"output_type": "execute_result"
}
],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "fcd2faf5",
"metadata": {},
"outputs": [],
"source": []
} }
], ],
"metadata": { "metadata": {