Add Zola templates and browser-tested site scaffold

This commit is contained in:
ms2k 2026-09-15 19:30:08 +09:00
commit a33673243e
22 changed files with 2114 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/public/
/.zola-build/
/node_modules/
/static/vendor/

26
assets.mjs Normal file
View file

@ -0,0 +1,26 @@
import { cp, mkdir, rm } from "node:fs/promises";
import { dirname } from "node:path";
await rm("static/vendor", { recursive: true, force: true });
for (const [source, target] of [
["@msgpack/msgpack/dist.esm", "msgpack"],
["@msgpack/msgpack/LICENSE", "msgpack/LICENSE"],
["mermaid/dist/mermaid.esm.min.mjs", "mermaid/mermaid.esm.min.mjs"],
["mermaid/dist/chunks/mermaid.esm.min", "mermaid/chunks/mermaid.esm.min"],
["mermaid/LICENSE", "mermaid/LICENSE"],
["mathjax/tex-chtml-nofont.js", "mathjax/tex-chtml-nofont.js"],
...["a11y", "input", "output", "sre", "ui", "LICENSE"].map((path) => [
`mathjax/${path}`,
`mathjax/${path}`,
]),
["@mathjax/mathjax-fira-font/chtml.js", "fonts/mathjax-fira-font/chtml.js"],
["@mathjax/mathjax-fira-font/chtml", "fonts/mathjax-fira-font/chtml"],
["mathjax/LICENSE", "fonts/mathjax-fira-font/LICENSE"],
]) {
await mkdir(dirname(`static/vendor/${target}`), { recursive: true });
await cp(`node_modules/${source}`, `static/vendor/${target}`, {
recursive: true,
filter: (path) => !path.endsWith(".map"),
});
}

1201
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

11
package.json Normal file
View file

@ -0,0 +1,11 @@
{
"private": true,
"type": "module",
"scripts": { "assets": "node assets.mjs" },
"dependencies": {
"@msgpack/msgpack": "3.1.3",
"mermaid": "12.0.0",
"mathjax": "4.1.3",
"@mathjax/mathjax-fira-font": "4.1.3"
}
}

BIN
static/fonts/comic.ttf Normal file

Binary file not shown.

BIN
static/fonts/neue-bold.ttf Normal file

Binary file not shown.

BIN
static/fonts/neue.ttf Normal file

Binary file not shown.

149
static/health.js Normal file
View file

