@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
@@ -1,39 +1,164 @@
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.
1
+ // Theme switcher: a subtle ghost icon button that opens a Light / Dark / System menu.
2
+ //
3
+ // The chosen *mode* is persisted to localStorage("theme") as "light" | "dark" |
4
+ // "system" (default "system"). The applied theme is the resolved value — in System
5
+ // mode it follows the OS and live-updates when the OS preference flips. The trigger
6
+ // shows the mode's icon; in System mode it shows the monitor icon plus a small badge of
7
+ // the currently resolved icon (sun/moon), so you can see both the choice and the result.
8
+ //
9
+ // Pair with the no-flash inline script in index.html, which must resolve "system"/none
10
+ // to a concrete theme on first paint.
4
11
 
5
12
  import { onMount } from "@opentf/web";
6
13
 
14
+ import Tooltip from "./Tooltip.jsx";
15
+
16
+ const STORAGE_KEY = "theme";
17
+ const prefersDark = () =>
18
+ typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
19
+
7
20
  export default function ThemeToggle() {
8
- let theme = $state("light");
21
+ let mode = $state("system"); // "light" | "dark" | "system"
22
+ let resolved = $state("light"); // "light" | "dark"
23
+ let openMenu = $state(false);
9
24
 
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
- });
25
+ const rootRef = $ref();
17
26
 
18
- const toggle = () => {
19
- theme = theme === "light" ? "dark" : "light";
20
- document.documentElement.setAttribute("data-theme", theme);
21
- localStorage.setItem("theme", theme);
27
+ const resolve = (m) => (m === "system" ? (prefersDark() ? "dark" : "light") : m);
28
+
29
+ const apply = (m) => {
30
+ resolved = resolve(m);
31
+ if (typeof document !== "undefined") {
32
+ document.documentElement.setAttribute("data-theme", resolved);
33
+ }
34
+ };
35
+
36
+ const setMode = (m) => {
37
+ mode = m;
38
+ openMenu = false;
39
+ try {
40
+ localStorage.setItem(STORAGE_KEY, m);
41
+ } catch {}
42
+ apply(m);
22
43
  };
23
44
 
45
+ onMount(() => {
46
+ const saved = (() => {
47
+ try {
48
+ return localStorage.getItem(STORAGE_KEY);
49
+ } catch {
50
+ return null;
51
+ }
52
+ })();
53
+ mode = saved === "light" || saved === "dark" || saved === "system" ? saved : "system";
54
+ apply(mode);
55
+
56
+ // Follow the OS while in System mode.
57
+ const mql = window.matchMedia("(prefers-color-scheme: dark)");
58
+ const onOS = () => mode === "system" && apply("system");
59
+ mql.addEventListener?.("change", onOS);
60
+
61
+ // Close the menu on outside click / Escape.
62
+ const onDocClick = (e) => {
63
+ if (openMenu && rootRef && !rootRef.contains(e.target)) openMenu = false;
64
+ };
65
+ const onKey = (e) => e.key === "Escape" && (openMenu = false);
66
+ document.addEventListener("click", onDocClick);
67
+ document.addEventListener("keydown", onKey);
68
+
69
+ return () => {
70
+ mql.removeEventListener?.("change", onOS);
71
+ document.removeEventListener("click", onDocClick);
72
+ document.removeEventListener("keydown", onKey);
73
+ };
74
+ });
75
+
24
76
  return (
25
- <button class="otfw-theme-toggle" onclick={toggle} aria-label="Toggle color theme">
77
+ <div class="otfw-theme" ref={rootRef}>
78
+ <Tooltip text="Change theme">
79
+ <button
80
+ class="otfw-navbar-icon otfw-theme-trigger"
81
+ onclick={(e) => {
82
+ e.stopPropagation();
83
+ openMenu = !openMenu;
84
+ }}
85
+ aria-label="Change theme"
86
+ aria-haspopup="menu"
87
+ aria-expanded={openMenu ? "true" : "false"}
88
+ >
89
+ {() =>
90
+ mode === "system" ? (
91
+ <span class="otfw-theme-trigger-system">
92
+ <MonitorIcon />
93
+ <span class="otfw-theme-trigger-badge">
94
+ {resolved === "dark" ? <MoonIcon small /> : <SunIcon small />}
95
+ </span>
96
+ </span>
97
+ ) : mode === "dark" ? (
98
+ <MoonIcon />
99
+ ) : (
100
+ <SunIcon />
101
+ )
102
+ }
103
+ </button>
104
+ </Tooltip>
105
+
26
106
  {() =>
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
- )
107
+ openMenu ? (
108
+ <div class="otfw-theme-menu" role="menu">
109
+ <MenuItem active={mode === "light"} onclick={() => setMode("light")}>
110
+ <SunIcon small /> Light
111
+ </MenuItem>
112
+ <MenuItem active={mode === "dark"} onclick={() => setMode("dark")}>
113
+ <MoonIcon small /> Dark
114
+ </MenuItem>
115
+ <MenuItem active={mode === "system"} onclick={() => setMode("system")}>
116
+ <MonitorIcon small /> System
117
+ </MenuItem>
118
+ </div>
119
+ ) : null
36
120
  }
121
+ </div>
122
+ );
123
+ }
124
+
125
+ function MenuItem(props) {
126
+ return (
127
+ <button
128
+ class={props.active ? "otfw-theme-item is-active" : "otfw-theme-item"}
129
+ role="menuitemradio"
130
+ aria-checked={props.active ? "true" : "false"}
131
+ onclick={props.onclick}
132
+ >
133
+ {props.children}
37
134
  </button>
38
135
  );
39
136
  }
