T48a Docs · Frame: a Docs tab, /docs/ and /docs/$page/, Markdown guides with a rail, an on-this-page list, previous and next and code blocks; full Rust samples are compiled examples shown as text
This commit is contained in:
12 files changed
+305
-6
No files matched your search
@@ -20,9 +20,12 @@ function Header() {
|
||||
<Link to="/components/" className="link" activeProps={{ "aria-current": "page" }}>
|
||||
Components
|
||||
</Link>
|
||||
<Link to="/docs/" className="link" activeProps={{ "aria-current": "page" }}>
|
||||
Docs
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="end">
|
||||
<button className="tool" aria-label={sound ? "Sound off" : "Sound on"} aria-pressed={sound} onClick={() => setSound(!sound)}>
|
||||
<button className="tool sound" aria-label={sound ? "Sound off" : "Sound on"} aria-pressed={sound} onClick={() => setSound(!sound)}>
|
||||
<Icon name={sound ? "Sound" : "VolumeCross"} />
|
||||
</button>
|
||||
<button className="tool" aria-label={dark ? "Light mode" : "Dark mode"} onClick={() => setMode(dark ? "light" : "dark")}>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Marked, type Tokens } from "marked";
|
||||
|
||||
const sources = import.meta.glob<string>("./*.md", { query: "?raw", import: "default", eager: true });
|
||||
const examples = import.meta.glob<string>("../../../examples/docs/*.rs", { query: "?raw", import: "default", eager: true });
|
||||
|
||||
/** The guides in reading order, by file name. */
|
||||
const ORDER = ["introduction"];
|
||||
|
||||
export interface Guide {
|
||||
/** The path segment; the introduction has none. */
|
||||
slug: string;
|
||||
title: string;
|
||||
html: string;
|
||||
/** The `##` headings, for the on-this-page list. */
|
||||
toc: { id: string; text: string }[];
|
||||
}
|
||||
|
||||
const escape = (text: string) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
const slugify = (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
|
||||
function read(file: string): Guide {
|
||||
const source = sources[`./${file}.md`];
|
||||
if (source === undefined) throw new Error(`docs: no ${file}.md`);
|
||||
let title = "";
|
||||
const toc: Guide["toc"] = [];
|
||||
const marked = new Marked({
|
||||
renderer: {
|
||||
heading({ tokens, depth }: Tokens.Heading) {
|
||||
const html = this.parser.parseInline(tokens);
|
||||
if (depth === 1) {
|
||||
title = html.replace(/<[^>]+>/g, "");
|
||||
return "";
|
||||
}
|
||||
const id = slugify(html);
|
||||
if (depth === 2) {
|
||||
if (toc.some((h) => h.id === id)) throw new Error(`docs: ${file}.md repeats heading ${id}`);
|
||||
toc.push({ id, text: html.replace(/<[^>]+>/g, "") });
|
||||
}
|
||||
return `<h${depth} id="${id}">${html}</h${depth}>`;
|
||||
},
|
||||
code({ text, lang }: Tokens.Code) {
|
||||
const [language = "", ...rest] = (lang ?? "").split(/\s+/);
|
||||
const example = rest.find((part) => part.startsWith("example="))?.slice("example=".length);
|
||||
let code = text;
|
||||
let caption = language;
|
||||
if (example) {
|
||||
const body = examples[`../../../examples/docs/${example}.rs`];
|
||||
if (body === undefined) throw new Error(`docs: ${file}.md names a missing example ${example}`);
|
||||
code = body.trimEnd();
|
||||
caption = `examples/docs/${example}.rs · cargo run --example docs_${example}`;
|
||||
}
|
||||
return `<figure class="code"><figcaption>${escape(caption)}</figcaption><button class="copy" type="button">Copy</button><pre><code>${escape(code)}</code></pre></figure>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
const html = marked.parse(source, { async: false });
|
||||
if (!title) throw new Error(`docs: ${file}.md has no # title`);
|
||||
return { slug: file === "introduction" ? "" : file, title, html, toc };
|
||||
}
|
||||
|
||||
export const guides: Guide[] = ORDER.map(read);
|
||||
@@ -0,0 +1,14 @@
|
||||
# Introduction
|
||||
|
||||
Ely is a component library for [GPUI](https://www.gpui.rs), the Rust UI framework behind Zed. Every component draws in light and dark, and every one runs live on the [components page](/components/).
|
||||
|
||||
## A first window
|
||||
|
||||
The example opens a window with one counter and one Button. `cargo run --example docs_hello` runs it from a clone of the repository.
|
||||
|
||||
```rust example=hello
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT or Apache-2.0, at your option.
|
||||
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { Shell } from "./Shell";
|
||||
import { chapters } from "./data";
|
||||
import { Chapters } from "./pages/Chapters";
|
||||
import { Docs } from "./pages/Docs";
|
||||
import { Home } from "./pages/Home";
|
||||
import { NotFound } from "./pages/NotFound";
|
||||
import { Story } from "./pages/Story";
|
||||
@@ -24,9 +25,11 @@ const chapter = createRoute({
|
||||
component: () => <NotFound what="No chapter by that name." />,
|
||||
});
|
||||
const story = createRoute({ getParentRoute: () => root, path: "/components/$page/$story", component: Story });
|
||||
const docs = createRoute({ getParentRoute: () => root, path: "/docs", component: Docs });
|
||||
const doc = createRoute({ getParentRoute: () => root, path: "/docs/$page", component: Docs });
|
||||
|
||||
const router = createRouter({
|
||||
routeTree: root.addChildren([home, index, chapter, story]),
|
||||
routeTree: root.addChildren([home, index, chapter, story, docs, doc]),
|
||||
trailingSlash: "always",
|
||||
scrollRestoration: true,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { useEffect, type MouseEvent } from "react";
|
||||
import { guides, type Guide } from "../docs/guides";
|
||||
import { NotFound } from "./NotFound";
|
||||
|
||||
const to = (guide: Guide) => (guide.slug ? `/docs/${guide.slug}/` : "/docs/");
|
||||
|
||||
async function copy(button: HTMLButtonElement) {
|
||||
const code = button.parentElement?.querySelector("code")?.textContent;
|
||||
if (code == null) throw new Error("docs: a copy button without code");
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
button.textContent = "Copied";
|
||||
} catch (error) {
|
||||
console.error("docs: copy failed", error);
|
||||
button.textContent = "Copy failed";
|
||||
}
|
||||
setTimeout(() => (button.textContent = "Copy"), 1600);
|
||||
}
|
||||
|
||||
export function Docs() {
|
||||
const { page } = useParams({ strict: false });
|
||||
const navigate = useNavigate();
|
||||
const at = guides.findIndex((g) => g.slug === (page ?? ""));
|
||||
const guide = guides[at];
|
||||
useEffect(() => {
|
||||
if (guide) document.title = `${guide.title} · Docs · Ely`;
|
||||
}, [guide]);
|
||||
if (!guide) return <NotFound what={`No guide at ${page}.`} />;
|
||||
const [prev, next] = [guides[at - 1], guides[at + 1]];
|
||||
|
||||
// Links and copy buttons inside the rendered Markdown.
|
||||
const press = (e: MouseEvent<HTMLElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const button = target.closest<HTMLButtonElement>("button.copy");
|
||||
if (button) return void copy(button);
|
||||
const link = target.closest<HTMLAnchorElement>("a[href^='/']");
|
||||
if (!link || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
navigate({ to: link.getAttribute("href")! });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="docs">
|
||||
<nav className="docs-rail" aria-label="Guides" data-lenis-prevent>
|
||||
<ol>
|
||||
{guides.map((g) => (
|
||||
<li key={g.slug}>
|
||||
<Link to={to(g)} aria-current={g === guide ? "page" : undefined}>
|
||||
{g.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
<article className="doc">
|
||||
<h1>{guide.title}</h1>
|
||||
<div className="prose" onClick={press} dangerouslySetInnerHTML={{ __html: guide.html }} />
|
||||
<nav className="pager" aria-label="More guides">
|
||||
{prev && <Link to={to(prev)}>← {prev.title}</Link>}
|
||||
{next && (
|
||||
<Link className="next" to={to(next)}>
|
||||
{next.title} →
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</article>
|
||||
{guide.toc.length > 0 && (
|
||||
<nav className="doc-toc" aria-label="On this page">
|
||||
<ol>
|
||||
{guide.toc.map((h) => (
|
||||
<li key={h.id}>
|
||||
<a href={`#${h.id}`}>{h.text}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+48
-1
@@ -9,6 +9,7 @@
|
||||
--iso-line: #1a1918;
|
||||
--iso-side: #f3f0ec;
|
||||
--iso-shade: #e9e5df;
|
||||
--code: #f3f0ec;
|
||||
--sans: "Geist Variable", system-ui, sans-serif;
|
||||
--mono: "Geist Mono Variable", ui-monospace, monospace;
|
||||
color-scheme: light;
|
||||
@@ -25,6 +26,7 @@
|
||||
--iso-line: #d9d5d0;
|
||||
--iso-side: #1a1816;
|
||||
--iso-shade: #201e1c;
|
||||
--code: #1b1917;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -162,13 +164,51 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
||||
.pager a:hover { color: var(--fg); }
|
||||
.pager .next { margin-left: auto; text-align: right; }
|
||||
|
||||
.docs { display: grid; grid-template-columns: 200px minmax(0, 720px) 200px; justify-content: center; gap: 64px; padding: 0 64px; }
|
||||
.docs-rail, .doc-toc { position: sticky; top: 64px; height: calc(100vh - 64px); overflow-y: auto; padding: 96px 0 64px; }
|
||||
.docs-rail ol, .doc-toc ol { list-style: none; margin: 0; padding: 0; }
|
||||
.docs-rail li { padding: 5px 0; font-size: 15px; }
|
||||
.doc-toc li { padding: 4px 0; font-size: 13px; line-height: 20px; }
|
||||
.docs-rail a, .doc-toc a { color: var(--muted); transition: color 160ms ease-out; }
|
||||
.doc-toc a { color: var(--faint); }
|
||||
.docs-rail a:hover, .doc-toc a:hover, .docs-rail a[aria-current="page"] { color: var(--fg); }
|
||||
.docs-rail a[aria-current="page"] { font-weight: 500; }
|
||||
.doc { padding: 96px 0 128px; min-width: 0; }
|
||||
.doc h1 { font-size: clamp(36px, 4vw, 56px); line-height: 1.05; font-weight: 800; letter-spacing: -0.035em; margin-bottom: 40px; }
|
||||
.prose { font-size: 17px; line-height: 30px; }
|
||||
.prose p, .prose li { color: color-mix(in srgb, var(--fg) 82%, var(--bg)); }
|
||||
.prose p { margin: 0 0 20px; }
|
||||
.prose h2 { font-size: 28px; line-height: 36px; font-weight: 700; letter-spacing: -0.02em; margin: 64px 0 16px; scroll-margin-top: 96px; }
|
||||
.prose h3 { font-size: 20px; line-height: 28px; font-weight: 600; margin: 40px 0 12px; scroll-margin-top: 96px; }
|
||||
.prose a { color: var(--fg); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line); }
|
||||
.prose a:hover { text-decoration-color: var(--fg); }
|
||||
.prose ul, .prose ol { margin: 0 0 20px; padding-left: 22px; }
|
||||
.prose code { font: 400 0.86em/1 var(--mono); background: var(--code); padding: 3px 6px; border-radius: 6px; }
|
||||
.prose table { width: 100%; border-collapse: collapse; margin: 8px 0 28px; font-size: 15px; line-height: 24px; }
|
||||
.prose th, .prose td { text-align: left; vertical-align: top; padding: 12px 16px 12px 0; border-top: 1px solid var(--line); }
|
||||
.prose th { font-weight: 600; }
|
||||
.prose td { color: color-mix(in srgb, var(--fg) 82%, var(--bg)); }
|
||||
.code { position: relative; margin: 28px 0; background: var(--code); border-radius: 12px; }
|
||||
.code figcaption { padding: 14px 88px 0 20px; font: 400 12px/18px var(--mono); color: var(--faint); overflow-wrap: anywhere; }
|
||||
.code pre { margin: 0; padding: 14px 20px 20px; overflow-x: auto; }
|
||||
.code code { display: block; font: 400 13.5px/22px var(--mono); background: none; padding: 0; color: var(--fg); }
|
||||
.code .copy { position: absolute; top: 10px; right: 12px; padding: 4px 8px; font-size: 12px; color: var(--muted); transition: color 160ms ease-out; }
|
||||
.code .copy:hover { color: var(--fg); }
|
||||
|
||||
.missing { min-height: 70vh; display: grid; align-content: center; justify-items: start; gap: 24px; padding: 160px 64px; }
|
||||
.missing h1 { font-size: 64px; letter-spacing: -0.04em; }
|
||||
|
||||
.foot { display: flex; flex-wrap: wrap; gap: 8px 28px; padding: 48px 64px; font-size: 13px; color: var(--faint); }
|
||||
.foot a { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.docs { grid-template-columns: 200px minmax(0, 720px); }
|
||||
.doc-toc { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.docs { grid-template-columns: minmax(0, 1fr); }
|
||||
.docs-rail { display: none; }
|
||||
.reader { grid-template-columns: minmax(0, 1fr); }
|
||||
.side { display: none; }
|
||||
.feature, .feature:nth-of-type(odd), .city { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; gap: 32px; }
|
||||
@@ -182,7 +222,7 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
||||
.bar { padding: 0 16px; gap: 12px; }
|
||||
.bar .end { gap: 0; }
|
||||
.hero, .chapters, .missing { padding-left: 20px; padding-right: 20px; }
|
||||
.reader { padding: 0 20px; }
|
||||
.reader, .docs { padding: 0 20px; }
|
||||
.chapters li { grid-template-columns: 40px minmax(0, 1fr); }
|
||||
.chapters .titles { grid-column: 2; margin-top: 8px; }
|
||||
.foot { padding: 32px 20px; }
|
||||
@@ -199,6 +239,13 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
||||
.close { padding: 112px 20px; }
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
.bar { padding: 0 12px; gap: 10px; }
|
||||
.bar nav { gap: 14px; }
|
||||
.bar .tool { width: 32px; }
|
||||
.bar .sound { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; animation: none !important; }
|
||||
}
|
||||
Reference in new issue
Block a user