@ubx/docs-ui 0.1.0
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.
- package/README.md +37 -0
- package/dist/CodeBlock.d.ts +4 -0
- package/dist/CodeBlock.js +35 -0
- package/dist/Footer.d.ts +1 -0
- package/dist/Footer.js +11 -0
- package/dist/GlobalSearch.d.ts +15 -0
- package/dist/GlobalSearch.js +34 -0
- package/dist/Header.d.ts +18 -0
- package/dist/Header.js +18 -0
- package/dist/MobileSidebarToggle.d.ts +3 -0
- package/dist/MobileSidebarToggle.js +75 -0
- package/dist/ThemeToggle.d.ts +1 -0
- package/dist/ThemeToggle.js +94 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +16 -0
- package/dist/theme-a.d.ts +6 -0
- package/dist/theme-a.js +145 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @ubx/docs-ui
|
|
2
|
+
|
|
3
|
+
Shared UI for ubx's two documentation sites, `ubx-docs-providers` and
|
|
4
|
+
`ubx-docs-users`.
|
|
5
|
+
|
|
6
|
+
Extracted only after both consumers existed. Three interfaces had to
|
|
7
|
+
change to be shareable at all, and one of those was not predicted:
|
|
8
|
+
`MobileSidebarToggle` imported `ProviderSidebar` directly, found only
|
|
9
|
+
when the second site failed to build. Extracting against a single call
|
|
10
|
+
site would have carried that coupling in unnoticed.
|
|
11
|
+
|
|
12
|
+
## What is here
|
|
13
|
+
|
|
14
|
+
| Export | Note |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `CodeBlock` | Shiki-backed, async server component, build-time only |
|
|
17
|
+
| `themeA` | Theme A as a TextMate theme, CSS variables not hex |
|
|
18
|
+
| `Header` | Takes `nav` and optional `tabs` as props |
|
|
19
|
+
| `GlobalSearch` | Takes a generic `SearchEntry` |
|
|
20
|
+
| `MobileSidebarToggle` | Takes drawer contents as `children` |
|
|
21
|
+
| `ThemeToggle`, `Footer` | Unchanged from the originals |
|
|
22
|
+
|
|
23
|
+
## Theme A and the consuming site
|
|
24
|
+
|
|
25
|
+
`themeA` emits `var(--color-code-green)` and friends rather than hex, so
|
|
26
|
+
the consuming site's own `globals.css` keeps driving light and dark
|
|
27
|
+
through its existing `[data-theme]` contract with zero client JS. This
|
|
28
|
+
package never needs to know the actual colors.
|
|
29
|
+
|
|
30
|
+
The site must define: `--color-code-green`, `--color-code-red`,
|
|
31
|
+
`--color-code-yellow`, `--color-foreground`, `--color-foreground-muted`,
|
|
32
|
+
`--color-code-bg`.
|
|
33
|
+
|
|
34
|
+
Read `src/theme-a.ts` before changing the scope mapping. The three
|
|
35
|
+
overrides at the bottom were findable only by inspecting real token
|
|
36
|
+
output per language, and without them the naive mapping is visibly worse
|
|
37
|
+
than the hand-rolled tokenizer this replaces.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { codeToHtml } from "shiki";
|
|
3
|
+
import { themeA, isSupportedLang } from "./theme-a";
|
|
4
|
+
// Shiki-backed, replacing the hand-rolled tokenizer both sites used to
|
|
5
|
+
// carry a copy of. That tokenizer's own comment already named a real
|
|
6
|
+
// highlighting library as the honest long-term answer; six languages
|
|
7
|
+
// across two consumers is past where hand rolling pays.
|
|
8
|
+
//
|
|
9
|
+
// This is an async server component. Both sites statically export, so
|
|
10
|
+
// Shiki runs at build time only and contributes nothing to the client
|
|
11
|
+
// bundle.
|
|
12
|
+
//
|
|
13
|
+
// Two behaviours are strictly better than what it replaces, both
|
|
14
|
+
// verified against real token output rather than assumed:
|
|
15
|
+
// - JSON keys render yellow. The old regex matched quoted strings
|
|
16
|
+
// first, so keys came out red, which theme A reserves for string
|
|
17
|
+
// literals.
|
|
18
|
+
// - HCL attribute names render yellow. The old property rule only
|
|
19
|
+
// recognised `name:`, so HCL's `name =` was left plain.
|
|
20
|
+
export async function CodeBlock({ code, lang }) {
|
|
21
|
+
const trimmed = code.replace(/\n$/, "");
|
|
22
|
+
// An unknown language renders in the same code surface, uncoloured,
|
|
23
|
+
// rather than being forced through a grammar built for something else.
|
|
24
|
+
if (!isSupportedLang(lang)) {
|
|
25
|
+
return (_jsx("pre", { className: "overflow-x-auto rounded-2xl bg-code p-4 text-sm leading-relaxed", children: _jsx("code", { className: "font-mono-tabular text-foreground", children: trimmed }) }));
|
|
26
|
+
}
|
|
27
|
+
const html = await codeToHtml(trimmed, {
|
|
28
|
+
lang,
|
|
29
|
+
theme: themeA,
|
|
30
|
+
});
|
|
31
|
+
// Shiki emits its own <pre class="shiki">; the wrapper below keeps the
|
|
32
|
+
// surface, radius and spacing identical to what both sites already
|
|
33
|
+
// render, so the swap is invisible outside the token colors themselves.
|
|
34
|
+
return (_jsx("div", { className: "overflow-x-auto rounded-2xl bg-code p-4 text-sm leading-relaxed [&_pre]:bg-transparent [&_pre]:m-0 [&_code]:font-mono-tabular", dangerouslySetInnerHTML: { __html: html } }));
|
|
35
|
+
}
|
package/dist/Footer.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function Footer(): import("react").JSX.Element;
|
package/dist/Footer.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Footer: Material style, a hairline divider and quiet text, not a
|
|
3
|
+
// heavy block -- the same restrained treatment already established
|
|
4
|
+
// for hairline dividers site-wide (globals.css's own --color-border).
|
|
5
|
+
// Links repeat two of Header's own real, checked destinations
|
|
6
|
+
// (Documentation, GitHub) rather than inventing new ones, plus a
|
|
7
|
+
// License link to this repo's own real LICENSE file (Apache 2.0,
|
|
8
|
+
// confirmed directly from the file, not assumed).
|
|
9
|
+
export function Footer() {
|
|
10
|
+
return (_jsx("footer", { className: "border-t border-border", children: _jsxs("div", { className: "mx-auto max-w-7xl px-6 py-8 text-sm text-foreground-muted", children: [_jsxs("div", { className: "flex flex-col items-center gap-3 sm:flex-row sm:justify-between", children: [_jsx("p", { children: "Reference content is generated from each provider\u2019s own real schema, not hand-written." }), _jsxs("nav", { className: "flex items-center gap-5", children: [_jsx("a", { href: "https://docs.ubiquex.io", className: "hover:text-primary", children: "Documentation" }), _jsx("a", { href: "https://github.com/Ubiquex", className: "hover:text-primary", children: "GitHub" }), _jsx("a", { href: "https://github.com/Ubiquex/ubx-docs-providers/blob/main/LICENSE", className: "hover:text-primary", children: "License" })] })] }), _jsx("p", { className: "mt-4 text-center text-xs sm:text-left", children: "\u00A9 2026 Ubiquex" })] }) }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type SearchEntry = {
|
|
2
|
+
title: string;
|
|
3
|
+
subtitle?: string;
|
|
4
|
+
group?: string;
|
|
5
|
+
/** Short tag rendered on the right, e.g. the provider site's "data". */
|
|
6
|
+
badge?: string;
|
|
7
|
+
path: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function GlobalSearch({ indexUrl, placeholder, inputClassName, emptyMessage, }: {
|
|
10
|
+
indexUrl?: string;
|
|
11
|
+
placeholder: string;
|
|
12
|
+
inputClassName: string;
|
|
13
|
+
/** Rendered when a non-empty query matches nothing. */
|
|
14
|
+
emptyMessage?: (query: string) => React.ReactNode;
|
|
15
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
// PRESENTATION IS A PROP, NOT A DEFAULT, and that is deliberate.
|
|
6
|
+
//
|
|
7
|
+
// The first draft of this extraction silently carried the USER site's
|
|
8
|
+
// input styling into the provider site, changing its search box from a
|
|
9
|
+
// pill (rounded-full, px-5 py-3, text-base) to a bordered rectangle and
|
|
10
|
+
// its placeholder from "Search resources and data sources..." to
|
|
11
|
+
// "Search". Caught by diffing the provider site's rendered markup
|
|
12
|
+
// against the live site before and after the swap. A shared component
|
|
13
|
+
// quietly restyling a working site is exactly the failure mode this
|
|
14
|
+
// extraction was supposed to avoid, so both consumers now state their
|
|
15
|
+
// own presentation explicitly and neither inherits the other's.
|
|
16
|
+
export function GlobalSearch({ indexUrl = "/search-index.json", placeholder, inputClassName, emptyMessage, }) {
|
|
17
|
+
const [index, setIndex] = useState([]);
|
|
18
|
+
const [query, setQuery] = useState("");
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
fetch(indexUrl)
|
|
21
|
+
.then((r) => r.json())
|
|
22
|
+
.then(setIndex)
|
|
23
|
+
.catch(() => setIndex([]));
|
|
24
|
+
}, [indexUrl]);
|
|
25
|
+
const q = query.trim().toLowerCase();
|
|
26
|
+
const results = q
|
|
27
|
+
? index
|
|
28
|
+
.filter((e) => e.title.toLowerCase().includes(q) ||
|
|
29
|
+
e.subtitle?.toLowerCase().includes(q) ||
|
|
30
|
+
e.group?.toLowerCase().includes(q))
|
|
31
|
+
.slice(0, 20)
|
|
32
|
+
: [];
|
|
33
|
+
return (_jsxs("div", { className: "relative", children: [_jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: placeholder, className: inputClassName }), q && (_jsx("div", { className: "absolute z-10 mt-2 w-full rounded-2xl bg-surface shadow-lg", children: results.length === 0 ? (_jsx("p", { className: "px-4 py-3 text-sm text-foreground-muted", children: emptyMessage ? emptyMessage(query) : _jsxs(_Fragment, { children: ["No matches for \u201C", query, "\u201D."] }) })) : (_jsx("ul", { className: "max-h-96 overflow-y-auto divide-y divide-border", children: results.map((r) => (_jsx("li", { children: _jsxs(Link, { href: r.path, className: "flex items-center justify-between gap-3 px-4 py-2 text-sm hover:bg-surface", children: [_jsxs("span", { children: [_jsx("span", { className: "text-primary", children: r.title }), r.subtitle ? (_jsx("span", { className: "ml-2 text-xs text-foreground-muted", children: r.subtitle })) : null] }), r.badge ? (_jsx("span", { className: "shrink-0 rounded-full bg-foreground-muted/10 px-2 py-0.5 text-xs text-foreground-muted", children: r.badge })) : null] }) }, r.path))) })) }))] }));
|
|
34
|
+
}
|
package/dist/Header.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type NavLink = {
|
|
2
|
+
label: string;
|
|
3
|
+
href: string;
|
|
4
|
+
/** Marks the destination representing the current site. */
|
|
5
|
+
current?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type SectionTab = {
|
|
8
|
+
label: string;
|
|
9
|
+
href: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function Header({ nav, tabs, activeTab, mobileMenu, }: {
|
|
12
|
+
nav: NavLink[];
|
|
13
|
+
/** Omit entirely for a single-tier header, which is the provider site. */
|
|
14
|
+
tabs?: SectionTab[];
|
|
15
|
+
/** href of the active tab, matched by prefix so nested pages stay lit. */
|
|
16
|
+
activeTab?: string;
|
|
17
|
+
mobileMenu?: React.ReactNode;
|
|
18
|
+
}): import("react").JSX.Element;
|
package/dist/Header.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { ThemeToggle } from "./ThemeToggle";
|
|
4
|
+
export function Header({ nav, tabs, activeTab, mobileMenu, }) {
|
|
5
|
+
return (_jsxs("header", { className: "border-b border-border bg-background", children: [_jsxs("div", { className: "relative mx-auto flex max-w-7xl items-center gap-3 px-6 py-4", children: [mobileMenu, _jsxs(Link, { href: "/", className: "flex shrink-0 items-center", children: [_jsx("img", { src: "/logo/logo.png", alt: "ubx", className: "logo-light h-6 w-auto" }), _jsx("img", { src: "/logo/logo-dark.png", alt: "ubx", className: "logo-dark h-6 w-auto" })] }), _jsx("div", { className: "flex-1" }), _jsx("nav", { className: "absolute left-1/2 hidden -translate-x-1/2 items-center gap-5 md:flex", children: nav.map((item) => {
|
|
6
|
+
const className = item.current
|
|
7
|
+
? "text-sm text-primary"
|
|
8
|
+
: "text-sm text-foreground-muted hover:text-primary";
|
|
9
|
+
return item.href.startsWith("/") ? (_jsx(Link, { href: item.href, className: className, children: item.label }, item.label)) : (_jsx("a", { href: item.href, className: className, children: item.label }, item.label));
|
|
10
|
+
}) }), _jsx(ThemeToggle, {})] }), tabs && tabs.length > 0 ? (_jsx("div", { className: "mx-auto max-w-7xl px-6", children: _jsx("nav", { className: "flex gap-6 overflow-x-auto", children: tabs.map((tab) => {
|
|
11
|
+
// Prefix match, so /concepts/ledger keeps the Concepts tab
|
|
12
|
+
// lit rather than only the exact section index.
|
|
13
|
+
const active = activeTab === tab.href || activeTab?.startsWith(tab.href + "/");
|
|
14
|
+
return (_jsx(Link, { href: tab.href, className: active
|
|
15
|
+
? "-mb-px border-b-2 border-primary py-2 text-sm text-primary"
|
|
16
|
+
: "-mb-px border-b-2 border-transparent py-2 text-sm text-foreground-muted hover:text-primary", children: tab.label }, tab.href));
|
|
17
|
+
}) }) })) : null] }));
|
|
18
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { usePathname } from "next/navigation";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
// Opens from the header, since the desktop sidebar (ProviderSidebar's
|
|
6
|
+
// own "hidden lg:block" rail) contributes nothing below that
|
|
7
|
+
// breakpoint -- without this, a reader on mobile has no way to reach
|
|
8
|
+
// a provider's service groups or resources at all once past the
|
|
9
|
+
// provider home page. Renders the identical ProviderSidebar (same
|
|
10
|
+
// fetch, same filter, same tree) inside a drawer rather than a
|
|
11
|
+
// second, parallel mobile nav that could drift from the real one.
|
|
12
|
+
//
|
|
13
|
+
// Two-state open/close (mounted + visible, not a single boolean) so
|
|
14
|
+
// the close transition has time to actually play: closing flips
|
|
15
|
+
// `visible` off immediately (the CSS transition animates toward the
|
|
16
|
+
// closed position) and only removes the drawer from the DOM once that
|
|
17
|
+
// transition has had time to finish, rather than the element
|
|
18
|
+
// vanishing the instant the tap registers.
|
|
19
|
+
const TRANSITION_MS = 200;
|
|
20
|
+
// UBI-247: takes the drawer contents as `children` rather than
|
|
21
|
+
// constructing a ProviderSidebar itself. That coupling was the one thing
|
|
22
|
+
// in this component that was not generic, and it is exactly the kind of
|
|
23
|
+
// thing that would have been carried into @ubx/docs-ui unnoticed had the
|
|
24
|
+
// extraction been done first against a single call site. The provider
|
|
25
|
+
// site passes <ProviderSidebar .../>, this site passes <DocSidebar .../>,
|
|
26
|
+
// and the open/close/transition/route-change behaviour below is
|
|
27
|
+
// identical for both.
|
|
28
|
+
export function MobileSidebarToggle({ children }) {
|
|
29
|
+
const [mounted, setMounted] = useState(false);
|
|
30
|
+
const [visible, setVisible] = useState(false);
|
|
31
|
+
const closeTimer = useRef(null);
|
|
32
|
+
const pathname = usePathname();
|
|
33
|
+
const isFirstPathname = useRef(true);
|
|
34
|
+
function clearCloseTimer() {
|
|
35
|
+
if (closeTimer.current) {
|
|
36
|
+
clearTimeout(closeTimer.current);
|
|
37
|
+
closeTimer.current = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function open() {
|
|
41
|
+
clearCloseTimer();
|
|
42
|
+
setMounted(true);
|
|
43
|
+
// Mounts in the closed visual position first -- flipping to
|
|
44
|
+
// `visible` one frame later gives the browser an actual "from"
|
|
45
|
+
// state to transition out of, rather than painting already open.
|
|
46
|
+
requestAnimationFrame(() => {
|
|
47
|
+
requestAnimationFrame(() => setVisible(true));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function close() {
|
|
51
|
+
setVisible(false);
|
|
52
|
+
clearCloseTimer();
|
|
53
|
+
closeTimer.current = setTimeout(() => setMounted(false), TRANSITION_MS);
|
|
54
|
+
}
|
|
55
|
+
useEffect(() => clearCloseTimer, []);
|
|
56
|
+
// Explicit close-on-navigation rather than relying on this
|
|
57
|
+
// component happening to remount between routes -- Header sits
|
|
58
|
+
// directly in each page.tsx, not behind a shared layout boundary
|
|
59
|
+
// between different resource pages, so whether React actually tears
|
|
60
|
+
// this instance down on every navigation isn't guaranteed. Skips
|
|
61
|
+
// the very first render (mount already starts closed) so this only
|
|
62
|
+
// ever fires on a real route change.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (isFirstPathname.current) {
|
|
65
|
+
isFirstPathname.current = false;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
close();
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [pathname]);
|
|
71
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: open, "aria-label": "Open service navigation", className: "flex h-9 w-9 shrink-0 items-center justify-center rounded text-foreground-muted hover:bg-surface hover:text-primary lg:hidden", children: _jsx("svg", { viewBox: "0 0 20 20", width: "20", height: "20", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M3 5.5h14M3 10h14M3 14.5h14", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) }) }), mounted && (_jsxs("div", { role: "dialog", "aria-modal": "true", className: "fixed inset-0 z-50 lg:hidden", children: [_jsx("button", { type: "button", "aria-label": "Close service navigation", onClick: close, className: "absolute inset-0 bg-foreground/40 transition-opacity duration-200 motion-reduce:transition-none " +
|
|
72
|
+
(visible ? "opacity-100" : "opacity-0") }), _jsxs("div", { className: "absolute inset-y-0 left-0 flex w-80 max-w-[85vw] flex-col overflow-y-auto bg-background p-4 shadow-lg " +
|
|
73
|
+
"transition-transform duration-200 ease-out motion-reduce:transition-none " +
|
|
74
|
+
(visible ? "translate-x-0" : "-translate-x-full"), children: [_jsxs("div", { className: "mb-3 flex items-center justify-between", children: [_jsx("span", { className: "text-sm font-semibold text-foreground", children: "Services" }), _jsx("button", { type: "button", onClick: close, "aria-label": "Close service navigation", className: "flex h-8 w-8 items-center justify-center rounded text-foreground-muted hover:bg-surface hover:text-primary", children: _jsx("svg", { viewBox: "0 0 16 16", width: "16", height: "16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) }) })] }), children] })] }))] }));
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ThemeToggle(): import("react").JSX.Element;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
const STORAGE_KEY = "ubx-docs-theme";
|
|
5
|
+
function applyTheme(choice) {
|
|
6
|
+
const root = document.documentElement;
|
|
7
|
+
if (choice === "system")
|
|
8
|
+
root.removeAttribute("data-theme");
|
|
9
|
+
else
|
|
10
|
+
root.setAttribute("data-theme", choice);
|
|
11
|
+
}
|
|
12
|
+
// localStorage is real external state, not React state -- read through
|
|
13
|
+
// useSyncExternalStore rather than mirrored into a useState via a
|
|
14
|
+
// useEffect (the latter renders "system" first, then immediately
|
|
15
|
+
// re-renders to whatever was actually stored, a real extra render this
|
|
16
|
+
// avoids). getServerSnapshot returns "system" unconditionally: the
|
|
17
|
+
// server has no localStorage at all, and it's also the correct answer
|
|
18
|
+
// for a first client render before the "ubx-theme-change" listener
|
|
19
|
+
// below has run once -- the inline head script in app/layout.tsx has
|
|
20
|
+
// already set the real data-theme attribute on the DOM by then, so the
|
|
21
|
+
// page never actually shows the wrong theme, only this control's own
|
|
22
|
+
// active-state highlight briefly lags by one render on a genuinely
|
|
23
|
+
// fresh mount.
|
|
24
|
+
function subscribe(callback) {
|
|
25
|
+
window.addEventListener("storage", callback);
|
|
26
|
+
window.addEventListener("ubx-theme-change", callback);
|
|
27
|
+
return () => {
|
|
28
|
+
window.removeEventListener("storage", callback);
|
|
29
|
+
window.removeEventListener("ubx-theme-change", callback);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function getSnapshot() {
|
|
33
|
+
// Same real risk app/layout.tsx's own inline THEME_INIT_SCRIPT
|
|
34
|
+
// already guards against for the identical read -- private
|
|
35
|
+
// browsing, a restrictive mobile browser or in-app-browser storage
|
|
36
|
+
// policy, or a managed device can make localStorage throw rather
|
|
37
|
+
// than return null. This runs inside useSyncExternalStore, during
|
|
38
|
+
// React's render pass, in a component mounted in Header on every
|
|
39
|
+
// page -- an uncaught throw here, with no error boundary anywhere
|
|
40
|
+
// in this app, can fail hydration for the whole page, not just this
|
|
41
|
+
// control.
|
|
42
|
+
try {
|
|
43
|
+
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
44
|
+
return stored === "light" || stored === "dark" ? stored : "system";
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return "system";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function getServerSnapshot() {
|
|
51
|
+
return "system";
|
|
52
|
+
}
|
|
53
|
+
function SystemIcon() {
|
|
54
|
+
return (_jsxs("svg", { viewBox: "0 0 16 16", width: "14", height: "14", fill: "none", "aria-hidden": "true", children: [_jsx("rect", { x: "1.5", y: "2.5", width: "13", height: "8.5", rx: "1", stroke: "currentColor", strokeWidth: "1.3" }), _jsx("path", { d: "M5.5 14h5M8 11v3", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round" })] }));
|
|
55
|
+
}
|
|
56
|
+
function SunIcon() {
|
|
57
|
+
return (_jsxs("svg", { viewBox: "0 0 16 16", width: "14", height: "14", fill: "none", "aria-hidden": "true", children: [_jsx("circle", { cx: "8", cy: "8", r: "3", stroke: "currentColor", strokeWidth: "1.3" }), _jsx("path", { d: "M8 1v1.5M8 13.5V15M15 8h-1.5M2.5 8H1M12.7 3.3l-1 1M4.3 11.7l-1 1M12.7 12.7l-1-1M4.3 4.3l-1-1", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round" })] }));
|
|
58
|
+
}
|
|
59
|
+
function MoonIcon() {
|
|
60
|
+
return (_jsx("svg", { viewBox: "0 0 16 16", width: "14", height: "14", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M13.5 9.5A5.8 5.8 0 0 1 6.5 2.5 5.8 5.8 0 1 0 13.5 9.5Z", stroke: "currentColor", strokeWidth: "1.3", strokeLinejoin: "round" }) }));
|
|
61
|
+
}
|
|
62
|
+
const OPTIONS = [
|
|
63
|
+
{ choice: "system", label: "System", Icon: SystemIcon },
|
|
64
|
+
{ choice: "light", label: "Light", Icon: SunIcon },
|
|
65
|
+
{ choice: "dark", label: "Dark", Icon: MoonIcon },
|
|
66
|
+
];
|
|
67
|
+
// A three-way segmented control, one icon per real state, all three
|
|
68
|
+
// always visible -- replaces the earlier single cycling button (whose
|
|
69
|
+
// current state was legible only from its text label, distinct icons
|
|
70
|
+
// were the whole point of this pass).
|
|
71
|
+
export function ThemeToggle() {
|
|
72
|
+
const choice = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
73
|
+
function choose(next) {
|
|
74
|
+
applyTheme(next);
|
|
75
|
+
try {
|
|
76
|
+
if (next === "system")
|
|
77
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
78
|
+
else
|
|
79
|
+
window.localStorage.setItem(STORAGE_KEY, next);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Storage blocked -- the choice still applies to this page via
|
|
83
|
+
// applyTheme/data-theme above, it just will not persist across
|
|
84
|
+
// a reload. Never let a blocked write crash the click handler.
|
|
85
|
+
}
|
|
86
|
+
window.dispatchEvent(new Event("ubx-theme-change"));
|
|
87
|
+
}
|
|
88
|
+
return (_jsx("div", { role: "radiogroup", "aria-label": "Theme", className: "inline-flex rounded-md border border-border p-0.5", children: OPTIONS.map(({ choice: c, label, Icon }) => {
|
|
89
|
+
const active = c === choice;
|
|
90
|
+
return (_jsx("button", { type: "button", role: "radio", "aria-checked": active, "aria-label": label, title: label, onClick: () => choose(c), className: active
|
|
91
|
+
? "flex h-6 w-6 items-center justify-center rounded bg-primary text-primary-foreground"
|
|
92
|
+
: "flex h-6 w-6 items-center justify-center rounded text-foreground-muted hover:text-primary", children: _jsx(Icon, {}) }, c));
|
|
93
|
+
}) }));
|
|
94
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { CodeBlock } from "./CodeBlock";
|
|
2
|
+
export { themeA, isSupportedLang, SUPPORTED_LANGS } from "./theme-a";
|
|
3
|
+
export type { SupportedLang } from "./theme-a";
|
|
4
|
+
export { Header } from "./Header";
|
|
5
|
+
export type { NavLink, SectionTab } from "./Header";
|
|
6
|
+
export { GlobalSearch } from "./GlobalSearch";
|
|
7
|
+
export type { SearchEntry } from "./GlobalSearch";
|
|
8
|
+
export { ThemeToggle } from "./ThemeToggle";
|
|
9
|
+
export { Footer } from "./Footer";
|
|
10
|
+
export { MobileSidebarToggle } from "./MobileSidebarToggle";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @ubx/docs-ui: the components genuinely shared by ubx's two
|
|
2
|
+
// documentation sites (ubx-docs-providers and ubx-docs-users).
|
|
3
|
+
//
|
|
4
|
+
// Extracted only after both consumers existed, deliberately. Three of
|
|
5
|
+
// these interfaces had to change to be shareable at all, and one of
|
|
6
|
+
// those was NOT predicted: MobileSidebarToggle imported ProviderSidebar
|
|
7
|
+
// directly, which was found when the second site failed to build.
|
|
8
|
+
// Extracting against a single call site would have carried that coupling
|
|
9
|
+
// into this package unnoticed.
|
|
10
|
+
export { CodeBlock } from "./CodeBlock";
|
|
11
|
+
export { themeA, isSupportedLang, SUPPORTED_LANGS } from "./theme-a";
|
|
12
|
+
export { Header } from "./Header";
|
|
13
|
+
export { GlobalSearch } from "./GlobalSearch";
|
|
14
|
+
export { ThemeToggle } from "./ThemeToggle";
|
|
15
|
+
export { Footer } from "./Footer";
|
|
16
|
+
export { MobileSidebarToggle } from "./MobileSidebarToggle";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ThemeRegistration } from "shiki";
|
|
2
|
+
export declare const themeA: ThemeRegistration;
|
|
3
|
+
/** Languages theme A has been verified against, token by token. */
|
|
4
|
+
export declare const SUPPORTED_LANGS: readonly ["go", "typescript", "python", "bash", "hcl", "json"];
|
|
5
|
+
export type SupportedLang = (typeof SUPPORTED_LANGS)[number];
|
|
6
|
+
export declare function isSupportedLang(lang: string): lang is SupportedLang;
|
package/dist/theme-a.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Theme A as a real TextMate theme for Shiki.
|
|
2
|
+
//
|
|
3
|
+
// Every color is a CSS variable rather than a hex value, deliberately.
|
|
4
|
+
// Both consuming sites already define --color-code-green/red/yellow,
|
|
5
|
+
// --color-foreground, --color-foreground-muted and --color-code-bg in
|
|
6
|
+
// their own globals.css, and already swap them under [data-theme] and
|
|
7
|
+
// prefers-color-scheme. Emitting variables means light and dark keep
|
|
8
|
+
// working through the existing mechanism with zero client JS, and this
|
|
9
|
+
// package never has to know what the actual colors are.
|
|
10
|
+
//
|
|
11
|
+
// Theme A is deliberately coarse: green for keywords, types, numbers and
|
|
12
|
+
// language constants; red for string literals; yellow for property
|
|
13
|
+
// names; muted for comments; foreground for everything else. Mapping a
|
|
14
|
+
// coarse palette onto TextMate's fine-grained scopes is many-to-one,
|
|
15
|
+
// which is the easy direction.
|
|
16
|
+
//
|
|
17
|
+
// THE THREE OVERRIDES AT THE BOTTOM ARE THE POINT OF THIS FILE. They
|
|
18
|
+
// were findable only by inspecting real Shiki token output per language,
|
|
19
|
+
// not by reading scope documentation, and without them the naive mapping
|
|
20
|
+
// is visibly WORSE than the hand-rolled tokenizer it replaces. They are
|
|
21
|
+
// written out with their reasoning because the next person to touch this
|
|
22
|
+
// will not otherwise know why a plausible-looking simplification breaks
|
|
23
|
+
// the dominant language on the user docs site.
|
|
24
|
+
export const themeA = {
|
|
25
|
+
name: "ubx-theme-a",
|
|
26
|
+
// Irrelevant in practice: every color below is a variable, and the
|
|
27
|
+
// consuming site's own [data-theme] contract decides light or dark.
|
|
28
|
+
type: "dark",
|
|
29
|
+
colors: {
|
|
30
|
+
"editor.foreground": "var(--color-foreground)",
|
|
31
|
+
"editor.background": "var(--color-code-bg)",
|
|
32
|
+
},
|
|
33
|
+
tokenColors: [
|
|
34
|
+
{
|
|
35
|
+
scope: ["comment", "punctuation.definition.comment"],
|
|
36
|
+
settings: { foreground: "var(--color-foreground-muted)" },
|
|
37
|
+
},
|
|
38
|
+
// GREEN: keywords, types, numeric and language constants.
|
|
39
|
+
{
|
|
40
|
+
scope: [
|
|
41
|
+
"keyword",
|
|
42
|
+
"storage",
|
|
43
|
+
"storage.type",
|
|
44
|
+
"storage.modifier",
|
|
45
|
+
"constant.language",
|
|
46
|
+
"constant.numeric",
|
|
47
|
+
"support.type",
|
|
48
|
+
"entity.name.type",
|
|
49
|
+
"entity.name.class",
|
|
50
|
+
"support.class",
|
|
51
|
+
// The shell command itself, a deliberate docs-specific choice
|
|
52
|
+
// rather than a general one. In these docs the subject of nearly
|
|
53
|
+
// every fence is a ubx command, and the hand-rolled tokenizer
|
|
54
|
+
// this replaces had "ubx" in a hardcoded keyword set for exactly
|
|
55
|
+
// that reason. Shiki scopes it precisely as entity.name.command,
|
|
56
|
+
// so the intent survives the swap without hardcoding a binary
|
|
57
|
+
// name into a shared library.
|
|
58
|
+
"entity.name.function.call",
|
|
59
|
+
"entity.name.command",
|
|
60
|
+
],
|
|
61
|
+
settings: { foreground: "var(--color-code-green)" },
|
|
62
|
+
},
|
|
63
|
+
// RED: real string literals only. See override 1 below for what this
|
|
64
|
+
// must NOT catch.
|
|
65
|
+
{
|
|
66
|
+
scope: ["string", "string.quoted", "punctuation.definition.string"],
|
|
67
|
+
settings: { foreground: "var(--color-code-red)" },
|
|
68
|
+
},
|
|
69
|
+
// YELLOW: property and attribute names.
|
|
70
|
+
{
|
|
71
|
+
scope: [
|
|
72
|
+
"variable.other.member",
|
|
73
|
+
"meta.object-literal.key",
|
|
74
|
+
"support.type.property-name",
|
|
75
|
+
"meta.mapping.key",
|
|
76
|
+
// HCL attribute names. The hand-rolled tokenizer only recognised
|
|
77
|
+
// the `name:` form and so left HCL's `name =` plain. Shiki scopes
|
|
78
|
+
// it, so this is strictly closer to theme A's stated intent than
|
|
79
|
+
// what it replaces.
|
|
80
|
+
"variable.other.readwrite.hcl",
|
|
81
|
+
"variable.declaration.hcl",
|
|
82
|
+
// Go struct-literal field names. Verified against real token
|
|
83
|
+
// output: Go scopes `Owner:` inside a composite literal as
|
|
84
|
+
// variable.other.property, which is NOT covered by the generic
|
|
85
|
+
// object-literal scopes above. Without this, Go examples lose the
|
|
86
|
+
// yellow the hand-rolled tokenizer gave them, which showed up
|
|
87
|
+
// when the provider site's rendered code was diffed before and
|
|
88
|
+
// after the swap.
|
|
89
|
+
"variable.other.property",
|
|
90
|
+
],
|
|
91
|
+
settings: { foreground: "var(--color-code-yellow)" },
|
|
92
|
+
},
|
|
93
|
+
// ---- Overrides. More specific scopes win, so these run last. ----
|
|
94
|
+
// OVERRIDE 1, the important one. Shell grammars scope every bare
|
|
95
|
+
// command argument as string.unquoted, so the naive mapping paints an
|
|
96
|
+
// ENTIRE command line red. Measured on the user docs site: 92 of 116
|
|
97
|
+
// code fences are bash, so this would have been the single most
|
|
98
|
+
// visible thing on the site, and it is also a lie about what red
|
|
99
|
+
// means in theme A (a string literal). Confirmed against real token
|
|
100
|
+
// output: `ubx scan --propose both` came out as
|
|
101
|
+
// ubx=plain scan=RED --propose=RED both=RED before this override, and
|
|
102
|
+
// ubx=GREEN with the rest plain after it.
|
|
103
|
+
{
|
|
104
|
+
scope: ["string.unquoted", "constant.other.option"],
|
|
105
|
+
settings: { foreground: "var(--color-foreground)" },
|
|
106
|
+
},
|
|
107
|
+
// OVERRIDE 2. An assignment operator is not a keyword in theme A's
|
|
108
|
+
// sense. Without this, HCL's `=` renders green and reads as though it
|
|
109
|
+
// were a language keyword, which is exactly the emphasis theme A
|
|
110
|
+
// reserves for real keywords and types.
|
|
111
|
+
{
|
|
112
|
+
scope: ["keyword.operator"],
|
|
113
|
+
settings: { foreground: "var(--color-foreground)" },
|
|
114
|
+
},
|
|
115
|
+
// OVERRIDE 4. TypeScript scopes the arrow of an arrow function as
|
|
116
|
+
// storage.type.function.arrow, which the generic `storage.type` rule
|
|
117
|
+
// above catches and paints green. The hand-rolled tokenizer left it
|
|
118
|
+
// plain, and theme A reserves green for keywords and type names, not
|
|
119
|
+
// punctuation. Also caught by the before/after diff rather than by
|
|
120
|
+
// reading scopes.
|
|
121
|
+
{
|
|
122
|
+
scope: ["storage.type.function.arrow"],
|
|
123
|
+
settings: { foreground: "var(--color-foreground)" },
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
// OVERRIDE 3 is an omission rather than a rule, recorded here because it
|
|
128
|
+
// was a real mistake made and corrected during the mapping work: adding
|
|
129
|
+
// a blanket `punctuation` -> foreground override alongside the two above
|
|
130
|
+
// looks tidy and is wrong. It strips the color from the quote marks
|
|
131
|
+
// around JSON keys and string values, so `"provider"` renders with a
|
|
132
|
+
// yellow key between two plain quotes. Leave punctuation inheriting from
|
|
133
|
+
// its parent scope.
|
|
134
|
+
/** Languages theme A has been verified against, token by token. */
|
|
135
|
+
export const SUPPORTED_LANGS = [
|
|
136
|
+
"go",
|
|
137
|
+
"typescript",
|
|
138
|
+
"python",
|
|
139
|
+
"bash",
|
|
140
|
+
"hcl",
|
|
141
|
+
"json",
|
|
142
|
+
];
|
|
143
|
+
export function isSupportedLang(lang) {
|
|
144
|
+
return SUPPORTED_LANGS.includes(lang);
|
|
145
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ubx/docs-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared UI for ubx documentation sites: theme A, Shiki-backed CodeBlock, header, search, theme toggle.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": ["dist"],
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"next": ">=15",
|
|
20
|
+
"react": ">=19",
|
|
21
|
+
"react-dom": ">=19"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"shiki": "^4.4.3"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^20",
|
|
28
|
+
"@types/react": "^19",
|
|
29
|
+
"@types/react-dom": "^19",
|
|
30
|
+
"next": "16.3.4",
|
|
31
|
+
"react": "19.2.8",
|
|
32
|
+
"react-dom": "19.2.8",
|
|
33
|
+
"typescript": "^5"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": { "access": "public" },
|
|
36
|
+
"repository": { "type": "git", "url": "git+https://github.com/Ubiquex/ubx-docs-ui.git" }
|
|
37
|
+
}
|