+ {content}
+
+ {partials__footer}
+
\ No newline at end of file
diff --git a/templates/partials/blog_article.html b/templates/partials/blog_article.html
new file mode 100644
index 0000000..e69de29
diff --git a/templates/partials/default_css.html b/templates/partials/default_css.html
new file mode 100644
index 0000000..0bc0f4c
--- /dev/null
+++ b/templates/partials/default_css.html
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/templates/partials/footer.html b/templates/partials/footer.html
new file mode 100644
index 0000000..4764231
--- /dev/null
+++ b/templates/partials/footer.html
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/templates/partials/header.html b/templates/partials/header.html
new file mode 100644
index 0000000..45c5c32
--- /dev/null
+++ b/templates/partials/header.html
@@ -0,0 +1,3 @@
+
+ Jimlab
+
\ No newline at end of file
diff --git a/templates/partials/nav.html b/templates/partials/nav.html
new file mode 100644
index 0000000..9e34918
--- /dev/null
+++ b/templates/partials/nav.html
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/testbench.ipynb b/testbench.ipynb
new file mode 100644
index 0000000..9269cad
--- /dev/null
+++ b/testbench.ipynb
@@ -0,0 +1,142 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "dda60de8",
+ "metadata": {},
+ "source": [
+ "## Roadmap\n",
+ "\n",
+ "- [x] Load markdown\n",
+ "- [] Determine static website structure\n",
+ " - Where to put assets for subsites like dogma jimfinium\n",
+ " - How to otherwise organize pages\n",
+ "- [] Resolve markdown links\n",
+ "- [] Consider separating article templates and overall page templates\n",
+ "- [] RSS feed\n",
+ "\n",
+ "\n",
+ "WEBROOT\n",
+ "- assets\n",
+ "- main pages\n",
+ "- resume\n",
+ "- dogma-jimfinium/\n",
+ " - assets/\n",
+ " - pages"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "207d2510",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "import markdown\n",
+ "import yaml\n",
+ "import subprocess\n",
+ "\n",
+ "\n",
+ "from datetime import datetime\n",
+ "from main import *"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "d2361c42",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "metadata, content = load_markdown('tmp/dogma-jimfinium/blowouts.md')\n",
+ "content = content.replace('src=\"assets', 'src=\"../tmp/dogma-jimfinium/assets')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "ed7b3b2f",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "PARTIALS = load_partials()\n",
+ "html = format_html_template('templates/default.html', content = content, **{'metadata__'+k:v for k,v in metadata.items()}, **PARTIALS)\n",
+ "with open('dist/home.html', 'w') as f:\n",
+ " f.write(html)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b8c87620",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "57383c24",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "2025-12-01\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "CompletedProcess(args=['cp', 'build/resume/2025-12-01/shepich_resume.pdf', 'dist/shepich_resume.pdf'], returncode=0, stdout=b'', stderr=b'')"
+ ]
+ },
+ "execution_count": 170,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "def get_latest_resume():\n",
+ " max_date = '0000-00-00'\n",
+ " for resume_folder in os.listdir('build/resume'):\n",
+ " try:\n",
+ " datetime.strptime(resume_folder,'%Y-%m-%d')\n",
+ " except Exception:\n",
+ " continue\n",
+ " \n",
+ " if resume_folder > max_date:\n",
+ " max_date = resume_folder\n",
+ " \n",
+ "\n",
+ " print(max_date)\n",
+ " # print(max_date.strftime('%Y-%m-%d'))\n",
+ " \n",
+ " run(f'cp build/resume/{max_date}/shepich_resume.pdf dist/shepich_resume.pdf')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": ".venv",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
From 006aeab2c14f8c99e064d748d7da66c0b91d506d Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Thu, 29 Jan 2026 10:09:32 -0500
Subject: [PATCH 03/20] Added blog archive
---
assets/css/theme.css | 28 +++++
.../partials/blog_article.html => config.yaml | 0
main.py | 34 +++++-
templates/{default.html => blog_post.html} | 5 +-
templates/components/blog_archive_li.html | 1 +
templates/components/blog_article.html | 11 ++
templates/components/blog_tag.html | 1 +
templates/partials/nav.html | 3 +-
templates/simple.html | 14 +++
testbench.ipynb | 107 ++++++++++++++----
10 files changed, 180 insertions(+), 24 deletions(-)
rename templates/partials/blog_article.html => config.yaml (100%)
rename templates/{default.html => blog_post.html} (96%)
create mode 100644 templates/components/blog_archive_li.html
create mode 100644 templates/components/blog_article.html
create mode 100644 templates/components/blog_tag.html
create mode 100644 templates/simple.html
diff --git a/assets/css/theme.css b/assets/css/theme.css
index cc1e447..c66247f 100644
--- a/assets/css/theme.css
+++ b/assets/css/theme.css
@@ -145,3 +145,31 @@ summary.heading{
-webkit-tap-highlight-color: rgba(0,0,0,0);
-webkit-tap-highlight-color: transparent;
}
+
+span.blog-tag{
+ font-weight: bold;
+ border-radius: 3px 3px 3px 3px;
+ background-color: var(--azure);
+ color: white;
+ font-size: 0.6em;
+ padding: 0.1em;
+ padding-left: 0.5em;
+ padding-right: 0.5em;
+ vertical-align: middle;
+}
+
+span.blog-tag:hover{
+ background-color: var(--azure-tint-20);
+}
+
+a:has(> span.blog-tag){
+ vertical-align: middle;
+ color: unset;
+ text-decoration: unset;
+ font-weight: unset;
+}
+
+article > hr{
+ border: 0.1rem solid var(--silver);
+ box-shadow: none;
+}
\ No newline at end of file
diff --git a/templates/partials/blog_article.html b/config.yaml
similarity index 100%
rename from templates/partials/blog_article.html
rename to config.yaml
diff --git a/main.py b/main.py
index cb46608..23ffae5 100644
--- a/main.py
+++ b/main.py
@@ -76,4 +76,36 @@ def load_partials() -> dict:
partial_template,
current_year = datetime.now().year
)
- return partials
\ No newline at end of file
+ return partials
+
+
+def import_resume():
+
+ # Use a sentinel value for the loop.
+ max_date = '0000-00-00'
+
+ # Loop through the folders in the resume repo to find the most recent one.
+ for resume_folder in os.listdir('build/resume'):
+
+ # Skip folders that are not in YYYY-MM-DD format.
+ try:
+ datetime.strptime(resume_folder,'%Y-%m-%d')
+ except Exception:
+ continue
+
+ # Keep track of the most recent date.
+ if resume_folder > max_date:
+ max_date = resume_folder
+
+ # Copy the resume into the /dist directory.
+ run(f'cp build/resume/{max_date}/shepich_resume.pdf dist/shepich_resume.pdf')
+
+
+def format_blog_tags(tags: list[str], template = 'templates/components/blog_tag.html') -> list[str]:
+ '''Generates HTML blog tag components from a list of tag names.'''
+ return [
+ format_html_template(template, tag_name = t) for t in tags
+ ]
+
+if __name__ == '__main__':
+ pass
\ No newline at end of file
diff --git a/templates/default.html b/templates/blog_post.html
similarity index 96%
rename from templates/default.html
rename to templates/blog_post.html
index 3cc4d24..227fdfd 100644
--- a/templates/default.html
+++ b/templates/blog_post.html
@@ -1,5 +1,4 @@
-
@@ -7,9 +6,8 @@
{partials__header}
{partials__nav}
-
-
+
{metadata__title}
@@ -19,5 +17,6 @@
{content}
+
{partials__footer}
\ No newline at end of file
diff --git a/templates/components/blog_archive_li.html b/templates/components/blog_archive_li.html
new file mode 100644
index 0000000..a06da53
--- /dev/null
+++ b/templates/components/blog_archive_li.html
@@ -0,0 +1 @@
+
'\n",
+ "\n",
+ " index_html_page = format_html_template(\n",
+ " template, \n",
+ " content = index_html_content, \n",
+ " **kwargs,\n",
+ " **PARTIALS\n",
+ " )\n",
+ " \n",
+ " return index_html_page"
]
}
],
From d56569668d6709610168e4269b7d0368745aeb26 Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Fri, 30 Jan 2026 10:02:09 -0500
Subject: [PATCH 04/20] Working on config and Pydantic types
---
.gitignore | 3 +-
__pycache__/main.cpython-312.pyc | Bin 3854 -> 0 bytes
config.yaml | 30 ++++
main.py | 71 +++++++-
pages/home.html | 8 -
pages/home.md | 10 ++
requirements.txt | 3 +-
templates/blog_post.html | 6 +-
templates/components/blog_archive_li.html | 2 +-
templates/components/blog_article.html | 8 +-
templates/pages/default.html | 14 ++
testbench.ipynb | 203 +++++++++++++++++-----
12 files changed, 295 insertions(+), 63 deletions(-)
delete mode 100644 __pycache__/main.cpython-312.pyc
delete mode 100644 pages/home.html
create mode 100644 pages/home.md
create mode 100644 templates/pages/default.html
diff --git a/.gitignore b/.gitignore
index 87a67e9..d9d0210 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,5 @@ shepich resume.pdf
**/.env
tmp
build
-dist
\ No newline at end of file
+dist
+**/__pycache__
\ No newline at end of file
diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc
deleted file mode 100644
index 590ae1839a37bb4c5921b88943450ad4bf9aedd1..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 3854
zcmahMS!^4}b!HE8c}dh^w~l3MUDXF2i?pRAb`@2wRi{4c*iNjbjo_A8ahKB4B$t?7
zS`tCI3=qIFV$?JeKr~##3{oHp?2mk=K!KwD?GITxkg!&P0L@SS=*WNp{j_hE+@&lF
z?GQW1doyp|yqo_B1iT2^{Pn!b!tCF4VK-N6vi=E7mXM5OoI;b$5l>=k%}g@Z$WF2_
zGAS<2O>!8?tisEj!Y4SH|B#z>$u4W{Rs?`CyMP{G+<@`O0BP;?z}hQ&W#2S2>685*
zqDj9TPy#Y1W7ypR*bdk!BpA69=AH969Q=;5cp;2UL6!`~P}7Qub<;%@BS$iEOtGy-
zAH%}8z6{+GGFo7Sl8iHTCQ06zI+Fr=*rG@F8*Uq10HHi=QFt9K-~vv1Y+QSM*qX)}
z+4|WhFuIHi@C~4&NXN4bM(-mGzjLT+0|r#?VDAAxffd
z5H&L`stHjwL{$e!LQN@I$(T7TYOtNms=6XJCDAZZ}=3PLG|e{@DmE77Eyj%JCLRN{sn)n}Bf8lQ>8wRAKsshLQ2
z-t4xyh-oC&ypePQ2`VxDG;~Gut=n^B_Ji3Q*FU&kk{-K%xEAOt?JZsY=t=`&Pha_+
z)t=s^H}1Ug@$u&@te;X~!-axfi^6j^om0Su?gLtnf+q#`Zvua^%WX1g`2~ej{w;-z
zpf*srV+?IYCMkYPvsJYn4piNuvK`hwxd0|nK-=wTpTC9)r-JGe3^jo5%&oom3TR*f
zFEDMROA1?{?6zHi`lm=6>7a)LlxEB(
zNp=H_64h@&S47VQz_qyTz~OO>CI8gw+^fzQ!tctHU**T1yiN3p(H=b@p|m0mY;&57!I1Eu@E^cL3Qbl1<}3sXlGW%%x_C_@sx*~?nVwbRYC=_H
zQPRb$%KWTGU`nQQX)COFsX#4#(`YlTUQ;sSnTfa0i#F4dFThZm>UAYFl9r4xOZEUK
zrt9h~(1Cz5W293$bt^|Vz0I95iiz2j$N+5F51)PrI#7`p1%FgIXo(R-?ccq4{Pu~P
zCu+VQm*0b~c!~mp#b1&>*q(ny*Jnne(P`CCBr75jMTa*;0FWQpij%eJv@{5DE1_o8
zT$*lrN%)lE^+o753x7Ld|36>dlveEQ2~_R&zp;;)hm;R!;E(Ku4J?Kk)1@1-mNTqT
zAtcP1UOhJj5jd{sy2+AUhV;M*le={G(p!)R*?3ykX^?7q#)dGOlG0PMG)4{s@Hl)r
z6?+kVa%d@XCsOJ8eAlPDmWMwJ{pHYKqkoP*p7`C$M7%Z;f7~q>ktJ=DCrVb+UyvCM
z=9bJhV6p^;c?$jZ8jVsA`Eb&Sw18|i*@-zaOSAvB(UG&`QXY0dymOLpJ7QsEIC9-C
z3MQS%XWtBwk-3Lt{=UNj2^s+#E4wVL`#x*mjnS>dCcIF
zcQ55qDP7EEY5f6dR)dTS(gHWKQub6%P00{33FJ$S%tK}-Vmzf~=++q}O|v(Q^P;R}
z4j7^`r|L!|@3KxsH*oa3|3J%gmSOWzYjA`8ii1BF^L{Z4R|7$HTFvCW6l_rz$v$}9
zg|OQ)4AKn?lg~n}U=W%%2rW-cmvxm+!mt1z9;7fx{8`QPrnI<}iqXR0ML!NU-x!07
zV%8hU>u~BP@ac2V711}r9p57tKeWiL?$}e7A0+Q4mwWDM%Tu+`(Us7#TIkqUu6pQ1
zZO4g4@0t);Og|d9r+dnXkSQdF+d>`8!Jo%7@B*l`GZY(AWOq
zZvk+qJW>8dWpCwNWwaVR^0oiy(*WFN*=QUXaGGPB<6wwONJR^qlK*y2-A=V_*N}
z+%`R#p$5=49;opk$|mU@Tbh&pS=suvYdpiL2!FphHT$;^fTc|%NweCP_K347Le0r<
z3MDktZD~mw6SlN=fgro63({AO!gwpsfdju>gWkH*szKfPv1VzIr3qO|Iq5(b6GTf}
zc5cUYJ=)r&w~aoM?|vDb?|B&(rG>mdo+AX(QEXn3i0QS-#bSi!zOdIaV$+=hPXV_g
z)ZYn}q?J(!4N4}P(Pp9Ifb&gb7pxjYnKK}I3N}GgpoMBif`1JD7A$5}!9jcFFvtN>
zNM|q98a5en5|F3h(;q+wPSDv^JhRrjbLrfjbJhOgdhcuBBRB6|6rjkh_8wRkD*0;n
zP(3hAtJvW$3#>ts$Ytrj16uc4rIjUWlYjTE
zOBXL&5ya%EI;?nL$%FJ$GBj0NIli)@1t@KE_w1Hn+4Sf@i{rw{j*jAwAF*W&iSeBp16
diff --git a/config.yaml b/config.yaml
index e69de29..2ed6b33 100644
--- a/config.yaml
+++ b/config.yaml
@@ -0,0 +1,30 @@
+author: Jim Shepich III
+site_defaults:
+ base_url: http://localhost:8000
+ web_root: ./dist
+ templates:
+ partials: ./templates/partials
+ components: ./templates/components
+ pages: ./templates/pages
+sites:
+ main:
+ build_cache: ./build
+ assets:
+ - ./assets
+ articles:
+ - ./pages/*.md
+ resume:
+ git_repo: ssh://gitea/jim/resume.git
+ build_cache: ./build/resume
+ assets:
+ - '{build_cache}/shepich_resume.pdf'
+ dogma_jimfinium:
+ base_url: http://localhost:8080/dogma-jimfinium
+ git_repo: ssh://gitea/jim/dogma-jimfinium.git
+ build_cache: ./build/dogma-jimfinium
+ web_root: ./dist/dogma-jimfinium
+ assets:
+ - '{build_cache}/assets'
+ articles:
+ - '{build_cache}/*.md'
+
\ No newline at end of file
diff --git a/main.py b/main.py
index 23ffae5..3fca3d1 100644
--- a/main.py
+++ b/main.py
@@ -2,7 +2,9 @@ import os
import subprocess
import markdown
import yaml
-from datetime import datetime
+import pydantic
+from typing import Optional
+from datetime import datetime, date
def filepath_or_string(s: str) -> str:
'''Loads the contents of a string if it is a filepath, otherwise returns the string.'''
@@ -13,15 +15,37 @@ def filepath_or_string(s: str) -> str:
return s
-def load_markdown(md: str) -> tuple[dict, str]:
- '''Loads a Markdown file into a (metadata: dict, content: str) pair.'''
+with open('config.yaml', 'r') as config_file:
+ config = yaml.safe_load(config_file.read())
+
+class SiteConfig(pydantic.BaseModel):
+ base_url: Optional[str] = config['site_defaults'].get('base_url')
+ git_repo: Optional[str] = config['site_defaults'].get('git_repo')
+ build_cache: Optional[str] = config['site_defaults'].get('build_cache')
+ assets: Optional[list] = config['site_defaults'].get('assets')
+ web_root: Optional[str] = config['site_defaults'].get('web_root')
+ articles: Optional[list] = config['site_defaults'].get('articles')
+
+class ArticleMetadata(pydantic.BaseModel):
+ title: str
+ author: Optional[str] = config.get('author')
+ date: date
+ lastmod: Optional[date]
+ published: bool
+ tags: list
+ thumbnail: Optional[str] = None
+
+
+
+def load_markdown(md: str) -> tuple[ArticleMetadata|None, str]:
+ '''Loads a Markdown file into a (metadata: ArticleMetadata, content: str) pair.'''
# Load the file contents if a filepath is specified, and strip document delimiters ('---').
md = filepath_or_string(md).strip().strip('---').strip()
# If there is no `---` delimiter, then the article has no metadata.
if '---' not in md.strip('---'):
- return {}, md
+ return None, md
# Split the metadata from the contents.
[raw_metadata, raw_article] = md.split('---')
@@ -32,7 +56,7 @@ def load_markdown(md: str) -> tuple[dict, str]:
# Convert the contents to a HTML string.
content = markdown.markdown(raw_article)
- return metadata, content
+ return ArticleMetadata(**metadata), content
def format_html_template(template: str, **kwargs) -> str:
@@ -107,5 +131,42 @@ def format_blog_tags(tags: list[str], template = 'templates/components/blog_tag.
format_html_template(template, tag_name = t) for t in tags
]
+
+def build_blog_archive(
+ index: dict[str, tuple[str, str]],
+ page_template = 'templates/pages/default.html',
+ li_template = 'templates/components/blog_archive_li.html',
+ **kwargs
+ ) -> str:
+ '''Converts an index, formatted as filestem: (metadata, contents) dict,
+ into an HTML page containing the list of articles, sorted from newest to oldest.
+
+ Note: partials must be expanded into the kwargs, as they are needed to generate
+ the overall page.
+ '''
+
+ # Add each article as a list item to an unordered list.
+ archive_html_content = '
'
+ for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0]['date'])[::-1]:
+
+ # Generate HTML for the article (including metadata tags).
+ archive_html_content += format_html_template(
+ li_template,
+ article_filestem = article,
+ blog_tags = ' '.join(format_blog_tags(metadata.tags)),
+ metadata = metadata
+
+ )
+ archive_html_content +='
'
+
+ # Interpolate the article into the overall page template.
+ archive_html_page = format_html_template(
+ page_template,
+ content = archive_html_content,
+ **kwargs
+ )
+
+ return archive_html_page
+
if __name__ == '__main__':
pass
\ No newline at end of file
diff --git a/pages/home.html b/pages/home.html
deleted file mode 100644
index 8b05c10..0000000
--- a/pages/home.html
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
Welcome!
-
-
Welcome to my little corner of the Internet! My name is Jim Shepich (@epicshepich). I like to introduce myself as a jack-of-all-trades with a Master's in data science. My main interests include STEM, martial arts (especially jūjutsu), sci-fi and fantasy, tabletop and video gaming, cartoons and comics, and DIY, but I also love to branch out and learn new things! My dream is to become "The Most Interesting Man in the World."
-
-
This website started as a way for me to exercise and showcase my skills with vanilla HTML, JavaScript, CSS, and PHP. Now, it's just a matter of filling the site with content! My vision for this website is for it to serve as a gift to my future reincarnated self (if that's what happens) — a way to pass on the little tips and tricks, bits of wisdom, resources, and treasures I've accumulated over the course of my life (think new game plus). And if some other kindred spirits find something here that helps them on their journey, that's a bonus!
-
-
diff --git a/pages/home.md b/pages/home.md
new file mode 100644
index 0000000..588b968
--- /dev/null
+++ b/pages/home.md
@@ -0,0 +1,10 @@
+---
+title: Welcome
+date: 2026-01-30
+published: true
+tags: []
+---
+
+Welcome to my little corner of the Internet! My name is Jim Shepich (@epicshepich). I like to introduce myself as a jack-of-all-trades with a Master's in data science. My main interests include STEM, martial arts (especially jūjutsu), sci-fi and fantasy, tabletop and video gaming, cartoons and comics, and DIY, but I also love to branch out and learn new things! My dream is to become "The Most Interesting Man in the World."
+
+This website started as a way for me to exercise and showcase my skills with vanilla HTML, JavaScript, CSS, and PHP. Now, it's just a matter of filling the site with content! My vision for this website is for it to serve as a gift to my future reincarnated self (if that's what happens) — a way to pass on the little tips and tricks, bits of wisdom, resources, and treasures I've accumulated over the course of my life (think _new game plus_). And if some other kindred spirits find something here that helps them on their journey, that's a bonus!
diff --git a/requirements.txt b/requirements.txt
index 735541d..f9c7c46 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
ipykernel
markdown
-pyyaml
\ No newline at end of file
+pyyaml
+rfeed
\ No newline at end of file
diff --git a/templates/blog_post.html b/templates/blog_post.html
index 227fdfd..3a7c584 100644
--- a/templates/blog_post.html
+++ b/templates/blog_post.html
@@ -9,11 +9,11 @@
-
{metadata__title}
+
{metadata.title}
By Jim Shepich III
- First published:
- Last modified:
+ First published:
+ Last modified:
{content}
diff --git a/templates/components/blog_archive_li.html b/templates/components/blog_archive_li.html
index a06da53..4fe2927 100644
--- a/templates/components/blog_archive_li.html
+++ b/templates/components/blog_archive_li.html
@@ -1 +1 @@
-
\u001b[39m\u001b[33m'\u001b[39m\n",
+ "\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/main.py:150\u001b[39m, in \u001b[36mbuild_blog_archive..\u001b[39m\u001b[34m(item)\u001b[39m\n\u001b[32m 148\u001b[39m \u001b[38;5;66;03m# Add each article as a list item to an unordered list.\u001b[39;00m\n\u001b[32m 149\u001b[39m archive_html_content = \u001b[33m'\u001b[39m\u001b[33m
\u001b[39m\u001b[33m'\u001b[39m\n",
- "\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/main.py:150\u001b[39m, in \u001b[36mbuild_blog_archive..\u001b[39m\u001b[34m(item)\u001b[39m\n\u001b[32m 148\u001b[39m \u001b[38;5;66;03m# Add each article as a list item to an unordered list.\u001b[39;00m\n\u001b[32m 149\u001b[39m archive_html_content = \u001b[33m'\u001b[39m\u001b[33m
The \"Superlock\" knot is the way I tie my obi. I prefer it because it keeps the ends pointing down, can be cinched back up without having to start from scratch, and avoids an unsightly mess behind your back.
As a homeowner, I have made it an ongoing goal to adopt sustainable and eco-friendly practices. As with all new undertakings, start with the small things and work your way up.
\\n
Before we get started, I do have to add one caveat. Disposable goods do have some legitimate use-cases, like sanitization and keeping things sterile (e.g. disposable needles, disinfectant wipes, toilet paper, etc).
\\n
In roughly increasing order of how difficult it was for me to build these habits, here are some of the things I do.
\\n
Cultivate a distaste for disposable products, especially single-use plastics
\\n
Buy a thing of Milano cookies and see that the cookies are separated with plastic risers into tiers, and how each tier is wrapped in plastic. The damn bag is already sealed!
\\n
Buy a pack of paper towel rolls, and notice that each roll is individually wrapped in plastic, inside the overall plastic-wrapped pack.
\\n
Just think about how pointless and wasteful all this single-use plastic is. Imagine a turtle swallowing a piece of this plastic and choking to death.
\\n
Now think about how much money you spend on paper towel. Think about how much money you spend on bottles of cleaning spray, and how much waste they generate.
\\n
This is your first step.
\\n
When you opt for reusable things instead of disposable things, it\\'s an investment that is both eco-friendly and financially rewarding.
\\n
You can\\'t always control how things are packaged, but if you develop this anti-disposable mindset, it will nudge you towards choosing better products.
\\n
Use natural lighting during the day
\\n
If you have LEDs, this won\\'t save much energy, but every little bit counts. And sunlight is good for the soul.
\\n
Buy in bulk
\\n
As containers grow in size, the surface-area-to-volume ratio typically decreases. In other words, you\\'ll generate much less waste buying a gallon jug of dishsoap than you would buying 4x 32oz bottles.
\\n
Typically, unit prices also go down when you buy larger containers or multi-packs, so it\\'s a win-win.
\\n
Use powder laundry detergent
\\n
Powder detergent is much cheaper than liquid detergent, and you need much less of it to wash your clothes. When I moved here a hear and a half ago, I bought a 16.5lb drum of Arm & Hammer powder detergent from Home Depot for a little over $30. I\\'m not even halfway through it.
\\n
Liquid detergents also sometimes contain byproducts of petroleum cracking.
\\n
Mix your own cleaning sprays
\\n
Blueland sells tablets that you can dissolve in water to make your own cleaning spray. They also sell volumetric reusable spray bottles, which you can use to easily ensure your cleaning solution is made to the correct concentration.
\\n
As of the time of writing, the tablets are around $2 unit price. Most cleaning sprays in disposable bottles cost at least twice as much and generate a bunch of plastic waste. Blueland uses compostable packaging.
\\n
Don\\'t use garbageware
\\n
Stop using garbageware (disposable utensils, plates, cups, etc). If you have a dishwasher, just use it. If you are hosting company and are overwhelmed by dishes, conscript someone to help you; most people are eager to be good guests. It\\'s just not worth generating so much waste. If you\\'re afraid of people breaking your fine china, get reusable plastic dishes. If someone serves you with garbageware, act offended. Cultural attitudes need to change in order for us to make progress.
\\n
If you get a paper cup at a restaurant, try to go without a straw or lid. If you are getting carryout, ask for no plasticware (or better yet, just don\\'t get carryout).
\\n
Use cloth towels instead of paper towels
\\n
Cleaning up after my son as he learns how to feed himself, I would go through a roll of paper towel in less than a week. So wasteful and so expensive. So, I bought these Mioeco-brand \"reusable paper towels\". People said that they\\'re a scam and just to use regular rags, but I did not have rags, and these seemed like they would have a good texture (they do).
\\n
I use these to wipe up his crumbs. When one gets too crumby, I rinse it off in the sink, wring it out, and use it again. Once I feel like it\\'s too gross, I throw it in a bucket I keep on my countertop. When I run low, I wash them.
\\n
Likewise, I use cloth napkins instead of paper napkins or paper towels. I got separate cloth napkins because the \"reusable paper towels\" have a nice rough texture for wiping hard surfaces, not skin.
\\n
Use a water flosser
\\n
When I first started trying to reduce single-use plastics, I switched from disposable floss picks to a reusable floss pick, which uses spools from floss cartridges. This is much less wasteful, but still generates plastic waste in the form of the empty cartridges, as well as the floss itself.
\\n
After I got some floss stuck in my teeth and had to go to the dentist to have it removed, I switched to a water flosser (we decided on the classic Waterpik). I now generate no plastic waste, and my teeth feel cleaner than ever. There\\'s no chance of floss getting stuck between my teeth now, and the water flosser minimizes gum bleeding too.
\\n
Turn off fans when not in use
\\n
Fans use a lot of energy (typically hundreds of watts; cf LED overhead lights, which typically use less than 10W). It\\'s important to use them to keep the air fresh and even out the temperature in the house, but be sure to turn them off when not in use.
\\n
Use canvas bags, boxes, or baskets instead of plastic grocery bags
\\n
I purchased a set of 4 canvas-and-mesh bags for groceries (including one with thermal lining) from Meijer several years ago. The bags have poles in them that span the top of a grocery cart, so they dangle down into it. Whenever I empty them, I try to toss them into the back of my car. If they get gross, I just toss them in the wash.
\\n
I also keep a few collapsible baskets in case I forget the bags, or in case the basket would be more convenient.
\\n
In one of the grocery bags, I keep smaller cloth bags for fresh fruits and vegetables. Whenever I use these, I wash them along with with towels, work gloves, bibs, etc.
\\n
If you slip up and forget your reusable grocery recepticles, just use their paper bags because you can compost them.
\\n
Use LED lighting
\\n
This takes more monetary investment that many of the other practices, but you also it\\'s also pretty much a one-and-done. LEDs use around 10x less power than incandescent lights, and they don\\'t dump a bunch of heat into their surroundings (which you have to air condition away in the summer), and they typically last much longer. I think I\\'ve only had to replace two LED bulbs in the last 2 years (and they were in the same fixture, so maybe there\\'s a problem with that).
\\n
Distill your own water
\\n
This doesn\\'t apply to everyone, just people who use a lot of distilled water. We use distilled water in our humidifiers, bottle washer, warm water dispenser (for making bottles), steam cleaner, Waterpik, etc. Some things don\\'t explicitly require distilled, but if you do use distilled, you don\\'t have to worry about having to clean out salt buildup.
\\n
We got a countertop still, and we typically have to run it 1 to 3 times per day. This keeps us from wasting around 20 plastic jugs of distilled water per day. The downside is that it does take a considerable amount of energy to distill the water, but it\\'s worth not needing to use all that plastic (or carry that many jugs in from the grocery store every week).
\\n
Go to the farmers\\' market
\\n
Fresh produce at the farmers\\' market is typically cheaper, tastier, less likely to be affected by recalls, and supports people in your community instead of supermarkets. Things at the farmers\\' market are also less likely to use single-use plastics.
\\n
Shop online less
\\n
There are some easy ways to make shopping online less wasteful. Whenever possible, ship in the manufacturer\\'s packaging, and choose lower-carbon delivery options.
\\n
But actually reducing how much you shop online is tough but rewarding. Shipping costs money and energy (even if the cost is incorporated into the price of the product instead of a distinct \"shipping fee\"), and inevitably requires more disposable packaging than what you would buy from a store.
\\n
I understand that it isn\\'t always possible to buy stuff locally, and if you are very busy, it can be hard to get to the store. But I\\'m not saying be perfect; just try.
\\n
Compost
\\n
I\\'m still in the process of getting started with composting.
\\n
For years, I have felt deeply bad about putting vegetable waste in plastic trash bags and shipping them off to the dump. So, as much as possible, I try to chuck food waste into the woods, just so the nutrients can return to the ecosystem. I purchased a countertop compost bin, in which I put fruit/vegetable waste. Every few days, I dump the bin onto a pile in the woods.
\\n
Soon, I\\'m planning to use a compost tumbler. I\\'ll also be able to put shredded office paper and packaging cardboard waste into the composter and turn it plus the biomass into fertile soil for gardening.
This is a strategy for managing household consumables that prioritizes never running out.
\\n
Think of some consumable groceries that you (1) use on a recurring basis, and (2) are either nonperishable or take significantly longer to expire than it takes for you to consume them. A few examples in my household:
\\n
\\n
Cleaning supplies
\\n
Paper towel / toilet paper
\\n
Butter
\\n
Olive oil
\\n
Diapers and butt cream
\\n
Jelly
\\n
\\n
Now, we'll divide this set into two classes based on how fast you consume the smallest standalone unit: slow-burn consumables are those which take you a significantly longer interval to use (from start to finish) than the interval between trips to the grocery store where you get them; fast-burn consumables are those which you completely use on an interval that is smaller or similarly-sized than the interval between shopping trips.
\\n
For slow-burn consumables, keep one un-opened extra on hand. When you finish the opened unit, leave the empty package by your door to remind you to get another the next time you're out. Some examples of slow-burn consumables for me include:
\\n
\\n
gallon jug of white vinegar for cleaning
\\n
a jar of salt or pepper
\\n
gallon jug of dishsoap
\\n
a bottle of vitamin pills
\\n
\\n
For fast-burn consumables, stash enough of them to last you at least one full between-shopping-trips interval around your house. Some examples of fast-burn consumables for me include:
\\n
\\n
a roll of toilet paper
\\n
a jar of jelly
\\n
an Affresh tablet
\\n
\\n
Multi-packs of fast-burn consumables can be treated en-bloc as slow-burn consumables, but only if you keep an entire extra multi-pack as backup. Some examples include:
\\n
\\n
a 4-pack of butter
\\n
a 24-pack of soda cans
\\n
a box of diapers
\\n
\\n
The main reason against storing a full backup of a multi-pack is storage.
\\n
Another principle of the stock-up strategy is that larger packs typically have lower unit prices, so when possible, opt for them.
\\n
The last thing to note is that when consumables you use regularly go on sale, it is an opportunity for you to stock up on as many as you have space for (and if perishable, can use before the expiration date).
Around the time our son started walking, my wife began the tradition of straightening up his toys every night so they would be ready for him to jumble up in the morning. When she became too pregnant with our daughter to continue, I assumed the mantle.
\\n
Ultimately, setting up the toys is something I do for my kids because I love them and feel like they deserve it. That\\'s what keeps me doing it night after night. On top of that, my son tends to be entertained by his toys (instead of my glasses, laptops, etc.) for much longer when they are set up in the morning.
\\n
Setting up the toys can also give you a sense of which toys your kid likes and how they interact with them, which in turn, can help you understand where your kid is developmentally. Occasionally, I try to build challenges into the setup, like hiding a favorite toy or a dry snack, or putting something out of reach to encourage them to use tools.
\\n
Examples
\\n
When I\\'m especially proud of my work (or when I just want to try to capture a slice of life), I take a picture of the toy setup. Here are some of the best.
\\n
March 2025
\\n
\\n
Bunky\\'s First Birthday
\\n
\\n
Real-life Dancing Fruits for a \"berry sweet\" boy\\'s first birthday.
\\n
Baby\\'s First Dungeon
\\n
\\n
With dragons guarding his favorite toys (Pinks the bear, the \"Stink Flowers\", and his candied apple Pusheen), and Knight Owl waiting by the entrance with a sword and shield for the brave adventurer.
\\n
Big Brother\\'s Dungeon
\\n
\\n
A dungeon that leads to his playpen, full of his best toys. I built this to make him feel special while my wife and I were in the labor and delivery ward for the scheduled delivery of our daughter.
\\n
October 2025
\\n
\\n
I started building a citadel out of his couch.
\\n
Attack on Junebug
\\n
\\n
I tried to encourage my son to play with my daughter by locking her stuff in his playpen and building ramps into it out of his couch.
\\n
Elfpocalypse
\\n
\\n
In the thrilling conclusion to our first season of Elf-on-the-Shelf, the Elf leads the toys on a Christmas Day siege.
Find a job you enjoy, and you will never have to work a day in your life.
\\n
— Mark Twain (allegedly)
\\n
\\n
When I was in middle school, I discovered in myself a passion for computer programming. But in high school, I decided that I did not want to pursue a career in computer science. I did not want my favorite pastime and form of creative expression to be commodified. I believed that if I were to let my programming be adulterated by the grimy reality of capital and wage slavery, I would love it less.
\\n
In college, I majored in chemistry, which I did and still do also love. But because chemistry was part of my compulsory education, I felt like my passion for it was already tainted by the academic industrial complex, so I had less to lose by making it my career. While I worked in a research lab, I found that the most fun I had was when I was setting up the automatic flash chromatograph, documenting how to use our analytical equipment, and programming a Python-based gas chromatogram analytical software.
\\n
At that point, I had an epiphany — by trying to keep programming separate from my career, I didn\\'t have many opportunities to do it anymore.
\\n
Nowadays, I have a MS in data science, and my day-to-day work mostly consists of devops. Although I spend most of my time working on projects that I don\\'t really care about (at least compared to my personal projects), I am happy that I made a career pivot that lets me program and work with computers. I can get excited about even the most topically insipid of projects if it is enough of a technical challenge. And I basically get to spend all day honing my skills, so that when I do have time to work on my hobby projects, I do them better.
\\n
I do not mean to diminish the tragedy of the fact that proletarians are only allowed to meaningfully pursue our passions insofar as we pervert said passions into part of the economic process. But the solution is not to rebel against this facet of industrial society by reserving your calling for nights, weekends, and holidays; you will be the only one who suffers in the end
\\n
So, back to the quote up top. When Mark Twain (or whoever) says \"never ... work a day in your life\", he does not mean that finding a job you like will liberate you from wage slavery. I think a more accurate (yet less quotable) way to look at it is this:
\\n
\\n
If you base your career on something you\\'re passionate about, then you\\'ll have an excuse to do something you love every day.
To be a provider is to be a machine that converts time and energy into the resources your dependents need to survive. You may think that taking time to care for yourself is selfish. It is not. Pushing yourself to the point of physical or mental breakdown will only hurt your dependents in the long run. Self care is like sharpening a blade, cleaning a filter, or changing oil.
\\n
Develop the mindset that everything you do to take care of yourself is a short- and long-term investment in being better able to fulfill your responsibilities and provide for your loved ones.
\\n
As a husband and a father, I now have to take a much more intentional approach to my health and wellness. Here are some of the things I\\'ve found useful to keep me running like a well-oiled machine:
\\n
\\n
Find a regimen of vitamins and other supplements that works for you: ensuring your body has everything it needs to function properly is critical for good health. Once you\\'ve made the initial investment of figuring out what you need and building the habit of taking it when you need it, it\\'s pretty easy to maintain this habit.
\\n
Drink herbal tea: tea isn\\'t for everyone, but I learned to enjoy it while I was recovering from mononucleosis in winter 2023/2024. It\\'s become a part of my morning routine that helps me start the day with calm determination. My favorites are Twinings Immune Support+ (Blackberry) and Glow+ (Peach) with a teaspoon of raw honey (which allegedly can help allergies in the long run). I\\'ve also found that Traditional Medicinals Throat Coat helps if I have a sore/dry throat and Peppermint Delight Probiotic helps if I have gastrointestinal pain.
\\n
Stretch once when you wake up and once before you go to bed: becoming a parent entails going from resting to swinging around a free-weight with little warning. If you don\\'t stretch, you will throw out your back, neck, or shoulder. When I stretch in the morning, I feel more awake throughout the rest of the day.
\\n
Exercise regularly: this habit is the hardest to build, but the most rewarding. Obesity comes with so many comorbidities that you cannot afford to waste your life dealing with, and weakness or lack of fitness makes it hard to keep up with children. I find that I\\'m able to exercise much better if the activity itself is fun. So, most nights, I play Super Punch Out or Wii Fit. If you have a kid, playing with your kid is like exercise, fun, and bonding all rolled up into one activity. Roughhousing with your kid is a great way to get exercise for both of you, while also teaching your kid body awareness and confidence, coordination, balance, and how to respect others\\' boundaries and limits.
\\n
Use skin care: I\\'ve got dry skin on my hands, and when they start to crack and bleed, I don\\'t want to use them for anything. So every night, I use lotion and put on fingerless gloves to trap the moisture against my hands while I sleep. I\\'m still trying to find a good hand cream, but O\\'Keeffe\\'s Healthy Feet works miracles for my feet. If your feet are dry and cracking, get a pedicure, then use O\\'Keeffe\\'s under some socks. Finding a good face wash and lotion can reduce acne breakouts and rashes (I use Clear Days Ahead face wash and L\\'Oréal Age Perfect face cream).
\\n
Break unhealthy digital habits: doomscrolling is perhaps the greatest waste of life of the 21st century. Take steps to get off your phone and either consume long-form media that requires active attention, or be present in the real world. This can be really difficult, but I\\'ve found the following steps to be very helpful in renegotiating my relationship with my phone and digital media:
\\n
Use do not disturb: I currently mute all notifications except for phone calls. Having your phone pinging constantly is the best way to ensure it stays in your hand (or drives you insane from across the room). I make sure the people I regularly communicate have an understanding that if they need to reach me urgently, they have to call.
\\n
Archive or delete social media apps: If you need them, you can download them again. But without that barrier in place, you constantly have to resist the temptation.
\\n
Block addictive websites: Once you make it harder to use your apps, there\\'s a good chance that you\\'ll just start to use the website versions of your usual social media platforms through your browser app. I paid $100 for a lifetime subscription to the BlockSite app/browser extension to remedy this; I figured that if it saves me 4 hours of my life, it has paid for itself. And it has. Once you have BlockSite, you will be appalled to see just how often you see its \"This website has been blocked\" message because the habit of navigating to your usual websites is so deeply ingrained.
\\n
Make the UI less appealing/convenient: when people talk about gambling addictions, they often cite how the gaudy displays, flashing lights, and exiting sounds draw gamblers in and keep them playing [1]. I paid $30 for a lifetime subscription to minimalist phone launcher, which replaces my home screen with a plain text. I feel like this has helped me reduce how often I, by reflex, open my phone and navigate to a scrolling app. Another intervention you can use is to set your display to be greyscale only. That did help me reduce my phone usage, but I had to disable it too often for it to be practical (and I once got pulled over while using Google Maps in greyscale because I couldn\\'t distinguish the route from the rest of the roads).
\\n
Learn to let go of FOMO: there are times when I get down about not seeing my friends as much, not having time for my hobbies, having to miss out on events, or even falling behind on housework. While it\\'s okay to feel like that, and you should absolutely make time to see your friends and do what you love, you can\\'t let feelings of discontent be an assault on your well-being. Remember that if there are people who you love so much that you take care of them, you\\'re not wasting your time.
\\n
Invest in efficiency: this is not conventional self-care, but anytime you learn how to do a chore or routine faster, better, or cheaper, that\\'s time and/or money that you can give back to yourself.
\\n
Play: if you only take one thing away from this note, let it be that it is critically important that you make time to play. If all you do is work and sleep, you will be miserable. Play rejuvenates the soul while at the same time exercising the mind and/or body. Play with your spouse, and your relationship will be stronger. Play with your children and you will watch their development progress in real time. Play with your friends and you will feel community. Play outdoors and you will feel alive. Play roleplaying games and you will have new and fantastical experiences. Play rhythm or action games and you will develop better reflexes. Play strategic games and you will develop an intuition for understanding real-world interpersonal interactions. Do not play mobile games because they are a plot by the Canadian Devil to capitalize on addiction to fund the development of Canadian infrastructure [2].
\\n
\\n
Sources:\\n[1] https://doi.org/10.1523/JNEUROSCI.1171-18.2018 \\n[2] https://southpark.cc.com/video-clips/jgkzdr/south-park-beelzaboot\\n (alt: South Park S18E06: \"Freemium Isn\\'t Free\")
Here's my troubleshooting guide for if your infant is having a lot of blowouts:
\\n\\n
If it's not that frequent (a couple times a week at the most), consider: shit happens. They'll blow out less when they transition to solids.
\\n
If they are blowing out through the leg, ensure the leg ruffles are fluffed out. You may need to ensure the waistband is not too high because that can sometimes create gaps along the legs.
\\n
If ② doesn't help, you may need to use the next size of diaper.
\\n
If they are blowing out up their backside, try to ensure the back of the diaper is as high up their backside as possible putting it on.
\\n
If ④ doesn't help, consider using a different brand of diaper, as it may fit your kid's butt better.
\\n
If ⑤ doesn't help, consider double-bagging your baby, by putting a diaper of a much larger size on over their regular one.
After several years of experimenting, I\\'ve found a regimen of vitamins and other supplements that have helped me manage some of my chronic health problems. Here\\'s what I take and why I take it:
\\n
Allergies
\\n
\\n
Cetirizine (10 mg, QD): I take this daily and it works like a charm. I used to take Fexofenadine (which seemed to have a stronger antihistamine effect), but if I missed a dose, I would get insomnia. Walmart sells a bottle with ~300 tablets, and that\\'s the best deal I\\'ve found.
\\n
Raw honey: I don\\'t have this nearly as often as I would like, but my dad cured his hay fever by having raw honey every day.
\\n
\\n
Digestive Health
\\n
\\n
Probiotic (1 cap, QD): I take a daily probiotic, and it helps a lot with lactose intolerance. I get the CVS Health Ultimate Digestive Probiotic with 80B CFU, but I have not really experimented with any others.
\\n
Lactase Enzyme (1 cap, PRN): I take these every time I have dairy. Between these and my daily probiotic, it\\'s like a subscription to the ability to lactose tolerance. The ones from Amazon seem to work better than the CVS brand or Lactaid (plus, the latter two taste disgusting).
\\n
Omeprazole (20 mg, QD): daily for erosive esophagitis/acid reflux. They sell these in bottles of 14, which is bogus.
\\n
Turmeric (1000 mg, QD): I am not sure if this helps or not, but I\\'ve seen sources online say that turmeric can be good for fighting fatty liver disease.
\\n
Fish Oil (1000 mg, QD): Like turmeric, I\\'ve seen things say that fish oil helps fight fatty liver disease.
\\n
IB Gard (1 cap, QD): I don\\'t take these anymore. I feel like they did help a little, but you have to be very careful with them. If you don\\'t have a sufficiently large meal after taking an IB Gard, the peppermint on your empty stomach will make you have cramps for a couple days.
\\n
\\n
In addition to these supplements, diet and exercise have made a huge impact for me. I think that my IBS was largely a symptom of fatty liver disease, so reducing fatty/high-carb foods and doing daily aerobic exercise has essentially cured me.
\\n
Headaches
\\n
\\n
Magnesium (250 mg, QD): recommended by my primary doctor. When I do have headaches these days, it\\'s typically because I forgot to take my magnesium the day before.
\\n
CoQ10 (100 mg, QD): recommended by my primary doctor. Beware, they\\'re kind of expensive (unless you get the 5-finger discount)
\\n
\\n
I\\'ve noticed that if I have a headache, it\\'s most likely because I\\'ve been sleep deprived most nights. There\\'s no substitute for getting adequate sleep, but Mg + CoQ10 are a good second.
\\n
Immune Support
\\n
\\n
Zinc (50 mg, QD): since I started taking zinc and drinking my immune support tea, I have been less susceptible to contracting illnesses. There have been times that Codi gets sick but I don\\'t.
\\n
Twinings Immune Support+ Blackberry Hibiscus and Elderberry Tea (PRN): this stuff is legit, and it tastes really good.
\\n
\\n
Misc
\\n
\\n
Vitamin D₃ (1000 IU, QD): I started taking this on a recommendation from my therapist when I was depressed. I always say that it made my mood about 2.5% better, which doesn\\'t seem like a lot, but when you\\'re at rock bottom, it\\'s pretty good for such an easy intervention. Things didn\\'t start getting better until after I started taking Vitamin D regularly. I call them \"little drops of sunshine.\"
\\n
Vitamin B Complex (1 tab, QD): I take these so I don\\'t have to get separate pills for the B vitamins I really need:
\\n
Niacin (B3): the skin on my fingers used to peel because I was B3 deficient.
\\n
Pyridoxine (B6): since I started taking this, I don\\'t really have nightmares anymore.
I\\'ve compiled a list of useful items to purchase in preparation for a baby. I\\'ve excluded a bunch of the obvious stuff (e.g. diapers).
\\n
Whole House / No Specific Location
\\n
\\n
Wall clocks: you will want these in every room that you intend to feed the baby in; it\\'s useful to track when and how long the baby feeds.
\\n
Wall plate extension cords (Example) paired with cable management boxes (Example): you can delay babyproofing until your baby starts to crawl, but it\\'s good to remove tripping hazards.
\\n
Baby wrap carrier (e.g. KeaBabies Baby Wrap): they work really well once you figure out how to use them properly, and they seem to be more comfortable for the parent and the baby (and more adaptive to the baby\\'s size).
\\n
Gloves: if you have sensitive skin like me, invest in gloves because you will be washing your hands much more frequently when the baby is around. Reusable nitrile gloves to use while washing dishes and cleaning the house, biodegradable disposable gloves to use while cooking, and fingerless moisturizing gloves to use whenever your hands get dry.
\\n
\\n
Nursery
\\n
\\n
Wipe warmer: this made diaper changes a lot less painful.
\\n
Disposable changing pads (Example): I typically try to get reusable alternatives to reduce waste, but I am okay with going disposable for anything that regularly gets poop on it .
\\n
Drawer organizers: a baby has lots of little things, so you will typically need to put multiple types of items in a single dresser drawer.
\\n
Drawer labels: these are especially useful for people who come to help
\\n
Hospital-grade breast pump (e.g. Spectra S1): we had the Momcozy S9 Pro (not hospital-grade), and was more uncomfortable while less effective at removing the milk. Your insurance might cover it.
\\n
Let-down collectors (e.g. Haakaa Ladybugs): keep one of these on an end table next to where you nurse or where you spend most of your time, because when let-down starts to leak, you will feel really bad if it goes to waste.
\\n
Resinol: smear this stuff on the baby\\'s butt after every change to keep diaper rash at bay.
\\n
Pacifier medicine dispenser: I don\\'t know how we would give him medicine without this.
\\n
Electric nail file: don\\'t be scared of this thing; it is really gentle; it can\\'t cause nearly as much damage to a baby as the baby\\'s own nails.
\\n
Swaddle bags with microfiber wings (Example): these make swaddling really easy, and they are super cute.
\\n
\\n
Parents\\' Room
\\n
\\n
Cosleeper cushion: if you can avoid having the baby sleep in your bed, that\\'s best, but if you can\\'t, get one of these.
\\n
\\n
Kitchen
\\n
\\n
UV Sterilizer: these things are super convenient (especially while traveling) because they are so much faster than steam sterilizers. Papablic makes a small one for pacifiers and the like, as well as a larger one for bottles and pump equipment; we have both and we love them.
\\n
Boon Cacti Bottle Brushes (Example): they\\'re fun and functional!
\\n
Reusable cleaning gloves: you will do a LOT of washing, especially if you use pumped breast milk. If you have sensitive skin, these are a must-have.
\\n
Drying pads: even if you have drying racks, water gets everywhere. It actually works well to set your drying rack on top of a drying pad.
\\n
Dedicated baby sink: if you\\'re lucky enough to have an extra sink in your kitchen like we do, pick one to use exclusively for baby stuff. It just makes life easier.
\\n
Momcozy Kleanpal Pro Baby Bottle Washer: if you can afford it, this is well worth the price. Before this, I spent over an hour a day on handwashing bottles and breast pump equipment; now, all I have to do a quick rinse and load it into the washer.
\\n
Water still: if you formula feed or if you have the aforementioned bottle washer, you will go through a lot of distilled water, so it\\'s useful to get a cheap water still (there are plenty <$100 that you can find online).
\\n
Thermos-type bottle warmer: steam-based bottle warmers get the bottles all wet and heat the plastic up high enough to cause microplastics to leak into the milk, and hot plate bottle warmers are very prone to leaking.
\\n
\\n
On-the-Go
\\n
\\n
A quality diaper bag: when you\\'re out and about, you live out of your diaper bag. We have the Zara Lemon Diaper Bag, and it works great.
\\n
Portable changing pad: public bathroom changing tables can get pretty nasty.
\\n
An infant carseat; not a convertible: it is difficult enough to get the baby into the seat even when you have the convenience of being able to bring the seat inside your home. We don\\'t have a stroller base that goes with the infant carseat, but I think it would be convenient since that thing is damn heavy.
The \"Superlock\" knot is the way I tie my obi. I prefer it because it keeps the ends pointing down, can be cinched back up without having to start from scratch, and avoids an unsightly mess behind your back.
As a homeowner, I have made it an ongoing goal to adopt sustainable and eco-friendly practices. As with all new undertakings, start with the small things and work your way up.
\\n
Before we get started, I do have to add one caveat. Disposable goods do have some legitimate use-cases, like sanitization and keeping things sterile (e.g. disposable needles, disinfectant wipes, toilet paper, etc).
\\n
In roughly increasing order of how difficult it was for me to build these habits, here are some of the things I do.
\\n
Cultivate a distaste for disposable products, especially single-use plastics
\\n
Buy a thing of Milano cookies and see that the cookies are separated with plastic risers into tiers, and how each tier is wrapped in plastic. The damn bag is already sealed!
\\n
Buy a pack of paper towel rolls, and notice that each roll is individually wrapped in plastic, inside the overall plastic-wrapped pack.
\\n
Just think about how pointless and wasteful all this single-use plastic is. Imagine a turtle swallowing a piece of this plastic and choking to death.
\\n
Now think about how much money you spend on paper towel. Think about how much money you spend on bottles of cleaning spray, and how much waste they generate.
\\n
This is your first step.
\\n
When you opt for reusable things instead of disposable things, it\\'s an investment that is both eco-friendly and financially rewarding.
\\n
You can\\'t always control how things are packaged, but if you develop this anti-disposable mindset, it will nudge you towards choosing better products.
\\n
Use natural lighting during the day
\\n
If you have LEDs, this won\\'t save much energy, but every little bit counts. And sunlight is good for the soul.
\\n
Buy in bulk
\\n
As containers grow in size, the surface-area-to-volume ratio typically decreases. In other words, you\\'ll generate much less waste buying a gallon jug of dishsoap than you would buying 4x 32oz bottles.
\\n
Typically, unit prices also go down when you buy larger containers or multi-packs, so it\\'s a win-win.
\\n
Use powder laundry detergent
\\n
Powder detergent is much cheaper than liquid detergent, and you need much less of it to wash your clothes. When I moved here a hear and a half ago, I bought a 16.5lb drum of Arm & Hammer powder detergent from Home Depot for a little over $30. I\\'m not even halfway through it.
\\n
Liquid detergents also sometimes contain byproducts of petroleum cracking.
\\n
Mix your own cleaning sprays
\\n
Blueland sells tablets that you can dissolve in water to make your own cleaning spray. They also sell volumetric reusable spray bottles, which you can use to easily ensure your cleaning solution is made to the correct concentration.
\\n
As of the time of writing, the tablets are around $2 unit price. Most cleaning sprays in disposable bottles cost at least twice as much and generate a bunch of plastic waste. Blueland uses compostable packaging.
\\n
Don\\'t use garbageware
\\n
Stop using garbageware (disposable utensils, plates, cups, etc). If you have a dishwasher, just use it. If you are hosting company and are overwhelmed by dishes, conscript someone to help you; most people are eager to be good guests. It\\'s just not worth generating so much waste. If you\\'re afraid of people breaking your fine china, get reusable plastic dishes. If someone serves you with garbageware, act offended. Cultural attitudes need to change in order for us to make progress.
\\n
If you get a paper cup at a restaurant, try to go without a straw or lid. If you are getting carryout, ask for no plasticware (or better yet, just don\\'t get carryout).
\\n
Use cloth towels instead of paper towels
\\n
Cleaning up after my son as he learns how to feed himself, I would go through a roll of paper towel in less than a week. So wasteful and so expensive. So, I bought these Mioeco-brand \"reusable paper towels\". People said that they\\'re a scam and just to use regular rags, but I did not have rags, and these seemed like they would have a good texture (they do).
\\n
I use these to wipe up his crumbs. When one gets too crumby, I rinse it off in the sink, wring it out, and use it again. Once I feel like it\\'s too gross, I throw it in a bucket I keep on my countertop. When I run low, I wash them.
\\n
Likewise, I use cloth napkins instead of paper napkins or paper towels. I got separate cloth napkins because the \"reusable paper towels\" have a nice rough texture for wiping hard surfaces, not skin.
\\n
Use a water flosser
\\n
When I first started trying to reduce single-use plastics, I switched from disposable floss picks to a reusable floss pick, which uses spools from floss cartridges. This is much less wasteful, but still generates plastic waste in the form of the empty cartridges, as well as the floss itself.
\\n
After I got some floss stuck in my teeth and had to go to the dentist to have it removed, I switched to a water flosser (we decided on the classic Waterpik). I now generate no plastic waste, and my teeth feel cleaner than ever. There\\'s no chance of floss getting stuck between my teeth now, and the water flosser minimizes gum bleeding too.
\\n
Turn off fans when not in use
\\n
Fans use a lot of energy (typically hundreds of watts; cf LED overhead lights, which typically use less than 10W). It\\'s important to use them to keep the air fresh and even out the temperature in the house, but be sure to turn them off when not in use.
\\n
Use canvas bags, boxes, or baskets instead of plastic grocery bags
\\n
I purchased a set of 4 canvas-and-mesh bags for groceries (including one with thermal lining) from Meijer several years ago. The bags have poles in them that span the top of a grocery cart, so they dangle down into it. Whenever I empty them, I try to toss them into the back of my car. If they get gross, I just toss them in the wash.
\\n
I also keep a few collapsible baskets in case I forget the bags, or in case the basket would be more convenient.
\\n
In one of the grocery bags, I keep smaller cloth bags for fresh fruits and vegetables. Whenever I use these, I wash them along with with towels, work gloves, bibs, etc.
\\n
If you slip up and forget your reusable grocery recepticles, just use their paper bags because you can compost them.
\\n
Use LED lighting
\\n
This takes more monetary investment that many of the other practices, but you also it\\'s also pretty much a one-and-done. LEDs use around 10x less power than incandescent lights, and they don\\'t dump a bunch of heat into their surroundings (which you have to air condition away in the summer), and they typically last much longer. I think I\\'ve only had to replace two LED bulbs in the last 2 years (and they were in the same fixture, so maybe there\\'s a problem with that).
\\n
Distill your own water
\\n
This doesn\\'t apply to everyone, just people who use a lot of distilled water. We use distilled water in our humidifiers, bottle washer, warm water dispenser (for making bottles), steam cleaner, Waterpik, etc. Some things don\\'t explicitly require distilled, but if you do use distilled, you don\\'t have to worry about having to clean out salt buildup.
\\n
We got a countertop still, and we typically have to run it 1 to 3 times per day. This keeps us from wasting around 20 plastic jugs of distilled water per day. The downside is that it does take a considerable amount of energy to distill the water, but it\\'s worth not needing to use all that plastic (or carry that many jugs in from the grocery store every week).
\\n
Go to the farmers\\' market
\\n
Fresh produce at the farmers\\' market is typically cheaper, tastier, less likely to be affected by recalls, and supports people in your community instead of supermarkets. Things at the farmers\\' market are also less likely to use single-use plastics.
\\n
Shop online less
\\n
There are some easy ways to make shopping online less wasteful. Whenever possible, ship in the manufacturer\\'s packaging, and choose lower-carbon delivery options.
\\n
But actually reducing how much you shop online is tough but rewarding. Shipping costs money and energy (even if the cost is incorporated into the price of the product instead of a distinct \"shipping fee\"), and inevitably requires more disposable packaging than what you would buy from a store.
\\n
I understand that it isn\\'t always possible to buy stuff locally, and if you are very busy, it can be hard to get to the store. But I\\'m not saying be perfect; just try.
\\n
Compost
\\n
I\\'m still in the process of getting started with composting.
\\n
For years, I have felt deeply bad about putting vegetable waste in plastic trash bags and shipping them off to the dump. So, as much as possible, I try to chuck food waste into the woods, just so the nutrients can return to the ecosystem. I purchased a countertop compost bin, in which I put fruit/vegetable waste. Every few days, I dump the bin onto a pile in the woods.
\\n
Soon, I\\'m planning to use a compost tumbler. I\\'ll also be able to put shredded office paper and packaging cardboard waste into the composter and turn it plus the biomass into fertile soil for gardening.
This is a strategy for managing household consumables that prioritizes never running out.
\\n
Think of some consumable groceries that you (1) use on a recurring basis, and (2) are either nonperishable or take significantly longer to expire than it takes for you to consume them. A few examples in my household:
\\n
\\n
Cleaning supplies
\\n
Paper towel / toilet paper
\\n
Butter
\\n
Olive oil
\\n
Diapers and butt cream
\\n
Jelly
\\n
\\n
Now, we'll divide this set into two classes based on how fast you consume the smallest standalone unit: slow-burn consumables are those which take you a significantly longer interval to use (from start to finish) than the interval between trips to the grocery store where you get them; fast-burn consumables are those which you completely use on an interval that is smaller or similarly-sized than the interval between shopping trips.
\\n
For slow-burn consumables, keep one un-opened extra on hand. When you finish the opened unit, leave the empty package by your door to remind you to get another the next time you're out. Some examples of slow-burn consumables for me include:
\\n
\\n
gallon jug of white vinegar for cleaning
\\n
a jar of salt or pepper
\\n
gallon jug of dishsoap
\\n
a bottle of vitamin pills
\\n
\\n
For fast-burn consumables, stash enough of them to last you at least one full between-shopping-trips interval around your house. Some examples of fast-burn consumables for me include:
\\n
\\n
a roll of toilet paper
\\n
a jar of jelly
\\n
an Affresh tablet
\\n
\\n
Multi-packs of fast-burn consumables can be treated en-bloc as slow-burn consumables, but only if you keep an entire extra multi-pack as backup. Some examples include:
\\n
\\n
a 4-pack of butter
\\n
a 24-pack of soda cans
\\n
a box of diapers
\\n
\\n
The main reason against storing a full backup of a multi-pack is storage.
\\n
Another principle of the stock-up strategy is that larger packs typically have lower unit prices, so when possible, opt for them.
\\n
The last thing to note is that when consumables you use regularly go on sale, it is an opportunity for you to stock up on as many as you have space for (and if perishable, can use before the expiration date).
Around the time our son started walking, my wife began the tradition of straightening up his toys every night so they would be ready for him to jumble up in the morning. When she became too pregnant with our daughter to continue, I assumed the mantle.
\\n
Ultimately, setting up the toys is something I do for my kids because I love them and feel like they deserve it. That\\'s what keeps me doing it night after night. On top of that, my son tends to be entertained by his toys (instead of my glasses, laptops, etc.) for much longer when they are set up in the morning.
\\n
Setting up the toys can also give you a sense of which toys your kid likes and how they interact with them, which in turn, can help you understand where your kid is developmentally. Occasionally, I try to build challenges into the setup, like hiding a favorite toy or a dry snack, or putting something out of reach to encourage them to use tools.
\\n
Examples
\\n
When I\\'m especially proud of my work (or when I just want to try to capture a slice of life), I take a picture of the toy setup. Here are some of the best.
\\n
March 2025
\\n
\\n
Bunky\\'s First Birthday
\\n
\\n
Real-life Dancing Fruits for a \"berry sweet\" boy\\'s first birthday.
\\n
Baby\\'s First Dungeon
\\n
\\n
With dragons guarding his favorite toys (Pinks the bear, the \"Stink Flowers\", and his candied apple Pusheen), and Knight Owl waiting by the entrance with a sword and shield for the brave adventurer.
\\n
Big Brother\\'s Dungeon
\\n
\\n
A dungeon that leads to his playpen, full of his best toys. I built this to make him feel special while my wife and I were in the labor and delivery ward for the scheduled delivery of our daughter.
\\n
October 2025
\\n
\\n
I started building a citadel out of his couch.
\\n
Attack on Junebug
\\n
\\n
I tried to encourage my son to play with my daughter by locking her stuff in his playpen and building ramps into it out of his couch.
\\n
Elfpocalypse
\\n
\\n
In the thrilling conclusion to our first season of Elf-on-the-Shelf, the Elf leads the toys on a Christmas Day siege.
Find a job you enjoy, and you will never have to work a day in your life.
\\n
— Mark Twain (allegedly)
\\n
\\n
When I was in middle school, I discovered in myself a passion for computer programming. But in high school, I decided that I did not want to pursue a career in computer science. I did not want my favorite pastime and form of creative expression to be commodified. I believed that if I were to let my programming be adulterated by the grimy reality of capital and wage slavery, I would love it less.
\\n
In college, I majored in chemistry, which I did and still do also love. But because chemistry was part of my compulsory education, I felt like my passion for it was already tainted by the academic industrial complex, so I had less to lose by making it my career. While I worked in a research lab, I found that the most fun I had was when I was setting up the automatic flash chromatograph, documenting how to use our analytical equipment, and programming a Python-based gas chromatogram analytical software.
\\n
At that point, I had an epiphany — by trying to keep programming separate from my career, I didn\\'t have many opportunities to do it anymore.
\\n
Nowadays, I have a MS in data science, and my day-to-day work mostly consists of devops. Although I spend most of my time working on projects that I don\\'t really care about (at least compared to my personal projects), I am happy that I made a career pivot that lets me program and work with computers. I can get excited about even the most topically insipid of projects if it is enough of a technical challenge. And I basically get to spend all day honing my skills, so that when I do have time to work on my hobby projects, I do them better.
\\n
I do not mean to diminish the tragedy of the fact that proletarians are only allowed to meaningfully pursue our passions insofar as we pervert said passions into part of the economic process. But the solution is not to rebel against this facet of industrial society by reserving your calling for nights, weekends, and holidays; you will be the only one who suffers in the end
\\n
So, back to the quote up top. When Mark Twain (or whoever) says \"never ... work a day in your life\", he does not mean that finding a job you like will liberate you from wage slavery. I think a more accurate (yet less quotable) way to look at it is this:
\\n
\\n
If you base your career on something you\\'re passionate about, then you\\'ll have an excuse to do something you love every day.
To be a provider is to be a machine that converts time and energy into the resources your dependents need to survive. You may think that taking time to care for yourself is selfish. It is not. Pushing yourself to the point of physical or mental breakdown will only hurt your dependents in the long run. Self care is like sharpening a blade, cleaning a filter, or changing oil.
\\n
Develop the mindset that everything you do to take care of yourself is a short- and long-term investment in being better able to fulfill your responsibilities and provide for your loved ones.
\\n
As a husband and a father, I now have to take a much more intentional approach to my health and wellness. Here are some of the things I\\'ve found useful to keep me running like a well-oiled machine:
\\n
\\n
Find a regimen of vitamins and other supplements that works for you: ensuring your body has everything it needs to function properly is critical for good health. Once you\\'ve made the initial investment of figuring out what you need and building the habit of taking it when you need it, it\\'s pretty easy to maintain this habit.
\\n
Drink herbal tea: tea isn\\'t for everyone, but I learned to enjoy it while I was recovering from mononucleosis in winter 2023/2024. It\\'s become a part of my morning routine that helps me start the day with calm determination. My favorites are Twinings Immune Support+ (Blackberry) and Glow+ (Peach) with a teaspoon of raw honey (which allegedly can help allergies in the long run). I\\'ve also found that Traditional Medicinals Throat Coat helps if I have a sore/dry throat and Peppermint Delight Probiotic helps if I have gastrointestinal pain.
\\n
Stretch once when you wake up and once before you go to bed: becoming a parent entails going from resting to swinging around a free-weight with little warning. If you don\\'t stretch, you will throw out your back, neck, or shoulder. When I stretch in the morning, I feel more awake throughout the rest of the day.
\\n
Exercise regularly: this habit is the hardest to build, but the most rewarding. Obesity comes with so many comorbidities that you cannot afford to waste your life dealing with, and weakness or lack of fitness makes it hard to keep up with children. I find that I\\'m able to exercise much better if the activity itself is fun. So, most nights, I play Super Punch Out or Wii Fit. If you have a kid, playing with your kid is like exercise, fun, and bonding all rolled up into one activity. Roughhousing with your kid is a great way to get exercise for both of you, while also teaching your kid body awareness and confidence, coordination, balance, and how to respect others\\' boundaries and limits.
\\n
Use skin care: I\\'ve got dry skin on my hands, and when they start to crack and bleed, I don\\'t want to use them for anything. So every night, I use lotion and put on fingerless gloves to trap the moisture against my hands while I sleep. I\\'m still trying to find a good hand cream, but O\\'Keeffe\\'s Healthy Feet works miracles for my feet. If your feet are dry and cracking, get a pedicure, then use O\\'Keeffe\\'s under some socks. Finding a good face wash and lotion can reduce acne breakouts and rashes (I use Clear Days Ahead face wash and L\\'Oréal Age Perfect face cream).
\\n
Break unhealthy digital habits: doomscrolling is perhaps the greatest waste of life of the 21st century. Take steps to get off your phone and either consume long-form media that requires active attention, or be present in the real world. This can be really difficult, but I\\'ve found the following steps to be very helpful in renegotiating my relationship with my phone and digital media:
\\n
Use do not disturb: I currently mute all notifications except for phone calls. Having your phone pinging constantly is the best way to ensure it stays in your hand (or drives you insane from across the room). I make sure the people I regularly communicate have an understanding that if they need to reach me urgently, they have to call.
\\n
Archive or delete social media apps: If you need them, you can download them again. But without that barrier in place, you constantly have to resist the temptation.
\\n
Block addictive websites: Once you make it harder to use your apps, there\\'s a good chance that you\\'ll just start to use the website versions of your usual social media platforms through your browser app. I paid $100 for a lifetime subscription to the BlockSite app/browser extension to remedy this; I figured that if it saves me 4 hours of my life, it has paid for itself. And it has. Once you have BlockSite, you will be appalled to see just how often you see its \"This website has been blocked\" message because the habit of navigating to your usual websites is so deeply ingrained.
\\n
Make the UI less appealing/convenient: when people talk about gambling addictions, they often cite how the gaudy displays, flashing lights, and exiting sounds draw gamblers in and keep them playing [1]. I paid $30 for a lifetime subscription to minimalist phone launcher, which replaces my home screen with a plain text. I feel like this has helped me reduce how often I, by reflex, open my phone and navigate to a scrolling app. Another intervention you can use is to set your display to be greyscale only. That did help me reduce my phone usage, but I had to disable it too often for it to be practical (and I once got pulled over while using Google Maps in greyscale because I couldn\\'t distinguish the route from the rest of the roads).
\\n
Learn to let go of FOMO: there are times when I get down about not seeing my friends as much, not having time for my hobbies, having to miss out on events, or even falling behind on housework. While it\\'s okay to feel like that, and you should absolutely make time to see your friends and do what you love, you can\\'t let feelings of discontent be an assault on your well-being. Remember that if there are people who you love so much that you take care of them, you\\'re not wasting your time.
\\n
Invest in efficiency: this is not conventional self-care, but anytime you learn how to do a chore or routine faster, better, or cheaper, that\\'s time and/or money that you can give back to yourself.
\\n
Play: if you only take one thing away from this note, let it be that it is critically important that you make time to play. If all you do is work and sleep, you will be miserable. Play rejuvenates the soul while at the same time exercising the mind and/or body. Play with your spouse, and your relationship will be stronger. Play with your children and you will watch their development progress in real time. Play with your friends and you will feel community. Play outdoors and you will feel alive. Play roleplaying games and you will have new and fantastical experiences. Play rhythm or action games and you will develop better reflexes. Play strategic games and you will develop an intuition for understanding real-world interpersonal interactions. Do not play mobile games because they are a plot by the Canadian Devil to capitalize on addiction to fund the development of Canadian infrastructure [2].
\\n
\\n
Sources:\\n[1] https://doi.org/10.1523/JNEUROSCI.1171-18.2018 \\n[2] https://southpark.cc.com/video-clips/jgkzdr/south-park-beelzaboot\\n (alt: South Park S18E06: \"Freemium Isn\\'t Free\")
Here's my troubleshooting guide for if your infant is having a lot of blowouts:
\\n\\n
If it's not that frequent (a couple times a week at the most), consider: shit happens. They'll blow out less when they transition to solids.
\\n
If they are blowing out through the leg, ensure the leg ruffles are fluffed out. You may need to ensure the waistband is not too high because that can sometimes create gaps along the legs.
\\n
If ② doesn't help, you may need to use the next size of diaper.
\\n
If they are blowing out up their backside, try to ensure the back of the diaper is as high up their backside as possible putting it on.
\\n
If ④ doesn't help, consider using a different brand of diaper, as it may fit your kid's butt better.
\\n
If ⑤ doesn't help, consider double-bagging your baby, by putting a diaper of a much larger size on over their regular one.
After several years of experimenting, I\\'ve found a regimen of vitamins and other supplements that have helped me manage some of my chronic health problems. Here\\'s what I take and why I take it:
\\n
Allergies
\\n
\\n
Cetirizine (10 mg, QD): I take this daily and it works like a charm. I used to take Fexofenadine (which seemed to have a stronger antihistamine effect), but if I missed a dose, I would get insomnia. Walmart sells a bottle with ~300 tablets, and that\\'s the best deal I\\'ve found.
\\n
Raw honey: I don\\'t have this nearly as often as I would like, but my dad cured his hay fever by having raw honey every day.
\\n
\\n
Digestive Health
\\n
\\n
Probiotic (1 cap, QD): I take a daily probiotic, and it helps a lot with lactose intolerance. I get the CVS Health Ultimate Digestive Probiotic with 80B CFU, but I have not really experimented with any others.
\\n
Lactase Enzyme (1 cap, PRN): I take these every time I have dairy. Between these and my daily probiotic, it\\'s like a subscription to the ability to lactose tolerance. The ones from Amazon seem to work better than the CVS brand or Lactaid (plus, the latter two taste disgusting).
\\n
Omeprazole (20 mg, QD): daily for erosive esophagitis/acid reflux. They sell these in bottles of 14, which is bogus.
\\n
Turmeric (1000 mg, QD): I am not sure if this helps or not, but I\\'ve seen sources online say that turmeric can be good for fighting fatty liver disease.
\\n
Fish Oil (1000 mg, QD): Like turmeric, I\\'ve seen things say that fish oil helps fight fatty liver disease.
\\n
IB Gard (1 cap, QD): I don\\'t take these anymore. I feel like they did help a little, but you have to be very careful with them. If you don\\'t have a sufficiently large meal after taking an IB Gard, the peppermint on your empty stomach will make you have cramps for a couple days.
\\n
\\n
In addition to these supplements, diet and exercise have made a huge impact for me. I think that my IBS was largely a symptom of fatty liver disease, so reducing fatty/high-carb foods and doing daily aerobic exercise has essentially cured me.
\\n
Headaches
\\n
\\n
Magnesium (250 mg, QD): recommended by my primary doctor. When I do have headaches these days, it\\'s typically because I forgot to take my magnesium the day before.
\\n
CoQ10 (100 mg, QD): recommended by my primary doctor. Beware, they\\'re kind of expensive (unless you get the 5-finger discount)
\\n
\\n
I\\'ve noticed that if I have a headache, it\\'s most likely because I\\'ve been sleep deprived most nights. There\\'s no substitute for getting adequate sleep, but Mg + CoQ10 are a good second.
\\n
Immune Support
\\n
\\n
Zinc (50 mg, QD): since I started taking zinc and drinking my immune support tea, I have been less susceptible to contracting illnesses. There have been times that Codi gets sick but I don\\'t.
\\n
Twinings Immune Support+ Blackberry Hibiscus and Elderberry Tea (PRN): this stuff is legit, and it tastes really good.
\\n
\\n
Misc
\\n
\\n
Vitamin D₃ (1000 IU, QD): I started taking this on a recommendation from my therapist when I was depressed. I always say that it made my mood about 2.5% better, which doesn\\'t seem like a lot, but when you\\'re at rock bottom, it\\'s pretty good for such an easy intervention. Things didn\\'t start getting better until after I started taking Vitamin D regularly. I call them \"little drops of sunshine.\"
\\n
Vitamin B Complex (1 tab, QD): I take these so I don\\'t have to get separate pills for the B vitamins I really need:
\\n
Niacin (B3): the skin on my fingers used to peel because I was B3 deficient.
\\n
Pyridoxine (B6): since I started taking this, I don\\'t really have nightmares anymore.
I\\'ve compiled a list of useful items to purchase in preparation for a baby. I\\'ve excluded a bunch of the obvious stuff (e.g. diapers).
\\n
Whole House / No Specific Location
\\n
\\n
Wall clocks: you will want these in every room that you intend to feed the baby in; it\\'s useful to track when and how long the baby feeds.
\\n
Wall plate extension cords (Example) paired with cable management boxes (Example): you can delay babyproofing until your baby starts to crawl, but it\\'s good to remove tripping hazards.
\\n
Baby wrap carrier (e.g. KeaBabies Baby Wrap): they work really well once you figure out how to use them properly, and they seem to be more comfortable for the parent and the baby (and more adaptive to the baby\\'s size).
\\n
Gloves: if you have sensitive skin like me, invest in gloves because you will be washing your hands much more frequently when the baby is around. Reusable nitrile gloves to use while washing dishes and cleaning the house, biodegradable disposable gloves to use while cooking, and fingerless moisturizing gloves to use whenever your hands get dry.
\\n
\\n
Nursery
\\n
\\n
Wipe warmer: this made diaper changes a lot less painful.
\\n
Disposable changing pads (Example): I typically try to get reusable alternatives to reduce waste, but I am okay with going disposable for anything that regularly gets poop on it .
\\n
Drawer organizers: a baby has lots of little things, so you will typically need to put multiple types of items in a single dresser drawer.
\\n
Drawer labels: these are especially useful for people who come to help
\\n
Hospital-grade breast pump (e.g. Spectra S1): we had the Momcozy S9 Pro (not hospital-grade), and was more uncomfortable while less effective at removing the milk. Your insurance might cover it.
\\n
Let-down collectors (e.g. Haakaa Ladybugs): keep one of these on an end table next to where you nurse or where you spend most of your time, because when let-down starts to leak, you will feel really bad if it goes to waste.
\\n
Resinol: smear this stuff on the baby\\'s butt after every change to keep diaper rash at bay.
\\n
Pacifier medicine dispenser: I don\\'t know how we would give him medicine without this.
\\n
Electric nail file: don\\'t be scared of this thing; it is really gentle; it can\\'t cause nearly as much damage to a baby as the baby\\'s own nails.
\\n
Swaddle bags with microfiber wings (Example): these make swaddling really easy, and they are super cute.
\\n
\\n
Parents\\' Room
\\n
\\n
Cosleeper cushion: if you can avoid having the baby sleep in your bed, that\\'s best, but if you can\\'t, get one of these.
\\n
\\n
Kitchen
\\n
\\n
UV Sterilizer: these things are super convenient (especially while traveling) because they are so much faster than steam sterilizers. Papablic makes a small one for pacifiers and the like, as well as a larger one for bottles and pump equipment; we have both and we love them.
\\n
Boon Cacti Bottle Brushes (Example): they\\'re fun and functional!
\\n
Reusable cleaning gloves: you will do a LOT of washing, especially if you use pumped breast milk. If you have sensitive skin, these are a must-have.
\\n
Drying pads: even if you have drying racks, water gets everywhere. It actually works well to set your drying rack on top of a drying pad.
\\n
Dedicated baby sink: if you\\'re lucky enough to have an extra sink in your kitchen like we do, pick one to use exclusively for baby stuff. It just makes life easier.
\\n
Momcozy Kleanpal Pro Baby Bottle Washer: if you can afford it, this is well worth the price. Before this, I spent over an hour a day on handwashing bottles and breast pump equipment; now, all I have to do a quick rinse and load it into the washer.
\\n
Water still: if you formula feed or if you have the aforementioned bottle washer, you will go through a lot of distilled water, so it\\'s useful to get a cheap water still (there are plenty <$100 that you can find online).
\\n
Thermos-type bottle warmer: steam-based bottle warmers get the bottles all wet and heat the plastic up high enough to cause microplastics to leak into the milk, and hot plate bottle warmers are very prone to leaking.
\\n
\\n
On-the-Go
\\n
\\n
A quality diaper bag: when you\\'re out and about, you live out of your diaper bag. We have the Zara Lemon Diaper Bag, and it works great.
\\n
Portable changing pad: public bathroom changing tables can get pretty nasty.
\\n
An infant carseat; not a convertible: it is difficult enough to get the baby into the seat even when you have the convenience of being able to bring the seat inside your home. We don\\'t have a stroller base that goes with the infant carseat, but I think it would be convenient since that thing is damn heavy.
\\n
')}"
- ]
- },
- "execution_count": 48,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": []
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "d48110fc",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "datetime.datetime(2024, 7, 12, 0, 0)"
- ]
- },
- "execution_count": 38,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "datetime(metadata['date'].year, metadata['date'].month, metadata['date'].day)"
+ "sites = {k:SiteConfig(**v) for k,v in config['sites'].items()}"
]
},
{
@@ -477,91 +79,9 @@
" # print(rss_feed.rss())"
]
},
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "b068c448",
- "metadata": {},
- "outputs": [],
- "source": []
- },
- {
- "cell_type": "code",
- "execution_count": 12,
- "id": "de229ef3",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "SiteConfig(base_url='http://localhost:8000', git_repo=None, build_cache='./build', assets=['./assets'], web_root='./dist', articles=['./pages/*.md'])"
- ]
- },
- "execution_count": 12,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "sites['main']"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 17,
- "id": "b7c12428",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "['./build/dogma-jimfinium/superlock.md',\n",
- " './build/dogma-jimfinium/sustainable-living.md',\n",
- " './build/dogma-jimfinium/stocking-up.md',\n",
- " './build/dogma-jimfinium/set-up-the-toys.md',\n",
- " './build/dogma-jimfinium/babies-love-trash.md',\n",
- " './build/dogma-jimfinium/do-what-you-love.md',\n",
- " './build/dogma-jimfinium/self-care-is-not-selfish.md',\n",
- " './build/dogma-jimfinium/temptation.md',\n",
- " './build/dogma-jimfinium/blowouts.md',\n",
- " './build/dogma-jimfinium/assets',\n",
- " './build/dogma-jimfinium/vitamins.md',\n",
- " './build/dogma-jimfinium/gear-for-new-parents.md']"
- ]
- },
- "execution_count": 17,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "glob.glob('./build/dogma-jimfinium/*')"
- ]
- },
{
"cell_type": "code",
"execution_count": 15,
- "id": "d5bcdfcb",
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'dogma-jimfinium'"
- ]
- },
- "execution_count": 15,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "os.path.dirname('dogma-jimfinium/assets')"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
"id": "70408b85",
"metadata": {},
"outputs": [],
@@ -570,7 +90,7 @@
" '''Generates HTML files for all of a given site's Markdown articles\n",
" by interpolating the contents and metadata into the HTML templates.'''\n",
"\n",
- " for filestem, (metadata, content) in index:\n",
+ " for filestem, (metadata, content) in index.items():\n",
" article = format_html_template(\n",
" 'templates/components/blog_article.html',\n",
" content = content,\n",
@@ -581,7 +101,7 @@
" page = format_html_template(\n",
" 'templates/pages/default.html',\n",
" content = article,\n",
- " **PARTIALS\n",
+ " partials = templates.partials\n",
" )\n",
"\n",
" with open(f'{site.web_root.rstrip('/')}/{filestem}.html', 'w') as f:\n",
@@ -644,9 +164,13 @@
"source": [
"def build_site(site: SiteConfig):\n",
"\n",
+ " # Initialize the build cache and web root, in case they do not exist.\n",
+ " os.makedirs(site.build_cache, exist_ok = True)\n",
+ " os.makedirs(site.web_root, exist_ok = True)\n",
+ "\n",
" # If the site is built from a git repo, pull that repo into the build cache.\n",
" if site.git_repo:\n",
- " pull_git_repo(site.git_repo)\n",
+ " pull_git_repo(site.git_repo, site.build_cache)\n",
"\n",
" # Copy the sites assets into the web root.\n",
" copy_assets(site)\n",
@@ -662,16 +186,12 @@
},
{
"cell_type": "code",
- "execution_count": 8,
+ "execution_count": 16,
"id": "a28b95a6",
"metadata": {},
"outputs": [],
"source": [
- "with open('config.yaml', 'r') as config_file:\n",
- " config = yaml.safe_load(config_file.read())\n",
- "sites = {k:SiteConfig(**v) for k,v in config['sites'].items()} \n",
- "\n",
- "build_site(sites['main'])"
+ "build_site(sites['dogma_jimfinium'])"
]
}
],
From 2dfb9fa7ed2a9d90578cec98e9437124b278e593 Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Sun, 1 Feb 2026 01:09:33 -0500
Subject: [PATCH 09/20] Modularization
---
config.yaml | 6 +-
data/lists.json | 1137 -----------------
data/pages.json | 50 -
data/socials.json | 27 -
jimsite.py | 342 -----
jimsite/__init__.py | 58 +
jimsite/articles.py | 103 ++
jimsite/assets.py | 43 +
jimsite/blog.py | 68 +
jimsite/common.py | 33 +
requirements.txt => jimsite/requirements.txt | 0
jimsite/templating.py | 123 ++
{templates => site/templates}/blog_post.html | 8 +-
.../components/blog_archive_li.html | 0
.../templates}/components/blog_article.html | 0
.../templates}/components/blog_tag.html | 0
.../templates}/pages/default.html | 8 +-
.../templates}/partials/default_css.html | 0
.../templates}/partials/footer.html | 0
.../templates}/partials/header.html | 0
.../templates}/partials/nav.html | 0
{templates => site/templates}/simple.html | 8 +-
testbench.ipynb | 82 +-
23 files changed, 452 insertions(+), 1644 deletions(-)
delete mode 100644 data/lists.json
delete mode 100644 data/pages.json
delete mode 100644 data/socials.json
delete mode 100644 jimsite.py
create mode 100644 jimsite/__init__.py
create mode 100644 jimsite/articles.py
create mode 100644 jimsite/assets.py
create mode 100644 jimsite/blog.py
create mode 100644 jimsite/common.py
rename requirements.txt => jimsite/requirements.txt (100%)
create mode 100644 jimsite/templating.py
rename {templates => site/templates}/blog_post.html (81%)
rename {templates => site/templates}/components/blog_archive_li.html (100%)
rename {templates => site/templates}/components/blog_article.html (100%)
rename {templates => site/templates}/components/blog_tag.html (100%)
rename {templates => site/templates}/pages/default.html (55%)
rename {templates => site/templates}/partials/default_css.html (100%)
rename {templates => site/templates}/partials/footer.html (100%)
rename {templates => site/templates}/partials/header.html (100%)
rename {templates => site/templates}/partials/nav.html (100%)
rename {templates => site/templates}/simple.html (55%)
diff --git a/config.yaml b/config.yaml
index 7a5e5ed..8065da7 100644
--- a/config.yaml
+++ b/config.yaml
@@ -1,20 +1,22 @@
author: Jim Shepich III
templates_folder: ./templates
site_defaults:
- base_url: http://localhost:8000
- web_root: ./dist
templates:
partials: ./templates/partials
components: ./templates/components
pages: ./templates/pages
sites:
main:
+ base_url: http://localhost:8000
+ web_root: ./dist
build_cache: ./site
assets:
- /assets
articles:
- ./pages/*.md
resume:
+ base_url: http://localhost:8000
+ web_root: ./dist
git_repo: ssh://gitea/jim/resume.git
build_cache: ./build/resume
assets:
diff --git a/data/lists.json b/data/lists.json
deleted file mode 100644
index 5608897..0000000
--- a/data/lists.json
+++ /dev/null
@@ -1,1137 +0,0 @@
-{
- "master":{
- "title":"Master List",
- "type":"master",
- "description":"A lot of the things I want to convey on this website (my likes, my dislikes, useful links, helpful tips, etc.) can be sorted into lists. So, I thought that it would be a good idea to make a page of all my lists instead of having all this information scattered across a bunch of different articles. I was inspired in large part by a book I saw called Listography: Your Life in Lists, which is a journal with a bunch of prompts for making lists of things about yourself."
- },
- "quotes":{
- "title":"My Favorite Quotes",
- "type":"quotes",
- "description":"This list contains quotes that I live by, quotes that have shaped my fundamental understanding of things, and quotes that I otherwise just like.",
- "list":[
- {
- "quote":"You're not going crazy. You're going sane in a crazy world!",
- "quotee":"The Tick (Ben Edlund)",
- "source":"The Tick vs. The Idea Men"
- },
- {
- "quote":"Disrespect is a two-way street.",
- "quotee":"Henry Rollins",
- "source":"Get in the Van: On the Road with Black Flag"
- },
- {
- "quote":"No man has the right to be an amateur in the matter of physical training. It is a shame for a man to grow old without seeing the beauty and strength of which his body is capable.",
- "quotee":"Socrates",
- "translated":true
- },
- {
- "quote":"A fascist worked out today. Did you?",
- "quotee":"Unknown Anarchist"
- },
- {
- "quote":"To progress again, man must remake himself. And he cannot remake himself without suffering. For he is both the marble and the sculptor. In order to uncover his true visage he must shatter his own substance with heavy blows of his hammer.",
- "quotee":"Alexis Carrel",
- "source":"Man, The Unknown",
- "translated":true
- },
- {
- "quote":"To train the mind, first train the body.",
- "quotee":"Izumi Curtis (Hiromu Arakawa)",
- "source":"Fullmetal Alchemist",
- "translated":true
- },
- {
- "title":"Bene Gesserit Litany Against Fear",
- "quote":"I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. I will face my fear. I will permit it to pass over me and through me. And when it has gone past, I will turn the inner eye to see its path. Where the fear has gone there will be nothing. Only I will remain.",
- "quotee":"Frank Herbert",
- "source":"Dune"
- },
- {
- "quote":"It's not a bug, it's a feature."
- },
- {
- "quote":"...do these things happen to other people or am I just the chosen one? I decided it was the latter...",
- "quotee":"Charles Bukowski",
- "source":"the freeway life"
- },
- {
- "quote":"There are two possible outcomes: if the result confirms the hypothesis, then you've made a measurement. If the result is contrary to the hypothesis, then you've made a discovery",
- "quotee":"Enrico Fermi"
- },
- {
- "quote":"No gods, no masters"
- },
- {
- "quote":"One must imagine Sisyphus happy.",
- "quotee":"Albert Camus",
- "source":"The Myth of Sisyphus",
- "translated":true
- },
- {
- "quote":"It is important to draw wisdom from many different places. If we take it from only one place, it becomes rigid and stale. Understanding others, the other elements, and the other nations will help you become whole.",
- "quotee":"Uncle Iroh",
- "source":"Avatar: the Last Airbender"
- },
- {
- "quote":"It is better to be a warrior in a garden than a gardener in a war."
- },
- {
- "quote":"Sucking at something is the first step towards being sorta good at someething.",
- "quotee":"Jake the Dog",
- "source":"Adventure Time"
- },
- {
- "quote":"You know, I mean, like, Old-Lady Science, you know? She's a real — You got to hang on tight, you know? Because she — she — she bucks pretty hard.",
- "quotee":"Morty Smith (Justin Roiland)",
- "source":"Rick & Morty"
- },
- {
- "quote":"Through even our darkest days, we must never cease creating. Each new invention brings value to the world, be it beauty, utility, or both.",
- "quotee":"Rashmi, Aether-Seer",
- "card":"Ornithopter of Paradise",
- "multiverseid":"522308"
- },
- {
- "quote":"What senses do we lack that we cannot see or hear another world all around us?",
- "quotee":"Frank Herbert",
- "source":"Dune"
- },
- {
- "quote":"Humankind cannot gain anything without first giving something in return. To obtain, something of equal value must be lost.",
- "quotee":"Hiromu Arakawa",
- "source":"Fullmetal Alchemist",
- "title":"Law of Equivalent Exchange"
- },
- {
- "quote":"I would rather have had you by my side than all the blue in the world.",
- "quotee":"Maggie Nelson",
- "source":"Bluets"
- },
- {
- "quote":"Where the head goes, the body follows.",
- "quotee":"Sensei Steve Tack"
- },
- {
- "quote":"When you enter the dojo, leave your emotions at the door.",
- "quotee":"Richard Goist"
- },
- {
- "quote":"You're in the top half of any group.",
- "quotee":"Houston Webb"
- },
- {
- "quote":"A fool knows no fear. A hero shows no fear.",
- "card":"Intrepid Hero",
- "multiverseid":"280320"
- },
- {
- "quote":"Only in mirrors do heroes find their equal.",
- "card":"Mirror Gallery",
- "multiverseid":"74555"
- },
- {
- "quote":"You can't truly call yourself “peaceful” unless you're capable of great violence. If you're not capable of violence you're not peaceful, you're harmless.",
- "quotee":"Stefan Grant"
- },
- {
- "quote":"The convoluted wording of legalisms grew up around the necessity to hide from ourselves the violence we intend toward each other. Between depriving a man of one hour from his life and depriving him of his life there exists only a difference of degree. You have done violence to him, consumed his energy. Elaborate euphemisms may conceal your intent to kill, but behind any use of power over another the ultimate assumption remains: \"I feed on your energy.\"",
- "quotee":"Frank Herbert",
- "source":"Dune Messiah"
- },
- {
- "quote":"If you don't think your life is worth more than someone else's, sign your donor card and kill yourself.",
- "quotee":"Dr. Gregory House",
- "source":"House MD (S5E09 Last Resort)"
- },
- {
- "quote":"If there can be no victory, then I will fight forever.",
- "quotee":"Koth of the Hammer",
- "card":"Darksteel Plate",
- "multiverseid":"213749"
- },
- {
- "quote":"If you want something done right, do it yourself.",
- "quotee":"Grandma Mary Kay Shepich"
- },
- {
- "quote":"Now, look, we're gonna be dealing with some real serious stuff today. You might have heard of it. It's called math! And without it, none of us would even exist, so let's jump right in.",
- "quotee":"Mr. Goldenfold",
- "source":"Rick & Morty"
- },
- {
- "quote":"Life was not gentle to him,\n And the elements so mixed in him\nThat he made warfare on life\nIn the which he was slain.",
- "quotee":"Edgar Lee Masters",
- "source":"Spoon River Anthology (Cassius Hueffer)"
- },
- {
- "quote":"You don't tug on superman's cape, you don't spit into the wind, you don't pull the mask off that old lone ranger, and you don't mess around with Jim.",
- "quotee":"Jim Croce",
- "source":"You Don't Mess Around with Jim"
- },
- {
- "quote":"Inspiration is reciprocal: we all have a responsibility to each other to create.",
- "card":"Oviya Pashiri, Sage Lifecrafter",
- "multiverseid":"417738"
- },
- {
- "quote":"You're the only one who can get up when you're down. No one else.",
- "quotee":"Daniel LaRusso",
- "source":"Cobra Kai"
- },
- {
- "quote":"A true champion never stops training. You gotta keep moving forward, or else you could get stuck exactly where you are.",
- "quotee":"Johnny Lawrence",
- "source":"Cobra Kai"
- },
- {
- "quote":"Some convictions are so strong that the world must break to accommodate them.",
- "card":"Vindicate",
- "multiverseid":"19135"
- },
- {
- "quote":"Madness and genius are separated only by degrees of success.",
- "quotee":"Sidar Jabari",
- "card":"Inspiration",
- "multiverseid":"3642"
- }
-
- ]
- },
- "words":{
- "title":"Words I Like",
- "description":"This list contains a bunch of words I like. Some of them I like because of how they sound, some of them I like because of what they mean, and some of them I like because of what I associate with them.",
- "list":["guppy","wrought","confectionery","immaculate","phantasmal","imperious","chevalier","glisten","hasami","genuflect","spectre","delineate","confluence","libation","Pythonic"]
- },
- "mal":{
- "title":"All of the Anime I've Watched",
- "description":"MyAnimeList",
- "type":"external",
- "link":"https://myanimelist.net/animelist/epicshepich"
- },
- "goodreads":{
- "title":"Books I've Read Since High School",
- "description":"My Goodreads list",
- "type":"external",
- "link":"https://www.goodreads.com/review/list/110528977-jim-shepich?shelf=read"
- },
- "mnemonics":{
- "title":"Mnemonics I Use",
- "type":"key-value",
- "description":"When you've been in school as long as I have, you pick up a few tricks to help remember things.
Since antiquity, culture was passed down throughout the ages via oral tradition. Grand works of epic poetry, sacred knowledge, and more were passed from generation to generation, with each new generation memorizing these works through the use of mantras, chants, and verse. Sounds and rhythms carved out a place in people's memories that stood the test of time.
When I memorized the Periodic Table of the Elements, I found that my general strategy was to just say a group of symbols as if they spelled out a word, and then I memorized that sound (it got a little bit awkward with dysprosium-holmium-erbium). A lot of the mnemonics I present here are short, otherwise-meaningless mantras such as Roy G. Biv, SOH CAH TOA, etc. And with these mantras, I welcome you into my own oral tradition.",
- "list":[
- {
- "k":"AnOx and a RedCat",
- "v":"In electrochemical cells (both voltaic/galvanic and electrolytic), oxidation occurs at the anode, and reduction occurs at the cathode."
- },
- {
- "k":"Oil Rig",
- "v":"Oxidation is loss of electrons, Reduction is gain of electrons."
- },
- {
- "k":"M Vem Jsun P",
- "v":"Listed in order of distance from the sun, the planets of the solar system are: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, Neptune, (Pluto)"
- },
- {
- "k":"Ben Stiller's Grandma Likes Corn",
- "v":"Strata of the epidermis listed in deep-to-superficial order: basale, spinosum, granulum, lucidum, corneum"
- },
- {
- "k":"WXwater xylem, OPorganics phloem",
- "v":"The types of transport tissue in vascular plants, xylem transports fresh water up from the roots, while phloem transports sugars and other organics down from the leaves."
- },
- {
- "k":"SOH CAH TOA",
- "v":"the trigonometric functions are ratios of the side lengths of a right triangle: sine is opposite over hypotenuse, cosine is adjacent over hypotenuse, and tangent is opposite over adjacent."
- },
- {
- "k":"HOMES",
- "v":"The Great Lakes of Michigan are: Huron, Ontario, Michigan, Erie, Superior."
- },
- {
- "k":"Roy G. Biv",
- "v":"The colors of a rainbow (visible light) in order of increasing wavelength is: red, orange, yellow, green, blue, indigo, violet."
- }
- ]
- },
- "albums":{
- "type":"gallery",
- "subtype":"album",
- "title":"My Favorite Albums",
- "description":"Albums I listen to in the car, albums I listen to when I'm going to sleep, albums I listen to while I work, and more. Since, I was a kid, I've had an unhealthy relationship with collecting things (bordering on hoarding), so I won't allow myself to get into collecting vinyls, but think of this as my digital \"vinyl\" collection. I've noticed that an unusally large fraction of these albums were released in 1977 and/or have covers that depict spaceships.",
- "list":[
- {
- "title":"Boston",
- "artist":"Boston",
- "year":"1976",
- "link":"https://youtu.be/wu77H0gsAYY",
- "cover":"https://i.pinimg.com/736x/53/4c/6e/534c6e1931b4c123d1c14d0e65ad25eb--more-than-a-feeling-boston-boston.jpg"
- },
- {
- "title":"Rumours",
- "artist":"Fleetwood Mac",
- "year":"1977",
- "link":"https://youtu.be/uzEt9cATWFw",
- "cover":"https://qph.fs.quoracdn.net/main-qimg-b3593ea30145790da58d4f39f4ed0231"
- },
- {
- "title":"Fleetwood Mac",
- "artist":"Fleetwood Mac",
- "year":"1975",
- "link":"https://youtu.be/qs-hn1y7T7c",
- "cover":"https://static-musique.qub.ca/images/covers/7b/ov/n65tnqwnjov7b_max.jpg"
- },
- {
- "title":"The Green Letter",
- "artist":"Mitsukiyo",
- "year":"2017",
- "link":"https://youtu.be/e2MhdE9szv8",
- "cover":"https://i.discogs.com/8x-MsOu_eY-RZkr1qyyxT3jyYWMYw5IteCG3KbHoQ-Y/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWltYWdlcy9SLTEz/NDQzMzA5LTE1NTQz/MTAxMjktMzExNi5q/cGVn.jpeg"
- },
- {
- "title":"Moenie and Kitchi",
- "artist":"Gregory and the Hawk",
- "year":"2008",
- "link":"https://youtu.be/EMZOtHIL7QM",
- "cover":"https://f4.bcbits.com/img/0011833953_10.jpg"
- },
- {
- "title":"Don't Look Back",
- "artist":"Boston",
- "year":"1978",
- "link":"https://youtu.be/s2_xH7vOK7k",
- "cover":"https://i.etsystatic.com/20562574/r/il/6769bc/2377662835/il_570xN.2377662835_juft.jpg"
- },
- {
- "title":"Out of the Blue",
- "artist":"Electric Light Orchestra",
- "year":"1977",
- "link":"https://youtu.be/2f9CoaIH9FE",
- "cover":"https://m.media-amazon.com/images/I/71-4vcunM+L._SL1500_.jpg"
- },
- {
- "title":"Master of Puppets",
- "artist":"Metallica",
- "year":"1986",
- "link":"https://youtu.be/K6LA7v1PApU",
- "cover":"https://m.media-amazon.com/images/I/71SziOTzXrL._AC_SL1425_.jpg"
- },
- {
- "title":"Ride the Lightning",
- "artist":"Metallica",
- "year":"1984",
- "link":"https://youtu.be/H0XGswUuZU0",
- "cover":"https://www.revolvermag.com/sites/default/files/styles/original_image__844px_x_473px_/public/media/section-media/ridethelightning.jpg?itok=Fd0KtaS2×tamp=1549044407"
- },
- {
- "title":"The Black Album",
- "artist":"Metallica",
- "year":"1991",
- "link":"https://youtu.be/DqDeH3hwxfw",
- "cover":"https://m.media-amazon.com/images/I/71z4zm5yohL._SL1425_.jpg"
- },
- {
- "title":"Point of Know Return",
- "artist":"Kansas",
- "year":"1977",
- "link":"https://youtu.be/LepSiqpC6hA",
- "cover":"http://static-1.ivoox.com/audios/2/9/5/0/7241500500592_XXL.jpg"
- },
- {
- "title":"Leftoverture",
- "artist":"Kansas",
- "year":"1976",
- "link":"https://youtu.be/f5jom_YYeGU",
- "cover":"https://m.media-amazon.com/images/I/91CLL0FhHbL._SL1500_.jpg"
- },
- {
- "title":"Heroes",
- "artist":"David Bowie",
- "year":"1977",
- "link":"https://youtu.be/TFLLE8LPA_k",
- "cover":"https://m.media-amazon.com/images/I/81VycvvC49L._SL1300_.jpg"
- },
- {
- "title":"Frontiers",
- "artist":"Journey",
- "year":"1983",
- "link":"https://youtu.be/eD7HC7g0dG8",
- "cover":"https://m.media-amazon.com/images/I/81RPFmr49sL._SL1500_.jpg"
- },
- {
- "title":"...And Justice For All",
- "artist":"Metallica",
- "year":"1988",
- "link":"https://youtu.be/7PktvdsPXjI",
- "cover":"https://images.genius.com/79908883b42660f49d71b42f4f82216c.1000x1000x1.jpg"
- },
- {
- "title":"Escape",
- "artist":"Journey",
- "year":"1981",
- "link":"https://youtu.be/T8gTlHInJIA",
- "cover":"https://i.discogs.com/J6fEDZdk6kTWDArqmpjsTo5rrHnl9jUr655FedNoErU/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWltYWdlcy9SLTU0/MzIwNS0xMzY1NDUw/ODczLTkzMTQuanBl/Zw.jpeg"
- },
- {
- "title":"Babymetal",
- "artist":"Babymetal",
- "year":"2014",
- "link":"https://youtu.be/yIVRs6YSbOM",
- "cover":"https://pbs.twimg.com/media/D0QrY-UX0AUTyfe?format=jpg&name=4096x4096"
- },
- {
- "title":"OMNI",
- "artist":"Minus the Bear",
- "year":"2010",
- "link":"https://www.youtube.com/watch?v=0RsY_mjiqk4&list=PL9B0A0664758349FA",
- "cover":"https://m.media-amazon.com/images/I/91DhrWe+g4L._SL1500_.jpg"
- },
- {
- "title":"The Stranger",
- "artist":"Billy Joel",
- "year":"1977",
- "link":"https://youtu.be/DglOd7Wdueg",
- "cover":"https://m.media-amazon.com/images/I/71JxCGwe9cL._SL1500_.jpg"
- },
- {
- "title":"Planet of Ice",
- "artist":"Minus the Bear",
- "year":"2007",
- "link":"https://www.youtube.com/watch?v=iZaBD3pymH0&list=OLAK5uy_lvNJl_TeMmqtjJJj0qH3ZhW5msijTqhvQ",
- "cover":"https://f4.bcbits.com/img/a2748888567_10.jpg"
- },
- {
- "title":"idyll",
- "artist":"Steven Naylor",
- "year":"2021",
- "link":"https://youtu.be/dN6KMdvx8sM",
- "cover":"https://i.scdn.co/image/ab67616d0000b2739117378d83714dd38a0340b7"
- },
- {
- "title":"Ocean Eyes",
- "artist":"Owl City",
- "year":"2009",
- "link":"https://www.youtube.com/watch?v=Kiwea1iV6cs&list=PLOKKW8hN_oU8Tc41hbE8QzuL6Zi8Ri0pt",
- "cover":"https://m.media-amazon.com/images/I/51wQ1iymmsL.jpg"
- },
- {
- "title":"All Things Bright and Beautiful",
- "artist":"Owl City",
- "year":"2011",
- "link":"https://www.youtube.com/watch?v=LhTHXZfw3vY&list=PL23315D4CDDFE9D6C",
- "cover":"https://m.media-amazon.com/images/I/71pTeXLg9kL._SL1200_.jpg"
- },
- {
- "title":"An Airplane Carried Me To Bed",
- "artist":"Sky Sailing",
- "year":"2010",
- "link":"https://www.youtube.com/watch?v=SlOQPOR8z7g&list=PL7MiJHw2jDfFJOh3NoL6x_2EtYExminHg",
- "cover":"https://m.media-amazon.com/images/I/91nhQdHvTrL._SL1500_.jpg"
- },
- {
- "title":"You Don't Mess Around with Jim",
- "artist":"Jim Croce",
- "year":"1972",
- "link":"https://youtu.be/38B81cpwjNA",
- "cover":"https://m.media-amazon.com/images/I/71ld2tNpCEL._SL1425_.jpg"
- }
- ]
- },
- "_test":{
- "title":"Test",
- "hidden":true,
- "list":["My Favorite Movies",{"test":"It works"},"My Favorite Food",{"nested lists":["Useful Websites","Favorite foods",{"title":"cereals","dropdown":true,"list":["Cocoa Pebbles","Froot Loops"]}]}]
- },
- "foods":{
- "title":"My Favorite Foods",
- "dropdown":true,
- "sections":[
- {
- "title":"Breakfast",
- "list":[{"Cereal":["Reese's Puffs","Honeycombs","Frosted Mini Wheats","Eggo Cereal","Froot Loops","Frosted Flakes"]}, "Crepes", "Pancakes","Bacon","Hash browns","Smokey links","French toast","French fries"]
- },
- {
- "title":"Snacks",
- "list":["Pretzels (especially Old Dutch)","Popcorn (especially Orville Redenbacher)",{"Cheese":["Swiss","Colby Jack","Cheddar","Mozzarella"]},{"Candy":["Take 5","Kit Kat","Snickers","Reese's cups","Milky Way",{"Gummy bears":["Albanese","Meijer brand"]}]}]
- },
- {
- "title":"Fruits",
- "description":"Here are some of my favorite fruits presented in a tier-list along with tips on how to get the best experience out of them. One metric I use to rank the fruits that you might find odd is the shelf life. The reason I care about the shelf life of my fruits is that I hate going to the grocery store, so I prefer fruits that I can stockpile.",
- "sections":[
- {
- "title":"S-Tier",
- "list":[{"Mango":"When you can smell the aroma through the skin, the mango is ready to eat. Usually takes around a week from when you buy them. I loved Rubicon mango juice so much as a kid that my grandparents went to Toronto to buy it for me. Mango is my preferred flavor of smoothie."},{"Watermelon":"The best watermelons sound hollow when you knock on them and have a faded spot on their rind. Nothing beats a cool, juicy watermelon on a warm summer afternoon."},{"Lychee":"Out of all the fruits on my list, I think lychees and watermelons are the only ones that are consistently sweet. Lychees dry out your mouth a little bit, which annoying, but it's also kinda neat because that's not what you'd expect from a fruit. Lychee flavored Ramune is awesome."}]
- },
- {
- "title":"A-Tier",
- "list":[{"Apple":"Loses points because they can be tough and hard to eat, and sometimes they just taste too sour, but follow my instructions and they'll usually be good. Plus, they can last up to 3 weeks on the shelf, and they're very versatile (apple juice, cider, doughnuts, pies, etc.). Honeycrisp is my favorite cultivar, and the best honeycrisps are the biggest and the yellowest. Apples usually taste better a week after you buy them. I'm also a fan of the Opal cultivar, which tastes like a cross-breed with pears."},{"Banana":"Taste is A-tier, but the short shelf life (and concomitant fruit flies) is a big negative. What brings them back is that bananas have such good synergy with other fruits (and other foods: see peanutbutter banana sandwich). Healthiest when they're green, sweetest when they're brown, and best when they're just yellow. I like to make banana smoothies: 150 mL milk, two bananas, a tablespoon of sugar, optionally a spoon or two of yogurt, and 7 ice cubes."},{"Peach":"Taste is S-tier, but they go bad way too fast and the window where they are soft enough to eat but not overripe is way too small. I've heard you can tell that a peach is ready to eat when it's as squishy as the meat of your thumb, but obviously I've still had issues."},{"Pear":"Like peaches, ripe pears taste divine. I don't like flavors that are too overpowering, and I find that pears are perfectly subtle. However, unless the pear is at the perfect point of ripeness, it is too tough to bite into and tastes starchy like an unripe banana."},{"Cherry":"It's hard to come by bad cherries in Michigan (just avoid the maraschinos). I'd say that the taste is B-tier, but they get points because the seeds are so fun to spit."},{"Clementine":"Ironically, the \"sweet orange\" can often be a bit too sour for my taste. Plus, I got the stomach flu during my orange juice phase, so I developed a bit of an aversion. But, clementines are usually pretty sweet, don't have that undertone that I dislike, and they last pretty long on the shelf."},{"Grape":"Grapes taste pretty good. They aren't super sweet or really juicy like other fruits, but they still taste good. The great advantage of grapes over all other fruits is that you don't need to wash your hands or your face after eating them because of their durable dry skin."}]
- },
- {
- "title":"B-Tier",
- "list":[{"Blueberry":"Good blueberries can be really good, but bad blueberries can be really sour and nasty, and the worst thing is that since they're so small, it's always a mixed bag when you buy them. I've noticed that blueberries, like apples, tend to follow a bigger is better rule."},{"Raspberry":"Like blueberries, raspberries are a mixed bag. On average, I'd say the taste of plain raspberries is C-tier, but raspberry jelly is really good, so here they are."},{"Apricot":"They have a good flavor, but like peaches and pears, apricots have a small window where they are squishy but not overripe."}]
- }
- ]
- },
- {
- "title":"Sandwiches",
- "list":[{"Peanut butter and...":["Jelly","Banana","M&M","Chocolate chip","Nutella"]},"Ham and Cheese","Hamburger"]
- }
- ]
- },
- "mtg":{
- "title":"My Favorite Magic: the Gathering Cards",
- "type":"gallery",
- "subtype":"mtg-card",
- "description":"",
- "sections":[
- {
- "title":"Best of the Best",
- "list":[
- {
- "name":"Battle of Wits",
- "multiverseid":"83133"
- },
- {
- "name":"Enter the Infinite",
- "multiverseid":"366411"
- },
- {
- "name":"Intrepid Hero",
- "multiverseid":"280320"
- },
- {
- "name":"Ral Zarek",
- "multiverseid":"470744"
- },
- {
- "name":"Tamiyo, the Moon Sage",
- "multiverseid":"240070"
- },
- {
- "name":"Zuran Spellcaster",
- "multiverseid":"184683"
- },
- {
- "name":"Sublime Epiphany",
- "multiverseid":"485397"
- },
- {
- "name":"Ponder",
- "multiverseid":"519160"
- },
- {
- "name":"Force of Will",
- "multiverseid":"489724"
- },
- {
- "name":"Niv Mizzet, the Firemind",
- "multiverseid":"96952"
- }
- ]
- },
- {
- "title":"Best Art/Aesthetics",
- "list":[
- {
- "name":"Divination",
- "multiverseid":"447187"
- },
- {
- "name":"Syr Elenora, the Discerning",
- "multiverseid":"473029"
- },
- {
- "name":"Serra Angel",
- "multiverseid":"370602"
- },
- {
- "name":"Captain of the Watch",
- "multiverseid":"394354"
- },
- {
- "name":"Sunblade Elf",
- "multiverseid":"383406"
- },
- {
- "name":"Angelic Destiny",
- "multiverseid":"220230"
- },
- {
- "name":"Island",
- "multiverseid":"289315"
- },
- {
- "name":"Island",
- "multiverseid":"473219"
- },
- {
- "name":"Aether Storm",
- "multiverseid":"184722"
- },
- {
- "name":"Leviathan",
- "multiverseid":"26619"
- },
- {
- "name":"Time of Ice",
- "multiverseid":"442958"
- },
- {
- "name":"Auramancer",
- "multiverseid":"441995"
- },
- {
- "name":"Wall of Frost",
- "multiverseid":"383432"
- },
- {
- "name":"Gift of Orzhova",
- "multiverseid":"366339"
- },
- {
- "name":"Fylgja",
- "multiverseid":"2686"
- },
- {
- "name":"Call to Mind",
- "multiverseid":"208218"
- },
- {
- "name":"Thassa's Bounty",
- "multiverseid":"373662"
- },
- {
- "name":"Ephara, God of the Polis",
- "multiverseid":"378517"
- },
- {
- "name":"Ephara's Radiance",
- "multiverseid":"378381"
- },
- {
- "name":"Stream of Unconsciousness",
- "multiverseid":"152720"
- },
- {
- "name":"Hana Kami",
- "multiverseid":"370475"
- }
- ]
- },
- {
- "title":"Best Mechanics",
- "list":[
- {
- "name":"Cheatyface",
- "multiverseid":"479416"
- },
- {
- "name":"Pestilence",
- "multiverseid":"2119"
- },
- {
- "name":"Underworld Connections",
- "multiverseid":"545758"
- },
- {
- "name":"Isochron Scepter",
- "multiverseid":"292752"
- },
- {
- "name":"Cramped Bunker",
- "multiverseid":"439518"
- },
- {
- "name":"Skywise Teachings",
- "multiverseid":"438639"
- },
- {
- "name":"Serpent Generator",
- "multiverseid":"159826"
- },
- {
- "name":"Judge's Familiar",
- "multiverseid":"460143"
- },
- {
- "name":"Elite Arcanist",
- "multiverseid":"370747"
- },
- {
- "name":"Crackling Perimeter",
- "multiverseid":"366255"
- },
- {
- "name":"Aetherflux Reservoir",
- "multiverseid":"417765"
- },
- {
- "name":"Lighthouse Chronologist",
- "multiverseid":"193590"
- },
- {
- "name":"Intruder Alarm",
- "multiverseid":"5174"
- },
- {
- "name":"Lobber Crew",
- "multiverseid":"289218"
- },
- {
- "name":"Dream Halls",
- "multiverseid":"397553"
- },
- {
- "name":"Arcane Melee",
- "multiverseid":"376246"
- },
- {
- "name":"Disruptive Student",
- "multiverseid":"21123"
- },
- {
- "name":"The Magic Mirror",
- "multiverseid":"473013"
- },
- {
- "name":"Chalice of Life / Chalice of Death",
- "multiverseid":["226735","226721"]
- },
- {
- "name":"Ovinomancer",
- "multiverseid":"108863"
- },
- {
- "name":"Talrand, Sky Summoner",
- "multiverseid":"253701"
- },
- {
- "name":"Odric, Master Tactician",
- "multiverseid":"259670"
- },
- {
- "name":"Quicksilver Dagger",
- "multiverseid":"292757"
- },
- {
- "name":"Young Pyromancer",
- "multiverseid":"370600"
- },
- {
- "name":"Koth of the Hammer",
- "multiverseid":"266362"
- },
- {
- "name":"Curiosity",
- "multiverseid":"489304"
- },
- {
- "name":"Censorship",
- "multiverseid":"9747"
- },
- {
- "name":"Bureaucracy",
- "multiverseid":"9778"
- },
- {
- "name":"Stuffy Doll",
- "multiverseid":"509639"
- },
- {
- "name":"Epic Struggle",
- "multiverseid":"33697"
- },
- {
- "name":"Azor's Elocutors",
- "multiverseid":"265418"
- }
- ]
- },
- {
- "title":"Best Flavor Text",
- "list":[
- {
- "name":"Ornithopter of Paradise",
- "multiverseid":"522308"
- },
- {
- "name":"Mirror Gallery",
- "multiverseid":"74555"
- },
- {
- "name":"Darksteel Plate",
- "multiverseid":"213749"
- },
- {
- "name":"Oviya Pashiri, Sage Lifecrafter",
- "multiverseid":"417738"
- },
- {
- "name":"Vindicate",
- "image":"https://c1.scryfall.com/file/scryfall-cards/large/front/2/c/2c2d88dd-813a-4cd5-9a6a-ca6f80564078.jpg?1561756842",
- "link":"https://scryfall.com/card/g07/4/vindicate"
- },
- {
- "name":"Raging Goblin",
- "multiverseid":"393980"
- },
- {
- "name":"Inspiration",
- "multiverseid":"3642"
- },
- {
- "name":"Pygmy Giant",
- "multiverseid":"74333"
- }
- ]
- },
- {
- "title":"Best Fake Cards",
- "list":[
- {
- "name":"Slidshocking Krow",
- "image":"https://i.imgur.com/57fkA9S.jpg",
- "link":"https://www.reddit.com/r/MTGLardFetcher/comments/6la7e4/roborosewater_slidshocking_krow/"
- },
- {
- "name":"Eight Fucking Bears",
- "image":"https://i.imgur.com/QT4H77w.jpg",
- "link":"https://www.reddit.com/r/magicTCG/comments/3x2qwu/eight_fucking_bears/"
- },
- {
- "name":"Soulbond Giant",
- "image":"http://cdn.themis-media.com/media/global/images/library/deriv/940/940220.jpg",
- "link":"https://www.escapistmagazine.com/the-most-interesting-magic-the-gathering-cards-made-by-artificial-intelligence/"
- },
- {
- "name":"Black Lotus But Bri'ish",
- "image":"https://i.redd.it/zv7jw7bur6871.png",
- "link":"https://www.reddit.com/r/magicthecirclejerking/comments/oa5yc8/black_lotus_but_briish/"
- },
- {
- "name":"Countercounterspell",
- "image":"https://i.imgur.com/wjm2Haw.png",
- "link":"https://www.reddit.com/r/MTGLardFetcher/comments/ghi157/countercounterspell/"
- },
- {
- "name":"Stroke",
- "image":"https://i.redd.it/onnf1xqgk7d51.png",
- "link":"https://www.reddit.com/r/MTGLardFetcher/comments/hy8vk7/stroke/"
- },
- {
- "name":"Enter the Finite",
- "image":"https://i.redd.it/5hi1qup8l7661.jpg",
- "link":"https://www.reddit.com/r/MTGLardFetcher/comments/kggc1a/enter_the_finite/"
- },
- {
- "name":"[V]indicate",
- "image":"https://i.redd.it/k4ng1j106dw71.jpg",
- "link":"https://www.reddit.com/r/MTGLardFetcher/comments/qi9haz/indicate/"
- },
- {
- "name":"Crip Moon",
- "image":"https://i.redd.it/9zwqyay1aiz71.png",
- "link":"https://www.reddit.com/r/MTGLardFetcher/comments/qtjwg3/an_idea_that_came_to_me_visionlike/"
- },
- {
- "name":"Krenko's Command",
- "image":"https://i.redd.it/7w67a5ml3wb51.png",
- "link":"https://www.reddit.com/r/MTGLardFetcher/comments/hu9ij2/krenkos_command/"
- },
- {
- "name":"Strict Denial",
- "image":"https://i.redd.it/2mp6r83ywlc41.jpg",
- "link":"https://www.reddit.com/r/MTGLardFetcher/comments/et0vjw/strict_denial/"
- }
- ]
- }
- ]
- },
- "movies":{
- "title":"My Favorite Movies",
- "description":"This list has most of the movies I consider to be my favorites, except for the ones I forgot to add. I am not much of a film buff, so if something is missing that it seems should be on here, it's either because I forgot it or because I haven't seen it.",
- "type":"gallery",
- "subtype":"movie",
- "list":[
- {
- "title":"The Road to El Dorado",
- "year":"2000",
- "poster":"https://m.media-amazon.com/images/M/MV5BOTEzNWIwMzctOTE1YS00YjIyLTgwZGEtMTMxZDAzNzlmODMxXkEyXkFqcGdeQXVyMjgyMDk1MzY@._V1_FMjpg_UX1000_.jpg"
- },
- {
- "title":"Shrek",
- "year":"2001",
- "poster":"https://m.media-amazon.com/images/M/MV5BOGZhM2FhNTItODAzNi00YjA0LWEyN2UtNjJlYWQzYzU1MDg5L2ltYWdlL2ltYWdlXkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_FMjpg_UX1007_.jpg"
- },
- {
- "title":"Click",
- "year":"2006",
- "poster":"https://m.media-amazon.com/images/M/MV5BMTA1MTUxNDY4NzReQTJeQWpwZ15BbWU2MDE3ODAxNw@@._V1_FMjpg_UX450_.jpg"
- },
- {
- "title":"Princess Mononoke",
- "year":"1997",
- "poster":"https://m.media-amazon.com/images/M/MV5BNGIzY2IzODQtNThmMi00ZDE4LWI5YzAtNzNlZTM1ZjYyYjUyXkEyXkFqcGdeQXVyODEzNjM5OTQ@._V1_.jpg"
- },
- {
- "title":"Spirited Away",
- "year":"2001",
- "poster":"https://m.media-amazon.com/images/M/MV5BMjlmZmI5MDctNDE2YS00YWE0LWE5ZWItZDBhYWQ0NTcxNWRhXkEyXkFqcGdeQXVyMTMxODk2OTU@._V1_.jpg"
- },
- {
- "title":"Space Jam",
- "year":"1996",
- "poster":"https://m.media-amazon.com/images/M/MV5BMDgyZTI2YmYtZmI4ZC00MzE0LWIxZWYtMWRlZWYxNjliNTJjXkEyXkFqcGdeQXVyNjY5NDU4NzI@._V1_.jpg"
- },
- {
- "title":"Fight Club",
- "year":"1999",
- "poster":"https://m.media-amazon.com/images/M/MV5BMmEzNTkxYjQtZTc0MC00YTVjLTg5ZTEtZWMwOWVlYzY0NWIwXkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_.jpg"
- },
- {
- "title":"Shrek 2",
- "year":"2002",
- "poster":"https://m.media-amazon.com/images/M/MV5BMDJhMGRjN2QtNDUxYy00NGM3LThjNGQtMmZiZTRhNjM4YzUxL2ltYWdlL2ltYWdlXkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_.jpg"
- },
- {
- "title":"Elf",
- "year":"2003",
- "poster":"https://m.media-amazon.com/images/M/MV5BMzUxNzkzMzQtYjIxZC00NzU0LThkYTQtZjNhNTljMTA1MDA1L2ltYWdlL2ltYWdlXkEyXkFqcGdeQXVyMTMxODk2OTU@._V1_FMjpg_UX1000_.jpg"
- },
- {
- "title":"Die Hard",
- "year":"1988",
- "poster":"https://m.media-amazon.com/images/M/MV5BZjRlNDUxZjAtOGQ4OC00OTNlLTgxNmQtYTBmMDgwZmNmNjkxXkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_.jpg"
- },
- {
- "title":"The Karate Kid",
- "year":"1984",
- "poster":"https://m.media-amazon.com/images/M/MV5BNTkzY2YzNmYtY2ViMS00MThiLWFlYTEtOWQ1OTBiOGEwMTdhXkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_.jpg"
- },
- {
- "title":"Tim and Eric's Billion Dollar Movie",
- "year":"2012",
- "poster":"https://m.media-amazon.com/images/M/MV5BMTU0NTQ5NDYwMV5BMl5BanBnXkFtZTcwNjUzNzUxNw@@._V1_.jpg"
- },
- {
- "title":"Monty Python and the Holy Grail",
- "year":"1975",
- "poster":"https://m.media-amazon.com/images/M/MV5BN2IyNTE4YzUtZWU0Mi00MGIwLTgyMmQtMzQ4YzQxYWNlYWE2XkEyXkFqcGdeQXVyNjU0OTQ0OTY@._V1_.jpg"
- },
- {
- "title":"Dragon Quest: Your Story",
- "year":"2019",
- "poster":"https://m.media-amazon.com/images/M/MV5BM2Q5YTI0NTQtOGFlOC00MTEzLTg2NDYtM2VhNDk1ZTllNTNiXkEyXkFqcGdeQXVyMjU0ODQ5NTA@._V1_FMjpg_UX1000_.jpg"
- },
- {
- "title":"Dune",
- "year":"2021",
- "poster":"https://m.media-amazon.com/images/M/MV5BN2FjNmEyNWMtYzM0ZS00NjIyLTg5YzYtYThlMGVjNzE1OGViXkEyXkFqcGdeQXVyMTkxNjUyNQ@@._V1_.jpg"
- },
- {
- "title":"The Princess Bride",
- "year":"1987",
- "poster":"https://m.media-amazon.com/images/M/MV5BMGM4M2Q5N2MtNThkZS00NTc1LTk1NTItNWEyZjJjNDRmNDk5XkEyXkFqcGdeQXVyMjA0MDQ0Mjc@._V1_.jpg"
- },
- {
- "title":"Sky High",
- "year":"2005",
- "poster":"https://m.media-amazon.com/images/M/MV5BZjA2NmY1OTQtMjE4Mi00NGRkLWFmODUtM2Q3ZTRlYjZhNWYwXkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_.jpg"
- },
- {
- "title":"The Pacifier",
- "year":"2005",
- "poster":"https://m.media-amazon.com/images/M/MV5BMTE5MTcxOTQxNl5BMl5BanBnXkFtZTYwMzk3Nzg2._V1_FMjpg_UX1000_.jpg"
- },
- {
- "title":"Back to the Future",
- "year":"1985",
- "poster":"https://m.media-amazon.com/images/M/MV5BZmU0M2Y1OGUtZjIxNi00ZjBkLTg1MjgtOWIyNThiZWIwYjRiXkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_.jpg"
- },
- {
- "title":"Matilda",
- "year":"1996",
- "poster":"https://m.media-amazon.com/images/M/MV5BNzRhYmZhOWEtZjA4NC00MTU1LWE1ODgtN2Q5Y2QxN2JlNDljXkEyXkFqcGdeQXVyMjUzOTY1NTc@._V1_.jpg"
- },
- {
- "title":"Kung Fu Panda",
- "year":"2008",
- "poster":"https://m.media-amazon.com/images/M/MV5BODJkZTZhMWItMDI3Yy00ZWZlLTk4NjQtOTI1ZjU5NjBjZTVjXkEyXkFqcGdeQXVyODE5NzE3OTE@._V1_.jpg"
- },
- {
- "title":"Megamind",
- "year":"2010",
- "poster":"https://m.media-amazon.com/images/M/MV5BMTAzMzI0NTMzNDBeQTJeQWpwZ15BbWU3MDM3NTAyOTM@._V1_.jpg"
- },
- {
- "title":"Labyrinth",
- "year":"1986",
- "poster":"https://m.media-amazon.com/images/M/MV5BMjM2MDE4OTQwOV5BMl5BanBnXkFtZTgwNjgxMTg2NzE@._V1_.jpg"
- },
- {
- "title":"Frozen",
- "year":"2013",
- "poster":"https://m.media-amazon.com/images/M/MV5BMTQ1MjQwMTE5OF5BMl5BanBnXkFtZTgwNjk3MTcyMDE@._V1_.jpg"
- },
- {
- "title":"Tangled",
- "year":"2010",
- "poster":"https://m.media-amazon.com/images/M/MV5BMTAxNDYxMjg0MjNeQTJeQWpwZ15BbWU3MDcyNTk2OTM@._V1_.jpg"
- },
- {
- "title":"Cloudy with a Chance of Meatballs",
- "year":"2009",
- "poster":"https://m.media-amazon.com/images/M/MV5BMTg0MjAwNDI5MV5BMl5BanBnXkFtZTcwODkyMzg2Mg@@._V1_.jpg"
- },
- {
- "title":"Joe Dirt",
- "year":"2001",
- "poster":"https://m.media-amazon.com/images/M/MV5BMTE5NDgxNzU1MV5BMl5BanBnXkFtZTYwODQ4ODE3._V1_.jpg"
- },
- {
- "title":"Star Wars III: Revenge of the Sith",
- "year":"2005",
- "poster":"https://m.media-amazon.com/images/M/MV5BNTc4MTc3NTQ5OF5BMl5BanBnXkFtZTcwOTg0NjI4NA@@._V1_.jpg"
- },
- {
- "title":"Deadpool",
- "year":"2016",
- "poster":"https://m.media-amazon.com/images/M/MV5BYzE5MjY1ZDgtMTkyNC00MTMyLThhMjAtZGI5OTE1NzFlZGJjXkEyXkFqcGdeQXVyNjU0OTQ0OTY@._V1_FMjpg_UX1000_.jpg"
- },
- {
- "title":"Kung Pow: Enter the Fist",
- "year":"2002",
- "poster":"https://m.media-amazon.com/images/M/MV5BMGQxZDEwZDctMjNkMi00YmIxLTgyN2MtYmJhYjEzZGY0NjljXkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_FMjpg_UX1000_.jpg"
- },
- {
- "title":"Django Unchained",
- "year":"2012",
- "poster":"https://m.media-amazon.com/images/M/MV5BMjIyNTQ5NjQ1OV5BMl5BanBnXkFtZTcwODg1MDU4OA@@._V1_.jpg"
- },
- {
- "title":"The Prestige",
- "year":"2006",
- "poster":"https://m.media-amazon.com/images/M/MV5BMjA4NDI0MTIxNF5BMl5BanBnXkFtZTYwNTM0MzY2._V1_FMjpg_UX1000_.jpg"
- },
- {
- "title":"Oldboy",
- "year":"2003",
- "poster":"https://m.media-amazon.com/images/M/MV5BMTI3NTQyMzU5M15BMl5BanBnXkFtZTcwMTM2MjgyMQ@@._V1_FMjpg_UX1000_.jpg"
- },
- {
- "title":"Zombieland",
- "year":"2009",
- "poster":"https://m.media-amazon.com/images/M/MV5BMTU5MDg0NTQ1N15BMl5BanBnXkFtZTcwMjA4Mjg3Mg@@._V1_FMjpg_UX1000_.jpg"
- }
- ]
- },
- "countries":{
- "title":"Countries I've Visited",
- "description":"This list does not include countries that I have been in but not outside an airport (e.g. Germany).",
- "list":["United States","Canada","Mexico","Belize","Puerto Rico","Jamaica","Bahamas","Japan","India"]
- },
- "internet":{
- "title":"Guide to the Internet",
- "description":"This is a collection of all of the websites on the Internet I like to use.",
- "type":"key-value",
- "dropdown-open":"true",
- "sections":[
- {
- "title":"Computer Science",
- "description":"When I was in middle school, I taught myself how to program using various tutorials and resources on the Internet. Here are the resources that I've found useful over the years.",
- "sections":[
- {
- "title":"General Purpose",
- "list":[
- {"k":"StackOverflow","link":"https://stackoverflow.com/","v":"A Q&A forum about all things computer science. If you're trying to figure out how to do something, Google it, and most of the time you'll find a StackOverflow thread about it. If you're trying to debug an error, just paste the error into Google and again, StackOverflow will probably have the answer."},
- {"k":"Codecademy","link":"https://www.codecademy.com/","v":"This was my starting point for learning most of the languages I know. Codecademy has courses of tutorials to get you up and running with almost all of the most popular languages. It has in-browser interpreters/compilers, which are really convenient. In the last few years, a lot of the content has become paid, which is really a travesty, but the free courses can still get you started."},
- {"k":"GitHub","link":"https://github.com/","v":"The most popular platform for hosting code. GitHub allows developers to manage and track changes with a version control software called Git."},
- {"k":"Esolang Wiki","v":"A Wiki about esoteric programming languages (esolangs). Many of these languages were created as jokes, but trying to use these languages can be a genuinely beneficial exercise. Because of how different esolangs are from normal languages, they make you approach problems in new and creative ways.","link":"https://esolangs.org/"},
- {"k":"Atom Editor","dropdown":true,"link":"https://atom.io/","v":"Atom is my IDE of choice. It's free, open-source, integrates nicely with GitHub, and has a slick, uncluttered UI (as compared to something like Eclipse). Some of the packages I've found that improve my Atom experience are:","list":[
- {"script":"By far the most important Atom package, script allows you to run code from most popular languages within the Atom editor. Hit CTRL+SHIFT+B and the console will pop up at the bottom and your code will do its thing. Go to the settings and change the first setting (CWD) to \"Directory of the script\"."},
- {"autoclose-html":"Pretty self-explanatory; this package will automatically create the corresponding close tag when you type in an open HTML tag. If you don't want the close tag on the next line, then type the wildcard (*) in the Force Inline field."},
- {"markdown-preview-plus":"Will render Markdown syntax in a new pane when you press CTRL+SHIFT+M. If you want to include LaTeX-formatted math script in your Markdown, tick \"Enable Math Rendering By Default\" in the Math Options."},
- {"minimap":"Shows a zoomed-out view of your code, which can be helpful for moving through a long script, especially if you know what regions of the code look like."},
- {"pentagons":"Animates floating polygons in the background of the code, just for fun :)"},
- {"php-server":"Allows you to spin up a localhost PHP server in the directory of your choosing, which is really convenient for if you want to develop PHP and actually be able to run it."},
- {"sync-settings":"Allows you to sync your Atom settings and installed packages across multiple devices/installations by storing data in a private GitHub gist."}
- ]}
- ]
- },
- {
- "title":"Web Development",
- "list":[
- {"k":"W3Schools","link":"https://www.w3schools.com/","v":"The best documentation for learning JavaScript, HTML, and CSS. You can often learn a lot by just browsing through documentation to learn what you can do with the language."},
- {"k":"Font Awesome","link":"https://fontawesome.com/start","v":"A huge library of icons that you can use on your website. Font Awesome integrates beautifully with CSS, and it's what I used to make those silver social media icons on the footer of this website. There are paid upgrades, but the basic free package still offers a huge selection of icons. Check out some cool Font Awesome icons: "}
- ]
- },
- {
- "title":"Home Server",
- "list":[
- {"k":"Installing Apache","v":"Apache HTTP Server is a widely popular open-source server software. This link is to a tutorial for setting up Apache on a Windows machine.","link":"https://www.sitepoint.com/how-to-install-apache-on-windows/"},
- {"k":"Configuring PHP","v":"Back-end development is a key component of creating a dynamic, interactive website, and PHP is a popular language for back-end. This link is a tutorial for installing and configuring PHP on your Apache server.","link":"https://www.sitepoint.com/how-to-install-php-on-windows/"},
- {"k":"Certbot","link":"https://certbot.eff.org/instructions","v":"A free SSL/TLS certificate generator application. Just follow the instructions, and Certbot will take care of the rest for you. Make sure you renew your certificate periodically or set up automatic renewal."},
- {"k":"DokuWiki","link":"https://www.dokuwiki.org/","v":"If you want to create a wiki on your server, DokuWiki is the way to do it. It's free, open-source, and user-friendly. All you have to do is download a version of it and extract the files into whatever directory of your server that you want to be the root of the wiki. After that, everything can be configured in your browser through the wiki's settings."}
- ]
- }
- ]
- },
- {
- "title":"Websites for Students",
- "sections":[
- {
- "title":"General",
- "list":[
- {"k":"BibMe","v":"Generates formatted citations (MLA, APA, ACS, etc.) from the link to or title of a source. Makes writing a bibliography a lot less painful.","link":"https://www.bibme.org/"}
- ]
- },
- {
- "title":"Johns Hopkins Engineering for Professionals",
- "list":[
- {"k":"Course Evaluation Results Database","v":"This is where all the course/instructor evaluations are compiled.","link":"https://ep.jhu.edu/course-evaluation-results-public-reports"}
- ]
- },
- {
- "title":"University of Michigan",
- "list":[
- {"k":"LSA Course Guide","link":"https://www.lsa.umich.edu/cg/","v":"This may have changed, but when I was a student, the course catalog on Wolverine Access had an awful interface, so I always used the LSA Course Guide."},
- {"k":"Vertere","v":"The inventory management system for the chemistry department. This link was always way too difficult for me to find, so I finally ended up bookmarking it. You'll need login credentials, which are hard to come by.","link":"http://chem-vim.chem.lsa.umich.edu/VimWebV2/(S(jao5q1jgvvfjwzqi0rlpwejc))/Login.aspx"},
- {"k":"NMR Scheduler","v":"If you want to use the department NMR spectrometers, schedule your time here.","link":"https://apps-prod.chem.lsa.umich.edu/nmr/horarios/index.php#now"},
- {"k":"GC-MS Scheduler","v":"If you want to use the department GC-MS, schedule your time here.","link":"https://sites.google.com/a/umich.edu/lsa-chemistry-instrumentation-reservations/home/shimadzu-gcms"}
- ]
- },
- {
- "title":"Learning Japanese",
- "list":[
- {"k":"Free Japanese Lessons","v":"Using this website, I learned enough Japanese to find my way around Japan as well as place out of the first year of college Japanese. \"Free\" is not entirely accurate, but the one-time payment of $12.95 is well worth it. The site had a major update since I last used it, but I checked it out and it seems even higher quality than before.","link":"https://freejapaneselessons.com/"},
- {"k":"Rikaikun","v":"A Google Chrome extension that displays the reading and English translation of Japanese words when you hover over them. Rikaikun is very helpful when you're trying to read something that has a bunch of kanji that you don't know.","link":"https://chrome.google.com/webstore/detail/rikaikun/jipdnfibhldikgcjhfnomkfpcebammhp?hl=en"},
- {"k":"Suzuki-Kun","v":"This software analyzes inputted Japanese text and predicts the pitch-accent of the text. It displays pitch contours over the text and will generate synthesized speech so you can listen along. Use this tool if you want to take your pronunciation to the next level, or if you want an mp3 of some text (maybe for help memorizing a speech).","link":"http://www.gavo.t.u-tokyo.ac.jp/ojad/eng/phrasing/index"},
- {"k":"Vocabulary Lists","v":"Free Japanese Lessons has a lot of great wordbanks, but if you're looking for some esoteric subjects, you're going to go off the beaten path. These are some of the lists that I've found useful:",
- "list":[
- "Board Games",
- "Chemical Elements",
- "Pokemon Names"
- ]
- }
- ]
- }
- ]
- },
- {
- "title":"Digital Acquisitions",
- "description":"Useful links for all you 21st century swashbucklers out there.",
- "list":[
- {"k":"Library Genesis (libgen)","link":"https://libgen.is/","v":"Textbooks, comic books, novels, you name it and there's a good chance that libgen has it."},
- {"k":"SciHub","link":"https://sci-hub.se/","v":"Paste in the link or DOI of a scientific article, SciHub will almost always get the PDF for you. Research is paid for largely by government grants funded by tax dollars, so you should be entitled to its results."},
- {"k":"YT1s","link":"https://yt1s.com/","v":"Allows you to download videos or audio files from YouTube."},
- {"k":"WatchCartoonOnline","link":"https://www.wcostream.com/","v":"This has my site for cartoons and anime since around 2013."},
- {"k":"Nyaa","link":"https://nyaa.si/","v":"The best magnets for torrenting anime."},
- {"k":"AnimeShow","link":"https://www2.animeshow.tv/","v":"The only site I've found for watching raw (unsubtitled) anime, in case you want to practice your Japanese listening comprehension."},
- {"k":"AQ Stream","link":"https://aqstream.com/jp","v":"For streaming live Japanese television. It only has a few channels, but they're the big ones: TV Asahi, TV Tokyo, Fuji TV, TBS, NHK, and a few others."},
- {"k":"Lj Video Downloader","v":"This is an app for Android, but it is the best video downloader I've found. Paste in any website with a video in it, and Lj will find the m3u8 link and let you download the video.","link":"https://play.google.com/store/apps/details?id=com.leavjenn.m3u8downloader&hl=en_US&gl=US"},
- {"k":"r/Piracy","link":"https://www.reddit.com/r/Piracy/","v":"If you're trying to figure out how to pirate something, chances are that somebody else has tried too. Maybe they posted about it in the Piracy subreddit."},
- {"k":"Internet Archive","link":"https://archive.org/","v":"The Internet Archive isn't traditionally considered a piracy website, but if you can rent a book, you can flip through all the pages and get scans of them in your browser cache. If your browser is Chrome, you can use ChromeCacheView to extract those scans. Then, just use an image to PDF converter, like this one I made, and you're in business."}
- ]
- }
- ]
- }
-}
diff --git a/data/pages.json b/data/pages.json
deleted file mode 100644
index 29791ab..0000000
--- a/data/pages.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "home" : {
- "name" : "Home",
- "query_value" : "home",
- "file" : "home.html",
- "index" : 0
- },
-
- "404" : {
- "name" : "404",
- "query_value" : "404",
- "file" : "404.html",
- "index" : -1
- },
-
- "about" : {
- "name" : "About",
- "query_value" : "about",
- "file" : "about.html",
- "index" : 1
- },
-
- "resume" : {
- "name" : "Resume",
- "query_value" : "resume",
- "link" : "pages/shepich resume.pdf",
- "index" : 2
- },
-
- "epics" : {
- "name" : "Epics & Emprises",
- "query_value" : "epics",
- "link" : "https://epics.shepich.com",
- "index" : -1
- },
-
- "lists" : {
- "name" : "Lists",
- "query_value" : "lists",
- "file" : "lists.html",
- "index" : -1
- },
-
- "don-info" : {
- "name" : "Info for Don",
- "query_value" : "don-info",
- "file" : "don-info.html",
- "index" : -1
- }
-}
diff --git a/data/socials.json b/data/socials.json
deleted file mode 100644
index 85d4e4c..0000000
--- a/data/socials.json
+++ /dev/null
@@ -1,27 +0,0 @@
-[
- {
- "platform": "Instagram",
- "icon": "instagram",
- "link": "https://instagram.com/epicshepich"
- },
- {
- "platform": "GitHub",
- "icon": "github",
- "link": "https://github.com/epicshepich"
- },
- {
- "platform": "Facebook",
- "icon": "facebook-square",
- "link": "https://www.facebook.com/jim.shepich/"
- },
- {
- "platform": "LinkedIn",
- "icon": "linkedin",
- "link": "https://www.linkedin.com/in/jshepich/"
- },
- {
- "platform": "Discord",
- "icon": "discord",
- "link": "https://discordapp.com/users/epicshepich#0131"
- }
-]
diff --git a/jimsite.py b/jimsite.py
deleted file mode 100644
index f8e3fbc..0000000
--- a/jimsite.py
+++ /dev/null
@@ -1,342 +0,0 @@
-import os
-import re
-import glob
-import shutil
-import subprocess
-import markdown
-import yaml
-import pydantic
-from typing import Optional
-from datetime import datetime, date
-from dotmap import DotMap
-
-class GlobalVars(pydantic.BaseModel):
- '''Static-valued global variables to be interpolated into any HTML templates.'''
- today: date = datetime.today()
-
-
-def filepath_or_string(s: str) -> str:
- '''Loads the contents of a string if it is a filepath, otherwise returns the string.'''
- if os.path.isfile(s):
- with open(s, 'r') as f:
- return f.read()
- else:
- 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:
- config = yaml.safe_load(config_file.read())
-
-class SiteConfig(pydantic.BaseModel):
- base_url: Optional[str] = config['site_defaults'].get('base_url')
- git_repo: Optional[str] = config['site_defaults'].get('git_repo')
- build_cache: Optional[str] = config['site_defaults'].get('build_cache')
- assets: Optional[list] = config['site_defaults'].get('assets')
- web_root: Optional[str] = config['site_defaults'].get('web_root')
- articles: Optional[list] = config['site_defaults'].get('articles')
-
-class ArticleMetadata(pydantic.BaseModel):
- title: str
- author: Optional[str] = config.get('author')
- date: date
- lastmod: Optional[date] = None
- published: bool
- tags: list
- thumbnail: Optional[str] = None
-
-
-
-def load_markdown(md: str) -> tuple[ArticleMetadata|None, str]:
- '''Loads a Markdown file into a (metadata: ArticleMetadata, content: str) pair.'''
-
- # Load the file contents if a filepath is specified, and strip document delimiters ('---').
- md = filepath_or_string(md).strip().strip('---').strip()
-
- # If there is no `---` delimiter, then the article has no metadata.
- if '---' not in md.strip('---'):
- return None, md
-
- # Split the metadata from the contents.
- [raw_metadata, raw_article] = md.split('---')
-
- # Use YAML to parse the metadata.
- metadata = yaml.safe_load(raw_metadata)
-
- # Convert the contents to a HTML string.
- content = markdown.markdown(raw_article)
-
- return ArticleMetadata(**metadata), content
-
-
-def format_html_template(template: str, **kwargs) -> str:
- '''Interpolates variables specified as keyword arguments
- 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.
- template = filepath_or_string(template)
-
- # Ensure the template does not have cyclical placeholder references.
- cycles = find_cyclical_placeholders(template, globalvars = GlobalVars(), **kwargs)
-
- if len(cycles) > 0:
- raise ValueError('Template has cyclical dependencies: {cycles}')
-
- # 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 formatted_html
-
-
-run = lambda cmd: subprocess.run(cmd.split(' '), stdout = subprocess.PIPE, stderr = subprocess.PIPE)
-def pull_git_repo(repo: str, build_cache: str) -> None:
- '''Pulls/clones a repo into the build cache directory.'''
- if os.path.exists(f'{build_cache}/.git'):
- run(f'git -C {build_cache} pull origin')
- else:
- run(f'git clone {repo} {build_cache}')
-
-
-def load_partials() -> dict:
- """Loads partial templates from the templates/partials directory."""
- partials = {}
- for filename in os.listdir('templates/partials'):
- with open(f'templates/partials/{filename}') as partial_file:
- partial_template = partial_file.read()
-
- partials[f'partials.{os.path.splitext(filename)[0]}'] = format_html_template(
- partial_template,
- current_year = datetime.now().year
- )
- return partials
-
-
-def import_resume():
-
- # Use a sentinel value for the loop.
- max_date = '0000-00-00'
-
- # Loop through the folders in the resume repo to find the most recent one.
- for resume_folder in os.listdir('build/resume'):
-
- # Skip folders that are not in YYYY-MM-DD format.
- try:
- datetime.strptime(resume_folder,'%Y-%m-%d')
- except Exception:
- continue
-
- # Keep track of the most recent date.
- if resume_folder > max_date:
- max_date = resume_folder
-
- # Copy the resume into the /dist directory.
- run(f'cp build/resume/{max_date}/shepich_resume.pdf dist/shepich_resume.pdf')
-
-
-def format_blog_tags(tags: list[str], template = 'templates/components/blog_tag.html') -> list[str]:
- '''Generates HTML blog tag components from a list of tag names.'''
- return [
- format_html_template(template, tag_name = t) for t in tags
- ]
-
-
-def build_blog_archive(
- index: dict[str, tuple[str, str]],
- page_template = 'templates/pages/default.html',
- li_template = 'templates/components/blog_archive_li.html',
- **kwargs
- ) -> str:
- '''Converts an index, formatted as filestem: (metadata, contents) dict,
- into an HTML page containing the list of articles, sorted from newest to oldest.
-
- Note: partials must be expanded into the kwargs, as they are needed to generate
- the overall page.
- '''
-
- # Add each article as a list item to an unordered list.
- archive_html_content = '
'
- for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0].date)[::-1]:
-
- # Generate HTML for the article (including metadata tags).
- archive_html_content += format_html_template(
- li_template,
- article_filestem = article,
- blog_tags = ' '.join(format_blog_tags(metadata.tags)),
- metadata = metadata
-
- )
- archive_html_content +='
'
-
- # Interpolate the article into the overall page template.
- archive_html_page = format_html_template(
- page_template,
- content = archive_html_content,
- **kwargs
- )
-
- return archive_html_page
-
-def copy_assets(site: SiteConfig):
- '''Copies the list of site assets from the build cache to the web root.'''
-
- # Expand any globbed expressions.
- expanded_asset_list = []
- for a in site.assets:
- expanded_asset_list.extend(
- # Assets are defined relative to the build cache; construct the full path.
- glob.glob(f'{site.build_cache}/{a.lstrip("/")}')
- )
-
- for asset in expanded_asset_list:
-
- # Construct the destination path analogous to the source path
- # but in the web root instead of the build cache.
- destination = f'{site.web_root}/{a.lstrip("/")}'
-
- # Delete existing files.
- shutil.rmtree(destination, ignore_errors=True)
-
- # Copy the asset.
- if os.path.isdir(asset):
- shutil.copytree(asset, destination)
- elif os.path.isfile(asset):
- shutil.copyfile(asset, destination)
- else:
- continue
-
- return None
-
-
-def build_index(site: SiteConfig) -> dict:
- '''Loads the sites articles into an index mapping the filename stem
- to a (metadata: dict, content: str) tuple.'''
-
- index = {}
-
- # Expand any globbed expressions.
- expanded_article_list = []
- 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("/")}')
- )
-
-
- for article in expanded_article_list:
- metadata, content = load_markdown(article)
-
- # Skip unpublished articles.
- if not metadata.published:
- continue
-
- article_filestem = os.path.splitext(os.path.basename(article))[0]
- index[article_filestem] = (metadata, content)
-
- return index
-
-
-def map_templates(dir: str, parent = '') -> DotMap:
- '''Recursively maps the templates directory into a nested dict structure.
- Leaves map the filestems of .html template files to their contents.
- '''
-
- output = {}
-
- # List the files and subdirectories at the top level.
- for sub in os.listdir(os.path.join(parent,dir)):
-
- # Construct the full path to the file or subdir from the root of the tree.
- full_path = os.path.join(parent,dir,sub)
-
- # Recursively map subdirectories.
- if os.path.isdir(full_path):
- output[sub] = map_templates(sub, parent = dir)
- continue
-
- # Templates must be .html files.
- filestem, ext = os.path.splitext(sub)
- if ext != '.html':
- continue
-
- # Load template file.
- with open(full_path, 'r') as file:
- html = file.read()
-
- output[filestem] = html
-
- return DotMap(output)
-
-
-
-
-if __name__ == '__main__':
- pass
-
-
diff --git a/jimsite/__init__.py b/jimsite/__init__.py
new file mode 100644
index 0000000..8572ae2
--- /dev/null
+++ b/jimsite/__init__.py
@@ -0,0 +1,58 @@
+import os
+import re
+import glob
+import shutil
+import subprocess
+import markdown
+import yaml
+import pydantic
+from typing import Optional
+from datetime import datetime, date
+from dotmap import DotMap
+
+from .common import filepath_or_string, GlobalVars, SiteConfig
+from .templating import format_html_template, map_templates
+from .assets import pull_git_repo, copy_assets
+from .articles import ArticleMetadata, load_markdown, build_articles, build_index
+
+
+
+
+
+
+
+
+
+def build_site(site: SiteConfig, templates: DotMap):
+
+ # Initialize the build cache and web root, in case they do not exist.
+ os.makedirs(site.build_cache, exist_ok = True)
+ 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:
+ pull_git_repo(site.git_repo, site.build_cache)
+
+ # Copy the sites assets into the web root.
+ copy_assets(site)
+
+ # Load the site's articles into an index.
+ index = build_index(site)
+
+ # Generate HTML pages for the articles.
+ build_articles(site, index, templates)
+
+
+def main():
+ with open('/home/jim/projects/shepich.com/config.yaml', 'r') as config_file:
+ config = yaml.safe_load(config_file.read())
+
+ templates = map_templates(config['templates_folder'])
+
+ for site in config['sites'].values():
+ build_site(SiteConfig(**site), templates)
+
+
+if __name__ == '__main__':
+ main()
+
diff --git a/jimsite/articles.py b/jimsite/articles.py
new file mode 100644
index 0000000..01ef1de
--- /dev/null
+++ b/jimsite/articles.py
@@ -0,0 +1,103 @@
+import os
+import glob
+import yaml
+import markdown
+import pydantic
+from typing import Optional
+from dotmap import DotMap
+from datetime import date
+
+from .common import filepath_or_string, SiteConfig
+from .templating import format_html_template
+
+class ArticleMetadata(pydantic.BaseModel):
+ title: str
+ date: date
+ published: bool
+ tags: list
+ author: Optional[str] = None
+ lastmod: Optional[date] = None
+ thumbnail: Optional[str] = None
+
+
+def load_markdown(md: str) -> tuple[ArticleMetadata|None, str]:
+ '''Loads a Markdown file into a (metadata: ArticleMetadata, content: str) pair.'''
+
+ # Load the file contents if a filepath is specified, and strip document delimiters ('---').
+ md = filepath_or_string(md).strip().strip('---').strip()
+
+ # If there is no `---` delimiter, then the article has no metadata.
+ if '---' not in md.strip('---'):
+ return None, md
+
+ # Split the metadata from the contents.
+ [raw_metadata, raw_article] = md.split('---')
+
+ # Use YAML to parse the metadata.
+ metadata = yaml.safe_load(raw_metadata)
+
+ # Convert the contents to a HTML string.
+ content = markdown.markdown(raw_article)
+
+ 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.'''
+
+ index = {}
+
+ # Expand any globbed expressions.
+ expanded_article_list = []
+ 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("/")}')
+ )
+
+
+ for article in expanded_article_list:
+ metadata, content = load_markdown(article)
+
+ # Skip unpublished articles.
+ if not metadata.published:
+ continue
+
+ article_filestem = os.path.splitext(os.path.basename(article))[0]
+ index[article_filestem] = (metadata, content)
+
+ return index
+
+
+def format_article_tags(tags: list[str], template = 'templates/components/blog_tag.html') -> list[str]:
+ '''Generates HTML article tag components from a list of tag names.'''
+ return [
+ format_html_template(template, tag_name = t) for t in tags
+ ]
+
+
+def build_articles(site: SiteConfig, index: dict[str, tuple[ArticleMetadata, str]], templates: DotMap):
+ '''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(
+ templates.components.blog_article,
+ content = content,
+ blog_tags = ' '.join(format_article_tags(metadata.tags)),
+ metadata = metadata,
+ templates = templates
+ )
+
+ page = format_html_template(
+ templates.pages.default,
+ content = article,
+ templates = templates
+
+ )
+
+ with open(f'{site.web_root.rstrip('/')}/{filestem}.html', 'w') as f:
+ f.write(page)
+
+
\ No newline at end of file
diff --git a/jimsite/assets.py b/jimsite/assets.py
new file mode 100644
index 0000000..f72d0fb
--- /dev/null
+++ b/jimsite/assets.py
@@ -0,0 +1,43 @@
+import os
+import glob
+import shutil
+from .common import run, SiteConfig
+
+
+def pull_git_repo(repo: str, build_cache: str) -> None:
+ '''Pulls/clones a repo into the build cache directory.'''
+ if os.path.exists(f'{build_cache}/.git'):
+ run(f'git -C {build_cache} pull origin')
+ else:
+ run(f'git clone {repo} {build_cache}')
+
+
+def copy_assets(site: SiteConfig):
+ '''Copies the list of site assets from the build cache to the web root.'''
+
+ # Expand any globbed expressions.
+ expanded_asset_list = []
+ for a in site.assets:
+ expanded_asset_list.extend(
+ # Assets are defined relative to the build cache; construct the full path.
+ glob.glob(f'{site.build_cache}/{a.lstrip("/")}')
+ )
+
+ for asset in expanded_asset_list:
+
+ # Construct the destination path analogous to the source path
+ # but in the web root instead of the build cache.
+ destination = f'{site.web_root}/{a.lstrip("/")}'
+
+ # Delete existing files.
+ shutil.rmtree(destination, ignore_errors=True)
+
+ # Copy the asset.
+ if os.path.isdir(asset):
+ shutil.copytree(asset, destination)
+ elif os.path.isfile(asset):
+ shutil.copyfile(asset, destination)
+ else:
+ continue
+
+ return None
\ No newline at end of file
diff --git a/jimsite/blog.py b/jimsite/blog.py
new file mode 100644
index 0000000..806845b
--- /dev/null
+++ b/jimsite/blog.py
@@ -0,0 +1,68 @@
+import rfeed
+import datetime
+
+
+from .common import SiteConfig
+from .articles import ArticleMetadata, format_article_tags
+from .templating import format_html_template
+
+
+
+def build_blog_archive(
+ index: dict[str, tuple[str, str]],
+ page_template = 'templates/pages/default.html',
+ li_template = 'templates/components/blog_archive_li.html',
+ **kwargs
+ ) -> str:
+ '''Converts an index, formatted as filestem: (metadata, contents) dict,
+ into an HTML page containing the list of articles, sorted from newest to oldest.
+
+ Note: partials must be expanded into the kwargs, as they are needed to generate
+ the overall page.
+ '''
+
+ # Add each article as a list item to an unordered list.
+ archive_html_content = '
'
+ for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0].date)[::-1]:
+
+ # Generate HTML for the article (including metadata tags).
+ archive_html_content += format_html_template(
+ li_template,
+ article_filestem = article,
+ blog_tags = ' '.join(format_article_tags(metadata.tags)),
+ metadata = metadata
+
+ )
+ archive_html_content +='
'
+
+ # Interpolate the article into the overall page template.
+ archive_html_page = format_html_template(
+ page_template,
+ content = archive_html_content,
+ **kwargs
+ )
+
+ return archive_html_page
+
+# TODO: Finish
+def build_rss_feed(site: SiteConfig, index: dict[str, tuple[ArticleMetadata, str]]):
+ feed = rfeed.Feed(
+ title = site.title,
+ link = f'{site.base_url.rstrip('/')}/rss.xml',
+ description = site.description,
+ language = "en-US",
+ 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)
+ )
+ for filestem, (metadata, _) in index.items()
+ ]
+ )
+
+ # print(rss_feed.rss())
\ No newline at end of file
diff --git a/jimsite/common.py b/jimsite/common.py
new file mode 100644
index 0000000..6f129de
--- /dev/null
+++ b/jimsite/common.py
@@ -0,0 +1,33 @@
+import os
+import subprocess
+import pydantic
+from typing import Optional
+from datetime import date, datetime
+
+run = lambda cmd: subprocess.run(
+ cmd.split(' '),
+ stdout = subprocess.PIPE,
+ stderr = subprocess.PIPE
+)
+
+def filepath_or_string(s: str) -> str:
+ '''Loads the contents of a string if it is a filepath, otherwise returns the string.'''
+ if os.path.isfile(s):
+ with open(s, 'r') as f:
+ return f.read()
+ else:
+ return s
+
+
+class GlobalVars(pydantic.BaseModel):
+ '''Static-valued global variables to be interpolated into any HTML templates.'''
+ today: date = datetime.today()
+
+
+class SiteConfig(pydantic.BaseModel):
+ base_url: str
+ web_root: str
+ build_cache: str
+ git_repo: Optional[str] = None
+ assets: Optional[list] = None
+ articles: Optional[list] = None
\ No newline at end of file
diff --git a/requirements.txt b/jimsite/requirements.txt
similarity index 100%
rename from requirements.txt
rename to jimsite/requirements.txt
diff --git a/jimsite/templating.py b/jimsite/templating.py
new file mode 100644
index 0000000..f05c332
--- /dev/null
+++ b/jimsite/templating.py
@@ -0,0 +1,123 @@
+import os
+import re
+from dotmap import DotMap
+from .common import filepath_or_string, GlobalVars
+
+
+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.
+
+ # 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')}
+ ```
+ '''
+
+ # 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
+
+
+def format_html_template(template: str, **kwargs) -> str:
+ '''Interpolates variables specified as keyword arguments
+ into the given HTML template.
+ '''
+
+ # Load the template if a filepath is given.
+ template = filepath_or_string(template)
+
+ # Ensure the template does not have cyclical placeholder references.
+ cycles = find_cyclical_placeholders(template, globalvars = GlobalVars(), **kwargs)
+
+ if len(cycles) > 0:
+ raise ValueError('Template has cyclical dependencies: {cycles}')
+
+ # 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 formatted_html
+
+
+def map_templates(dir: str, parent = '') -> DotMap:
+ '''Recursively maps the templates directory into a nested dict structure.
+ Leaves map the filestems of .html template files to their contents.
+ '''
+
+ output = {}
+
+ # List the files and subdirectories at the top level.
+ for sub in os.listdir(os.path.join(parent,dir)):
+
+ # Construct the full path to the file or subdir from the root of the tree.
+ full_path = os.path.join(parent,dir,sub)
+
+ # Recursively map subdirectories.
+ if os.path.isdir(full_path):
+ output[sub] = map_templates(sub, parent = dir)
+ continue
+
+ # Templates must be .html files.
+ filestem, ext = os.path.splitext(sub)
+ if ext != '.html':
+ continue
+
+ # Load template file.
+ with open(full_path, 'r') as file:
+ html = file.read()
+
+ output[filestem] = html
+
+ return DotMap(output)
\ No newline at end of file
diff --git a/templates/blog_post.html b/site/templates/blog_post.html
similarity index 81%
rename from templates/blog_post.html
rename to site/templates/blog_post.html
index 666ddca..6322b55 100644
--- a/templates/blog_post.html
+++ b/site/templates/blog_post.html
@@ -2,9 +2,9 @@
- {partials.default_css}
- {partials.header}
- {partials.nav}
+ {templates.partials.default_css}
+ {templates.partials.header}
+ {templates.partials.nav}
@@ -18,5 +18,5 @@
{content}
- {partials.footer}
+ {templates.partials.footer}
\ No newline at end of file
diff --git a/templates/components/blog_archive_li.html b/site/templates/components/blog_archive_li.html
similarity index 100%
rename from templates/components/blog_archive_li.html
rename to site/templates/components/blog_archive_li.html
diff --git a/templates/components/blog_article.html b/site/templates/components/blog_article.html
similarity index 100%
rename from templates/components/blog_article.html
rename to site/templates/components/blog_article.html
diff --git a/templates/components/blog_tag.html b/site/templates/components/blog_tag.html
similarity index 100%
rename from templates/components/blog_tag.html
rename to site/templates/components/blog_tag.html
diff --git a/templates/pages/default.html b/site/templates/pages/default.html
similarity index 55%
rename from templates/pages/default.html
rename to site/templates/pages/default.html
index 9aa69a2..5753822 100644
--- a/templates/pages/default.html
+++ b/site/templates/pages/default.html
@@ -2,13 +2,13 @@
- {partials.default_css}
- {partials.header}
- {partials.nav}
+ {templates.partials.default_css}
+ {templates.partials.header}
+ {templates.partials.nav}
{content}
- {partials.footer}
+ {templates.partials.footer}
\ No newline at end of file
diff --git a/templates/partials/default_css.html b/site/templates/partials/default_css.html
similarity index 100%
rename from templates/partials/default_css.html
rename to site/templates/partials/default_css.html
diff --git a/templates/partials/footer.html b/site/templates/partials/footer.html
similarity index 100%
rename from templates/partials/footer.html
rename to site/templates/partials/footer.html
diff --git a/templates/partials/header.html b/site/templates/partials/header.html
similarity index 100%
rename from templates/partials/header.html
rename to site/templates/partials/header.html
diff --git a/templates/partials/nav.html b/site/templates/partials/nav.html
similarity index 100%
rename from templates/partials/nav.html
rename to site/templates/partials/nav.html
diff --git a/templates/simple.html b/site/templates/simple.html
similarity index 55%
rename from templates/simple.html
rename to site/templates/simple.html
index 9aa69a2..5753822 100644
--- a/templates/simple.html
+++ b/site/templates/simple.html
@@ -2,13 +2,13 @@
- {partials.default_css}
- {partials.header}
- {partials.nav}
+ {templates.partials.default_css}
+ {templates.partials.header}
+ {templates.partials.nav}
{content}
- {partials.footer}
+ {templates.partials.footer}
\ No newline at end of file
diff --git a/testbench.ipynb b/testbench.ipynb
index 20c1804..ceb9bdb 100644
--- a/testbench.ipynb
+++ b/testbench.ipynb
@@ -27,7 +27,7 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 3,
"id": "8f435a12",
"metadata": {},
"outputs": [],
@@ -55,60 +55,15 @@
]
}
],
- "source": [
- "def build_rss_feed(site: SiteConfig, index: dict[str, tuple[ArticleMetadata, str]]):\n",
- " feed = rfeed.Feed(\n",
- " title = site.title,\n",
- " link = f'{site.base_url.rstrip('/')}/rss.xml',\n",
- " description = site.description,\n",
- " language = \"en-US\",\n",
- " lastBuildDate = datetime.now(),\n",
- " items = [\n",
- " rfeed.Item(\n",
- " title = metadata.title,\n",
- " link = f'{site.base_url.rstrip('/')}/{filestem}.md', \n",
- " description = metadata.description,\n",
- " author = metadata.author,\n",
- " guid = rfeed.Guid(filestem),\n",
- " pubDate = datetime(metadata.date.year, metadata.date.month, metadata.date.day)\n",
- " )\n",
- " for filestem, (metadata, _) in index.items()\n",
- " ]\n",
- " )\n",
- "\n",
- " # print(rss_feed.rss())"
- ]
+ "source": []
},
{
"cell_type": "code",
- "execution_count": 15,
+ "execution_count": null,
"id": "70408b85",
"metadata": {},
"outputs": [],
- "source": [
- "def build_articles(site: SiteConfig, index: dict[str, tuple[ArticleMetadata, str]]):\n",
- " '''Generates HTML files for all of a given site's Markdown articles\n",
- " by interpolating the contents and metadata into the HTML templates.'''\n",
- "\n",
- " for filestem, (metadata, content) in index.items():\n",
- " article = format_html_template(\n",
- " 'templates/components/blog_article.html',\n",
- " content = content,\n",
- " blog_tags = ' '.join(format_blog_tags(metadata.tags)),\n",
- " metadata = metadata\n",
- " )\n",
- "\n",
- " page = format_html_template(\n",
- " 'templates/pages/default.html',\n",
- " content = article,\n",
- " partials = templates.partials\n",
- " )\n",
- "\n",
- " with open(f'{site.web_root.rstrip('/')}/{filestem}.html', 'w') as f:\n",
- " f.write(page)\n",
- "\n",
- " "
- ]
+ "source": []
},
{
"cell_type": "code",
@@ -139,7 +94,7 @@
" article_html = format_html_template(\n",
" 'templates/components/blog_article.html',\n",
" content = content,\n",
- " blog_tags = ' '.join(format_blog_tags(metadata.tags)),\n",
+ " blog_tags = ' '.join(format_article_tags(metadata.tags)),\n",
" metadata = metadata\n",
" )\n",
" html = format_html_template('templates/pages/default.html', content = article_html, **PARTIALS)\n",
@@ -161,37 +116,16 @@
"id": "e3171afd",
"metadata": {},
"outputs": [],
- "source": [
- "def build_site(site: SiteConfig):\n",
- "\n",
- " # Initialize the build cache and web root, in case they do not exist.\n",
- " os.makedirs(site.build_cache, exist_ok = True)\n",
- " os.makedirs(site.web_root, exist_ok = True)\n",
- "\n",
- " # If the site is built from a git repo, pull that repo into the build cache.\n",
- " if site.git_repo:\n",
- " pull_git_repo(site.git_repo, site.build_cache)\n",
- "\n",
- " # Copy the sites assets into the web root.\n",
- " copy_assets(site)\n",
- "\n",
- " # Load the site's articles into an index.\n",
- " index = build_index(site)\n",
- "\n",
- " # Generate HTML pages for the articles.\n",
- " build_articles(site, index)\n",
- "\n",
- " "
- ]
+ "source": []
},
{
"cell_type": "code",
- "execution_count": 16,
+ "execution_count": 10,
"id": "a28b95a6",
"metadata": {},
"outputs": [],
"source": [
- "build_site(sites['dogma_jimfinium'])"
+ "build_site(sites['resume'])"
]
}
],
From f933e47a0427cdd3e639c4d05c93c20c7769f06e Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Sun, 1 Feb 2026 14:10:32 -0500
Subject: [PATCH 10/20] Standardizing templating
---
config.yaml | 14 +-
jimsite/__init__.py | 19 ++-
jimsite/articles.py | 27 ++--
jimsite/blog.py | 50 +++++--
jimsite/common.py | 40 +++++-
jimsite/templating.py | 33 ++++-
site/assets/img/favicon.svg | 129 ++++++++++++++++++
site/templates/blog_post.html | 22 ---
site/templates/components/blog_archive.html | 5 +
site/templates/components/blog_tag.html | 2 +-
site/templates/components/simple_article.html | 5 +
site/templates/pages/default.html | 2 +
site/templates/partials/nav.html | 2 +-
site/templates/simple.html | 14 --
testbench.ipynb | 73 +++++++++-
15 files changed, 361 insertions(+), 76 deletions(-)
create mode 100644 site/assets/img/favicon.svg
delete mode 100644 site/templates/blog_post.html
create mode 100644 site/templates/components/blog_archive.html
create mode 100644 site/templates/components/simple_article.html
delete mode 100644 site/templates/simple.html
diff --git a/config.yaml b/config.yaml
index 8065da7..01715d9 100644
--- a/config.yaml
+++ b/config.yaml
@@ -1,12 +1,8 @@
author: Jim Shepich III
-templates_folder: ./templates
-site_defaults:
- templates:
- partials: ./templates/partials
- components: ./templates/components
- pages: ./templates/pages
+templates_folder: ./site/templates
sites:
main:
+ title: Jimlab
base_url: http://localhost:8000
web_root: ./dist
build_cache: ./site
@@ -14,14 +10,20 @@ sites:
- /assets
articles:
- ./pages/*.md
+ template_selections:
+ article: templates.components.simple_article
+
resume:
+ title: Resume
base_url: http://localhost:8000
web_root: ./dist
git_repo: ssh://gitea/jim/resume.git
build_cache: ./build/resume
assets:
- 'shepich_resume.pdf'
+
dogma_jimfinium:
+ title: Dogma Jimfinium
base_url: http://localhost:8080/dogma-jimfinium
git_repo: ssh://gitea/jim/dogma-jimfinium.git
build_cache: ./build/dogma-jimfinium
diff --git a/jimsite/__init__.py b/jimsite/__init__.py
index 8572ae2..b07106c 100644
--- a/jimsite/__init__.py
+++ b/jimsite/__init__.py
@@ -10,11 +10,11 @@ from typing import Optional
from datetime import datetime, date
from dotmap import DotMap
-from .common import filepath_or_string, GlobalVars, SiteConfig
-from .templating import format_html_template, map_templates
+from .common import filepath_or_string, GlobalVars, SiteConfig, dotmap_access
+from .templating import format_html_template, map_templates, TemplateSelections
from .assets import pull_git_repo, copy_assets
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):
+ # 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.
os.makedirs(site.build_cache, 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.
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.
- 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():
diff --git a/jimsite/articles.py b/jimsite/articles.py
index 01ef1de..9ee38d3 100644
--- a/jimsite/articles.py
+++ b/jimsite/articles.py
@@ -8,7 +8,7 @@ from dotmap import DotMap
from datetime import date
from .common import filepath_or_string, SiteConfig
-from .templating import format_html_template
+from .templating import format_html_template, TemplateSelections
class ArticleMetadata(pydantic.BaseModel):
title: str
@@ -70,30 +70,39 @@ def build_index(site: SiteConfig) -> dict:
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.'''
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
by interpolating the contents and metadata into the HTML templates.'''
for filestem, (metadata, content) in index.items():
article = format_html_template(
- templates.components.blog_article,
+ template_selections['article'],
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,
- templates = templates
+ templates = templates,
+ site = site
)
page = format_html_template(
- templates.pages.default,
+ template_selections['page'],
content = article,
- templates = templates
+ templates = templates,
+ site = site
)
diff --git a/jimsite/blog.py b/jimsite/blog.py
index 806845b..bbefe98 100644
--- a/jimsite/blog.py
+++ b/jimsite/blog.py
@@ -4,14 +4,13 @@ import datetime
from .common import SiteConfig
from .articles import ArticleMetadata, format_article_tags
-from .templating import format_html_template
-
+from .templating import format_html_template, TemplateSelections
def build_blog_archive(
+ site: SiteConfig,
index: dict[str, tuple[str, str]],
- page_template = 'templates/pages/default.html',
- li_template = 'templates/components/blog_archive_li.html',
+ template_selections: TemplateSelections,
**kwargs
) -> str:
'''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.
- archive_html_content = '
'
+ archive_html_list = '
'
for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0].date)[::-1]:
# Generate HTML for the article (including metadata tags).
- archive_html_content += format_html_template(
- li_template,
+ archive_html_list += format_html_template(
+ template_selections['archive_li'],
article_filestem = article,
- blog_tags = ' '.join(format_article_tags(metadata.tags)),
- metadata = metadata
-
+ blog_tags = ' '.join(format_article_tags(metadata.tags, template_selections['tag'], site = site)),
+ metadata = metadata,
+ site = site,
+ **kwargs
)
- archive_html_content +='
'
+ archive_html_list +='
'
+
+ # Generate the archive article.
+ archive_html_article = format_html_template(
+ template_selections['archive_article'],
+ content = archive_html_list,
+ site = site,
+ **kwargs
+ )
# Interpolate the article into the overall page template.
archive_html_page = format_html_template(
- page_template,
- content = archive_html_content,
+ template_selections['page'],
+ content = archive_html_article,
+ site = site,
**kwargs
)
- return archive_html_page
+ 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
def build_rss_feed(site: SiteConfig, index: dict[str, tuple[ArticleMetadata, str]]):
diff --git a/jimsite/common.py b/jimsite/common.py
index 6f129de..9bc82ed 100644
--- a/jimsite/common.py
+++ b/jimsite/common.py
@@ -1,6 +1,8 @@
import os
+import inspect
import subprocess
import pydantic
+from dotmap import DotMap
from typing import Optional
from datetime import date, datetime
@@ -28,6 +30,42 @@ class SiteConfig(pydantic.BaseModel):
base_url: str
web_root: str
build_cache: str
+ title: str
+ published: Optional[bool] = True
git_repo: Optional[str] = None
assets: Optional[list] = None
- articles: Optional[list] = None
\ No newline at end of file
+ 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
diff --git a/jimsite/templating.py b/jimsite/templating.py
index f05c332..3a1dfd9 100644
--- a/jimsite/templating.py
+++ b/jimsite/templating.py
@@ -1,7 +1,7 @@
import os
import re
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:
@@ -120,4 +120,33 @@ def map_templates(dir: str, parent = '') -> DotMap:
output[filestem] = html
- return DotMap(output)
\ No newline at end of file
+ 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]))
+
diff --git a/site/assets/img/favicon.svg b/site/assets/img/favicon.svg
new file mode 100644
index 0000000..e33690b
--- /dev/null
+++ b/site/assets/img/favicon.svg
@@ -0,0 +1,129 @@
+
+
+
+
diff --git a/site/templates/blog_post.html b/site/templates/blog_post.html
deleted file mode 100644
index 6322b55..0000000
--- a/site/templates/blog_post.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
- {templates.partials.default_css}
- {templates.partials.header}
- {templates.partials.nav}
-
-
-
-
-
{metadata.title}
-
-
By Jim Shepich III
- First published:
- Last modified:
-
- {content}
-
-
- {templates.partials.footer}
-
\ No newline at end of file
diff --git a/site/templates/components/blog_archive.html b/site/templates/components/blog_archive.html
new file mode 100644
index 0000000..0c2033b
--- /dev/null
+++ b/site/templates/components/blog_archive.html
@@ -0,0 +1,5 @@
+
+
{site.title} Archive
+
+ {content}
+
\ No newline at end of file
diff --git a/site/templates/components/blog_tag.html b/site/templates/components/blog_tag.html
index 1a77d3b..8f1ebaa 100644
--- a/site/templates/components/blog_tag.html
+++ b/site/templates/components/blog_tag.html
@@ -1 +1 @@
-{tag_name}
\ No newline at end of file
+{tag_name}
\ No newline at end of file
diff --git a/site/templates/components/simple_article.html b/site/templates/components/simple_article.html
new file mode 100644
index 0000000..0b33bfe
--- /dev/null
+++ b/site/templates/components/simple_article.html
@@ -0,0 +1,5 @@
+
+
{metadata.title}
+
+ {content}
+
\ No newline at end of file
diff --git a/site/templates/pages/default.html b/site/templates/pages/default.html
index 5753822..66608ca 100644
--- a/site/templates/pages/default.html
+++ b/site/templates/pages/default.html
@@ -5,6 +5,8 @@
{templates.partials.default_css}
{templates.partials.header}
{templates.partials.nav}
+ {site.title}
+
diff --git a/site/templates/partials/nav.html b/site/templates/partials/nav.html
index 1c8861f..ed5c746 100644
--- a/site/templates/partials/nav.html
+++ b/site/templates/partials/nav.html
@@ -1,5 +1,5 @@
\ No newline at end of file
diff --git a/site/templates/simple.html b/site/templates/simple.html
deleted file mode 100644
index 5753822..0000000
--- a/site/templates/simple.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- {templates.partials.default_css}
- {templates.partials.header}
- {templates.partials.nav}
-
-
-
- {content}
-
- {templates.partials.footer}
-
\ No newline at end of file
diff --git a/testbench.ipynb b/testbench.ipynb
index ceb9bdb..72d680d 100644
--- a/testbench.ipynb
+++ b/testbench.ipynb
@@ -25,6 +25,14 @@
"from jimsite import *"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "68b107f1",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ },
{
"cell_type": "code",
"execution_count": 3,
@@ -120,13 +128,74 @@
},
{
"cell_type": "code",
- "execution_count": 10,
+ "execution_count": 4,
"id": "a28b95a6",
"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": [
"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": {
From f4433ac1887bd848913627a0c90daf29cd504dd7 Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Sun, 1 Feb 2026 17:05:08 -0500
Subject: [PATCH 11/20] Formalized Article model; added basic logging
---
config.yaml | 5 +-
jimsite/__init__.py | 23 +++++++-
jimsite/articles.py | 53 ++++++++++++-------
jimsite/blog.py | 52 +++++++++++-------
jimsite/common.py | 2 +
site/assets/css/theme.css | 11 +++-
site/assets/img/rss.svg | 18 +++++++
.../templates/components/blog_archive_li.html | 2 +-
site/templates/components/blog_article.html | 10 ++--
site/templates/components/blog_tag.html | 2 +-
.../components/blog_tag_reference.html | 11 ++++
site/templates/components/rss_icon.html | 1 +
site/templates/components/simple_article.html | 4 +-
13 files changed, 144 insertions(+), 50 deletions(-)
create mode 100644 site/assets/img/rss.svg
create mode 100644 site/templates/components/blog_tag_reference.html
create mode 100644 site/templates/components/rss_icon.html
diff --git a/config.yaml b/config.yaml
index 01715d9..6a9c028 100644
--- a/config.yaml
+++ b/config.yaml
@@ -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
\ No newline at end of file
diff --git a/jimsite/__init__.py b/jimsite/__init__.py
index b07106c..2ed9366 100644
--- a/jimsite/__init__.py
+++ b/jimsite/__init__.py
@@ -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()
diff --git a/jimsite/articles.py b/jimsite/articles.py
index 9ee38d3..e6074a1 100644
--- a/jimsite/articles.py
+++ b/jimsite/articles.py
@@ -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)
\ No newline at end of file
diff --git a/jimsite/blog.py b/jimsite/blog.py
index bbefe98..2e4403e 100644
--- a/jimsite/blog.py
+++ b/jimsite/blog.py
@@ -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 = '
'
- 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())
\ No newline at end of file
+ with open(f'{site.web_root.rstrip('/')}/rss.xml', 'w') as f:
+ f.write(feed.rss())
\ No newline at end of file
diff --git a/jimsite/common.py b/jimsite/common.py
index 9bc82ed..4ee97fc 100644
--- a/jimsite/common.py
+++ b/jimsite/common.py
@@ -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):
diff --git a/site/assets/css/theme.css b/site/assets/css/theme.css
index c66247f..21a1de5 100644
--- a/site/assets/css/theme.css
+++ b/site/assets/css/theme.css
@@ -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;
}
\ No newline at end of file
diff --git a/site/assets/img/rss.svg b/site/assets/img/rss.svg
new file mode 100644
index 0000000..98dc7b2
--- /dev/null
+++ b/site/assets/img/rss.svg
@@ -0,0 +1,18 @@
+
+
+
diff --git a/site/templates/components/blog_archive_li.html b/site/templates/components/blog_archive_li.html
index 4fe2927..6944216 100644
--- a/site/templates/components/blog_archive_li.html
+++ b/site/templates/components/blog_archive_li.html
@@ -1 +1 @@
-
By Jim Shepich III
- First published:
- Last modified:
+ First published:
+ Last modified:
- {content}
+ {article.content}
-
{blog_tags}
+
Tags: {blog_tags}{templates.components.rss_icon}
\ No newline at end of file
diff --git a/site/templates/components/blog_tag.html b/site/templates/components/blog_tag.html
index 8f1ebaa..8e481d4 100644
--- a/site/templates/components/blog_tag.html
+++ b/site/templates/components/blog_tag.html
@@ -1 +1 @@
-{tag_name}
\ No newline at end of file
+{tag_name}
\ No newline at end of file
diff --git a/site/templates/components/blog_tag_reference.html b/site/templates/components/blog_tag_reference.html
new file mode 100644
index 0000000..c850452
--- /dev/null
+++ b/site/templates/components/blog_tag_reference.html
@@ -0,0 +1,11 @@
+
+
{site.title} Tag Reference
+
+
By Jim Shepich III
+ First published:
+ Last modified:
+
+ {article.content}
+
+
{blog_tags}
+
\ No newline at end of file
diff --git a/site/templates/components/rss_icon.html b/site/templates/components/rss_icon.html
new file mode 100644
index 0000000..7d4c72b
--- /dev/null
+++ b/site/templates/components/rss_icon.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/site/templates/components/simple_article.html b/site/templates/components/simple_article.html
index 0b33bfe..82fa3fc 100644
--- a/site/templates/components/simple_article.html
+++ b/site/templates/components/simple_article.html
@@ -1,5 +1,5 @@
-
{metadata.title}
+
{article.metadata.title}
- {content}
+ {article.content}
\ No newline at end of file
From cf48c16bcae06f38bc781af8f142c1b9cd6713ac Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Sun, 1 Feb 2026 17:27:43 -0500
Subject: [PATCH 12/20] Fixed RSS icon
---
jimsite/blog.py | 6 +-----
site/assets/css/theme.css | 4 ++++
site/templates/components/rss_icon.html | 2 +-
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/jimsite/blog.py b/jimsite/blog.py
index 2e4403e..a1a8955 100644
--- a/jimsite/blog.py
+++ b/jimsite/blog.py
@@ -62,7 +62,7 @@ def build_tag_index(index: dict[str, Article]) -> dict[str, list[str]]:
tag_index[tag] = (tag_index.get(tag,[])) + [article]
-
+# TODO: create a tag inventory page, as well as individual browse-by-tag pages.
# def build_tag_inventory(
# site: SiteConfig,
# index: dict[str, tuple[str, str]],
@@ -72,10 +72,6 @@ def build_tag_index(index: dict[str, Article]) -> dict[str, list[str]]:
# tag_
-
-
-
-# TODO: Finish
def build_rss_feed(site: SiteConfig, index: dict[str, Article]):
feed = rfeed.Feed(
title = site.title,
diff --git a/site/assets/css/theme.css b/site/assets/css/theme.css
index 21a1de5..fed556e 100644
--- a/site/assets/css/theme.css
+++ b/site/assets/css/theme.css
@@ -179,4 +179,8 @@ article hr{
img.rss-icon{
width: 1rem;
vertical-align: middle;
+}
+
+span img.rss-icon{
+ float: right;
}
\ No newline at end of file
diff --git a/site/templates/components/rss_icon.html b/site/templates/components/rss_icon.html
index 7d4c72b..9368675 100644
--- a/site/templates/components/rss_icon.html
+++ b/site/templates/components/rss_icon.html
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
From de6a8973e6852665d661a9fd33b9b2a2a27e3bbb Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Mon, 2 Feb 2026 00:01:26 -0500
Subject: [PATCH 13/20] Tag selector
---
jimsite/assets.py | 1 +
jimsite/blog.py | 45 ++++++++++++++++---
jimsite/templating.py | 4 +-
site/assets/css/reset.css | 5 ++-
site/assets/css/theme.css | 43 +++++++++++++-----
site/templates/components/blog_archive.html | 4 ++
.../components/blog_archive_tag_selector.html | 4 ++
.../blog_archive_tag_selector_option.html | 3 ++
site/templates/components/blog_tag.html | 2 +-
9 files changed, 92 insertions(+), 19 deletions(-)
create mode 100644 site/templates/components/blog_archive_tag_selector.html
create mode 100644 site/templates/components/blog_archive_tag_selector_option.html
diff --git a/jimsite/assets.py b/jimsite/assets.py
index f72d0fb..c397b60 100644
--- a/jimsite/assets.py
+++ b/jimsite/assets.py
@@ -4,6 +4,7 @@ import shutil
from .common import run, SiteConfig
+# TODO: Add support for origin and branch.
def pull_git_repo(repo: str, build_cache: str) -> None:
'''Pulls/clones a repo into the build cache directory.'''
if os.path.exists(f'{build_cache}/.git'):
diff --git a/jimsite/blog.py b/jimsite/blog.py
index a1a8955..a596b82 100644
--- a/jimsite/blog.py
+++ b/jimsite/blog.py
@@ -7,6 +7,14 @@ from .articles import ArticleMetadata, Article, format_article_tags
from .templating import format_html_template, TemplateSelections
+def build_tag_index(index: dict[str, Article]) -> dict[str, list[str]]:
+ tag_index = {}
+ for article in index.values():
+ for tag in article.metadata.tags:
+ tag_index[tag] = (tag_index.get(tag,[])) + [article]
+ return tag_index
+
+
def build_blog_archive(
site: SiteConfig,
index: dict[str, Article],
@@ -35,10 +43,41 @@ def build_blog_archive(
)
archive_html_list +='
{content}
\ No newline at end of file
diff --git a/site/templates/components/blog_archive_tag_selector.html b/site/templates/components/blog_archive_tag_selector.html
new file mode 100644
index 0000000..09f89a7
--- /dev/null
+++ b/site/templates/components/blog_archive_tag_selector.html
@@ -0,0 +1,4 @@
+
+{tag_selector_css_rules}
\ No newline at end of file
diff --git a/site/templates/components/blog_archive_tag_selector_option.html b/site/templates/components/blog_archive_tag_selector_option.html
new file mode 100644
index 0000000..6f8cf07
--- /dev/null
+++ b/site/templates/components/blog_archive_tag_selector_option.html
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/site/templates/components/blog_tag.html b/site/templates/components/blog_tag.html
index 8e481d4..e0bdc54 100644
--- a/site/templates/components/blog_tag.html
+++ b/site/templates/components/blog_tag.html
@@ -1 +1 @@
-{tag_name}
\ No newline at end of file
+{tag_name}
\ No newline at end of file
From b24256a59e88fdf41571edbae7bee693167d2e37 Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Mon, 2 Feb 2026 00:19:40 -0500
Subject: [PATCH 14/20] Tag linking
---
site/assets/js/blog_archive_query.js | 11 +
site/assets/js/character_sheet.js | 177 ---------
site/assets/js/json_scraper.js | 63 ----
site/assets/js/lists.js | 385 --------------------
site/assets/js/resize.js | 1 -
site/assets/js/vendor/jquery-3.6.0.min.js | 2 -
site/assets/php/footer.php | 11 -
site/assets/php/nav.php | 44 ---
site/assets/php/query_handler.php | 23 --
site/templates/components/blog_archive.html | 3 +-
site/templates/components/blog_tag.html | 2 +-
11 files changed, 14 insertions(+), 708 deletions(-)
create mode 100644 site/assets/js/blog_archive_query.js
delete mode 100644 site/assets/js/character_sheet.js
delete mode 100644 site/assets/js/json_scraper.js
delete mode 100644 site/assets/js/lists.js
delete mode 100644 site/assets/js/resize.js
delete mode 100644 site/assets/js/vendor/jquery-3.6.0.min.js
delete mode 100644 site/assets/php/footer.php
delete mode 100644 site/assets/php/nav.php
delete mode 100644 site/assets/php/query_handler.php
diff --git a/site/assets/js/blog_archive_query.js b/site/assets/js/blog_archive_query.js
new file mode 100644
index 0000000..4fddc32
--- /dev/null
+++ b/site/assets/js/blog_archive_query.js
@@ -0,0 +1,11 @@
+function selectTagByUrlQuery(){
+ query = new URLSearchParams(window.location.search);
+ tag = query.get('tag');
+
+ if(tag){
+ document.querySelector(`#tag-selector-${tag}`).checked = true;
+ }
+}
+
+document.addEventListener('load', selectTagByUrlQuery);
+selectTagByUrlQuery();
\ No newline at end of file
diff --git a/site/assets/js/character_sheet.js b/site/assets/js/character_sheet.js
deleted file mode 100644
index e0a8ada..0000000
--- a/site/assets/js/character_sheet.js
+++ /dev/null
@@ -1,177 +0,0 @@
-if(document.getElementsByName("keywords")[0].content.indexOf("player")>-1){
- main();
-}
-
-function main(){
- var tables = document.getElementsByTagName("table");
- var abilities = {"-":0};
- var skills = {};
- var stats = {};
- var features = {};
- var masteries = [];
- var EXP = {};
-
- for(i=0;i tr -> th elements
- for(k=0;k-1){
- output += 1;
- }
- }
- return output;
- }
-
- function align(cell,how="center"){
- cell.className = cell.className.replace("leftalign","");
- cell.className = cell.className.replace("rightalign","");
- cell.className = cell.className.replace("centeralign","");
- cell.className += " "+how+"align";
-
- }
-
- for(i=0;i-1) {
- stats["HD Improvement"] = parseInt(exp_value.substring(exp_value.indexOf("d")+1,exp_value.length));
- stats["HD Increase"] = parseInt(exp_value.substring(0,exp_value.indexOf("d")));
- exp_value = stats["HD Improvement"]+stats["HD Increase"];
- } else {
- exp_value = 0;
- exp.innerHTML = "0";
- }
-
- }
- EXP[stat.innerHTML.trim()] = exp_value;
- }
-
- for(i=0;i-1){
- exp = parseInt(exp_str.substring(0,exp_str.indexOf("/")));
- } else {
- exp = parseInt(exp_str);
- }
- EXP[feature.innerHTML.trim()] = exp;
- }
- }
-
- var HP = 1 + Math.floor((skills["Persistence"]+skills["Toughness"])/2);
- if(HP<1){
- HP = 1;
- }
- for(h=1;h<=EXP["HP"];h++){
- if(h%5==0 && abilities["CON"]>=1){
- HP += abilities["CON"];
- } else {
- HP += 1;
- }
- }
- stats["HP"].innerHTML = " "+HP+" ";
-
- if(!("HD Increase" in stats) && !("HD Improvement" in stats)){
- stats["HD Increase"] = 0;
- stats["HD Improvement"] = 0;
-
- }
- var hd = [Math.floor(stats["HD Increase"]/2)+1];
- hd.push(4+2*Math.floor(stats["HD Improvement"]/2));
- stats["Hit Dice"].innerHTML = hd[0]+"d"+hd[1];
-
-
- var AC = 8 + Math.ceil(skills["Toughness"]/3) + Math.ceil(skills["Evasion"]/3);
- stats["AC"].innerHTML = " "+AC+" ";
-
- var exp_total = [0,parseInt(stats["EXP Total"].innerHTML.trim())];
- for(key in EXP){
- var temp_exp = parseInt(EXP[key]);
- if(!isNaN(temp_exp)){
- exp_total[0] += temp_exp;
- }
- }
- stats["EXP Total"].innerHTML = (exp_total[1]-exp_total[0])+"/"+exp_total[1];
-
-}
diff --git a/site/assets/js/json_scraper.js b/site/assets/js/json_scraper.js
deleted file mode 100644
index 8e3363c..0000000
--- a/site/assets/js/json_scraper.js
+++ /dev/null
@@ -1,63 +0,0 @@
-function extract_data(){
- var data = document.getElementsByTagName("tr");
- var master = {};
- var header = null;
- for(row of data){
- if(row.className == "row0"){
- continue;
- //Skip table headers
- } else if (row.className == "row1"){
- header = row.parentElement.parentElement.parentElement.parentElement.previousElementSibling.innerText;
- //If we're at a new table, then we have to go up the DOM tree until we
- //find that section's header.
- master[header] = [];
- //And create a new list for that header in the master object.
- }
-
- var row_object = {};
- row_object["link"] = row.children[0].children[0].href;
- //The link can be found in the anchor element in the first cell of the
- //row.
- var text = row.children[0].children[0].innerText;
- //Get the text of the link.
- row_object["song"] = text.substring(0,text.indexOf("(")-1);
- //The name of the song is the start of the text until the space before
- //the parenthesis. I've made sure to use hyphens in place of parentheses in
- //song titles.
- row_object["source"] = text.substring(text.indexOf("(")+1, text.indexOf(")"));
- //The work that the song is from is indicated between the parentheses.
-
- row_object["vibes"] = row.children[1].innerText.split(", ")
- //The vibes are in the second column, delimited by a comma-space.
-
-
- master[header].push(row_object);
-
- }
-
- return master;
-}
-
-
-
-
-function download(filename, text) {
- //Borrowed shamelessly from:
- //https://ourcodeworld.com/articles/read/189/how-to-create-a-file-and-generate-a-download-with-javascript-in-the-browser-without-a-server
- var element = document.createElement('a');
- element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
- element.setAttribute('download', filename);
-
- element.style.display = 'none';
- document.body.appendChild(element);
-
- element.click();
-
- document.body.removeChild(element);
-}
-
-var button = document.getElementById("download-JSON");
-button.addEventListener("click",function(){
- var json_data = JSON.stringify(extract_data(),null,2);
- download("epics_bgm.json",json_data);
-});
diff --git a/site/assets/js/lists.js b/site/assets/js/lists.js
deleted file mode 100644
index 8ee9710..0000000
--- a/site/assets/js/lists.js
+++ /dev/null
@@ -1,385 +0,0 @@
-//Variale `list` is imported from JSON with query_handler.php.
-//var lists = JSON.parse(document.getElementById("lists-json").innerText);
-var list_id = document.getElementById("query-list").innerText;
-//Get list id from tag created in query-handler.php.
-var selected_list = null;
-if(lists.hasOwnProperty(list_id)){
- selected_list = lists[list_id];
-} else {
- selected_list = lists["master"];
- //If the list id in the query is invalid, go back to the main list.
-}
-
-
-
-function gen_list_html(list){
- //This function creates HTML from a list's JSON object by iterativel calling the gen_item_html function to convert individual items. This design paradigm facilitates the construction of nested lists or lists with sections.
- var html = "";
- if(!list.hasOwnProperty("type")){
- list.type = "default";
- }
-
- if(!list.hasOwnProperty("subtype")){
- list.subtype = "default";
- }
-
- var list_type = list.type;
-
- if(list.hasOwnProperty("sections")){
- list_type = "sectioned";
- //Lists that have separate sections require special handling involving recursion.
- }
-
- switch(list_type){
- case "master":
- for(id in lists){
- html += gen_item_html(id,type="list-id");
- }
- html = `
${html}
`;
- break;
-
- case "quotes":
- html += "";
- for(quote of list.list){
- html += gen_item_html(quote,type="quote");
- }
- break;
-
- case "key-value":
- for(pair of list.list){
- html += gen_item_html(pair,type="kv-pair");
- }
- html = (list.hasOwnProperty("ordered") && list.ordered) ? `${html}` : `
${html}
`;
- //Create an ordered list if list has the property "ordered".
- break;
-
-
- case "gallery":
- document.getElementById("lists").style.textAlign="center";
- //Center the rows of exhibits on the page.
- if(!list.hasOwnProperty("subtype")){
- list.subtype="default";
- }
- for(exhibit of list.list){
- html += gen_item_html(exhibit,type="exhibit",subtype=list.subtype);
- }
- break;
-
- case "external":
- //This branch will only run if someone manually enters the list name into the query; clicking an external-type list from the Master List will just open the link in a new tab.
- setTimeout(function(){location.assign(list["link"])}, 600);
- //If the list specified in the query has the external property, then redirect to the external link. I use setTimeout in order to allow the rest of the code to run.
- list.title = `Redirecting to List of ${list.title}...`;
- list.list = [];
- //It takes a second to redirect, so put some filler on the page while the reader waits.
- break;
-
- case "sectioned":
- if(!list.hasOwnProperty("section-level")||list["section-level"]==null){
- list["section-level"] = 1;
- //Section-level defines the heading level of the section. Top level is 1.
- }
- if(!list.hasOwnProperty("dropdown")||list.dropdown==null){
- list.dropdown = true;
- //By default, sections ARE in details/summary tags.
- }
- if(!list.hasOwnProperty("dropdown-open")||list["dropdown-open"]==null){
- list["dropdown-open"] = false;
- //By default, sections are collapsed.
- }
- for(section of list.sections){
- var section_html = "";
- var level = list["section-level"] + 1;
- section["section-level"] = level;
- //Nested secions are of lower levels.
- if(!section.hasOwnProperty("type")||section.type==undefined){
- section.type = list.type;
- //This branch transfers the list type down from higher levels. By default, the bottom-level lists will inherit the type of the top-level object unless otherwise specified.
- }
- if(!section.hasOwnProperty("subtype")||section.subtype==undefined){
- section.subtype = list.subtype;
- //Sections should also inherit subtypes.
- }
- if(!section.hasOwnProperty("dropdown")||section.dropdown==undefined){
- section.dropdown = list.dropdown;
- //Inherit dropdown-ness unless otherwise specified.
- }
- if(!section.hasOwnProperty("dropdown-open")||section["dropdown-open"]==undefined){
- section["dropdown-open"] = list["dropdown-open"];
- //Inherit whether the dropdown should be collapsed or open.
- }
- var description = (section.hasOwnProperty("description")) ? `
${section.description}
` : "";
- //Sections can have their own descriptions.
-
- var title = `${section.title}`;
- //Wrap the section title in a header tag.
-
- if(section.hasOwnProperty("dropdown") && section.dropdown){
- //If the section is marked with the "dropdown" attribute, then nest the section's data in a details/summary tag.
- var open = (section["dropdown-open"]) ? "open":"";
- section_html = `${title}${description}${gen_list_html(section)}`;
- } else {
- section_html = `${title}${description}${gen_list_html(section)}`;
- }
- //Sectioned
- html += `${section_html}`;
- //Assigning ids allows for the possibility of fragment linking.
- }
- break;
-
- default:
- for(item of list.list){
- html += gen_item_html(item);
- }
-
- html = (list.hasOwnProperty("ordered") && list.ordered) ? `${html}` : `
${html}
`;
- //Create an ordered list if list has the property "ordered".
- }
-
- return html;
-
-}
-
-function gen_item_html(item,type="default",subtype=null){
- var item_html = "";
- switch(type){
- case "list-id":
- if(lists[item].hasOwnProperty("hidden") && lists[item].hidden){
- return "";
- //Lists marked with the "hidden" attribute are in-development and should not be displayed on the Master List.
- }
- if(!lists[item].hasOwnProperty("title")){
- lists[item].title = item;
- //If a title is not set for the list, then just use its id.
- }
- var tooltip = "";
- if(lists[item].hasOwnProperty("description")){
- tooltip=lists[item]["description"];
- //When hovering over a list in the directory, display its description as a tooltip.
- }
- var link = `href='index.php?page=lists&list=${item}' target='_self'`;
- //By default, lists have internal links that change the query value to that list's title. Internal links should open in the same tab.
- if(lists[id].hasOwnProperty("type") && lists[item].type=="external"){
- link = `href='${lists[item]["link"]}' target='_blank'`;
- //For external lists, use their link instead of an internal link. External links should open in a new tab.
- }
- item_html = `
`;
- //The Master List contains a link to every other list in the JSON file.
- break;
-
- case "quote":
- //Format quotes like they are formatted on Goodreads.
- if(!item.hasOwnProperty("quote")){
- //If the quote is blank, then things are going to get real weird. Most likely, this will be caused by a typo, so this branch is a safeguard against any resulting errors.
- return "";
- }
- item_html = "
";
- if(item.hasOwnProperty("title")){
- item_html += `${item.title} `;
- //Add a title if the quote has one (e.g. "The Litany Against Fear").
- }
- item_html += `“${item.quote}” `;
- //Add the text of the quote.
- if(item.hasOwnProperty("card") && !item.hasOwnProperty("quotee")){
- item.quotee="";
- //If a flavor text doesn't have a quotee, don't write "Unknown".
- }
-
- item_html += ` — ${item.hasOwnProperty("quotee") ? item.quotee : "Unknown"}`;
- //Add the quotee's name, or "Unknown" if one is not specified.
- if(item.hasOwnProperty("source")){
- if(item.hasOwnProperty("quotee") && item.quotee!=""){
- item_html += ", ";
- //Unless there is no quotee, separate the quotee from the source with ", "
- }
- item_html += item.source;
- //Add the source if the quote has one.
- }
-
- if(item.hasOwnProperty("card")){
- if(item.quotee!=""||item.hasOwnProperty("source")){
- item_html += `, `;
- //Separate quotee or source from card title with ", "
- }
- item_html += `${item.card} (Magic: the Gathering)`;
- }
- item_html += "
";
- //Delimit the quotes with a horizontal rule.
- break;
-
- case "kv-pair":
- if(!(item.hasOwnProperty("k")&&item.hasOwnProperty("v"))||Object.keys(item).length==1){
- var key = Object.keys(item)[0];
- item["k"] = key;
- item["v"] = item[key];
- //If the item is an object containing a single key-value pair, then use that pair as the k and v for displaying.
- }
- if(item.hasOwnProperty("link")){
- item_html = `${item["k"]}`;
- } else {
- item_html = `${item["k"]}`;
- }
- item_html += ` — ${item["v"]}`;
-
- if(item.hasOwnProperty("list") && Array.isArray(item.list)){
- //Sublist time, baby!
- var temp = {
- "title":item_html,
- "list":item.list,
- "ordered": (item.hasOwnProperty("ordered")) ? item.ordered:undefined,
- "dropdown": (item.hasOwnProperty("dropdown")) ? item.dropdown:undefined,
- "dropdown-open": (item.hasOwnProperty("dropdown-open")) ? item["dropdown-open"]:undefined
- };
- //If a key-value pair has a list key with an array attribute, then reformat it as a sublist whose title is the kv pair and whose list is the array.
- item_html = gen_item_html(temp,type="sublist")
- } else {
- item_html = `
${item_html}
`;
- }
- break;
-
-
- case "exhibit":
- var tooltip, alt, text, img_src, image;
- tooltip = text = img_src = image = "";
- classes = {"div":[],"img":[],"a":[]}
- switch(subtype){
- case "album":
- tooltip = `${item.title} (${item.year}) - ${item.artist}`;
- alt = tooltip;
- img_src = item.cover;
- text = `${item.title} ${item.artist} (${item.year})`;
- break;
- case "movie":
- tooltip = `${item.title} (${item.year})`;
- alt = tooltip;
- img_src = item.poster;
- text = `${item.title} (${item.year})`;
- break;
- case "mtg-card":
- var gatherer_link = "https://gatherer.wizards.com/pages/card/Details.aspx?multiverseid=";
- var gatherer_image = "https://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=";
-
- if(item.hasOwnProperty("multiverseid")&&!item.hasOwnProperty("link")){
- if(Array.isArray(item.multiverseid)){
- //If the multiverseid is an array, treat it as a transform card and link to the Gatherer page for the front half.
- item.link = gatherer_link+item.multiverseid[0];
- } else {
- item.link = gatherer_link+item.multiverseid;
- }
- //If there's no alternate link specified, then use the multiverseid to generate the Gatherer link.
- }
- if(item.hasOwnProperty("multiverseid")&&!item.hasOwnProperty("image")){
- if(Array.isArray(item.multiverseid)){
- img_src = [];
- for(id of item.multiverseid){
- img_src.push(gatherer_image+id+"&type=card")
- }
- //If there are multiple (two) multiverseids, make a link src for each of them for convenience.
- } else {
- img_src = gatherer_image+item.multiverseid+"&type=card";
- }
- //If there's no alternate link specified, then use the multiverseid to generate the Gatherer link for the image.
- } else {
- if(Array.isArray(item.image)){
- img_src=[];
- for(src of item.image){
- img_src.push(src);
- }
- //If multiple (two) image sources are specified, create an array of both.
- } else {
- img_src = item.image;
- }
- }
- if(item.hasOwnProperty("name")){
- alt = tooltip = item.name;
- }
-
- if(Array.isArray(img_src)){
- image = ``
- //Two srcs are to be treated as those of a transform card, which switches on hover.
- classes.div.push("card-transform");
- }
-
-
- break;
- default:
- }
-
- if(image==""||!image){
- image = image = ``;
- //If the image variable is not already defined, then generate it.
- }
- //Gallery items must have an image.
- if(item.hasOwnProperty("link")){
- image = `${image}`;
- //If there's a link associated with the exhibit, put it on the image.
- }
- item_html = (text=="") ? image : `${image} ${text}`;
- //If there's no text, then there's no need for a line break.
- item_html = `
${item_html}
`
-
- break;
-
- case "sublist":
- //Sublists can be any type (most likely default or key-value), so the best way to deal with them is recursion.
- if(item.hasOwnProperty("dropdown") && item.dropdown){
- var open = (item.hasOwnProperty("dropdown-open")&&item["dropdown-open"])?"open":"";
- item_html = `
`;
- //If the element is a simple string or number, then we don't need to do any special formatting.
- } else if (typeof item === "object"){
- var keys = Object.keys(item);
- if(keys.length == 1){
- if (Array.isArray(item[keys[0]])){
- //An item that is a dictionary only containing a list is probably a sublist. Format it as such, and pass it back through this switch statement.
- var temp = {
- "title":keys[0],
- "type":"default",
- "list":item[keys[0]]
- };
- item_html = gen_item_html(temp,"sublist");
- } else {
- item_html = gen_item_html(item,"kv-pair");
- //A item that is dictionary with one key, whose value is not a list, is to be treated as a term-explanation type key-value pair.
- }
- } else {
- item_html = gen_item_html(item,"sublist");
- //At this point, if there is no other specification, an item that's an object is probably a sublist.
- }
- }
- }
- return item_html;
-}
-
-function str2html(md){
- //This function replaces escapes commands and Markdown with their HTML equivalents.
- html = md.replaceAll("\n"," ");
- return html;
-}
-
-
-document.getElementById("list-title").innerHTML = selected_list.title;
-if(selected_list.hasOwnProperty("description")){
- document.getElementById("list-description").innerHTML = selected_list.description;
-} else {
- document.getElementById("list-description").style.display = "none";
- //If the list has no description, hide the element to remove the awkward space from the padding.
-}
-//Generate the article header from the list's title and description.
-
-document.getElementById("list-container").innerHTML += str2html(gen_list_html(selected_list));
-//Call the gen_list_html function to convert the list from a JSON object to sophisticated HTML.
-
-if(list_id != "master"){
- document.getElementById("lists").innerHTML += "
";
-}
-
-$page_array = get_object_vars($pages);
-//Convert $pages from object to an associative array of [key=>val, key=>val]
-//where the keys are the names of the page objects from the JSON file and the
-//vals are the page objects themselves.
-usort($page_array,"page_comparator");
-//Sort the pages in order of increasing "index" value.
-
-foreach($page_array as $key=>$p){
- if($p->index>-1){
- gen_nav_element($p);
- //Pages with indices less than 0 are hidden from the navibar.
- }
-}
-
-?>
diff --git a/site/assets/php/query_handler.php b/site/assets/php/query_handler.php
deleted file mode 100644
index 4114cca..0000000
--- a/site/assets/php/query_handler.php
+++ /dev/null
@@ -1,23 +0,0 @@
-$query_page)) ? $pages->$query_page : $pages->{404};
- //If the query value ($query_page) is not in the dictionary ($pages), then load the 404 page instead.
- echo "".$query_page."";
- //Store the page id in a var tag in case we need to access it with JavaScript.
-
- $list = (isset($_REQUEST['list']) && !empty($_REQUEST['list'])) ? $_GET['list'] : "master";
- //Get the value of "list" from the query key; default is "master" (the master list).
- echo "".$list."";
- //Store the list id in a var tag so that we can easily figure out what list to generate with list.js.
-
- if($query_page=="lists"){
- $lists_json = json_decode(file_get_contents(__DIR__ ."/../data/lists.json"));
- echo "";
- //Copy the data from lists.json and paste it into a script tag, saving it as a variable called `list` to be accessed by later JavaScript code (i.e. lists.js).
- //I would have stored it in a var tag, but there was a weird bug with some of the tags leaking out and making my title underlined, and this seemed to avoid that (and it's probably more efficient too).
- }
-?>
diff --git a/site/templates/components/blog_archive.html b/site/templates/components/blog_archive.html
index ee2fc92..e552559 100644
--- a/site/templates/components/blog_archive.html
+++ b/site/templates/components/blog_archive.html
@@ -6,4 +6,5 @@
Post History
{content}
-
\ No newline at end of file
+
+
\ No newline at end of file
diff --git a/site/templates/components/blog_tag.html b/site/templates/components/blog_tag.html
index e0bdc54..764e4c8 100644
--- a/site/templates/components/blog_tag.html
+++ b/site/templates/components/blog_tag.html
@@ -1 +1 @@
-{tag_name}
\ No newline at end of file
+{tag_name}
\ No newline at end of file
From ed7ddb2dc7ec8b6d957125d0b889583a2b66417f Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Mon, 2 Feb 2026 13:35:53 -0500
Subject: [PATCH 15/20] Moved home to index; added order to blog tag selector
---
index.php | 47 --------------------------------
jimsite/blog.py | 34 +++++++++++------------
site/pages/{home.md => index.md} | 0
3 files changed, 17 insertions(+), 64 deletions(-)
delete mode 100644 index.php
rename site/pages/{home.md => index.md} (100%)
diff --git a/index.php b/index.php
deleted file mode 100644
index dc0525b..0000000
--- a/index.php
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- name." | Jim Shepich";
- ?>
-
-
-
- Jim Shepich III
-
-
-
- link)){
- echo "";
- //If the directory assigns the page to an external link, redirect to that location.
- }
-
- if(isset($page->iframe)){
- echo "";
- //If the directory says to embed the page in an iframe, then do that.
- } else {
- echo file_get_contents(__DIR__ ."/pages/".$page->file);
- //Otherwise, just write the HTML of the page in the content area.
- }
-
- ?>
-
-
-
-
-
diff --git a/jimsite/blog.py b/jimsite/blog.py
index a596b82..5ec4c16 100644
--- a/jimsite/blog.py
+++ b/jimsite/blog.py
@@ -7,11 +7,22 @@ from .articles import ArticleMetadata, Article, format_article_tags
from .templating import format_html_template, TemplateSelections
-def build_tag_index(index: dict[str, Article]) -> dict[str, list[str]]:
+def build_tag_index(index: dict[str, Article], wildcard ='*') -> dict[str, list[Article]]:
+ '''Creates an inverted index mapping each article tag to a postings list of articles
+ with said tag.'''
tag_index = {}
- for article in index.values():
+
+ # Index the articles in ascending order of original publication date.
+ for article in sorted(index.values(), key = lambda a: a.metadata.date):
+
+ # Add all articles to the wildcard tag's postings list.
+ if wildcard is not None:
+ tag_index[wildcard] = (tag_index.get(wildcard,[])) + [article]
+
+ # Add the article to each of its tags' posting lists.
for tag in article.metadata.tags:
- tag_index[tag] = (tag_index.get(tag,[])) + [article]
+ tag_index[tag] = (tag_index.get(tag,[])) + [article]
+
return tag_index
@@ -51,7 +62,9 @@ def build_blog_archive(
display: list-item!important;
}}}}
''']
- for tag, articles in ({'*': [*index.keys()]} | tag_index).items():
+
+ # Add tag selector options in descending order of article count.
+ for tag, articles in sorted(tag_index.items(), key = lambda item : -len(item[1])):
tag_selector_options.append(format_html_template(
template_selections['tag_selector_option'],
tag_name = tag,
@@ -92,19 +105,6 @@ def build_blog_archive(
with open(f'{site.web_root.rstrip('/')}/archive.html', 'w') as f:
f.write(archive_html_page)
-
-
-
-
-
-# TODO: create a tag inventory page, as well as individual browse-by-tag pages.
-# def build_tag_inventory(
-# site: SiteConfig,
-# index: dict[str, tuple[str, str]],
-# tag_index: dict[str, list[str]],
-# template_selections: TemplateSelections
-# ):
-# tag_
def build_rss_feed(site: SiteConfig, index: dict[str, Article]):
diff --git a/site/pages/home.md b/site/pages/index.md
similarity index 100%
rename from site/pages/home.md
rename to site/pages/index.md
From ab94ee0babfb9b7af3c8197af41719d0a0735c9a Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Mon, 2 Feb 2026 13:43:45 -0500
Subject: [PATCH 16/20] Python 3.9 friendliness
---
jimsite/__init__.py | 2 +-
jimsite/articles.py | 6 +++---
jimsite/blog.py | 8 ++++----
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/jimsite/__init__.py b/jimsite/__init__.py
index 2ed9366..f8b83e5 100644
--- a/jimsite/__init__.py
+++ b/jimsite/__init__.py
@@ -76,7 +76,7 @@ def build_site(site: SiteConfig, templates: DotMap):
def main():
logger.info('Loading config.')
- with open('/home/jim/projects/shepich.com/config.yaml', 'r') as config_file:
+ with open('./config.yaml', 'r') as config_file:
config = yaml.safe_load(config_file.read())
logger.info('Loading global templates.')
diff --git a/jimsite/articles.py b/jimsite/articles.py
index e6074a1..cd2b9b7 100644
--- a/jimsite/articles.py
+++ b/jimsite/articles.py
@@ -3,7 +3,7 @@ import glob
import yaml
import markdown
import pydantic
-from typing import Optional
+from typing import Optional, Union
from dotmap import DotMap
from datetime import date
@@ -26,7 +26,7 @@ class Article(pydantic.BaseModel):
metadata: Optional[ArticleMetadata] = None
-def load_markdown(md: str) -> tuple[ArticleMetadata|None, str]:
+def load_markdown(md: str) -> tuple[Optional[ArticleMetadata], str]:
'''Loads a Markdown file into a (metadata: ArticleMetadata, content: str) pair.'''
# Load the file contents if a filepath is specified, and strip document delimiters ('---').
@@ -59,7 +59,7 @@ def build_index(site: SiteConfig) -> dict[str, Article]:
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.removeprefix('./').lstrip("/")}')
+ glob.glob(f"{site.build_cache}/{a.removeprefix('./').lstrip('/')}")
)
diff --git a/jimsite/blog.py b/jimsite/blog.py
index 5ec4c16..8a3b4b8 100644
--- a/jimsite/blog.py
+++ b/jimsite/blog.py
@@ -103,21 +103,21 @@ def build_blog_archive(
**kwargs
)
- with open(f'{site.web_root.rstrip('/')}/archive.html', 'w') as f:
+ with open(f"{site.web_root.rstrip('/')}/archive.html", 'w') as f:
f.write(archive_html_page)
def build_rss_feed(site: SiteConfig, index: dict[str, Article]):
feed = rfeed.Feed(
title = site.title,
- link = f'{site.base_url.rstrip('/')}/rss.xml',
+ link = f"{site.base_url.rstrip('/')}/rss.xml",
description = site.description,
language = "en-US",
lastBuildDate = datetime.now(),
items = [
rfeed.Item(
title = article.metadata.title,
- link = f'{site.base_url.rstrip('/')}/{article.path}',
+ link = f"{site.base_url.rstrip('/')}/{article.path}",
description = article.metadata.description,
author = article.metadata.author,
guid = rfeed.Guid(article.path),
@@ -131,5 +131,5 @@ def build_rss_feed(site: SiteConfig, index: dict[str, Article]):
]
)
- with open(f'{site.web_root.rstrip('/')}/rss.xml', 'w') as f:
+ with open(f"{site.web_root.rstrip('/')}/rss.xml", 'w') as f:
f.write(feed.rss())
\ No newline at end of file
From aa6cae97fde2c7d7cb1028887f39ffc61c309f64 Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Mon, 2 Feb 2026 13:49:04 -0500
Subject: [PATCH 17/20] Fixed for Python 3.9
---
jimsite/articles.py | 2 +-
jimsite/blog.py | 5 +++--
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/jimsite/articles.py b/jimsite/articles.py
index cd2b9b7..7a449dd 100644
--- a/jimsite/articles.py
+++ b/jimsite/articles.py
@@ -123,7 +123,7 @@ def build_articles(
)
- with open(f'{site.web_root.rstrip('/')}/{article.path}', 'w') as f:
+ with open(f"{site.web_root.rstrip('/')}/{article.path}", 'w') as f:
f.write(page_html)
\ No newline at end of file
diff --git a/jimsite/blog.py b/jimsite/blog.py
index 8a3b4b8..59229a3 100644
--- a/jimsite/blog.py
+++ b/jimsite/blog.py
@@ -83,14 +83,15 @@ def build_blog_archive(
}}}}
''')
-
+ # For Python 3.9-friendliness.
+ newline = '\n'
# Generate the archive article.
archive_html_article = format_html_template(
template_selections['archive_article'],
content = archive_html_list,
tag_selector_options = ' '.join(tag_selector_options),
- tag_selector_css_rules = f'',
+ tag_selector_css_rules = f'',
site = site,
**kwargs
)
From be0e7c4ae602cf80f9029c8e849416c010336a33 Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Mon, 2 Feb 2026 13:52:31 -0500
Subject: [PATCH 18/20] Added
---
site/templates/pages/default.html | 1 +
1 file changed, 1 insertion(+)
diff --git a/site/templates/pages/default.html b/site/templates/pages/default.html
index 66608ca..d2724d4 100644
--- a/site/templates/pages/default.html
+++ b/site/templates/pages/default.html
@@ -1,3 +1,4 @@
+
From d8067b1d93207b0c8b74e186ea7da61a3581684b Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Mon, 2 Feb 2026 17:06:31 -0500
Subject: [PATCH 19/20] Addressed lighthouse report
---
site/templates/pages/default.html | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/site/templates/pages/default.html b/site/templates/pages/default.html
index d2724d4..608c0ce 100644
--- a/site/templates/pages/default.html
+++ b/site/templates/pages/default.html
@@ -1,7 +1,8 @@
-
+
+
{templates.partials.default_css}
{templates.partials.header}
From f42b10351563cdcc240c3fa12e75ab2a22ce1913 Mon Sep 17 00:00:00 2001
From: Jim Shepich III
Date: Tue, 3 Feb 2026 01:07:11 -0500
Subject: [PATCH 20/20] Configured to run as a Python module
---
.gitignore | 3 ++-
config.yaml | 7 ++++--
jimsite/__init__.py | 24 +++++--------------
jimsite/__main__.py | 15 ++++++++++++
jimsite/articles.py | 3 +++
jimsite/assets.py | 26 ++++++++++++++-------
jimsite/common.py | 7 +++++-
site/templates/components/blog_archive.html | 2 ++
8 files changed, 57 insertions(+), 30 deletions(-)
create mode 100644 jimsite/__main__.py
diff --git a/.gitignore b/.gitignore
index d9d0210..68fd4d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,5 @@ shepich resume.pdf
tmp
build
dist
-**/__pycache__
\ No newline at end of file
+**/__pycache__
+custom.yaml
\ No newline at end of file
diff --git a/config.yaml b/config.yaml
index 6a9c028..93a5532 100644
--- a/config.yaml
+++ b/config.yaml
@@ -17,7 +17,8 @@ sites:
title: Resume
base_url: http://localhost:8000
web_root: ./dist
- git_repo: ssh://gitea/jim/resume.git
+ git_repo:
+ url: ssh://gitea/jim/resume.git
build_cache: ./build/resume
assets:
- 'shepich_resume.pdf'
@@ -26,7 +27,9 @@ sites:
title: 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
+ git_repo:
+ url: ssh://gitea/jim/dogma-jimfinium.git
+ branch: pub
build_cache: ./build/dogma-jimfinium
web_root: ./dist/dogma-jimfinium
assets:
diff --git a/jimsite/__init__.py b/jimsite/__init__.py
index f8b83e5..76f716a 100644
--- a/jimsite/__init__.py
+++ b/jimsite/__init__.py
@@ -1,13 +1,5 @@
import os
-import re
-import glob
-import shutil
-import subprocess
-import markdown
import yaml
-import pydantic
-from typing import Optional
-from datetime import datetime, date
from dotmap import DotMap
import logging
@@ -21,13 +13,8 @@ from .articles import ArticleMetadata, load_markdown, build_articles, build_inde
from .blog import build_blog_archive, build_rss_feed
-
-
-
-
-
-
def build_site(site: SiteConfig, templates: DotMap):
+ '''The pipeline for building a site defined in config.yaml.'''
logger.info(f'Building site "{site.title}".')
@@ -74,9 +61,11 @@ def build_site(site: SiteConfig, templates: DotMap):
else:
logger.debug('Addon "rss" not elected.')
-def main():
+
+def build(config_filepath = './config.yaml'):
+ '''Pipeline for building the entire website, including all sites defined in config.yaml.'''
logger.info('Loading config.')
- with open('./config.yaml', 'r') as config_file:
+ with open(config_filepath, 'r') as config_file:
config = yaml.safe_load(config_file.read())
logger.info('Loading global templates.')
@@ -85,6 +74,5 @@ def main():
for site in config['sites'].values():
build_site(SiteConfig(**site), templates)
-if __name__ == '__main__':
- main()
+
diff --git a/jimsite/__main__.py b/jimsite/__main__.py
new file mode 100644
index 0000000..e6cf236
--- /dev/null
+++ b/jimsite/__main__.py
@@ -0,0 +1,15 @@
+import argparse
+from jimsite import build
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(
+ prog='Jimsite',
+ description='A Python-based templating engine for building static websites from Markdown documents.',
+ epilog='Gonna be a hot one!'
+ )
+
+ parser.add_argument('-c', '--config', type=str, default='config.yaml', help='Specifies the path to a YAML config file; defaults to config.yaml in the CWD.')
+
+ args = parser.parse_args()
+
+ build(config_filepath = args.config)
\ No newline at end of file
diff --git a/jimsite/articles.py b/jimsite/articles.py
index 7a449dd..ec803b1 100644
--- a/jimsite/articles.py
+++ b/jimsite/articles.py
@@ -11,6 +11,7 @@ from .common import filepath_or_string, SiteConfig
from .templating import format_html_template, TemplateSelections
class ArticleMetadata(pydantic.BaseModel):
+ '''A model for the YAML frontmatter included with Markdown articles.'''
title: str
date: date
published: bool
@@ -21,6 +22,8 @@ class ArticleMetadata(pydantic.BaseModel):
description: Optional[str] = None
class Article(pydantic.BaseModel):
+ '''A model for a Markdown article, including its YAML frontmatter
+ metadata, as well as its path relative to the build cache root.'''
path: str
content: str
metadata: Optional[ArticleMetadata] = None
diff --git a/jimsite/assets.py b/jimsite/assets.py
index c397b60..70f13dd 100644
--- a/jimsite/assets.py
+++ b/jimsite/assets.py
@@ -1,20 +1,30 @@
import os
import glob
import shutil
-from .common import run, SiteConfig
+from .common import run, GitRepo, SiteConfig
-# TODO: Add support for origin and branch.
-def pull_git_repo(repo: str, build_cache: str) -> None:
+def pull_git_repo(repo: GitRepo, build_cache: str) -> None:
'''Pulls/clones a repo into the build cache directory.'''
+
+ # If a repo exists in the build cache, pull to it.
if os.path.exists(f'{build_cache}/.git'):
- run(f'git -C {build_cache} pull origin')
+
+ # If a branch is specified, check out to it.
+ if repo.branch is not None:
+ run(f"git checkout {repo.branch}")
+ run(f"git -C {build_cache} pull {repo.remote}{' '+repo.branch if repo.branch else ''}")
+
+ # If the build cache is empty, clone the repo into it.
else:
- run(f'git clone {repo} {build_cache}')
+ run(f"git clone {repo.url}{(' -b '+repo.branch) if repo.branch else ''} -o {repo.remote} {build_cache}")
-def copy_assets(site: SiteConfig):
- '''Copies the list of site assets from the build cache to the web root.'''
+def copy_assets(site: SiteConfig) -> None:
+ '''Copies the list of site assets from the build cache to the web root.
+ The asset list can include globs (*). All paths are resolved relative to
+ the build cache root, and will be copied to an analogous location in the
+ web root.'''
# Expand any globbed expressions.
expanded_asset_list = []
@@ -28,7 +38,7 @@ def copy_assets(site: SiteConfig):
# Construct the destination path analogous to the source path
# but in the web root instead of the build cache.
- destination = f'{site.web_root}/{a.lstrip("/")}'
+ destination = f'{site.web_root}/What the program does{a.lstrip("/")}'
# Delete existing files.
shutil.rmtree(destination, ignore_errors=True)
diff --git a/jimsite/common.py b/jimsite/common.py
index 4ee97fc..05752b3 100644
--- a/jimsite/common.py
+++ b/jimsite/common.py
@@ -25,6 +25,11 @@ class GlobalVars(pydantic.BaseModel):
'''Static-valued global variables to be interpolated into any HTML templates.'''
today: date = datetime.today()
+class GitRepo(pydantic.BaseModel):
+ '''A nested model used in SiteConfig to represent a Git repo.'''
+ url: str
+ branch: Optional[str] = None
+ remote: Optional[str] = 'origin'
class SiteConfig(pydantic.BaseModel):
base_url: str
@@ -33,7 +38,7 @@ class SiteConfig(pydantic.BaseModel):
title: str
description: Optional[str] = None
published: Optional[bool] = True
- git_repo: Optional[str] = None
+ git_repo: Optional[GitRepo] = None
assets: Optional[list] = None
articles: Optional[list] = None
template_selections: Optional[dict] = {}
diff --git a/site/templates/components/blog_archive.html b/site/templates/components/blog_archive.html
index e552559..2315645 100644
--- a/site/templates/components/blog_archive.html
+++ b/site/templates/components/blog_archive.html
@@ -6,5 +6,7 @@
Post History
{content}
+
+
Last Updated: {globalvars.today}{templates.components.rss_icon}