initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist/
|
||||
17
Makefile
Normal file
17
Makefile
Normal 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
47
README.md
Normal 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
218
build.py
Normal 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("<", "<")
|
||||
.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)
|
||||
216
index.html
Normal file
216
index.html
Normal 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 <file> <link>" />
|
||||
<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
|
||||
><{{file}}></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
|
||||
><{{link}}></span
|
||||
><span class="blink">_</span></code
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="deed">
|
||||
<p>{{deed}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
PyYAML==6.0.2
|
||||
1
res/arrow-path.txt
Normal file
1
res/arrow-path.txt
Normal 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
37
res/i18n.yml
Normal 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
BIN
res/ln-s.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
BIN
res/ln-s.webp
Normal file
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
BIN
vendor/caveat-latin-400-normal.woff2
vendored
Normal file
Binary file not shown.
BIN
vendor/recursive-latin-full-normal.woff2
vendored
Normal file
BIN
vendor/recursive-latin-full-normal.woff2
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user