219 lines
6.4 KiB
Python
219 lines
6.4 KiB
Python
import dataclasses
|
|
import os
|
|
import re
|
|
import typing as t
|
|
|
|
import yaml
|
|
|
|
|
|
with open("index.html") as file:
|
|
template = file.read()
|
|
|
|
with open("res/arrow-path.txt") as file:
|
|
arrow_path = file.read().strip()
|
|
|
|
with open("res/i18n.yml") as file:
|
|
i18n = yaml.safe_load(file)
|
|
|
|
|
|
def htmlify(text: str) -> str:
|
|
return (
|
|
text.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace("\n", "<br />")
|
|
)
|
|
|
|
|
|
# Awful awful awful code, but it does the job.
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class BBLiteral:
|
|
text: str
|
|
|
|
def to_html(self) -> str:
|
|
return htmlify(self.text)
|
|
|
|
|
|
TokenRaw = t.Tuple[t.Literal["start", "end", "text"], str]
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class BBTag:
|
|
name: str
|
|
children: list["BBLiteral | BBTag"]
|
|
|
|
def to_html(self) -> str:
|
|
if self.name == "root":
|
|
return "".join(child.to_html() for child in self.children)
|
|
elif self.name == "b":
|
|
inner = "".join(child.to_html() for child in self.children)
|
|
return f"<strong>{inner}</strong>"
|
|
elif self.name == "i":
|
|
inner = "".join(child.to_html() for child in self.children)
|
|
return f"<em>{inner}</em>"
|
|
elif self.name == "a":
|
|
assert len(self.children) == 1 and isinstance(
|
|
self.children[0], BBLiteral
|
|
), "[a] tag must contain exactly one literal child"
|
|
href = "https://creativecommons.org/public-domain/cc0/"
|
|
inner = "".join(child.to_html() for child in self.children)
|
|
return f'<a href="{href}">{inner}</a>'
|
|
else:
|
|
raise ValueError(f"Unknown tag: {self.name}")
|
|
|
|
|
|
class BBParser:
|
|
source: str
|
|
current_tags: list[str]
|
|
pos: int
|
|
|
|
def __init__(self, source: str):
|
|
self.source = source
|
|
self.current_tags = []
|
|
self.pos = 0
|
|
|
|
def render(self) -> BBTag:
|
|
root = BBTag(name="root", children=[])
|
|
stack = [root]
|
|
|
|
for kind, value in self.parse():
|
|
if kind == "text":
|
|
stack[-1].children.append(BBLiteral(text=value))
|
|
elif kind == "start":
|
|
new_tag = BBTag(name=value, children=[])
|
|
stack[-1].children.append(new_tag)
|
|
stack.append(new_tag)
|
|
elif kind == "end":
|
|
assert stack.pop().name == value, "Mismatched closing tag"
|
|
|
|
assert len(stack) == 1, "Unclosed tags remain"
|
|
return root
|
|
|
|
def parse(
|
|
self,
|
|
) -> t.Generator[TokenRaw, None, None]:
|
|
while self.pos < len(self.source):
|
|
yield self.next_token()
|
|
|
|
def next_token(self) -> TokenRaw:
|
|
if self.source[self.pos] == "[":
|
|
end_tag_pos = self.source.find("]", self.pos)
|
|
if end_tag_pos == -1:
|
|
raise ValueError("Unclosed tag")
|
|
tag = self.source[self.pos + 1 : end_tag_pos]
|
|
if tag.startswith("/"):
|
|
kind = "end"
|
|
tag = tag[1:]
|
|
assert self.current_tags.pop() == tag, "Mismatched closing tag"
|
|
else:
|
|
kind = "start"
|
|
self.current_tags.append(tag)
|
|
assert (
|
|
len([i for i in self.current_tags if i == "a"]) <= 1
|
|
), "Nested [a] tags"
|
|
self.pos = end_tag_pos + 1
|
|
return (kind, tag)
|
|
else:
|
|
next_tag_pos = self.source.find("[", self.pos)
|
|
if next_tag_pos == -1:
|
|
next_tag_pos = len(self.source)
|
|
text = self.source[self.pos : next_tag_pos]
|
|
self.pos = next_tag_pos
|
|
return ("text", text)
|
|
|
|
|
|
def htmlify_bbcode(text: str) -> str:
|
|
parser = BBParser(text)
|
|
parsed = parser.render()
|
|
return parsed.to_html()
|
|
|
|
|
|
def format(template: str, values: dict[str, str]) -> str:
|
|
output = ""
|
|
i = 0
|
|
while i < len(template):
|
|
if template[i:].startswith("{{"):
|
|
end_pos = template.find("}}", i)
|
|
if end_pos == -1:
|
|
raise ValueError("Unclosed placeholder")
|
|
key = template[i + 2 : end_pos].strip()
|
|
if key not in values:
|
|
raise KeyError(f"Missing value for key '{key}'")
|
|
output += values[key]
|
|
i = end_pos + 2
|
|
else:
|
|
output += template[i]
|
|
i += 1
|
|
return output
|
|
|
|
|
|
# English is my native language and the one I can advocate for the accuracy of
|
|
# the content of this website the most, so it is the canonical translation. I'm
|
|
# not trying to make the claim that English is in any way the "default".
|
|
canonical_lang = "en"
|
|
|
|
LANG_RE = re.compile(r"^[a-z]{2,3}(_[A-Z]{2,3})?$")
|
|
for lang in i18n["locales"]:
|
|
if not LANG_RE.match(lang):
|
|
raise ValueError(f"Invalid language code: '{lang}'")
|
|
|
|
alternates_list = []
|
|
for lang in i18n["locales"]:
|
|
href = (
|
|
"https://eunakria.com/ln-s/"
|
|
if lang == canonical_lang
|
|
else f"https://eunakria.com/ln-s/{lang}/index.html"
|
|
)
|
|
alternates_list.append(
|
|
(lang, f'<link rel="alternate" hreflang="{lang}" href="{href}" />')
|
|
)
|
|
|
|
|
|
for lang, texts in i18n["locales"].items():
|
|
fallbacks = [texts]
|
|
if "_" in lang:
|
|
base_lang = lang.split("_")[0]
|
|
if base_lang in i18n["locales"]:
|
|
fallbacks.append(i18n["locales"][base_lang])
|
|
fallbacks.append(i18n["locales"][canonical_lang])
|
|
|
|
def tr(key: str) -> str:
|
|
for fb in fallbacks:
|
|
if key in fb:
|
|
return fb[key]
|
|
raise KeyError(f"Missing translation for key '{key}' in language '{lang}'")
|
|
|
|
description = htmlify(tr("description"))
|
|
file_annotation = htmlify(tr("file_annotation"))
|
|
link_annotation = htmlify(tr("link_annotation"))
|
|
file = htmlify(tr("file"))
|
|
link = htmlify(tr("link"))
|
|
deed = htmlify_bbcode(tr("deed"))
|
|
alternates_str = "\n".join(
|
|
alt for alt_lang, alt in alternates_list if alt_lang != lang
|
|
)
|
|
|
|
output = format(
|
|
template,
|
|
{
|
|
"lang": lang,
|
|
"arrow_path": arrow_path,
|
|
"description": description,
|
|
"file_annotation": file_annotation,
|
|
"link_annotation": link_annotation,
|
|
"file": file,
|
|
"link": link,
|
|
"deed": deed,
|
|
"alternates": alternates_str,
|
|
},
|
|
)
|
|
|
|
output_path = "index.html" if lang == canonical_lang else f"{lang}/index.html"
|
|
output_path = f"dist/{output_path}"
|
|
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
with open(output_path, "w") as file:
|
|
file.write(output)
|