Formalized Article model; added basic logging

This commit is contained in:
Jim Shepich III 2026-02-01 17:05:08 -05:00
parent f933e47a04
commit f4433ac188
13 changed files with 144 additions and 50 deletions

View File

@ -24,7 +24,8 @@ sites:
dogma_jimfinium:
title: Dogma Jimfinium
base_url: http://localhost:8080/dogma-jimfinium
description: May it bolster the skills of all who read it.
base_url: http://localhost:8000/dogma-jimfinium
git_repo: ssh://gitea/jim/dogma-jimfinium.git
build_cache: ./build/dogma-jimfinium
web_root: ./dist/dogma-jimfinium
@ -32,4 +33,6 @@ sites:
- assets
articles:
- '*.md'
addons:
- rss

View File

@ -9,6 +9,10 @@ import pydantic
from typing import Optional
from datetime import datetime, date
from dotmap import DotMap
import logging
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
from .common import filepath_or_string, GlobalVars, SiteConfig, dotmap_access
from .templating import format_html_template, map_templates, TemplateSelections
@ -25,45 +29,62 @@ from .blog import build_blog_archive, build_rss_feed
def build_site(site: SiteConfig, templates: DotMap):
logger.info(f'Building site "{site.title}".')
# Do not build a site marked as unpublished.
if not site.published:
logger.info(f'"{site.title}" is not published. Skipping.')
return None
# Initialize the build cache and web root, in case they do not exist.
logger.debug(f'Creating build cache: {site.build_cache}')
os.makedirs(site.build_cache, exist_ok = True)
logger.debug(f'Creating web root: {site.web_root}')
os.makedirs(site.web_root, exist_ok = True)
# If the site is built from a git repo, pull that repo into the build cache.
if site.git_repo:
logger.info(f'Cloning/pulling git repo from {site.git_repo}')
pull_git_repo(site.git_repo, site.build_cache)
# Copy the sites assets into the web root.
logger.info(f'Copying static assets.')
copy_assets(site)
# Load the site's articles into an index.
logger.info('Building index of articles.')
index = build_index(site)
# Determine which templates are to be used for explicit applications, e.g.
# the tag component.
logger.info('Loading selected templates.')
template_selections = TemplateSelections(site, templates)
# Generate HTML pages for the articles.
logger.info('Building articles.')
build_articles(site, index, templates, template_selections)
if len(site.articles or []):
logger.info('Building archive of articles.')
build_blog_archive(site, index, template_selections, templates = templates)
if 'rss' in (site.addons or []):
logger.info('Building RSS feed.')
build_rss_feed(site, index)
else:
logger.debug('Addon "rss" not elected.')
def main():
logger.info('Loading config.')
with open('/home/jim/projects/shepich.com/config.yaml', 'r') as config_file:
config = yaml.safe_load(config_file.read())
logger.info('Loading global templates.')
templates = map_templates(config['templates_folder'])
for site in config['sites'].values():
build_site(SiteConfig(**site), templates)
if __name__ == '__main__':
main()

View File

