site: render docs nav both in global nav and docs sidebar

This commit is contained in:
Glen Huang
2025-10-08 14:26:29 +08:00
committed by Johannes Kirschbauer
parent 6d622f7f68
commit 9812d4114f
10 changed files with 302 additions and 224 deletions

27
site/src/config.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { RawNavLink } from "./routes/docs";
export const blog = {
base: "/blog",
};
export const docs = {
base: "/docs",
navLinks: [
{
label: "Getting Started",
items: ["getting-started/add-machines"],
},
{
label: "Reference",
items: [
{
label: "Overview",
slug: "reference/overview",
},
{
label: "Options",
autogenerate: { directory: "reference/options" },
},
],
},
] as RawNavLink[],
};

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import * as config from "~/config";
import "./index.css";
import favicon from "$lib/assets/favicon.svg";
import type { NavLink } from "./docs";
let { children } = $props();
const { data, children } = $props();
</script>
<svelte:head>
@@ -13,20 +15,63 @@
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/docs/">Docs</a></li>
<li><a href={config.blog.base}>Blog</a></li>
<li>
<a href={config.docs.base}>Docs</a>
{#if data.docs}{@render navLinks(data.docs.navLinks)}{/if}
</li>
</ul>
</nav>
<main>
{@render children?.()}
</main>
{#snippet navLinks(nLinks: NavLink[])}
<ul>
{#each nLinks as nLink}
{@render navLink(nLink)}
{/each}
</ul>
{/snippet}
{#snippet navLink(nLink: NavLink)}
{#if "items" in nLink}
<li>
<details open={!nLink.collapsed}>
<summary><span class="label group">{nLink.label}</span></summary>
{@render navLinks(nLink.items)}
</details>
</li>
{:else}
<li>
<a href={nLink.link}>{nLink.label}</a>
</li>
{/if}
{/snippet}
<style>
nav {
height: var(--globalNavHeight);
display: flex;
align-items: center;
border-bottom: 1px solid;
padding: 0 var(--pagePadding);
}
ul {
display: flex;
border-bottom: 1px solid;
list-style: none;
padding: 0;
margin: 0;
ul {
display: none;
}
}
li {
padding-left: 2em;
&:first-child {
padding-left: 0;
}
}
</style>

View File

@@ -1,2 +1,14 @@
import * as config from "~/config";
import { Docs } from "./docs";
export const prerender = true;
export const trailingSlash = "always";
export async function load({ url }) {
const path = url.pathname.endsWith("/")
? url.pathname.slice(0, -1)
: url.pathname;
return {
docs: path != config.docs.base ? null : await new Docs().init(),
};
}

View File

@@ -1,34 +1,35 @@
<script lang="ts">
import type { NavLink } from "./utils";
import type { NavLink } from ".";
let { children, data } = $props();
let docs = $derived(data.docs!);
</script>
{#snippet navLinkSnippet(navLink: NavLink)}
{#if "items" in navLink}
<li>
<details open={!navLink.collapsed}>
<summary><span class="label group">{navLink.label}</span></summary>
{#snippet navLinks(nLinks: NavLink[])}
<ul>
{#each navLink.items as item}
{@render navLinkSnippet(item)}
{#each nLinks as nLink}
{@render navLink(nLink)}
{/each}
</ul>
{/snippet}
{#snippet navLink(nLink: NavLink)}
{#if "items" in nLink}
<li>
<details open={!nLink.collapsed}>
<summary><span class="label group">{nLink.label}</span></summary>
{@render navLinks(nLink.items)}
</details>
</li>
{:else}
<li>
<a href={navLink.link}>{navLink.label}</a>
<a href={nLink.link}>{nLink.label}</a>
</li>
{/if}
{/snippet}
<div class="container">
<nav>
<ul>
{#each data.navLinks as navLink}
{@render navLinkSnippet(navLink)}
{/each}
</ul>
{@render navLinks(docs.navLinks)}
</nav>
<div class="content">
{@render children()}
@@ -41,8 +42,12 @@
}
nav {
display: none;
width: 300px;
flex: none;
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background: #fff;
}
summary {

View File

@@ -1,8 +0,0 @@
import { navLinks } from "./settings";
import { normalizeNavLinks } from "./utils";
export async function load() {
return {
navLinks: await normalizeNavLinks(navLinks),
};
}

View File

@@ -0,0 +1,184 @@
import * as config from "~/config";
export class Docs {
articles: Record<string, () => Promise<Article>> = {};
navLinks: NavLink[] = [];
async init() {
this.articles = Object.fromEntries(
Object.entries(import.meta.glob<Article>("./**/*.md")).map(
([key, fn]) => [key.slice("./".length, -".md".length), fn],
),
);
this.navLinks = await Promise.all(
config.docs.navLinks.map((navLink) => this.#normalizeNavLink(navLink)),
);
return this;
}
async #normalizeNavLink(navLink: RawNavLink): Promise<NavLink> {
if (typeof navLink === "string") {
const article = this.articles[navLink];
if (!article) {
throw new Error(`Doc not found: ${navLink}`);
}
return {
label: (await article()).frontmatter.title,
link: `${config.docs.base}/${navLink}`,
external: false,
};
}
if ("items" in navLink) {
return {
...navLink,
collapsed: !!navLink.collapsed,
badge: normalizeBadge(navLink.badge),
items: await Promise.all(
navLink.items.map((navLink) => this.#normalizeNavLink(navLink)),
),
};
}
if ("slug" in navLink) {
const article = this.articles[navLink.slug];
if (!article) {
throw new Error(`Doc not found: ${navLink.slug}`);
}
return {
label: navLink.label ?? (await article()).frontmatter.title,
link: `${config.docs.base}/${navLink.slug}`,
badge: normalizeBadge(navLink.badge),
external: false,
};
}
if ("autogenerate" in navLink) {
const dir = navLink.autogenerate.directory;
const articleEntries = Object.entries(this.articles).filter(([key]) =>
key.startsWith(dir + "/"),
);
const frontmatters = await Promise.all(
articleEntries.map(async ([key, article]) => ({
key,
frontmatter: (await article()).frontmatter,
})),
);
let titleMissing = false;
// Check frontmatter for title
for (const item of frontmatters) {
if (!item.frontmatter.title) {
console.error(
`Missing title in frontmatter for autogenerated doc: ${item.key}`,
);
titleMissing = true;
}
}
if (titleMissing) throw new Error("Aborting due to errors.");
const items: NavLink[] = await Promise.all(
frontmatters
.sort((a, b) => {
const orderA = a.frontmatter.order;
const orderB = b.frontmatter.order;
if (orderA != null && orderB != null) {
return orderA - orderB;
}
if (orderA != null) {
return -1;
}
if (orderB != null) {
return 1;
}
const titleA = a.frontmatter.title ?? a.key;
const titleB = a.frontmatter.title ?? a.key;
return titleA.localeCompare(titleB.title);
})
.map((item) =>
this.#normalizeNavLink({
label: item.frontmatter.title,
link: `${config.docs.base}/${item.key}`,
}),
),
);
return {
label: navLink.label ?? dir.split("/").slice(-1)[0],
items,
collapsed: !!navLink.collapsed,
badge: normalizeBadge(navLink.badge),
};
}
return {
...navLink,
badge: normalizeBadge(navLink.badge),
external: /^(https?:)?\/\//.test(navLink.link),
};
}
}
export type RawNavLink =
| string
| {
label: string;
items: RawNavLink[];
collapsed?: boolean;
badge?: RawBadge;
}
| {
label: string;
autogenerate: { directory: string };
collapsed?: boolean;
badge?: RawBadge;
}
| {
label?: string;
slug: string;
badge?: RawBadge;
}
| {
label: string;
link: string;
badge?: RawBadge;
};
export type NavLink =
| {
label: string;
items: NavLink[];
collapsed: boolean;
badge?: Badge;
}
| {
label: string;
link: string;
badge?: Badge;
external: boolean;
};
export type RawBadge = string | Badge;
export type Badge = {
text: string;
variant: "caution" | "normal";
};
export type Article = {
content: string;
frontmatter: Record<string, any>;
toc: string;
};
function normalizeBadge(badge: RawBadge | undefined): Badge | undefined {
if (!badge) {
return undefined;
}
if (typeof badge === "string") {
return {
text: badge,
variant: "normal",
};
}
return badge;
}

View File

@@ -1,21 +0,0 @@
import type { RawNavLink } from "./utils";
export const navLinks: RawNavLink[] = [
{
label: "Getting Started",
items: ["getting-started/add-machines"],
},
{
label: "Reference",
items: [
{
label: "Overview",
slug: "reference/overview",
},
{
label: "Options",
autogenerate: { directory: "reference/options" },
},
],
},
];

View File

@@ -1,174 +0,0 @@
export const articles = Object.fromEntries(
Object.entries(
import.meta.glob<{
content: string;
frontmatter: Record<string, any>;
toc: string;
}>("./**/*.md"),
).map(([key, fn]) => [key.slice("./".length, -".md".length), fn]),
);
export type RawNavLink =
| string
| {
label: string;
items: RawNavLink[];
collapsed?: boolean;
badge?: RawBadge;
}
| {
label: string;
autogenerate: { directory: string };
collapsed?: boolean;
badge?: RawBadge;
}
| {
label?: string;
slug: string;
badge?: RawBadge;
}
| {
label: string;
link: string;
badge?: RawBadge;
};
export type NavLink =
| {
label: string;
items: NavLink[];
collapsed: boolean;
badge?: Badge;
}
| {
label: string;
link: string;
badge?: Badge;
external: boolean;
};
export type RawBadge = string | Badge;
export type Badge = {
text: string;
variant: "caution" | "normal";
};
export async function normalizeNavLinks(
navLinks: RawNavLink[],
): Promise<NavLink[]> {
return await Promise.all(navLinks.map(normalizeNavLink));
}
export async function normalizeNavLink(navLink: RawNavLink): Promise<NavLink> {
if (typeof navLink === "string") {
const article = articles[navLink];
if (!article) {
throw new Error(`Doc not found: ${navLink}`);
}
return {
label: (await article()).frontmatter.title,
link: `/docs/${navLink}`,
external: false,
};
}
if ("items" in navLink) {
return {
...navLink,
collapsed: !!navLink.collapsed,
badge: normalizeBadge(navLink.badge),
items: await Promise.all(navLink.items.map(normalizeNavLink)),
};
}
if ("slug" in navLink) {
const article = articles[navLink.slug];
if (!article) {
throw new Error(`Doc not found: ${navLink.slug}`);
}
return {
label: navLink.label ?? (await article()).frontmatter.title,
link: `/docs/${navLink.slug}`,
badge: normalizeBadge(navLink.badge),
external: false,
};
}
if ("autogenerate" in navLink) {
const dir = navLink.autogenerate.directory;
const articleEntries = Object.entries(articles).filter(([key]) =>
key.startsWith(dir + "/"),
);
const frontmatters = await Promise.all(
articleEntries.map(async ([key, article]) => ({
key,
frontmatter: (await article()).frontmatter,
})),
);
let titleMissing = false;
// Check frontmatter for title
for (const item of frontmatters) {
if (!item.frontmatter.title) {
console.error(
`Missing title in frontmatter for autogenerated doc: ${item.key}`,
);
titleMissing = true;
}
}
if (titleMissing) throw new Error("Aborting due to errors.");
const items: NavLink[] = await Promise.all(
frontmatters
.sort((a, b) => {
const orderA = a.frontmatter.order;
const orderB = b.frontmatter.order;
if (orderA != null && orderB != null) {
return orderA - orderB;
}
if (orderA != null) {
return -1;
}
if (orderB != null) {
return 1;
}
const titleA = a.frontmatter.title ?? a.key;
const titleB = a.frontmatter.title ?? a.key;
return titleA.localeCompare(titleB.title);
})
.map((item) =>
normalizeNavLink({
label: item.frontmatter.title,
link: `/docs/${item.key}`,
}),
),
);
return {
label: navLink.label ?? dir.split("/").slice(-1)[0],
items,
collapsed: !!navLink.collapsed,
badge: normalizeBadge(navLink.badge),
};
}
return {
...navLink,
badge: normalizeBadge(navLink.badge),
external: /^(https?:)?\/\//.test(navLink.link),
};
}
export function normalizeBadge(badge: RawBadge | undefined): Badge | undefined {
if (!badge) {
return undefined;
}
if (typeof badge === "string") {
return {
text: badge,
variant: "normal",
};
}
return badge;
}

View File

@@ -14,3 +14,8 @@ body {
font-size: 16px;
margin: 0;
}
:root {
--pagePadding: 15px;
--globalNavHeight: 60px;
}

View File

@@ -15,6 +15,9 @@ const config = {
handleHttpError: "warn",
handleMissingId: "warn",
},
alias: {
"~": new URL("src", import.meta.url).pathname,
},
},
extensions: [".svelte"],
};