Jinja, table of contents

This commit is contained in:
object-Object 2023-07-13 23:14:36 -04:00
parent 276ca5b73f
commit 9059cfe6a5
14 changed files with 586 additions and 422 deletions

View file

@ -2,7 +2,6 @@
modid = "hexcasting"
book_name = "thehexbook"
template = "template.html"
is_0_black = false
recipe_dirs = [
@ -11,6 +10,10 @@ recipe_dirs = [
]
default_recipe_dir = 0
# NOTE: _Raw means "don't apply variable interpolation to this value"
pattern_regex = {_Raw='HexPattern\.fromAngles\("([qweasd]+)", HexDir\.(\w+)\),\s*modLoc\("([^"]+)"\)([^;]*true\);)?'}
template = "main.html.jinja"
spoilers = [
"hexcasting:opened_eyes",
"hexcasting:y_u_no_cast_angy",
@ -18,9 +21,14 @@ spoilers = [
]
blacklist = []
# NOTE: _Raw means "don't apply variable interpolation to this value"
# more on that later
pattern_regex = {_Raw='HexPattern\.fromAngles\("([qweasd]+)", HexDir\.(\w+)\),\s*modLoc\("([^"]+)"\)([^;]*true\);)?'}
[template_args]
title = "Hex Book"
mod_name = "Hexcasting"
author = "petrak@, Alwinfy"
description = "The Hex Book, all in one place."
icon_href = "icon.png"
is_bleeding_edge = true
show_landing_text = true
[base_asset_urls]
hexcasting = "https://raw.githubusercontent.com/gamma-delta/HexMod/main/Common/src/main/resources"

View file

@ -39,19 +39,21 @@ class I18nProps(HexDocModel[Any]):
class Properties(HexDocModel[Any]):
modid: str
book_name: str
template: Path
is_0_black: bool
"""If true, the style `$(0)` changes the text color to black; otherwise it resets
the text color to the default."""
spoilers: set[ResourceLocation]
blacklist: set[ResourceLocation]
recipe_dirs: list[Path]
default_recipe_dir_index_: int = Field(alias="default_recipe_dir")
pattern_regex: re.Pattern[str]
template: str
spoilers: set[ResourceLocation]
blacklist: set[ResourceLocation]
template_args: dict[str, Any]
base_asset_urls: dict[str, NoTrailingSlashHttpUrl]
"""Mapping from modid to the url of that mod's `resources` directory on GitHub."""

View file

@ -0,0 +1,53 @@
from html import escape
from typing import Any
from jinja2 import nodes
from jinja2.ext import Extension
from jinja2.parser import Parser
from markupsafe import Markup
from minecraft.i18n import LocalizedStr
from patchouli.text.formatting import FormatTree
from patchouli.text.html import HTMLStream
# https://stackoverflow.com/a/64392515
class IncludeRawExtension(Extension):
tags = {"include_raw"}
def parse(self, parser: Parser) -> nodes.Node:
lineno = parser.stream.expect("name:include_raw").lineno
template = parser.parse_expression()
result = self.call_method("_render", [template], lineno=lineno)
return nodes.Output([result], lineno=lineno)
def _render(self, filename: str) -> Markup:
assert self.environment.loader is not None
source = self.environment.loader.get_source(self.environment, filename)
return Markup(source[0])
def hexdoc_minify(value: str) -> str:
return "".join(line.strip() for line in value.splitlines())
# return minify_html.minify(
# code=value,
# keep_closing_tags=True,
# keep_html_and_head_opening_tags=True,
# keep_spaces_between_attributes=True,
# ensure_spec_compliant_unquoted_attribute_values=True,
# )
def hexdoc_block(value: Any) -> str:
match value:
case LocalizedStr() | str():
lines = str(value).splitlines()
return "<br />".join(escape(line) for line in lines)
case FormatTree():
with HTMLStream() as out:
with value.style.element(out):
for child in value.children:
out.write(hexdoc_block(child))
return out.getvalue()
case _:
raise TypeError(value)

View file

@ -4,13 +4,15 @@
import sys
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, StrictUndefined
# from jinja2.sandbox import SandboxedEnvironment
from tap import Tap
from common.properties import Properties
from common.templates import IncludeRawExtension, hexdoc_block, hexdoc_minify
from hexcasting.hex_book import HexBook
from .collate_data import generate_docs
if sys.version_info < (3, 11):
raise RuntimeError("Minimum Python version: 3.11")
@ -31,9 +33,30 @@ def main(args: Args) -> None:
props = Properties.load(args.properties_file)
book = HexBook.load(*HexBook.prepare(props))
# load and fill the template
template = props.template.read_text("utf-8")
docs = generate_docs(book, template)
# set up Jinja environment
# TODO: SandboxedEnvironment
env = Environment(
# TODO: ChoiceLoader w/ ModuleLoader, but we need the directory restructure
loader=FileSystemLoader("./templates"),
undefined=StrictUndefined,
lstrip_blocks=True,
trim_blocks=True,
autoescape=False,
extensions=[IncludeRawExtension],
)
env.filters["hexdoc_minify"] = hexdoc_minify
env.filters["hexdoc_block"] = hexdoc_block
# load and render template
template = env.get_template(props.template)
docs = template.render(
props.template_args
| {
"book": book,
"spoilers": props.spoilers,
"blacklist": props.blacklist,
}
)
# if there's an output file specified, write to it
# otherwise just print the generated docs

View file

@ -1,7 +1,7 @@
from pathlib import Path
from typing import Self
from typing import Self, cast
from pydantic import Field
from pydantic import Field, FieldValidationInfo, field_validator
from common.properties import Properties
from common.types import Sortable, sorted_dict
@ -74,6 +74,10 @@ class Category(BookModelFile[BookContext, BookContext], Sortable):
# implement BookModelFile
return props.categories_dir
@property
def is_spoiler(self) -> bool:
return all(entry.is_spoiler for entry in self.entries)
@property
def _is_cmp_key_ready(self) -> bool:
return self.parent_id is None or self.parent_cmp_key_ is not None

View file

@ -1,6 +1,13 @@
from pathlib import Path
from typing import Any, cast
from pydantic import Field
from pydantic import (
Field,
FieldValidationInfo,
ValidationInfo,
field_validator,
model_validator,
)
from common.properties import Properties
from common.types import Color, Sortable
@ -17,6 +24,8 @@ class Entry(BookModelFile[BookContext, BookContext], Sortable):
See: https://vazkiimods.github.io/Patchouli/docs/reference/entry-json
"""
is_spoiler: bool = False
# required (entry.json)
name: LocalizedStr
category_id: ResourceLocation = Field(alias="category")
@ -43,3 +52,12 @@ class Entry(BookModelFile[BookContext, BookContext], Sortable):
# implement Sortable
# note: python sorts false before true, so we invert priority
return (not self.priority, self.sortnum, self.name)
@model_validator(mode="after")
def _check_is_spoiler(self, info: ValidationInfo):
context = cast(BookContext | None, info.context)
if not context or self.advancement is None:
return self
self.is_spoiler = self.advancement in context["props"].spoilers
return self

View file

@ -1,4 +1,5 @@
# TODO: type
import io
from contextlib import nullcontext
from dataclasses import dataclass
from html import escape
@ -27,17 +28,14 @@ class HTMLElement:
self.stream.write(f"</{self.name}>")
@dataclass
class HTMLStream:
stream: IO[str]
class HTMLStream(io.StringIO):
def void_element(self, name: str, **kwargs: Any):
"""Like `<img />`."""
keywords = attributes_to_str(kwargs)
self.stream.write(f"<{name}{keywords} />")
self.write(f"<{name}{keywords} />")
def element(self, name: str, /, **kwargs: Any):
return HTMLElement(self.stream, name, kwargs)
return HTMLElement(self, name, kwargs)
def element_if(self, condition: bool, name: str, /, **kwargs: Any):
if condition:
@ -49,5 +47,5 @@ class HTMLStream:
pass
def text(self, txt: str | LocalizedStr):
self.stream.write(escape(str(txt)))
self.write(escape(str(txt)))
return self

View file

@ -1,399 +0,0 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="The Hex Book, all in one place.">
<meta name="author" content="petrak@, Alwinfy">
<link rel="icon" href="icon.png">
<title>Hex Book</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap.min.css"
integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.3.0/font/bootstrap-icons.css">
<style>
summary { display: list-item; }
details.spell-collapsible {
display: inline-block;
border: 1px solid #aaa;
border-radius: 4px;
padding: .5em .5em 0;
margin-bottom: .5em;
}
summary.collapse-spell {
font-weight: bold;
margin: -.5em -.5em 0;
padding: .5em;
}
details.spell-collapsible[open] {
padding: .5em;
}
details[open] summary.collapse-spell {
border-bottom: 1px solid #aaa;
margin-bottom: .5em;
}
details .collapse-spell::before {
content: "Click to show spell";
}
details[open] .collapse-spell::before {
content: "Click to hide spell";
}
blockquote.crafting-info {
font-size: inherit;
}
a.permalink {
margin-left: 0.5em;
}
a.permalink:hover {
color: lightgray;
}
p {
margin: 0.5ex 0;
}
p.fake-li {
margin: 0;
}
p.fake-li::before {
content: "\2022";
margin: 1ex;
}
.linkout::before {
content: "Link: ";
}
p.todo-note {
font-style: italic;
color: lightgray;
}
.obfuscated {
filter: blur(1em);
}
.spoilered {
filter: blur(1ex);
-moz-transition: filter 0.04s linear;
}
.spoilered:hover {
filter: blur(0.5ex);
}
.spoilered.unspoilered {
filter: blur(0);
}
canvas.spell-viz {
--dot-color: #777f;
--dot-color: #777f;
--start-dot-color: #f009;
--moving-dot-color: #0fa9;
--path-color: darkgray;
--visited-path-color: #0c8;
--dot-scale: 0.0625;
--moving-dot-scale: 0.125;
--line-scale: 0.08333;
--pausetext-scale: 0.5;
--dark-mode: 0;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #201a20;
color: #ddd;
}
.jumbotron {
background-color: #323;
}
canvas.spell-viz {
/* hack */
--dark-mode: 1;
}
}
</style>
<noscript>
<style>
/* for accessibility */
.spoilered {
filter: none !important;
}
</style>
</noscript>
<script>
"use strict";
const speeds = [0, 0.25, 0.5, 1, 2, 4];
const scrollThreshold = 100;
const rfaQueue = [];
const colorCache = new Map();
function getColorRGB(ctx, str) {
if (!colorCache.has(str)) {
ctx.fillStyle = str;
ctx.clearRect(0, 0, 1, 1);
ctx.fillRect(0, 0, 1, 1);
const imgData = ctx.getImageData(0, 0, 1, 1);
colorCache.set(str, imgData.data);
}
return colorCache.get(str);
}
function startAngle(str) {
switch (str) {
case "east": return 0;
case "north_east": return 1;
case "north_west": return 2;
case "west": return 3;
case "south_west": return 4;
case "south_east": return 5;
default: return 0;
}
}
function offsetAngle(str) {
switch (str) {
case "w": return 0;
case "q": return 1;
case "a": return 2;
case "s": return 3;
case "d": return 4;
case "e": return 5;
default: return -1;
}
}
function initializeElem(canvas) {
const str = canvas.dataset.string;
let angle = startAngle(canvas.dataset.start);
const perWorld = canvas.dataset.perWorld === "True";
// build geometry
const points = [[0, 0]];
let lastPoint = points[0];
let minPoint = lastPoint, maxPoint = lastPoint;
for (const ch of "w" + str) {
const addAngle = offsetAngle(ch);
if (addAngle < 0) continue;
angle = (angle + addAngle) % 6;
const trueAngle = Math.PI / 3 * angle;
const [lx, ly] = lastPoint;
const newPoint = [lx + Math.cos(trueAngle), ly - Math.sin(trueAngle)];
points.push(newPoint);
lastPoint = newPoint;
const [mix, miy] = minPoint;
minPoint = [Math.min(mix, newPoint[0]), Math.min(miy, newPoint[1])];
const [max, may] = maxPoint;
maxPoint = [Math.max(max, newPoint[0]), Math.max(may, newPoint[1])];
}
const size = Math.min(canvas.width, canvas.height) * 0.8;
const scale = size / Math.max(3, Math.max(maxPoint[1] - minPoint[1], maxPoint[0] - minPoint[0]));
const center = [(minPoint[0] + maxPoint[0]) * 0.5, (minPoint[1] + maxPoint[1]) * 0.5];
const truePoints = points.map(p => [canvas.width * 0.5 + scale * (p[0] - center[0]), canvas.height * 0.5 + scale * (p[1] - center[1])]);
let uniqPoints = [];
l1: for (const point of truePoints) {
for (const pt of uniqPoints) {
if (Math.abs(point[0] - pt[0]) < 0.00001 && Math.abs(point[1] - pt[1]) < 0.00001) {
continue l1;
}
}
uniqPoints.push(point);
}
// rendering code
const speed = 0.0025;
const context = canvas.getContext("2d");
const negaProgress = -3;
let progress = 0;
let scrollTimeout = 1e309;
let speedLevel = 3;
let speedIncrement = 0;
function speedScale() {
return speeds[speedLevel];
}
const style = getComputedStyle(canvas);
const getProp = n => style.getPropertyValue(n);
const tick = dt => {
scrollTimeout += dt;
if (canvas.offsetParent === null) return;
const strokeStyle = getProp("--path-color");
const strokeVisitedStyle = getProp("--visited-path-color");
const startDotStyle = getProp("--start-dot-color");
const dotStyle = getProp("--dot-color");
const movDotStyle = getProp("--moving-dot-color");
const strokeWidth = scale * +getProp("--line-scale");
const dotRadius = scale * +getProp("--dot-scale");
const movDotRadius = scale * +getProp("--moving-dot-scale");
const pauseScale = scale * +getProp("--pausetext-scale");
const bodyBg = scale * +getProp("--pausetext-scale");
const darkMode = +getProp("--dark-mode");
const bgColors = getColorRGB(context, getComputedStyle(document.body).backgroundColor);
if (!perWorld) {
progress += speed * dt * (progress > 0 ? speedScale() : Math.sqrt(speedScale()));
}
if (progress >= truePoints.length - 1) {
progress = negaProgress;
}
let ix = Math.floor(progress), frac = progress - ix, core = null, fadeColor = 0;
if (ix < 0) {
const rawFade = 2 * progress / negaProgress - 1;
fadeColor = 1 - Math.abs(rawFade);
context.strokeStyle = rawFade > 0 ? strokeVisitedStyle : strokeStyle;
ix = rawFade > 0 ? truePoints.length - 2 : 0;
frac = +(rawFade > 0);
} else {
context.strokeStyle = strokeVisitedStyle;
}
const [lx, ly] = truePoints[ix];
const [rx, ry] = truePoints[ix + 1];
core = [lx + (rx - lx) * frac, ly + (ry - ly) * frac];
context.clearRect(0, 0, canvas.width, canvas.height);
context.beginPath();
context.lineWidth = strokeWidth;
context.moveTo(truePoints[0][0], truePoints[0][1]);
for (let i = 1; i < ix + 1; i++) {
context.lineTo(truePoints[i][0], truePoints[i][1]);
}
context.lineTo(core[0], core[1]);
context.stroke();
context.beginPath();
context.strokeStyle = strokeStyle;
context.moveTo(core[0], core[1]);
for (let i = ix + 1; i < truePoints.length; i++) {
context.lineTo(truePoints[i][0], truePoints[i][1]);
}
context.stroke();
for (let i = 0; i < uniqPoints.length; i++) {
context.beginPath();
context.fillStyle = (i == 0 && !perWorld) ? startDotStyle : dotStyle;
const radius = (i == 0 && !perWorld) ? movDotRadius : dotRadius;
context.arc(uniqPoints[i][0], uniqPoints[i][1], radius, 0, 2 * Math.PI);
context.fill();
}
if (!perWorld) {
context.beginPath();
context.fillStyle = movDotStyle;
context.arc(core[0], core[1], movDotRadius, 0, 2 * Math.PI);
context.fill();
}
if (fadeColor) {
context.fillStyle = `rgba(${bgColors[0]}, ${bgColors[1]}, ${bgColors[2]}, ${fadeColor})`;
context.fillRect(0, 0, canvas.width, canvas.height);
}
if (scrollTimeout <= 2000) {
context.fillStyle = `rgba(200, 200, 200, ${(2000 - scrollTimeout) / 1000})`;
context.font = `${pauseScale}px sans-serif`;
context.fillText(speedScale() ? speedScale() + "x" : "Paused", 0.2 * scale, canvas.height - 0.2 * scale);
}
};
rfaQueue.push(tick);
// scrolling input
if (!perWorld) {
canvas.addEventListener("wheel", ev => {
speedIncrement += ev.deltaY;
const oldSpeedLevel = speedLevel;
if (speedIncrement >= scrollThreshold) {
speedLevel--;
} else if (speedIncrement <= -scrollThreshold) {
speedLevel++;
}
if (oldSpeedLevel != speedLevel) {
speedIncrement = 0;
speedLevel = Math.max(0, Math.min(speeds.length - 1, speedLevel));
scrollTimeout = 0;
}
ev.preventDefault();
});
}
}
function hookLoad(elem) {
let init = false;
const canvases = elem.querySelectorAll("canvas");
elem.addEventListener("toggle", () => {
if (!init) {
canvases.forEach(initializeElem);
init = true;
}
});
}
function hookToggle(elem) {
const details = Array.from(document.querySelectorAll("details." + elem.dataset.target));
elem.addEventListener("click", () => {
if (details.some(x => x.open)) {
details.forEach(x => x.open = false);
} else {
details.forEach(x => x.open = true);
}
});
}
const params = new URLSearchParams(document.location.search);
function hookSpoiler(elem) {
if (params.get("nospoiler") !== null) {
elem.classList.add("unspoilered");
} else {
const thunk = ev => {
if (!elem.classList.contains("unspoilered")) {
ev.preventDefault();
ev.stopImmediatePropagation();
elem.classList.add("unspoilered");
}
elem.removeEventListener("click", thunk);
};
elem.addEventListener("click", thunk);
if (elem instanceof HTMLAnchorElement) {
const href = elem.getAttribute("href");
if (href.startsWith("#")) {
elem.addEventListener("click", () => document.getElementById(href.substring(1)).querySelector(".spoilered").classList.add("unspoilered"));
}
}
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('details.spell-collapsible').forEach(hookLoad);
document.querySelectorAll('a.toggle-link').forEach(hookToggle);
document.querySelectorAll('.spoilered').forEach(hookSpoiler);
function tick(prevTime, time) {
const dt = time - prevTime;
for (const q of rfaQueue) {
q(dt);
}
requestAnimationFrame(t => tick(time, t));
}
requestAnimationFrame(t => tick(t, t));
});
</script>
</head>
<body>
<div class="container" style="margin-top: 3em;">
<blockquote>
<h1>This is the online version of the Hexcasting documentation.</h1>
<p>Embedded images and patterns are included, but not crafting recipes or items. There's an in-game book for
those.</p>
<p>Additionally, this is built from the latest code on GitHub. It may describe <b>newer</b> features that you
may not necessarily have, even on the latest CurseForge version!</p>
<p><b>Entries which are blurred are spoilers</b>. Click to reveal them, but be aware that they may spoil endgame
progression. Alternatively, click <a href="?nospoiler">here</a> to get a version with all spoilers showing.
</p>
</blockquote>
</div>
#DUMP_BODY_HERE
</body>
</html>

18
doc/templates/book.html.jinja vendored Normal file
View file

@ -0,0 +1,18 @@
<div class="container">
{% if show_landing_text %}
<header class="jumbotron">
<h1 class="book-title">
{{ book.name }}
</h1>
{{ book.landing_text | hexdoc_block }}
</header>
{% endif %}
<nav>
{% include "tableofcontents.html.jinja" %}
</nav>
<main class="book-body">
{% for category in book.categories.values() %}
{% include "category.html.jinja" %}
{% endfor %}
</main>
</div>

1
doc/templates/category.html.jinja vendored Normal file
View file

@ -0,0 +1 @@
category goes here

11
doc/templates/macros.html.jinja vendored Normal file
View file

@ -0,0 +1,11 @@
{% macro permalink(href) %}
<a href="#{{ href }}" class="permalink small" title="Permalink">
<i class="bi bi-link-45deg"></i>
</a>
{% endmacro %}
{% macro maybe_spoilered_link(value) %}
<a href="#{{ value.id.path }}" class="{{ 'spoilered' if value.is_spoiler else '' }}">
{{- value.name -}}
</a>
{%- endmacro %}

153
doc/templates/main.html.jinja vendored Normal file
View file

@ -0,0 +1,153 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{ description }}">
<meta name="author" content="{{ author }}">
<link rel="icon" href="{{ icon_href }}">
<title>{{ title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap.min.css"
integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.3.0/font/bootstrap-icons.css">
<style>
summary { display: list-item; }
details.spell-collapsible {
display: inline-block;
border: 1px solid #aaa;
border-radius: 4px;
padding: .5em .5em 0;
margin-bottom: .5em;
}
summary.collapse-spell {
font-weight: bold;
margin: -.5em -.5em 0;
padding: .5em;
}
details.spell-collapsible[open] {
padding: .5em;
}
details[open] summary.collapse-spell {
border-bottom: 1px solid #aaa;
margin-bottom: .5em;
}
details .collapse-spell::before {
content: "Click to show spell";
}
details[open] .collapse-spell::before {
content: "Click to hide spell";
}
blockquote.crafting-info {
font-size: inherit;
}
a.permalink {
margin-left: 0.5em;
}
a.permalink:hover {
color: lightgray;
}
p {
margin: 0.5ex 0;
}
p.fake-li {
margin: 0;
}
p.fake-li::before {
content: "\2022";
margin: 1ex;
}
.linkout::before {
content: "Link: ";
}
p.todo-note {
font-style: italic;
color: lightgray;
}
.obfuscated {
filter: blur(1em);
}
.spoilered {
filter: blur(1ex);
-moz-transition: filter 0.04s linear;
}
.spoilered:hover {
filter: blur(0.5ex);
}
.spoilered.unspoilered {
filter: blur(0);
}
canvas.spell-viz {
--dot-color: #777f;
--dot-color: #777f;
--start-dot-color: #f009;
--moving-dot-color: #0fa9;
--path-color: darkgray;
--visited-path-color: #0c8;
--dot-scale: 0.0625;
--moving-dot-scale: 0.125;
--line-scale: 0.08333;
--pausetext-scale: 0.5;
--dark-mode: 0;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #201a20;
color: #ddd;
}
.jumbotron {
background-color: #323;
}
canvas.spell-viz {
/* hack */
--dark-mode: 1;
}
}
</style>
<noscript>
<style>
/* for accessibility */
.spoilered {
filter: none !important;
}
</style>
</noscript>
<script>
{# TODO: kinda hacky #}
{% include_raw "main.js" %}
</script>
</head>
<body>
<div class="container" style="margin-top: 3em;">
<blockquote>
<h1>This is the online version of the {{ mod_name }} documentation.</h1>
<p>Embedded images and patterns are included, but not crafting recipes or items. There's an in-game book for
those.</p>
{% if is_bleeding_edge %}
<p>Additionally, this is built from the latest code on GitHub. It may describe <b>newer</b> features that you
may not necessarily have, even on the latest CurseForge version!</p>
{% endif %}
<p><b>Entries which are blurred are spoilers</b>. Click to reveal them, but be aware that they may spoil endgame
progression. Alternatively, click <a href="?nospoiler">here</a> to get a version with all spoilers showing.
</p>
</blockquote>
</div>
{% filter hexdoc_minify %}
{% include "book.html.jinja" %}
{% endfilter +%}
</body>
</html>

252
doc/templates/main.js vendored Normal file
View file

@ -0,0 +1,252 @@
"use strict";
const speeds = [0, 0.25, 0.5, 1, 2, 4];
const scrollThreshold = 100;
const rfaQueue = [];
const colorCache = new Map();
function getColorRGB(ctx, str) {
if (!colorCache.has(str)) {
ctx.fillStyle = str;
ctx.clearRect(0, 0, 1, 1);
ctx.fillRect(0, 0, 1, 1);
const imgData = ctx.getImageData(0, 0, 1, 1);
colorCache.set(str, imgData.data);
}
return colorCache.get(str);
}
function startAngle(str) {
switch (str) {
case "east": return 0;
case "north_east": return 1;
case "north_west": return 2;
case "west": return 3;
case "south_west": return 4;
case "south_east": return 5;
default: return 0;
}
}
function offsetAngle(str) {
switch (str) {
case "w": return 0;
case "q": return 1;
case "a": return 2;
case "s": return 3;
case "d": return 4;
case "e": return 5;
default: return -1;
}
}
function initializeElem(canvas) {
const str = canvas.dataset.string;
let angle = startAngle(canvas.dataset.start);
const perWorld = canvas.dataset.perWorld === "True";
// build geometry
const points = [[0, 0]];
let lastPoint = points[0];
let minPoint = lastPoint, maxPoint = lastPoint;
for (const ch of "w" + str) {
const addAngle = offsetAngle(ch);
if (addAngle < 0) continue;
angle = (angle + addAngle) % 6;
const trueAngle = Math.PI / 3 * angle;
const [lx, ly] = lastPoint;
const newPoint = [lx + Math.cos(trueAngle), ly - Math.sin(trueAngle)];
points.push(newPoint);
lastPoint = newPoint;
const [mix, miy] = minPoint;
minPoint = [Math.min(mix, newPoint[0]), Math.min(miy, newPoint[1])];
const [max, may] = maxPoint;
maxPoint = [Math.max(max, newPoint[0]), Math.max(may, newPoint[1])];
}
const size = Math.min(canvas.width, canvas.height) * 0.8;
const scale = size / Math.max(3, Math.max(maxPoint[1] - minPoint[1], maxPoint[0] - minPoint[0]));
const center = [(minPoint[0] + maxPoint[0]) * 0.5, (minPoint[1] + maxPoint[1]) * 0.5];
const truePoints = points.map(p => [canvas.width * 0.5 + scale * (p[0] - center[0]), canvas.height * 0.5 + scale * (p[1] - center[1])]);
let uniqPoints = [];
l1: for (const point of truePoints) {
for (const pt of uniqPoints) {
if (Math.abs(point[0] - pt[0]) < 0.00001 && Math.abs(point[1] - pt[1]) < 0.00001) {
continue l1;
}
}
uniqPoints.push(point);
}
// rendering code
const speed = 0.0025;
const context = canvas.getContext("2d");
const negaProgress = -3;
let progress = 0;
let scrollTimeout = 1e309;
let speedLevel = 3;
let speedIncrement = 0;
function speedScale() {
return speeds[speedLevel];
}
const style = getComputedStyle(canvas);
const getProp = n => style.getPropertyValue(n);
const tick = dt => {
scrollTimeout += dt;
if (canvas.offsetParent === null) return;
const strokeStyle = getProp("--path-color");
const strokeVisitedStyle = getProp("--visited-path-color");
const startDotStyle = getProp("--start-dot-color");
const dotStyle = getProp("--dot-color");
const movDotStyle = getProp("--moving-dot-color");
const strokeWidth = scale * +getProp("--line-scale");
const dotRadius = scale * +getProp("--dot-scale");
const movDotRadius = scale * +getProp("--moving-dot-scale");
const pauseScale = scale * +getProp("--pausetext-scale");
const bodyBg = scale * +getProp("--pausetext-scale");
const darkMode = +getProp("--dark-mode");
const bgColors = getColorRGB(context, getComputedStyle(document.body).backgroundColor);
if (!perWorld) {
progress += speed * dt * (progress > 0 ? speedScale() : Math.sqrt(speedScale()));
}
if (progress >= truePoints.length - 1) {
progress = negaProgress;
}
let ix = Math.floor(progress), frac = progress - ix, core = null, fadeColor = 0;
if (ix < 0) {
const rawFade = 2 * progress / negaProgress - 1;
fadeColor = 1 - Math.abs(rawFade);
context.strokeStyle = rawFade > 0 ? strokeVisitedStyle : strokeStyle;
ix = rawFade > 0 ? truePoints.length - 2 : 0;
frac = +(rawFade > 0);
} else {
context.strokeStyle = strokeVisitedStyle;
}
const [lx, ly] = truePoints[ix];
const [rx, ry] = truePoints[ix + 1];
core = [lx + (rx - lx) * frac, ly + (ry - ly) * frac];
context.clearRect(0, 0, canvas.width, canvas.height);
context.beginPath();
context.lineWidth = strokeWidth;
context.moveTo(truePoints[0][0], truePoints[0][1]);
for (let i = 1; i < ix + 1; i++) {
context.lineTo(truePoints[i][0], truePoints[i][1]);
}
context.lineTo(core[0], core[1]);
context.stroke();
context.beginPath();
context.strokeStyle = strokeStyle;
context.moveTo(core[0], core[1]);
for (let i = ix + 1; i < truePoints.length; i++) {
context.lineTo(truePoints[i][0], truePoints[i][1]);
}
context.stroke();
for (let i = 0; i < uniqPoints.length; i++) {
context.beginPath();
context.fillStyle = (i == 0 && !perWorld) ? startDotStyle : dotStyle;
const radius = (i == 0 && !perWorld) ? movDotRadius : dotRadius;
context.arc(uniqPoints[i][0], uniqPoints[i][1], radius, 0, 2 * Math.PI);
context.fill();
}
if (!perWorld) {
context.beginPath();
context.fillStyle = movDotStyle;
context.arc(core[0], core[1], movDotRadius, 0, 2 * Math.PI);
context.fill();
}
if (fadeColor) {
context.fillStyle = `rgba(${bgColors[0]}, ${bgColors[1]}, ${bgColors[2]}, ${fadeColor})`;
context.fillRect(0, 0, canvas.width, canvas.height);
}
if (scrollTimeout <= 2000) {
context.fillStyle = `rgba(200, 200, 200, ${(2000 - scrollTimeout) / 1000})`;
context.font = `${pauseScale}px sans-serif`;
context.fillText(speedScale() ? speedScale() + "x" : "Paused", 0.2 * scale, canvas.height - 0.2 * scale);
}
};
rfaQueue.push(tick);
// scrolling input
if (!perWorld) {
canvas.addEventListener("wheel", ev => {
speedIncrement += ev.deltaY;
const oldSpeedLevel = speedLevel;
if (speedIncrement >= scrollThreshold) {
speedLevel--;
} else if (speedIncrement <= -scrollThreshold) {
speedLevel++;
}
if (oldSpeedLevel != speedLevel) {
speedIncrement = 0;
speedLevel = Math.max(0, Math.min(speeds.length - 1, speedLevel));
scrollTimeout = 0;
}
ev.preventDefault();
});
}
}
function hookLoad(elem) {
let init = false;
const canvases = elem.querySelectorAll("canvas");
elem.addEventListener("toggle", () => {
if (!init) {
canvases.forEach(initializeElem);
init = true;
}
});
}
function hookToggle(elem) {
const details = Array.from(document.querySelectorAll("details." + elem.dataset.target));
elem.addEventListener("click", () => {
if (details.some(x => x.open)) {
details.forEach(x => x.open = false);
} else {
details.forEach(x => x.open = true);
}
});
}
const params = new URLSearchParams(document.location.search);
function hookSpoiler(elem) {
if (params.get("nospoiler") !== null) {
elem.classList.add("unspoilered");
} else {
const thunk = ev => {
if (!elem.classList.contains("unspoilered")) {
ev.preventDefault();
ev.stopImmediatePropagation();
elem.classList.add("unspoilered");
}
elem.removeEventListener("click", thunk);
};
elem.addEventListener("click", thunk);
if (elem instanceof HTMLAnchorElement) {
const href = elem.getAttribute("href");
if (href.startsWith("#")) {
elem.addEventListener("click", () => document.getElementById(href.substring(1)).querySelector(".spoilered").classList.add("unspoilered"));
}
}
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('details.spell-collapsible').forEach(hookLoad);
document.querySelectorAll('a.toggle-link').forEach(hookToggle);
document.querySelectorAll('.spoilered').forEach(hookSpoiler);
function tick(prevTime, time) {
const dt = time - prevTime;
for (const q of rfaQueue) {
q(dt);
}
requestAnimationFrame(t => tick(time, t));
}
requestAnimationFrame(t => tick(t, t));
});

View file

@ -0,0 +1,22 @@
{% import "macros.html.jinja" as macros %}
<h2 id="table-of-contents" class="page-header">
Table of Contents
<a href="javascript:void(0)" class="permalink toggle-link small" data_target="toc-category" title="Toggle all">
<i class="bi bi-list-nested"></i>
</a>
{{ macros.permalink("table-of-contents") }}
</h2>
{% for category in book.categories.values() %}
<details class="toc-category">
<summary>
{{ macros.maybe_spoilered_link(category) }}
</summary>
<ul>
{% for entry in category.entries %}
<li>
{{ macros.maybe_spoilered_link(entry) }}
</li>
{% endfor %}
</ul>
</details>
{% endfor %}