Compare commits

...

28 Commits

Author SHA1 Message Date
7d1ceded8d meow 2025-04-17 17:42:50 +02:00
b3e23a53d9 feat: added children to context 2025-04-17 14:39:14 +02:00
b2513f7caf improved child cards from overview 2025-04-17 14:22:48 +02:00
4743456bd8 feat: improved translation cards 2025-04-17 14:08:26 +02:00
6994662bb4 feat: shift the headers in the preview 2025-04-17 14:05:59 +02:00
db23ceac78 feat: removed unnecessary code 2025-04-17 13:54:37 +02:00
9b030b24f4 feat: added parent context 2025-04-16 16:42:21 +02:00
44b651cace feat: move meta values to article 2025-04-16 16:39:06 +02:00
eb2edc3710 feat: removed card templates 2025-04-16 16:17:57 +02:00
f63497090b Revert "feat: removed redundand templates"
This reverts commit 215142dee43e12c7e423ebb64bd38e5e413694d1.
2025-04-16 16:17:13 +02:00
215142dee4 feat: removed redundand templates 2025-04-16 16:09:18 +02:00
e391f64cc5 feat: removed redundand methods 2025-04-16 16:08:25 +02:00
4216d153fd feat: removed redundand methods 2025-04-16 16:08:11 +02:00
9241ecfee8 feat: added dynamic children cards in templates 2025-04-16 16:07:20 +02:00
4bc7a6d980 feat: added article translation templating 2025-04-16 16:01:38 +02:00
b50833f19f feat: dynamic translation cards 2025-04-16 15:57:17 +02:00
f5cccc30dc feat: dynamic translation cards 2025-04-16 15:56:34 +02:00
8a8f02c5bd feat: implemented code to use with jinja 2025-04-16 15:45:06 +02:00
292d71edc5 feat: added proper content 2025-04-16 15:26:43 +02:00
c9fb8fda93 feat: building recursive context structures 2025-04-16 14:26:50 +02:00
1fae03e70b feat: properly recursively building context 2025-04-16 14:20:51 +02:00
6ed94db8cf feat: added translations 2025-04-16 14:17:45 +02:00
02a7c29dba feat: add flat context to article 2025-04-16 14:05:16 +02:00
cf8e2955c2 feat: initializing location 2025-04-16 13:23:30 +02:00
7aa06b7f83 feat: added meta context 2025-04-16 13:15:27 +02:00
f7a690405b feat: add language context 2025-04-16 13:09:46 +02:00
93ea11cd0e feat: removed weird article cards 2025-04-16 12:30:54 +02:00
263281df3c feat: renamed ArticleOverview to article 2025-04-16 12:18:44 +02:00
9 changed files with 317 additions and 265 deletions

3
.gitignore vendored
View File

@ -173,4 +173,5 @@ cython_debug/
# PyPI configuration file
.pypirc
dist
dist
context.json

View File

