refactor #1

Merged
jim merged 20 commits from refactor into main 2026-02-03 01:08:46 -05:00
75 changed files with 1247 additions and 2012 deletions

7
.gitignore vendored
View File

@ -1 +1,8 @@
shepich resume.pdf shepich resume.pdf
**/.venv
**/.env
tmp
build
dist
**/__pycache__
custom.yaml

View File

@ -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/

41
config.yaml Normal file
View File

@ -0,0 +1,41 @@
author: Jim Shepich III
templates_folder: ./site/templates
sites:
main:
title: Jimlab
base_url: http://localhost:8000
web_root: ./dist
build_cache: ./site
assets:
- /assets
articles:
- ./pages/*.md
template_selections:
article: templates.components.simple_article
resume:
title: Resume
base_url: http://localhost:8000
web_root: ./dist
git_repo:
url: ssh://gitea/jim/resume.git
build_cache: ./build/resume
assets:
- 'shepich_resume.pdf'
dogma_jimfinium:
title: Dogma Jimfinium
description: May it bolster the skills of all who read it.
base_url: http://localhost:8000/dogma-jimfinium
git_repo:
url: ssh://gitea/jim/dogma-jimfinium.git
branch: pub
build_cache: ./build/dogma-jimfinium
web_root: ./dist/dogma-jimfinium
assets:
- assets
articles:
- '*.md'
addons:
- rss

File diff suppressed because it is too large Load Diff

View File

@ -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
}
}

View File

@ -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"
}
]

View File

@ -1,47 +0,0 @@
<!DOCTYPE html>
<html>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<head>
<script src="scripts/vendor/jquery-3.6.0.min.js"></script>
<script src="https://kit.fontawesome.com/09beb7ae4a.js" crossorigin="anonymous"></script>
<link rel="stylesheet" type="text/css" href="styles/reset.css">
<link rel="stylesheet" type="text/css" href="styles/common.css">
<link rel="stylesheet" type="text/css" href="styles/layout.css">
<link rel="stylesheet" type="text/css" href="styles/theme.css">
<?php include 'scripts/query_handler.php';?>
<title><?php
echo $page->name." | Jim Shepich";
?></title>
</head>
<body>
<header id="main-header" class="no-highlight">
<span class="silver-text">Jim Shepich III</span>
</header>
<nav id="main-navbar" class="no-highlight">
<?php include 'scripts/nav.php';?>
</nav>
<main>
<?php
if(isset($page->link)){
echo "<script>window.location.href = '".$page->link."'</script>";
//If the directory assigns the page to an external link, redirect to that location.
}
if(isset($page->iframe)){
echo "<iframe id='content' src='pages/".$page->file."'></iframe>";
//If the directory says to embed the page in an iframe, then do that.
} else {
echo file_get_contents(__DIR__ ."/pages/".$page->file);
//Otherwise, just write the HTML of the page in the content area.
}
?>
</main>
<footer>
<div id="socials" class="silver-text"><?php include 'scripts/footer.php';?></div>
</footer>
</body>
<script src="scripts/resize.js"></script>
</html>

View File

@ -1,5 +0,0 @@
<?php
phpinfo();
?>

78
jimsite/__init__.py Normal file
View File

@ -0,0 +1,78 @@
import os
import yaml
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
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
def build_site(site: SiteConfig, templates: DotMap):
'''The pipeline for building a site defined in config.yaml.'''
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 build(config_filepath = './config.yaml'):
'''Pipeline for building the entire website, including all sites defined in config.yaml.'''
logger.info('Loading config.')
with open(config_filepath, '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)

15
jimsite/__main__.py Normal file
View File

@ -0,0 +1,15 @@
import argparse
from jimsite import build
if __name__ == '__main__':
parser = argparse.ArgumentParser(
prog='Jimsite',
description='A Python-based templating engine for building static websites from Markdown documents.',
epilog='Gonna be a hot one!'
)
parser.add_argument('-c', '--config', type=str, default='config.yaml', help='Specifies the path to a YAML config file; defaults to config.yaml in the CWD.')
args = parser.parse_args()
build(config_filepath = args.config)

132
jimsite/articles.py Normal file
View File

@ -0,0 +1,132 @@
import os
import glob
import yaml
import markdown
import pydantic
from typing import Optional, Union
from dotmap import DotMap
from datetime import date
from .common import filepath_or_string, SiteConfig
from .templating import format_html_template, TemplateSelections
class ArticleMetadata(pydantic.BaseModel):
'''A model for the YAML frontmatter included with Markdown articles.'''
title: str
date: date
published: bool
tags: list
author: Optional[str] = None
lastmod: Optional[date] = None
thumbnail: Optional[str] = None
description: Optional[str] = None
class Article(pydantic.BaseModel):
'''A model for a Markdown article, including its YAML frontmatter
metadata, as well as its path relative to the build cache root.'''
path: str
content: str
metadata: Optional[ArticleMetadata] = None
def load_markdown(md: str) -> tuple[Optional[ArticleMetadata], str]:
'''Loads a Markdown file into a (metadata: ArticleMetadata, content: str) pair.'''
# Load the file contents if a filepath is specified, and strip document delimiters ('---').
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[str, Article]:
'''Loads the sites articles into an index mapping the filename
to an Article object.'''
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.removeprefix('./').lstrip('/')}")
)
for article_full_path in expanded_article_list:
metadata, content = load_markdown(article_full_path)
# Skip unpublished articles.
if not metadata.published:
continue
# 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
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(tag_template, tag_name = t, **kwargs) for t in tags
]
def build_articles(
site: SiteConfig,
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 article in index.values():
article_html = format_html_template(
template_selections['article'],
article = article,
blog_tags = ' '.join(format_article_tags(
article.metadata.tags, template_selections['tag'], site = site
)),
templates = templates,
site = site
)
page_html = format_html_template(
template_selections['page'],
content = article_html,
templates = templates,
site = site
)
with open(f"{site.web_root.rstrip('/')}/{article.path}", 'w') as f:
f.write(page_html)

54
jimsite/assets.py Normal file
View File

@ -0,0 +1,54 @@
import os
import glob
import shutil
from .common import run, GitRepo, SiteConfig
def pull_git_repo(repo: GitRepo, build_cache: str) -> None:
'''Pulls/clones a repo into the build cache directory.'''
# If a repo exists in the build cache, pull to it.
if os.path.exists(f'{build_cache}/.git'):
# If a branch is specified, check out to it.
if repo.branch is not None:
run(f"git checkout {repo.branch}")
run(f"git -C {build_cache} pull {repo.remote}{' '+repo.branch if repo.branch else ''}")
# If the build cache is empty, clone the repo into it.
else:
run(f"git clone {repo.url}{(' -b '+repo.branch) if repo.branch else ''} -o {repo.remote} {build_cache}")
def copy_assets(site: SiteConfig) -> None:
'''Copies the list of site assets from the build cache to the web root.
The asset list can include globs (*). All paths are resolved relative to
the build cache root, and will be copied to an analogous location in the
web root.'''
# Expand any globbed expressions.
expanded_asset_list = []
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}/What the program does{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

136
jimsite/blog.py Normal file
View File

@ -0,0 +1,136 @@
import rfeed
from datetime import datetime
from .common import SiteConfig
from .articles import ArticleMetadata, Article, format_article_tags
from .templating import format_html_template, TemplateSelections
def build_tag_index(index: dict[str, Article], wildcard ='*') -> dict[str, list[Article]]:
'''Creates an inverted index mapping each article tag to a postings list of articles
with said tag.'''
tag_index = {}
# Index the articles in ascending order of original publication date.
for article in sorted(index.values(), key = lambda a: a.metadata.date):
# Add all articles to the wildcard tag's postings list.
if wildcard is not None:
tag_index[wildcard] = (tag_index.get(wildcard,[])) + [article]
# Add the article to each of its tags' posting lists.
for tag in article.metadata.tags:
tag_index[tag] = (tag_index.get(tag,[])) + [article]
return tag_index
def build_blog_archive(
site: SiteConfig,
index: dict[str, Article],
template_selections: TemplateSelections,
**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_list = '<ul>'
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(article.metadata.tags, template_selections['tag'], site = site)),
article = article,
site = site,
**kwargs
)
archive_html_list +='</ul>'
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;
}}}}
''']
# Add tag selector options in descending order of article count.
for tag, articles in sorted(tag_index.items(), key = lambda item : -len(item[1])):
tag_selector_options.append(format_html_template(
template_selections['tag_selector_option'],
tag_name = tag,
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;
}}}}
''')
# For Python 3.9-friendliness.
newline = '\n'
# Generate the archive article.
archive_html_article = format_html_template(
template_selections['archive_article'],
content = archive_html_list,
tag_selector_options = ' '.join(tag_selector_options),
tag_selector_css_rules = f'<style>{newline.join(tag_selector_css_rules)}</style>',
site = site,
**kwargs
)
# Interpolate the article into the overall page template.
archive_html_page = format_html_template(
template_selections['page'],
content = archive_html_article,
site = site,
**kwargs
)
with open(f"{site.web_root.rstrip('/')}/archive.html", 'w') as f:
f.write(archive_html_page)
def build_rss_feed(site: SiteConfig, index: dict[str, Article]):
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 = 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 article in index.values()
]
)
with open(f"{site.web_root.rstrip('/')}/rss.xml", 'w') as f:
f.write(feed.rss())

78
jimsite/common.py Normal file
View File

@ -0,0 +1,78 @@
import os
import inspect
import subprocess
import pydantic
from dotmap import DotMap
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 GitRepo(pydantic.BaseModel):
'''A nested model used in SiteConfig to represent a Git repo.'''
url: str
branch: Optional[str] = None
remote: Optional[str] = 'origin'
class SiteConfig(pydantic.BaseModel):
base_url: str
web_root: str
build_cache: str
title: str
description: Optional[str] = None
published: Optional[bool] = True
git_repo: Optional[GitRepo] = None
assets: Optional[list] = None
articles: Optional[list] = None
template_selections: Optional[dict] = {}
addons: Optional[list] = None
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

5
jimsite/requirements.txt Normal file
View File

@ -0,0 +1,5 @@
ipykernel
markdown
pyyaml
rfeed
dotmap

154
jimsite/templating.py Normal file
View File

@ -0,0 +1,154 @@
import os
import re
from dotmap import DotMap
from .common import filepath_or_string, GlobalVars, SiteConfig, dotmap_access
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)
# 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',
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:
# 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]))

View File

@ -1,8 +0,0 @@
<article>
<h1>Welcome!</h1>
<hr />
<p>Welcome to my little corner of the Internet! My name is Jim <span title="'&#643;&#603;p&#618;k">Shepich</span> (@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."</p>
<p>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) &mdash; 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 <i>new game plus</i>). And if some other kindred spirits find something here that helps them on their journey, that's a bonus! </p>
<article>

View File

@ -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<tables.length;i++){
var header_names = "";
var header_row = tables[i].children[0].children[0];
//Iterate over the thead -> tr -> th elements
for(k=0;k<header_row.children.length;k++){
header_names += header_row.children[k].innerHTML.trim();
}
if(checkheaders(header_names,["Ability","Score","Modifier"])==3){
abilities.DOM = tables[i];
} else if(checkheaders(header_names,["Ability","Skill","Bonus","EXP"])==4){
skills.DOM = tables[i];
} else if(checkheaders(header_names,["Stat","Value"])==2){
stats.DOM = tables[i];
} else if(checkheaders(header_names,["Feature","EXP","Description"])==3){
features.DOM = tables[i];
} else if(checkheaders(header_names,["Mastery","EXP","Requirements","Type","Description"])==5){
masteries.push({"DOM":tables[i]});
}
}
function checkheaders(string, keywords){
var output = 0;
for(j=0;j<keywords.length;j++){
if(string.indexOf(keywords[j])>-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<abilities.DOM.children[1].children.length;i++){
//Iterate over the rows of the abilities table
var row = abilities.DOM.children[1].children[i];
var ability = row.children[0].innerHTML.trim();
abilities[ability] = parseInt(row.children[2].innerHTML.trim());
}
var row_modifier = "";
for(i=0;i<skills.DOM.children[1].children.length;i++){
//Iterate over rows of the skills table.
var row = skills.DOM.children[1].children[i];
var bonus;
var exp;
if(row.children[0].innerHTML.trim() in abilities){
row_modifier = row.children[0].innerHTML.trim();
skill = row.children[1];
bonus = row.children[2];
exp = row.children[3];
} else {
skill = row.children[0];
bonus = row.children[1];
exp = row.children[2];
}
if(isNaN(parseInt(exp.innerHTML.trim()))){
exp.innerHTML = "0";
}
align(exp);
align(bonus);
EXP[skill.innerHTML.trim()] = parseInt(exp.innerHTML.trim());
var skill_bonus = Math.floor(parseInt(exp.innerHTML.trim())/2) + abilities[row_modifier];
bonus.innerHTML = " "+skill_bonus+" ";
skills[skill.innerHTML.trim()] = skill_bonus;
}
for(i=0;i<stats.DOM.children[1].children.length;i++){
var row = stats.DOM.children[1].children[i];
var stat = row.children[0];
var value = row.children[1];
var exp = row.children[2];
align(value);
align(exp);
stats[stat.innerHTML.trim()] = value;
var exp_value = exp.innerHTML.trim();
if(isNaN(exp_value)){
if(exp_value=="-"){
exp_value = 0;
} else if(exp_value.indexOf("d")>-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<features.DOM.children[1].children.length;i++){
var row = features.DOM.children[1].children[i];
var feature = row.children[0];
var exp_str = row.children[1].innerHTML.trim();
var exp = parseInt(exp_str.substring(0,exp_str.indexOf("/")));
features[feature.innerHTML.trim()] = parseInt(exp_str.substring(exp_str.indexOf("/")+1,exp_str.length));
EXP[feature.innerHTML.trim()] = exp;
}
for(j=0;j<masteries.length;j++){
var mastery = masteries[j];
for(i=0;i<mastery.DOM.children[1].children.length;i++){
var row = mastery.DOM.children[1].children[i];
var feature = row.children[0];
var exp_str = row.children[1].innerHTML.trim();
var exp = 0;
if(exp_str.indexOf("/")>-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];
}

View File

@ -1,11 +0,0 @@
<?php
$socials_string = file_get_contents(__DIR__ ."/../data/socials.json",true);
//__DIR__ constant specifies the path to the script
$socials = json_decode($socials_string);
foreach($socials as $s){
echo "<a class='social' href='" . $s->link . "'><span class='fa-brands fa-" . $s->icon . "' title='".$s->platform."'></span></a>";
}
echo "<br /><span class='copyright'>Copyright &copy; 2021-".date("Y")." Jim Shepich</span>";
?>

View File

@ -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);
});

View File

@ -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 <var> 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 = `<ul>${html}</ul>`;
break;
case "quotes":
html += "<hr />";
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) ? `<ol>${html}</ol>` : `<ul>${html}</ul>`;
//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")) ? `<p>${section.description}</p>` : "";
//Sections can have their own descriptions.
var title = `<h${level}>${section.title}</h${level}>`;
//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 = `<details class="heading" ${open}><summary class="heading">${title}</summary>${description}${gen_list_html(section)}</details>`;
} else {
section_html = `${title}${description}${gen_list_html(section)}`;
}
//Sectioned
html += `<section id="${section.title}">${section_html}</section>`;
//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) ? `<ol>${html}</ol>` : `<ul>${html}</ul>`;
//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 = `<li title="${tooltip}"><a ${link}>${lists[item].title}</a></li>`;
//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 = "<p>";
if(item.hasOwnProperty("title")){
item_html += `<b>${item.title}</b><br />`;
//Add a title if the quote has one (e.g. "The Litany Against Fear").
}
item_html += `&ldquo;${item.quote}&rdquo;<br>`;
//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 += `&nbsp;&mdash; ${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 += `<a target='_blank' href='https://gatherer.wizards.com/pages/card/Details.aspx?multiverseid=${item.multiverseid}'>${item.card}</a> (Magic: the Gathering)`;
}
item_html += "</p><hr />";
//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 = `<span class="list-key"><a href="${item.link}" target="_blank">${item["k"]}</a></span>`;
} else {
item_html = `<span class="list-key">${item["k"]}</span>`;
}
item_html += ` &mdash; ${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 = `<li>${item_html}</li>`;
}
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}<br />${item.artist}<br />(${item.year})`;
break;
case "movie":
tooltip = `${item.title} (${item.year})`;
alt = tooltip;
img_src = item.poster;
text = `${item.title}<br />(${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 = `<img title="${tooltip}" alt="${tooltip}" src="${img_src[0]}" class="gallery exhibit-${subtype} card-front ${classes.img.join(" ")}" /><img title="${tooltip}" alt="${tooltip}" src="${img_src[1]}" class="gallery exhibit-${subtype} card-back ${classes.img.join(" ")}" />`
//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 = `<img title="${tooltip}" alt="${tooltip}" src="${img_src}" class="gallery exhibit-${subtype} ${classes.img.join(" ")}" />`;
//If the image variable is not already defined, then generate it.
}
//Gallery items must have an image.
if(item.hasOwnProperty("link")){
image = `<a href='${item.link}' target='_blank' class="${classes.a.join(" ")}">${image}</a>`;
//If there's a link associated with the exhibit, put it on the image.
}
item_html = (text=="") ? image : `${image}<br />${text}`;
//If there's no text, then there's no need for a line break.
item_html = `<div class="gallery exhibit-${subtype} ${classes.div.join(" ")}">${item_html}</div>`
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 = `<li><details ${open}><summary>${item.title}</summary>${gen_list_html(item)}</details></li>`;
} else {
item_html = `<li>${item.title}${gen_list_html(item)}</li>`;
}
break;
default:
if(["string","number"].includes(typeof item)){
item_html = `<li>${item}</li>`;
//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","<br />");
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 += "<br /><p><a id='list-return-link' href='index.php?page=lists&list=master'>Return to Master List &#8617;</a></p>";
//Add a return link to the bottom of the article.
}

View File

@ -1,44 +0,0 @@
<?php
function page_comparator($a,$b){
//This function is the sorting criterion for the later call of usort, which will sort the page objects
//from pages.json in increasing order of their "index" value.
return $a->index <=> $b->index;
//For some reason, PHP8 requires the spaceship operator (<=>)
//instead of a greater than symbol.
}
function gen_nav_element($page){
$iframe = false;
//By default, echo the contents of a file instead of embedding it in an iframe.
if(isset($page->file)){
$href = "index.php?page=".$page->query_value;
$target = "_self";
//If the page is associated with a file, then point the navibar href to the file's query value.
$iframe = (isset($page->iframe)) ? true : false;
//If the page has the iframe attribute, then keep track of that.
} elseif(isset($page->link)) {
$href = $page->link;
$target = "_blank";
//If instead the page is associated with an external link, then point the navibar href there and make it open in a new window.
}
echo "<a href='".$href."' target='".$target."'><div class='nav-tab'><span class='nav-text'>".$page->name."</span></div></a>";
}
$page_array = get_object_vars($pages);
//Convert $pages from object to an associative array of [key=>val, key=>val]
//where the keys are the names of the page objects from the JSON file and the
//vals are the page objects themselves.
usort($page_array,"page_comparator");
//Sort the pages in order of increasing "index" value.
foreach($page_array as $key=>$p){
if($p->index>-1){
gen_nav_element($p);
//Pages with indices less than 0 are hidden from the navibar.
}
}
?>

View File

@ -1,23 +0,0 @@
<?php
//__DIR__ points to scripts folder.
$pages = json_decode(file_get_contents(__DIR__ ."/../data/pages.json"));
//Load dictionary of site pages from pages.json file.
$query_page = (isset($_REQUEST['page']) && !empty($_REQUEST['page'])) ? $_GET['page'] : "home";
//Get value of "page" query key; default is "home".
$page = (isset($pages->$query_page)) ? $pages->$query_page : $pages->{404};
//If the query value ($query_page) is not in the dictionary ($pages), then load the 404 page instead.
echo "<var id='query-page'>".$query_page."</var>";
//Store the page id in a var tag in case we need to access it with JavaScript.
$list = (isset($_REQUEST['list']) && !empty($_REQUEST['list'])) ? $_GET['list'] : "master";
//Get the value of "list" from the query key; default is "master" (the master list).
echo "<var id='query-list'>".$list."</var>";
//Store the list id in a var tag so that we can easily figure out what list to generate with list.js.
if($query_page=="lists"){
$lists_json = json_decode(file_get_contents(__DIR__ ."/../data/lists.json"));
echo "<script id='lists-json'>var lists=".json_encode($lists_json)."</script>";
//Copy the data from lists.json and paste it into a script tag, saving it as a variable called `list` to be accessed by later JavaScript code (i.e. lists.js).
//I would have stored it in a var tag, but there was a weird bug with some of the <u> tags leaking out and making my title underlined, and this seemed to avoid that (and it's probably more efficient too).
}
?>

View File

@ -1 +0,0 @@
//$("#main-header").css({"font-size":$("#main-header").height()+"px"})

File diff suppressed because one or more lines are too long

View File

@ -8,6 +8,7 @@
/*https://www.schemecolor.com/light-silver-gradient.php)*/ /*https://www.schemecolor.com/light-silver-gradient.php)*/
--navy-blue:#091b75; --navy-blue:#091b75;
--azure:#4f67db; --azure:#4f67db;
--azure-tint-20: #6e87e5;
--charcoal:#333333; --charcoal:#333333;
font-size:120%; font-size:120%;
@ -22,47 +23,47 @@
@font-face { @font-face {
font-family: Beleren; font-family: Beleren;
src: url('fonts/Beleren-Bold.ttf'); src: url('/assets/fonts/Beleren-Bold.ttf');
} }
@font-face { @font-face {
font-family: Playbill; font-family: Playbill;
src: url('fonts/Playbill.ttf'); src: url('/assets/fonts/Playbill.ttf');
} }
@font-face { @font-face {
font-family: Moderna; font-family: Moderna;
src: url('fonts/MODERNA_.ttf'); src: url('/assets/fonts/MODERNA_.ttf');
} }
@font-face { @font-face {
font-family: Adventure; font-family: Adventure;
src: url('fonts/Adventure.ttf'); src: url('/assets/fonts/Adventure.ttf');
} }
@font-face { @font-face {
font-family: Oxygen; font-family: Oxygen;
src: url('fonts/OxygenMono-Regular.ttf'); src: url('/assets/fonts/OxygenMono-Regular.ttf');
} }
@font-face { @font-face {
font-family: Garamond; font-family: Garamond;
src: url('fonts/EBGaramond.ttf'); src: url('/assets/fonts/EBGaramond.ttf');
} }
@font-face { @font-face {
font-family: Fira; font-family: Fira;
src: url('fonts/FiraSans-Regular.ttf'); src: url('/assets/fonts/FiraSans-Regular.ttf');
} }
@font-face { @font-face {
font-family: StitchWarrior; font-family: StitchWarrior;
src: url('fonts/StitchWarrior demo.ttf'); src: url('/assets/fonts/StitchWarrior demo.ttf');
} }
@font-face { @font-face {
font-family: Floral; font-family: Floral;
src: url('fonts/FloralCapitals.ttf'); src: url('/assets/fonts/FloralCapitals.ttf');
} }
var{ var{

View File

@ -31,9 +31,12 @@ footer, header, hgroup, menu, nav, section {
body { body {
line-height: 1; line-height: 1;
} }
ol, ul { ul {
list-style: disc; list-style: disc;
} }
ol {
list-style-type: decimal;
}
blockquote, q { blockquote, q {
quotes: none; quotes: none;
} }

View File

@ -145,3 +145,63 @@ summary.heading{
-webkit-tap-highlight-color: rgba(0,0,0,0); -webkit-tap-highlight-color: rgba(0,0,0,0);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
input[name="tag-selector"]{
display: none;
}
.blog-tag{
font-size: 0.6em;
padding: 0.1em;
padding-left: 0.5em;
padding-right: 0.5em;
}
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;
}
.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;
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{
border-bottom: none;
border-color: var(--silver);
border-width: 0.1rem;
box-shadow: none;
}
img.rss-icon{
width: 1rem;
vertical-align: middle;
}
span img.rss-icon{
float: right;
}

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

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

After

Width:  |  Height:  |  Size: 4.5 KiB

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

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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();

10
site/pages/index.md Normal file
View File

@ -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 <span title="'&#643;&#603;p&#618;k">Shepich</span> (@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) &mdash; 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!

View File

@ -0,0 +1,12 @@
<article>
<h1 class="headline">{site.title} Archive</h1>
<hr />
<h2>Tag Inventory</h2>
{templates.components.blog_archive_tag_selector}
<br />
<h2>Post History</h2>
{content}
<br /><hr /><br />
<p>Last Updated: {globalvars.today}<span>{templates.components.rss_icon}</span></p>
</article>
<script src="/assets/js/blog_archive_query.js"></script>

View File

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

View File

@ -0,0 +1,4 @@
<form id="tag-selector">
{tag_selector_options}
</form>
{tag_selector_css_rules}

View File

@ -0,0 +1,3 @@
<label for="tag-selector-{tag_name}">{tag_name} ({number_with_tag})
<input type="radio" name="tag-selector" value="{tag_name}" id="tag-selector-{tag_name}">
</label>

View File

@ -0,0 +1,11 @@
<article>
<h1 class="headline">{article.metadata.title}</h1>
<p class="byline">
<address class="author">By <a rel="author" href="mailto:admin@jimlab.io">Jim Shepich III</a></address>
<br/>First published: <time pubdate datetime="{article.metadata.date}">{article.metadata.date}</time>
<br/>Last modified: <time pubdate datetime="{article.metadata.lastmod}">{article.metadata.lastmod}</time>
</p>
{article.content}
<br /><hr /><br />
<p>Tags: {blog_tags}<span>{templates.components.rss_icon}</span></p>
</article>

View File

@ -0,0 +1 @@
<a class='blog-tag' data='{tag_name}'' href='{site.base_url}/archive.html?tag={tag_name}'>{tag_name}</a>

View File

@ -0,0 +1,11 @@
<article>
<h1 class="headline">{site.title} Tag Reference</h1>
<p class="byline">
<address class="author">By <a rel="author" href="mailto:admin@jimlab.io">Jim Shepich III</a></address>
<br/>First published: <time pubdate datetime="{article.metadata.date}">{article.metadata.date}</time>
<br/>Last modified: <time pubdate datetime="{article.metadata.lastmod}">{article.metadata.lastmod}</time>
</p>
{article.content}
<br /><hr /><br />
<p>{blog_tags}</p>
</article>

View File

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

View File

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

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en-US">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{site.description}">
<head>
{templates.partials.default_css}
{templates.partials.header}
{templates.partials.nav}
<title>{site.title}</title>
<link rel="icon" type="image/svg" href="/assets/img/favicon.svg">
</head>
<body>
<main>
{content}
</main>
{templates.partials.footer}
</body>

View File

@ -0,0 +1,4 @@
<link rel="stylesheet" type="text/css" href="/assets/css/reset.css">
<link rel="stylesheet" type="text/css" href="/assets/css/common.css">
<link rel="stylesheet" type="text/css" href="/assets/css/layout.css">
<link rel="stylesheet" type="text/css" href="/assets/css/theme.css">

View File

@ -0,0 +1,3 @@
<footer>
<br /><span class='copyright'>Copyright &copy; 2021-{globalvars.today.year} Jim Shepich</span>
</footer>

View File

@ -0,0 +1,3 @@
<header id="main-header" class="no-highlight">
<span class="silver-text">Jimlab</span>
</header>

View File

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

222
testbench.ipynb Normal file
View File

@ -0,0 +1,222 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"id": "207d2510",
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"import re\n",
"import shutil\n",
"import markdown\n",
"import yaml\n",
"import subprocess\n",
"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 jimsite import *"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "68b107f1",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": 3,
"id": "8f435a12",
"metadata": {},
"outputs": [],
"source": [
"with open('config.yaml', 'r') as config_file:\n",
" config = yaml.safe_load(config_file.read())\n",
" \n",
"templates = map_templates(config['templates_folder'])\n",
"\n",
"sites = {k:SiteConfig(**v) for k,v in config['sites'].items()}"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e32458c7",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<rss version=\"2.0\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><channel><title>Dogma Jimfinium</title><link>http://localhost:8000/dogma-jimfinium/rss</link><description>Dogma Jimfinium</description><language>en-US</language><lastBuildDate>Thu, 29 Jan 2026 16:29:57 GMT</lastBuildDate><generator>rfeed v1.1.1</generator><docs>https://github.com/svpino/rfeed/blob/master/README.md</docs><item><title>Superlock</title><link>http://localhost:8000/dogma-jimfinium/superlock</link><author>Jim Shepich III</author><pubDate>Wed, 26 Nov 2025 00:00:00 GMT</pubDate><guid isPermaLink=\"true\">superlock</guid></item><item><title>Sustainable Living</title><link>http://localhost:8000/dogma-jimfinium/sustainable-living</link><author>Jim Shepich III</author><pubDate>Thu, 20 Nov 2025 00:00:00 GMT</pubDate><guid isPermaLink=\"true\">sustainable-living</guid></item><item><title>Stocking Up</title><link>http://localhost:8000/dogma-jimfinium/stocking-up</link><author>Jim Shepich III</author><pubDate>Wed, 19 Nov 2025 00:00:00 GMT</pubDate><guid isPermaLink=\"true\">stocking-up</guid></item><item><title>Set Up the Toys</title><link>http://localhost:8000/dogma-jimfinium/set-up-the-toys</link><author>Jim Shepich III</author><pubDate>Wed, 14 Jan 2026 00:00:00 GMT</pubDate><guid isPermaLink=\"true\">set-up-the-toys</guid></item><item><title>Do What You Love</title><link>http://localhost:8000/dogma-jimfinium/do-what-you-love</link><author>Jim Shepich III</author><pubDate>Tue, 10 Jun 2025 00:00:00 GMT</pubDate><guid isPermaLink=\"true\">do-what-you-love</guid></item><item><title>Self-Care is not Selfish</title><link>http://localhost:8000/dogma-jimfinium/self-care-is-not-selfish</link><author>Jim Shepich III</author><pubDate>Sun, 18 May 2025 00:00:00 GMT</pubDate><guid isPermaLink=\"true\">self-care-is-not-selfish</guid></item><item><title>Blowouts</title><link>http://localhost:8000/dogma-jimfinium/blowouts</link><author>Jim Shepich III</author><pubDate>Wed, 26 Nov 2025 00:00:00 GMT</pubDate><guid isPermaLink=\"true\">blowouts</guid></item><item><title>Vitamins &amp; Supplements</title><link>http://localhost:8000/dogma-jimfinium/vitamins</link><author>Jim Shepich III</author><pubDate>Sun, 18 May 2025 00:00:00 GMT</pubDate><guid isPermaLink=\"true\">vitamins</guid></item><item><title>Gear for New Parents</title><link>http://localhost:8000/dogma-jimfinium/gear-for-new-parents</link><author>Jim Shepich III</author><pubDate>Fri, 12 Jul 2024 00:00:00 GMT</pubDate><guid isPermaLink=\"true\">gear-for-new-parents</guid></item></channel></rss>\n"
]
}
],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "70408b85",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "7de0d84d",
"metadata": {},
"outputs": [],
"source": [
"\n",
"index = {}\n",
"\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.published:\n",
" continue\n",
"\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",
"\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_article_tags(metadata.tags)),\n",
" metadata = metadata\n",
" )\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, **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": "e3171afd",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": 4,
"id": "a28b95a6",
"metadata": {},
"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": {
"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
}