@eqtylab/docs 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/runtime/chrome/GlobalSearch.module.css +26 -0
  2. package/dist/runtime/chrome/GlobalSearch.tsx +422 -0
  3. package/dist/runtime/chrome/Header.astro +105 -0
  4. package/dist/runtime/chrome/LinkIcon.astro +35 -0
  5. package/dist/runtime/chrome/NavDrawer.astro +60 -0
  6. package/dist/runtime/chrome/NavTree.astro +112 -0
  7. package/dist/runtime/chrome/NotFoundBody.tsx +12 -0
  8. package/dist/runtime/chrome/PageFooter.astro +80 -0
  9. package/dist/runtime/chrome/Prose.astro +169 -0
  10. package/dist/runtime/chrome/Sidebar.astro +21 -0
  11. package/dist/runtime/chrome/TableOfContents.astro +86 -0
  12. package/dist/runtime/chrome/ThemeToggle.tsx +85 -0
  13. package/dist/runtime/chrome/TocElbow.astro +30 -0
  14. package/dist/runtime/chrome/TocList.astro +43 -0
  15. package/dist/runtime/components/AlertBridge.astro +16 -0
  16. package/dist/runtime/components/CodeFence.astro +38 -0
  17. package/dist/runtime/components/CodeFenceBridge.astro +20 -0
  18. package/dist/runtime/components/Link.astro +21 -0
  19. package/dist/runtime/components/TabItem.astro +16 -0
  20. package/dist/runtime/components/TableBridge.astro +18 -0
  21. package/dist/runtime/components/Tabs.astro +121 -0
  22. package/dist/runtime/components/TabsBridge.tsx +41 -0
  23. package/dist/runtime/components/index.ts +4 -0
  24. package/dist/runtime/layouts/DocsPage.astro +50 -0
  25. package/dist/runtime/layouts/DocsShell.astro +92 -0
  26. package/dist/runtime/lib/mdx-components.ts +49 -0
  27. package/dist/runtime/lib/nav-data.ts +74 -0
  28. package/dist/runtime/lib/summary.ts +57 -0
  29. package/dist/runtime/lib/theme.ts +84 -0
  30. package/dist/runtime/routes/docs-md.ts +33 -0
  31. package/dist/runtime/routes/docs.astro +92 -0
  32. package/dist/runtime/routes/llms-txt.ts +38 -0
  33. package/dist/runtime/routes/not-found.astro +16 -0
  34. package/dist/runtime/scripts/eq-copy.ts +27 -0
  35. package/dist/runtime/scripts/eq-highlight.ts +24 -0
  36. package/dist/runtime/scripts/eq-nav-drawer.ts +31 -0
  37. package/dist/runtime/scripts/eq-nav-group.ts +52 -0
  38. package/dist/runtime/scripts/eq-tabs.ts +114 -0
  39. package/dist/runtime/scripts/eq-toc.ts +166 -0
  40. package/dist/runtime/styles/chrome.css +30 -0
  41. package/dist/runtime/styles/prose.css +102 -0
  42. package/dist/runtime/styles/theme.css +2 -0
  43. package/dist/runtime/styles/utilities.css +38 -0
  44. package/package.json +2 -2
  45. package/dist/chunk-ATIOPYKE.js +0 -102
  46. package/dist/chunk-ATIOPYKE.js.map +0 -1