@ -18,7 +18,13 @@ class ArticleMetadata(pydantic.BaseModel):
author: Optional[str] = None
lastmod: Optional[date] = None
thumbnail: Optional[str] = None
description: Optional[str] = None
class Article(pydantic.BaseModel):
path: str
content: str
metadata: Optional[ArticleMetadata] = None
def load_markdown(md: str) -> tuple[ArticleMetadata|None, str]:
'''Loads a Markdown file into a (metadata: ArticleMetadata, content: str) pair.'''
@ -42,9 +48,9 @@ def load_markdown(md: str) -> tuple[ArticleMetadata|None, str]:
return ArticleMetadata(**metadata), content
def build_index(site: SiteConfig) -> dict:
'''Loads the sites articles into an index mapping the filename stem
to a (metadata: dict, content: str) tuple.'''
def build_index(site: SiteConfig) -> dict[str, Article]:
'''Loads the sites articles into an index mapping the filename
to an Article object.'''
index = {}
@ -53,19 +59,31 @@ def build_index(site: SiteConfig) -> dict:
for a in site.articles or {}:
expanded_article_list.extend(
# 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.removeprefix('./').lstrip("/")}')
)
for article in expanded_article_list:
metadata, content = load_markdown(article)
for article_full_path in expanded_article_list:
metadata, content = load_markdown(article_full_path)
# Skip unpublished articles.
if not metadata.published:
continue
article_filestem = os.path.splitext(os.path.basename(article))[0]
index[article_filestem] = (metadata, content)
# Construct the article's path for the index by discarding the build cache
# and replacing .md with .html.
# article_path = article_full_path\
# .removeprefix(site.build_cache)\
# .lstrip('/')\
# .replace('.md','.html')
# TODO: add tolerance for a hierarchical article directory structure.
article_path = os.path.basename(article_full_path).replace('.md', '.html')
index[article_path] = Article(
path = article_path,
content = content,
metadata = metadata
)
return index
@ -79,34 +97,33 @@ def format_article_tags(tags: list[str], tag_template, **kwargs) -> list[str]:
def build_articles(
site: SiteConfig,
index: dict[str, tuple[ArticleMetadata, str]],
index: dict[str, Article],
templates: DotMap,
template_selections: TemplateSelections
):
'''Generates HTML files for all of a given site's Markdown articles
by interpolating the contents and metadata into the HTML templates.'''
for filestem, (metadata, content) in index.items():
article = format_html_template(
for article in index.values():
article_html = format_html_template(
template_selections['article'],
content = content,
article = article,
blog_tags = ' '.join(format_article_tags(
metadata.tags, template_selections['tag'], site = site
article.metadata.tags, template_selections['tag'], site = site
)),
metadata = metadata,
templates = templates,
site = site
)
page = format_html_template(
page_html = format_html_template(
template_selections['page'],
content = article,
content = article_html,
templates = templates,
site = site
)
with open(f'{site.web_root.rstrip('/')}/{filestem}.html', 'w') as f:
f.write(page)
with open(f'{site.web_root.rstrip('/')}/{article.path}', 'w') as f:
f.write(page_html)

View File

@ -1,15 +1,15 @@
import rfeed
import datetime
from datetime import datetime
from .common import SiteConfig
from .articles import ArticleMetadata, format_article_tags
from .articles import ArticleMetadata, Article, format_article_tags
from .templating import format_html_template, TemplateSelections
def build_blog_archive(
site: SiteConfig,
index: dict[str, tuple[str, str]],
index: dict[str, Article],
template_selections: TemplateSelections,
**kwargs
) -> str:
@ -22,14 +22,14 @@ def build_blog_archive(
# Add each article as a list item to an unordered list.
archive_html_list = '<ul>'
for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0].date)[::-1]:
for article in sorted(index.values(), key = lambda a: a.metadata.date)[::-1]:
# Generate HTML for the article (including metadata tags).
archive_html_list += format_html_template(
template_selections['archive_li'],
article_filestem = article,
blog_tags = ' '.join(format_article_tags(metadata.tags, template_selections['tag'], site = site)),
metadata = metadata,
blog_tags = ' '.join(format_article_tags(article.metadata.tags, template_selections['tag'], site = site)),
article = article,
site = site,
**kwargs
)
@ -55,19 +55,28 @@ def build_blog_archive(
f.write(archive_html_page)
def build_tag_reference(index: dict[str, tuple[str, str]]):
def build_tag_index(index: dict[str, Article]) -> dict[str, list[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
# def build_tag_inventory(
# site: SiteConfig,
# index: dict[str, tuple[str, str]],
# tag_index: dict[str, list[str]],
# template_selections: TemplateSelections
# ):
# tag_
# TODO: Finish
def build_rss_feed(site: SiteConfig, index: dict[str, tuple[ArticleMetadata, str]]):
def build_rss_feed(site: SiteConfig, index: dict[str, Article]):
feed = rfeed.Feed(
title = site.title,
link = f'{site.base_url.rstrip('/')}/rss.xml',
@ -76,15 +85,20 @@ def build_rss_feed(site: SiteConfig, index: dict[str, tuple[ArticleMetadata, str
lastBuildDate = datetime.now(),
items = [
rfeed.Item(
title = metadata.title,
link = f'{site.base_url.rstrip('/')}/{filestem}.md',
description = metadata.description,
author = metadata.author,
guid = rfeed.Guid(filestem),
pubDate = datetime(metadata.date.year, metadata.date.month, metadata.date.day)
title = article.metadata.title,
link = f'{site.base_url.rstrip('/')}/{article.path}',
description = article.metadata.description,
author = article.metadata.author,
guid = rfeed.Guid(article.path),
pubDate = datetime(
article.metadata.date.year,
article.metadata.date.month,
article.metadata.date.day
)
)
for filestem, (metadata, _) in index.items()
for article in index.values()
]
)
# print(rss_feed.rss())
with open(f'{site.web_root.rstrip('/')}/rss.xml', 'w') as f:
f.write(feed.rss())

View File

@ -31,11 +31,13 @@ class SiteConfig(pydantic.BaseModel):
web_root: str
build_cache: str
title: str
description: Optional[str] = None
published: Optional[bool] = True
git_repo: Optional[str] = None
assets: Optional[list] = None
articles: Optional[list] = None
template_selections: Optional[dict] = {}
addons: Optional[list] = None
def get_var_names(var):

View File

@ -169,7 +169,14 @@ a:has(> span.blog-tag){
font-weight: unset;
}
article > hr{
border: 0.1rem solid var(--silver);
article hr{
border-bottom: none;
border-color: var(--silver);
border-width: 0.1rem;
box-shadow: none;
}
img.rss-icon{
width: 1rem;
vertical-align: middle;
}

18
site/assets/img/rss.svg Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="128px" height="128px" id="RSSicon" viewBox="0 0 256 256">
<defs>
<linearGradient x1="0.085" y1="0.085" x2="0.915" y2="0.915" id="RSSg">
<stop offset="0.0" stop-color="#E3702D"/><stop offset="0.1071" stop-color="#EA7D31"/>
<stop offset="0.3503" stop-color="#F69537"/><stop offset="0.5" stop-color="#FB9E3A"/>
<stop offset="0.7016" stop-color="#EA7C31"/><stop offset="0.8866" stop-color="#DE642B"/>
<stop offset="1.0" stop-color="#D95B29"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="55" ry="55" x="0" y="0" fill="#CC5D15"/>
<rect width="246" height="246" rx="50" ry="50" x="5" y="5" fill="#F49C52"/>
<rect width="236" height="236" rx="47" ry="47" x="10" y="10" fill="url(#RSSg)"/>
<circle cx="68" cy="189" r="24" fill="#FFF"/>
<path d="M160 213h-34a82 82 0 0 0 -82 -82v-34a116 116 0 0 1 116 116z" fill="#FFF"/>
<path d="M184 213A140 140 0 0 0 44 73 V 38a175 175 0 0 1 175 175z" fill="#FFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1 +1 @@
<li>{metadata.date} - <a href='{article_filestem}.html'>{metadata.title}</a> {blog_tags}</li>
<li>{article.metadata.date} - <a href='{article.path}'>{article.metadata.title}</a> {blog_tags}</li>

View File

@ -1,11 +1,11 @@
<article>
<h1 class="headline">{metadata.title}</h1>
<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>
<br/>First published: <time pubdate datetime="{metadata.date}">{metadata.date}</time>
<br/>Last modified: <time pubdate datetime="{metadata.lastmod}">{metadata.lastmod}</time>
<br/>First published: <time pubdate datetime="{article.metadata.date}">{article.metadata.date}</time>
<br/>Last modified: <time pubdate datetime="{article.metadata.lastmod}">{article.metadata.lastmod}</time>
</p>
{content}
{article.content}
<br /><hr /><br />
<p>{blog_tags}</p>
<p>Tags: {blog_tags}<span>{templates.components.rss_icon}</span></p>
</article>

View File

@ -1 +1 @@
<a href='{site.base_url}/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' data="{tag_name}">{tag_name}</span></a>

View File

@ -0,0 +1,11 @@
<article>
<h1 class="headline">{site.title} Tag Reference</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="{article.metadata.date}">{article.metadata.date}</time>
<br/>Last modified: <time pubdate datetime="{article.metadata.lastmod}">{article.metadata.lastmod}</time>
</p>
{article.content}
<br /><hr /><br />
<p>{blog_tags}</p>
</article>

View File

@ -0,0 +1 @@
<a href="{site.base_url}/rss.xml"><img class='rss-icon' src='/assets/img/rss.svg' /></a>

View File

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