mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 20:33:50 +00:00
Breadcrumbs on every page, and a sidebar that unfolds to where you are (#170)
Anthony: "this needs a cookie crumb navbar, all pages need this with the new IA" and "broad and drill down, I'm not seeing that in the sidebar". lib/crumbs.ts derives the trail from the path and the spec registry, so no page declares it: /openthreat is Home > Specs > Catalogs a site serves about itself > OpenThreat, /opencpu adds OpenServer before OpenCPU, /docs/openthreat ends in Specification, /docs/cli is Home > Docs > CLI, a blog post passes its title as the leaf. components/breadcrumbs.tsx renders it (server-rendered, with a BreadcrumbList JSON-LD) at the top of every SiteShell page; the SPA routes rendered by page-markup.ts get the same trail as a string. components/side-nav.tsx replaces the flat sidebar: the four groups stay, and under Specs the family the current page belongs to unfolds to its specs, and the spec to its blocks, marked active. The home page string marks the active entry for the SPA routes too. Claude-Session: https://claude.ai/code/session_014cmNRtR2vL1p89dbVQ7FZJ Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
eee9ce09c5
commit
df57b54bde
9 changed files with 310 additions and 32 deletions
|
|
@ -70,7 +70,7 @@ export default async function Page({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div id="app" dangerouslySetInnerHTML={{ __html: renderPageMarkup() }} />
|
||||
<div id="app" dangerouslySetInnerHTML={{ __html: renderPageMarkup(slug?.[0] ? `/${slug[0]}` : "/") }} />
|
||||
<HomeInteractivity />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export default async function BlogPostPage({
|
|||
const html = sanitizeRenderedHtml(post.html);
|
||||
|
||||
return (
|
||||
<SiteShell active="Blog">
|
||||
<SiteShell active="Blog" crumbTitle={post.title}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export default async function DocPage({
|
|||
const html = sanitizeRenderedHtml(rawHtml);
|
||||
|
||||
return (
|
||||
<SiteShell active="Docs">
|
||||
<SiteShell active="Docs" crumbTitle={docTitle(md, slug)}>
|
||||
<article className="band" style={{ maxWidth: "48rem" }}>
|
||||
<p style={{ marginBottom: "1.5rem" }}>
|
||||
<Link href="/docs" style={{ color: "#5b6b7a", textDecoration: "none" }}>
|
||||
|
|
|
|||
46
apps/logicsrc-web/src/components/breadcrumbs.tsx
Normal file
46
apps/logicsrc-web/src/components/breadcrumbs.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { crumbsFor } from "@/lib/crumbs";
|
||||
|
||||
/**
|
||||
* The trail at the top of every page, derived from the path and the spec
|
||||
* registry. Rendered on the server too (usePathname works during SSR), so the
|
||||
* HTML carries it, plus a BreadcrumbList for search engines.
|
||||
*/
|
||||
export function Breadcrumbs({ leaf }: { leaf?: string }): ReactNode {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const crumbs = crumbsFor(pathname, leaf);
|
||||
if (crumbs.length === 0) return null;
|
||||
const site = "https://logicsrc.com";
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: crumbs.map((c, i) => ({
|
||||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: c.label,
|
||||
item: `${site}${c.href}`
|
||||
}))
|
||||
};
|
||||
return (
|
||||
<nav className="crumbs" aria-label="Breadcrumb">
|
||||
<ol>
|
||||
{crumbs.map((c, i) => {
|
||||
const last = i === crumbs.length - 1;
|
||||
return (
|
||||
<li key={c.href + c.label}>
|
||||
{last ? (
|
||||
<span aria-current="page">{c.label}</span>
|
||||
) : (
|
||||
<a href={c.href}>{c.label}</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
89
apps/logicsrc-web/src/components/side-nav.tsx
Normal file
89
apps/logicsrc-web/src/components/side-nav.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { NAV_GROUPS } from "@/lib/nav";
|
||||
import { FAMILIES, allSpecs, familyOfSpec, familyTree, type Family } from "@/lib/specs";
|
||||
|
||||
/**
|
||||
* The sidebar, broad first, drilling down in place. The four groups are
|
||||
* always shown. Under Specs, the family the current page belongs to unfolds
|
||||
* to its specs, and the spec the page belongs to unfolds to its blocks, so
|
||||
* the sidebar shows where you are and what is beside you without leaving the
|
||||
* page. Rendered on the server too, so the unfolded state is in the HTML.
|
||||
*/
|
||||
function currentFamily(pathname: string): { family?: Family; specSlug?: string } {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
if (parts[0] === "specs" && parts[1]) return { family: FAMILIES.find((f) => f.slug === parts[1]) };
|
||||
const slug =
|
||||
parts[0] === "docs" && parts[1]
|
||||
? allSpecs().find((x) => x.doc === `/docs/${parts[1]}`)?.slug
|
||||
: allSpecs().find((x) => x.landing === `/${parts[0]}`)?.slug;
|
||||
if (!slug) return {};
|
||||
return { family: familyOfSpec(slug), specSlug: slug };
|
||||
}
|
||||
|
||||
export function SideNav(): ReactNode {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const { family: open, specSlug } = currentFamily(pathname);
|
||||
const openParent = specSlug ? allSpecs().find((x) => x.slug === specSlug)?.parent ?? specSlug : undefined;
|
||||
|
||||
const isCurrent = (href: string): boolean => href === pathname;
|
||||
|
||||
return (
|
||||
<nav aria-label="LogicSRC sections">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label} className="nav-group-block">
|
||||
<span className="nav-group">{group.label}</span>
|
||||
{group.items.map((item) => {
|
||||
const family = group.label === "Specs" ? FAMILIES.find((f) => `/specs/${f.slug}` === item.href) : undefined;
|
||||
const unfolded = family && open && family.slug === open.slug;
|
||||
const active = isCurrent(item.href) || (family && open?.slug === family.slug && pathname.startsWith("/specs/"));
|
||||
return (
|
||||
<div key={item.href} className={unfolded ? "nav-branch open" : "nav-branch"}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={active ? "active" : undefined}
|
||||
aria-current={isCurrent(item.href) ? "page" : undefined}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noreferrer" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{unfolded ? (
|
||||
<div className="nav-children">
|
||||
{familyTree(family).map(({ spec, children }) => {
|
||||
const href = spec.landing ?? spec.doc ?? "/specs";
|
||||
const here = spec.slug === specSlug || children.some((c) => c.slug === specSlug);
|
||||
return (
|
||||
<div key={spec.slug}>
|
||||
<a href={href} className={spec.slug === specSlug ? "active" : undefined} aria-current={spec.slug === specSlug ? "page" : undefined}>
|
||||
{spec.name}
|
||||
</a>
|
||||
{children.length > 0 && (here || openParent === spec.slug) ? (
|
||||
<div className="nav-children">
|
||||
{children.map((c) => (
|
||||
<a
|
||||
key={c.slug}
|
||||
href={c.landing ?? c.doc ?? href}
|
||||
className={c.slug === specSlug ? "active" : undefined}
|
||||
aria-current={c.slug === specSlug ? "page" : undefined}
|
||||
>
|
||||
{c.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,23 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { renderInstallCommand } from "@/lib/install-command";
|
||||
import { NAV_GROUPS } from "@/lib/nav";
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs";
|
||||
import { SideNav } from "@/components/side-nav";
|
||||
|
||||
/**
|
||||
* The site chrome for every standalone route. The sidebar comes from
|
||||
* lib/nav.ts, the same array the home page renders, so the two cannot drift.
|
||||
* `active` is a label or an href; either marks the current entry.
|
||||
* The site chrome for every standalone route: the sidebar that unfolds to
|
||||
* where you are (components/side-nav.tsx), and the breadcrumb trail at the
|
||||
* top of the page (components/breadcrumbs.tsx), both derived from the spec
|
||||
* registry and the path. `crumbTitle` names the last crumb when the
|
||||
* registry cannot, such as a blog post's title. `active` is kept for
|
||||
* callers that still pass it and is no longer needed.
|
||||
*/
|
||||
export function SiteShell({
|
||||
children,
|
||||
active,
|
||||
crumbTitle,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
active?: string;
|
||||
crumbTitle?: string;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<main className="shell">
|
||||
|
|
@ -27,30 +32,10 @@ export function SiteShell({
|
|||
{/* Same markup the homepage uses, so the two can never drift apart.
|
||||
Static content from a module constant -- nothing user-supplied. */}
|
||||
<div dangerouslySetInnerHTML={{ __html: renderInstallCommand("rail") }} />
|
||||
<nav aria-label="LogicSRC sections">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label} className="nav-group-block">
|
||||
<span className="nav-group">{group.label}</span>
|
||||
{group.items.map((item) => {
|
||||
const isActive = item.label === active || item.href === active;
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={isActive ? "active" : undefined}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noreferrer" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<SideNav />
|
||||
</aside>
|
||||
<section className="workspace">
|
||||
<Breadcrumbs leaf={crumbTitle} />
|
||||
{children}
|
||||
<footer
|
||||
style={{
|
||||
|
|
|
|||
99
apps/logicsrc-web/src/lib/crumbs.ts
Normal file
99
apps/logicsrc-web/src/lib/crumbs.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { NAV_GROUPS } from "./nav";
|
||||
import { FAMILIES, GUIDES, allSpecs, familyOfSpec, type Spec } from "./specs";
|
||||
|
||||
/**
|
||||
* Breadcrumbs for any path, derived from the registry so no page declares
|
||||
* them by hand. The trail follows the site's architecture, broad to narrow:
|
||||
*
|
||||
* /specs/catalogs Home › Specs › Catalogs a site serves about itself
|
||||
* /openthreat Home › Specs › Catalogs … › OpenThreat
|
||||
* /opencpu Home › Specs › Catalogs … › OpenServer › OpenCPU
|
||||
* /docs/openthreat Home › Specs › Catalogs … › OpenThreat › Specification
|
||||
* /docs/cli Home › Docs › CLI
|
||||
* /blog/<slug> Home › Blog › <leaf, supplied by the page>
|
||||
*
|
||||
* `leaf` lets a page name the last crumb when the registry cannot (a blog
|
||||
* post's title). The home page has no trail.
|
||||
*/
|
||||
export type Crumb = { href: string; label: string };
|
||||
|
||||
const HOME: Crumb = { href: "/", label: "Home" };
|
||||
|
||||
function specTrail(spec: Spec): Crumb[] {
|
||||
const family = familyOfSpec(spec.slug);
|
||||
const trail: Crumb[] = [HOME, { href: "/specs", label: "Specs" }];
|
||||
if (family) trail.push({ href: `/specs/${family.slug}`, label: family.name });
|
||||
if (spec.parent) {
|
||||
const parent = allSpecs().find((x) => x.slug === spec.parent);
|
||||
if (parent) trail.push({ href: parent.landing ?? parent.doc ?? "/specs", label: parent.name });
|
||||
}
|
||||
trail.push({ href: spec.landing ?? spec.doc ?? "/specs", label: spec.name });
|
||||
return trail;
|
||||
}
|
||||
|
||||
function navLabel(path: string): string | null {
|
||||
for (const g of NAV_GROUPS) for (const i of g.items) if (i.href === path) return i.label;
|
||||
return null;
|
||||
}
|
||||
|
||||
function pretty(segment: string): string {
|
||||
return segment.replace(/[-_]+/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function crumbsFor(pathname: string, leaf?: string): Crumb[] {
|
||||
const path = pathname.replace(/\/+$/, "") || "/";
|
||||
if (path === "/") return [];
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
|
||||
// /specs and /specs/<family>
|
||||
if (parts[0] === "specs") {
|
||||
const trail: Crumb[] = [HOME, { href: "/specs", label: "Specs" }];
|
||||
const family = parts[1] ? FAMILIES.find((f) => f.slug === parts[1]) : undefined;
|
||||
if (family) trail.push({ href: `/specs/${family.slug}`, label: family.name });
|
||||
return trail;
|
||||
}
|
||||
|
||||
// /docs and /docs/<slug>
|
||||
if (parts[0] === "docs") {
|
||||
if (!parts[1]) return [HOME, { href: "/docs", label: "Docs" }];
|
||||
const spec = allSpecs().find((x) => x.doc === `/docs/${parts[1]}`);
|
||||
if (spec) {
|
||||
const trail = specTrail(spec);
|
||||
// A spec with no landing page: its doc IS the spec crumb already.
|
||||
if (spec.landing) trail.push({ href: `/docs/${parts[1]}`, label: "Specification" });
|
||||
return trail;
|
||||
}
|
||||
const guide = GUIDES.find((g) => g.slug === parts[1]);
|
||||
return [HOME, { href: "/docs", label: "Docs" }, { href: path, label: leaf ?? guide?.name ?? pretty(parts[1]) }];
|
||||
}
|
||||
|
||||
// /<slug> for a spec landing page (one segment), including /openontology/explore under it.
|
||||
const spec = allSpecs().find((x) => x.landing === `/${parts[0]}`);
|
||||
if (spec) {
|
||||
const trail = specTrail(spec);
|
||||
if (parts[1]) trail.push({ href: path, label: leaf ?? pretty(parts[1]) });
|
||||
return trail;
|
||||
}
|
||||
|
||||
// Everything else: Home › <section> [› leaf]
|
||||
const trail: Crumb[] = [HOME];
|
||||
const first = `/${parts[0]}`;
|
||||
trail.push({ href: first, label: navLabel(first) ?? pretty(parts[0]) });
|
||||
if (parts.length > 1) trail.push({ href: path, label: leaf ?? pretty(parts[parts.length - 1]) });
|
||||
else if (leaf && leaf !== trail[trail.length - 1].label) trail[trail.length - 1] = { href: path, label: leaf };
|
||||
return trail;
|
||||
}
|
||||
|
||||
/** The trail as HTML, for the server-string home template's non-root routes. */
|
||||
export function renderCrumbsHtml(pathname: string): string {
|
||||
const crumbs = crumbsFor(pathname);
|
||||
if (crumbs.length === 0) return "";
|
||||
const items = crumbs
|
||||
.map((c, i) =>
|
||||
i === crumbs.length - 1
|
||||
? `<li><span aria-current="page">${c.label}</span></li>`
|
||||
: `<li><a href="${c.href}">${c.label}</a></li>`
|
||||
)
|
||||
.join("");
|
||||
return `<nav class="crumbs" aria-label="Breadcrumb"><ol>${items}</ol></nav>`;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
// the `home-interactivity` client component.
|
||||
import { renderInstallCommand } from "./install-command";
|
||||
import { renderNavHtml } from "./nav";
|
||||
import { renderCrumbsHtml } from "./crumbs";
|
||||
import { FAMILIES, familyTree } from "./specs";
|
||||
|
||||
const schemas = [
|
||||
|
|
@ -110,7 +111,7 @@ const comparisonRows = [
|
|||
}
|
||||
];
|
||||
|
||||
export function renderPageMarkup(): string {
|
||||
export function renderPageMarkup(path = "/"): string {
|
||||
return `
|
||||
<main class="shell">
|
||||
<aside class="rail">
|
||||
|
|
@ -122,9 +123,10 @@ export function renderPageMarkup(): string {
|
|||
</div>
|
||||
</div>
|
||||
${renderInstallCommand("rail")}
|
||||
<nav aria-label="LogicSRC sections">${renderNavHtml("/")}</nav>
|
||||
<nav aria-label="LogicSRC sections">${renderNavHtml(path)}</nav>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
${renderCrumbsHtml(path)}
|
||||
<header id="overview" class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Profullstack open spec project</p>
|
||||
|
|
|
|||
|
|
@ -926,3 +926,60 @@ pre {
|
|||
margin: 1rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Sidebar drill-down: the current family unfolds to its specs, a spec to its blocks. */
|
||||
.rail nav .nav-branch {
|
||||
display: contents;
|
||||
}
|
||||
.rail nav .nav-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin: 0.2rem 0 0.35rem 0.6rem;
|
||||
padding-left: 0.6rem;
|
||||
border-left: 1px solid #263039;
|
||||
}
|
||||
.rail nav .nav-children a {
|
||||
min-height: 1.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
border-color: transparent;
|
||||
}
|
||||
.rail nav .nav-children a.active,
|
||||
.rail nav .nav-children a:hover {
|
||||
border-color: #3f5049;
|
||||
}
|
||||
|
||||
/* Breadcrumb trail at the top of every page but the home page. */
|
||||
.crumbs {
|
||||
width: 100%;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto 0.75rem;
|
||||
font-size: 0.88rem;
|
||||
color: #5b6b7a;
|
||||
}
|
||||
.crumbs ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.35rem 0 0.75rem;
|
||||
border-bottom: 1px solid #e3e6e0;
|
||||
}
|
||||
.crumbs li + li::before {
|
||||
content: "›";
|
||||
margin-right: 0.35rem;
|
||||
color: #9aa6b1;
|
||||
}
|
||||
.crumbs a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.crumbs a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.crumbs [aria-current="page"] {
|
||||
color: #101418;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue