@opentf/web-docs 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.
@@ -0,0 +1,180 @@
1
+ // Build-time navigation generator (a Rolldown plugin).
2
+ //
3
+ // The sidebar / breadcrumbs / prev-next need the whole documentation tree, which
4
+ // only exists on disk at build time. This plugin scans `app/<dir>` for routes,
5
+ // reads each folder's `_meta.{js,json}` for ordering + labels and each page's
6
+ // frontmatter for titles, and resolves `@opentf/web-docs/nav` to a generated module
7
+ // exporting the ordered tree. It is a pure build-time *consumer* of the file tree
8
+ // (ARCHITECTURE.md §6) — no new compiler stage, no runtime cost.
9
+ //
10
+ // Tree node shape: `{ title, path?, order?, items?: Node[] }`. A node with `path`
11
+ // is a link; a node with `items` is a (possibly also linked) group.
12
+
13
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+
17
+ import { readFrontmatter } from "./frontmatter.js";
18
+
19
+ const VIRTUAL_ID = "@opentf/web-docs/nav";
20
+ const RESOLVED_ID = "\0otfw-docs-nav";
21
+ const PAGE_RE = /^page\.(mdx|md|[jt]sx)$/;
22
+ const MD_RE = /\.(mdx|md)$/;
23
+
24
+ /**
25
+ * @param {Object} opts
26
+ * @param {string} opts.appDir Absolute path to the project's `app/` directory.
27
+ * @param {string} [opts.contentDir] Docs content folder under app/ (default "docs").
28
+ * @param {Set<string>} [opts.exclude] Folder names to skip (mirrors route exclusions).
29
+ */
30
+ export function docsNavPlugin({ appDir, contentDir = "docs", exclude = new Set() } = {}) {
31
+ // `"."`/`""` means the docs live at the app root (routes at "/"); otherwise they
32
+ // live in app/<contentDir> (routes under "/<contentDir>").
33
+ const atRoot = contentDir === "." || contentDir === "";
34
+ const root = atRoot ? appDir : join(appDir, contentDir);
35
+ const base = atRoot ? "" : "/" + contentDir;
36
+ return {
37
+ name: "otfw-docs-nav",
38
+ resolveId(source) {
39
+ if (source === VIRTUAL_ID) return RESOLVED_ID;
40
+ return null;
41
+ },
42
+ async load(id) {
43
+ if (id !== RESOLVED_ID) return null;
44
+ const watch = [];
45
+ const tree = existsSync(root) ? await buildSection(root, base, watch, true, exclude) : [];
46
+ // Rebuild on changes to meta/page files during `otfw dev`.
47
+ for (const f of watch) this.addWatchFile?.(f);
48
+ return `export default ${JSON.stringify(tree)};\n`;
49
+ },
50
+ };
51
+ }
52
+
53
+ /** Load a folder's `_meta` (ordering + labels). Supports `.js`/`.mjs`/`.json`. */
54
+ async function loadMeta(dir, watch) {
55
+ for (const name of ["_meta.js", "_meta.mjs", "_meta.json"]) {
56
+ const p = join(dir, name);
57
+ if (!existsSync(p)) continue;
58
+ watch.push(p);
59
+ try {
60
+ if (name.endsWith(".json")) return JSON.parse(readFileSync(p, "utf8"));
61
+ // Cache-bust so `otfw dev` picks up edits.
62
+ const mod = await import(pathToFileURL(p).href + `?t=${Date.now()}`);
63
+ return mod.default ?? mod;
64
+ } catch (e) {
65
+ console.warn(`⚠ [@opentf/web-docs] could not load ${p}: ${e?.message ?? e}`);
66
+ }
67
+ return null;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /** The `page.*` file directly in `dir`, or null. */
73
+ function pageFile(dir) {
74
+ for (const name of readdirSync(dir)) {
75
+ if (PAGE_RE.test(name)) return join(dir, name);
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function humanize(seg) {
81
+ return seg.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
82
+ }
83
+
84
+ /** Order child keys: those named in `_meta` first (in declared order), rest A→Z. */
85
+ function orderKeys(keys, meta) {
86
+ if (!meta) return keys.slice().sort();
87
+ const metaKeys = Array.isArray(meta) ? meta : Object.keys(meta);
88
+ const named = metaKeys.filter((k) => keys.includes(k));
89
+ const rest = keys.filter((k) => !metaKeys.includes(k)).sort();
90
+ return [...named, ...rest];
91
+ }
92
+
93
+ /** A label declared in `_meta` for `key`, or null. */
94
+ function metaLabel(meta, key) {
95
+ if (!meta || Array.isArray(meta)) return null;
96
+ const v = meta[key];
97
+ if (typeof v === "string") return v;
98
+ if (v && typeof v === "object") return v.title ?? v.label ?? null;
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Build the ordered items of a section directory. Each subdirectory becomes a child
104
+ * node (a link and/or a group). At the docs root (`withIndex`), the root's own
105
+ * `page.*` is emitted as a standalone "Overview"-style entry; in nested groups the
106
+ * folder's own page is the group node's link (added by `buildNode`), so it is not
107
+ * repeated here. Order + labels come from `_meta`.
108
+ */
109
+ async function buildSection(dir, route, watch, withIndex = false, exclude = new Set()) {
110
+ const meta = await loadMeta(dir, watch);
111
+ const entries = readdirSync(dir, { withFileTypes: true });
112
+ const subdirs = entries
113
+ .filter(
114
+ (e) =>
115
+ e.isDirectory() &&
116
+ !e.name.startsWith(".") &&
117
+ !e.name.startsWith("_") &&
118
+ !exclude.has(e.name),
119
+ )
120
+ .map((e) => e.name);
121
+
122
+ const items = [];
123
+
124
+ // The docs root's own landing page (e.g. app/docs/page.mdx → /docs).
125
+ const indexFile = withIndex ? pageFile(dir) : null;
126
+ const keys = subdirs.slice();
127
+ if (indexFile) keys.unshift("index");
128
+
129
+ let ordered = orderKeys(keys, meta);
130
+ // Default the landing page to the top unless `_meta` orders it.
131
+ const metaOrdersIndex = meta && !Array.isArray(meta) && "index" in meta;
132
+ if (indexFile && !metaOrdersIndex) {
133
+ ordered = ["index", ...ordered.filter((k) => k !== "index")];
134
+ }
135
+
136
+ for (const key of ordered) {
137
+ if (key === "index") {
138
+ watch.push(indexFile);
139
+ const fm = MD_RE.test(indexFile) ? readFrontmatter(indexFile) : {};
140
+ items.push(
141
+ clean({
142
+ title: metaLabel(meta, "index") ?? fm.sidebar_label ?? fm.title ?? "Overview",
143
+ path: route || "/",
144
+ order: fm.order,
145
+ }),
146
+ );
147
+ continue;
148
+ }
149
+ const node = await buildNode(join(dir, key), `${route}/${key}`, key, meta, watch, exclude);
150
+ if (node) items.push(node);
151
+ }
152
+ return items;
153
+ }
154
+
155
+ /** Build a single subdirectory node: its own link (if it has a page) + children. */
156
+ async function buildNode(dir, route, key, parentMeta, watch, exclude) {
157
+ const pf = pageFile(dir);
158
+ let fm = {};
159
+ if (pf) {
160
+ watch.push(pf);
161
+ if (MD_RE.test(pf)) fm = readFrontmatter(pf);
162
+ }
163
+ const children = await buildSection(dir, route, watch, false, exclude);
164
+ // A directory that has neither a page nor children contributes nothing.
165
+ if (!pf && children.length === 0) return null;
166
+
167
+ const title = metaLabel(parentMeta, key) ?? fm.sidebar_label ?? fm.title ?? humanize(key);
168
+ return clean({
169
+ title,
170
+ path: pf ? route : undefined,
171
+ order: fm.order,
172
+ items: children.length ? children : undefined,
173
+ });
174
+ }
175
+
176
+ /** Drop undefined fields so the emitted JSON stays small. */
177
+ function clean(node) {
178
+ for (const k of Object.keys(node)) if (node[k] === undefined) delete node[k];
179
+ return node;
180
+ }
@@ -0,0 +1,42 @@
1
+ // Minimal YAML frontmatter reader for the nav generator.
2
+ //
3
+ // Mirrors the flat-scalar parser in the Rust MDX front-end
4
+ // (crates/otfw_compiler/src/mdx.rs `frontmatter_object`): a leading `---` block of
5
+ // `key: value` lines, scalars only. Booleans/numbers are coerced; everything else is
6
+ // a string. Nested maps / lists are out of scope (a follow-up, same as the Rust side).
7
+
8
+ import { readFileSync } from "node:fs";
9
+
10
+ /** Parse the leading frontmatter block of an .mdx/.md file into a flat object. */
11
+ export function readFrontmatter(file) {
12
+ let source;
13
+ try {
14
+ source = readFileSync(file, "utf8");
15
+ } catch {
16
+ return {};
17
+ }
18
+ const m = source.match(/^?---\r?\n([\s\S]*?)\r?\n---/);
19
+ if (!m) return {};
20
+ const out = {};
21
+ for (const raw of m[1].split(/\r?\n/)) {
22
+ const line = raw.trim();
23
+ if (!line || line.startsWith("#")) continue;
24
+ const idx = line.indexOf(":");
25
+ if (idx < 0) continue;
26
+ const key = line.slice(0, idx).trim();
27
+ if (!key) continue;
28
+ let value = line.slice(idx + 1).trim();
29
+ // Strip a single matching pair of surrounding quotes.
30
+ if (
31
+ (value.startsWith('"') && value.endsWith('"')) ||
32
+ (value.startsWith("'") && value.endsWith("'"))
33
+ ) {
34
+ value = value.slice(1, -1);
35
+ }
36
+ if (value === "true") out[key] = true;
37
+ else if (value === "false") out[key] = false;
38
+ else if (value !== "" && !Number.isNaN(Number(value))) out[key] = Number(value);
39
+ else out[key] = value;
40
+ }
41
+ return out;
42
+ }
package/build/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // Build-time entry for the docs toolchain integration.
2
+ //
3
+ // Re-exports the Rolldown nav plugin (registered by @opentf/web-cli when a project
4
+ // has a `docs` config) and the Pagefind post-build hook (Phase 2). Importing this
5
+ // from web-cli keeps all docs-specific build logic owned by this package.
6
+
7
+ export { docsNavPlugin } from "./docs-nav-plugin.js";
@@ -0,0 +1,38 @@
1
+ // Breadcrumb trail for the current page, derived from the nav tree + router path.
2
+
3
+ import { Link, router } from "@opentf/web";
4
+
5
+ export default function Breadcrumbs(props) {
6
+ const nav = props.nav || [];
7
+
8
+ return (
9
+ <nav class="otfw-breadcrumbs" aria-label="Breadcrumb">
10
+ {() => {
11
+ const findTrail = (items, path, trail) => {
12
+ for (const it of items) {
13
+ const next = trail.concat(it);
14
+ if (it.path === path) return next;
15
+ if (it.items) {
16
+ const found = findTrail(it.items, path, next);
17
+ if (found) return found;
18
+ }
19
+ }
20
+ return null;
21
+ };
22
+ const trail = findTrail(nav, router.pathname, []) || [];
23
+ return trail.map((it, i) => (
24
+ <span class="otfw-crumb">
25
+ {i > 0 ? <span class="otfw-crumb-sep">/</span> : null}
26
+ {it.path ? (
27
+ <Link href={it.path} class="otfw-crumb-link">
28
+ {it.title}
29
+ </Link>
30
+ ) : (
31
+ <span class="otfw-crumb-text">{it.title}</span>
32
+ )}
33
+ </span>
34
+ ));
35
+ }}
36
+ </nav>
37
+ );
38
+ }
@@ -0,0 +1,29 @@
1
+ // Admonition / callout box. `type` is note | tip | info | warning | danger.
2
+ //
3
+ // <Callout type="warning" title="Heads up">…</Callout>
4
+
5
+ const ICONS = {
6
+ note: "✎",
7
+ tip: "💡",
8
+ info: "ℹ",
9
+ warning: "⚠",
10
+ danger: "⛔",
11
+ };
12
+
13
+ export default function Callout(props) {
14
+ const type = props.type || "note";
15
+ const title = props.title || type.charAt(0).toUpperCase() + type.slice(1);
16
+ const icon = ICONS[type] || ICONS.note;
17
+
18
+ return (
19
+ <div class={"otfw-callout otfw-callout-" + type}>
20
+ <div class="otfw-callout-title">
21
+ <span class="otfw-callout-icon" aria-hidden="true">
22
+ {icon}
23
+ </span>
24
+ {title}
25
+ </div>
26
+ <div class="otfw-callout-body">{props.children}</div>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,28 @@
1
+ // Grouped code blocks behind tabs (e.g. npm / pnpm / bun install commands).
2
+ // `items` is an array of `{ label, content }` where content is typically a fenced
3
+ // code block (already highlighted at build time by the MDX front-end).
4
+
5
+ export default function CodeGroup(props) {
6
+ let active = $state(0);
7
+ const items = props.items || [];
8
+
9
+ return (
10
+ <div class="otfw-codegroup">
11
+ <div class="otfw-codegroup-tabs" role="tablist">
12
+ {items.map((it, i) => (
13
+ <button
14
+ class={active === i ? "otfw-codegroup-tab otfw-active" : "otfw-codegroup-tab"}
15
+ onclick={() => (active = i)}
16
+ >
17
+ {it.label}
18
+ </button>
19
+ ))}
20
+ </div>
21
+ {items.map((it, i) => (
22
+ <div class={active === i ? "otfw-codegroup-panel" : "otfw-codegroup-panel otfw-hidden"}>
23
+ {it.content}
24
+ </div>
25
+ ))}
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,46 @@
1
+ // Top-level documentation frame: navbar + (sidebar · content · TOC) + footer.
2
+ //
3
+ // import config from "../../otfw.config.js";
4
+ // import nav from "@opentf/web-docs/nav";
5
+ // export default function (props) {
6
+ // return <DocsLayout config={config.docs} nav={nav}>{props.children}</DocsLayout>;
7
+ // }
8
+ //
9
+ // `frame` (default true) renders the full chrome (navbar + footer). Pass
10
+ // `frame={false}` when nesting inside an existing site layout that already provides
11
+ // the navbar/footer — only the sidebar · content · TOC grid is rendered.
12
+
13
+ import Navbar from "./Navbar.jsx";
14
+ import Sidebar from "./Sidebar.jsx";
15
+ import Toc from "./Toc.jsx";
16
+ import Footer from "./Footer.jsx";
17
+ import Breadcrumbs from "./Breadcrumbs.jsx";
18
+ import Pagination from "./Pagination.jsx";
19
+
20
+ export default function DocsLayout(props) {
21
+ const config = props.config || {};
22
+ const nav = props.nav || [];
23
+ const frame = props.frame !== false;
24
+
25
+ const body = (
26
+ <div class="otfw-docs">
27
+ <Sidebar nav={nav} config={config} />
28
+ <main id="otfw-content" class="otfw-content" data-pagefind-body>
29
+ <Breadcrumbs nav={nav} />
30
+ <article class="otfw-prose">{props.children}</article>
31
+ <Pagination nav={nav} />
32
+ </main>
33
+ <Toc />
34
+ </div>
35
+ );
36
+
37
+ return frame ? (
38
+ <div class="otfw-shell">
39
+ <Navbar config={config} />
40
+ <div class="otfw-shell-body">{body}</div>
41
+ <Footer config={config} />
42
+ </div>
43
+ ) : (
44
+ body
45
+ );
46
+ }
@@ -0,0 +1,25 @@
1
+ // Site footer, driven by `config.footer` ({ text, links }).
2
+
3
+ export default function Footer(props) {
4
+ const config = props.config || {};
5
+ const footer = config.footer || {};
6
+ const text = footer.text || "Built with OTF Web";
7
+ const links = footer.links || [];
8
+
9
+ return (
10
+ <footer class="otfw-footer">
11
+ <div class="otfw-footer-inner">
12
+ <div class="otfw-footer-text">{text}</div>
13
+ {links.length > 0 ? (
14
+ <div class="otfw-footer-links">
15
+ {links.map((l) => (
16
+ <a href={l.href} class="otfw-footer-link">
17
+ {l.label}
18
+ </a>
19
+ ))}
20
+ </div>
21
+ ) : null}
22
+ </div>
23
+ </footer>
24
+ );
25
+ }
@@ -0,0 +1,42 @@
1
+ // Top navigation bar: brand, top-level links, search, GitHub, theme toggle. All
2
+ // content is driven by the `docs` config object passed down from DocsLayout.
3
+
4
+ import { Link } from "@opentf/web";
5
+ import ThemeToggle from "./ThemeToggle.jsx";
6
+ import SearchTrigger from "./SearchTrigger.jsx";
7
+
8
+ export default function Navbar(props) {
9
+ const config = props.config || {};
10
+ const links = config.nav || [];
11
+
12
+ return (
13
+ <header class="otfw-navbar">
14
+ <div class="otfw-navbar-inner">
15
+ <Link href={config.homeUrl || "/"} class="otfw-navbar-brand">
16
+ {config.logo ? <img src={config.logo} alt="" class="otfw-navbar-logo" /> : null}
17
+ <span class="otfw-navbar-title">{config.title || "Docs"}</span>
18
+ </Link>
19
+
20
+ <nav class="otfw-navbar-nav">
21
+ {links.map((l) => (
22
+ <Link href={l.href} class="otfw-navbar-link">
23
+ {l.label}
24
+ </Link>
25
+ ))}
26
+ </nav>
27
+
28
+ <div class="otfw-navbar-actions">
29
+ <SearchTrigger />
30
+ {config.github ? (
31
+ <a href={config.github} target="_blank" rel="noreferrer" class="otfw-navbar-icon" aria-label="GitHub">
32
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
33
+ <path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.5v-1.7c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 016 0C17 4.7 18 5 18 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.6.8.5a11.5 11.5 0 007.9-10.9C23.5 5.7 18.3.5 12 .5z" />
34
+ </svg>
35
+ </a>
36
+ ) : null}
37
+ <ThemeToggle />
38
+ </div>
39
+ </div>
40
+ </header>
41
+ );
42
+ }
@@ -0,0 +1,45 @@
1
+ // Prev / next page links, derived from a depth-first flatten of the nav tree.
2
+
3
+ import { Link, router } from "@opentf/web";
4
+
5
+ export default function Pagination(props) {
6
+ const nav = props.nav || [];
7
+
8
+ return (
9
+ <nav class="otfw-pagination" aria-label="Pagination">
10
+ {() => {
11
+ const flat = [];
12
+ const walk = (items) => {
13
+ for (const it of items) {
14
+ if (it.path) flat.push(it);
15
+ if (it.items) walk(it.items);
16
+ }
17
+ };
18
+ walk(nav);
19
+ const i = flat.findIndex((x) => x.path === router.pathname);
20
+ const prev = i > 0 ? flat[i - 1] : null;
21
+ const next = i >= 0 && i < flat.length - 1 ? flat[i + 1] : null;
22
+ return (
23
+ <>
24
+ {prev ? (
25
+ <Link href={prev.path} class="otfw-page-link otfw-page-prev">
26
+ <span class="otfw-page-dir">← Previous</span>
27
+ <span class="otfw-page-title">{prev.title}</span>
28
+ </Link>
29
+ ) : (
30
+ <span class="otfw-page-spacer" />
31
+ )}
32
+ {next ? (
33
+ <Link href={next.path} class="otfw-page-link otfw-page-next">
34
+ <span class="otfw-page-dir">Next →</span>
35
+ <span class="otfw-page-title">{next.title}</span>
36
+ </Link>
37
+ ) : (
38
+ <span class="otfw-page-spacer" />
39
+ )}
40
+ </>
41
+ );
42
+ }}
43
+ </nav>
44
+ );
45
+ }
@@ -0,0 +1,21 @@
1
+ // Search box trigger in the navbar (⌘K). Inert in Phase 1 — it calls a global
2
+ // opener that the Phase 2 Pagefind-backed <Search> modal installs on `window`.
3
+
4
+ export default function SearchTrigger() {
5
+ const open = () => {
6
+ if (typeof window !== "undefined" && typeof window.__otfwOpenSearch === "function") {
7
+ window.__otfwOpenSearch();
8
+ }
9
+ };
10
+
11
+ return (
12
+ <button class="otfw-search-trigger" onclick={open} aria-label="Search docs">
13
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
14
+ <circle cx="11" cy="11" r="7" />
15
+ <line x1="21" y1="21" x2="16.65" y2="16.65" stroke-linecap="round" />
16
+ </svg>
17
+ <span class="otfw-search-label">Search</span>
18
+ <kbd class="otfw-search-kbd">⌘K</kbd>
19
+ </button>
20
+ );
21
+ }
@@ -0,0 +1,21 @@
1
+ // Documentation sidebar — renders the build-time nav tree (from
2
+ // `@opentf/web-docs/nav`). Static content (crawlable with JS off); the active link
3
+ // updates reactively in SidebarNode.
4
+
5
+ import SidebarNode from "./SidebarNode.jsx";
6
+
7
+ export default function Sidebar(props) {
8
+ const nav = props.nav || [];
9
+
10
+ return (
11
+ <aside class="otfw-sidebar">
12
+ <nav class="otfw-sidebar-nav" aria-label="Documentation">
13
+ <ul class="otfw-sidebar-list">
14
+ {nav.map((item) => (
15
+ <SidebarNode item={item} />
16
+ ))}
17
+ </ul>
18
+ </nav>
19
+ </aside>
20
+ );
21
+ }
@@ -0,0 +1,31 @@
1
+ // One node of the sidebar tree — a link, a group title, or both. Recurses into
2
+ // `item.items`. The active link is derived from `router.pathname` (reactive).
3
+
4
+ import { Link, router } from "@opentf/web";
5
+
6
+ export default function SidebarNode(props) {
7
+ const item = props.item || {};
8
+ const hasChildren = item.items && item.items.length > 0;
9
+
10
+ return (
11
+ <li class="otfw-sidebar-node">
12
+ {item.path ? (
13
+ <Link
14
+ href={item.path}
15
+ class={router.pathname === item.path ? "otfw-sidebar-link otfw-active" : "otfw-sidebar-link"}
16
+ >
17
+ {item.title}
18
+ </Link>
19
+ ) : (
20
+ <span class="otfw-sidebar-group-title">{item.title}</span>
21
+ )}
22
+ {hasChildren ? (
23
+ <ul class="otfw-sidebar-sublist">
24
+ {item.items.map((child) => (
25
+ <SidebarNode item={child} />
26
+ ))}
27
+ </ul>
28
+ ) : null}
29
+ </li>
30
+ );
31
+ }
@@ -0,0 +1,11 @@
1
+ // Styled, horizontally-scrollable table wrapper. (MDX GFM tables already emit a
2
+ // bare <table>, styled by the theme; use <Table> in JSX pages or to force the
3
+ // responsive scroll container.)
4
+
5
+ export default function Table(props) {
6
+ return (
7
+ <div class="otfw-table-wrap">
8
+ <table class="otfw-table">{props.children}</table>
9
+ </div>
10
+ );
11
+ }
@@ -0,0 +1,30 @@
1
+ // Generic tabbed panel. `tabs` is an array of `{ label, content }`.
2
+ //
3
+ // <Tabs tabs={[{ label: "npm", content: <…/> }, …]} />
4
+
5
+ export default function Tabs(props) {
6
+ let active = $state(0);
7
+ const tabs = props.tabs || [];
8
+
9
+ return (
10
+ <div class="otfw-tabs">
11
+ <div class="otfw-tabs-list" role="tablist">
12
+ {tabs.map((tab, i) => (
13
+ <button
14
+ class={active === i ? "otfw-tab otfw-active" : "otfw-tab"}
15
+ onclick={() => (active = i)}
16
+ >
17
+ {tab.label}
18
+ </button>
19
+ ))}
20
+ </div>
21
+ <div class="otfw-tabs-panels">
22
+ {tabs.map((tab, i) => (
23
+ <div class={active === i ? "otfw-tab-panel" : "otfw-tab-panel otfw-hidden"}>
24
+ {tab.content}
25
+ </div>
26
+ ))}
27
+ </div>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,39 @@
1
+ // Light/dark toggle. Sets `data-theme` on <html>, persists to localStorage, and
2
+ // respects the OS preference on first load. Pair with the no-flash inline script in
3
+ // the docs shell index.html so the initial paint already has the right theme.
4
+
5
+ import { onMount } from "@opentf/web";
6
+
7
+ export default function ThemeToggle() {
8
+ let theme = $state("light");
9
+
10
+ onMount(() => {
11
+ const saved =
12
+ localStorage.getItem("theme") ||
13
+ (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
14
+ theme = saved;
15
+ document.documentElement.setAttribute("data-theme", theme);
16
+ });
17
+
18
+ const toggle = () => {
19
+ theme = theme === "light" ? "dark" : "light";
20
+ document.documentElement.setAttribute("data-theme", theme);
21
+ localStorage.setItem("theme", theme);
22
+ };
23
+
24
+ return (
25
+ <button class="otfw-theme-toggle" onclick={toggle} aria-label="Toggle color theme">
26
+ {() =>
27
+ theme === "light" ? (
28
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
29
+ <path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
30
+ </svg>
31
+ ) : (
32
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
33
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M12 5a7 7 0 100 14 7 7 0 000-14z" />
34
+ </svg>
35
+ )
36
+ }
37
+ </button>
38
+ );
39
+ }