@ -1,37 +1,85 @@
<!DOCTYPE html>
<html lang="{article_language_code}">
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{article_language_flag} {article_title}</title>
<title>{{slug}}</title>
<link rel="stylesheet" href="/static/bulma.min.css" />
</head>
<body>
<!-- Header (Navbar) -->
<nav
class="navbar is-primary"
role="navigation"
aria-label="main navigation"
>
<div class="navbar-brand">
<a class="navbar-item" href="{article_overview_url}">
<strong>{article_language_flag} {article_title}</strong>
<time datetime="{article_datetime_iso}">{article_datetime}</time>
<a class="navbar-item" href="#">
<strong>Static Translated Site Generator</strong>
</a>
</div>
</nav>
<!-- Main Content -->
<section class="section">
<div class="content">
{article_content}
{% if translations|length %}
<div class="container content">
<div class="content">
<h1>Translations</h1>
</div>
<div class="columns is-multiline">
{% for t in translations %}
<div class="column is-half">
<div class="card mb-4" lang="{{t.language.code}}" style="height: 100%;">
<a href="{{t.url}}" hreflang="{{t.language.code}}" class="card mb-4" style="color: inherit; text-decoration: none;">
<div class="card-header">
<div class="card-header-title">{{t.title}} </div>
<div class="card-header-icon">{{t.language.flag}}</div>
</div>
<div class="card-content">
<hr />
<p class="content">
{{t.preview}}
</p>
</div>
<h1>Further Reading</h1>
<div class="row">
{article_children_cards}
<div class="card-footer">
<time datetime="{{iso_date}}">{{date}}</time>
</div>
</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if children|length %}
<div class="container content">
<div class="column is-half is-offset-one-quarter">
<h1>Further reading</h1>
</div>
<div class="column is-half is-offset-one-quarter">
{% for c in children %}
<div class="card mb-4" >
<a href="{{c.url}}" class="card mb-4" style="color: inherit; text-decoration: none;">
<div class="card-content">
<p class="title">{{c.slug}} </p>
<hr />
<p class="content is-flex is-flex-direction-column" style="gap: 10px;">
{% for ct in c.translations %}
<a href="{{ct.url}}" hreflang="{{ct.language.code}}">{{ct.language.flag}}: {{ct.title}}</a>
{% endfor %}
</p>
<hr />
<time datetime="{{iso_date}}">{{date}}</time>
</div>
</a>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</section>
<!-- Footer -->
@ -41,4 +89,23 @@
</div>
</footer>
</body>
<script>
document.addEventListener("DOMContentLoaded", function () {
const userLang = navigator.language || navigator.userLanguage;
// Normalize and check if the language is not English or German
if (!["en", "de", "de-DE"].includes(userLang)) {
// Try to find a matching card by language attribute
const cardToMove =
document.querySelector(`.card[lang^="${userLang.replace("_", "-").toLowerCase()}"]`) ||
document.querySelector(`.card[lang^="${userLang.split("-")[0]}"]`);
if (cardToMove) {
const container = cardToMove.parentNode;
container.insertBefore(cardToMove, container.firstChild);
}
}
});
</script>
</html>

View File

@ -1,12 +0,0 @@
<div class="card mb-4">
<a href="{article_url}" class="card mb-4" style="color: inherit; text-decoration: none;">
<div class="card-content">
<p class="title">{article_title} </p>
<p class="content">
{article_preview}
<br />
<time datetime="{article_datetime_iso}">{article_datetime}</time>
</p>
</div>
</a>
</div>

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="{article_language_code}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{language.flag}} {{title}}</title>
<link rel="stylesheet" href="/static/bulma.min.css" />
</head>
<body>
<!-- Header (Navbar) -->
<nav
class="navbar is-primary"
role="navigation"
aria-label="main navigation"
>
<div class="navbar-brand">
<a class="navbar-item" href="{{article_url}}">
<strong>{{language.flag}} {{title}}</strong>
<time datetime="{{meta.iso_date}}">{{meta.date}}</time>
</a>
</div>
</nav>
<!-- Main Content -->
<section class="section">
<div class="content">
{{content}}
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="content has-text-centered">
<p><strong>STSG</strong> by Hazel. &copy; 2025</p>
</div>
</footer>
</body>
</html>

View File

@ -1,69 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{article_title}</title>
<link rel="stylesheet" href="/static/bulma.min.css" />
</head>
<body>
<!-- Header (Navbar) -->
<nav
class="navbar is-primary"
role="navigation"
aria-label="main navigation"
>
<div class="navbar-brand">
<a class="navbar-item" href="#">
<strong>Static Translated Site Generator</strong>
</a>
</div>
</nav>
<section class="section">
<div class="container content">
<div class="column is-half is-offset-one-quarter">
<h1>Translations</h1>
</div>
<div class="column is-half is-offset-one-quarter">
{article_translation_cards}
</div>
</div>
<div class="container content">
<div class="column is-half is-offset-one-quarter">
<h1>Child Articles</h1>
</div>
<div class="column is-half is-offset-one-quarter">
{article_children_cards}
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="content has-text-centered">
<p><strong>STSG</strong> by Hazel. &copy; 2025</p>
</div>
</footer>
</body>
<script>
document.addEventListener("DOMContentLoaded", function () {
const userLang = navigator.language || navigator.userLanguage;
// Normalize and check if the language is not English or German
if (!["en", "de", "de-DE"].includes(userLang)) {
// Try to find a matching card by language attribute
const cardToMove =
document.querySelector(`.card[lang^="${userLang.replace("_", "-").toLowerCase()}"]`) ||
document.querySelector(`.card[lang^="${userLang.split("-")[0]}"]`);
if (cardToMove) {
const container = cardToMove.parentNode;
container.insertBefore(cardToMove, container.firstChild);
}
}
});
</script>
</html>