137
+
138
+ function SunIcon(props) {
139
+ const s = props.small ? 15 : 17;
140
+ return (
141
+ <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
142
+ <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" />
143
+ </svg>
144
+ );
145
+ }
146
+
147
+ function MoonIcon(props) {
148
+ const s = props.small ? 15 : 17;
149
+ return (
150
+ <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
151
+ <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" />
152
+ </svg>
153
+ );
154
+ }
155
+
156
+ function MonitorIcon(props) {
157
+ const s = props.small ? 15 : 17;
158
+ return (
159
+ <svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
160
+ <rect x="2" y="3" width="20" height="14" rx="2" />
161
+ <path d="M8 21h8M12 17v4" />
162
+ </svg>
163
+ );
164
+ }
@@ -0,0 +1,19 @@
1
+ // A small hover/focus tooltip. CSS-driven (no JS state) — wrap any element and pass
2
+ // `text`; the bubble shows on hover or keyboard focus of the wrapped content.
3
+ //
4
+ // <Tooltip text="Change theme"><button>…</button></Tooltip>
5
+ //
6
+ // `placement` is "bottom" (default) or "top". The bubble is aria-hidden decorative
7
+ // chrome; give the wrapped control its own `aria-label` for assistive tech.
8
+
9
+ export default function Tooltip(props) {
10
+ const placement = props.placement === "top" ? "otfw-tooltip-top" : "otfw-tooltip-bottom";
11
+ return (
12
+ <span class={`otfw-tooltip ${placement}`}>
13
+ {props.children}
14
+ <span class="otfw-tooltip-bubble" role="tooltip" aria-hidden="true">
15
+ {props.text}
16
+ </span>
17
+ </span>
18
+ );
19
+ }
@@ -0,0 +1,11 @@
1
+ // Small shared formatting helpers for the docs/blog components.
2
+
3
+ // `2026-06-24` (or any Date-parseable value) → `June 24, 2026`. Falls back to the raw
4
+ // string for anything the Date constructor can't parse, so a non-ISO value still
5
+ // renders.
6
+ export function formatDate(value) {
7
+ if (!value) return "";
8
+ const d = new Date(value);
9
+ if (Number.isNaN(d.getTime())) return String(value);
10
+ return d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
11
+ }
package/config.js CHANGED
@@ -11,6 +11,8 @@
11
11
  * @typedef {Object} DocsNavLink
12
12
  * @property {string} label
13
13
  * @property {string} href
14
+ * @property {string} [icon] NavIcon name shown before the label (e.g. "book").
15
+ * @property {boolean} [external] Render as a plain target=_blank anchor (no SPA nav).
14
16
  *
15
17
  * @typedef {Object} DocsFooter
16
18
  * @property {string} [text]
@@ -18,17 +20,41 @@
18
20
  *
19
21
  * @typedef {Object} DocsConfig
20
22
  * @property {string} [title] Site/product name shown in the navbar.
23
+ * @property {string} [version] Version badge shown next to the brand (e.g. "v0.4.0").
21
24
  * @property {string} [logo] URL of the navbar logo image.
22
25
  * @property {string} [homeUrl] Where the navbar brand links to (default "/").
23
26
  * @property {string} [github] GitHub URL (shown in the navbar).
