site: render docs nav both in global nav and docs sidebar
This commit is contained in:
committed by
Johannes Kirschbauer
parent
6d622f7f68
commit
9812d4114f
27
site/src/config.ts
Normal file
27
site/src/config.ts
Normal 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[],
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { navLinks } from "./settings";
|
||||
import { normalizeNavLinks } from "./utils";
|
||||
|
||||
export async function load() {
|
||||
return {
|
||||
navLinks: await normalizeNavLinks(navLinks),
|
||||
};
|
||||
}
|
||||
184
site/src/routes/docs/index.ts
Normal file
184
site/src/routes/docs/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,3 +14,8 @@ body {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--pagePadding: 15px;
|
||||
--globalNavHeight: 60px;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ const config = {
|
||||
handleHttpError: "warn",
|
||||
handleMissingId: "warn",
|
||||
},
|
||||
alias: {
|
||||
"~": new URL("src", import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
extensions: [".svelte"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user