View File

@ -1,12 +0,0 @@
<div class="card mb-4" lang="{article_language_code}">
<a href="{article_url}" hreflang="{article_language_code}" class="card mb-4" style="color: inherit; text-decoration: none;">
<div class="card-content">
<p class="title">{article_language_flag} {article_title} </p>
<p class="content">
{article_preview}
<br />
<time datetime="{article_datetime_iso}">{article_datetime}</time>
</p>
</div>
</a>
</div>

View File

@ -3,7 +3,8 @@ source_directory = "src"
dist_directory = "dist"
[formatting]
article_preview_length = 400
preview_length = 400
preview_header_shift = 2
datetime_format = "%d. %B %Y"
default_language = "de"

View File

@ -4,9 +4,10 @@ class config:
dist_directory = "dist"
class formatting:
article_preview_length = 200
datetime_format = "%d. %B %Y"
fallback_language = "en"
preview_length = 400
preview_header_shift = 2
languages = {
"af": {

View File

@ -4,22 +4,16 @@ import shutil
from pathlib import Path
import os
import markdown
from typing import Optional, Union, Dict, Generator, List, DefaultDict
from typing import Optional, Union, Dict, Generator, List, DefaultDict, Any
from bs4 import BeautifulSoup
from collections import defaultdict
import toml
import datetime
from datetime import datetime
import jinja2
from . import config
def replace_values(template: str, values: Dict[str, str]) -> str:
for key, value in values.items():
template = template.replace("{" + key + "}", value)
return template
def get_first_header_content(content, fallback: str = ""):
soup = BeautifulSoup(content, 'html.parser')
for level in range(1, 7):
@ -30,116 +24,172 @@ def get_first_header_content(content, fallback: str = ""):
return fallback
class Template:
def shorten_text_and_clean(html_string, max_length=config.formatting.preview_length):
soup = BeautifulSoup(html_string, 'html.parser')
# Keep track of total characters added
total_chars = 0
finished = False
# Function to recursively trim and clean text
def process_element(element):
nonlocal total_chars, finished
for child in list(element.children):
if finished:
child.extract()
continue
if isinstance(child, str):
remaining = max_length - total_chars
if remaining <= 0:
child.extract()
finished = True
elif len(child) > remaining:
child.replace_with(child[:remaining] + '...')
total_chars = max_length
finished = True
else:
total_chars += len(child)
elif hasattr(child, 'children'):
process_element(child)
# Remove empty tags
if not child.text.strip():
child.decompose()
process_element(soup)
return str(soup)
def shift_headings(html_string, header_shift=config.formatting.preview_header_shift):
soup = BeautifulSoup(html_string, 'html.parser')
for level in range(6, 0, -1): # Start from h6 to h1 to avoid overwriting
old_tag = f'h{level}'
for tag in soup.find_all(old_tag):
new_level = min(level + header_shift, 6) # Cap at h6
new_tag = f'h{new_level}'
tag.name = new_tag
return str(soup)
def get_preview_text(html_string: str):
return shift_headings(shorten_text_and_clean(html_string))
def stem_to_language_code(stem: str) -> str:
language_code = stem.lower().replace("-", "_")
if language_code in config.languages:
return language_code
language_code = language_code.split("_")[0]
if language_code in config.languages:
return language_code
logger.error("Didn't recognize %s as a valid language code, add it to the config, or fix your structure.", stem)
exit(1)
class TemplateDict(dict):
def __init__(self, folder: Path):
self.folder = folder
super().__init__()
self.article: str = (self.folder / "article.html").read_text()
self.overview: str = (self.folder / "overview.html").read_text()
self.translation_card: str = (self.folder / "translation_card.html").read_text()
self.article_card: str = (self.folder / "article_card.html").read_text()
def __missing__(self, name: str) -> jinja2.Template:
f = self.folder / (name + ".html")
if not f.exists():
logger.error("no template with the name %s exists", name)
exit(1)
t = jinja2.Template(f.read_text())
self[name] = t
return t
TEMPLATE: Dict[str, jinja2.Template] = TemplateDict(Path(config.setup.source_directory, "templates"))
class LanguageDict(dict):
def __missing__(self, key: str):
if key not in config.languages:
raise KeyError(key)
lang_dict = config.languages[key]
lang_dict["priority"] = lang_dict.get("priority", 0)
elements = key.split("_")
if len(elements) > 1:
elements[-1] = elements[-1].upper()
lang_dict["code"] = "-".join(elements)
return lang_dict
LANGUAGES = LanguageDict()
class ArticleTranslation:
def __init__(self, file: Path, article_overview: ArticleOverview):
def __init__(self, file: Path, article: Article):
self.file = file
self.article_overview = article_overview
self.language_code = self.file.stem
self.article = article
self.article_content = self.file.read_text()
self.article_preview = self.article_content[:config.formatting.article_preview_length] + "..."
if self.file.suffix == ".md":
self.article_content = markdown.markdown(self.article_content)
self.article_preview = markdown.markdown(self.article_preview)
self.context: Dict[str, Any] = {}
self.location_in_tree = [self.language_code, *self.article_overview.location_in_tree]
# initializing the location of the article translation
self.language_code = stem_to_language_code(self.file.stem)
self.location_in_tree = [self.language_code, *self.article.location_in_tree]
self.url = "/" + "/".join(self.location_in_tree)
self.dist_path = Path(config.setup.dist_directory, *self.location_in_tree)
_language_info = config.languages[config.formatting.fallback_language]
parsed_language_code = self.language_code.lower().replace("-", "_")
if parsed_language_code in config.languages:
_language_info = config.languages[parsed_language_code]
elif parsed_language_code.split("_")[0] in config.languages:
_language_info = config.languages[parsed_language_code.split("_")[0]]
self.language_name: str = _language_info["native_name"]
self.language_flag: str = _language_info["flag"]
self.priority: int = _language_info.get("priority", 0)
self.title = get_first_header_content(self.article_content, fallback=self.language_name)
self.priority = LANGUAGES[self.language_code]["priority"]
self.real_language_code = LANGUAGES[self.language_code]["code"]
self.article_cards = ""
def __init_context__(self):
self.context["meta"] = self.article.context_shared
self.context["url"] = self.url
self.context["language"] = LANGUAGES[self.language_code]
self.context["article_url"] = self.article.url
html_content = self.file.read_text()
if self.file.suffix == ".md":
html_content = markdown.markdown(html_content)
def post_init(self):
"""
the initializing that takes place after the creation of the tree
"""
article_card_strings = []
for child in self.article_overview.child_articles:
if self.language_code in child.article_translations_map:
article_card_strings.append(child.article_translations_map[self.language_code].get_article_card())
self.article_cards = "\n".join(article_card_strings)
self.context["title"] = get_first_header_content(html_content, fallback=LANGUAGES[self.language_code]["native_name"])
self.context["content"] = html_content
self.context["preview"] = get_preview_text(html_string=html_content)
# get children
self.context["children"] = [c.article_translations_map[self.language_code].context for c in self.article.child_articles if self.language_code in c.article_translations_map]
def build(self):
self.dist_path.mkdir(parents=True, exist_ok=True)
with Path(self.dist_path, "index.html").open("w") as f:
f.write(self.get_article())
def _get_values(self, return_foreign_articles: bool = True) -> Dict[str, str]:
r = {
"article_content": self.article_content,
"article_preview": self.article_preview,
"article_url": self.url,
"article_overview_url": self.article_overview.url,
"article_slug": self.article_overview.slug,
"article_title": self.title,
"article_datetime": self.article_overview.article_written.strftime(config.formatting.datetime_format),
"article_datetime_iso": self.article_overview.article_written.isoformat(),
"article_language_name": self.language_name,
"article_language_code": self.language_code,
"article_language_flag": self.language_flag,
"article_children_cards": self.article_cards,
}
if return_foreign_articles:
r.update(ARTICLE_REFERENCE_VALUES[self.language_code])
return r
def get_article_values(self) -> Dict[str, str]:
res = {}
for key, value in self._get_values(return_foreign_articles=False).items():
res[key + ":" + self.article_overview.slug] = value
return res
def get_article(self) -> str:
return replace_values(TEMPLATE.article, self._get_values())
def get_translation_card(self) -> str:
return replace_values(TEMPLATE.translation_card, self._get_values())
def get_article_card(self) -> str:
return replace_values(TEMPLATE.article_card, self._get_values())
f.write(TEMPLATE["article_translation"].render(self.context))
class ArticleOverview:
def __init__(self, directory: Path, location_in_tree: Optional[List[str]] = None, is_root: bool = False):
class Article:
def __init__(self, directory: Path, location_in_tree: Optional[List[str]] = None, is_root: bool = False, parent: Optional[Article] = None):
self.directory = directory
article_config = {}
if (self.directory / "index.toml").exists():
article_config = toml.load(self.directory / "index.toml")
self.context: Dict[str, Any] = {}
self.context_shared: Dict[str, Any] = {}
if parent is not None:
self.context["parent"] = parent.context_shared
self.slug = article_config.get("name", self.directory.name)
# initializing the config values of the article
config_file = self.directory / "index.toml"
self.config = toml.load(config_file) if config_file.exists() else {}
self.article_written = datetime.datetime.fromisoformat(article_config["datetime"]) if "datetime" in article_config else datetime.datetime.fromtimestamp(self.directory.stat().st_mtime)
# initializing the location and slug of the article
self.slug = self.config.get("name", self.directory.name)
if self.slug in ARTICLE_LAKE:
logger.error("two articles have the same name at %s and %r", ARTICLE_LAKE[self.slug].directory, self.directory)
exit(1)
ARTICLE_LAKE[self.slug] = self
self.location_in_tree: List[str] = location_in_tree or []
if not is_root:
@ -147,13 +197,9 @@ class ArticleOverview:
self.url = "/" + "/".join(self.location_in_tree)
self.dist_path = Path(config.setup.dist_directory, *self.location_in_tree)
if self.slug in ARTICLE_LAKE:
logger.error("two articles have the same name at %s and %r", ARTICLE_LAKE[self.slug].directory, self.directory)
exit(1)
ARTICLE_LAKE[self.slug] = self
self.child_articles: List[ArticleOverview] = []
self.article_translations: List[ArticleTranslation] = []
# build the tree
self.child_articles: List[Article] = []
self.article_translations_list: List[ArticleTranslation] = []
self.article_translations_map: Dict[str, ArticleTranslation] = {}
for c in self.directory.iterdir():
@ -162,72 +208,62 @@ class ArticleOverview:
if c.is_file():
at = ArticleTranslation(c, self)
self.article_translations.append(at)
self.article_translations_list.append(at)
self.article_translations_map[at.language_code] = at
elif c.is_dir():
self.child_articles.append(ArticleOverview(
self.child_articles.append(Article(
directory=c,
location_in_tree=self.location_in_tree.copy(),
parent=self,
))
# the tree is built
self.article_translations.sort(key=lambda a: a.priority, reverse=True)
self.translation_cards = "\n".join(a.get_translation_card() for a in self.article_translations)
self.article_cards = "\n".join(c.get_article_card() for c in self.child_articles)
for at in self.article_translations:
at.post_init()
self.article_translations_list.sort(key=lambda a: a.priority, reverse=True)
logger.info("found %s at %s with the translations %s", self.slug, ".".join(list(self.location_in_tree)), ",".join(self.article_translations_map.keys()))
def build(self):
# builds the tree structure to the dist directory
self.dist_path.mkdir(parents=True, exist_ok=True)
with Path(self.dist_path, "index.html").open("w") as f:
f.write(self.get_overview())
def __init_context__(self):
self.context_shared["url"] = self.url
self.context_shared["slug"] = self.slug
for at in self.article_translations:
modified_at = datetime.fromisoformat(self.config["datetime"]) if "datetime" in self.config else datetime.fromtimestamp(self.directory.stat().st_mtime)
self.context_shared["date"] = modified_at.strftime(config.formatting.datetime_format)
self.context_shared["iso_date"] = modified_at.isoformat()
self.context.update(self.context_shared)
# recursive context structures
translation_list = self.context["translations"] = []
child_article_list = self.context["children"] = []
for article_translation in self.article_translations_list:
self.context[article_translation.real_language_code] = article_translation.context
translation_list.append(article_translation.context)
for child_article in self.child_articles:
child_article_list.append(child_article.context)
# recursively build context
for at in self.article_translations_list:
at.__init_context__()
for a in self.child_articles:
a.__init_context__()
def build(self):
self.dist_path.mkdir(parents=True, exist_ok=True)
with Path(self.dist_path, "index.html").open("w") as f:
f.write(TEMPLATE["article"].render(self.context))
for at in self.article_translations_list:
at.build()
for ca in self.child_articles:
ca.build()
def _get_values(self, return_foreign_articles: bool = True) -> Dict[str, str]:
r = {
"article_url": self.url,
"article_title": self.slug,
"article_slug": self.slug,
"article_datetime": self.article_written.strftime(config.formatting.datetime_format),
"article_datetime_iso": self.article_written.isoformat(),
"article_overview_url": self.url,
"article_translation_cards": self.translation_cards,
"article_children_cards": self.article_cards,
}
if return_foreign_articles:
r.update(ARTICLE_REFERENCE_VALUES[""])
return r
def get_article_values(self) -> Dict[str, str]:
res = {}
for key, value in self._get_values(return_foreign_articles=False).items():
res[key + ":" + self.slug] = value
return res
def get_overview(self) -> str:
global TEMPLATE
return replace_values(TEMPLATE.overview, self._get_values())
def get_article_card(self) -> str:
return replace_values(TEMPLATE.article_card, self._get_values())
for ac in self.child_articles:
ac.build()
# GLOBALS
logger = logging.getLogger("stsg.build")
TEMPLATE = Template(Path(config.setup.source_directory, "templates"))
ARTICLE_LAKE: Dict[str, ArticleOverview] = {}
ARTICLE_LAKE: Dict[str, Article] = {}
ARTICLE_REFERENCE_VALUES: DefaultDict[str, Dict[str, str]] = defaultdict(dict)
def build():
@ -236,14 +272,15 @@ def build():
logger.info("copying static folder...")
shutil.copytree(Path(config.setup.source_directory, "static"), Path(config.setup.dist_directory, "static"), dirs_exist_ok=True)
logger.info("reading page tree...")
tree = ArticleOverview(directory=Path(config.setup.source_directory, "articles"), is_root=True)
logger.info("building page tree...")
tree = Article(directory=Path(config.setup.source_directory, "articles"), is_root=True)
logger.info("compiling tree context...")
tree.__init_context__()
# build article reverence values
for article_overview in ARTICLE_LAKE.values():
ARTICLE_REFERENCE_VALUES[""].update(article_overview.get_article_values())
for language_code, at in article_overview.article_translations_map.items():
ARTICLE_REFERENCE_VALUES[language_code].update(at.get_article_values())
import json
with Path("context.json").open("w") as f:
json.dump(tree.context, f, indent=4)
logger.info("writing page tree...")
logger.info("dumping page tree...")
tree.build()