From 9ea1bee5b7bf0ff37d25557ee18429a9c804f330 Mon Sep 17 00:00:00 2001 From: Jim Shepich III Date: Wed, 28 Jan 2026 16:55:00 -0500 Subject: [PATCH 01/20] Started Python templating refactor --- {styles => assets/css}/common.css | 0 {styles => assets/css}/deprecated.css | 0 {styles => assets/css}/layout.css | 0 {styles => assets/css}/lists.css | 0 {styles => assets/css}/reset.css | 0 {styles => assets/css}/theme.css | 0 {styles => assets}/fonts/3dlet.ttf | Bin {styles => assets}/fonts/3dlet.txt | 0 {styles => assets}/fonts/Adventure Outline.ttf | Bin {styles => assets}/fonts/Adventure.ttf | Bin {styles => assets}/fonts/Beleren-Bold.ttf | Bin {styles => assets}/fonts/Brody Font Regular.otf | Bin {styles => assets}/fonts/CollegiateBlackFLF.ttf | Bin {styles => assets}/fonts/CollegiateBorderFLF.ttf | Bin {styles => assets}/fonts/CollegiateFLF.ttf | Bin {styles => assets}/fonts/CollegiateInsideFLF.ttf | Bin {styles => assets}/fonts/CollegiateOutlineFLF.ttf | Bin {styles => assets}/fonts/EBGaramond.ttf | Bin {styles => assets}/fonts/FiraSans-Regular.ttf | Bin {styles => assets}/fonts/FloralCapitals.ttf | Bin {styles => assets}/fonts/MODERNA_.TTF | Bin {styles => assets}/fonts/Open 24 Display St.ttf | Bin {styles => assets}/fonts/OxygenMono-Regular.ttf | Bin {styles => assets}/fonts/Playbill.ttf | Bin {styles => assets}/fonts/PoiretOne-Regular.ttf | Bin {styles => assets}/fonts/StitchWarrior demo.ttf | Bin {styles => assets}/fonts/electrical.ttf | Bin {scripts => assets/js}/character_sheet.js | 0 {scripts => assets/js}/json_scraper.js | 0 {scripts => assets/js}/lists.js | 0 {scripts => assets/js}/resize.js | 0 {scripts => assets/js}/vendor/jquery-3.6.0.min.js | 0 {scripts => assets/php}/footer.php | 0 {scripts => assets/php}/nav.php | 0 {scripts => assets/php}/query_handler.php | 0 35 files changed, 0 insertions(+), 0 deletions(-) rename {styles => assets/css}/common.css (100%) rename {styles => assets/css}/deprecated.css (100%) rename {styles => assets/css}/layout.css (100%) rename {styles => assets/css}/lists.css (100%) rename {styles => assets/css}/reset.css (100%) rename {styles => assets/css}/theme.css (100%) rename {styles => assets}/fonts/3dlet.ttf (100%) rename {styles => assets}/fonts/3dlet.txt (100%) rename {styles => assets}/fonts/Adventure Outline.ttf (100%) rename {styles => assets}/fonts/Adventure.ttf (100%) rename {styles => assets}/fonts/Beleren-Bold.ttf (100%) rename {styles => assets}/fonts/Brody Font Regular.otf (100%) rename {styles => assets}/fonts/CollegiateBlackFLF.ttf (100%) rename {styles => assets}/fonts/CollegiateBorderFLF.ttf (100%) rename {styles => assets}/fonts/CollegiateFLF.ttf (100%) rename {styles => assets}/fonts/CollegiateInsideFLF.ttf (100%) rename {styles => assets}/fonts/CollegiateOutlineFLF.ttf (100%) rename {styles => assets}/fonts/EBGaramond.ttf (100%) rename {styles => assets}/fonts/FiraSans-Regular.ttf (100%) rename {styles => assets}/fonts/FloralCapitals.ttf (100%) rename {styles => assets}/fonts/MODERNA_.TTF (100%) rename {styles => assets}/fonts/Open 24 Display St.ttf (100%) rename {styles => assets}/fonts/OxygenMono-Regular.ttf (100%) rename {styles => assets}/fonts/Playbill.ttf (100%) rename {styles => assets}/fonts/PoiretOne-Regular.ttf (100%) rename {styles => assets}/fonts/StitchWarrior demo.ttf (100%) rename {styles => assets}/fonts/electrical.ttf (100%) rename {scripts => assets/js}/character_sheet.js (100%) rename {scripts => assets/js}/json_scraper.js (100%) rename {scripts => assets/js}/lists.js (100%) rename {scripts => assets/js}/resize.js (100%) rename {scripts => assets/js}/vendor/jquery-3.6.0.min.js (100%) rename {scripts => assets/php}/footer.php (100%) rename {scripts => assets/php}/nav.php (100%) rename {scripts => assets/php}/query_handler.php (100%) diff --git a/styles/common.css b/assets/css/common.css similarity index 100% rename from styles/common.css rename to assets/css/common.css diff --git a/styles/deprecated.css b/assets/css/deprecated.css similarity index 100% rename from styles/deprecated.css rename to assets/css/deprecated.css diff --git a/styles/layout.css b/assets/css/layout.css similarity index 100% rename from styles/layout.css rename to assets/css/layout.css diff --git a/styles/lists.css b/assets/css/lists.css similarity index 100% rename from styles/lists.css rename to assets/css/lists.css diff --git a/styles/reset.css b/assets/css/reset.css similarity index 100% rename from styles/reset.css rename to assets/css/reset.css diff --git a/styles/theme.css b/assets/css/theme.css similarity index 100% rename from styles/theme.css rename to assets/css/theme.css diff --git a/styles/fonts/3dlet.ttf b/assets/fonts/3dlet.ttf similarity index 100% rename from styles/fonts/3dlet.ttf rename to assets/fonts/3dlet.ttf diff --git a/styles/fonts/3dlet.txt b/assets/fonts/3dlet.txt similarity index 100% rename from styles/fonts/3dlet.txt rename to assets/fonts/3dlet.txt diff --git a/styles/fonts/Adventure Outline.ttf b/assets/fonts/Adventure Outline.ttf similarity index 100% rename from styles/fonts/Adventure Outline.ttf rename to assets/fonts/Adventure Outline.ttf diff --git a/styles/fonts/Adventure.ttf b/assets/fonts/Adventure.ttf similarity index 100% rename from styles/fonts/Adventure.ttf rename to assets/fonts/Adventure.ttf diff --git a/styles/fonts/Beleren-Bold.ttf b/assets/fonts/Beleren-Bold.ttf similarity index 100% rename from styles/fonts/Beleren-Bold.ttf rename to assets/fonts/Beleren-Bold.ttf diff --git a/styles/fonts/Brody Font Regular.otf b/assets/fonts/Brody Font Regular.otf similarity index 100% rename from styles/fonts/Brody Font Regular.otf rename to assets/fonts/Brody Font Regular.otf diff --git a/styles/fonts/CollegiateBlackFLF.ttf b/assets/fonts/CollegiateBlackFLF.ttf similarity index 100% rename from styles/fonts/CollegiateBlackFLF.ttf rename to assets/fonts/CollegiateBlackFLF.ttf diff --git a/styles/fonts/CollegiateBorderFLF.ttf b/assets/fonts/CollegiateBorderFLF.ttf similarity index 100% rename from styles/fonts/CollegiateBorderFLF.ttf rename to assets/fonts/CollegiateBorderFLF.ttf diff --git a/styles/fonts/CollegiateFLF.ttf b/assets/fonts/CollegiateFLF.ttf similarity index 100% rename from styles/fonts/CollegiateFLF.ttf rename to assets/fonts/CollegiateFLF.ttf diff --git a/styles/fonts/CollegiateInsideFLF.ttf b/assets/fonts/CollegiateInsideFLF.ttf similarity index 100% rename from styles/fonts/CollegiateInsideFLF.ttf rename to assets/fonts/CollegiateInsideFLF.ttf diff --git a/styles/fonts/CollegiateOutlineFLF.ttf b/assets/fonts/CollegiateOutlineFLF.ttf similarity index 100% rename from styles/fonts/CollegiateOutlineFLF.ttf rename to assets/fonts/CollegiateOutlineFLF.ttf diff --git a/styles/fonts/EBGaramond.ttf b/assets/fonts/EBGaramond.ttf similarity index 100% rename from styles/fonts/EBGaramond.ttf rename to assets/fonts/EBGaramond.ttf diff --git a/styles/fonts/FiraSans-Regular.ttf b/assets/fonts/FiraSans-Regular.ttf similarity index 100% rename from styles/fonts/FiraSans-Regular.ttf rename to assets/fonts/FiraSans-Regular.ttf diff --git a/styles/fonts/FloralCapitals.ttf b/assets/fonts/FloralCapitals.ttf similarity index 100% rename from styles/fonts/FloralCapitals.ttf rename to assets/fonts/FloralCapitals.ttf diff --git a/styles/fonts/MODERNA_.TTF b/assets/fonts/MODERNA_.TTF similarity index 100% rename from styles/fonts/MODERNA_.TTF rename to assets/fonts/MODERNA_.TTF diff --git a/styles/fonts/Open 24 Display St.ttf b/assets/fonts/Open 24 Display St.ttf similarity index 100% rename from styles/fonts/Open 24 Display St.ttf rename to assets/fonts/Open 24 Display St.ttf diff --git a/styles/fonts/OxygenMono-Regular.ttf b/assets/fonts/OxygenMono-Regular.ttf similarity index 100% rename from styles/fonts/OxygenMono-Regular.ttf rename to assets/fonts/OxygenMono-Regular.ttf diff --git a/styles/fonts/Playbill.ttf b/assets/fonts/Playbill.ttf similarity index 100% rename from styles/fonts/Playbill.ttf rename to assets/fonts/Playbill.ttf diff --git a/styles/fonts/PoiretOne-Regular.ttf b/assets/fonts/PoiretOne-Regular.ttf similarity index 100% rename from styles/fonts/PoiretOne-Regular.ttf rename to assets/fonts/PoiretOne-Regular.ttf diff --git a/styles/fonts/StitchWarrior demo.ttf b/assets/fonts/StitchWarrior demo.ttf similarity index 100% rename from styles/fonts/StitchWarrior demo.ttf rename to assets/fonts/StitchWarrior demo.ttf diff --git a/styles/fonts/electrical.ttf b/assets/fonts/electrical.ttf similarity index 100% rename from styles/fonts/electrical.ttf rename to assets/fonts/electrical.ttf diff --git a/scripts/character_sheet.js b/assets/js/character_sheet.js similarity index 100% rename from scripts/character_sheet.js rename to assets/js/character_sheet.js diff --git a/scripts/json_scraper.js b/assets/js/json_scraper.js similarity index 100% rename from scripts/json_scraper.js rename to assets/js/json_scraper.js diff --git a/scripts/lists.js b/assets/js/lists.js similarity index 100% rename from scripts/lists.js rename to assets/js/lists.js diff --git a/scripts/resize.js b/assets/js/resize.js similarity index 100% rename from scripts/resize.js rename to assets/js/resize.js diff --git a/scripts/vendor/jquery-3.6.0.min.js b/assets/js/vendor/jquery-3.6.0.min.js similarity index 100% rename from scripts/vendor/jquery-3.6.0.min.js rename to assets/js/vendor/jquery-3.6.0.min.js diff --git a/scripts/footer.php b/assets/php/footer.php similarity index 100% rename from scripts/footer.php rename to assets/php/footer.php diff --git a/scripts/nav.php b/assets/php/nav.php similarity index 100% rename from scripts/nav.php rename to assets/php/nav.php diff --git a/scripts/query_handler.php b/assets/php/query_handler.php similarity index 100% rename from scripts/query_handler.php rename to assets/php/query_handler.php From 8b38b3e86db0fd67d7bdf47cde9d9ddabca0d7e1 Mon Sep 17 00:00:00 2001 From: Jim Shepich III Date: Wed, 28 Jan 2026 16:56:03 -0500 Subject: [PATCH 02/20] Forgot to stage changes --- .gitignore | 7 +- __pycache__/main.cpython-312.pyc | Bin 0 -> 3854 bytes agenda.md | 21 ---- info.php | 5 - main.py | 79 +++++++++++++++ requirements.txt | 3 + templates/default.html | 23 +++++ templates/partials/blog_article.html | 0 templates/partials/default_css.html | 4 + templates/partials/footer.html | 3 + templates/partials/header.html | 3 + templates/partials/nav.html | 4 + testbench.ipynb | 142 +++++++++++++++++++++++++++ 13 files changed, 267 insertions(+), 27 deletions(-) create mode 100644 __pycache__/main.cpython-312.pyc delete mode 100644 agenda.md delete mode 100644 info.php create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 templates/default.html create mode 100644 templates/partials/blog_article.html create mode 100644 templates/partials/default_css.html create mode 100644 templates/partials/footer.html create mode 100644 templates/partials/header.html create mode 100644 templates/partials/nav.html create mode 100644 testbench.ipynb diff --git a/.gitignore b/.gitignore index 78ecffe..87a67e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -shepich resume.pdf \ No newline at end of file +shepich resume.pdf +**/.venv +**/.env +tmp +build +dist \ No newline at end of file diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..590ae1839a37bb4c5921b88943450ad4bf9aedd1 GIT binary patch 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 literal 0 HcmV?d00001 diff --git a/agenda.md b/agenda.md deleted file mode 100644 index c543ab3..0000000 --- a/agenda.md +++ /dev/null @@ -1,21 +0,0 @@ -## Pages -- Home -- About -- CV - - Everything in as much detail as possible -- Projects - - E&E - - M|Chroma - - SI-Formatter -- Socials (maybe better as a footer) - - Instagram - - Facebook - - LinkedIn - - GitHub - - MAL -- Bookmarks (?) - -## Reference Material -- https://www.tomscott.com/ -- http://vihart.com/ -- https://www.singingbanana.com/ diff --git a/info.php b/info.php deleted file mode 100644 index 47b3cb9..0000000 --- a/info.php +++ /dev/null @@ -1,5 +0,0 @@ - \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..cb46608 --- /dev/null +++ b/main.py @@ -0,0 +1,79 @@ +import os +import subprocess +import markdown +import yaml +from datetime import datetime + +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 load_markdown(md: str) -> tuple[dict, str]: + '''Loads a Markdown file into a (metadata: dict, 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 + + # 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 metadata, content + + +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) + + # Interpolate the kwargs into the HTML template. + html = template.format(**kwargs) + + # Return the formatted HTML. + return html + + +REPOS = [ + 'ssh://gitea/jim/resume.git', + 'ssh://gitea/jim/dogma-jimfinium.git' +] +run = lambda cmd: subprocess.run(cmd.split(' '), stdout = subprocess.PIPE, stderr = subprocess.PIPE) +def update_git_repos(repos: list) -> None: + '''Pulls updates to repos in the build directory, or clones them if they don't exist.''' + for repo in repos: + local_path = 'build/'+repo.split('/')[-1].strip('.git') + print(local_path) + if os.path.exists(f'{local_path}/.git'): + run(f'git -C {local_path} pull origin') + else: + run(f'git clone {repo} {local_path}') + + +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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..735541d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +ipykernel +markdown +pyyaml \ No newline at end of file diff --git a/templates/default.html b/templates/default.html new file mode 100644 index 0000000..3cc4d24 --- /dev/null +++ b/templates/default.html @@ -0,0 +1,23 @@ + + + + + + {partials__default_css} + {partials__header} + {partials__nav} + + + + +
+

