@opentf/web-docs 0.1.0 → 0.3.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.
Files changed (44) hide show
  1. package/build/blog-posts-plugin.js +127 -0
  2. package/build/docs-nav-plugin.js +17 -10
  3. package/build/feed.js +136 -0
  4. package/build/index.js +5 -0
  5. package/build/last-updated-plugin.js +98 -0
  6. package/build/last-updated.js +45 -0
  7. package/build/llms.js +156 -0
  8. package/build/pagefind.js +58 -0
  9. package/build/reading-time.js +16 -0
  10. package/components/BlogLayout.jsx +77 -0
  11. package/components/Breadcrumbs.jsx +30 -27
  12. package/components/Callout.jsx +7 -7
  13. package/components/Card.jsx +21 -0
  14. package/components/Cards.jsx +11 -0
  15. package/components/CodeBlock.jsx +39 -0
  16. package/components/DocsLayout.jsx +66 -6
  17. package/components/LastUpdated.jsx +22 -0
  18. package/components/NavIcon.jsx +34 -0
  19. package/components/Navbar.jsx +34 -22
  20. package/components/NavbarLink.jsx +33 -0
  21. package/components/Pagination.jsx +37 -33
  22. package/components/PostBanner.jsx +25 -0
  23. package/components/PostCard.jsx +19 -0
  24. package/components/PostList.jsx +17 -0
  25. package/components/PostMeta.jsx +28 -0
  26. package/components/ReadingTime.jsx +6 -0
  27. package/components/Search.jsx +211 -0
  28. package/components/SearchTrigger.jsx +14 -3
  29. package/components/Sidebar.jsx +111 -9
  30. package/components/SidebarNode.jsx +1 -0
  31. package/components/SidebarToggle.jsx +47 -0
  32. package/components/Steps.jsx +14 -0
  33. package/components/Tabs.jsx +4 -2
  34. package/components/ThemeToggle.jsx +150 -25
  35. package/components/Tooltip.jsx +19 -0
  36. package/components/format.js +11 -0
  37. package/config.js +28 -2
  38. package/index.js +29 -2
  39. package/nav-virtual.js +6 -4
  40. package/package.json +15 -1
  41. package/posts-virtual.js +17 -0
  42. package/theme/index.css +1034 -58
  43. package/updated-virtual.js +10 -0
  44. package/components/CodeGroup.jsx +0 -28