@@ -0,0 +1,38 @@
1
+ ---
2
+ /**
3
+ * Fenced code through Equality's CodeBlock with no client React. eq-copy drives
4
+ * the copy button and eq-highlight starts the highlight pass CodeBlock's own
5
+ * useEffect never runs here. The highlighter owns language aliasing.
6
+ */
7
+ import { CodeBlock } from '@eqtylab/equality';
8
+
9
+ interface Props {
10
+ code: string;
11
+ lang?: string;
12
+ title?: string;
13
+ }
14
+
15
+ const { code, lang = 'text', title } = Astro.props;
16
+
17
+ const language = lang.toLowerCase();
18
+ ---
19
+
20
+ {/*
21
+ `block` is load-bearing: the fence is a custom element, which is inline by default.
22
+
23
+ The descendant rule undoes CodeBlock's max-h-64, which is wrong for a docs sample.
24
+ `!` is load-bearing too: Equality ships unlayered CSS, and an unlayered declaration
25
+ beats anything in `@layer utilities` whatever its specificity, so a plain utility
26
+ loses to `.code-block` and the sample silently goes back to scrolling at 16rem.
27
+ */}
28
+ <eq-highlight
29
+ class="mb-4 block [&_[class*=code-block]]:max-h-none!"
30
+ data-eq-chrome
31
+ data-eq-copy={code}
32
+ >
33
+ <CodeBlock code={code} language={language} title={title} />
34
+ </eq-highlight>
35
+ <script>
36
+ import '@eqtylab/docs/scripts/eq-copy.ts';
37
+ import '@eqtylab/docs/scripts/eq-highlight.ts';
38
+ </script>
@@ -0,0 +1,20 @@
1
+ ---
2
+ /**
3
+ * Renders a fence from the props `rehypeCodeFence` lifted onto the `<pre>`.
4
+ * Do not re-parse the slot: its HTML is escaped in dev and raw in build.
5
+ */
6
+ import CodeFence from './CodeFence.astro';
7
+
8
+ interface Props {
9
+ 'data-code'?: string;
10
+ 'data-language'?: string;
11
+ 'data-title'?: string;
12
+ }
13
+
14
+ const props = Astro.props as Props;
15
+ const code = props['data-code'] ?? '';
16
+ const lang = props['data-language'] ?? 'text';
17
+ const title = props['data-title'];
18
+ ---
19
+
20
+ <CodeFence code={code} lang={lang} title={title} />
@@ -0,0 +1,21 @@
1
+ ---
2
+ import { isExternalHref } from '@eqtylab/docs/paths';
3
+
4
+ interface Props {
5
+ href?: string;
6
+ [key: string]: unknown;
7
+ }
8
+
9
+ const { href = '', ...rest } = Astro.props;
10
+ const external = isExternalHref(href);
11
+ ---
12
+
13
+ <a
14
+ href={href}
15
+ class="text-inherit"
16
+ target={external ? '_blank' : undefined}
17
+ rel={external ? 'noopener noreferrer' : undefined}
18
+ {...rest}
19
+ >
20
+ <slot />
21
+ </a>
@@ -0,0 +1,16 @@
1
+ ---
2
+ /**
3
+ * One panel of a `<Tabs>` set. Emits nothing but a marked section: `Tabs.astro` recovers the
4
+ * label from it and re-emits the body, because Astro hands a framework component its children
5
+ * as opaque pre-rendered HTML and nothing inside React could read this `label`.
6
+ */
7
+ interface Props {
8
+ label: string;
9
+ /** Lucide icon name. */
10
+ icon?: string;
11
+ }
12
+
13
+ const { label, icon } = Astro.props;
14
+ ---
15
+
16
+ <section data-eq-tab-panel data-label={label} data-icon={icon}><slot /></section>
@@ -0,0 +1,18 @@
1
+ ---
2
+ /** Bridges a markdown table to Equality's `Table`; the column count comes from `rehypeTableColumns`. */
3
+ import { TableContainer } from '@eqtylab/equality';
4
+
5
+ interface Props {
6
+ 'data-column-count'?: string;
7
+ }
8
+
9
+ const count = Number(Astro.props['data-column-count'] ?? 0);
10
+
11
+ // minmax(0, auto): without a zero minimum, one long unbroken cell blows the grid past its container.
12
+ const columns = count > 0 ? `repeat(${count}, minmax(0, auto))` : undefined;
13
+ ---
14
+
15
+ {/* Flow spacing only; every visual property comes from Equality's Table. */}
16
+ <TableContainer columns={columns} border className="mb-4" data-eq-chrome>
17
+ <slot />
18
+ </TableContainer>
@@ -0,0 +1,121 @@
1
+ ---
2
+ /**
3
+ * `<Tabs><TabItem label="…">` for MDX, on top of Equality's `Tabs`.
4
+ *
5
+ * Labels are recovered from the rendered slot because Astro hands a framework component its
6
+ * children as opaque pre-rendered HTML - nothing inside React can read a `<TabItem>`'s
7
+ * `label`. Matching is on `data-eq-tab-panel`, this package's own marker, never on
8
+ * `data-label` alone, so authored content cannot be mistaken for a panel.
9
+ */
10
+ import GithubSlugger from 'github-slugger';
11
+
12
+ import TabsBridge from './TabsBridge.tsx';
13
+
14
+ interface Props {
15
+ /** Tab sets sharing a key switch together, and the reader's choice is remembered. */
16
+ syncKey?: string;
17
+ }
18
+
19
+ const { syncKey } = Astro.props;
20
+
21
+ const slotHtml = await Astro.slots.render('default');
22
+
23
+ if (/\bdata-eq-tabs\b/.test(slotHtml)) {
24
+ throw new Error('[@eqtylab/docs] Nested <Tabs> are not supported.');
25
+ }
26
+
27
+ interface Panel {
28
+ label: string;
29
+ icon?: string;
30
+ html: string;
31
+ }
32
+
33
+ const OPEN = /<section\b([^>]*)>/gi;
34
+ const SECTION = /<(\/?)section\b[^>]*>/gi;
35
+
36
+ const NAMED: Record<string, string> = {
37
+ amp: '&',
38
+ lt: '<',
39
+ gt: '>',
40
+ quot: '"',
41
+ apos: "'",
42
+ };
43
+
44
+ /**
45
+ * Attribute values arrive escaped, and a label like "BIOS & Host Config" is ordinary. One
46
+ * pass, not chained replaces: decoding named then numeric would turn a literal `&amp;#38;`
47
+ * into a bare `&`.
48
+ */
49
+ function decode(value: string): string {
50
+ return value.replace(
51
+ /&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|quot|apos));/gi,
52
+ (match: string, dec?: string, hex?: string, name?: string) => {
53
+ if (dec) return String.fromCodePoint(Number(dec));
54
+ if (hex) return String.fromCodePoint(parseInt(hex, 16));
55
+ return (name && NAMED[name.toLowerCase()]) || match;
56
+ }
57
+ );
58
+ }
59
+
60
+ function attribute(attrs: string, name: string): string | undefined {
61
+ const match = new RegExp(`\\b${name}="([^"]*)"`, 'i').exec(attrs);
62
+ return match ? decode(match[1] as string) : undefined;
63
+ }
64
+
65
+ /** Depth-counted, so a panel may contain sections of its own. */
66
+ function closeAt(source: string, from: number): number {
67
+ SECTION.lastIndex = from;
68
+ let depth = 1;
69
+ let match: RegExpExecArray | null;
70
+ while ((match = SECTION.exec(source))) {
71
+ depth += match[1] ? -1 : 1;
72
+ if (depth === 0) return match.index;
73
+ }
74
+ return -1;
75
+ }
76
+
77
+ function readPanels(source: string): Panel[] {
78
+ const found: Panel[] = [];
79
+ OPEN.lastIndex = 0;
80
+ let match: RegExpExecArray | null;
81
+ while ((match = OPEN.exec(source))) {
82
+ const attrs = match[1] ?? '';
83
+ if (!/\bdata-eq-tab-panel\b/.test(attrs)) continue;
84
+ const bodyStart = OPEN.lastIndex;
85
+ const bodyEnd = closeAt(source, bodyStart);
86
+ if (bodyEnd < 0) break;
87
+ found.push({
88
+ label: attribute(attrs, 'data-label') ?? '',
89
+ icon: attribute(attrs, 'data-icon'),
90
+ html: source.slice(bodyStart, bodyEnd),
91
+ });
92
+ OPEN.lastIndex = bodyEnd;
93
+ }
94
+ return found;
95
+ }
96
+
97
+ const panels = readPanels(slotHtml);
98
+
99
+ if (panels.length === 0) {
100
+ throw new Error('[@eqtylab/docs] <Tabs> needs at least one <TabItem label="…"> child.');
101
+ }
102
+
103
+ const slugger = new GithubSlugger();
104
+ const items = panels.map((panel) => ({
105
+ label: panel.label,
106
+ value: slugger.slug(panel.label || 'tab'),
107
+ icon: panel.icon,
108
+ html: panel.html,
109
+ }));
110
+
111
+ // Equality requires an id, but `staticRender` never renders the motion layoutId that uses it.
112
+ const id = `eq-tabs-${items[0]?.value}`;
113
+ ---
114
+
115
+ <div class="mb-4" data-eq-tabs data-sync-key={syncKey}>
116
+ <TabsBridge id={id} items={items} />
117
+ </div>
118
+
119
+ <script>
120
+ import '@eqtylab/docs/scripts/eq-tabs.ts';
121
+ </script>
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Equality's `Tabs`, server-rendered with no client React.
3
+ *
4
+ * This exists as a React component, rather than as markup in `Tabs.astro`, because the whole
5
+ * Radix tree has to render inside one root: its context does not survive an Astro component
6
+ * boundary, which is why `TableBridge` can get away with a plain `<slot />` and this cannot.
7
+ */
8
+ import { Tabs } from '@eqtylab/equality';
9
+
10
+ export interface TabsBridgeItem {
11
+ label: string;
12
+ value: string;
13
+ icon?: string;
14
+ /** Panel body, already rendered by `Astro.slots.render`. */
15
+ html: string;
16
+ }
17
+
18
+ interface Props {
19
+ id: string;
20
+ items: TabsBridgeItem[];
21
+ }
22
+
23
+ export default function TabsBridge({ id, items }: Props) {
24
+ return (
25
+ <Tabs
26
+ id={id}
27
+ /* Mounts every panel and puts the indicator in every trigger, so `eq-tabs.ts` can
28
+ switch tabs by flipping `data-state` with no React on the page. */
29
+ staticRender
30
+ items={items.map(({ label, value, icon, html }) => ({
31
+ label,
32
+ value,
33
+ icon,
34
+ /* `eq-tabs.ts` pairs synced sets by label; reading it off the trigger's text would
35
+ break as soon as a tab carries an icon or a suffix. */
36
+ triggerProps: { 'data-eq-tab-label': label },
37
+ content: <div dangerouslySetInnerHTML={{ __html: html }} />,
38
+ }))}
39
+ />
40
+ );
41
+ }
@@ -0,0 +1,4 @@
1
+ /** Public components for MDX authors. `.astro` only: a React component here pulls React into every page. */
2
+ export { default as Link } from './Link.astro';
3
+ export { default as TabItem } from './TabItem.astro';
4
+ export { default as Tabs } from './Tabs.astro';
@@ -0,0 +1,50 @@
1
+ ---
2
+ /** Header + sidebar | prose | TOC. The `doc` template. */
3
+ import type { NavNode, TocNode } from '@eqtylab/docs/types';
4
+ import CONFIG from 'virtual:eqty-docs/config';
5
+ import DocsShell from './DocsShell.astro';
6
+ import Header from '../chrome/Header.astro';
7
+ import NavDrawer from '../chrome/NavDrawer.astro';
8
+ import Sidebar from '../chrome/Sidebar.astro';
9
+ import TableOfContents from '../chrome/TableOfContents.astro';
10
+
11
+ interface Props {
12
+ title: string;
13
+ description?: string;
14
+ nav: NavNode[];
15
+ toc: TocNode[];
16
+ showToc?: boolean;
17
+ splash?: boolean;
18
+ }
19
+
20
+ const { title, description, nav, toc, showToc = true, splash = false } = Astro.props;
21
+
22
+ // A section's own index page if it has one, else its first child. Never the sidebar repeated.
23
+ const suggested = nav
24
+ .map((node) => ({ label: node.label, href: node.href ?? node.children?.[0]?.href }))
25
+ .filter((item): item is { label: string; href: string } => !!item.href)
26
+ .slice(0, 4);
27
+ ---
28
+
29
+ <DocsShell title={title} description={description}>
30
+ {/* A prop, not slot fallback: Astro registers a named slot at compile time, so a forwarded
31
+ slot always counts as filled and the fallback never runs. */}
32
+ <Header showSearch={!Astro.slots.has('search')} suggested={suggested}>
33
+ <slot name="search" slot="search" />
34
+ <slot name="versions" slot="versions" />
35
+ </Header>
36
+ <NavDrawer nodes={nav} />
37
+ <div class="flex items-start">
38
+ {!splash && <Sidebar nodes={nav} />}
39
+ {/* 128px of runway, so a page stops deliberately rather than at its last line. */}
40
+ <main
41
+ class="mx-auto flex w-full min-w-0 max-w-[calc(var(--eq-docs-content-max)+var(--eq-docs-toc-width)+8rem)] items-start gap-12 px-6 pb-32 pt-10 data-[splash]:max-w-none xl:gap-16"
42
+ data-splash={splash ? '' : undefined}
43
+ >
44
+ <div class="min-w-0 flex-1 [&>.eq-prose]:max-w-[var(--eq-docs-content-max)]">
45
+ <slot />
46
+ </div>
47
+ {!splash && showToc && toc.length > 0 && <TableOfContents items={toc} />}
48
+ </main>
49
+ </div>
50
+ </DocsShell>
@@ -0,0 +1,92 @@
1
+ ---
2
+ /** The <html> document: head, theme bootstrap, and a slot for the page body. */
3
+ import CONFIG from 'virtual:eqty-docs/config';
4
+ import { withBase } from '@eqtylab/docs/paths';
5
+
6
+ import '../styles/docs.css';
7
+
8
+ interface Props {
9
+ title?: string;
10
+ description?: string;
11
+ /** Emit robots noindex. Set for every pinned version build. */
12
+ noIndex?: boolean;
13
+ /** Canonical URL, when it differs from the current page. */
14
+ canonical?: string;
15
+ }
16
+
17
+ const { title, description, noIndex, canonical } = Astro.props;
18
+
19
+ const paths = {
20
+ base: import.meta.env.BASE_URL,
21
+ pathPrefix: CONFIG.pathPrefix,
22
+ };
23
+
24
+ const pageTitle = title && title !== CONFIG.title ? `${title} · ${CONFIG.title}` : CONFIG.title;
25
+ const pageDescription = description ?? CONFIG.description;
26
+
27
+ // Pinned version builds are noindex: latest today is not latest tomorrow.
28
+ const robots = noIndex ?? !CONFIG.env.isLatest;
29
+
30
+ /**
31
+ * Blocking inline theme bootstrap, so the attribute is set before first paint.
32
+ * Do not also call Equality's `initializeTheme`: it knows only light and dark
33
+ * and its stored value would override this. Never write storage here, or
34
+ * 'system' becomes a resolved value permanently. Constants must match
35
+ * `runtime/lib/theme.ts`.
36
+ */
37
+ const themeBootstrap = `
38
+ (() => {
39
+ const KEY = 'eqty-docs-theme';
40
+ const EVENT = 'eqty-docs-theme-change';
41
+ const PERSIST = ${JSON.stringify(CONFIG.theme.persist)};
42
+ const query = window.matchMedia('(prefers-color-scheme: dark)');
43
+
44
+ const read = () => {
45
+ if (!PERSIST) return window.__eqtyDocsTheme ?? null;
46
+ try {
47
+ return window.localStorage.getItem(KEY);
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ const apply = () => {
54
+ const stored = read();
55
+ const preference =
56
+ stored === 'dark' || stored === 'light' || stored === 'system' ? stored : 'system';
57
+ const resolved = preference === 'system' ? (query.matches ? 'dark' : 'light') : preference;
58
+ const root = document.documentElement;
59
+ root.setAttribute('data-equality-theme', resolved);
60
+ root.setAttribute('data-eq-theme-pref', preference);
61
+ // Native UI follows this, not the attribute above.
62
+ root.style.colorScheme = resolved;
63
+ };
64
+
65
+ apply();
66
+ query.addEventListener('change', apply);
67
+ window.addEventListener(EVENT, apply);
68
+ // The React subscription never touches the attribute, so other tabs repaint here.
69
+ window.addEventListener('storage', (event) => {
70
+ if (event.key === KEY) apply();
71
+ });
72
+ })();
73
+ `;
74
+ ---
75
+
76
+ <!doctype html>
77
+ <html lang="en">
78
+ <head>
79
+ <meta charset="utf-8" />
80
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
81
+ <title>{pageTitle}</title>
82
+ {pageDescription && <meta name="description" content={pageDescription} />}
83
+ {robots && <meta name="robots" content="noindex, follow" />}
84
+ {canonical && <link rel="canonical" href={canonical} />}
85
+ <link rel="icon" href={withBase(CONFIG.favicon, paths)} />
86
+ <script is:inline set:html={themeBootstrap} />
87
+ <slot name="head" />
88
+ </head>
89
+ <body>
90
+ <slot />
91
+ </body>
92
+ </html>
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Route-level MDX component map. Entries here OVERRIDE a page's own
3
+ * `export const components`, so keep it small.
4
+ */
5
+ import {
6
+ TableBody,
7
+ TableCaption,
8
+ TableCell,
9
+ TableFooter,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ } from '@eqtylab/equality';
14
+ import CONFIG from 'virtual:eqty-docs/config';
15
+
16
+ import AlertBridge from '../components/AlertBridge.astro';
17
+ import CodeFenceBridge from '../components/CodeFenceBridge.astro';
18
+ import Link from '../components/Link.astro';
19
+ import TabItem from '../components/TabItem.astro';
20
+ import TableBridge from '../components/TableBridge.astro';
21
+ import Tabs from '../components/Tabs.astro';
22
+
23
+ const base: Record<string, unknown> = {
24
+ a: Link,
25
+
26
+ // An explicit import of Alert from @eqtylab/equality still wins.
27
+ Alert: AlertBridge,
28
+
29
+ // Equality's Tabs, server-rendered. A file-level import still wins, as with Alert.
30
+ Tabs,
31
+ TabItem,
32
+
33
+ // All hook-free, so they render statically with no client directive.
34
+ table: TableBridge,
35
+ thead: TableHeader,
36
+ tbody: TableBody,
37
+ tfoot: TableFooter,
38
+ tr: TableRow,
39
+ th: TableHead,
40
+ td: TableCell,
41
+ caption: TableCaption,
42
+ };
43
+
44
+ // With 'shiki', Astro has already rendered the block.
45
+ if (CONFIG.code?.highlighter === 'codeblock') {
46
+ base.pre = CodeFenceBridge;
47
+ }
48
+
49
+ export const mdxComponents = base;
@@ -0,0 +1,74 @@
1
+ /** Bridges the content collections into the pure nav builder. Needs `astro:content`, so it lives in the runtime tree. */
2
+ import { breadcrumbsFor, buildNavTree, buildTocTree, prevNextFor } from '@eqtylab/docs/nav';
3
+ import type { GroupConfig } from '@eqtylab/docs/nav';
4
+ import type { DocsNavEntry, NavNode } from '@eqtylab/docs/types';
5
+ import { getCollection } from 'astro:content';
6
+ import CONFIG from 'virtual:eqty-docs/config';
7
+
8
+ export { buildTocTree, breadcrumbsFor, prevNextFor };
9
+
10
+ /** No `versionPrefix`: the version already lives in `base`, and adding it here doubles the segment. */
11
+ export function pathContext() {
12
+ return {
13
+ base: import.meta.env.BASE_URL,
14
+ pathPrefix: CONFIG.pathPrefix,
15
+ };
16
+ }
17
+
18
+ /** All docs entries, with drafts filtered out unless we're in `astro dev`. */
19
+ export async function docsEntries() {
20
+ return getCollection('docs', ({ data }: { data: { draft?: boolean } }) =>
21
+ import.meta.env.DEV ? true : !data.draft
22
+ );
23
+ }
24
+
25
+ async function groupMap(): Promise<Map<string, GroupConfig>> {
26
+ const map = new Map<string, GroupConfig>();
27
+ try {
28
+ const groups = await getCollection('docsGroups');
29
+ for (const group of groups) {
30
+ map.set(group.id, group.data as GroupConfig);
31
+ }
32
+ } catch {
33
+ // docsGroups is optional; no _group.yaml means alphabetical ordering.
34
+ }
35
+ return map;
36
+ }
37
+
38
+ export async function docsNav(currentPath: string): Promise<NavNode[]> {
39
+ const [entries, groups] = await Promise.all([docsEntries(), groupMap()]);
40
+
41
+ const navEntries: DocsNavEntry[] = entries.map((entry) => ({
42
+ id: entry.id,
43
+ filePath: entry.filePath,
44
+ label: entry.data.navLabel ?? entry.data.title,
45
+ icon: entry.data.icon,
46
+ badge: entry.data.badge ?? deprecationBadge(entry.data.deprecated),
47
+ hidden: entry.data.hidden,
48
+ draft: entry.data.draft,
49
+ }));
50
+
51
+ return buildNavTree({
52
+ entries: navEntries,
53
+ groups,
54
+ currentPath,
55
+ paths: pathContext(),
56
+ defaultCollapsed: CONFIG.sidebar.collapsed,
57
+ defaultSort: CONFIG.sidebar.sort,
58
+ extra: (CONFIG.sidebar.extra ?? []) as NavNode[],
59
+ onWarn: warnOnce,
60
+ });
61
+ }
62
+
63
+ // The nav is rebuilt per page; report each config problem once per build.
64
+ const warned = new Set<string>();
65
+ function warnOnce(message: string) {
66
+ if (warned.has(message)) return;
67
+ warned.add(message);
68
+ console.warn(`[@eqtylab/docs] ${message}`);
69
+ }
70
+
71
+ function deprecationBadge(deprecated: unknown) {
72
+ if (!deprecated) return undefined;
73
+ return { text: 'Deprecated', variant: 'warning' as const };
74
+ }
@@ -0,0 +1,57 @@
1
+ /** Fallback page summary for pages with no `description` frontmatter. Build time only. */
2
+
3
+ const MAX_LENGTH = 140;
4
+
5
+ function isSkippable(line: string): boolean {
6
+ return (
7
+ line === '' ||
8
+ line.startsWith('#') ||
9
+ line.startsWith('import ') ||
10
+ line.startsWith('export ') ||
11
+ line.startsWith('<') ||
12
+ line.startsWith('|') ||
13
+ line.startsWith('>') ||
14
+ line.startsWith('- ') ||
15
+ line.startsWith('* ') ||
16
+ /^\d+\.\s/.test(line)
17
+ );
18
+ }
19
+
20
+ /** Enough markdown to make one sentence readable; anything richer is not a summary anyway. */
21
+ function stripMarkdown(text: string): string {
22
+ return text
23
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
24
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
25
+ .replace(/[`*_]/g, '')
26
+ .replace(/<[^>]+>/g, '')
27
+ .replace(/\s+/g, ' ')
28
+ .trim();
29
+ }
30
+
31
+ function truncate(text: string): string {
32
+ if (text.length <= MAX_LENGTH) return text;
33
+ const cut = text.slice(0, MAX_LENGTH);
34
+ const lastSpace = cut.lastIndexOf(' ');
35
+ return `${cut.slice(0, lastSpace > 0 ? lastSpace : MAX_LENGTH).trimEnd()}…`;
36
+ }
37
+
38
+ /** First paragraph of prose. Frontmatter is already stripped by the content loader. */
39
+ export function firstParagraph(body: string | undefined): string | undefined {
40
+ if (!body) return undefined;
41
+
42
+ let inFence = false;
43
+ for (const raw of body.split('\n')) {
44
+ const line = raw.trim();
45
+
46
+ if (line.startsWith('```')) {
47
+ inFence = !inFence;
48
+ continue;
49
+ }
50
+ if (inFence || isSkippable(line)) continue;
51
+
52
+ const text = stripMarkdown(line);
53
+ // A line that was nothing but a link or a tag strips to nothing; keep looking.
54
+ if (text) return truncate(text);
55
+ }
56
+ return undefined;
57
+ }