{metadata__title}

+
By
+
First published: +
Last modified: +

+ {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 @@ +
+
Copyright © 2021-{current_year} Jim Shepich +
\ 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}

{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 @@ +
  • {metadata__date} - {metadata__title} {blog_tags}
  • diff --git a/templates/components/blog_article.html b/templates/components/blog_article.html new file mode 100644 index 0000000..b8acdf0 --- /dev/null +++ b/templates/components/blog_article.html @@ -0,0 +1,11 @@ +
    +

    {metadata__title}

    +
    By
    +
    First published: +
    Last modified: +

    + {content} +
    +

    {blog_tags}

    +
    \ No newline at end of file diff --git a/templates/components/blog_tag.html b/templates/components/blog_tag.html new file mode 100644 index 0000000..1a77d3b --- /dev/null +++ b/templates/components/blog_tag.html @@ -0,0 +1 @@ +{tag_name} \ No newline at end of file diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 9e34918..1c8861f 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -1,4 +1,5 @@ \ No newline at end of file diff --git a/templates/simple.html b/templates/simple.html new file mode 100644 index 0000000..bcb8ed9 --- /dev/null +++ b/templates/simple.html @@ -0,0 +1,14 @@ + + + + + {partials__default_css} + {partials__header} + {partials__nav} + + +
    + {content} +
    + {partials__footer} + \ No newline at end of file diff --git a/testbench.ipynb b/testbench.ipynb index 9269cad..b36751b 100644 --- a/testbench.ipynb +++ b/testbench.ipynb @@ -11,7 +11,7 @@ "- [] 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", + "- [x] ~~Resolve markdown links~~\n", "- [] Consider separating article templates and overall page templates\n", "- [] RSS feed\n", "\n", @@ -27,7 +27,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 11, "id": "207d2510", "metadata": {}, "outputs": [], @@ -44,7 +44,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 12, "id": "d2361c42", "metadata": {}, "outputs": [], @@ -55,13 +55,13 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 31, "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", + "html = format_html_template('templates/blog_post.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)" ] @@ -98,23 +98,92 @@ "output_type": "execute_result" } ], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 58, + "id": "944a5efd", + "metadata": {}, + "outputs": [], "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", + "run(f'rm -rf dist/dogma-jimfinium && mkdir -p dist/dogma-jimfinium')\n", + "run(f'cp -r build/dogma-jimfinium/assets dist/dogma-jimfinium')\n", "\n", - " print(max_date)\n", - " # print(max_date.strftime('%Y-%m-%d'))\n", + "index = {}\n", + "\n", + "for article in os.listdir('build/dogma-jimfinium'):\n", + " metadata, content = load_markdown(f'build/dogma-jimfinium/{article}')\n", + "\n", + " # Skip unpublished articles.\n", + " if not metadata.get('published'):\n", + " continue\n", + "\n", + " article_filestem = os.path.splitext(article)[0]\n", + "\n", + " # Add the article to the index.\n", + " index[article_filestem] = (metadata, content)\n", + "\n", + " # Interpolate the article contents into the webpage template.\n", + " article_html = format_html_template(\n", + " 'templates/components/blog_article.html',\n", + " content = content,\n", + " blog_tags = ' '.join(format_blog_tags(metadata['tags'])),\n", + " **{'metadata__'+k:v for k,v in metadata.items()}\n", + " )\n", + " html = format_html_template('templates/simple.html', content = article_html, **PARTIALS)\n", + " \n", + " # Write the HTML file to /dist/dogma-jimfinium.\n", + " with open(f'dist/dogma-jimfinium/{article_filestem}.html', 'w') as f:\n", + " f.write(html)\n", + "\n", + "\n", + "index_html = build_blog_archive(index, metadata__title = 'Dogma Jimfinium | Index')\n", + "# Write the HTML file to /dist/dogma-jimfinium.\n", + "with open(f'dist/dogma-jimfinium/index.html', 'w') as f:\n", + " f.write(index_html)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e32458c7", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3171afd", + "metadata": {}, + "outputs": [], + "source": [ + "def build_blog_archive(index: dict[str, tuple[str, str]], template = 'templates/simple.html', **kwargs) -> str:\n", + " '''Converts an index, formatted as filestem: (metadata, contents) dict,\n", + " into an HTML page containing the list of articles, sorted from newest to oldest.'''\n", + "\n", + " # Add each article in the format `YYYY-MM-DD - Title`\n", + " index_html_content = '
      '\n", + " for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0]['date'])[::-1]:\n", + " blog_tags = ' '.join(format_blog_tags(metadata['tags']))\n", + " index_html_content += format_html_template(\n", + " 'templates/components/blog_archive_li.html',\n", + " article_filestem = article,\n", + " blog_tags = blog_tags,\n", + " **{'metadata__'+k:v for k,v in metadata.items()}\n", " \n", - " run(f'cp build/resume/{max_date}/shepich_resume.pdf dist/shepich_resume.pdf')" + " )\n", + " index_html_content +='
    '\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
    -
    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 @@ -
  • {metadata__date} - {metadata__title} {blog_tags}
  • +
  • {metadata.date} - {metadata.title} {blog_tags}
  • diff --git a/templates/components/blog_article.html b/templates/components/blog_article.html index b8acdf0..452f7e9 100644 --- a/templates/components/blog_article.html +++ b/templates/components/blog_article.html @@ -1,11 +1,11 @@
    -

    {metadata__title}

    +

    {metadata.title}

    By
    -
    First published: -
    Last modified: +
    First published: +
    Last modified:

    {content} -
    +


    {blog_tags}

    \ No newline at end of file diff --git a/templates/pages/default.html b/templates/pages/default.html new file mode 100644 index 0000000..bcb8ed9 --- /dev/null +++ b/templates/pages/default.html @@ -0,0 +1,14 @@ + + + + + {partials__default_css} + {partials__header} + {partials__nav} + + +
    + {content} +
    + {partials__footer} + \ No newline at end of file diff --git a/testbench.ipynb b/testbench.ipynb index b36751b..959d6ea 100644 --- a/testbench.ipynb +++ b/testbench.ipynb @@ -27,15 +27,20 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "207d2510", "metadata": {}, "outputs": [], "source": [ "import os\n", + "import shutil\n", "import markdown\n", "import yaml\n", "import subprocess\n", + "import rfeed\n", + "import pydantic\n", + "from typing import Optional, Union, Literal, BinaryIO, Any\n", + "\n", "\n", "\n", "from datetime import datetime\n", @@ -44,24 +49,68 @@ }, { "cell_type": "code", - "execution_count": 12, - "id": "d2361c42", + "execution_count": null, + "id": "4b17a3ed", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "'dist/assets'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "metadata, content = load_markdown('tmp/dogma-jimfinium/blowouts.md')\n", - "content = content.replace('src=\"assets', 'src=\"../tmp/dogma-jimfinium/assets')" + "PARTIALS = load_partials()\n", + "shutil.rmtree('dist/assets', ignore_errors=True)\n", + "shutil.copytree('assets','dist/assets')" ] }, { "cell_type": "code", - "execution_count": 31, + "execution_count": null, + "id": "d2361c42", + "metadata": {}, + "outputs": [ + { + "ename": "ValidationError", + "evalue": "2 validation errors for ArticleMetadata\ndate\n Input should be a valid string [type=string_type, input_value=datetime.date(2026, 1, 30), input_type=date]\n For further information visit https://errors.pydantic.dev/2.12/v/string_type\nlastmod\n Field required [type=missing, input_value={'title': 'Welcome', 'dat...shed': True, 'tags': []}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.12/v/missing", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mValidationError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[3]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m metadata, content = \u001b[43mload_markdown\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mpages/home.md\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[32m 2\u001b[39m \u001b[38;5;66;03m# content = content.replace('src=\"assets', 'src=\"../tmp/dogma-jimfinium/assets')\u001b[39;00m\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/main.py:59\u001b[39m, in \u001b[36mload_markdown\u001b[39m\u001b[34m(md)\u001b[39m\n\u001b[32m 56\u001b[39m \u001b[38;5;66;03m# Convert the contents to a HTML string.\u001b[39;00m\n\u001b[32m 57\u001b[39m content = markdown.markdown(raw_article)\n\u001b[32m---> \u001b[39m\u001b[32m59\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mArticleMetadata\u001b[49m\u001b[43m(\u001b[49m\u001b[43m*\u001b[49m\u001b[43m*\u001b[49m\u001b[43mmetadata\u001b[49m\u001b[43m)\u001b[49m, content\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/.venv/lib/python3.12/site-packages/pydantic/main.py:250\u001b[39m, in \u001b[36mBaseModel.__init__\u001b[39m\u001b[34m(self, **data)\u001b[39m\n\u001b[32m 248\u001b[39m \u001b[38;5;66;03m# `__tracebackhide__` tells pytest and some other tools to omit this function from tracebacks\u001b[39;00m\n\u001b[32m 249\u001b[39m __tracebackhide__ = \u001b[38;5;28;01mTrue\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m250\u001b[39m validated_self = \u001b[38;5;28;43mself\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43m__pydantic_validator__\u001b[49m\u001b[43m.\u001b[49m\u001b[43mvalidate_python\u001b[49m\u001b[43m(\u001b[49m\u001b[43mdata\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mself_instance\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[32m 251\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m validated_self:\n\u001b[32m 252\u001b[39m warnings.warn(\n\u001b[32m 253\u001b[39m \u001b[33m'\u001b[39m\u001b[33mA custom validator is returning a value other than `self`.\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 254\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mReturning anything other than `self` from a top level model validator isn\u001b[39m\u001b[33m'\u001b[39m\u001b[33mt supported when validating via `__init__`.\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m\"\u001b[39m\n\u001b[32m 255\u001b[39m \u001b[33m'\u001b[39m\u001b[33mSee the `model_validator` docs (https://docs.pydantic.dev/latest/concepts/validators/#model-validators) for more details.\u001b[39m\u001b[33m'\u001b[39m,\n\u001b[32m 256\u001b[39m stacklevel=\u001b[32m2\u001b[39m,\n\u001b[32m 257\u001b[39m )\n", + "\u001b[31mValidationError\u001b[39m: 2 validation errors for ArticleMetadata\ndate\n Input should be a valid string [type=string_type, input_value=datetime.date(2026, 1, 30), input_type=date]\n For further information visit https://errors.pydantic.dev/2.12/v/string_type\nlastmod\n Field required [type=missing, input_value={'title': 'Welcome', 'dat...shed': True, 'tags': []}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.12/v/missing" + ] + } + ], + "source": [ + "metadata, content = load_markdown('pages/home.md')\n", + "# content = content.replace('src=\"assets', 'src=\"../tmp/dogma-jimfinium/assets')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0bb15524", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, "id": "ed7b3b2f", "metadata": {}, "outputs": [], "source": [ - "PARTIALS = load_partials()\n", - "html = format_html_template('templates/blog_post.html', content = content, **{'metadata__'+k:v for k,v in metadata.items()}, **PARTIALS)\n", + "html = format_html_template('templates/pages/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)" ] @@ -102,13 +151,14 @@ }, { "cell_type": "code", - "execution_count": 58, + "execution_count": null, "id": "944a5efd", "metadata": {}, "outputs": [], "source": [ - "run(f'rm -rf dist/dogma-jimfinium && mkdir -p dist/dogma-jimfinium')\n", - "run(f'cp -r build/dogma-jimfinium/assets dist/dogma-jimfinium')\n", + "shutil.rmtree('dist/dogma-jimfinium', ignore_errors=True)\n", + "os.makedirs('dist/dogma-jimfinium', exist_ok=True)\n", + "shutil.copytree('build/dogma-jimfinium/assets', 'dist/dogma-jimfinium/assets')\n", "\n", "index = {}\n", "\n", @@ -129,26 +179,122 @@ " 'templates/components/blog_article.html',\n", " content = content,\n", " blog_tags = ' '.join(format_blog_tags(metadata['tags'])),\n", - " **{'metadata__'+k:v for k,v in metadata.items()}\n", + " **{'metadata.'+k:v for k,v in metadata.items()}\n", " )\n", - " html = format_html_template('templates/simple.html', content = article_html, **PARTIALS)\n", + " html = format_html_template('templates/pages/default.html', content = article_html, **PARTIALS)\n", " \n", " # Write the HTML file to /dist/dogma-jimfinium.\n", " with open(f'dist/dogma-jimfinium/{article_filestem}.html', 'w') as f:\n", " f.write(html)\n", "\n", "\n", - "index_html = build_blog_archive(index, metadata__title = 'Dogma Jimfinium | Index')\n", + "index_html = build_blog_archive(index, metadata.title = 'Dogma Jimfinium | Index', **PARTIALS)\n", "# Write the HTML file to /dist/dogma-jimfinium.\n", "with open(f'dist/dogma-jimfinium/index.html', 'w') as f:\n", " f.write(index_html)" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "132a32ec", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "datetime.date(2024, 7, 12)" + ] + }, + "execution_count": 37, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "metadata['date']" + ] + }, + { + "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)" + ] + }, { "cell_type": "code", "execution_count": null, "id": "e32458c7", "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Dogma Jimfiniumhttp://localhost:8000/dogma-jimfinium/rssDogma Jimfiniumen-USThu, 29 Jan 2026 16:29:57 GMTrfeed v1.1.1https://github.com/svpino/rfeed/blob/master/README.mdSuperlockhttp://localhost:8000/dogma-jimfinium/superlockJim Shepich IIIWed, 26 Nov 2025 00:00:00 GMTsuperlockSustainable Livinghttp://localhost:8000/dogma-jimfinium/sustainable-livingJim Shepich IIIThu, 20 Nov 2025 00:00:00 GMTsustainable-livingStocking Uphttp://localhost:8000/dogma-jimfinium/stocking-upJim Shepich IIIWed, 19 Nov 2025 00:00:00 GMTstocking-upSet Up the Toyshttp://localhost:8000/dogma-jimfinium/set-up-the-toysJim Shepich IIIWed, 14 Jan 2026 00:00:00 GMTset-up-the-toysDo What You Lovehttp://localhost:8000/dogma-jimfinium/do-what-you-loveJim Shepich IIITue, 10 Jun 2025 00:00:00 GMTdo-what-you-loveSelf-Care is not Selfishhttp://localhost:8000/dogma-jimfinium/self-care-is-not-selfishJim Shepich IIISun, 18 May 2025 00:00:00 GMTself-care-is-not-selfishBlowoutshttp://localhost:8000/dogma-jimfinium/blowoutsJim Shepich IIIWed, 26 Nov 2025 00:00:00 GMTblowoutsVitamins & Supplementshttp://localhost:8000/dogma-jimfinium/vitaminsJim Shepich IIISun, 18 May 2025 00:00:00 GMTvitaminsGear for New Parentshttp://localhost:8000/dogma-jimfinium/gear-for-new-parentsJim Shepich IIIFri, 12 Jul 2024 00:00:00 GMTgear-for-new-parents\n" + ] + } + ], + "source": [ + "rss_feed = rfeed.Feed(\n", + " title = \"Dogma Jimfinium\",\n", + " link = \"http://localhost:8000/dogma-jimfinium/rss\",\n", + " description = \"Dogma Jimfinium\",\n", + " language = \"en-US\",\n", + " lastBuildDate = datetime.now(),\n", + " items = [\n", + " rfeed.Item(\n", + " title = metadata['title'],\n", + " link = f\"http://localhost:8000/dogma-jimfinium/{filestem}\", \n", + " description = metadata.get('description'),\n", + " author = metadata.get('author', 'Jim Shepich III'),\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())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c160693", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57ef8185", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b068c448", + "metadata": {}, "outputs": [], "source": [] }, @@ -159,31 +305,8 @@ "metadata": {}, "outputs": [], "source": [ - "def build_blog_archive(index: dict[str, tuple[str, str]], template = 'templates/simple.html', **kwargs) -> str:\n", - " '''Converts an index, formatted as filestem: (metadata, contents) dict,\n", - " into an HTML page containing the list of articles, sorted from newest to oldest.'''\n", - "\n", - " # Add each article in the format `YYYY-MM-DD - Title`\n", - " index_html_content = '
      '\n", - " for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0]['date'])[::-1]:\n", - " blog_tags = ' '.join(format_blog_tags(metadata['tags']))\n", - " index_html_content += format_html_template(\n", - " 'templates/components/blog_archive_li.html',\n", - " article_filestem = article,\n", - " blog_tags = blog_tags,\n", - " **{'metadata__'+k:v for k,v in metadata.items()}\n", - " \n", - " )\n", - " index_html_content +='
    '\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" + "def build_site(site_config: SiteConfig):\n", + " " ] } ], From bf0ca26a71a57373402ac37290535a5688a844e8 Mon Sep 17 00:00:00 2001 From: Jim Shepich III Date: Fri, 30 Jan 2026 10:51:10 -0500 Subject: [PATCH 05/20] Copy assets pipeline --- config.yaml | 8 +- main.py | 55 +++++--- {assets => site/assets}/css/common.css | 0 {assets => site/assets}/css/deprecated.css | 0 {assets => site/assets}/css/layout.css | 0 {assets => site/assets}/css/lists.css | 0 {assets => site/assets}/css/reset.css | 0 {assets => site/assets}/css/theme.css | 0 {assets => site/assets}/fonts/3dlet.ttf | Bin {assets => site/assets}/fonts/3dlet.txt | 0 .../assets}/fonts/Adventure Outline.ttf | Bin {assets => site/assets}/fonts/Adventure.ttf | Bin .../assets}/fonts/Beleren-Bold.ttf | Bin .../assets}/fonts/Brody Font Regular.otf | Bin .../assets}/fonts/CollegiateBlackFLF.ttf | Bin .../assets}/fonts/CollegiateBorderFLF.ttf | Bin .../assets}/fonts/CollegiateFLF.ttf | Bin .../assets}/fonts/CollegiateInsideFLF.ttf | Bin .../assets}/fonts/CollegiateOutlineFLF.ttf | Bin {assets => site/assets}/fonts/EBGaramond.ttf | Bin .../assets}/fonts/FiraSans-Regular.ttf | Bin .../assets}/fonts/FloralCapitals.ttf | Bin {assets => site/assets}/fonts/MODERNA_.TTF | Bin .../assets}/fonts/Open 24 Display St.ttf | Bin .../assets}/fonts/OxygenMono-Regular.ttf | Bin {assets => site/assets}/fonts/Playbill.ttf | Bin .../assets}/fonts/PoiretOne-Regular.ttf | Bin .../assets}/fonts/StitchWarrior demo.ttf | Bin {assets => site/assets}/fonts/electrical.ttf | Bin {assets => site/assets}/js/character_sheet.js | 0 {assets => site/assets}/js/json_scraper.js | 0 {assets => site/assets}/js/lists.js | 0 {assets => site/assets}/js/resize.js | 0 .../assets}/js/vendor/jquery-3.6.0.min.js | 0 {assets => site/assets}/php/footer.php | 0 {assets => site/assets}/php/nav.php | 0 {assets => site/assets}/php/query_handler.php | 0 {pages => site/pages}/404.html | 0 {pages => site/pages}/about.html | 0 {pages => site/pages}/don-info.html | 0 {pages => site/pages}/home.md | 0 {pages => site/pages}/lists.html | 0 testbench.ipynb | 122 ++++++++++++------ 43 files changed, 129 insertions(+), 56 deletions(-) rename {assets => site/assets}/css/common.css (100%) rename {assets => site/assets}/css/deprecated.css (100%) rename {assets => site/assets}/css/layout.css (100%) rename {assets => site/assets}/css/lists.css (100%) rename {assets => site/assets}/css/reset.css (100%) rename {assets => site/assets}/css/theme.css (100%) rename {assets => site/assets}/fonts/3dlet.ttf (100%) rename {assets => site/assets}/fonts/3dlet.txt (100%) rename {assets => site/assets}/fonts/Adventure Outline.ttf (100%) rename {assets => site/assets}/fonts/Adventure.ttf (100%) rename {assets => site/assets}/fonts/Beleren-Bold.ttf (100%) rename {assets => site/assets}/fonts/Brody Font Regular.otf (100%) rename {assets => site/assets}/fonts/CollegiateBlackFLF.ttf (100%) rename {assets => site/assets}/fonts/CollegiateBorderFLF.ttf (100%) rename {assets => site/assets}/fonts/CollegiateFLF.ttf (100%) rename {assets => site/assets}/fonts/CollegiateInsideFLF.ttf (100%) rename {assets => site/assets}/fonts/CollegiateOutlineFLF.ttf (100%) rename {assets => site/assets}/fonts/EBGaramond.ttf (100%) rename {assets => site/assets}/fonts/FiraSans-Regular.ttf (100%) rename {assets => site/assets}/fonts/FloralCapitals.ttf (100%) rename {assets => site/assets}/fonts/MODERNA_.TTF (100%) rename {assets => site/assets}/fonts/Open 24 Display St.ttf (100%) rename {assets => site/assets}/fonts/OxygenMono-Regular.ttf (100%) rename {assets => site/assets}/fonts/Playbill.ttf (100%) rename {assets => site/assets}/fonts/PoiretOne-Regular.ttf (100%) rename {assets => site/assets}/fonts/StitchWarrior demo.ttf (100%) rename {assets => site/assets}/fonts/electrical.ttf (100%) rename {assets => site/assets}/js/character_sheet.js (100%) rename {assets => site/assets}/js/json_scraper.js (100%) rename {assets => site/assets}/js/lists.js (100%) rename {assets => site/assets}/js/resize.js (100%) rename {assets => site/assets}/js/vendor/jquery-3.6.0.min.js (100%) rename {assets => site/assets}/php/footer.php (100%) rename {assets => site/assets}/php/nav.php (100%) rename {assets => site/assets}/php/query_handler.php (100%) rename {pages => site/pages}/404.html (100%) rename {pages => site/pages}/about.html (100%) rename {pages => site/pages}/don-info.html (100%) rename {pages => site/pages}/home.md (100%) rename {pages => site/pages}/lists.html (100%) diff --git a/config.yaml b/config.yaml index 2ed6b33..931fdc6 100644 --- a/config.yaml +++ b/config.yaml @@ -8,9 +8,9 @@ site_defaults: pages: ./templates/pages sites: main: - build_cache: ./build + build_cache: ./site assets: - - ./assets + - /assets articles: - ./pages/*.md resume: @@ -24,7 +24,7 @@ sites: build_cache: ./build/dogma-jimfinium web_root: ./dist/dogma-jimfinium assets: - - '{build_cache}/assets' + - assets articles: - - '{build_cache}/*.md' + - '*.md' \ No newline at end of file diff --git a/main.py b/main.py index 3fca3d1..106fecb 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,6 @@ import os +import glob +import shutil import subprocess import markdown import yaml @@ -30,7 +32,7 @@ class ArticleMetadata(pydantic.BaseModel): title: str author: Optional[str] = config.get('author') date: date - lastmod: Optional[date] + lastmod: Optional[date] = None published: bool tags: list thumbnail: Optional[str] = None @@ -73,20 +75,13 @@ def format_html_template(template: str, **kwargs) -> str: return html -REPOS = [ - 'ssh://gitea/jim/resume.git', - 'ssh://gitea/jim/dogma-jimfinium.git' -] run = lambda cmd: subprocess.run(cmd.split(' '), stdout = subprocess.PIPE, stderr = subprocess.PIPE) -def update_git_repos(repos: list) -> None: - '''Pulls updates to repos in the build directory, or clones them if they don't exist.''' - for repo in repos: - local_path = 'build/'+repo.split('/')[-1].strip('.git') - print(local_path) - if os.path.exists(f'{local_path}/.git'): - run(f'git -C {local_path} pull origin') - else: - run(f'git clone {repo} {local_path}') +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: @@ -147,7 +142,7 @@ def build_blog_archive( # 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]: + 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( @@ -168,5 +163,35 @@ def build_blog_archive( 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 + if __name__ == '__main__': pass \ No newline at end of file diff --git a/assets/css/common.css b/site/assets/css/common.css similarity index 100% rename from assets/css/common.css rename to site/assets/css/common.css diff --git a/assets/css/deprecated.css b/site/assets/css/deprecated.css similarity index 100% rename from assets/css/deprecated.css rename to site/assets/css/deprecated.css diff --git a/assets/css/layout.css b/site/assets/css/layout.css similarity index 100% rename from assets/css/layout.css rename to site/assets/css/layout.css diff --git a/assets/css/lists.css b/site/assets/css/lists.css similarity index 100% rename from assets/css/lists.css rename to site/assets/css/lists.css diff --git a/assets/css/reset.css b/site/assets/css/reset.css similarity index 100% rename from assets/css/reset.css rename to site/assets/css/reset.css diff --git a/assets/css/theme.css b/site/assets/css/theme.css similarity index 100% rename from assets/css/theme.css rename to site/assets/css/theme.css diff --git a/assets/fonts/3dlet.ttf b/site/assets/fonts/3dlet.ttf similarity index 100% rename from assets/fonts/3dlet.ttf rename to site/assets/fonts/3dlet.ttf diff --git a/assets/fonts/3dlet.txt b/site/assets/fonts/3dlet.txt similarity index 100% rename from assets/fonts/3dlet.txt rename to site/assets/fonts/3dlet.txt diff --git a/assets/fonts/Adventure Outline.ttf b/site/assets/fonts/Adventure Outline.ttf similarity index 100% rename from assets/fonts/Adventure Outline.ttf rename to site/assets/fonts/Adventure Outline.ttf diff --git a/assets/fonts/Adventure.ttf b/site/assets/fonts/Adventure.ttf similarity index 100% rename from assets/fonts/Adventure.ttf rename to site/assets/fonts/Adventure.ttf diff --git a/assets/fonts/Beleren-Bold.ttf b/site/assets/fonts/Beleren-Bold.ttf similarity index 100% rename from assets/fonts/Beleren-Bold.ttf rename to site/assets/fonts/Beleren-Bold.ttf diff --git a/assets/fonts/Brody Font Regular.otf b/site/assets/fonts/Brody Font Regular.otf similarity index 100% rename from assets/fonts/Brody Font Regular.otf rename to site/assets/fonts/Brody Font Regular.otf diff --git a/assets/fonts/CollegiateBlackFLF.ttf b/site/assets/fonts/CollegiateBlackFLF.ttf similarity index 100% rename from assets/fonts/CollegiateBlackFLF.ttf rename to site/assets/fonts/CollegiateBlackFLF.ttf diff --git a/assets/fonts/CollegiateBorderFLF.ttf b/site/assets/fonts/CollegiateBorderFLF.ttf similarity index 100% rename from assets/fonts/CollegiateBorderFLF.ttf rename to site/assets/fonts/CollegiateBorderFLF.ttf diff --git a/assets/fonts/CollegiateFLF.ttf b/site/assets/fonts/CollegiateFLF.ttf similarity index 100% rename from assets/fonts/CollegiateFLF.ttf rename to site/assets/fonts/CollegiateFLF.ttf diff --git a/assets/fonts/CollegiateInsideFLF.ttf b/site/assets/fonts/CollegiateInsideFLF.ttf similarity index 100% rename from assets/fonts/CollegiateInsideFLF.ttf rename to site/assets/fonts/CollegiateInsideFLF.ttf diff --git a/assets/fonts/CollegiateOutlineFLF.ttf b/site/assets/fonts/CollegiateOutlineFLF.ttf similarity index 100% rename from assets/fonts/CollegiateOutlineFLF.ttf rename to site/assets/fonts/CollegiateOutlineFLF.ttf diff --git a/assets/fonts/EBGaramond.ttf b/site/assets/fonts/EBGaramond.ttf similarity index 100% rename from assets/fonts/EBGaramond.ttf rename to site/assets/fonts/EBGaramond.ttf diff --git a/assets/fonts/FiraSans-Regular.ttf b/site/assets/fonts/FiraSans-Regular.ttf similarity index 100% rename from assets/fonts/FiraSans-Regular.ttf rename to site/assets/fonts/FiraSans-Regular.ttf diff --git a/assets/fonts/FloralCapitals.ttf b/site/assets/fonts/FloralCapitals.ttf similarity index 100% rename from assets/fonts/FloralCapitals.ttf rename to site/assets/fonts/FloralCapitals.ttf diff --git a/assets/fonts/MODERNA_.TTF b/site/assets/fonts/MODERNA_.TTF similarity index 100% rename from assets/fonts/MODERNA_.TTF rename to site/assets/fonts/MODERNA_.TTF diff --git a/assets/fonts/Open 24 Display St.ttf b/site/assets/fonts/Open 24 Display St.ttf similarity index 100% rename from assets/fonts/Open 24 Display St.ttf rename to site/assets/fonts/Open 24 Display St.ttf diff --git a/assets/fonts/OxygenMono-Regular.ttf b/site/assets/fonts/OxygenMono-Regular.ttf similarity index 100% rename from assets/fonts/OxygenMono-Regular.ttf rename to site/assets/fonts/OxygenMono-Regular.ttf diff --git a/assets/fonts/Playbill.ttf b/site/assets/fonts/Playbill.ttf similarity index 100% rename from assets/fonts/Playbill.ttf rename to site/assets/fonts/Playbill.ttf diff --git a/assets/fonts/PoiretOne-Regular.ttf b/site/assets/fonts/PoiretOne-Regular.ttf similarity index 100% rename from assets/fonts/PoiretOne-Regular.ttf rename to site/assets/fonts/PoiretOne-Regular.ttf diff --git a/assets/fonts/StitchWarrior demo.ttf b/site/assets/fonts/StitchWarrior demo.ttf similarity index 100% rename from assets/fonts/StitchWarrior demo.ttf rename to site/assets/fonts/StitchWarrior demo.ttf diff --git a/assets/fonts/electrical.ttf b/site/assets/fonts/electrical.ttf similarity index 100% rename from assets/fonts/electrical.ttf rename to site/assets/fonts/electrical.ttf diff --git a/assets/js/character_sheet.js b/site/assets/js/character_sheet.js similarity index 100% rename from assets/js/character_sheet.js rename to site/assets/js/character_sheet.js diff --git a/assets/js/json_scraper.js b/site/assets/js/json_scraper.js similarity index 100% rename from assets/js/json_scraper.js rename to site/assets/js/json_scraper.js diff --git a/assets/js/lists.js b/site/assets/js/lists.js similarity index 100% rename from assets/js/lists.js rename to site/assets/js/lists.js diff --git a/assets/js/resize.js b/site/assets/js/resize.js similarity index 100% rename from assets/js/resize.js rename to site/assets/js/resize.js diff --git a/assets/js/vendor/jquery-3.6.0.min.js b/site/assets/js/vendor/jquery-3.6.0.min.js similarity index 100% rename from assets/js/vendor/jquery-3.6.0.min.js rename to site/assets/js/vendor/jquery-3.6.0.min.js diff --git a/assets/php/footer.php b/site/assets/php/footer.php similarity index 100% rename from assets/php/footer.php rename to site/assets/php/footer.php diff --git a/assets/php/nav.php b/site/assets/php/nav.php similarity index 100% rename from assets/php/nav.php rename to site/assets/php/nav.php diff --git a/assets/php/query_handler.php b/site/assets/php/query_handler.php similarity index 100% rename from assets/php/query_handler.php rename to site/assets/php/query_handler.php diff --git a/pages/404.html b/site/pages/404.html similarity index 100% rename from pages/404.html rename to site/pages/404.html diff --git a/pages/about.html b/site/pages/about.html similarity index 100% rename from pages/about.html rename to site/pages/about.html diff --git a/pages/don-info.html b/site/pages/don-info.html similarity index 100% rename from pages/don-info.html rename to site/pages/don-info.html diff --git a/pages/home.md b/site/pages/home.md similarity index 100% rename from pages/home.md rename to site/pages/home.md diff --git a/pages/lists.html b/site/pages/lists.html similarity index 100% rename from pages/lists.html rename to site/pages/lists.html diff --git a/testbench.ipynb b/testbench.ipynb index 959d6ea..7d44a2d 100644 --- a/testbench.ipynb +++ b/testbench.ipynb @@ -27,7 +27,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "207d2510", "metadata": {}, "outputs": [], @@ -39,6 +39,7 @@ "import subprocess\n", "import rfeed\n", "import pydantic\n", + "import glob\n", "from typing import Optional, Union, Literal, BinaryIO, Any\n", "\n", "\n", @@ -49,7 +50,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "4b17a3ed", "metadata": {}, "outputs": [ @@ -72,24 +73,10 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "d2361c42", "metadata": {}, - "outputs": [ - { - "ename": "ValidationError", - "evalue": "2 validation errors for ArticleMetadata\ndate\n Input should be a valid string [type=string_type, input_value=datetime.date(2026, 1, 30), input_type=date]\n For further information visit https://errors.pydantic.dev/2.12/v/string_type\nlastmod\n Field required [type=missing, input_value={'title': 'Welcome', 'dat...shed': True, 'tags': []}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.12/v/missing", - "output_type": "error", - "traceback": [ - "\u001b[31m---------------------------------------------------------------------------\u001b[39m", - "\u001b[31mValidationError\u001b[39m Traceback (most recent call last)", - "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[3]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m metadata, content = \u001b[43mload_markdown\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mpages/home.md\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[32m 2\u001b[39m \u001b[38;5;66;03m# content = content.replace('src=\"assets', 'src=\"../tmp/dogma-jimfinium/assets')\u001b[39;00m\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/main.py:59\u001b[39m, in \u001b[36mload_markdown\u001b[39m\u001b[34m(md)\u001b[39m\n\u001b[32m 56\u001b[39m \u001b[38;5;66;03m# Convert the contents to a HTML string.\u001b[39;00m\n\u001b[32m 57\u001b[39m content = markdown.markdown(raw_article)\n\u001b[32m---> \u001b[39m\u001b[32m59\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mArticleMetadata\u001b[49m\u001b[43m(\u001b[49m\u001b[43m*\u001b[49m\u001b[43m*\u001b[49m\u001b[43mmetadata\u001b[49m\u001b[43m)\u001b[49m, content\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/.venv/lib/python3.12/site-packages/pydantic/main.py:250\u001b[39m, in \u001b[36mBaseModel.__init__\u001b[39m\u001b[34m(self, **data)\u001b[39m\n\u001b[32m 248\u001b[39m \u001b[38;5;66;03m# `__tracebackhide__` tells pytest and some other tools to omit this function from tracebacks\u001b[39;00m\n\u001b[32m 249\u001b[39m __tracebackhide__ = \u001b[38;5;28;01mTrue\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m250\u001b[39m validated_self = \u001b[38;5;28;43mself\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43m__pydantic_validator__\u001b[49m\u001b[43m.\u001b[49m\u001b[43mvalidate_python\u001b[49m\u001b[43m(\u001b[49m\u001b[43mdata\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mself_instance\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[32m 251\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m validated_self:\n\u001b[32m 252\u001b[39m warnings.warn(\n\u001b[32m 253\u001b[39m \u001b[33m'\u001b[39m\u001b[33mA custom validator is returning a value other than `self`.\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 254\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mReturning anything other than `self` from a top level model validator isn\u001b[39m\u001b[33m'\u001b[39m\u001b[33mt supported when validating via `__init__`.\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m\"\u001b[39m\n\u001b[32m 255\u001b[39m \u001b[33m'\u001b[39m\u001b[33mSee the `model_validator` docs (https://docs.pydantic.dev/latest/concepts/validators/#model-validators) for more details.\u001b[39m\u001b[33m'\u001b[39m,\n\u001b[32m 256\u001b[39m stacklevel=\u001b[32m2\u001b[39m,\n\u001b[32m 257\u001b[39m )\n", - "\u001b[31mValidationError\u001b[39m: 2 validation errors for ArticleMetadata\ndate\n Input should be a valid string [type=string_type, input_value=datetime.date(2026, 1, 30), input_type=date]\n For further information visit https://errors.pydantic.dev/2.12/v/string_type\nlastmod\n Field required [type=missing, input_value={'title': 'Welcome', 'dat...shed': True, 'tags': []}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.12/v/missing" - ] - } - ], + "outputs": [], "source": [ "metadata, content = load_markdown('pages/home.md')\n", "# content = content.replace('src=\"assets', 'src=\"../tmp/dogma-jimfinium/assets')" @@ -97,20 +84,12 @@ }, { "cell_type": "code", - "execution_count": null, - "id": "0bb15524", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "ed7b3b2f", "metadata": {}, "outputs": [], "source": [ - "html = format_html_template('templates/pages/default.html', content = content, **{'metadata.'+k:v for k,v in metadata.items()}, **PARTIALS)\n", + "html = format_html_template('templates/pages/default.html', content = content, metadata = metadata, **PARTIALS)\n", "with open('dist/home.html', 'w') as f:\n", " f.write(html)" ] @@ -151,10 +130,76 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, + "id": "dafd924b", + "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/vitamins.md',\n", + " 'build/dogma-jimfinium/gear-for-new-parents.md']" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "glob.glob('build/dogma-jimfinium/*.md')" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "cced61c4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'gear-for-new-parents.md'" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "os.path.basename( 'build/dogma-jimfinium/gear-for-new-parents.md')" + ] + }, + { + "cell_type": "code", + "execution_count": 14, "id": "944a5efd", "metadata": {}, - "outputs": [], + "outputs": [ + { + "ename": "TypeError", + "evalue": "'ArticleMetadata' object is not subscriptable", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mTypeError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[14]\u001b[39m\u001b[32m, line 36\u001b[39m\n\u001b[32m 32\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[38;5;28mopen\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mdist/dogma-jimfinium/\u001b[39m\u001b[38;5;132;01m{\u001b[39;00marticle_filestem\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m.html\u001b[39m\u001b[33m'\u001b[39m, \u001b[33m'\u001b[39m\u001b[33mw\u001b[39m\u001b[33m'\u001b[39m) \u001b[38;5;28;01mas\u001b[39;00m f:\n\u001b[32m 33\u001b[39m f.write(html)\n\u001b[32m---> \u001b[39m\u001b[32m36\u001b[39m index_html = \u001b[43mbuild_blog_archive\u001b[49m\u001b[43m(\u001b[49m\u001b[43mindex\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43m*\u001b[49m\u001b[43m*\u001b[49m\u001b[43mPARTIALS\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 37\u001b[39m \u001b[38;5;66;03m# Write the HTML file to /dist/dogma-jimfinium.\u001b[39;00m\n\u001b[32m 38\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[38;5;28mopen\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mdist/dogma-jimfinium/index.html\u001b[39m\u001b[33m'\u001b[39m, \u001b[33m'\u001b[39m\u001b[33mw\u001b[39m\u001b[33m'\u001b[39m) \u001b[38;5;28;01mas\u001b[39;00m f:\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/main.py:150\u001b[39m, in \u001b[36mbuild_blog_archive\u001b[39m\u001b[34m(index, page_template, li_template, **kwargs)\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[32m--> \u001b[39m\u001b[32m150\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m article, (metadata, contents) \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28;43msorted\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43mindex\u001b[49m\u001b[43m.\u001b[49m\u001b[43mitems\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mkey\u001b[49m\u001b[43m \u001b[49m\u001b[43m=\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mlambda\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mitem\u001b[49m\u001b[43m:\u001b[49m\u001b[43m \u001b[49m\u001b[43mitem\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m1\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m0\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mdate\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m)\u001b[49m[::-\u001b[32m1\u001b[39m]:\n\u001b[32m 151\u001b[39m \n\u001b[32m 152\u001b[39m \u001b[38;5;66;03m# Generate HTML for the article (including metadata tags).\u001b[39;00m\n\u001b[32m 153\u001b[39m archive_html_content += format_html_template(\n\u001b[32m 154\u001b[39m li_template,\n\u001b[32m 155\u001b[39m article_filestem = article,\n\u001b[32m (...)\u001b[39m\u001b[32m 158\u001b[39m \n\u001b[32m 159\u001b[39m )\n\u001b[32m 160\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
        \u001b[39m\u001b[33m'\u001b[39m\n\u001b[32m--> \u001b[39m\u001b[32m150\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m article, (metadata, contents) \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28msorted\u001b[39m(index.items(), key = \u001b[38;5;28;01mlambda\u001b[39;00m item: \u001b[43mitem\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m1\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m0\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mdate\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m]\u001b[49m)[::-\u001b[32m1\u001b[39m]:\n\u001b[32m 151\u001b[39m \n\u001b[32m 152\u001b[39m \u001b[38;5;66;03m# Generate HTML for the article (including metadata tags).\u001b[39;00m\n\u001b[32m 153\u001b[39m archive_html_content += format_html_template(\n\u001b[32m 154\u001b[39m li_template,\n\u001b[32m 155\u001b[39m article_filestem = article,\n\u001b[32m (...)\u001b[39m\u001b[32m 158\u001b[39m \n\u001b[32m 159\u001b[39m )\n\u001b[32m 160\u001b[39m archive_html_content +=\u001b[33m'\u001b[39m\u001b[33m
      \u001b[39m\u001b[33m'\u001b[39m\n", + "\u001b[31mTypeError\u001b[39m: 'ArticleMetadata' object is not subscriptable" + ] + } + ], "source": [ "shutil.rmtree('dist/dogma-jimfinium', ignore_errors=True)\n", "os.makedirs('dist/dogma-jimfinium', exist_ok=True)\n", @@ -162,14 +207,17 @@ "\n", "index = {}\n", "\n", - "for article in os.listdir('build/dogma-jimfinium'):\n", - " metadata, content = load_markdown(f'build/dogma-jimfinium/{article}')\n", + "for article in glob.glob('build/dogma-jimfinium/*.md'):\n", + " metadata, content = load_markdown(article)\n", + "\n", + " if metadata is None:\n", + " print(article)\n", "\n", " # Skip unpublished articles.\n", - " if not metadata.get('published'):\n", + " if not metadata.published:\n", " continue\n", "\n", - " article_filestem = os.path.splitext(article)[0]\n", + " article_filestem = os.path.splitext(os.path.basename(article))[0]\n", "\n", " # Add the article to the index.\n", " index[article_filestem] = (metadata, content)\n", @@ -178,8 +226,8 @@ " article_html = format_html_template(\n", " 'templates/components/blog_article.html',\n", " content = content,\n", - " blog_tags = ' '.join(format_blog_tags(metadata['tags'])),\n", - " **{'metadata.'+k:v for k,v in metadata.items()}\n", + " blog_tags = ' '.join(format_blog_tags(metadata.tags)),\n", + " metadata = metadata\n", " )\n", " html = format_html_template('templates/pages/default.html', content = article_html, **PARTIALS)\n", " \n", @@ -188,7 +236,7 @@ " f.write(html)\n", "\n", "\n", - "index_html = build_blog_archive(index, metadata.title = 'Dogma Jimfinium | Index', **PARTIALS)\n", + "index_html = build_blog_archive(index, **PARTIALS)\n", "# Write the HTML file to /dist/dogma-jimfinium.\n", "with open(f'dist/dogma-jimfinium/index.html', 'w') as f:\n", " f.write(index_html)" From 14a9e1c50458365321b3e90e3386b34a18731ace Mon Sep 17 00:00:00 2001 From: Jim Shepich III Date: Sat, 31 Jan 2026 04:11:23 -0500 Subject: [PATCH 06/20] Template mapping --- config.yaml | 1 + main.py => jimsite.py | 85 ++++- requirements.txt | 3 +- site/assets/css/common.css | 19 +- templates/blog_post.html | 8 +- templates/pages/default.html | 8 +- templates/partials/footer.html | 2 +- templates/simple.html | 8 +- testbench.ipynb | 553 +++++++++++++++++++++++---------- 9 files changed, 500 insertions(+), 187 deletions(-) rename main.py => jimsite.py (72%) diff --git a/config.yaml b/config.yaml index 931fdc6..aaf4501 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,5 @@ author: Jim Shepich III +templates_folder: ./templates site_defaults: base_url: http://localhost:8000 web_root: ./dist diff --git a/main.py b/jimsite.py similarity index 72% rename from main.py rename to jimsite.py index 106fecb..d18dfad 100644 --- a/main.py +++ b/jimsite.py @@ -7,6 +7,12 @@ 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.''' @@ -69,7 +75,11 @@ def format_html_template(template: str, **kwargs) -> str: template = filepath_or_string(template) # Interpolate the kwargs into the HTML template. - html = template.format(**kwargs) + # Apply global variables twice in case a partial used + # by the first call of .format() uses a variable. + html = template.format( + globalvars = GlobalVars(), **kwargs + ).format(globalvars = GlobalVars()) # Return the formatted HTML. return html @@ -91,7 +101,7 @@ def load_partials() -> dict: 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( + partials[f'partials.{os.path.splitext(filename)[0]}'] = format_html_template( partial_template, current_year = datetime.now().year ) @@ -193,5 +203,74 @@ def copy_assets(site: SiteConfig): 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: + 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() + + # # Interpolate global variables into partials. + # if 'partials' in full_path: + # html = html.format(globalvars = GlobalVars()) + + output[filestem] = html + + return DotMap(output) + + + + if __name__ == '__main__': - pass \ No newline at end of file + pass + + diff --git a/requirements.txt b/requirements.txt index f9c7c46..10264cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ ipykernel markdown pyyaml -rfeed \ No newline at end of file +rfeed +dotmap \ No newline at end of file diff --git a/site/assets/css/common.css b/site/assets/css/common.css index 7cc25f7..1845eb7 100644 --- a/site/assets/css/common.css +++ b/site/assets/css/common.css @@ -8,6 +8,7 @@ /*https://www.schemecolor.com/light-silver-gradient.php)*/ --navy-blue:#091b75; --azure:#4f67db; + --azure-tint-20: #6e87e5; --charcoal:#333333; font-size:120%; @@ -22,47 +23,47 @@ @font-face { font-family: Beleren; - src: url('fonts/Beleren-Bold.ttf'); + src: url('/assets/fonts/Beleren-Bold.ttf'); } @font-face { font-family: Playbill; - src: url('fonts/Playbill.ttf'); + src: url('/assets/fonts/Playbill.ttf'); } @font-face { font-family: Moderna; - src: url('fonts/MODERNA_.ttf'); + src: url('/assets/fonts/MODERNA_.ttf'); } @font-face { font-family: Adventure; - src: url('fonts/Adventure.ttf'); + src: url('/assets/fonts/Adventure.ttf'); } @font-face { font-family: Oxygen; - src: url('fonts/OxygenMono-Regular.ttf'); + src: url('/assets/fonts/OxygenMono-Regular.ttf'); } @font-face { font-family: Garamond; - src: url('fonts/EBGaramond.ttf'); + src: url('/assets/fonts/EBGaramond.ttf'); } @font-face { font-family: Fira; - src: url('fonts/FiraSans-Regular.ttf'); + src: url('/assets/fonts/FiraSans-Regular.ttf'); } @font-face { font-family: StitchWarrior; - src: url('fonts/StitchWarrior demo.ttf'); + src: url('/assets/fonts/StitchWarrior demo.ttf'); } @font-face { font-family: Floral; - src: url('fonts/FloralCapitals.ttf'); + src: url('/assets/fonts/FloralCapitals.ttf'); } var{ diff --git a/templates/blog_post.html b/templates/blog_post.html index 3a7c584..666ddca 100644 --- a/templates/blog_post.html +++ b/templates/blog_post.html @@ -2,9 +2,9 @@ - {partials__default_css} - {partials__header} - {partials__nav} + {partials.default_css} + {partials.header} + {partials.nav}
      @@ -18,5 +18,5 @@ {content}
    - {partials__footer} + {partials.footer} \ No newline at end of file diff --git a/templates/pages/default.html b/templates/pages/default.html index bcb8ed9..9aa69a2 100644 --- a/templates/pages/default.html +++ b/templates/pages/default.html @@ -2,13 +2,13 @@ - {partials__default_css} - {partials__header} - {partials__nav} + {partials.default_css} + {partials.header} + {partials.nav}
    {content}
    - {partials__footer} + {partials.footer} \ No newline at end of file diff --git a/templates/partials/footer.html b/templates/partials/footer.html index 4764231..dd714d4 100644 --- a/templates/partials/footer.html +++ b/templates/partials/footer.html @@ -1,3 +1,3 @@
    -
    Copyright © 2021-{current_year} Jim Shepich +
    Copyright © 2021-{globalvars.today.year} Jim Shepich
    \ No newline at end of file diff --git a/templates/simple.html b/templates/simple.html index bcb8ed9..9aa69a2 100644 --- a/templates/simple.html +++ b/templates/simple.html @@ -2,13 +2,13 @@ - {partials__default_css} - {partials__header} - {partials__nav} + {partials.default_css} + {partials.header} + {partials.nav}
    {content}
    - {partials__footer} + {partials.footer} \ No newline at end of file diff --git a/testbench.ipynb b/testbench.ipynb index 7d44a2d..ae6c9f1 100644 --- a/testbench.ipynb +++ b/testbench.ipynb @@ -27,7 +27,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "id": "207d2510", "metadata": {}, "outputs": [], @@ -40,40 +40,159 @@ "import rfeed\n", "import pydantic\n", "import glob\n", + "from dotmap import DotMap\n", "from typing import Optional, Union, Literal, BinaryIO, Any\n", "\n", "\n", "\n", "from datetime import datetime\n", - "from main import *" + "from jimsite import *" ] }, { "cell_type": "code", - "execution_count": 2, - "id": "4b17a3ed", + "execution_count": 19, + "id": "68233fbb", + "metadata": {}, + "outputs": [], + "source": [ + "config['templates_folder'] = './templates'\n", + "templates_dict = {}\n", + "for subfolder, _, files in os.walk(config['templates_folder']):\n", + " templates_dict[subfolder] = {}\n", + " for file in files:\n", + " templates_dict[subfolder][file] = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "id": "0f4bf2d6", + "metadata": {}, + "outputs": [ + { + "ename": "KeyError", + "evalue": "'poop'", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mKeyError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[36]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m \u001b[33;43m'\u001b[39;49m\u001b[38;5;132;43;01m{poop}\u001b[39;49;00m\u001b[33;43m \u001b[39;49m\u001b[38;5;132;43;01m{loop}\u001b[39;49;00m\u001b[33;43m'\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43mformat\u001b[49m\u001b[43m(\u001b[49m\u001b[43mloop\u001b[49m\u001b[43m \u001b[49m\u001b[43m=\u001b[49m\u001b[43m \u001b[49m\u001b[32;43m1\u001b[39;49m\u001b[43m)\u001b[49m\n", + "\u001b[31mKeyError\u001b[39m: 'poop'" + ] + } + ], + "source": [ + "'{poop} {loop}'.format(loop = 1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92310172", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 82, + "id": "9e4becd3", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'dist/assets'" + "'\\n\\n\\n\\n \\n\\n\\n\\n\\n
    \\n Jimlab\\n
    \\n \\n\\n\\n
    \\n foo\\n
    \\n
    \\n
    Copyright © 2021-{globalvars.today.year} Jim Shepich\\n
    \\n'" ] }, - "execution_count": 2, + "execution_count": 82, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "PARTIALS = load_partials()\n", - "shutil.rmtree('dist/assets', ignore_errors=True)\n", - "shutil.copytree('assets','dist/assets')" + "format_html_template(templates.simple, content = 'foo', partials = templates.partials)" ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, + "id": "5344cc93", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "56c5d493", + "metadata": {}, + "outputs": [], + "source": [ + "def build_html_tree(root_dir):\n", + " # Create an empty dictionary to store the HTML files\n", + " html_files = {}\n", + "\n", + " # Walk through the directory tree\n", + " for dirpath, dirs, files in os.walk(root_dir):\n", + " level = root_dir.replace('\\\\', '/').count('/') # Counting slashes to determine depth\n", + " rel_path = dirpath[len(root_dir) + 1:].replace('\\\\', '/') # Get relative path\n", + "\n", + " if not rel_path: # If the path is the root directory, start with a dot for nesting\n", + " html_files['.'] = {}\n", + " rel_path = '.'\n", + " \n", + " current_level = html_files\n", + " \n", + " # Navigate through directories in the relative path\n", + " for part in rel_path.split('/'):\n", + " if part not in current_level:\n", + " current_level[part] = {} # Create a new nested dictionary if it doesn't exist\n", + " current_level = current_level[part]\n", + " \n", + " # Add .html files to the appropriate directory level\n", + " for file in [f for f in files if f.endswith('.html')]:\n", + " current_level[file] = {}\n", + "\n", + " return html_files" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "35dee326", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DotMap(blog_tag.html=DotMap(), blog_article.html=DotMap(), blog_archive_li.html=DotMap(), _ipython_display_=DotMap(), _repr_mimebundle_=DotMap())" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "templates = DotMap(build_html_tree('./templates'))\n", + "templates.components" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "4b17a3ed", + "metadata": {}, + "outputs": [], + "source": [ + "PARTIALS = load_partials()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, "id": "d2361c42", "metadata": {}, "outputs": [], @@ -84,7 +203,26 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, + "id": "d718ae33", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "datetime.datetime(2026, 1, 31, 3, 50, 47, 891770)" + ] + }, + "execution_count": 54, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 6, "id": "ed7b3b2f", "metadata": {}, "outputs": [], @@ -128,37 +266,6 @@ ], "source": [] }, - { - "cell_type": "code", - "execution_count": 12, - "id": "dafd924b", - "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/vitamins.md',\n", - " 'build/dogma-jimfinium/gear-for-new-parents.md']" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "glob.glob('build/dogma-jimfinium/*.md')" - ] - }, { "cell_type": "code", "execution_count": 13, @@ -182,28 +289,228 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "944a5efd", "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "132a32ec", + "metadata": {}, "outputs": [ { - "ename": "TypeError", - "evalue": "'ArticleMetadata' object is not subscriptable", - "output_type": "error", - "traceback": [ - "\u001b[31m---------------------------------------------------------------------------\u001b[39m", - "\u001b[31mTypeError\u001b[39m Traceback (most recent call last)", - "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[14]\u001b[39m\u001b[32m, line 36\u001b[39m\n\u001b[32m 32\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[38;5;28mopen\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mdist/dogma-jimfinium/\u001b[39m\u001b[38;5;132;01m{\u001b[39;00marticle_filestem\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m.html\u001b[39m\u001b[33m'\u001b[39m, \u001b[33m'\u001b[39m\u001b[33mw\u001b[39m\u001b[33m'\u001b[39m) \u001b[38;5;28;01mas\u001b[39;00m f:\n\u001b[32m 33\u001b[39m f.write(html)\n\u001b[32m---> \u001b[39m\u001b[32m36\u001b[39m index_html = \u001b[43mbuild_blog_archive\u001b[49m\u001b[43m(\u001b[49m\u001b[43mindex\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43m*\u001b[49m\u001b[43m*\u001b[49m\u001b[43mPARTIALS\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 37\u001b[39m \u001b[38;5;66;03m# Write the HTML file to /dist/dogma-jimfinium.\u001b[39;00m\n\u001b[32m 38\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[38;5;28mopen\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mdist/dogma-jimfinium/index.html\u001b[39m\u001b[33m'\u001b[39m, \u001b[33m'\u001b[39m\u001b[33mw\u001b[39m\u001b[33m'\u001b[39m) \u001b[38;5;28;01mas\u001b[39;00m f:\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/main.py:150\u001b[39m, in \u001b[36mbuild_blog_archive\u001b[39m\u001b[34m(index, page_template, li_template, **kwargs)\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[32m--> \u001b[39m\u001b[32m150\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m article, (metadata, contents) \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28;43msorted\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43mindex\u001b[49m\u001b[43m.\u001b[49m\u001b[43mitems\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mkey\u001b[49m\u001b[43m \u001b[49m\u001b[43m=\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mlambda\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mitem\u001b[49m\u001b[43m:\u001b[49m\u001b[43m \u001b[49m\u001b[43mitem\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m1\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m0\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mdate\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m)\u001b[49m[::-\u001b[32m1\u001b[39m]:\n\u001b[32m 151\u001b[39m \n\u001b[32m 152\u001b[39m \u001b[38;5;66;03m# Generate HTML for the article (including metadata tags).\u001b[39;00m\n\u001b[32m 153\u001b[39m archive_html_content += format_html_template(\n\u001b[32m 154\u001b[39m li_template,\n\u001b[32m 155\u001b[39m article_filestem = article,\n\u001b[32m (...)\u001b[39m\u001b[32m 158\u001b[39m \n\u001b[32m 159\u001b[39m )\n\u001b[32m 160\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
      \u001b[39m\u001b[33m'\u001b[39m\n\u001b[32m--> \u001b[39m\u001b[32m150\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m article, (metadata, contents) \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28msorted\u001b[39m(index.items(), key = \u001b[38;5;28;01mlambda\u001b[39;00m item: \u001b[43mitem\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m1\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m0\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mdate\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m]\u001b[49m)[::-\u001b[32m1\u001b[39m]:\n\u001b[32m 151\u001b[39m \n\u001b[32m 152\u001b[39m \u001b[38;5;66;03m# Generate HTML for the article (including metadata tags).\u001b[39;00m\n\u001b[32m 153\u001b[39m archive_html_content += format_html_template(\n\u001b[32m 154\u001b[39m li_template,\n\u001b[32m 155\u001b[39m article_filestem = article,\n\u001b[32m (...)\u001b[39m\u001b[32m 158\u001b[39m \n\u001b[32m 159\u001b[39m )\n\u001b[32m 160\u001b[39m archive_html_content +=\u001b[33m'\u001b[39m\u001b[33m
    \u001b[39m\u001b[33m'\u001b[39m\n", - "\u001b[31mTypeError\u001b[39m: 'ArticleMetadata' object is not subscriptable" + "data": { + "text/plain": [ + "{'superlock': (ArticleMetadata(title='Superlock', author='Jim Shepich III', date=datetime.date(2025, 11, 26), lastmod=None, published=True, tags=['martialarts'], thumbnail='assets/images/superlock.jpg'),\n", + " '

    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.

    \\n

    This video shows how to do it:

    \\n'),\n", + " 'sustainable-living': (ArticleMetadata(title='Sustainable Living', author='Jim Shepich III', date=datetime.date(2025, 11, 20), lastmod=None, published=True, tags=['housekeeping', 'sustainability'], thumbnail=None),\n", + " '

    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.

    '),\n", + " 'stocking-up': (ArticleMetadata(title='Stocking Up', author='Jim Shepich III', date=datetime.date(2025, 11, 19), lastmod=None, published=True, tags=['housekeeping'], thumbnail=None),\n", + " \"

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

    \"),\n", + " 'set-up-the-toys': (ArticleMetadata(title='Set Up the Toys', author='Jim Shepich III', date=datetime.date(2026, 1, 14), lastmod=datetime.date(2026, 1, 14), published=True, tags=['parenting'], thumbnail=None),\n", + " '

    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

    \"March

    \\n

    Bunky\\'s First Birthday

    \\n

    \"Bunky\\'s

    \\n

    Real-life Dancing Fruits for a \"berry sweet\" boy\\'s first birthday.

    \\n

    Baby\\'s First Dungeon

    \\n

    \"Baby\\'s

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

    \"Big

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

    \"October

    \\n

    I started building a citadel out of his couch.

    \\n

    Attack on Junebug

    \\n

    \"Attack

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

    \"Elfpocalypse\"

    \\n

    In the thrilling conclusion to our first season of Elf-on-the-Shelf, the Elf leads the toys on a Christmas Day siege.

    '),\n", + " 'do-what-you-love': (ArticleMetadata(title='Do What You Love', author='Jim Shepich III', date=datetime.date(2025, 6, 10), lastmod=None, published=True, tags=['career', 'ikigai', 'quote'], thumbnail=None),\n", + " '
    \\n

    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.

    \\n

    — Jim Shepich (actually)

    \\n
    '),\n", + " 'self-care-is-not-selfish': (ArticleMetadata(title='Self-Care is not Selfish', author='Jim Shepich III', date=datetime.date(2025, 5, 18), lastmod=None, published=True, tags=['adulting', 'health'], thumbnail=None),\n", + " '

    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\")

    '),\n", + " 'blowouts': (ArticleMetadata(title='Blowouts', author='Jim Shepich III', date=datetime.date(2025, 11, 26), lastmod=None, published=True, tags=['parenting'], thumbnail=None),\n", + " \"

    Here's my troubleshooting guide for if your infant is having a lot of blowouts:

    \\n
      \\n
    1. 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.
    2. \\n
    3. 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.
    4. \\n
    5. If ② doesn't help, you may need to use the next size of diaper.
    6. \\n
    7. 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.
    8. \\n
    9. If ④ doesn't help, consider using a different brand of diaper, as it may fit your kid's butt better.
    10. \\n
    11. If ⑤ doesn't help, consider double-bagging your baby, by putting a diaper of a much larger size on over their regular one.
    12. \\n
    \"),\n", + " 'vitamins': (ArticleMetadata(title='Vitamins & Supplements', author='Jim Shepich III', date=datetime.date(2025, 5, 18), lastmod=None, published=True, tags=['health'], thumbnail=None),\n", + " '

    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.
    • \\n
    '),\n", + " 'gear-for-new-parents': (ArticleMetadata(title='Gear for New Parents', author='Jim Shepich III', date=datetime.date(2024, 7, 12), lastmod=None, published=True, tags=['parenting', 'babyprep'], thumbnail=None),\n", + " '

    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)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e32458c7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Dogma Jimfiniumhttp://localhost:8000/dogma-jimfinium/rssDogma Jimfiniumen-USThu, 29 Jan 2026 16:29:57 GMTrfeed v1.1.1https://github.com/svpino/rfeed/blob/master/README.mdSuperlockhttp://localhost:8000/dogma-jimfinium/superlockJim Shepich IIIWed, 26 Nov 2025 00:00:00 GMTsuperlockSustainable Livinghttp://localhost:8000/dogma-jimfinium/sustainable-livingJim Shepich IIIThu, 20 Nov 2025 00:00:00 GMTsustainable-livingStocking Uphttp://localhost:8000/dogma-jimfinium/stocking-upJim Shepich IIIWed, 19 Nov 2025 00:00:00 GMTstocking-upSet Up the Toyshttp://localhost:8000/dogma-jimfinium/set-up-the-toysJim Shepich IIIWed, 14 Jan 2026 00:00:00 GMTset-up-the-toysDo What You Lovehttp://localhost:8000/dogma-jimfinium/do-what-you-loveJim Shepich IIITue, 10 Jun 2025 00:00:00 GMTdo-what-you-loveSelf-Care is not Selfishhttp://localhost:8000/dogma-jimfinium/self-care-is-not-selfishJim Shepich IIISun, 18 May 2025 00:00:00 GMTself-care-is-not-selfishBlowoutshttp://localhost:8000/dogma-jimfinium/blowoutsJim Shepich IIIWed, 26 Nov 2025 00:00:00 GMTblowoutsVitamins & Supplementshttp://localhost:8000/dogma-jimfinium/vitaminsJim Shepich IIISun, 18 May 2025 00:00:00 GMTvitaminsGear for New Parentshttp://localhost:8000/dogma-jimfinium/gear-for-new-parentsJim Shepich IIIFri, 12 Jul 2024 00:00:00 GMTgear-for-new-parents\n" ] } ], "source": [ - "shutil.rmtree('dist/dogma-jimfinium', ignore_errors=True)\n", - "os.makedirs('dist/dogma-jimfinium', exist_ok=True)\n", - "shutil.copytree('build/dogma-jimfinium/assets', 'dist/dogma-jimfinium/assets')\n", + "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())" + ] + }, + { + "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": [], + "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:\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\n", + " )\n", + "\n", + " with open(f'{site.web_root.rstrip('/')}/{filestem}.html', 'w') as f:\n", + " f.write(page)\n", + "\n", + " " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7de0d84d", + "metadata": {}, + "outputs": [], + "source": [ "\n", "index = {}\n", "\n", @@ -242,110 +549,6 @@ " f.write(index_html)" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "132a32ec", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "datetime.date(2024, 7, 12)" - ] - }, - "execution_count": 37, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "metadata['date']" - ] - }, - { - "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)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e32458c7", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Dogma Jimfiniumhttp://localhost:8000/dogma-jimfinium/rssDogma Jimfiniumen-USThu, 29 Jan 2026 16:29:57 GMTrfeed v1.1.1https://github.com/svpino/rfeed/blob/master/README.mdSuperlockhttp://localhost:8000/dogma-jimfinium/superlockJim Shepich IIIWed, 26 Nov 2025 00:00:00 GMTsuperlockSustainable Livinghttp://localhost:8000/dogma-jimfinium/sustainable-livingJim Shepich IIIThu, 20 Nov 2025 00:00:00 GMTsustainable-livingStocking Uphttp://localhost:8000/dogma-jimfinium/stocking-upJim Shepich IIIWed, 19 Nov 2025 00:00:00 GMTstocking-upSet Up the Toyshttp://localhost:8000/dogma-jimfinium/set-up-the-toysJim Shepich IIIWed, 14 Jan 2026 00:00:00 GMTset-up-the-toysDo What You Lovehttp://localhost:8000/dogma-jimfinium/do-what-you-loveJim Shepich IIITue, 10 Jun 2025 00:00:00 GMTdo-what-you-loveSelf-Care is not Selfishhttp://localhost:8000/dogma-jimfinium/self-care-is-not-selfishJim Shepich IIISun, 18 May 2025 00:00:00 GMTself-care-is-not-selfishBlowoutshttp://localhost:8000/dogma-jimfinium/blowoutsJim Shepich IIIWed, 26 Nov 2025 00:00:00 GMTblowoutsVitamins & Supplementshttp://localhost:8000/dogma-jimfinium/vitaminsJim Shepich IIISun, 18 May 2025 00:00:00 GMTvitaminsGear for New Parentshttp://localhost:8000/dogma-jimfinium/gear-for-new-parentsJim Shepich IIIFri, 12 Jul 2024 00:00:00 GMTgear-for-new-parents\n" - ] - } - ], - "source": [ - "rss_feed = rfeed.Feed(\n", - " title = \"Dogma Jimfinium\",\n", - " link = \"http://localhost:8000/dogma-jimfinium/rss\",\n", - " description = \"Dogma Jimfinium\",\n", - " language = \"en-US\",\n", - " lastBuildDate = datetime.now(),\n", - " items = [\n", - " rfeed.Item(\n", - " title = metadata['title'],\n", - " link = f\"http://localhost:8000/dogma-jimfinium/{filestem}\", \n", - " description = metadata.get('description'),\n", - " author = metadata.get('author', 'Jim Shepich III'),\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())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6c160693", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "57ef8185", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b068c448", - "metadata": {}, - "outputs": [], - "source": [] - }, { "cell_type": "code", "execution_count": null, @@ -353,9 +556,37 @@ "metadata": {}, "outputs": [], "source": [ - "def build_site(site_config: SiteConfig):\n", + "def build_site(site: SiteConfig):\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", + "\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", " " ] + }, + { + "cell_type": "code", + "execution_count": 8, + "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'])" + ] } ], "metadata": { From 239b3f1a84254af9e925572988584d9c649af889 Mon Sep 17 00:00:00 2001 From: Jim Shepich III Date: Sat, 31 Jan 2026 10:45:57 -0500 Subject: [PATCH 07/20] Cyclical depenency detection for templating --- testbench.ipynb | 92 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/testbench.ipynb b/testbench.ipynb index ae6c9f1..f941874 100644 --- a/testbench.ipynb +++ b/testbench.ipynb @@ -27,12 +27,13 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 3, "id": "207d2510", "metadata": {}, "outputs": [], "source": [ "import os\n", + "import re\n", "import shutil\n", "import markdown\n", "import yaml\n", @@ -88,11 +89,96 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, + "id": "9abb76b2", + "metadata": {}, + "outputs": [], + "source": [ + "def find_cyclical_placeholders(s, _parents = None, _leaves = None, **kwargs):\n", + " if _parents is None:\n", + " _parents = tuple()\n", + " if _leaves is None:\n", + " _leaves = {}\n", + " placeholders = extract_placeholders(s)\n", + "\n", + " if placeholders is None or len(placeholders) == 0:\n", + " _leaves[_parents] = False\n", + "\n", + " for p in placeholders:\n", + " if p in _parents:\n", + " _leaves[_parents + (p,)] = True\n", + " else:\n", + " find_cyclical_placeholders(\n", + " ('{'+p+'}').format(**kwargs),\n", + " _parents = _parents+(p,),\n", + " _leaves = _leaves,\n", + " **kwargs\n", + " )\n", + "\n", + " return _leaves\n", + " \n", + " \n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "8699f542", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{('a',): False, ('b',): False, ('c', 'd'): False, ('c', 'e', 'c'): True}" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "kwargs = {'a': '1', 'b': '2', 'c': '{d}+{e}', 'd': '3', 'e': '{c}'}\n", + "s = '{a} + {b} = {c}'\n", + "find_cyclical_placeholders(s, **kwargs)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, "id": "92310172", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "def extract_placeholders(s):\n", + " # Regex pattern to match placeholders\n", + " placeholder_pattern = r'\\{(\\w+)\\}'\n", + " \n", + " # Find all matches in the string\n", + " matches = re.findall(placeholder_pattern, s)\n", + " \n", + " return matches\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e6884a45", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "FOR FORMAT_HTML_TEMPLATE:\n", + "unformatted = ...\n", + "formatted = None\n", + "filled_placeholders = set()\n", + "while unformatted != formatted:\n", + " placeholders = extract_paceholders(unformatted)\n", + " formatted = unformatted.format(...)\n", + " filled_placeholders.add(set(placeholders))" + ] }, { "cell_type": "code", From f0b26fb2d5f0bca3ceb5c6ffae4d63fed7ec62a6 Mon Sep 17 00:00:00 2001 From: Jim Shepich III Date: Sat, 31 Jan 2026 14:02:22 -0500 Subject: [PATCH 08/20] Added true recursive template interpolation and cleaned up --- config.yaml | 2 +- jimsite.py | 92 +++++++-- testbench.ipynb | 512 ++---------------------------------------------- 3 files changed, 96 insertions(+), 510 deletions(-) diff --git a/config.yaml b/config.yaml index aaf4501..7a5e5ed 100644 --- a/config.yaml +++ b/config.yaml @@ -18,7 +18,7 @@ sites: git_repo: ssh://gitea/jim/resume.git build_cache: ./build/resume assets: - - '{build_cache}/shepich_resume.pdf' + - 'shepich_resume.pdf' dogma_jimfinium: base_url: http://localhost:8080/dogma-jimfinium git_repo: ssh://gitea/jim/dogma-jimfinium.git diff --git a/jimsite.py b/jimsite.py index d18dfad..f8e3fbc 100644 --- a/jimsite.py +++ b/jimsite.py @@ -1,4 +1,5 @@ import os +import re import glob import shutil import subprocess @@ -23,6 +24,59 @@ def filepath_or_string(s: str) -> str: 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()) @@ -69,20 +123,36 @@ def load_markdown(md: str) -> tuple[ArticleMetadata|None, str]: def format_html_template(template: str, **kwargs) -> str: '''Interpolates variables specified as keyword arguments - into the given HTML template.''' + into the given HTML template. + + # Example + + ```python + kwargs = {'a': '1', 'b': '2', 'c': '{d}+{e}', 'd': '3', 'e': '{c}'} + s = '{a} + {b} = {c}' + find_cyclical_placeholders(s, **kwargs) + + >>> {('c', 'e', 'c')} + ``` + ''' # Load the template if a filepath is given. template = filepath_or_string(template) - # Interpolate the kwargs into the HTML template. - # Apply global variables twice in case a partial used - # by the first call of .format() uses a variable. - html = template.format( - globalvars = GlobalVars(), **kwargs - ).format(globalvars = GlobalVars()) + # 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 html + return formatted_html run = lambda cmd: subprocess.run(cmd.split(' '), stdout = subprocess.PIPE, stderr = subprocess.PIPE) @@ -212,7 +282,7 @@ def build_index(site: SiteConfig) -> dict: # Expand any globbed expressions. expanded_article_list = [] - for a in site.articles: + 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("/")}') @@ -259,10 +329,6 @@ def map_templates(dir: str, parent = '') -> DotMap: with open(full_path, 'r') as file: html = file.read() - # # Interpolate global variables into partials. - # if 'partials' in full_path: - # html = html.format(globalvars = GlobalVars()) - output[filestem] = html return DotMap(output) diff --git a/testbench.ipynb b/testbench.ipynb index f941874..20c1804 100644 --- a/testbench.ipynb +++ b/testbench.ipynb @@ -1,33 +1,8 @@ { "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", - "- [x] ~~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": 3, + "execution_count": 1, "id": "207d2510", "metadata": {}, "outputs": [], @@ -52,390 +27,17 @@ }, { "cell_type": "code", - "execution_count": 19, - "id": "68233fbb", + "execution_count": null, + "id": "8f435a12", "metadata": {}, "outputs": [], "source": [ - "config['templates_folder'] = './templates'\n", - "templates_dict = {}\n", - "for subfolder, _, files in os.walk(config['templates_folder']):\n", - " templates_dict[subfolder] = {}\n", - " for file in files:\n", - " templates_dict[subfolder][file] = 1" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "id": "0f4bf2d6", - "metadata": {}, - "outputs": [ - { - "ename": "KeyError", - "evalue": "'poop'", - "output_type": "error", - "traceback": [ - "\u001b[31m---------------------------------------------------------------------------\u001b[39m", - "\u001b[31mKeyError\u001b[39m Traceback (most recent call last)", - "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[36]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m \u001b[33;43m'\u001b[39;49m\u001b[38;5;132;43;01m{poop}\u001b[39;49;00m\u001b[33;43m \u001b[39;49m\u001b[38;5;132;43;01m{loop}\u001b[39;49;00m\u001b[33;43m'\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43mformat\u001b[49m\u001b[43m(\u001b[49m\u001b[43mloop\u001b[49m\u001b[43m \u001b[49m\u001b[43m=\u001b[49m\u001b[43m \u001b[49m\u001b[32;43m1\u001b[39;49m\u001b[43m)\u001b[49m\n", - "\u001b[31mKeyError\u001b[39m: 'poop'" - ] - } - ], - "source": [ - "'{poop} {loop}'.format(loop = 1)" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "9abb76b2", - "metadata": {}, - "outputs": [], - "source": [ - "def find_cyclical_placeholders(s, _parents = None, _leaves = None, **kwargs):\n", - " if _parents is None:\n", - " _parents = tuple()\n", - " if _leaves is None:\n", - " _leaves = {}\n", - " placeholders = extract_placeholders(s)\n", - "\n", - " if placeholders is None or len(placeholders) == 0:\n", - " _leaves[_parents] = False\n", - "\n", - " for p in placeholders:\n", - " if p in _parents:\n", - " _leaves[_parents + (p,)] = True\n", - " else:\n", - " find_cyclical_placeholders(\n", - " ('{'+p+'}').format(**kwargs),\n", - " _parents = _parents+(p,),\n", - " _leaves = _leaves,\n", - " **kwargs\n", - " )\n", - "\n", - " return _leaves\n", - " \n", - " \n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "8699f542", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{('a',): False, ('b',): False, ('c', 'd'): False, ('c', 'e', 'c'): True}" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "kwargs = {'a': '1', 'b': '2', 'c': '{d}+{e}', 'd': '3', 'e': '{c}'}\n", - "s = '{a} + {b} = {c}'\n", - "find_cyclical_placeholders(s, **kwargs)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "92310172", - "metadata": {}, - "outputs": [], - "source": [ - "def extract_placeholders(s):\n", - " # Regex pattern to match placeholders\n", - " placeholder_pattern = r'\\{(\\w+)\\}'\n", + "with open('config.yaml', 'r') as config_file:\n", + " config = yaml.safe_load(config_file.read())\n", " \n", - " # Find all matches in the string\n", - " matches = re.findall(placeholder_pattern, s)\n", - " \n", - " return matches\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e6884a45", - "metadata": {}, - "outputs": [], - "source": [ + "templates = map_templates(config['templates_folder'])\n", "\n", - "FOR FORMAT_HTML_TEMPLATE:\n", - "unformatted = ...\n", - "formatted = None\n", - "filled_placeholders = set()\n", - "while unformatted != formatted:\n", - " placeholders = extract_paceholders(unformatted)\n", - " formatted = unformatted.format(...)\n", - " filled_placeholders.add(set(placeholders))" - ] - }, - { - "cell_type": "code", - "execution_count": 82, - "id": "9e4becd3", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'\\n\\n\\n\\n \\n\\n\\n\\n\\n
    \\n Jimlab\\n
    \\n \\n\\n\\n
    \\n foo\\n
    \\n
    \\n
    Copyright © 2021-{globalvars.today.year} Jim Shepich\\n
    \\n'" - ] - }, - "execution_count": 82, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "format_html_template(templates.simple, content = 'foo', partials = templates.partials)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5344cc93", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "56c5d493", - "metadata": {}, - "outputs": [], - "source": [ - "def build_html_tree(root_dir):\n", - " # Create an empty dictionary to store the HTML files\n", - " html_files = {}\n", - "\n", - " # Walk through the directory tree\n", - " for dirpath, dirs, files in os.walk(root_dir):\n", - " level = root_dir.replace('\\\\', '/').count('/') # Counting slashes to determine depth\n", - " rel_path = dirpath[len(root_dir) + 1:].replace('\\\\', '/') # Get relative path\n", - "\n", - " if not rel_path: # If the path is the root directory, start with a dot for nesting\n", - " html_files['.'] = {}\n", - " rel_path = '.'\n", - " \n", - " current_level = html_files\n", - " \n", - " # Navigate through directories in the relative path\n", - " for part in rel_path.split('/'):\n", - " if part not in current_level:\n", - " current_level[part] = {} # Create a new nested dictionary if it doesn't exist\n", - " current_level = current_level[part]\n", - " \n", - " # Add .html files to the appropriate directory level\n", - " for file in [f for f in files if f.endswith('.html')]:\n", - " current_level[file] = {}\n", - "\n", - " return html_files" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "id": "35dee326", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "DotMap(blog_tag.html=DotMap(), blog_article.html=DotMap(), blog_archive_li.html=DotMap(), _ipython_display_=DotMap(), _repr_mimebundle_=DotMap())" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "templates = DotMap(build_html_tree('./templates'))\n", - "templates.components" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "4b17a3ed", - "metadata": {}, - "outputs": [], - "source": [ - "PARTIALS = load_partials()" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "d2361c42", - "metadata": {}, - "outputs": [], - "source": [ - "metadata, content = load_markdown('pages/home.md')\n", - "# content = content.replace('src=\"assets', 'src=\"../tmp/dogma-jimfinium/assets')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d718ae33", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "datetime.datetime(2026, 1, 31, 3, 50, 47, 891770)" - ] - }, - "execution_count": 54, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "ed7b3b2f", - "metadata": {}, - "outputs": [], - "source": [ - "html = format_html_template('templates/pages/default.html', content = content, metadata = metadata, **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": [] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "cced61c4", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'gear-for-new-parents.md'" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "os.path.basename( 'build/dogma-jimfinium/gear-for-new-parents.md')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "944a5efd", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "132a32ec", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'superlock': (ArticleMetadata(title='Superlock', author='Jim Shepich III', date=datetime.date(2025, 11, 26), lastmod=None, published=True, tags=['martialarts'], thumbnail='assets/images/superlock.jpg'),\n", - " '

    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.

    \\n

    This video shows how to do it:

    \\n'),\n", - " 'sustainable-living': (ArticleMetadata(title='Sustainable Living', author='Jim Shepich III', date=datetime.date(2025, 11, 20), lastmod=None, published=True, tags=['housekeeping', 'sustainability'], thumbnail=None),\n", - " '

    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.

    '),\n", - " 'stocking-up': (ArticleMetadata(title='Stocking Up', author='Jim Shepich III', date=datetime.date(2025, 11, 19), lastmod=None, published=True, tags=['housekeeping'], thumbnail=None),\n", - " \"

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

    \"),\n", - " 'set-up-the-toys': (ArticleMetadata(title='Set Up the Toys', author='Jim Shepich III', date=datetime.date(2026, 1, 14), lastmod=datetime.date(2026, 1, 14), published=True, tags=['parenting'], thumbnail=None),\n", - " '

    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

    \"March

    \\n

    Bunky\\'s First Birthday

    \\n

    \"Bunky\\'s

    \\n

    Real-life Dancing Fruits for a \"berry sweet\" boy\\'s first birthday.

    \\n

    Baby\\'s First Dungeon

    \\n

    \"Baby\\'s

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

    \"Big

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

    \"October

    \\n

    I started building a citadel out of his couch.

    \\n

    Attack on Junebug

    \\n

    \"Attack

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

    \"Elfpocalypse\"

    \\n

    In the thrilling conclusion to our first season of Elf-on-the-Shelf, the Elf leads the toys on a Christmas Day siege.

    '),\n", - " 'do-what-you-love': (ArticleMetadata(title='Do What You Love', author='Jim Shepich III', date=datetime.date(2025, 6, 10), lastmod=None, published=True, tags=['career', 'ikigai', 'quote'], thumbnail=None),\n", - " '
    \\n

    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.

    \\n

    — Jim Shepich (actually)

    \\n
    '),\n", - " 'self-care-is-not-selfish': (ArticleMetadata(title='Self-Care is not Selfish', author='Jim Shepich III', date=datetime.date(2025, 5, 18), lastmod=None, published=True, tags=['adulting', 'health'], thumbnail=None),\n", - " '

    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\")

    '),\n", - " 'blowouts': (ArticleMetadata(title='Blowouts', author='Jim Shepich III', date=datetime.date(2025, 11, 26), lastmod=None, published=True, tags=['parenting'], thumbnail=None),\n", - " \"

    Here's my troubleshooting guide for if your infant is having a lot of blowouts:

    \\n
      \\n
    1. 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.
    2. \\n
    3. 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.
    4. \\n
    5. If ② doesn't help, you may need to use the next size of diaper.
    6. \\n
    7. 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.
    8. \\n
    9. If ④ doesn't help, consider using a different brand of diaper, as it may fit your kid's butt better.
    10. \\n
    11. If ⑤ doesn't help, consider double-bagging your baby, by putting a diaper of a much larger size on over their regular one.
    12. \\n
    \"),\n", - " 'vitamins': (ArticleMetadata(title='Vitamins & Supplements', author='Jim Shepich III', date=datetime.date(2025, 5, 18), lastmod=None, published=True, tags=['health'], thumbnail=None),\n", - " '

    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.
    • \\n
    '),\n", - " 'gear-for-new-parents': (ArticleMetadata(title='Gear for New Parents', author='Jim Shepich III', date=datetime.date(2024, 7, 12), lastmod=None, published=True, tags=['parenting', 'babyprep'], thumbnail=None),\n", - " '

    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":"An Ox and a Red Cat", - "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":"WX water xylem, OP organics 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
    -
    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 @@ -
    • {metadata.date} - {metadata.title} {blog_tags}
    • +
    • {article.metadata.date} - {article.metadata.title} {blog_tags}
    • diff --git a/site/templates/components/blog_article.html b/site/templates/components/blog_article.html index 452f7e9..e209b46 100644 --- a/site/templates/components/blog_article.html +++ b/site/templates/components/blog_article.html @@ -1,11 +1,11 @@
      -

      {metadata.title}

      +

      {article.metadata.title}

      By
      -
      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
      +
      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 +='
    ' + tag_index = build_tag_index(index) + + tag_selector_options = [] + tag_selector_css_rules = [f''' + body:has(input[name="tag-selector"][value="*"]:checked) li:has(.blog-tag){{{{ + display: list-item!important; + }}}} + '''] + for tag, articles in ({'*': [*index.keys()]} | tag_index).items(): + tag_selector_options.append(format_html_template( + template_selections['tag_selector_option'], + tag_name = tag, + number_with_tag = len(articles), + site = site, + **kwargs + )) + if tag == '*': + continue + tag_selector_css_rules.append(f''' + body:has(input[name="tag-selector"]:not([value="{tag}"]):checked) li:has(.blog-tag[data="{tag}"]){{{{ + display: none; + }}}} + body:has(input[name="tag-selector"][value="{tag}"]:checked) li:has(.blog-tag[data="{tag}"]){{{{ + display: list-item!important; + }}}} + ''') + + + # 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'', site = site, **kwargs ) @@ -55,11 +94,7 @@ def build_blog_archive( f.write(archive_html_page) -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] + # TODO: create a tag inventory page, as well as individual browse-by-tag pages. diff --git a/jimsite/templating.py b/jimsite/templating.py index 3a1dfd9..edefc19 100644 --- a/jimsite/templating.py +++ b/jimsite/templating.py @@ -141,7 +141,9 @@ class TemplateSelections: article = 'templates.components.blog_article', page = 'templates.pages.default', archive_li = 'templates.components.blog_archive_li', - archive_article = 'templates.components.blog_archive' + archive_article = 'templates.components.blog_archive', + tag_selector = 'templates.components.blog_archive_tag_selector', + tag_selector_option = 'templates.components.blog_archive_tag_selector_option' ) | (config or {}).get('template_default_selections', {}) def __getitem__(self, key: str) -> str: diff --git a/site/assets/css/reset.css b/site/assets/css/reset.css index 980eb1f..4d0352d 100644 --- a/site/assets/css/reset.css +++ b/site/assets/css/reset.css @@ -31,9 +31,12 @@ footer, header, hgroup, menu, nav, section { body { line-height: 1; } -ol, ul { +ul { list-style: disc; } +ol { + list-style-type: decimal; +} blockquote, q { quotes: none; } diff --git a/site/assets/css/theme.css b/site/assets/css/theme.css index fed556e..38ca3e1 100644 --- a/site/assets/css/theme.css +++ b/site/assets/css/theme.css @@ -146,27 +146,47 @@ summary.heading{ -webkit-tap-highlight-color: transparent; } -span.blog-tag{ - font-weight: bold; - border-radius: 3px 3px 3px 3px; - background-color: var(--azure); - color: white; +input[name="tag-selector"]{ + display: none; +} + +.blog-tag{ 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); +label:has(input[name="tag-selector"]){ + font-size: 0.8em; + padding: 0.1em; + padding-left: 0.5em; + padding-right: 0.5em; + margin: 0.5em 0.25em; + } -a:has(> span.blog-tag){ +.blog-tag, label:has(input[name="tag-selector"]){ + font-weight: bold; + border-radius: 3px 3px 3px 3px; + background-color: var(--azure); + color: white; + vertical-align: middle; - color: unset; text-decoration: unset; font-weight: unset; + + cursor: pointer; +} + +.blog-tag:hover, label:has(input[name="tag-selector"]):hover{ + background-color: var(--azure-tint-20); + color: white; +} + +label:has(input[name="tag-selector"]:checked){ + background: var(--silver); + color: var(--charcoal); } article hr{ @@ -183,4 +203,5 @@ img.rss-icon{ span img.rss-icon{ float: right; -} \ No newline at end of file +} + diff --git a/site/templates/components/blog_archive.html b/site/templates/components/blog_archive.html index 0c2033b..ee2fc92 100644 --- a/site/templates/components/blog_archive.html +++ b/site/templates/components/blog_archive.html @@ -1,5 +1,9 @@

    {site.title} Archive


    +

    Tag Inventory

    + {templates.components.blog_archive_tag_selector} +
    +

    Post History

    {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_options} +
    +{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 = `
  • ${lists[item].title}
  • `; - //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 = `${tooltip}${tooltip}` - //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 = `${tooltip}`; - //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 = `` - - 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 = `
  • ${item.title}${gen_list_html(item)}
  • `; - } else { - item_html = `
  • ${item.title}${gen_list_html(item)}
  • `; - } - - - break; - - default: - if(["string","number"].includes(typeof item)){ - item_html = `
  • ${item}
  • `; - //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 += "

    Return to Master List ↩

    "; - //Add a return link to the bottom of the article. -} diff --git a/site/assets/js/resize.js b/site/assets/js/resize.js deleted file mode 100644 index 1711628..0000000 --- a/site/assets/js/resize.js +++ /dev/null @@ -1 +0,0 @@ -//$("#main-header").css({"font-size":$("#main-header").height()+"px"}) diff --git a/site/assets/js/vendor/jquery-3.6.0.min.js b/site/assets/js/vendor/jquery-3.6.0.min.js deleted file mode 100644 index c4c6022..0000000 --- a/site/assets/js/vendor/jquery-3.6.0.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */ -!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="
    ",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0