@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 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,4 @@
1
+ export declare function CodeBlock({ code, lang }: {
2
+ code: string;
3
+ lang: string;
4
+ }): Promise<import("react").JSX.Element>;
@@ -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
+ }
@@ -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
+ }
@@ -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,3 @@
1
+ export declare function MobileSidebarToggle({ children }: {
2
+ children: React.ReactNode;
3
+ }): import("react").JSX.Element;
@@ -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
+ }
@@ -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;
@@ -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
+ }