@ -0,0 +1,149 @@
import { decode } from "/vendor/msgpack/index.mjs";
const target = document.querySelector("#target");
const day = document.querySelector("#day");
const detail = document.querySelector("#detail");
const observed = document.querySelector("#observed");
const percent = (value) =>
value === null ? "Unknown" : `${(value * 100).toFixed(2)}% healthy`;
function state(value) {
switch (value) {
case null:
return "unknown";
case 1:
return "good";
case 0:
return "down";
default:
return "mixed";
}
}
async function load() {
const response = await fetch(
document.querySelector("script[data-api]").dataset.api,
);
return response.ok ? decode(await response.arrayBuffer()) : null;
}
function renderRow(record, recordIndex, dates, select) {
const row = document.createElement("article");
row.className = "health-row";
const title = document.createElement("h3");
title.textContent = record.name;
const bars = document.createElement("div");
bars.className = "bars";
bars.setAttribute("role", "group");
bars.setAttribute("aria-label", `${record.name}, daily history`);
for (const [dayIndex, value] of record.values.entries()) {
const button = document.createElement("button");
button.type = "button";
button.className = `bar ${state(value)}`;
button.title = `${dates[dayIndex]}: ${percent(value)}`;
button.setAttribute("aria-label", `${record.name} · ${button.title}`);
button.setAttribute("aria-pressed", "false");
button.dataset.target = String(recordIndex);
button.dataset.day = String(dayIndex);
button.addEventListener("click", () => {
target.value = String(recordIndex);
day.value = String(dayIndex);
select();
});
bars.append(button);
}
row.append(title, bars);
return row;
}
function renderGroup(group, groupIndex, records, dates, select) {
const [header, labels, values] = group;
const section = document.createElement("section");
section.className = "health-group";
const heading = document.createElement("h2");
heading.id = `group-${groupIndex}`;
heading.textContent = `${String(groupIndex + 1).padStart(2, "0")} ${header}`;
section.setAttribute("aria-labelledby", heading.id);
section.append(heading);
for (const [seriesIndex, metric] of labels.entries()) {
const name =
Object.entries(metric)
.map(([key, value]) => `${key}=${value}`)
.join(", ") || "{}";
const record = { name, values: values[seriesIndex] };
const recordIndex = records.length;
records.push(record);
target.add(new Option(`${header} · ${name}`, String(recordIndex)));
section.append(renderRow(record, recordIndex, dates, select));
}
return section;
}
function render([start, groups]) {
const dates = Array.from({ length: 30 }, (_, index) =>
new Date((start + index * 86400) * 1000).toISOString().slice(0, 10),
);
observed.textContent = `${dates[0]} – ${dates.at(-1)} · UTC`;
const records = [];
const select = () => {
const record = records[Number(target.value)];
const dayIndex = Number(day.value);
detail.textContent = `${record.name} · ${dates[dayIndex]}: ${percent(record.values[dayIndex])}.`;
for (const bar of document.querySelectorAll(".bar")) {
const selected =
bar.dataset.target === target.value && bar.dataset.day === day.value;
bar.setAttribute("aria-pressed", String(selected));
}
};
for (const [index, date] of dates.entries()) {
day.add(new Option(date, String(index)));
}
day.value = "29";
groups.sort((left, right) => left[1].length - right[1].length);
const container = document.querySelector("#groups");
for (const [groupIndex, group] of groups.entries()) {
container.append(renderGroup(group, groupIndex, records, dates, select));
}
if (records.length) {
document.querySelector(".controls").hidden = false;
target.addEventListener("change", select);
day.addEventListener("change", select);
select();
} else {
observed.textContent = "No data";
}
}
let snapshot;
try {
snapshot = await load();
} catch {
snapshot = null;
}
if (snapshot === null) {
observed.textContent = "No data";
} else {
render(snapshot);
}
document.documentElement.dataset.ready = "true";

27
static/post.js Normal file
View file

@ -0,0 +1,27 @@
import mermaid from "/vendor/mermaid/mermaid.esm.min.mjs";
const diagrams = document.querySelectorAll('pre code[data-lang="mermaid"]');
for (const code of diagrams) {
const diagram = document.createElement("div");
diagram.className = "mermaid";
diagram.textContent = code.textContent;
code.parentElement.replaceWith(diagram);
}
mermaid.initialize({
startOnLoad: false,
theme: "neutral",
fontFamily: "Comic Neue",
});
await mermaid.run();
window.MathJax = {
loader: { paths: { fonts: "/vendor/fonts" } },
output: { font: "mathjax-fira" },
chtml: { matchFontHeight: false },
};
const script = document.createElement("script");
script.src = "/vendor/mathjax/tex-chtml-nofont.js";
document.head.append(script);

4
static/robots.txt Normal file
View file

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://quorate.dev/sitemap.xml

377
static/style.css Normal file
View file