@@ -0,0 +1,19 @@
1
+ // One post in the blog index: a link to the post with its meta + title + excerpt.
2
+ import { Link } from "@opentf/web";
3
+ import PostMeta from "./PostMeta.jsx";
4
+
5
+ export default function PostCard(props) {
6
+ const post = props.post;
7
+ return (
8
+ <Link href={post.path} class={post.cover ? "otfw-post-card otfw-has-cover" : "otfw-post-card"}>
9
+ {post.cover ? (
10
+ <img class="otfw-post-card-cover" src={post.cover} alt={post.title} loading="lazy" />
11
+ ) : null}
12
+ <div class="otfw-post-card-body">
13
+ <PostMeta post={post} />
14
+ <div class="otfw-post-card-title">{post.title}</div>
15
+ {post.description ? <p class="otfw-post-card-desc">{post.description}</p> : null}
16
+ </div>
17
+ </Link>
18
+ );
19
+ }
@@ -0,0 +1,17 @@
1
+ // The blog index listing: renders a PostCard per post. Pass the list from
2
+ // `@opentf/web-docs/posts`:
3
+ //
4
+ // import { posts } from "@opentf/web-docs/posts";
5
+ // <PostList posts={posts} />
6
+ import PostCard from "./PostCard.jsx";
7
+
8
+ export default function PostList(props) {
9
+ const posts = props.posts || [];
10
+ return (
11
+ <div class="otfw-post-list">
12
+ {posts.length
13
+ ? posts.map((post) => <PostCard post={post} />)
14
+ : <p class="otfw-post-empty">No posts yet.</p>}
15
+ </div>
16
+ );
17
+ }
@@ -0,0 +1,28 @@
1
+ // A post's metadata row: date · author · reading time. Pass a post record (from
2
+ // `@opentf/web-docs/posts`) as `post`, or the fields directly. Missing fields are
3
+ // omitted. Used in both the post banner and the index cards.
4
+ import ReadingTime from "./ReadingTime.jsx";
5
+ import { formatDate } from "./format.js";
6
+
7
+ export default function PostMeta(props) {
8
+ const post = props.post || props;
9
+ return (
10
+ <div class="otfw-post-meta">
11
+ {post.author ? (
12
+ <span class="otfw-post-author">
13
+ {post.authorAvatar ? (
14
+ <img class="otfw-post-avatar" src={post.authorAvatar} alt={post.author} loading="lazy" />
15
+ ) : null}
16
+ <span class="otfw-post-author-name">{post.author}</span>
17
+ {post.authorRole ? <span class="otfw-post-author-role">{post.authorRole}</span> : null}
18
+ </span>
19
+ ) : null}
20
+ {post.date ? (
21
+ <time class="otfw-post-date" datetime={String(post.date)}>
22
+ {formatDate(post.date)}
23
+ </time>
24
+ ) : null}
25
+ {post.readingTime ? <ReadingTime minutes={post.readingTime} /> : null}
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,6 @@
1
+ // Reading-time label, e.g. "4 min read". `minutes` is precomputed at build time by
2
+ // the blog posts plugin (see build/reading-time.js).
3
+ export default function ReadingTime(props) {
4
+ const minutes = props.minutes ?? 1;
5
+ return <span class="otfw-reading-time">{minutes} min read</span>;
6
+ }
@@ -0,0 +1,211 @@
1
+ // Pagefind-backed search modal (Phase 2). The navbar's `SearchTrigger` and the global
2
+ // ⌘K / Ctrl+K shortcut open it via `window.__otfwOpenSearch`, which this component
3
+ // installs on mount. The static index is generated at build time (`otfw build --ssg`
4
+ // with `docs.search.provider === "pagefind"`) and lives at `/pagefind/`; we load its
5
+ // runtime on first open so pages without a search never pay for it.
6
+ //
7
+ // Note: results only exist against a built site (`dist/`). In dev there is no index,
8
+ // so the modal opens but reports no results — expected.
9
+ import { onMount, router, RawHtml, Portal } from "@opentf/web";
10
+
11
+ let pagefindPromise = null;
12
+ function loadPagefind() {
13
+ if (!pagefindPromise) {
14
+ // A non-literal specifier keeps the bundler from trying to resolve the index at
15
+ // build time (it doesn't exist yet) — it stays a runtime import of the static file.
16
+ const path = "/pagefind/pagefind.js";
17
+ pagefindPromise = import(/* @vite-ignore */ path).then(async (pf) => {
18
+ await pf.init?.();
19
+ return pf;
20
+ });
21
+ }
22
+ return pagefindPromise;
23
+ }
24
+
25
+ export default function Search() {
26
+ let open = $state(false);
27
+ let query = $state("");
28
+ let results = $state([]);
29
+ let active = $state(0);
30
+ let loading = $state(false);
31
+ const inputRef = $ref();
32
+
33
+ let timer;
34
+ let token = 0;
35
+
36
+ async function runSearch(q) {
37
+ const mine = ++token;
38
+ if (!q.trim()) {
39
+ results = [];
40
+ loading = false;
41
+ return;
42
+ }
43
+ loading = true;
44
+ try {
45
+ const pf = await loadPagefind();
46
+ const search = await pf.search(q);
47
+ const top = (search.results || []).slice(0, 6);
48
+ const data = await Promise.all(top.map((r) => r.data()));
49
+ if (mine !== token) return; // a newer query superseded this one
50
+ // Flatten to heading-anchored sub-results so each row deep-links to the matched
51
+ // section (`/page/#heading`) rather than the page top. Pagefind builds these from
52
+ // the body's headings; a page with none falls back to a single page-level row.
53
+ const flat = [];
54
+ for (const d of data) {
55
+ const crumb = d.meta && d.meta.breadcrumb ? d.meta.breadcrumb.replace(/\s*\/\s*/g, " › ") : "";
56
+ // The page/post title — always shown as the result heading so you can tell which
57
+ // page (or blog post) a hit belongs to, even across same-named sections.
58
+ const pageTitle = (d.meta && d.meta.title) || d.url;
59
+ const subs =
60
+ d.sub_results && d.sub_results.length
61
+ ? d.sub_results
62
+ : [{ title: pageTitle, url: d.url, excerpt: d.excerpt }];
63
+ for (const s of subs.slice(0, 4)) {
64
+ flat.push({
65
+ url: s.url,
66
+ title: pageTitle,
67
+ // The matched section heading, when it differs from the page title.
68
+ section: s.title && s.title !== pageTitle ? s.title : "",
69
+ crumb,
70
+ excerpt: s.excerpt,
71
+ });
72
+ if (flat.length >= 10) break;
73
+ }
74
+ if (flat.length >= 10) break;
75
+ }
76
+ results = flat;
77
+ active = 0;
78
+ } catch (e) {
79
+ if (mine === token) results = [];
80
+ } finally {
81
+ if (mine === token) loading = false;
82
+ }
83
+ }
84
+
85
+ $effect(() => {
86
+ const q = query;
87
+ clearTimeout(timer);
88
+ timer = setTimeout(() => runSearch(q), 150);
89
+ });
90
+
91
+ const focusInput = () => {
92
+ const el = inputRef;
93
+ if (el && typeof requestAnimationFrame !== "undefined") requestAnimationFrame(() => el.focus());
94
+ };
95
+ const openModal = () => {
96
+ open = true;
97
+ focusInput();
98
+ };
99
+ const close = () => {
100
+ open = false;
101
+ query = "";
102
+ results = [];
103
+ active = 0;
104
+ };
105
+ const go = (url) => {
106
+ close();
107
+ router.push(url);
108
+ };
109
+
110
+ onMount(() => {
111
+ if (typeof window === "undefined") return;
112
+ window.__otfwOpenSearch = openModal;
113
+ const onKey = (e) => {
114
+ const k = e.key;
115
+ if ((e.metaKey || e.ctrlKey) && k.toLowerCase() === "k") {
116
+ e.preventDefault();
117
+ openModal();
118
+ return;
119
+ }
120
+ if (!open) return;
121
+ if (k === "Escape") close();
122
+ else if (k === "ArrowDown") {
123
+ e.preventDefault();
124
+ active = results.length ? (active + 1) % results.length : 0;
125
+ } else if (k === "ArrowUp") {
126
+ e.preventDefault();
127
+ active = results.length ? (active - 1 + results.length) % results.length : 0;
128
+ } else if (k === "Enter" && results[active]) {
129
+ e.preventDefault();
130
+ go(results[active].url);
131
+ }
132
+ };
133
+ window.addEventListener("keydown", onKey);
134
+ return () => {
135
+ window.removeEventListener("keydown", onKey);
136
+ if (window.__otfwOpenSearch === openModal) delete window.__otfwOpenSearch;
137
+ };
138
+ });
139
+
140
+ // Portal to <body>: the navbar (our render parent) sets `backdrop-filter`, which
141
+ // establishes a containing block for fixed-position descendants — so without this the
142
+ // `position: fixed` overlay would collapse to the navbar's box instead of the viewport.
143
+ return (
144
+ <Portal>
145
+ <div
146
+ class={open ? "otfw-search-modal is-open" : "otfw-search-modal"}
147
+ onclick={(e) => {
148
+ if (e.target === e.currentTarget) close();
149
+ }}
150
+ >
151
+ <div class="otfw-search-panel" role="dialog" aria-label="Search docs">
152
+ <div class="otfw-search-field">
153
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
154
+ <circle cx="11" cy="11" r="7" />
155
+ <line x1="21" y1="21" x2="16.65" y2="16.65" stroke-linecap="round" />
156
+ </svg>
157
+ <input
158
+ ref={inputRef}
159
+ class="otfw-search-input-field"
160
+ type="search"
161
+ placeholder="Search docs…"
162
+ aria-label="Search docs"
163
+ value={query}
164
+ oninput={(e) => (query = e.target.value)}
165
+ />
166
+ </div>
167
+ <ul class="otfw-search-results">
168
+ {results.map((r, i) => (
169
+ <li>
170
+ <a
171
+ href={r.url}
172
+ class={i === active ? "otfw-search-result is-active" : "otfw-search-result"}
173
+ onclick={(e) => {
174
+ e.preventDefault();
175
+ go(r.url);
176
+ }}
177
+ onmouseenter={() => (active = i)}
178
+ >
179
+ {r.crumb ? <span class="otfw-search-result-crumb">{r.crumb}</span> : null}
180
+ <span class="otfw-search-result-title">{r.title}</span>
181
+ {r.section ? <span class="otfw-search-result-section">{r.section}</span> : null}
182
+ <span class="otfw-search-result-excerpt">
183
+ <RawHtml html={r.excerpt} />
184
+ </span>
185
+ </a>
186
+ </li>
187
+ ))}
188
+ </ul>
189
+ {query.trim() && !loading && results.length === 0 ? (
190
+ <div class="otfw-search-empty">No results for “{query}”.</div>
191
+ ) : null}
192
+ <div class="otfw-search-hint">
193
+ <span class="otfw-search-hint-item">
194
+ <kbd>↑</kbd>
195
+ <kbd>↓</kbd>
196
+ navigate
197
+ </span>
198
+ <span class="otfw-search-hint-item">
199
+ <kbd>↵</kbd>
200
+ select
201
+ </span>
202
+ <span class="otfw-search-hint-item">
203
+ <kbd>esc</kbd>
204
+ close
205
+ </span>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </Portal>
210
+ );
211
+ }
@@ -1,7 +1,18 @@
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`.
1
+ // Search box trigger in the navbar. Calls the global opener that the Pagefind-backed
2
+ // <Search> modal installs on `window` (the ⌘K / Ctrl+K shortcut opens it too). Shows
3
+ // the shortcut as a hint; the modifier resolves to ⌘ on Apple platforms, Ctrl
4
+ // elsewhere, on mount.
5
+
6
+ import { onMount } from "@opentf/web";
3
7
 
4
8
  export default function SearchTrigger() {
9
+ let mod = $state("Ctrl");
10
+
11
+ onMount(() => {
12
+ const ua = navigator.platform || navigator.userAgent || "";
13
+ if (/Mac|iPhone|iPad|iPod/.test(ua)) mod = "⌘";
14
+ });
15
+
5
16
  const open = () => {
6
17
  if (typeof window !== "undefined" && typeof window.__otfwOpenSearch === "function") {
7
18
  window.__otfwOpenSearch();
@@ -15,7 +26,7 @@ export default function SearchTrigger() {
15
26
  <line x1="21" y1="21" x2="16.65" y2="16.65" stroke-linecap="round" />
16
27
  </svg>
17
28
  <span class="otfw-search-label">Search</span>
18
- <kbd class="otfw-search-kbd">⌘K</kbd>
29
+ <kbd class="otfw-search-kbd">{() => `${mod} K`}</kbd>
19
30
  </button>
20
31
  );
21
32
  }
@@ -1,21 +1,123 @@
1
1
  // Documentation sidebar — renders the build-time nav tree (from
2
2
  // `@opentf/web-docs/nav`). Static content (crawlable with JS off); the active link
3
3
  // updates reactively in SidebarNode.
4
+ //
5
+ // On desktop it is the sticky left column of the docs grid. On mobile (< 768px) the
6
+ // same element becomes an off-canvas drawer: hidden off-screen, slid in over a
7
+ // backdrop. The drawer is opened by the navbar hamburger, which lives in a different
8
+ // component (and, on the website, a different layout), so the two are coupled the same
9
+ // way Search/SearchTrigger are — through a global toggle installed here:
10
+ //
11
+ // • `window.__otfwToggleSidebar()` / `__otfwCloseSidebar()` — called by the burger.
12
+ // • `document.documentElement[data-otfw-has-sidebar]` — set while a Sidebar is
13
+ // mounted, so the burger shows only on pages that actually have one.
14
+ // • a `otfw:sidebar` CustomEvent (`{ open }`) — lets the burger mirror `aria-expanded`.
15
+ //
16
+ // The drawer closes on navigation, Escape, backdrop click, or a viewport resize up to
17
+ // the desktop breakpoint, and locks body scroll while open.
18
+
19
+ import { onMount, router } from "@opentf/web";
4
20
 
5
21
  import SidebarNode from "./SidebarNode.jsx";
22
+ import NavbarLink from "./NavbarLink.jsx";
23
+
24
+ const DESKTOP_QUERY = "(min-width: 768px)";
25
+
26
+ // How many Sidebars are mounted right now. On SPA navigation a new section's Sidebar can
27
+ // mount *before* the old one unmounts, so the burger's `data-otfw-has-sidebar` flag must
28
+ // be reference-counted — otherwise the departing instance's cleanup would clear the flag
29
+ // the arriving instance just set, and the burger would vanish after navigating.
30
+ let liveSidebars = 0;
6
31
 
7
32
  export default function Sidebar(props) {
8
33
  const nav = props.nav || [];
34
+ // Top-level site links (config.nav). On desktop they live in the navbar; on mobile the
35
+ // navbar hides them and the drawer surfaces them above the section tree, so the whole
36
+ // nav is reachable from one place.
37
+ const navLinks = (props.config && props.config.nav) || [];
38
+ let open = $state(false);
39
+
40
+ const close = () => (open = false);
41
+ // Stable per-instance toggle so cleanup can tell whether the global still points at us.
42
+ const toggle = () => (open = !open);
43
+
44
+ onMount(() => {
45
+ const root = document.documentElement;
46
+ liveSidebars += 1;
47
+ root.setAttribute("data-otfw-has-sidebar", "");
48
+
49
+ // The burger (navbar) drives the drawer through these globals — same decoupling as
50
+ // the Search modal's `__otfwOpenSearch`. The latest-mounted Sidebar owns them.
51
+ window.__otfwToggleSidebar = toggle;
52
+ window.__otfwCloseSidebar = close;
53
+
54
+ const onKey = (e) => e.key === "Escape" && close();
55
+ window.addEventListener("keydown", onKey);
56
+
57
+ // If the viewport grows to desktop while the drawer is open, drop the drawer state
58
+ // (CSS turns the element back into the sticky column; this releases the scroll lock
59
+ // and resyncs the burger).
60
+ const mql = window.matchMedia(DESKTOP_QUERY);
61
+ const onDesktop = (e) => e.matches && close();
62
+ mql.addEventListener?.("change", onDesktop);
63
+
64
+ return () => {
65
+ window.removeEventListener("keydown", onKey);
66
+ mql.removeEventListener?.("change", onDesktop);
67
+ document.body.style.removeProperty("overflow");
68
+ root.removeAttribute("data-otfw-sidebar-open");
69
+ // Only drop the flag/globals if we're the last Sidebar and still the owner — an
70
+ // arriving instance may already have taken over during navigation.
71
+ liveSidebars -= 1;
72
+ if (liveSidebars <= 0) {
73
+ liveSidebars = 0;
74
+ root.removeAttribute("data-otfw-has-sidebar");
75
+ }
76
+ if (window.__otfwToggleSidebar === toggle) delete window.__otfwToggleSidebar;
77
+ if (window.__otfwCloseSidebar === close) delete window.__otfwCloseSidebar;
78
+ };
79
+ });
80
+
81
+ // Reflect open state: lock body scroll, mark the root (for styling), and notify the
82
+ // burger so it can mirror `aria-expanded`. Reading `open` (not writing it) keeps this
83
+ // effect free of the route/resize effects.
84
+ $effect(() => {
85
+ const isOpen = open;
86
+ if (typeof document === "undefined") return;
87
+ document.documentElement.toggleAttribute("data-otfw-sidebar-open", isOpen);
88
+ document.body.style.overflow = isOpen ? "hidden" : "";
89
+ window.dispatchEvent(new CustomEvent("otfw:sidebar", { detail: { open: isOpen } }));
90
+ });
91
+
92
+ // Close on navigation — clicking a drawer link should dismiss it.
93
+ $effect(() => {
94
+ router.pathname; // subscribe
95
+ close();
96
+ });
9
97
 
10
98
  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>
99
+ <div class="otfw-sidebar-shell">
100
+ <div
101
+ class={open ? "otfw-sidebar-backdrop is-open" : "otfw-sidebar-backdrop"}
102
+ onclick={close}
103
+ aria-hidden="true"
104
+ ></div>
105
+ <aside id="otfw-sidebar" class={open ? "otfw-sidebar is-open" : "otfw-sidebar"}>
106
+ {navLinks.length ? (
107
+ <nav class="otfw-drawer-links" aria-label="Site">
108
+ {navLinks.map((link) => (
109
+ <NavbarLink link={link} />
110
+ ))}
111
+ </nav>
112
+ ) : null}
113
+ <nav class="otfw-sidebar-nav" aria-label="Documentation">
114
+ <ul class="otfw-sidebar-list">
115
+ {nav.map((item) => (
116
+ <SidebarNode item={item} />
117
+ ))}
118
+ </ul>
119
+ </nav>
120
+ </aside>
121
+ </div>
20
122
  );
21
123
  }
@@ -14,6 +14,7 @@ export default function SidebarNode(props) {
14
14
  href={item.path}
15
15
  class={router.pathname === item.path ? "otfw-sidebar-link otfw-active" : "otfw-sidebar-link"}
16
16
  >
17
+ <span class="otfw-sidebar-dot" aria-hidden="true"></span>
17
18
  {item.title}
18
19
  </Link>
19
20
  ) : (
@@ -0,0 +1,47 @@
1
+ // Hamburger button in the navbar that opens the mobile sidebar drawer. Like
2
+ // SearchTrigger, it is decoupled from the thing it controls: the <Sidebar> installs
3
+ // `window.__otfwToggleSidebar` and emits an `otfw:sidebar` event, so this button works
4
+ // even when the navbar and the sidebar live in different layouts (the website renders
5
+ // the navbar at its root, the sidebar only inside the docs/api sections).
6
+ //
7
+ // The button is hidden by default and revealed by CSS only on small screens and only
8
+ // while a Sidebar is mounted (`:root[data-otfw-has-sidebar]`), so it never shows on a
9
+ // page without a drawer (e.g. the marketing home).
10
+
11
+ import { onMount } from "@opentf/web";
12
+
13
+ export default function SidebarToggle() {
14
+ let expanded = $state(false);
15
+
16
+ onMount(() => {
17
+ // Mirror the drawer's real state so aria-expanded stays correct however it was
18
+ // toggled (button, backdrop, Escape, navigation, resize).
19
+ const onState = (e) => (expanded = !!(e.detail && e.detail.open));
20
+ window.addEventListener("otfw:sidebar", onState);
21
+ return () => window.removeEventListener("otfw:sidebar", onState);
22
+ });
23
+
24
+ const toggle = () => window.__otfwToggleSidebar?.();
25
+
26
+ return (
27
+ <button
28
+ class="otfw-navbar-icon otfw-navbar-burger"
29
+ onclick={toggle}
30
+ aria-label="Toggle navigation"
31
+ aria-controls="otfw-sidebar"
32
+ aria-expanded={expanded ? "true" : "false"}
33
+ >
34
+ {() =>
35
+ expanded ? (
36
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
37
+ <path d="M6 6l12 12M18 6L6 18" />
38
+ </svg>
39
+ ) : (
40
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
41
+ <path d="M3 6h18M3 12h18M3 18h18" />
42
+ </svg>
43
+ )
44
+ }
45
+ </button>
46
+ );
47
+ }
@@ -0,0 +1,14 @@
1
+ // Numbered, vertically-connected steps for walkthroughs. Each child heading
2
+ // (`<h3>`/`<h4>`) becomes a numbered step via a CSS counter, so it composes with
3
+ // plain Markdown inside MDX:
4
+ //
5
+ // <Steps>
6
+ // ### Install
7
+ // …
8
+ // ### Run the dev server
9
+ // …
10
+ // </Steps>
11
+
12
+ export default function Steps(props) {
13
+ return <div class="otfw-steps">{props.children}</div>;
14
+ }
@@ -1,6 +1,8 @@
1
- // Generic tabbed panel. `tabs` is an array of `{ label, content }`.
1
+ // Generic tabbed panel. `tabs` is an array of `{ label, content }`. `content` is
2
+ // rendered as-is: a string is plain text, a node is the node. For a code panel with a
3
+ // copy button, pass a `CodeBlock`: `{ label, content: <CodeBlock code="…" /> }`.
2
4
  //
3
- // <Tabs tabs={[{ label: "npm", content: <…/> }, …]} />
5
+ // <Tabs tabs={[{ label: "npm", content: <CodeBlock code="npm i …" /> }, …]} />
4
6
 
5
7
  export default function Tabs(props) {
6
8
  let active = $state(0);