commit 49c870d0eabb0d722081acb3656343cd57db4286 Author: Eunakria Date: Sat Aug 9 18:17:23 2025 -0400 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d640721 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +all: Makefile dist/index.html static + +dist/index.html: build.py Makefile index.html res/arrow-path.txt res/i18n.yml + mkdir -p dist + python3 build.py + +STATIC_FILES := $(wildcard vendor/*) res/ln-s.png res/ln-s.webp + +.PHONY: static +static: $(STATIC_FILES) + mkdir -p dist/vendor + cp -r vendor/* dist/vendor/ + cp res/ln-s.png res/ln-s.webp dist/ + +.PHONY: clean +clean: + rm -rf dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..61a9cb4 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# `ln -s` + +> *Because you can't remember which way round it goes either, can you?* + +## Table of Contents + +* [Introduction](#introduction) +* [Building](#building) +* [License](#license) + +## Introduction + +I made this website in all of 20 minutes and don't have any particular affection towards it. It's an overengineered poster to help clear up a bad design decision made years ago by people who are both smarter than me, and who I do not know. This website has an entirety of three sentences of text and a single image, and no delusions of grandeur. + +To that extent, I am releasing it into the [**public domain**](https://creativecommons.org/public-domain/cc0/). Feel free to use it, fork it, copy it, share it, or whatever else you like. If you do make something cool with it, I'd love to see it, so please let me know! + +**Translations and bug fixes are welcome,** and can be submitted as pull requests. Translations are stored in the YAML file `dist/i18n.yml`. See [Building](#building) for instructions on how to build the site. If you have any questions, feel free to reach out to me (`@eunakria` on Discord)! + +**Bug reports are also welcome,** and can be submitted as issues on the official [Git repository](https://git.eunakria.com/Eunakria/ln-s). (You might see the code mirrored elsewhere, e.g. on GitHub, but I don't check those repositories for issues or pull requests.) + +## Building + +To build the site, you will need to have [Python](https://www.python.org/) installed, as well as the [Make](https://www.gnu.org/software/make/) utility. (GNU-flavored, ideally, but it might work on POSIX systems as well.) Once you have those installed, run the following commands: + +```bash +# First create a virtual environment, if you haven't already. +# This is optional, but recommended to avoid polluting your global +# Python environment. +python -m venv .venv +source .venv/bin/activate # On Windows, use `.venv\Scripts\activate` + +# Then install the required dependencies and build the site. +pip install -r requirements.txt +make +``` + +The site will be built into the `dist/` directory. + +## License + +The project itself is provided under the [CC0 1.0 Universal (CC0 1.0) Public Domain Dedication](https://creativecommons.org/public-domain/zero/1.0/legalcode). This means you can do whatever you like with it, including using it in commercial projects, without needing to give credit. + +Some third-party assets are vendored in the `vendor/` directory, and may be provided under different licenses. A list of these assets and their licenses follows: + +* [**Caveat Brush**](https://fonts.google.com/specimen/Caveat+Brush) (font) by Impallari Type is provided under the [SIL Open Font License 1.1](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL_web). + +* [**Recursive**](https://www.recursive.design/) (font) by Arrow Type is provided under the [SIL Open Font License 1.1](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL_web). diff --git a/build.py b/build.py new file mode 100644 index 0000000..4a625ab --- /dev/null +++ b/build.py @@ -0,0 +1,218 @@ +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", "
") + ) + + +# 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"{inner}" + elif self.name == "i": + inner = "".join(child.to_html() for child in self.children) + return f"{inner}" + 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'{inner}' + 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'') + ) + + +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) diff --git a/index.html b/index.html new file mode 100644 index 0000000..9dc249e --- /dev/null +++ b/index.html @@ -0,0 +1,216 @@ + + + + + + ln -s + + + + + + {{alternates}} + + + +
+

+ $ ln -s + + + + + {{file_annotation}} <{{file}}> + + {{link_annotation}} + + + <{{link}}>_ +

+ +
+

{{deed}}

+
+
+ + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8392d54 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyYAML==6.0.2 diff --git a/res/arrow-path.txt b/res/arrow-path.txt new file mode 100644 index 0000000..4a21647 --- /dev/null +++ b/res/arrow-path.txt @@ -0,0 +1 @@ +M 13.14535,19.124796 C 8.070788,12.781593 7.094762,6.5375184 7.1308176,6.1536656 7.1707296,5.7287728 7.4174216,5.4931112 7.833276,5.482622 8.2983444,5.470906 8.5566736,5.7441924 8.7258152,6.4268936 10.871159,12.99163 14.201725,18.345966 20.324082,21.86679 c 4.475777,2.573918 10.5352,2.805409 13.857858,2.52444 0.572185,-0.04839 1.564879,-0.129952 2.205982,-0.181261 0.641103,-0.05131 1.518086,-0.174575 1.948849,-0.273932 0.430766,-0.09935 0.872423,-0.156071 0.98146,-0.126037 0.318461,0.08772 0.688533,0.496735 0.681675,0.753413 -0.01202,0.449765 -0.244781,0.708314 -0.7753,0.861173 -1.171227,0.337463 -4.579103,0.906036 -7.251636,0.912845 -6.582923,0.28931 -12.232636,-1.22617 -17.162423,-5.514142 -0.112324,-0.11904 -0.95017,-0.80471 -1.665194,-1.698489 z M 8.00684e-4,6.5387796 C -0.00811637,6.1852196 0.04284897,5.5825252 0.91822624,4.755912 2.2707684,3.4787105 6.1504772,0.6645454 7.0631776,0.29864299 8.2103828,-0.16127781 8.7016204,-0.10446997 9.4111832,0.57017512 11.206882,2.2775063 12.419114,5.3434752 15.544424,8.3149844 17.16438,9.85522 17.225674,10.642653 16.748948,11.148915 16.508736,11.403732 16.198502,11.448568 15.887697,11.272968 13.616382,9.3814968 11.678868,7.1964816 10.077191,4.7280752 9.3292148,3.56635 8.7625016,2.7799272 8.4787384,2.5099225 L 8.0391072,2.0916034 7.5696896,2.294472 C 6.6583264,2.6883347 2.4221618,5.7378884 1.9864454,6.3137644 1.5868802,6.8418584 0.98552116,7.2211216 0.60448748,7.18533 0.17737519,7.1452068 0.01162094,6.9676856 8.00684e-4,6.5387796 Z diff --git a/res/i18n.yml b/res/i18n.yml new file mode 100644 index 0000000..dafceda --- /dev/null +++ b/res/i18n.yml @@ -0,0 +1,37 @@ +locales: + + # Speak another language? Feel free to submit a PR! + # Feel free to reach out to me (@eunakria on Discord) if you need help + # with Git or the other technologies used here. + en: + description: >- + Because you can't remember which way round it goes either, can you? + file: 'file' + link: 'link' + file_annotation: |- + The file you are + linking to + link_annotation: |- + The symlink you + want to create + deed: |- + Probably doesn't count as a copyrightable work to begin with, + but hey, what the heck. [a]no rights reserved[/a]. Knock yourself out. + + # ¿Hablas español? ¡Siéntete libre de enviar un PR! + # No dudes en contactarme (@eunakria en Discord) si necesitas ayuda + # con Git u otras tecnologías usadas aquí. + es: + description: >- + Porque tú tampoco puedes recordar cuál va primero, ¿verdad? + file: 'archivo' + link: 'enlace' + file_annotation: |- + El archivo al que + estás enlazando + link_annotation: |- + El enlace simbólico + que deseas crear + deed: |- + Probablemente no cuenta como una obra protegida por derechos de autor para empezar, + pero bueno, qué demonios. [a]sin derechos reservados[/a]. Diviértete. diff --git a/res/ln-s.png b/res/ln-s.png new file mode 100644 index 0000000..2713e7d Binary files /dev/null and b/res/ln-s.png differ diff --git a/res/ln-s.webp b/res/ln-s.webp new file mode 100644 index 0000000..21cdf21 Binary files /dev/null and b/res/ln-s.webp differ diff --git a/vendor/caveat-latin-400-normal.woff2 b/vendor/caveat-latin-400-normal.woff2 new file mode 100644 index 0000000..7016cd0 Binary files /dev/null and b/vendor/caveat-latin-400-normal.woff2 differ diff --git a/vendor/recursive-latin-full-normal.woff2 b/vendor/recursive-latin-full-normal.woff2 new file mode 100644 index 0000000..7ee30fc Binary files /dev/null and b/vendor/recursive-latin-full-normal.woff2 differ