@ -0,0 +1,377 @@
@font-face {
font-family: "Comic Neue";
src: url("/fonts/neue.ttf");
font-weight: 400;
}
@font-face {
font-family: "Comic Neue";
src: url("/fonts/neue-bold.ttf");
font-weight: 700;
}
@font-face {
font-family: "Comic Mono";
src: url("/fonts/comic.ttf");
}
:root {
font:
16px/1.5 "Comic Neue",
sans-serif;
color-scheme: light;
--paper: #f5f1e8;
--ink: #292828;
--muted: #69645f;
--rule: #cec7bc;
--band: #e7dfd2;
--link: #7b392c;
--good: #625d55;
--mixed: #b28032;
--down: #a83f32;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--paper: #181818;
--ink: #f3eee5;
--muted: #c0b9b0;
--rule: #67625c;
--band: #302e2b;
--link: #efb99b;
--good: #c0b9b0;
--mixed: #dfb366;
--down: #ed9986;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--paper: #181818;
--ink: #f3eee5;
--muted: #c0b9b0;
--rule: #67625c;
--band: #302e2b;
--link: #efb99b;
--good: #c0b9b0;
--mixed: #dfb366;
--down: #ed9986;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0 1rem;
background: var(--paper);
color: var(--ink);
}
.shell {
max-width: 44rem;
min-height: 100svh;
margin: auto;
display: flex;
flex-direction: column;
}
header,
footer {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
padding: 1rem 0;
}
header {
border-bottom: 1px solid var(--rule);
flex-wrap: wrap;
}
footer {
border-top: 1px solid var(--rule);
padding-block: 0.75rem;
color: var(--muted);
flex-wrap: wrap;
}
main {
flex: 1;
padding: 2rem 0;
min-width: 0;
}
h1 {
font-size: 1.5rem;
margin: 0 0 0.5rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
p {
margin: 0 0 0.6rem;
}
a {
color: var(--link);
text-underline-offset: 0.15em;
}
header a {
text-decoration: none;
}
nav {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.35rem;
}
nav span {
color: var(--rule);
}
.brand {
color: var(--ink);
font-weight: bold;
}
button,
select {
font: inherit;
color: inherit;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: 0;
}
button {
cursor: pointer;
}
#theme {
border: 0;
padding: 0 0.2rem;
line-height: 1;
}
#theme svg {
width: 0.8em;
height: 0.8em;
vertical-align: baseline;
}
:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 3px;
}
code,
pre,
kbd,
samp {
font-family: "Comic Mono", monospace;
font-size: 0.95em;
}
pre {
overflow-x: auto;
padding: 0.75rem;
}
blockquote {
margin-inline: 0;
padding-left: 1rem;
border-left: 2px solid var(--rule);
}
table {
border-collapse: collapse;
max-width: 100%;
display: block;
overflow-x: auto;
}
th,
td {
padding: 0.4rem 0.7rem;
border-bottom: 1px solid var(--rule);
text-align: left;
}
.post {
max-width: 48rem;
line-height: 1.65;
}
.post h2,
.post h3 {
margin-top: 1.5rem;
}
.post time {
color: var(--muted);
}
.post img,
.mermaid svg {
max-width: 100%;
height: auto;
}
.posts {
list-style: none;
margin: 0;
padding: 0;
}
.posts li {
border-bottom: 1px solid var(--rule);
}
.posts a {
display: flex;
align-items: start;
gap: 1rem;
padding: 1rem 0;
text-decoration: none;
}
.posts h2 {
margin: 0 0 0.25rem;
}
.posts p {
color: var(--ink);
}
.posts time {
color: var(--muted);
}
.posts img {
width: 25%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.masthead {
display: flex;
align-items: center;
gap: 1rem;
border-bottom: 3px double var(--ink);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.badge {
background: var(--ink);
color: var(--paper);
padding: 0.7rem;
white-space: nowrap;
}
.health-group {
margin-top: 1.25rem;
}
.health-group h2 {
font-size: 1rem;
background: var(--band);
border-block: 1px solid var(--ink);
padding: 0.35rem 0.5rem;
}
.health-row {
display: grid;
grid-template-columns: 12rem minmax(0, 1fr);
gap: 0.6rem;
padding: 0.6rem 0.5rem;
border-bottom: 1px solid var(--rule);
}
.health-row h3 {
font: inherit;
margin: 0;
overflow-wrap: anywhere;
}
.bars {
display: grid;
grid-template-columns: repeat(30, minmax(0, 1fr));
gap: 3px;
align-items: center;
}
.bar {
border: 0;
height: 1.5rem;
min-width: 0;
padding: 0;
}
.good {
background: var(--good);
}
.mixed {
background: repeating-linear-gradient(
135deg,
var(--mixed) 0 3px,
var(--paper) 3px 5px
);
}
.down {
background: var(--down);
}
.unknown {
background: transparent;
box-shadow: inset 0 0 0 1px var(--muted);
}
.bar[aria-pressed="true"] {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.controls {
display: none;
}
#detail {
margin-top: 1rem;
}
@media (max-width: 600px) {
.health-row {
grid-template-columns: 1fr;
}
.controls:not([hidden]) {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
}
.controls label {
max-width: 100%;
}
select {
max-width: 100%;
}
}

24
static/theme.js Normal file
View file

@ -0,0 +1,24 @@
const mode = localStorage.getItem("theme") || "auto";
document.documentElement.dataset.theme = mode;
document.addEventListener("DOMContentLoaded", () => {
const button = document.querySelector("#theme");
const label = () =>
button.setAttribute(
"aria-label",
`Theme: ${document.documentElement.dataset.theme}. Change theme`,
);
label();
button.addEventListener("click", () => {
const modes = ["auto", "light", "dark"];
const next =
modes[
(modes.indexOf(document.documentElement.dataset.theme) + 1) %
modes.length
];
document.documentElement.dataset.theme = next;
localStorage.setItem("theme", next);
label();
});
});

1
templates/404.html Symbolic link
View file

@ -0,0 +1 @@
base.html

28
templates/base.html Normal file
View file

@ -0,0 +1,28 @@
{% if page %}{% set content = page %}{% elif section %}{% set content = section %}{% else %}{% set content = get_page(path="404.md") %}{% endif %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ content.title }} · Quorate{% endblock title %}</title>
<link rel="stylesheet" href="/style.css">
<script src="/theme.js"></script>
</head>
<body>
<div class="shell">
<header>
<a class="brand" href="/">Quorate</a>
<nav aria-label="Main"><a href="/about/">About</a><span aria-hidden="true">|</span><a href="/blog/">Blog</a><span aria-hidden="true">|</span><a href="https://code.quorate.dev">Code</a><span aria-hidden="true">|</span><a href="/up/">Health</a></nav>
</header>
<main>{% block main %}
<article{% if content.date %} class="post"{% endif %}>
<h1>{{ content.title }}</h1>
{% if content.date %}<time datetime="{{ content.date }}">{{ content.date }}</time>{% endif %}
{{ content.content | safe }}
</article>
{% if content.date %}<script type="module" src="/post.js"></script>{% endif %}
{% endblock main %}</main>
<footer><span>Quorate · a personal website.</span><button id="theme" type="button" aria-label="Theme: Auto"><svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 4a8 8 0 0 1 0 16Z" fill="currentColor"/></svg></button></footer>
</div>
</body>
</html>

13
templates/blog.html Normal file
View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Blog · Quorate{% endblock title %}
{% block main %}
<h1>{{ section.title }}</h1>
<ul class="posts">
{% for page in section.pages %}
<li><a href="{{ page.permalink }}">
{% if page.extra.thumbnail %}<img src="{{ page.extra.thumbnail }}" alt="">{% endif %}
<div><h2>{{ page.title }}</h2><p>{{ page.description }}</p><time datetime="{{ page.date }}">{{ page.date }}</time></div>
</a></li>
{% endfor %}
</ul>
{% endblock main %}

11
templates/health.html Normal file
View file

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Health · Quorate{% endblock title %}
{% block main %}
<div class="masthead"><span class="badge">Q / 01</span><div><h1>{{ page.title }}</h1><p>Daily record</p></div></div>
<p id="observed" role="status">Loading observations…</p>
<div id="groups"></div>
<div class="controls" hidden><label>Target <select id="target"></select></label><label>Day <select id="day"></select></label></div>
<p id="detail" aria-live="polite"></p>
<noscript>JavaScript is needed to display observations.</noscript>
<script type="module" src="/health.js" data-api="{{ config.extra.health_url }}"></script>
{% endblock main %}

8
tests/listing.md Normal file
View file

@ -0,0 +1,8 @@
+++
title = "Lorem ipsum"
description = "A list entry with a thumbnail."
date = 2026-09-14
[extra]
thumbnail = "/thumbnail.jpg"
+++
Lorem ipsum dolor sit amet.

172
tests/pages.cjs Normal file
View file

@ -0,0 +1,172 @@
const assert = require("node:assert/strict");
const { Buffer } = require("node:buffer");
const { encode } = require("@msgpack/msgpack");
const { chromium } = require(process.env.PLAYWRIGHT_PATH || "playwright");
const base = process.env.SITE_URL || "http://127.0.0.1:18790";
const endpoint = "https://health.quorate.dev/health";
const paths = ["/", "/about/", "/blog/", "/404.html", "/blog/specimen/"];
const days = Array(30).fill(1);
days[1] = null;
days[2] = 23 / 24;
days[3] = 0;
const snapshot = [
1786838400,
[
["Nodes", [{ node: "one" }, { node: "two" }], [days, days]],
["Public services", [{ service: "code" }], [days]],
],
];
function mockHealth(route, mode) {
if (mode === "connectionrefused") {
return route.abort("connectionrefused");
}
return route.fulfill({
status: mode === "503" ? 503 : 200,
contentType: "application/msgpack",
body: Buffer.from(encode(snapshot)),
});
}
async function checkWidth(page) {
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
true,
);
}
async function checkHealth(page, width, mode) {
await page.route(endpoint, (route) => mockHealth(route, mode));
await page.goto(`${base}/up/`);
await page.waitForFunction(
() => document.documentElement.dataset.ready === "true",
);
const healthy = mode === "healthy";
assert.equal(await page.locator(".bar").count(), healthy ? 90 : 0);
assert.equal(
await page.locator(".controls").isVisible(),
healthy && width <= 600,
);
if (healthy) {
assert.match(
await page.locator(".health-group h2").first().textContent(),
/Public services/,
);
await page.locator(".bar").nth(2).click();
assert.match(await page.locator("#detail").textContent(), /95\.83%/);
} else {
assert.equal(await page.locator("#observed").textContent(), "No data");
}
await checkWidth(page);
await page.unroute(endpoint);
}
async function checkLayout(page) {
assert.equal(await page.locator("header nav a").count(), 4);
assert.equal(await page.locator("h1").count(), 1);
assert.equal(
await page
.locator("body")
.evaluate((node) => getComputedStyle(node).fontSize),
"16px",
);
assert.equal(
await page
.locator("h1")
.evaluate((node) => getComputedStyle(node).fontSize),
"24px",
);
assert.equal(
await page
.locator(".shell")
.evaluate((node) => getComputedStyle(node).maxWidth),
"704px",
);
await checkWidth(page);
}
async function checkBlog(page) {
assert.equal(await page.locator(".posts li").count(), 2);
assert.equal(await page.locator(".posts h2").count(), 2);
assert.equal(await page.locator(".posts p").count(), 2);
assert.equal(await page.locator(".posts img").count(), 1);
}
async function checkPost(page) {
await page.locator(".mermaid svg").waitFor();
await page.locator("mjx-container").waitFor();
assert.equal(await page.locator("blockquote").count(), 1);
assert.equal(await page.locator("table").count(), 1);
}
async function checkPage(page, path) {
await page.goto(`${base}${path}`);
await checkLayout(page);
switch (path) {
case "/about/":
assert.equal(await page.locator("article p").textContent(), "WIP");
break;
case "/blog/":
await checkBlog(page);
break;
case "/blog/specimen/":
await checkPost(page);
break;
}
}
async function main() {
const browser = await chromium.launch({
executablePath: process.env.CHROME_PATH,
headless: true,
});
let cases = 0;
try {
for (const width of [320, 390, 768, 1280]) {
for (const colorScheme of ["light", "dark"]) {
const page = await browser.newPage({
viewport: { width, height: 844 },
colorScheme,
});
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
for (const mode of ["healthy", "503", "connectionrefused"]) {
await checkHealth(page, width, mode);
cases++;
}
for (const path of paths) {
await checkPage(page, path);
cases++;
}
assert.deepEqual(errors, []);
await page.close();
}
}
console.log(`PASS: ${cases} page and health cases`);
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

11
tests/render.sh Normal file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
site_root=$(cd "$(dirname "$0")/.." && pwd)
fixture=$(mktemp -d /tmp/home-check.XXXXXX)
cp -a "$site_root/zola.toml" "$site_root/templates" "$site_root/content" "$site_root/static" "$fixture/"
cp "$site_root/tests/specimen.md" "$fixture/content/blog/specimen.md"
cp "$site_root/tests/listing.md" "$fixture/content/blog/listing.md"
cp "$site_root/tests/thumbnail.jpg" "$fixture/static/thumbnail.jpg"
zola --root "$fixture" build
printf '%s\n' "$fixture/public"

33
tests/specimen.md Normal file
View file

@ -0,0 +1,33 @@
+++
title = "Typography specimen"
description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
date = 2026-09-15
+++
A paragraph with **bold**, *emphasis*, [a link](/about/), and `inline code`.
## Lists
- One
- Two
1. First
1. Second
> A quoted paragraph.
| Column | Value |
| ------- | ----- |
| Example | 23/24 |
```rust
fn main() {
println!("Hello, Quorate.");
}
```
```mermaid
flowchart LR
Page --> API --> Metrics
```
\\[h = 1 - \\frac{1}{24}\\]

Some files were not shown because too many files have changed in this diff Show more