24
- * @property {string} [dir] Docs content folder under app/ (default "docs").
27
+ * @property {string} [repoUrl] Source repository URL. When set (with `lastUpdated`),
28
+ * each docs page gets an "Edit this page" link to its file on
29
+ * GitHub (`<repoUrl>/edit/main/<path>`).
30
+ * @property {string} [dir] Docs content folder under app/ (default "docs"). Any
31
+ * other top-level folder with a `DocsLayout` is its own section
32
+ * (e.g. `app/api` → `/api`) — no extra config needed.
25
33
  * @property {DocsNavLink[]} [nav] Top-level navbar links.
26
34
  * @property {DocsFooter} [footer] Footer content.
27
35
  * @property {{ provider?: string }} [search] Search provider (Phase 2: "pagefind").
36
+ * @property {boolean} [lastUpdated] Show a "Last updated" line per docs page (from the
37
+ * file's last git commit, or a `lastUpdated` frontmatter
38
+ * override; `lastUpdated: false` in frontmatter hides a page).
39
+ *
40
+ * @typedef {Object} BlogConfig
41
+ * @property {string} [dir] Blog content folder under app/ (default "blog"). Each
42
+ * `<dir>/<slug>/page.mdx` is a post; its frontmatter
43
+ * (title, description, date, author, tags) feeds the
44
+ * generated `@opentf/web-docs/posts` list.
45
+ * @property {string} [title] Feed title (default `"<docs.title> Blog"`).
46
+ * @property {string} [description] Feed description (default the title). RSS and Atom
47
+ * feeds (`<dir>/rss.xml`, `<dir>/atom.xml`) are generated
48
+ * by `otfw build`; production docs/blog builds require
49
+ * `site.url` or `--base-url` so feed URLs are absolute.
50
+ * @property {boolean} [lastUpdated] Show a "Last updated" line on a post when it was
51
+ * edited after its publish date (from git / frontmatter).
28
52
  *
29
53
  * @typedef {Object} SiteConfig
30
- * @property {{ url?: string }} [site] Canonical site origin (for SEO / sitemap).
54
+ * @property {{ url?: string }} [site] Canonical site origin (required for production
55
+ * docs/blog builds unless `--base-url` is passed).
31
56
  * @property {DocsConfig} [docs] Documentation generator config.
57
+ * @property {BlogConfig} [blog] Blog generator config.
32
58
  */
33
59
 
