initial commit

This commit is contained in:
2025-08-09 18:17:23 -04:00
commit 49c870d0ea
12 changed files with 538 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

17
Makefile Normal file
View File

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

47
README.md Normal file
View File

@@ -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).

218
build.py Normal file
View File

@@ -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("<", "&lt;")
.replace(">", "&gt;")
.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)

216
index.html Normal file
View File

@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="{{lang}}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ln -s</title>
<meta property="og:title" content="ln -s &lt;file&gt; &lt;link&gt;" />
<meta
property="og:description"
content="{{description}}"
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://eunakria.com/ln-s" />
<meta
property="og:image"
content="https://eunakria.com/ln-s/ln-s.webp"
/>
{{alternates}}
<style>
/*@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Recursive:slnt,wght,CRSV,MONO@-15..0,300..1000,0..1,0..1&display=swap');*/
@font-face {
font-family: 'Recursive Variable';
font-style: oblique 0deg 15deg;
font-display: swap;
font-weight: 300 1000;
src: url(vendor/recursive-latin-full-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
U+FFFD;
}
@font-face {
font-family: 'Caveat Brush';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(vendor/caveat-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
U+FFFD;
}
:root {
color-scheme: dark;
--unit: 2vw;
}
@media (min-aspect-ratio: 3/2) {
.page {
--unit: 3vh;
}
}
.arrow {
width: calc(10 * var(--unit));
}
body {
margin: 0;
}
.page {
background: #101010;
height: 100vh;
display: grid;
align-items: center;
}
.headline {
font-size: calc(3 * var(--unit));
font-weight: 800;
text-align: center;
}
code {
font-family: 'Recursive Variable', monospace;
font-variation-settings: 'MONO' 1;
}
.blue,
.red {
position: relative;
font-style: italic;
font-variation-settings: 'MONO' 1, 'CRSV' 1;
}
.soft {
color: #808080;
}
.blue {
color: #8080ff;
}
.red {
color: #ff8080;
}
svg {
fill: currentColor;
}
.above,
.below {
position: absolute;
font-size: calc(2 * var(--unit));
display: inline-flex;
gap: calc(1 * var(--unit));
}
.above {
bottom: 150%;
right: 0;
justify-content: end;
}
.below {
top: 150%;
align-items: end;
}
.blink {
color: #808080;
animation: blink 2s infinite;
}
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.handwritten {
font-family: 'Caveat Brush', cursive;
display: inline-block;
white-space: nowrap;
}
.deed {
position: absolute;
width: 100%;
bottom: 0;
text-align: center;
color: #606060;
font-family: 'Recursive Variable', sans-serif;
transition: color 0.2s;
font-size: min(calc(1 * var(--unit)), 16px);
}
a {
color: #909090;
transition: color 0.2s;
}
.deed:hover {
color: #808080;
}
.deed:hover a {
color: #c0c0c0;
}
a:hover,
.deed:hover a:hover {
color: white;
}
</style>
</head>
<body>
<div class="page">
<p class="headline">
<code
><span class="soft">$ </span>ln -s
<span class="blue"
><span class="below">
<svg class="arrow" viewBox="0 0 40 30">
<path d="{{arrow_path}}" />
</svg>
<span class="handwritten"
>{{file_annotation}}</span
> </span
>&lt;{{file}}&gt;</span
>
<span class="red"
><span class="above">
<span class="handwritten"
>{{link_annotation}}</span
>
<svg class="arrow" viewBox="0 0 40 30">
<path
transform="scale(-1,-1) translate(-40,-30)"
d="{{arrow_path}}"
/>
</svg> </span
>&lt;{{link}}&gt;</span
><span class="blink">_</span></code
>
</p>
<div class="deed">
<p>{{deed}}</p>
</div>
</div>
</body>
</html>

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
PyYAML==6.0.2

1
res/arrow-path.txt Normal file
View File

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

37
res/i18n.yml Normal file
View File

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

BIN
res/ln-s.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
res/ln-s.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
vendor/caveat-latin-400-normal.woff2 vendored Normal file

Binary file not shown.

BIN
vendor/recursive-latin-full-normal.woff2 vendored Normal file

Binary file not shown.