Compare commits

17 Commits

7 changed files with 230 additions and 215 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{article_language_code}"> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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" /> <link rel="stylesheet" href="/static/bulma.min.css" />
</head> </head>
<body> <body>
@@ -14,24 +14,59 @@
aria-label="main navigation" aria-label="main navigation"
> >
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item" href="{article_overview_url}"> <a class="navbar-item" href="#">
<strong>{article_language_flag} {article_title}</strong> <strong>Static Translated Site Generator</strong>
<time datetime="{article_datetime_iso}">{article_datetime}</time>
</a> </a>
</div> </div>
</nav> </nav>
<!-- Main Content -->
<section class="section"> <section class="section">
<div class="content"> {% if translations|length %}
{article_content} <div class="container content">
<div class="column is-half is-offset-one-quarter">
<h1>Further Reading</h1> <h1>Translations</h1>
</div>
<div class="row"> <div class="column is-half is-offset-one-quarter">
{article_children_cards} {% for t in translations %}
<div class="card mb-4" lang="{{t.language.code}}">
<a href="{{t.url}}" hreflang="{{t.language.code}}" class="card mb-4" style="color: inherit; text-decoration: none;">
<div class="card-content">
<p class="title">{{t.language.flag}} {{t.title}} </p>
<p class="content">
{{t.preview}}
<br />
<time datetime="{{iso_date}}">{{date}}</time>
</p>
</div>
</a>
</div>
{% endfor %}
</div> </div>
</div> </div>
{% endif %}
{% if children|length %}
<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">
{% 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>
<p class="content">
<time datetime="{{iso_date}}">{{date}}</time>
</p>
</div>
</a>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</section> </section>
<!-- Footer --> <!-- Footer -->
@@ -41,4 +76,23 @@
</div> </div>
</footer> </footer>
</body> </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> </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

@@ -8,7 +8,8 @@ from typing import Optional, Union, Dict, Generator, List, DefaultDict, Any
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from collections import defaultdict from collections import defaultdict
import toml import toml
import datetime from datetime import datetime
import jinja2
from . import config from . import config
@@ -30,6 +31,44 @@ def get_first_header_content(content, fallback: str = ""):
return fallback return fallback
def shorten_text_and_clean(html_string, max_length=config.formatting.article_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 stem_to_language_code(stem: str) -> str: def stem_to_language_code(stem: str) -> str:
language_code = stem.lower().replace("-", "_") language_code = stem.lower().replace("-", "_")
@@ -44,6 +83,24 @@ def stem_to_language_code(stem: str) -> str:
exit(1) exit(1)
class TemplateDict(dict):
def __init__(self, folder: Path):
self.folder = folder
super().__init__()
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): class LanguageDict(dict):
def __missing__(self, key: str): def __missing__(self, key: str):
if key not in config.languages: if key not in config.languages:
@@ -61,15 +118,6 @@ class LanguageDict(dict):
LANGUAGES = LanguageDict() LANGUAGES = LanguageDict()
class Template:
def __init__(self, folder: Path):
self.folder = folder
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()
class ArticleTranslation: class ArticleTranslation:
def __init__(self, file: Path, article: Article): def __init__(self, file: Path, article: Article):
@@ -85,6 +133,7 @@ class ArticleTranslation:
self.dist_path = Path(config.setup.dist_directory, *self.location_in_tree) self.dist_path = Path(config.setup.dist_directory, *self.location_in_tree)
self.priority = LANGUAGES[self.language_code]["priority"] self.priority = LANGUAGES[self.language_code]["priority"]
self.real_language_code = LANGUAGES[self.language_code]["code"]
# TODO remove # TODO remove
self.article_content = self.file.read_text() self.article_content = self.file.read_text()
@@ -96,55 +145,34 @@ class ArticleTranslation:
self.title = get_first_header_content(self.article_content, fallback="") self.title = get_first_header_content(self.article_content, fallback="")
def __init_context__(self): def __init_context__(self):
self.context["meta"] = self.article.context_meta self.context["meta"] = self.article.context_shared
self.context["url"] = self.url
self.context["language"] = LANGUAGES[self.language_code] 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)
self.context["title"] = get_first_header_content(html_content, fallback=LANGUAGES[self.language_code]["native_name"])
self.context["content"] = html_content
self.context["preview"] = shorten_text_and_clean(html_string=html_content)
def build(self): def build(self):
self.dist_path.mkdir(parents=True, exist_ok=True) self.dist_path.mkdir(parents=True, exist_ok=True)
with Path(self.dist_path, "index.html").open("w") as f: with Path(self.dist_path, "index.html").open("w") as f:
f.write(self.get_article()) f.write(TEMPLATE["article_translation"].render(self.context))
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.url,
"article_slug": self.article.slug,
"article_title": self.title,
"article_datetime": self.article.article_written.strftime(config.formatting.datetime_format),
"article_datetime_iso": self.article.article_written.isoformat(),
}
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.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())
class Article: class Article:
def __init__(self, directory: Path, location_in_tree: Optional[List[str]] = None, is_root: bool = False): def __init__(self, directory: Path, location_in_tree: Optional[List[str]] = None, is_root: bool = False, parent: Optional[Article] = None):
self.directory = directory self.directory = directory
self.context: Dict[str, Any] = {} self.context: Dict[str, Any] = {}
self.context_meta = self.context["meta"] = {} self.context_shared: Dict[str, Any] = {}
if parent is not None:
self.context["parent"] = parent.context_shared
# initializing the config values of the article # initializing the config values of the article
config_file = self.directory / "index.toml" config_file = self.directory / "index.toml"
@@ -163,13 +191,9 @@ class Article:
self.url = "/" + "/".join(self.location_in_tree) self.url = "/" + "/".join(self.location_in_tree)
self.dist_path = Path(config.setup.dist_directory, *self.location_in_tree) self.dist_path = Path(config.setup.dist_directory, *self.location_in_tree)
# build the tree
self.article_written = datetime.datetime.fromisoformat(article_config["datetime"]) if "datetime" in article_config else datetime.datetime.fromtimestamp(self.directory.stat().st_mtime)
self.child_articles: List[Article] = [] self.child_articles: List[Article] = []
self.article_translations: List[ArticleTranslation] = [] self.article_translations_list: List[ArticleTranslation] = []
self.article_translations_map: Dict[str, ArticleTranslation] = {} self.article_translations_map: Dict[str, ArticleTranslation] = {}
for c in self.directory.iterdir(): for c in self.directory.iterdir():
@@ -178,71 +202,61 @@ class Article:
if c.is_file(): if c.is_file():
at = ArticleTranslation(c, self) at = ArticleTranslation(c, self)
self.article_translations.append(at) self.article_translations_list.append(at)
self.article_translations_map[at.language_code] = at self.article_translations_map[at.language_code] = at
elif c.is_dir(): elif c.is_dir():
self.child_articles.append(Article( self.child_articles.append(Article(
directory=c, directory=c,
location_in_tree=self.location_in_tree.copy(), location_in_tree=self.location_in_tree.copy(),
parent=self,
)) ))
# the tree is built self.article_translations_list.sort(key=lambda a: a.priority, reverse=True)
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.__init_context__()
logger.info("found %s at %s with the translations %s", self.slug, ".".join(list(self.location_in_tree)), ",".join(self.article_translations_map.keys())) 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): def __init_context__(self):
# builds the tree structure to the dist directory self.context_shared["url"] = self.url
self.dist_path.mkdir(parents=True, exist_ok=True) self.context_shared["slug"] = self.slug
with Path(self.dist_path, "index.html").open("w") as f:
f.write(self.get_overview())
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() at.build()
for ca in self.child_articles: for ac in self.child_articles:
ca.build() ac.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())
# GLOBALS # GLOBALS
logger = logging.getLogger("stsg.build") logger = logging.getLogger("stsg.build")
TEMPLATE = Template(Path(config.setup.source_directory, "templates"))
ARTICLE_LAKE: Dict[str, Article] = {} ARTICLE_LAKE: Dict[str, Article] = {}
ARTICLE_REFERENCE_VALUES: DefaultDict[str, Dict[str, str]] = defaultdict(dict) ARTICLE_REFERENCE_VALUES: DefaultDict[str, Dict[str, str]] = defaultdict(dict)
@@ -252,14 +266,15 @@ def build():
logger.info("copying static folder...") logger.info("copying static folder...")
shutil.copytree(Path(config.setup.source_directory, "static"), Path(config.setup.dist_directory, "static"), dirs_exist_ok=True) shutil.copytree(Path(config.setup.source_directory, "static"), Path(config.setup.dist_directory, "static"), dirs_exist_ok=True)
logger.info("reading page tree...") logger.info("building page tree...")
tree = Article(directory=Path(config.setup.source_directory, "articles"), is_root=True) tree = Article(directory=Path(config.setup.source_directory, "articles"), is_root=True)
logger.info("compiling tree context...")
tree.__init_context__()
# build article reverence values import json
for article_overview in ARTICLE_LAKE.values(): with Path("context.json").open("w") as f:
ARTICLE_REFERENCE_VALUES[""].update(article_overview.get_article_values()) json.dump(tree.context, f, indent=4)
for language_code, at in article_overview.article_translations_map.items():
ARTICLE_REFERENCE_VALUES[language_code].update(at.get_article_values())
logger.info("writing page tree...") logger.info("dumping page tree...")
tree.build() tree.build()