34
60
  /**
package/index.js CHANGED
@@ -16,23 +16,50 @@ import "./components/Toc.jsx";
16
16
  import "./components/Footer.jsx";
17
17
  import "./components/Breadcrumbs.jsx";
18
18
  import "./components/Pagination.jsx";
19
+ import "./components/LastUpdated.jsx";
19
20
  import "./components/ThemeToggle.jsx";
20
21
  import "./components/SearchTrigger.jsx";
22
+ import "./components/Search.jsx";
21
23
  import "./components/Callout.jsx";
24
+ import "./components/CodeBlock.jsx";
22
25
  import "./components/Tabs.jsx";
23
- import "./components/CodeGroup.jsx";
26
+ import "./components/Steps.jsx";
24
27
  import "./components/Table.jsx";
28
+ import "./components/Cards.jsx";
29
+ import "./components/Card.jsx";
30
+ import "./components/Tooltip.jsx";
31
+ import "./components/BlogLayout.jsx";
32
+ import "./components/PostList.jsx";
33
+ import "./components/PostCard.jsx";
34
+ import "./components/PostBanner.jsx";
35
+ import "./components/PostMeta.jsx";
36
+ import "./components/ReadingTime.jsx";
25
37
 
26
38
  export { default as DocsLayout } from "./components/DocsLayout.jsx";
27
39
  export { default as Navbar } from "./components/Navbar.jsx";
40
+ export { default as NavbarLink } from "./components/NavbarLink.jsx";
41
+ export { default as SidebarToggle } from "./components/SidebarToggle.jsx";
42
+ export { default as NavIcon } from "./components/NavIcon.jsx";
28
43
  export { default as Sidebar } from "./components/Sidebar.jsx";
29
44
  export { default as Toc } from "./components/Toc.jsx";
30
45
  export { default as Footer } from "./components/Footer.jsx";
31
46
  export { default as Breadcrumbs } from "./components/Breadcrumbs.jsx";
32
47
  export { default as Pagination } from "./components/Pagination.jsx";
48
+ export { default as LastUpdated } from "./components/LastUpdated.jsx";
33
49
  export { default as ThemeToggle } from "./components/ThemeToggle.jsx";
34
50
  export { default as SearchTrigger } from "./components/SearchTrigger.jsx";
51
+ export { default as Search } from "./components/Search.jsx";
35
52
  export { default as Callout } from "./components/Callout.jsx";
53
+ export { default as CodeBlock } from "./components/CodeBlock.jsx";
36
54
  export { default as Tabs } from "./components/Tabs.jsx";
37
- export { default as CodeGroup } from "./components/CodeGroup.jsx";
55
+ export { default as Steps } from "./components/Steps.jsx";
38
56
  export { default as Table } from "./components/Table.jsx";
57
+ export { default as Cards } from "./components/Cards.jsx";
58
+ export { default as Card } from "./components/Card.jsx";
59
+ export { default as Tooltip } from "./components/Tooltip.jsx";
60
+ export { default as BlogLayout } from "./components/BlogLayout.jsx";
61
+ export { default as PostList } from "./components/PostList.jsx";
62
+ export { default as PostCard } from "./components/PostCard.jsx";
63
+ export { default as PostBanner } from "./components/PostBanner.jsx";
64
+ export { default as PostMeta } from "./components/PostMeta.jsx";
65
+ export { default as ReadingTime } from "./components/ReadingTime.jsx";
package/nav-virtual.js CHANGED
@@ -2,9 +2,11 @@
2
2
  //
3
3
  // When `docsNavPlugin` is active (the toolchain registers it whenever the project
4
4
  // has a `docs` config), it intercepts this specifier and replaces it with a
5
- // build-time-generated module exporting the real navigation tree. This file is only
6
- // loaded when the plugin is NOT active (e.g. an editor, or a non-docs build), in
7
- // which case the nav is simply empty.
5
+ // build-time-generated module exporting the real navigation. This file is only loaded
6
+ // when the plugin is NOT active (e.g. an editor, or a non-docs build).
7
+ //
8
+ // Shape: a section map `{ [base]: tree }` (e.g. `{ "/docs": [...], "/api": [...] }`);
9
+ // empty here.
8
10
 
9
11
  if (typeof console !== "undefined") {
10
12
  console.warn(
@@ -13,4 +15,4 @@ if (typeof console !== "undefined") {
13
15
  );
14
16
  }
15
17
 
16
- export default [];
18
+ export default {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentf/web-docs",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Documentation-site generator for OTF Web — themed shell components (sidebar, TOC, callouts, tabs), a build-time nav generator, and a default light/dark theme.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,12 +8,16 @@
8
8
  "./theme": "./theme/index.css",
9
9
  "./config": "./config.js",
10
10
  "./nav": "./nav-virtual.js",
11
+ "./posts": "./posts-virtual.js",
12
+ "./updated": "./updated-virtual.js",
11
13
  "./build": "./build/index.js"
12
14
  },
13
15
  "files": [
14
16
  "index.js",
15
17
  "config.js",
16
18
  "nav-virtual.js",
19
+ "posts-virtual.js",
20
+ "updated-virtual.js",
17
21
  "components",
18
22
  "theme",
19
23
  "build",
@@ -40,5 +44,15 @@
40
44
  },
41
45
  "publishConfig": {
42
46
  "access": "public"
47
+ },
48
+ "scripts": {
49
+ "test": "bun test --env=happy-dom"
50
+ },
51
+ "dependencies": {
52
+ "pagefind": "^1.5.2"
53
+ },
54
+ "devDependencies": {
55
+ "@opentf/web": "0.7.0",
56
+ "@opentf/web-test": "1.2.0"
43
57
  }
44
58
  }
@@ -0,0 +1,17 @@
1
+ // Fallback for `@opentf/web-docs/posts`.
2
+ //
3
+ // When `blogPostsPlugin` is active (the toolchain registers it whenever the project
4
+ // has a `blog` config), it intercepts this specifier and replaces it with a
5
+ // build-time-generated module exporting the post list. This file is only loaded when
6
+ // the plugin is NOT active (e.g. an editor, or a project without a blog), in which
7
+ // case the list is simply empty.
8
+
9
+ if (typeof console !== "undefined") {
10
+ console.warn(
11
+ "[@opentf/web-docs] posts requested but blogPostsPlugin is not active — " +
12
+ "did you add a `blog` block to otfw.config.js? Returning an empty list.",
13
+ );
14
+ }
15
+
16
+ export const posts = [];
17
+ export default posts;