website: nxdns.sh on astro and starlight, with the docs and 0.0.24 screenshots
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
@@ -22,7 +22,7 @@ Convince, then teach. A marketing front page sells the project; the docs sit one
|
||||
Four sections, in this order:
|
||||
|
||||
1. Hero: the README pitch — "A DNS sinkhole for your LAN. It blocks the names you do not want, and forwards the rest over an encrypted connection." — with the README's flow diagram animated. The install commands sit in their own section near the end, in three tabs: docker, tarball, nix. Each tab is a complete path or names the guide that finishes it; the docker tab writes the smallest config that starts, so nobody faces an empty file. Commands use no shell variables and plain `curl -O`. The registry allows anonymous pulls (verified 2026-10-02 against v0.0.21), so the Docker tab works without an account. The site goes live only with a release that contains aa8c9e2 (the compose file pins its own version, so the tab ends in a plain `docker compose up -d`) and f351469 (the `services.nxdns` NixOS module the nix tab uses): 0.0.23 or later.
|
||||
2. Screenshots of the web UI, taken from a running instance with realistic data: Overview, Activity, Query detail, Clients, Diagnostics. Each comes in the admin UI's light and dark theme, behind a light/dark switch with light first. Phones get their own set, captured at a 390px viewport where the admin UI switches to its drawer layout, served through `<picture>`, so a phone shows readable screens instead of a shrunken desktop. All twenty images come from one session on the release the site launches with; the current set is 0.0.21 desktop-only.
|
||||
2. Screenshots of the web UI, taken from a running instance with realistic data: Overview, Activity, Query detail, Clients, Diagnostics. Each comes in the admin UI's light and dark theme, behind a light/dark switch with light first. Phones get their own set, captured at a 390px viewport where the admin UI switches to its drawer layout, served through `<picture>`, so a phone shows readable screens instead of a shrunken desktop. All twenty images come from one session on the release the site launches with; the current set is from 0.0.24, shot on 2026-10-21 with `website/demo/`.
|
||||
3. Feature grid: filtering, config-as-code, DoH/DoT, per-client groups, metrics, query log.
|
||||
4. Comparison table vs Pi-hole, AdGuard Home, Technitium — only to help people choose. Seven rows: how it ships; config as a file in git that the tool converges onto; encrypted upstreams built in; serves DoH/DoT to clients; signed, reproducible releases; DHCP server; DNSSEC validation. The last two are rows nxdns loses, kept because a table that only lists wins does not help anyone choose. Competitor cells were checked against Pi-hole 6.5 (FTL 6.8), AdGuard Home 0.107.79 and Technitium 15.6.2 on 2026-10-19.
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import { unified } from "@astrojs/markdown-remark";
|
||||
import starlight from "@astrojs/starlight";
|
||||
import { docsSidebarGroups } from "./src/lib/docs-sidebar";
|
||||
import { remarkRepoLinks } from "./src/lib/remark-repo-links";
|
||||
import { repoUrl } from "./src/lib/forge";
|
||||
import { readRelease } from "./src/lib/repo";
|
||||
|
||||
export default defineConfig({
|
||||
site: "https://nxdns.sh",
|
||||
trailingSlash: "always",
|
||||
markdown: { processor: unified({ remarkPlugins: [remarkRepoLinks] }) },
|
||||
vite: { define: { __NXDNS_RELEASE__: JSON.stringify(readRelease()) } },
|
||||
integrations: [
|
||||
starlight({
|
||||
title: "nxdns",
|
||||
description: "A DNS sinkhole for your LAN.",
|
||||
favicon: "/favicon.svg",
|
||||
// ZON is Zig's literal syntax; Shiki ships no grammar under its own name.
|
||||
expressiveCode: { shiki: { langAlias: { zon: "zig" } } },
|
||||
social: [{ icon: "seti:git", label: "Source", href: repoUrl }],
|
||||
customCss: ["./src/styles/fonts.css", "./src/styles/tokens.css", "./src/styles/starlight.css"],
|
||||
components: {
|
||||
SiteTitle: "./src/components/starlight/SiteTitle.astro",
|
||||
ThemeProvider: "./src/components/starlight/ThemeProvider.astro",
|
||||
ThemeSelect: "./src/components/starlight/ThemeSelect.astro",
|
||||
},
|
||||
sidebar: [{ label: "Overview", link: "/docs/" }, ...docsSidebarGroups(), { label: "Changelog", link: "/changelog/" }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -65,7 +65,7 @@ npm ci
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
Then shoot all five pages in light and dark at 1440×900, 2× scale, and build a contact sheet to review them:
|
||||
Then shoot all five pages in light and dark, at 1440×900 and 2× scale for desktops and at 390×844 and 3× scale for phones, and build a contact sheet to review them:
|
||||
|
||||
```sh
|
||||
node gen/shoot.mjs shots "$(./db.sh /gen/blocked_query_id.py /data)"
|
||||
|
||||
@@ -13,14 +13,15 @@ PAGES = [
|
||||
]
|
||||
|
||||
|
||||
def img(name, scheme):
|
||||
with open(f"{SHOTS}/{name}-{scheme}.png", "rb") as f:
|
||||
def img(name, scheme, form=""):
|
||||
with open(f"{SHOTS}/{name}-{scheme}{form}.png", "rb") as f:
|
||||
data = base64.b64encode(f.read()).decode()
|
||||
return f'<figure><img alt="{name} {scheme}" src="data:image/png;base64,{data}"><figcaption>{scheme}</figcaption></figure>'
|
||||
return f'<figure><img alt="{name} {scheme}{form}" src="data:image/png;base64,{data}"><figcaption>{scheme}{form}</figcaption></figure>'
|
||||
|
||||
|
||||
rows = "\n".join(
|
||||
f'<section><h2>{html.escape(caption)}</h2><div class="pair">{img(name, "light")}{img(name, "dark")}</div></section>'
|
||||
f'<section><h2>{html.escape(caption)}</h2><div class="pair">{img(name, "light")}{img(name, "dark")}</div>'
|
||||
f'<div class="pair phone">{img(name, "light", "-phone")}{img(name, "dark", "-phone")}</div></section>'
|
||||
for name, caption in PAGES
|
||||
)
|
||||
page = f"""<!doctype html>
|
||||
@@ -33,12 +34,13 @@ body {{ margin: 0; padding: 24px 16px; background: var(--bg); color: var(--fg);
|
||||
h1 {{ font-size: 20px; margin: 0 0 24px; }}
|
||||
h2 {{ font-size: 15px; font-weight: 500; margin: 32px 0 12px; }}
|
||||
.pair {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
|
||||
.phone {{ grid-template-columns: repeat(2, minmax(0, 300px)); margin-top: 16px; }}
|
||||
@media (max-width: 800px) {{ .pair {{ grid-template-columns: 1fr; }} }}
|
||||
figure {{ margin: 0; }}
|
||||
img {{ width: 100%; height: auto; border: 1px solid var(--line); border-radius: 6px; display: block; }}
|
||||
figcaption {{ color: var(--muted); font-size: 13px; margin-top: 4px; }}
|
||||
</style></head>
|
||||
<body><h1>nxdns admin — marketing screenshots (1440×900 @2x)</h1>
|
||||
<body><h1>nxdns admin — marketing screenshots (1440×900 @2x, phone 390×844 @3x)</h1>
|
||||
{rows}
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
@@ -21,11 +21,17 @@ async function settle(page) {
|
||||
await page.waitForTimeout(2500);
|
||||
}
|
||||
|
||||
// The phone form is 390px wide, where the admin UI switches to its drawer layout, and touch-only, so
|
||||
// no hover state is drawn.
|
||||
const FORMS = [
|
||||
["", { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }],
|
||||
["-phone", { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true }],
|
||||
];
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [suffix, form] of FORMS) for (const scheme of ["light", "dark"]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
...form,
|
||||
colorScheme: scheme,
|
||||
timezoneId: "Europe/Paris",
|
||||
locale: "en-GB",
|
||||
@@ -38,10 +44,15 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.waitForURL((u) => !u.pathname.startsWith("/login"));
|
||||
for (const [name, path] of PAGES) {
|
||||
await page.goto(BASE + path);
|
||||
await page.mouse.move(1430, 890);
|
||||
if (!form.hasTouch) await page.mouse.move(1430, 890);
|
||||
await settle(page);
|
||||
if (name === "activity") await page.waitForTimeout(8000);
|
||||
const file = `${outDir}/${name}-${scheme}.png`;
|
||||
// A phone stacks the health cards and filters above the episodes, so the first screen shows none.
|
||||
if (name === "diagnostics" && form.hasTouch) {
|
||||
await page.getByRole("heading", { name: "Active" }).evaluate((h) => h.scrollIntoView({ block: "start" }));
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
const file = `${outDir}/${name}-${scheme}${suffix}.png`;
|
||||
await page.screenshot({ path: file });
|
||||
console.log(file);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# docker twice. The data lives in a named volume so no file in this tree is owned by that uid.
|
||||
set -eu
|
||||
DEMO=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
IMAGE=${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:0.0.21}
|
||||
IMAGE=${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:0.0.24}
|
||||
docker network inspect nxdemo-lan >/dev/null 2>&1 ||
|
||||
docker network create --driver bridge --subnet 192.168.50.0/24 --gateway 192.168.50.1 nxdemo-lan
|
||||
if ! docker volume inspect nxdemo-data >/dev/null 2>&1; then
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "nxdns-website",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build && node scripts/check-links.mjs",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "7.3.2",
|
||||
"@astrojs/starlight": "0.42.6",
|
||||
"@fontsource-variable/figtree": "5.3.0",
|
||||
"@fontsource-variable/fraunces": "5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "5.3.0",
|
||||
"@types/mdast": "4.0.4",
|
||||
"astro": "7.3.8",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"vfile": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#1c1b18"/><path d="M10 10l12 12M22 10L10 22" stroke="#f2a93b" stroke-width="3.5" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 214 B |
@@ -0,0 +1,40 @@
|
||||
// Fails if any page in dist/ links to a site path or an #anchor that does not exist.
|
||||
// The docs are written for the forge and turned into routes at build time, so a
|
||||
// renamed page or heading surfaces here instead of as a 404 for a reader.
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const dist = new URL("../dist/", import.meta.url).pathname;
|
||||
|
||||
const pages = readdirSync(dist, { recursive: true })
|
||||
.filter((f) => f.endsWith(".html"))
|
||||
.map((f) => join(dist, f));
|
||||
|
||||
const ids = new Map();
|
||||
const idsOf = (file) => {
|
||||
if (!ids.has(file)) ids.set(file, new Set([...readFileSync(file, "utf8").matchAll(/\sid="([^"]+)"/g)].map((m) => m[1])));
|
||||
return ids.get(file);
|
||||
};
|
||||
|
||||
const pageOf = (path) => {
|
||||
const file = path.endsWith("/") ? join(dist, path, "index.html") : join(dist, path);
|
||||
return existsSync(file) ? file : null;
|
||||
};
|
||||
|
||||
const broken = [];
|
||||
for (const page of pages) {
|
||||
const from = "/" + relative(dist, page).replace(/index\.html$/, "");
|
||||
for (const [, href] of readFileSync(page, "utf8").matchAll(/\shref="([^"]+)"/g)) {
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) continue;
|
||||
const [path, hash] = href.split("#");
|
||||
const target = path === "" ? page : pageOf(path);
|
||||
if (!target) broken.push(`${from} -> ${href} (no page)`);
|
||||
else if (hash && !idsOf(target).has(decodeURIComponent(hash))) broken.push(`${from} -> ${href} (no #${hash})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (broken.length) {
|
||||
console.error(`${broken.length} broken link(s):\n${[...new Set(broken)].join("\n")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`links: ${pages.length} pages checked, no broken links`);
|
||||
|
After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 578 KiB After Width: | Height: | Size: 557 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 546 KiB After Width: | Height: | Size: 531 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 225 KiB |
@@ -0,0 +1,22 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
const { class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<a class:list={["wordmark", className]} href="/" aria-label="nxdns home">n<i>x</i>dns</a>
|
||||
|
||||
<style>
|
||||
.wordmark {
|
||||
font: 600 24px/1 var(--serif);
|
||||
font-variation-settings: "SOFT" 100, "opsz" 72;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
i {
|
||||
font-style: normal;
|
||||
color: var(--amber-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
import { release } from "../../lib/release";
|
||||
|
||||
/** A note may hold `code` spans, written with backticks. */
|
||||
type Cell = { verdict?: "Yes" | "No" | "Partial"; note?: string };
|
||||
type Row = { label: string; loses?: boolean; cells: [Cell, Cell, Cell, Cell] };
|
||||
|
||||
/** Competitor cells were checked on the date in `checked` against these versions; re-check them before each publication. */
|
||||
const tools = [
|
||||
{ name: "Pi-hole", version: "6.5 (FTL 6.8)" },
|
||||
{ name: "AdGuard Home", version: "0.107.79" },
|
||||
{ name: "Technitium", version: "15.6.2" },
|
||||
];
|
||||
const checked = "2026-10-19";
|
||||
|
||||
const rows: Row[] = [
|
||||
{
|
||||
label: "How it ships",
|
||||
cells: [
|
||||
{ note: "One static binary with the web UI in it; tarball, Docker, Nix" },
|
||||
{ note: "FTL daemon, bash CLI and web UI files; bash install script or Docker" },
|
||||
{ note: "One Go binary with the web UI in it; script, archive, Docker, Snap" },
|
||||
{ note: "One .NET process that needs the ASP.NET Core 10 runtime" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Config as a file in git that the tool converges onto",
|
||||
cells: [
|
||||
{ verdict: "Yes", note: "`--config`" },
|
||||
{ verdict: "Partial", note: "The UI rewrites `pihole.toml`; lists and rules are in SQLite" },
|
||||
{ verdict: "Partial", note: "A running instance overwrites the YAML file" },
|
||||
{ verdict: "No", note: "A binary file that the app writes" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Encrypted upstreams, built in",
|
||||
cells: [
|
||||
{ verdict: "Yes", note: "DoH, DoT" },
|
||||
{ verdict: "No", note: "Needs dnscrypt-proxy" },
|
||||
{ verdict: "Yes", note: "DoH, DoT, DoQ, DNSCrypt" },
|
||||
{ verdict: "Yes", note: "DoH, DoT, DoQ" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Serves DoH/DoT to clients",
|
||||
cells: [{ verdict: "Yes" }, { verdict: "No" }, { verdict: "Yes", note: "Also DoQ, DNSCrypt" }, { verdict: "Yes", note: "Also DoQ" }],
|
||||
},
|
||||
{
|
||||
label: "Signed, reproducible releases",
|
||||
cells: [
|
||||
{ verdict: "Yes", note: "Both" },
|
||||
{ verdict: "No", note: "SHA1 integrity check only" },
|
||||
{ verdict: "Partial", note: "Signed binary, no published key" },
|
||||
{ verdict: "No", note: "SHA256 checksums only" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "DHCP server",
|
||||
loses: true,
|
||||
cells: [{ verdict: "No" }, { verdict: "Yes" }, { verdict: "Yes", note: "Not on Windows" }, { verdict: "Yes" }],
|
||||
},
|
||||
{
|
||||
label: "DNSSEC validation",
|
||||
loses: true,
|
||||
cells: [{ verdict: "No", note: "DO bit passthrough" }, { verdict: "Yes", note: "Off by default" }, { verdict: "No", note: "Sets the bits only" }, { verdict: "Yes" }],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="compare wrap" aria-labelledby="compare-h">
|
||||
<div class="split-head">
|
||||
<div>
|
||||
<p class="eyebrow">;; COMPARISON</p>
|
||||
<h2 id="compare-h">How it compares</h2>
|
||||
</div>
|
||||
<p>To help you choose. nxdns has no DHCP server and does not validate DNSSEC; if you need either, pick a tool that does.</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<th scope="col" class="us">nxdns<small>{release}</small></th>
|
||||
{
|
||||
tools.map((t) => (
|
||||
<th scope="col">
|
||||
{t.name}
|
||||
<small>{t.version}</small>
|
||||
</th>
|
||||
))
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
rows.map((row) => (
|
||||
<tr class:list={{ loses: row.loses }}>
|
||||
<th scope="row">{row.label}</th>
|
||||
{row.cells.map((cell, i) => (
|
||||
<td class:list={{ us: i === 0 }}>
|
||||
{cell.verdict && <span class="verdict">{cell.verdict}</span>}
|
||||
{cell.note?.split("`").map((part, j) => (j % 2 === 1 ? <code>{part}</code> : part))}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="note">checked {checked} against the versions named in each column</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.compare {
|
||||
padding-block: 64px 112px;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: 20px;
|
||||
background: var(--card);
|
||||
box-shadow: 0 0 0 1px var(--line);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 860px;
|
||||
border-collapse: collapse;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
tbody tr:last-child > * {
|
||||
border-bottom: 0;
|
||||
}
|
||||
thead th {
|
||||
padding-top: 22px;
|
||||
font: 500 21px/1.1 var(--serif);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font: 12px var(--mono);
|
||||
color: var(--muted);
|
||||
}
|
||||
tbody th {
|
||||
width: 22%;
|
||||
font-weight: 500;
|
||||
}
|
||||
td {
|
||||
color: var(--muted);
|
||||
}
|
||||
.us {
|
||||
background: rgb(242 169 59 / 0.14);
|
||||
color: var(--ink);
|
||||
}
|
||||
thead .us {
|
||||
box-shadow: inset 0 3px 0 var(--amber);
|
||||
}
|
||||
.verdict {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
font: 500 13px var(--mono);
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink);
|
||||
}
|
||||
.loses th,
|
||||
.loses td {
|
||||
background: repeating-linear-gradient(-45deg, transparent 0 8px, rgb(161 77 10 / 0.05) 8px 9px);
|
||||
}
|
||||
.loses td.us {
|
||||
background-color: transparent;
|
||||
}
|
||||
code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: rgb(28 27 24 / 0.06);
|
||||
font-size: 13px;
|
||||
}
|
||||
.note {
|
||||
margin: 14px 4px 0;
|
||||
font: 12px var(--mono);
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<section class="features wrap" aria-labelledby="features-h">
|
||||
<div class="split-head">
|
||||
<div>
|
||||
<p class="eyebrow">;; FEATURES</p>
|
||||
<h2 id="features-h">Small on purpose</h2>
|
||||
</div>
|
||||
<p>Built for one household and the devices in it, on a Raspberry Pi or any Linux machine.</p>
|
||||
</div>
|
||||
<ol>
|
||||
<li>
|
||||
<h3>Filtering</h3>
|
||||
<p>Hosts files, plain domain lists and Adblock Plus lists, plus your own allow and block rules. Each answer records the rule that decided it.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Config as code</h3>
|
||||
<p>Point <code>--config</code> at one ZON file in git. nxdns converges onto it at every start, and the admin UI refuses edits that would drift from it.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>DoH and DoT</h3>
|
||||
<p>Forwards over HTTPS or TLS with failover between upstreams, and serves DoH and DoT to your own devices.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Per-client groups</h3>
|
||||
<p>Give the kids’ phone its own lists and safe search. Every other device keeps the default.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Metrics</h3>
|
||||
<p>Prometheus metrics at <code>/metrics</code> for queries, the cache and each upstream.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Query log</h3>
|
||||
<p>Searchable history in SQLite, pruned after 30 days by default, so the disk use stays bounded.</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.features {
|
||||
padding-block: 112px 48px;
|
||||
}
|
||||
ol {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
counter-reset: feature;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
ol {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 0 12px;
|
||||
padding: 24px 0 26px;
|
||||
border-top: 1px solid var(--line);
|
||||
counter-increment: feature;
|
||||
}
|
||||
li::before {
|
||||
content: counter(feature, decimal-leading-zero);
|
||||
font: 500 12px/2.4 var(--mono);
|
||||
color: var(--amber-text);
|
||||
}
|
||||
h3 {
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
li p {
|
||||
grid-column: 2;
|
||||
margin: 8px 0 0;
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
}
|
||||
code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: rgb(28 27 24 / 0.06);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
/* The diagram is drawn by script, because its layout follows the viewport width and its packets are measured text. */
|
||||
---
|
||||
|
||||
<figure class="flow">
|
||||
<svg
|
||||
id="flow"
|
||||
role="img"
|
||||
aria-label="Your devices send DNS queries to nxdns. nxdns forwards allowed names to upstream resolvers over DoH or DoT and returns their answers. It answers blocked names itself with 0.0.0.0 or NXDOMAIN."
|
||||
></svg>
|
||||
</figure>
|
||||
|
||||
<script>
|
||||
import { startFlow } from "../../scripts/flow";
|
||||
startFlow(document.getElementById("flow") as unknown as SVGSVGElement);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.flow {
|
||||
margin: 56px 0 0;
|
||||
padding: 8px;
|
||||
border-radius: 28px;
|
||||
background: var(--card);
|
||||
box-shadow: 0 0 0 1px var(--line);
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1000 / 360;
|
||||
user-select: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
svg {
|
||||
aspect-ratio: 360 / 620;
|
||||
}
|
||||
}
|
||||
.flow :global(.edge) {
|
||||
fill: none;
|
||||
stroke: var(--line-strong);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.flow :global(.edge.live) {
|
||||
stroke: var(--ink);
|
||||
stroke-width: 2;
|
||||
transition: stroke 150ms ease;
|
||||
}
|
||||
.flow :global(.edge.secure) {
|
||||
stroke: var(--ink);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 2 6;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.flow :global(.edge-label) {
|
||||
font: 500 12px var(--mono);
|
||||
fill: var(--muted);
|
||||
}
|
||||
.flow :global(.node rect) {
|
||||
fill: var(--paper);
|
||||
stroke: var(--line-strong);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.flow :global(.node .title) {
|
||||
font: 500 17px var(--sans);
|
||||
fill: var(--ink);
|
||||
}
|
||||
.flow :global(.node .sub) {
|
||||
font: 12px var(--mono);
|
||||
fill: var(--muted);
|
||||
}
|
||||
.flow :global(.node.core rect) {
|
||||
fill: var(--ink);
|
||||
stroke: none;
|
||||
}
|
||||
.flow :global(.node.core .title) {
|
||||
font: 600 26px var(--serif);
|
||||
font-variation-settings: "SOFT" 100, "opsz" 72;
|
||||
letter-spacing: -0.02em;
|
||||
fill: var(--on-ink);
|
||||
}
|
||||
.flow :global(.node.core tspan) {
|
||||
fill: var(--amber);
|
||||
}
|
||||
.flow :global(.node.core .pulse) {
|
||||
fill: none;
|
||||
stroke: var(--amber);
|
||||
stroke-width: 2;
|
||||
opacity: 0;
|
||||
}
|
||||
.flow :global(.node.hole rect) {
|
||||
stroke: var(--amber-text);
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
.flow :global(.pkt rect) {
|
||||
stroke-width: 1;
|
||||
}
|
||||
.flow :global(.pkt text) {
|
||||
font: 500 12px var(--mono);
|
||||
}
|
||||
.flow :global(.pkt.query rect) {
|
||||
fill: var(--card);
|
||||
stroke: var(--ink);
|
||||
}
|
||||
.flow :global(.pkt.query text) {
|
||||
fill: var(--ink);
|
||||
}
|
||||
.flow :global(.pkt.sealed rect),
|
||||
.flow :global(.pkt.answer rect) {
|
||||
fill: var(--ink);
|
||||
stroke: var(--ink);
|
||||
}
|
||||
.flow :global(.pkt.sealed text) {
|
||||
fill: var(--amber);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.flow :global(.pkt.answer text) {
|
||||
fill: var(--on-ink);
|
||||
}
|
||||
.flow :global(.pkt.blocked rect) {
|
||||
fill: var(--amber);
|
||||
stroke: var(--amber);
|
||||
}
|
||||
.flow :global(.pkt.blocked text) {
|
||||
fill: var(--ink);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.flow :global(.pkt.sink rect) {
|
||||
fill: var(--paper);
|
||||
stroke: var(--amber-text);
|
||||
}
|
||||
.flow :global(.pkt.sink text) {
|
||||
fill: var(--amber-text);
|
||||
}
|
||||
.flow :global(.pkt.tag.allowed rect) {
|
||||
fill: var(--paper);
|
||||
stroke: var(--ink);
|
||||
}
|
||||
.flow :global(.pkt.tag text) {
|
||||
fill: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
import Wordmark from "../Wordmark.astro";
|
||||
import { repoUrl } from "../../lib/forge";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Docs",
|
||||
links: [
|
||||
["Get started", "/docs/tutorial/get-started/"],
|
||||
["How-to guides", "/docs/#how-to-guides"],
|
||||
["Reference", "/docs/#reference"],
|
||||
["Explanation", "/docs/#explanation"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Project",
|
||||
links: [
|
||||
["Changelog", "/changelog/"],
|
||||
["Releases", `${repoUrl}/releases`],
|
||||
["Verify a release", "/docs/how-to/verify-a-release/"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Source",
|
||||
links: [
|
||||
["git.mial.net/mokhtar/nxdns", repoUrl],
|
||||
["Report a problem", `${repoUrl}/issues`],
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="wrap">
|
||||
<div class="cols">
|
||||
<div><Wordmark class="foot-mark" /></div>
|
||||
{
|
||||
columns.map((c) => (
|
||||
<div>
|
||||
<h2>{c.title}</h2>
|
||||
<ul>
|
||||
{c.links.map(([label, href]) => (
|
||||
<li>
|
||||
<a href={href}>{label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<p class="zone"><b>nxdns.sh.</b> 3600 IN TXT "A DNS sinkhole for your LAN. Licensed under the EUPL."</p>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
footer {
|
||||
padding-top: 64px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.cols {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
.cols :global(.foot-mark) {
|
||||
display: inline-block;
|
||||
font-size: 56px;
|
||||
line-height: 0.9;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 10px;
|
||||
font: 500 12px var(--mono);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
li a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
li a:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
}
|
||||
.zone {
|
||||
margin: 56px 0 0;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
font: 13px var(--mono);
|
||||
color: var(--muted);
|
||||
}
|
||||
b {
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import FlowDiagram from "./FlowDiagram.astro";
|
||||
---
|
||||
|
||||
<section class="hero wrap" aria-labelledby="hero-h">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<p class="eyebrow">;; nxdns.sh</p>
|
||||
<h1 id="hero-h">A DNS sinkhole for your LAN</h1>
|
||||
</div>
|
||||
<div>
|
||||
<p class="lede">It blocks the names you do not want, and forwards the rest over an encrypted connection.</p>
|
||||
<div class="actions">
|
||||
<a class="btn" href="#install">Install</a>
|
||||
<a class="arrow" href="/docs/">Read the docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FlowDiagram />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block: 80px 56px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 40px;
|
||||
align-items: end;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(52px, 7.4vw, 96px);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.03em;
|
||||
font-variation-settings: "SOFT" 50, "opsz" 144;
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
max-width: 38ch;
|
||||
font-size: 19px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
align-items: center;
|
||||
margin-top: 28px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
import { repoUrl } from "../../lib/forge";
|
||||
import { release } from "../../lib/release";
|
||||
|
||||
const download = `${repoUrl}/releases/download/v${release}`;
|
||||
const tarball = `nxdns-${release}-x86_64-linux-musl.tar.gz`;
|
||||
|
||||
/**
|
||||
* Every snippet runs as shown: no shell variables, and plain `curl -O`. None of
|
||||
* these URLs redirects, and a failed download is caught by the next line
|
||||
* (`docker compose` cannot parse it, `gpg` finds no key, `sha256sum` fails).
|
||||
*/
|
||||
const methods = [
|
||||
{
|
||||
key: "docker",
|
||||
code: `mkdir -p ~/nxdns/etc-nxdns && cd ~/nxdns
|
||||
curl -O ${repoUrl}/raw/tag/v${release}/deploy/docker/compose.yaml
|
||||
cat > etc-nxdns/config.zon <<'EOF'
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
|
||||
.web = .{ .password = "choose-a-real-password" },
|
||||
}
|
||||
EOF
|
||||
docker compose up -d`,
|
||||
next: "Then point your router’s DNS at this machine. The admin UI is on port 8080.",
|
||||
guide: ["Install with Docker", "/docs/how-to/install-with-docker/"],
|
||||
},
|
||||
{
|
||||
key: "tarball",
|
||||
code: `curl https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346FD1509B54946D08A95 | gpg --import
|
||||
curl -O ${download}/${tarball}
|
||||
curl -O ${download}/SHA256SUMS.txt
|
||||
curl -O ${download}/SHA256SUMS.txt.asc
|
||||
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
|
||||
sha256sum --check --ignore-missing SHA256SUMS.txt
|
||||
tar -xzf ${tarball}`,
|
||||
next: "Then install the binary and the systemd unit from the extracted directory, and write the config.",
|
||||
guide: ["Install with systemd", "/docs/how-to/install-with-systemd/"],
|
||||
},
|
||||
{
|
||||
key: "nix",
|
||||
code: `# flake.nix
|
||||
inputs.nxdns = {
|
||||
url = "git+${repoUrl}.git?ref=refs/tags/v${release}";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# configuration.nix
|
||||
{ inputs, ... }:
|
||||
{
|
||||
imports = [ inputs.nxdns.nixosModules.default ];
|
||||
services.nxdns = {
|
||||
enable = true;
|
||||
configFile = ./nxdns-config.zon;
|
||||
openFirewall = true;
|
||||
};
|
||||
}`,
|
||||
next: "Then point your router’s DNS at this machine. The guide shows what goes in the config file.",
|
||||
guide: ["Install with Nix", "/docs/how-to/install-with-nix/"],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="install on-ink slab" id="install" aria-labelledby="install-h">
|
||||
<div class="wrap">
|
||||
<div class="split-head">
|
||||
<div>
|
||||
<p class="eyebrow">;; INSTALL</p>
|
||||
<h2 id="install-h">Run it on your network</h2>
|
||||
</div>
|
||||
<p class="muted">Pick one way to run it. Each runs the same signed release, and the Docker image pulls without an account.</p>
|
||||
</div>
|
||||
<div class="cmd">
|
||||
<div class="bar">
|
||||
<div role="tablist" aria-label="Install method">
|
||||
{
|
||||
methods.map((m, i) => (
|
||||
<button class="tab" role="tab" id={`install-tab-${m.key}`} aria-controls={`install-${m.key}`} aria-selected={String(i === 0)} tabindex={i === 0 ? 0 : -1}>
|
||||
{m.key}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<button class="copy" type="button" aria-label="Copy commands">
|
||||
<svg class="idle" width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"
|
||||
><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"></rect><path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2"></path></svg
|
||||
>
|
||||
<svg class="done" width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"
|
||||
><path d="m3.5 8.5 3 3 6-7"></path></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div class="panels">
|
||||
{
|
||||
methods.map((m, i) => (
|
||||
<div class="panel" role="tabpanel" id={`install-${m.key}`} aria-labelledby={`install-tab-${m.key}`} inert={i !== 0}>
|
||||
<pre><code>{m.code}</code></pre>
|
||||
<p class="next">
|
||||
<span>{m.next}</span>
|
||||
<a class="arrow" href={m.guide[1]}>
|
||||
{m.guide[0]}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions"><a class="btn" href="/docs/tutorial/get-started/">Get started</a></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { tabs } from "../../scripts/tabs";
|
||||
|
||||
const section = document.getElementById("install")!;
|
||||
let active: HTMLElement;
|
||||
tabs(section.querySelector("[role=tablist]")!, (tab) => {
|
||||
for (const panel of section.querySelectorAll<HTMLElement>("[role=tabpanel]")) {
|
||||
panel.inert = panel.id !== tab.getAttribute("aria-controls");
|
||||
if (!panel.inert) active = panel;
|
||||
}
|
||||
});
|
||||
const copy = section.querySelector<HTMLButtonElement>(".copy")!;
|
||||
let reset: ReturnType<typeof setTimeout>;
|
||||
copy.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(active.querySelector("code")!.textContent ?? "");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
copy.dataset.copied = "";
|
||||
copy.setAttribute("aria-label", "Copied");
|
||||
clearTimeout(reset);
|
||||
reset = setTimeout(() => {
|
||||
delete copy.dataset.copied;
|
||||
copy.setAttribute("aria-label", "Copy commands");
|
||||
}, 1500);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.install {
|
||||
padding: 96px 0;
|
||||
}
|
||||
.split-head p {
|
||||
color: var(--on-ink-muted);
|
||||
}
|
||||
.cmd {
|
||||
border-radius: 18px;
|
||||
background: var(--ink-2);
|
||||
box-shadow: 0 0 0 1px var(--line-dark);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6px 0 8px;
|
||||
border-bottom: 1px solid var(--line-dark);
|
||||
}
|
||||
[role="tablist"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
/* All panels share one grid cell, so the box is as tall as the longest one and a tab switch moves nothing. */
|
||||
.panels {
|
||||
display: grid;
|
||||
}
|
||||
.panel {
|
||||
grid-area: 1 / 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 8px 20px 18px;
|
||||
}
|
||||
.panel[inert] {
|
||||
visibility: hidden;
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 12px 0 18px;
|
||||
overflow-x: auto;
|
||||
font: 13px/1.75 var(--mono);
|
||||
}
|
||||
.next {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 14px;
|
||||
margin: auto 0 0;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line-dark);
|
||||
font-size: 14px;
|
||||
color: var(--on-ink-muted);
|
||||
}
|
||||
.next .arrow {
|
||||
color: var(--on-ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.copy {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: none;
|
||||
color: var(--on-ink-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease,
|
||||
transform 150ms var(--ease-out);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.copy:hover {
|
||||
color: var(--on-ink);
|
||||
background: rgb(245 242 234 / 0.08);
|
||||
}
|
||||
}
|
||||
.copy:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.copy svg {
|
||||
grid-area: 1 / 1;
|
||||
transition:
|
||||
opacity 150ms var(--ease-out),
|
||||
transform 150ms var(--ease-out);
|
||||
}
|
||||
.copy .done,
|
||||
.copy[data-copied] .idle {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
.copy[data-copied] .done {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 28px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
---
|
||||
import { getImage } from "astro:assets";
|
||||
import type { ImageMetadata } from "astro";
|
||||
|
||||
const files = import.meta.glob<{ default: ImageMetadata }>("../../assets/screenshots/*.png", { eager: true });
|
||||
const image = (name: string) => {
|
||||
const file = files[`../../assets/screenshots/${name}.png`];
|
||||
if (!file) throw new Error(`missing screenshot ${name}.png`);
|
||||
return file.default;
|
||||
};
|
||||
|
||||
const screens = [
|
||||
{ key: "overview", label: "Overview", caption: "Overview: a day of queries, the blocked share, and which device made them." },
|
||||
{ key: "activity", label: "Activity", caption: "Activity: every query with its client, result and route." },
|
||||
{ key: "query-detail", label: "Query detail", caption: "Query detail: the group, rule, list and upstream that decided one answer, as recorded at that moment." },
|
||||
{ key: "clients", label: "Clients", caption: "Clients: each device, its group, and what it asked for." },
|
||||
{ key: "diagnostics", label: "Diagnostics", caption: "Diagnostics: upstream faults open an episode and close it when the upstream recovers." },
|
||||
];
|
||||
const selected = "activity";
|
||||
const themes = ["light", "dark"] as const;
|
||||
|
||||
/**
|
||||
* Phones get their own shots, taken at the 390px viewport where the admin UI uses its drawer layout,
|
||||
* so they show readable screens instead of a shrunken desktop.
|
||||
*/
|
||||
const phone = "(max-width: 600px)";
|
||||
const forms = {
|
||||
phone: { suffix: "-phone", widths: [400, 800, 1170], sizes: "calc(100vw - 44px)" },
|
||||
desktop: { suffix: "", widths: [530, 1060, 1440], sizes: "(max-width: 1084px) calc(100vw - 44px), 1040px" },
|
||||
};
|
||||
|
||||
async function variant(key: string, theme: string, form: keyof typeof forms, format: "avif" | "webp") {
|
||||
const { suffix, widths, sizes } = forms[form];
|
||||
const src = image(`${key}-${theme}${suffix}`);
|
||||
const images = await Promise.all(widths.map((width) => getImage({ src, width, format })));
|
||||
return {
|
||||
srcset: images.map((im, i) => `${im.src} ${widths[i]}w`).join(", "),
|
||||
src: images.at(-1)!.src,
|
||||
sizes,
|
||||
width: src.width,
|
||||
height: src.height,
|
||||
type: `image/${format}`,
|
||||
};
|
||||
}
|
||||
|
||||
const pictures = Object.fromEntries(
|
||||
await Promise.all(
|
||||
screens.flatMap((s) =>
|
||||
themes.map(async (t) => [
|
||||
`${s.key}-${t}`,
|
||||
{
|
||||
phone: await Promise.all([variant(s.key, t, "phone", "avif"), variant(s.key, t, "phone", "webp")]),
|
||||
desktop: await variant(s.key, t, "desktop", "avif"),
|
||||
fallback: await variant(s.key, t, "desktop", "webp"),
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
---
|
||||
|
||||
<section class="screens on-ink slab" aria-labelledby="screens-h" data-shots="light">
|
||||
<div class="wrap">
|
||||
<div class="split-head">
|
||||
<div>
|
||||
<p class="eyebrow">;; ANSWER SECTION</p>
|
||||
<h2 id="screens-h">Every answer, with its reason</h2>
|
||||
</div>
|
||||
<p class="muted">The admin UI records which rule, list or upstream decided each query, and keeps that record after the rule changes.</p>
|
||||
</div>
|
||||
<div class="tabs-row">
|
||||
<div class="tabs" role="tablist" aria-label="Screens">
|
||||
{
|
||||
screens.map((s) => (
|
||||
<button class="tab" role="tab" id={`tab-${s.key}`} aria-controls={`shot-${s.key}`} aria-selected={String(s.key === selected)} tabindex={s.key === selected ? 0 : -1}>
|
||||
{s.label}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="theme-switch" role="group" aria-label="Admin UI theme">
|
||||
{themes.map((t) => <button type="button" aria-pressed={String(t === "light")} data-theme={t}>{t}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
screens.map((s) => (
|
||||
<div class="shot" role="tabpanel" id={`shot-${s.key}`} aria-labelledby={`tab-${s.key}`} hidden={s.key !== selected}>
|
||||
<p class="caption muted wrap">{s.caption}</p>
|
||||
<div class="frame">
|
||||
{themes.map((t) => {
|
||||
const p = pictures[`${s.key}-${t}`];
|
||||
return (
|
||||
<picture>
|
||||
{p.phone.map((v) => (
|
||||
<source media={phone} type={v.type} srcset={v.srcset} sizes={v.sizes} width={v.width} height={v.height} />
|
||||
))}
|
||||
<source type={p.desktop.type} srcset={p.desktop.srcset} sizes={p.desktop.sizes} width={p.desktop.width} height={p.desktop.height} />
|
||||
<img
|
||||
class={t}
|
||||
src={p.fallback.src}
|
||||
srcset={p.fallback.srcset}
|
||||
sizes={p.fallback.sizes}
|
||||
width={p.fallback.width}
|
||||
height={p.fallback.height}
|
||||
alt={`${s.label} screen of the nxdns admin UI, ${t} theme`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { tabs } from "../../scripts/tabs";
|
||||
|
||||
const section = document.querySelector<HTMLElement>(".screens")!;
|
||||
tabs(section.querySelector("[role=tablist]")!, (tab) => {
|
||||
for (const panel of section.querySelectorAll<HTMLElement>("[role=tabpanel]")) panel.hidden = panel.id !== tab.getAttribute("aria-controls");
|
||||
});
|
||||
const themeButtons = [...section.querySelectorAll<HTMLButtonElement>(".theme-switch button")];
|
||||
for (const button of themeButtons)
|
||||
button.addEventListener("click", () => {
|
||||
section.dataset.shots = button.dataset.theme;
|
||||
for (const b of themeButtons) b.setAttribute("aria-pressed", String(b === button));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.screens {
|
||||
padding: 88px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.split-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.tabs-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 24px;
|
||||
margin-top: 40px;
|
||||
border-bottom: 1px solid var(--line-dark);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.theme-switch {
|
||||
display: flex;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 0 0 1px var(--line-dark);
|
||||
}
|
||||
.theme-switch button {
|
||||
min-width: 56px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--on-ink-muted);
|
||||
font: 500 13px var(--mono);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
.theme-switch button[aria-pressed="true"] {
|
||||
background: var(--on-ink);
|
||||
color: var(--ink);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.theme-switch button[aria-pressed="false"]:hover {
|
||||
color: var(--on-ink);
|
||||
}
|
||||
}
|
||||
.caption {
|
||||
margin: 16px auto 24px;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.caption {
|
||||
min-height: 3.4em;
|
||||
}
|
||||
}
|
||||
.frame {
|
||||
max-width: 1060px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 10px 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: var(--ink-2);
|
||||
box-shadow: 0 0 0 1px var(--line-dark);
|
||||
}
|
||||
.frame :global(img) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
/* The hidden theme is display:none, so its lazy image is never fetched until the switch shows it. */
|
||||
.screens[data-shots="light"] .frame :global(.dark),
|
||||
.screens[data-shots="dark"] .frame :global(.light) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
import Wordmark from "../Wordmark.astro";
|
||||
import { repoUrl } from "../../lib/forge";
|
||||
---
|
||||
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<Wordmark />
|
||||
<ul>
|
||||
<li><a href="/docs/">Docs</a></li>
|
||||
<li><a href="/changelog/">Changelog</a></li>
|
||||
<li><a href={repoUrl}>Source</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
header {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 64px;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
margin: 0 0 0 auto;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
li:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
import { release } from "../../lib/release";
|
||||
---
|
||||
|
||||
<section class="verify slab" aria-labelledby="verify-h">
|
||||
<div class="wrap grid">
|
||||
<div>
|
||||
<p class="eyebrow">;; VERIFY</p>
|
||||
<h2 id="verify-h">Check what you run</h2>
|
||||
<p class="lede">Every release is signed, and the tarballs rebuild to the same bytes. CI rebuilds them before upload and stops the release if one byte differs.</p>
|
||||
</div>
|
||||
<pre class="term"><span class="c">$ gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt</span>
|
||||
gpg: Good signature from "Mokhtar Mial (pc) <…>"
|
||||
Primary key fingerprint: A206 1F6A B24D F2C0 E923 46FD 1509 B549 46D0 8A95
|
||||
<span class="c">$ sha256sum -c --ignore-missing SHA256SUMS.txt</span>
|
||||
nxdns-{release}-x86_64-linux-musl.tar.gz: <span class="ok">OK</span></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.verify {
|
||||
margin-block: 12px;
|
||||
padding: 72px 0;
|
||||
background: var(--amber);
|
||||
color: var(--ink);
|
||||
}
|
||||
.eyebrow,
|
||||
.lede {
|
||||
color: var(--on-amber-muted);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 0.85fr 1.5fr;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: clamp(36px, 4.4vw, 54px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.lede {
|
||||
margin: 16px 0 0;
|
||||
max-width: 42ch;
|
||||
}
|
||||
.term {
|
||||
margin: 0;
|
||||
padding: 22px 24px;
|
||||
border-radius: 18px;
|
||||
background: var(--ink);
|
||||
color: var(--on-ink);
|
||||
font: 12.5px/1.8 var(--mono);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.c {
|
||||
color: var(--on-ink-muted);
|
||||
}
|
||||
.ok {
|
||||
color: var(--amber);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import Wordmark from "../Wordmark.astro";
|
||||
---
|
||||
|
||||
<Wordmark class="site-title" />
|
||||
|
||||
<style>
|
||||
.site-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
/* The site is light only, so the theme is fixed before first paint instead of read from storage or the OS. */
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
document.documentElement.dataset.theme = "light";
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
/* Light only: there is no theme to select. */
|
||||
---
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineCollection } from "astro:content";
|
||||
import { docsSchema } from "@astrojs/starlight/schema";
|
||||
import { repoDocsLoader } from "./lib/repo-docs";
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: repoDocsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import "../styles/fonts.css";
|
||||
import "../styles/tokens.css";
|
||||
import "../styles/landing.css";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
const { title, description } = Astro.props;
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:type" content="website" />
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { posix } from "node:path";
|
||||
import { repoRoot } from "./repo";
|
||||
import { routeOf } from "./repo-docs";
|
||||
|
||||
/**
|
||||
* The docs groups of the sidebar, read from `docs/README.md`: each `## ` section
|
||||
* is a group, and its `- [..](page.md)` items are the group's pages, in the order
|
||||
* the overview lists them. The overview is the index a reader on the forge sees,
|
||||
* so the sidebar cannot disagree with it, and a page missing from it fails the
|
||||
* build instead of being unreachable from the navigation.
|
||||
*/
|
||||
export function docsSidebarGroups() {
|
||||
const groups: { label: string; items: string[] }[] = [];
|
||||
for (const line of readFileSync(`${repoRoot}docs/README.md`, "utf8").split("\n")) {
|
||||
const section = /^## (.+)$/.exec(line);
|
||||
if (section) groups.push({ label: section[1].trim(), items: [] });
|
||||
const item = /^- \[[^\]]*\]\(([^)#]+\.md)\)/.exec(line);
|
||||
if (!item) continue;
|
||||
const route = routeOf(posix.join("docs", item[1]));
|
||||
if (groups.length === 0 || route === null) throw new Error(`docs/README.md: "${line}" is not a page in a "## " section`);
|
||||
groups.at(-1)!.items.push(route);
|
||||
}
|
||||
|
||||
const listed = groups.flatMap((g) => g.items);
|
||||
const pages = readdirSync(`${repoRoot}docs`, { recursive: true, encoding: "utf8" })
|
||||
.filter((f) => f.endsWith(".md") && f !== "README.md")
|
||||
.map((f) => routeOf(`docs/${f}`)!);
|
||||
const missing = pages.filter((p) => !listed.includes(p));
|
||||
const twice = listed.filter((p, i) => listed.indexOf(p) !== i);
|
||||
if (missing.length) throw new Error(`docs/README.md does not list ${missing.join(", ")}`);
|
||||
if (twice.length) throw new Error(`docs/README.md lists ${twice.join(", ")} more than once`);
|
||||
const empty = groups.filter((g) => g.items.length === 0).map((g) => g.label);
|
||||
if (empty.length) throw new Error(`docs/README.md: no pages under ${empty.join(", ")}`);
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const repoUrl = "https://git.mial.net/mokhtar/nxdns";
|
||||
|
||||
/** A repository path as a browsable link on the forge. */
|
||||
export const sourceUrl = (path: string) => `${repoUrl}/src/branch/master/${path}`;
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Replaced at build time by the version in `build.zig.zon` (see `astro.config.ts`). */
|
||||
declare const __NXDNS_RELEASE__: string;
|
||||
|
||||
export const release = __NXDNS_RELEASE__;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import type { Code, Heading, InlineCode, Link, Definition, Root, Text } from "mdast";
|
||||
import type { VFile } from "vfile";
|
||||
import { visit } from "unist-util-visit";
|
||||
import { sourceUrl } from "./forge";
|
||||
import { readRelease, repoRoot } from "./repo";
|
||||
import { routeOf } from "./repo-docs";
|
||||
|
||||
/**
|
||||
* Makes the repository's Markdown work as site pages without editing it.
|
||||
*
|
||||
* The files link to each other by relative path (`../reference/cli.md#run`) so
|
||||
* that they read correctly on the forge. Here a link to a published file becomes
|
||||
* its route, and a link to anything else in the repository becomes a forge URL.
|
||||
* A link to a path that does not exist fails the build: a broken link in the
|
||||
* docs is a bug in the docs, and the site is where it would first be visible.
|
||||
*
|
||||
* The leading `# ` heading is dropped, because Starlight renders the title it
|
||||
* was turned into (see `repo-docs.ts`).
|
||||
*
|
||||
* The pages write `<version>` wherever a command or a transcript names a
|
||||
* release, so they never go stale in the repository; the site prints the
|
||||
* release it was built for, so its commands can be pasted as they stand.
|
||||
*/
|
||||
export function remarkRepoLinks() {
|
||||
return (tree: Root, file: VFile) => {
|
||||
if (!file.path) return;
|
||||
const source = relative(repoRoot, file.path);
|
||||
if (source.startsWith("..") || source.startsWith("website/") || routeOf(source) === null) return;
|
||||
|
||||
const first = tree.children[0];
|
||||
if (first?.type === "heading" && (first as Heading).depth === 1) tree.children.shift();
|
||||
|
||||
const release = readRelease();
|
||||
visit(tree, ["link", "definition"], (node) => {
|
||||
const n = node as Link | Definition;
|
||||
n.url = rewrite(n.url, source).replaceAll("<version>", release);
|
||||
});
|
||||
visit(tree, ["code", "inlineCode", "text"], (node) => {
|
||||
const n = node as Code | InlineCode | Text;
|
||||
n.value = n.value.replaceAll("<version>", release);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function rewrite(url: string, source: string): string {
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("#") || url.startsWith("/")) return url;
|
||||
const hashAt = url.indexOf("#");
|
||||
const path = hashAt === -1 ? url : url.slice(0, hashAt);
|
||||
const hash = hashAt === -1 ? "" : url.slice(hashAt);
|
||||
const target = relative(repoRoot, resolve(repoRoot, dirname(source), path));
|
||||
if (target.startsWith("..")) throw new Error(`${source}: link "${url}" leaves the repository`);
|
||||
if (!existsSync(resolve(repoRoot, target))) throw new Error(`${source}: link "${url}" points at ${target}, which does not exist`);
|
||||
const route = routeOf(target);
|
||||
return route === null ? sourceUrl(target) + hash : `/${route}/${hash}`;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { glob } from "astro/loaders";
|
||||
import type { Loader } from "astro/loaders";
|
||||
import { repoRoot } from "./repo";
|
||||
|
||||
/**
|
||||
* The repository files Starlight publishes, read where they are: `docs/` and
|
||||
* `CHANGELOG.md` stay the single source, and nothing is copied into `website/`.
|
||||
*/
|
||||
const pattern = ["docs/**/*.md", "CHANGELOG.md"];
|
||||
|
||||
/** The route of a published file, from its repository path; `null` if it is not published. */
|
||||
export function routeOf(repoPath: string): string | null {
|
||||
if (repoPath === "CHANGELOG.md") return "changelog";
|
||||
const docs = /^docs\/(.+)\.md$/.exec(repoPath);
|
||||
if (!docs) return null;
|
||||
const page = docs[1] === "README" ? "" : `/${docs[1].replace(/\/README$/, "")}`;
|
||||
return `docs${page}`;
|
||||
}
|
||||
|
||||
/** The plain text of a Markdown span: links keep their text, code keeps its content. */
|
||||
const plain = (md: string) =>
|
||||
md
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/`([^`]*)`/g, "$1")
|
||||
.replace(/\*\*([^*]*)\*\*/g, "$1")
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* The pages carry no frontmatter: their first line is a `# ` heading, as they
|
||||
* read on the forge. Starlight needs a title before rendering, so it comes from
|
||||
* that heading, and the first paragraph becomes the page description.
|
||||
*/
|
||||
function headerOf(file: string) {
|
||||
const lines = readFileSync(file, "utf8").split("\n");
|
||||
const at = lines.findIndex((l) => l.trim() !== "");
|
||||
const heading = /^# (.+)$/.exec(lines[at] ?? "");
|
||||
if (!heading) throw new Error(`${relative(repoRoot, file)}: the first line must be a "# " heading`);
|
||||
const rest = lines.slice(at + 1);
|
||||
const start = rest.findIndex((l) => l.trim() !== "");
|
||||
const end = rest.findIndex((l, i) => i > start && l.trim() === "");
|
||||
const paragraph = rest.slice(start, end === -1 ? undefined : end).join(" ");
|
||||
return { title: plain(heading[1]), description: /^[#>|`-]/.test(paragraph) ? undefined : plain(paragraph) };
|
||||
}
|
||||
|
||||
export function repoDocsLoader(): Loader {
|
||||
const inner = glob({
|
||||
base: repoRoot,
|
||||
pattern,
|
||||
generateId: ({ entry }) => {
|
||||
const route = routeOf(entry);
|
||||
if (route === null) throw new Error(`${entry}: matched the docs pattern but has no route`);
|
||||
return route;
|
||||
},
|
||||
});
|
||||
return {
|
||||
name: "nxdns-repo-docs",
|
||||
load: (context) =>
|
||||
inner.load({
|
||||
...context,
|
||||
parseData: (props) =>
|
||||
context.parseData({
|
||||
...props,
|
||||
data: { ...headerOf(resolve(context.config.root.pathname, props.filePath ?? "")), ...props.data },
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* The repository checkout this site is built from: `website/` sits at its root.
|
||||
* Only the config and what it loads (the docs loader, the remark plugin) may
|
||||
* import this module: they run from `src/`, where this path holds. Page code is
|
||||
* bundled elsewhere, and reads the release from `release.ts` instead.
|
||||
*/
|
||||
export const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
|
||||
/**
|
||||
* The release this checkout describes. `zig build cut` moves `build.zig.zon` in
|
||||
* the commit it tags, so reading it keeps every version on the site equal to
|
||||
* the one the commit ships, with nothing to bump by hand.
|
||||
*/
|
||||
export function readRelease(): string {
|
||||
const zon = readFileSync(`${repoRoot}build.zig.zon`, "utf8");
|
||||
const found = [...zon.matchAll(/^\s*\.version\s*=\s*"([^"]+)"/gm)];
|
||||
if (found.length !== 1) throw new Error(`build.zig.zon: expected one .version field, found ${found.length}`);
|
||||
return found[0][1];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import Landing from "../layouts/Landing.astro";
|
||||
import TopBar from "../components/landing/TopBar.astro";
|
||||
import Hero from "../components/landing/Hero.astro";
|
||||
import Screens from "../components/landing/Screens.astro";
|
||||
import Verify from "../components/landing/Verify.astro";
|
||||
import Features from "../components/landing/Features.astro";
|
||||
import Compare from "../components/landing/Compare.astro";
|
||||
import Install from "../components/landing/Install.astro";
|
||||
import Footer from "../components/landing/Footer.astro";
|
||||
---
|
||||
|
||||
<Landing
|
||||
title="nxdns — a DNS sinkhole for your LAN"
|
||||
description="nxdns blocks the names you do not want, and forwards the rest over an encrypted connection. A self-hosted DNS sinkhole for one household, as one static binary."
|
||||
>
|
||||
<TopBar />
|
||||
<main>
|
||||
<Hero />
|
||||
<Screens />
|
||||
<Verify />
|
||||
<Features />
|
||||
<Compare />
|
||||
<Install />
|
||||
</main>
|
||||
<Footer />
|
||||
</Landing>
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* The README's flow diagram, animated: one query at a time goes from the
|
||||
* devices to nxdns, waits there while a tag shows the decision, then goes on to
|
||||
* the upstream and back, or into the sinkhole. Under prefers-reduced-motion it
|
||||
* draws one still frame with a packet on every edge instead.
|
||||
*/
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
type Node = { x: number; y: number; w: number; h: number; title?: string; sub?: string };
|
||||
type Edge = { d: string; label: [string, number, number]; secure?: boolean; anchor?: "start" | "middle" | "end" };
|
||||
type EdgeKey = "ask" | "reply" | "out" | "sink";
|
||||
type Layout = {
|
||||
viewBox: [number, number];
|
||||
tagGap: number;
|
||||
nodes: Record<"devices" | "core" | "upstream" | "hole", Node>;
|
||||
edges: Record<EdgeKey, Edge>;
|
||||
};
|
||||
|
||||
const LAYOUTS: Record<"wide" | "narrow", Layout> = {
|
||||
wide: {
|
||||
viewBox: [1000, 360],
|
||||
tagGap: 30,
|
||||
nodes: {
|
||||
devices: { x: 24, y: 120, w: 220, h: 112, title: "Your devices", sub: "TV · phones · laptop" },
|
||||
core: { x: 404, y: 140, w: 172, h: 72 },
|
||||
upstream: { x: 756, y: 24, w: 220, h: 104, title: "Upstream resolvers", sub: "DoH / DoT" },
|
||||
hole: { x: 756, y: 236, w: 220, h: 100, title: "0.0.0.0 / NXDOMAIN", sub: "answered locally" },
|
||||
},
|
||||
edges: {
|
||||
ask: { d: "M 244 162 L 404 162", label: ["DNS query", 324, 148] },
|
||||
reply: { d: "M 404 192 L 244 192", label: ["answer", 324, 216] },
|
||||
out: { d: "M 576 160 C 660 160, 672 76, 756 76", secure: true, label: ["allowed · encrypted", 736, 56], anchor: "end" },
|
||||
sink: { d: "M 576 196 C 660 196, 672 286, 756 286", label: ["blocked", 640, 268] },
|
||||
},
|
||||
},
|
||||
narrow: {
|
||||
viewBox: [360, 620],
|
||||
tagGap: 28,
|
||||
nodes: {
|
||||
devices: { x: 60, y: 16, w: 240, h: 96, title: "Your devices", sub: "TV · phones · laptop" },
|
||||
core: { x: 104, y: 236, w: 152, h: 64 },
|
||||
upstream: { x: 8, y: 448, w: 164, h: 104, title: "Upstreams", sub: "DoH / DoT" },
|
||||
hole: { x: 188, y: 448, w: 164, h: 104, title: "0.0.0.0", sub: "NXDOMAIN" },
|
||||
},
|
||||
edges: {
|
||||
ask: { d: "M 156 112 L 156 236", label: ["query", 140, 178], anchor: "end" },
|
||||
reply: { d: "M 204 236 L 204 112", label: ["answer", 220, 178], anchor: "start" },
|
||||
out: { d: "M 140 300 C 140 380, 90 380, 90 448", secure: true, label: ["allowed", 72, 384], anchor: "end" },
|
||||
sink: { d: "M 220 300 C 220 380, 270 380, 270 448", label: ["blocked", 288, 384], anchor: "start" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const QUERIES: [string, boolean][] = [
|
||||
["www.youtube.com", false],
|
||||
["doubleclick.net", true],
|
||||
["en.wikipedia.org", false],
|
||||
["app-measurement.com", true],
|
||||
["api.github.com", false],
|
||||
["tracking.samsungads.com", true],
|
||||
];
|
||||
|
||||
type Step =
|
||||
| { edge: EdgeKey; ms: number; label: string; kind: string; reverse?: boolean; fadeIn?: boolean; fadeOut?: boolean }
|
||||
| { hold: number; hidden?: boolean; tag?: "allowed" | "blocked" };
|
||||
|
||||
/** One query's trip, as steps: each moves the packet along one edge, or holds it. */
|
||||
function plan([name, blocked]: [string, boolean]): Step[] {
|
||||
const ask: Step = { edge: "ask", ms: 1600, label: name, kind: "query", fadeIn: true, fadeOut: true };
|
||||
const decide: Step = { hold: 900, hidden: true, tag: blocked ? "blocked" : "allowed" };
|
||||
if (blocked)
|
||||
return [
|
||||
ask,
|
||||
decide,
|
||||
{ edge: "sink", ms: 1500, label: name, kind: "blocked", fadeIn: true, fadeOut: true },
|
||||
{ edge: "reply", ms: 1500, label: "0.0.0.0", kind: "sink", fadeIn: true, fadeOut: true },
|
||||
];
|
||||
const sealed = "•".repeat(Math.min(name.length, 12));
|
||||
return [
|
||||
ask,
|
||||
decide,
|
||||
{ edge: "out", ms: 1500, label: sealed, kind: "sealed", fadeIn: true },
|
||||
{ hold: 500 },
|
||||
{ edge: "out", ms: 1300, reverse: true, label: "••••••", kind: "sealed", fadeOut: true },
|
||||
{ edge: "reply", ms: 1500, label: "answer", kind: "answer", fadeIn: true, fadeOut: true },
|
||||
];
|
||||
}
|
||||
|
||||
/** The share of an edge over which a packet fades in or out, so it never sits on top of a node's text. */
|
||||
const FADE = 0.15;
|
||||
const PAUSE_MS = 700;
|
||||
const PULSE_MS = 700;
|
||||
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
||||
|
||||
export function startFlow(svg: SVGSVGElement) {
|
||||
const el = <K extends keyof SVGElementTagNameMap>(name: K, attrs: Record<string, string | number> = {}, parent: Element = svg) => {
|
||||
const e = document.createElementNS(NS, name);
|
||||
for (const k in attrs) e.setAttribute(k, String(attrs[k]));
|
||||
parent.appendChild(e);
|
||||
return e;
|
||||
};
|
||||
|
||||
let paths = {} as Record<EdgeKey, SVGPathElement>;
|
||||
let packets: SVGGElement;
|
||||
let pulse: SVGRectElement;
|
||||
let tag: Packet;
|
||||
|
||||
type Packet = ReturnType<typeof packet>;
|
||||
function packet(label: string, kind: string) {
|
||||
const g = el("g", { class: `pkt ${kind}` }, packets);
|
||||
const rect = el("rect", { rx: 11, height: 22, y: -11 }, g);
|
||||
const text = el("text", { "text-anchor": "middle", y: 4 }, g);
|
||||
const set = (l: string, k: string) => {
|
||||
text.textContent = l;
|
||||
g.setAttribute("class", `pkt ${k}`);
|
||||
const w = text.getComputedTextLength() + 20;
|
||||
rect.setAttribute("width", String(w));
|
||||
rect.setAttribute("x", String(-w / 2));
|
||||
};
|
||||
set(label, kind);
|
||||
return {
|
||||
g,
|
||||
set,
|
||||
at(path: SVGPathElement, t: number) {
|
||||
const p = path.getPointAtLength(t * path.getTotalLength());
|
||||
g.setAttribute("transform", `translate(${p.x} ${p.y})`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function draw(layout: Layout) {
|
||||
svg.replaceChildren();
|
||||
svg.setAttribute("viewBox", `0 0 ${layout.viewBox.join(" ")}`);
|
||||
for (const [key, e] of Object.entries(layout.edges) as [EdgeKey, Edge][]) {
|
||||
paths[key] = el("path", { d: e.d, class: e.secure ? "edge secure" : "edge" });
|
||||
const [text, x, y] = e.label;
|
||||
el("text", { x, y, class: "edge-label", "text-anchor": e.anchor ?? "middle" }).textContent = text;
|
||||
}
|
||||
for (const [key, n] of Object.entries(layout.nodes)) {
|
||||
const g = el("g", { class: `node ${key}` });
|
||||
el("rect", { x: n.x, y: n.y, width: n.w, height: n.h, rx: key === "core" ? n.h / 2 : 18 }, g);
|
||||
const cx = n.x + n.w / 2;
|
||||
const cy = n.y + n.h / 2;
|
||||
if (key === "core") {
|
||||
pulse = el("rect", { x: n.x - 6, y: n.y - 6, width: n.w + 12, height: n.h + 12, rx: n.h / 2 + 6, class: "pulse" }, g);
|
||||
const t = el("text", { x: cx, y: cy + 9, "text-anchor": "middle", class: "title" }, g);
|
||||
t.append("n");
|
||||
el("tspan", {}, t).textContent = "x";
|
||||
t.append("dns");
|
||||
} else {
|
||||
el("text", { x: cx, y: cy - 4, "text-anchor": "middle", class: "title" }, g).textContent = n.title ?? "";
|
||||
el("text", { x: cx, y: cy + 18, "text-anchor": "middle", class: "sub" }, g).textContent = n.sub ?? "";
|
||||
}
|
||||
}
|
||||
packets = el("g");
|
||||
const core = layout.nodes.core;
|
||||
tag = packet("allowed", "tag allowed");
|
||||
tag.g.setAttribute("transform", `translate(${core.x + core.w / 2} ${core.y + core.h + layout.tagGap})`);
|
||||
tag.g.setAttribute("opacity", "0");
|
||||
}
|
||||
|
||||
type Job = { p: Packet; steps: Step[]; i: number; t0: number; entered: boolean };
|
||||
let job: Job | null = null;
|
||||
let idleUntil = 0;
|
||||
let next = 0;
|
||||
let flashAt = -Infinity;
|
||||
|
||||
function advance(j: Job, now: number) {
|
||||
const s = j.steps[j.i];
|
||||
if (!s) return false;
|
||||
if (!j.entered) {
|
||||
j.entered = true;
|
||||
if ("edge" in s) {
|
||||
j.p.set(s.label, s.kind);
|
||||
paths[s.edge].classList.add("live");
|
||||
}
|
||||
if ("tag" in s && s.tag) {
|
||||
tag.set(s.tag, `tag ${s.tag}`);
|
||||
tag.g.setAttribute("opacity", "1");
|
||||
flashAt = now;
|
||||
}
|
||||
}
|
||||
const t = Math.min(1, (now - j.t0) / ("edge" in s ? s.ms : s.hold));
|
||||
let opacity = "hidden" in s && s.hidden ? 0 : 1;
|
||||
if ("edge" in s) {
|
||||
j.p.at(paths[s.edge], s.reverse ? 1 - easeInOut(t) : easeInOut(t));
|
||||
opacity = Math.min(1, s.fadeIn ? t / FADE : 1, s.fadeOut ? (1 - t) / FADE : 1);
|
||||
}
|
||||
j.p.g.setAttribute("opacity", opacity.toFixed(3));
|
||||
if (t >= 1) {
|
||||
if ("edge" in s) paths[s.edge].classList.remove("live");
|
||||
j.i++;
|
||||
j.t0 = now;
|
||||
j.entered = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (!job && now >= idleUntil) job = { p: packet("", "query"), steps: plan(QUERIES[next++ % QUERIES.length]), i: 0, t0: now, entered: false };
|
||||
if (job && !advance(job, now)) {
|
||||
job.p.g.remove();
|
||||
job = null;
|
||||
idleUntil = now + PAUSE_MS;
|
||||
tag.g.setAttribute("opacity", "0");
|
||||
}
|
||||
pulse.setAttribute("opacity", (Math.max(0, 1 - (now - flashAt) / PULSE_MS) * 0.9).toFixed(3));
|
||||
}
|
||||
|
||||
function stillFrame() {
|
||||
const still: [string, string, EdgeKey, number][] = [
|
||||
["www.youtube.com", "query", "ask", 0.55],
|
||||
["doubleclick.net", "blocked", "sink", 0.84],
|
||||
["••••••••", "sealed", "out", 0.5],
|
||||
["0.0.0.0", "sink", "reply", 0.45],
|
||||
];
|
||||
for (const [label, kind, edge, t] of still) packet(label, kind).at(paths[edge], t);
|
||||
}
|
||||
|
||||
const narrow = matchMedia("(max-width: 640px)");
|
||||
const reduced = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let raf = 0;
|
||||
function start() {
|
||||
cancelAnimationFrame(raf);
|
||||
draw(LAYOUTS[narrow.matches ? "narrow" : "wide"]);
|
||||
if (reduced.matches) return stillFrame();
|
||||
job = null;
|
||||
idleUntil = 0;
|
||||
const loop = (now: number) => {
|
||||
frame(now);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
}
|
||||
narrow.addEventListener("change", start);
|
||||
reduced.addEventListener("change", start);
|
||||
// Packet widths come from measured text, so they must wait for the mono font.
|
||||
document.fonts.ready.then(start);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The WAI-ARIA tabs pattern: one tab in the tab order, arrow keys move the
|
||||
* selection, Home and End jump to the ends. `select` runs on every change and
|
||||
* once at start for the tab marked selected in the markup.
|
||||
*/
|
||||
export function tabs(list: HTMLElement, select: (tab: HTMLElement) => void) {
|
||||
const all = [...list.querySelectorAll<HTMLElement>("[role=tab]")];
|
||||
const show = (tab: HTMLElement) => {
|
||||
for (const t of all) {
|
||||
const on = t === tab;
|
||||
t.setAttribute("aria-selected", String(on));
|
||||
t.tabIndex = on ? 0 : -1;
|
||||
}
|
||||
select(tab);
|
||||
};
|
||||
all.forEach((tab, i) => {
|
||||
tab.addEventListener("click", () => show(tab));
|
||||
tab.addEventListener("keydown", (e) => {
|
||||
const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: all.length - 1 }[e.key];
|
||||
if (to === undefined) return;
|
||||
e.preventDefault();
|
||||
const next = all[(to + all.length) % all.length];
|
||||
show(next);
|
||||
next.focus();
|
||||
});
|
||||
});
|
||||
show(all.find((t) => t.getAttribute("aria-selected") === "true") ?? all[0]);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Self-hosted. Each file covers one unicode range, so a page fetches only the ranges its text uses. */
|
||||
@import "@fontsource-variable/fraunces/full.css";
|
||||
@import "@fontsource-variable/figtree/index.css";
|
||||
@import "@fontsource-variable/jetbrains-mono/index.css";
|
||||
@@ -0,0 +1,169 @@
|
||||
/* Shared by the landing page sections. Section-specific rules live in each component. */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
background: var(--paper);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font: 16px/1.6 var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-family: var(--serif);
|
||||
font-weight: 400;
|
||||
font-variation-settings: "SOFT" 50;
|
||||
text-wrap: balance;
|
||||
}
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1120px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 20px;
|
||||
font: 500 12px/1 var(--mono);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.on-ink {
|
||||
background: var(--ink);
|
||||
color: var(--on-ink);
|
||||
}
|
||||
.on-ink .eyebrow,
|
||||
.on-ink .muted {
|
||||
color: var(--on-ink-muted);
|
||||
}
|
||||
.slab {
|
||||
margin-inline: 12px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.split-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px 40px;
|
||||
align-items: end;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.split-head {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.split-head h2 {
|
||||
font-size: clamp(40px, 5vw, 64px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.split-head p {
|
||||
margin: 0;
|
||||
max-width: 46ch;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 0 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--ink);
|
||||
color: var(--on-ink);
|
||||
font: 500 15px/1 var(--sans);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 150ms var(--ease-out),
|
||||
background-color 150ms ease;
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.on-ink .btn {
|
||||
background: var(--amber);
|
||||
color: var(--ink);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.btn:hover {
|
||||
background: #34322d;
|
||||
}
|
||||
.on-ink .btn:hover {
|
||||
background: #f5b85a;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 0 6px;
|
||||
font: 500 15px/1 var(--sans);
|
||||
text-decoration: none;
|
||||
}
|
||||
.arrow::after {
|
||||
content: "→";
|
||||
transition: transform 150ms var(--ease-out);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.arrow:hover::after {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tabs in the dig-label style: mono, with an amber underline on the selected one. */
|
||||
.tab {
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--on-ink-muted);
|
||||
font: 500 13px var(--mono);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 -2px 0 transparent;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
.tab[aria-selected="true"] {
|
||||
color: var(--on-ink);
|
||||
box-shadow: inset 0 -2px 0 var(--amber);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.tab:hover {
|
||||
color: var(--on-ink);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* Starlight's light theme, mapped onto the site palette. ThemeProvider pins data-theme to light. */
|
||||
:root[data-theme="light"] {
|
||||
--sl-font: var(--sans);
|
||||
--sl-font-mono: var(--mono);
|
||||
|
||||
--sl-color-white: var(--ink);
|
||||
--sl-color-gray-1: #2a2925;
|
||||
--sl-color-gray-2: #3d3b35;
|
||||
--sl-color-gray-3: var(--muted);
|
||||
--sl-color-gray-4: #8a857a;
|
||||
--sl-color-gray-5: #d6d1c4;
|
||||
--sl-color-gray-6: #ebe7dc;
|
||||
--sl-color-gray-7: var(--card);
|
||||
--sl-color-black: var(--paper);
|
||||
|
||||
--sl-color-accent-low: #f8e3c2;
|
||||
--sl-color-accent: var(--amber-text);
|
||||
--sl-color-accent-high: #6e3406;
|
||||
|
||||
--sl-color-bg-nav: var(--paper);
|
||||
--sl-color-bg-sidebar: var(--paper);
|
||||
--sl-color-bg-inline-code: rgb(28 27 24 / 0.06);
|
||||
--sl-color-hairline: var(--line);
|
||||
--sl-color-hairline-light: var(--line);
|
||||
--sl-color-hairline-shade: var(--line-strong);
|
||||
}
|
||||
|
||||
.sl-markdown-content :is(h1, h2, h3, h4) {
|
||||
font-family: var(--serif);
|
||||
font-weight: 450;
|
||||
letter-spacing: -0.015em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1#_top {
|
||||
font-family: var(--serif);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.025em;
|
||||
font-variation-settings: "SOFT" 50, "opsz" 144;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline-color: var(--ink);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/* The one palette and type scale, shared by the landing page and the docs. Light only. */
|
||||
:root {
|
||||
--paper: #f5f2ea;
|
||||
--card: #fbf9f4;
|
||||
--ink: #1c1b18;
|
||||
--ink-2: #2a2925;
|
||||
--muted: #5f5b52;
|
||||
--on-ink: #f5f2ea;
|
||||
--on-ink-muted: #a9a397;
|
||||
--amber: #f2a93b;
|
||||
--amber-text: #a14d0a;
|
||||
--on-amber-muted: #4a3a1c;
|
||||
--line: rgb(28 27 24 / 0.14);
|
||||
--line-strong: rgb(28 27 24 / 0.32);
|
||||
--line-dark: rgb(245 242 234 / 0.16);
|
||||
--serif: "Fraunces Variable", Georgia, serif;
|
||||
--sans: "Figtree Variable", system-ui, sans-serif;
|
||||
--mono: "JetBrains Mono Variable", ui-monospace, monospace;
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||