@dreamlake/dockit 0.2.13 → 0.2.15

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.
@@ -1,9 +1,21 @@
1
1
  import { jsxs, Fragment, jsx } from "react/jsx-runtime";
2
+ import { loadSearchPreview } from "../lib/search-preview.js";
2
3
  import { useState, useRef, useEffect } from "react";
3
4
  import { defaultResults, searchPages } from "../lib/pagefind-search.js";
4
5
  import { useMediaQuery } from "../lib/use-media-query.js";
5
6
  import { useSearchResize, SearchResizeHandles } from "./SearchResize.js";
6
7
  import { useHiddenToggle } from "../lib/use-hidden-toggle.js";
8
+ import { Chip } from "./Chip.js";
9
+ function SourceBadge({ label }) {
10
+ return /* @__PURE__ */ jsx(
11
+ Chip,
12
+ {
13
+ variant: "accent",
14
+ style: { fontSize: 8.5, padding: "0px 5px", letterSpacing: "0.06em", flexShrink: 0 },
15
+ children: label
16
+ }
17
+ );
18
+ }
7
19
  const PAL_MAX_W = 860;
8
20
  const PAL_GUTTER = 24;
9
21
  const PAL_INSET = `max(${PAL_GUTTER}px, calc((100vw - ${PAL_MAX_W}px) / 2))`;
@@ -65,20 +77,13 @@ function SearchPalette({ open, onClose, query }) {
65
77
  const [pageHtml, setPageHtml] = useState("");
66
78
  const pageCacheRef = useRef(/* @__PURE__ */ new Map());
67
79
  const activePath = hits[active]?.path;
68
- useEffect(() => {
69
- if (!activePath || isSingleCol) {
70
- setPageHtml("");
71
- return;
72
- }
73
- const cached = pageCacheRef.current.get(activePath);
74
- if (cached !== void 0) {
75
- setPageHtml(cached);
76
- return;
77
- }
78
- setPageHtml("");
79
- let cancelled = false;
80
- fetch(activePath).then((r) => r.ok ? r.text() : "").then((html) => {
81
- if (cancelled) return;
80
+ useEffect(() => loadSearchPreview({
81
+ open,
82
+ path: activePath,
83
+ singleColumn: isSingleCol,
84
+ cache: pageCacheRef.current,
85
+ onPreview: setPageHtml,
86
+ extract: (html) => {
82
87
  const doc = new DOMParser().parseFromString(html, "text/html");
83
88
  const main = doc.querySelector("[data-pagefind-body]");
84
89
  main?.querySelectorAll("[data-pagefind-ignore], script, style").forEach((el) => el.remove());
@@ -88,16 +93,9 @@ function SearchPalette({ open, onClose, query }) {
88
93
  el.removeAttribute("style");
89
94
  el.removeAttribute("class");
90
95
  });
91
- const content = main?.innerHTML ?? "";
92
- pageCacheRef.current.set(activePath, content);
93
- setPageHtml(content);
94
- }).catch(() => {
95
- if (!cancelled) setPageHtml("");
96
- });
97
- return () => {
98
- cancelled = true;
99
- };
100
- }, [active, activePath, isSingleCol]);
96
+ return main?.innerHTML ?? "";
97
+ }
98
+ }), [open, activePath, isSingleCol]);
101
99
  useEffect(() => {
102
100
  if (!open) return;
103
101
  setActive(0);
@@ -341,6 +339,7 @@ function SearchPalette({ open, onClose, query }) {
341
339
  textOverflow: "ellipsis"
342
340
  },
343
341
  children: [
342
+ hit.sourceLabel && /* @__PURE__ */ jsx("span", { style: { marginRight: 5 }, children: /* @__PURE__ */ jsx(SourceBadge, { label: hit.sourceLabel }) }),
344
343
  hit.section && /* @__PURE__ */ jsxs(
345
344
  "span",
346
345
  {
@@ -379,19 +378,23 @@ function SearchPalette({ open, onClose, query }) {
379
378
  }
380
379
  )
381
380
  ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
382
- /* @__PURE__ */ jsx(
383
- "span",
384
- {
385
- className: "text-doc-template-ink",
386
- style: {
387
- fontWeight: 500,
388
- whiteSpace: "nowrap",
389
- overflow: "hidden",
390
- textOverflow: "ellipsis"
391
- },
392
- children: highlight(hit.title, query)
393
- }
394
- ),
381
+ /* @__PURE__ */ jsxs("span", { className: "flex items-center", style: { gap: 6, minWidth: 0 }, children: [
382
+ /* @__PURE__ */ jsx(
383
+ "span",
384
+ {
385
+ className: "text-doc-template-ink",
386
+ style: {
387
+ fontWeight: 500,
388
+ whiteSpace: "nowrap",
389
+ overflow: "hidden",
390
+ textOverflow: "ellipsis",
391
+ minWidth: 0
392
+ },
393
+ children: highlight(hit.title, query)
394
+ }
395
+ ),
396
+ hit.sourceLabel && /* @__PURE__ */ jsx(SourceBadge, { label: hit.sourceLabel })
397
+ ] }),
395
398
  hit.section && /* @__PURE__ */ jsx(
396
399
  "span",
397
400
  {
@@ -497,7 +500,7 @@ function SearchPalette({ open, onClose, query }) {
497
500
  letterSpacing: "0.05em",
498
501
  marginBottom: 6
499
502
  },
500
- children: previewHit.section || "—"
503
+ children: previewHit.section || previewHit.sourceLabel || "—"
501
504
  }
502
505
  ),
503
506
  /* @__PURE__ */ jsx(
@@ -51,7 +51,7 @@ function Sidebar() {
51
51
  "aside",
52
52
  {
53
53
  "aria-label": "Sections",
54
- className: "hidden md:flex sticky overflow-y-auto flex-col",
54
+ className: "doc-sidebar hidden md:flex sticky overflow-y-auto flex-col",
55
55
  style: { top: 40, height: "calc(100vh - 40px)", padding: "22px 12px 32px 18px", gap: 2 },
56
56
  children: groups.map((group) => {
57
57
  const soleSection = groups.length === 1;
@@ -65,7 +65,7 @@ function Sidebar() {
65
65
  className: "group sidebar-section",
66
66
  "data-folded": isFolded || void 0,
67
67
  children: [
68
- /* @__PURE__ */ jsx("span", { children: group.label }),
68
+ /* @__PURE__ */ jsx("span", { title: group.label, children: group.label }),
69
69
  /* @__PURE__ */ jsx(
70
70
  "span",
71
71
  {
@@ -1,11 +1,14 @@
1
1
  import { jsxs, jsx } from "react/jsx-runtime";
2
2
  import { useMemo, useRef, useState, useLayoutEffect } from "react";
3
3
  import { usePageContext } from "vike-react/usePageContext";
4
- import { tabForUrl, TABS, urlInTab } from "../lib/tabs.js";
4
+ import { tabForUrl, TABS, isExternalTab, urlInTab } from "../lib/tabs.js";
5
5
  import { pages } from "../lib/navigation.js";
6
6
  import { useHiddenToggle } from "../lib/use-hidden-toggle.js";
7
7
  const SPRING = "cubic-bezier(0.4, 0.7, 0.3, 1)";
8
8
  const SSR_BAR_BOTTOM = -13;
9
+ function tabKey(tab) {
10
+ return isExternalTab(tab) ? `ext:${tab.href}` : tab.id;
11
+ }
9
12
  function TabStrip() {
10
13
  const { urlPathname } = usePageContext();
11
14
  const activeTab = tabForUrl(urlPathname);
@@ -13,7 +16,7 @@ function TabStrip() {
13
16
  const visibleTabs = useMemo(() => {
14
17
  if (showHidden) return TABS;
15
18
  return TABS.filter(
16
- (tab) => tab.id === activeTab || pages.some((p) => !p.hidden && urlInTab(p.path, tab.id))
19
+ (tab) => isExternalTab(tab) || tab.id === activeTab || pages.some((p) => !p.hidden && urlInTab(p.path, tab.id))
17
20
  );
18
21
  }, [showHidden, activeTab]);
19
22
  const containerRef = useRef(null);
@@ -75,19 +78,22 @@ function TabStrip() {
75
78
  onMouseLeave: () => setHovered(null),
76
79
  children: [
77
80
  visibleTabs.map((tab) => {
78
- const isActive = tab.id === activeTab;
81
+ const key = tabKey(tab);
82
+ const external = isExternalTab(tab);
83
+ const isActive = !external && tab.id === activeTab;
79
84
  return /* @__PURE__ */ jsx(
80
85
  "li",
81
86
  {
82
87
  className: "flex items-stretch shrink-0",
83
- onMouseEnter: () => setHovered(tab.id),
84
- children: /* @__PURE__ */ jsx(
88
+ onMouseEnter: () => setHovered(key),
89
+ children: /* @__PURE__ */ jsxs(
85
90
  "a",
86
91
  {
87
92
  ref: (el) => {
88
- linkRefs.current[tab.id] = el;
93
+ linkRefs.current[key] = el;
89
94
  },
90
- href: tab.landing,
95
+ href: external ? tab.href : tab.landing,
96
+ ...external ? { target: "_blank", rel: "noreferrer" } : {},
91
97
  "aria-current": isActive ? "page" : void 0,
92
98
  className: "flex items-center no-underline px-1.5 md:px-2.5",
93
99
  style: {
@@ -97,15 +103,36 @@ function TabStrip() {
97
103
  letterSpacing: "0.12em",
98
104
  textTransform: "uppercase",
99
105
  whiteSpace: "nowrap",
100
- color: isActive || hovered === tab.id ? "var(--color-doc-template-ink)" : "var(--color-doc-template-muted)",
106
+ color: isActive || hovered === key ? "var(--color-doc-template-ink)" : "var(--color-doc-template-muted)",
101
107
  cursor: "pointer",
102
108
  transition: "color 0.18s ease"
103
109
  },
104
- children: tab.label
110
+ children: [
111
+ tab.label,
112
+ external && // ↗ affordance — drawn, not the glyph, so its weight and
113
+ // optical size stay consistent across the mono font stack.
114
+ /* @__PURE__ */ jsxs(
115
+ "svg",
116
+ {
117
+ viewBox: "0 0 24 24",
118
+ fill: "none",
119
+ stroke: "currentColor",
120
+ strokeWidth: "2.5",
121
+ strokeLinecap: "round",
122
+ strokeLinejoin: "round",
123
+ "aria-hidden": true,
124
+ style: { width: 9, height: 9, marginLeft: 4, opacity: 0.8, flexShrink: 0 },
125
+ children: [
126
+ /* @__PURE__ */ jsx("path", { d: "M7 17L17 7" }),
127
+ /* @__PURE__ */ jsx("path", { d: "M8 7h9v9" })
128
+ ]
129
+ }
130
+ )
131
+ ]
105
132
  }
106
133
  )
107
134
  },
108
- tab.id
135
+ key
109
136
  );
110
137
  }),
111
138
  /* @__PURE__ */ jsx(
@@ -10,7 +10,7 @@ import { ThemeToggle } from "./ThemeToggle.js";
10
10
  import { VersionBadge } from "./VersionBadge.js";
11
11
  import { useMerge } from "../renderer/Layout.js";
12
12
  import { TabStrip } from "./TabStrip.js";
13
- import { TABS, tabForUrl } from "../lib/tabs.js";
13
+ import { internalTabs, tabForUrl } from "../lib/tabs.js";
14
14
  function HiddenRevealChip() {
15
15
  const [show, setShow] = useHiddenToggle();
16
16
  if (!show) return null;
@@ -38,7 +38,7 @@ const SEARCH_INSET = `max(${SEARCH_GUTTER}px, calc((100vw - ${SEARCH_MAX_W}px) /
38
38
  function Topbar({ searchOpen, onOpenSearch, onCloseSearch, query, setQuery }) {
39
39
  const { urlPathname } = usePageContext();
40
40
  const current = findPage(urlPathname);
41
- const activeTab = TABS.find((t) => t.id === tabForUrl(urlPathname));
41
+ const activeTab = internalTabs().find((t) => t.id === tabForUrl(urlPathname));
42
42
  const versionChips = activeTab?.versionChips ?? siteConfig.versionChips ?? [];
43
43
  const inputRef = useRef(null);
44
44
  const labelRef = useRef(null);
@@ -342,7 +342,9 @@ function Topbar({ searchOpen, onOpenSearch, onCloseSearch, query, setQuery }) {
342
342
  label: chip.label,
343
343
  version: chip.version,
344
344
  collapsed: merged,
345
- dropdown: chip.dropdown
345
+ dropdown: chip.dropdown,
346
+ prefix: chip.prefix,
347
+ manifestUrl: chip.manifestUrl
346
348
  },
347
349
  chip.label
348
350
  )),
@@ -1,7 +1,7 @@
1
1
  export interface VersionBadgeProps {
2
2
  /** Left segment text (e.g. the package name). */
3
3
  label: string;
4
- /** Build-time version shown in the right segment (may be overridden on
4
+ /** Build-time version shown in the right segment (with dropdown enabled, may be overridden on
5
5
  * the client from the hostname / manifest — see below). */
6
6
  version: string;
7
7
  /** Collapse to width 0 (the Topbar drives this from its merged state).
@@ -10,6 +10,10 @@ export interface VersionBadgeProps {
10
10
  /** Turn the version segment into a popover listing the deploys from
11
11
  * `/versions.json`. */
12
12
  dropdown?: boolean;
13
+ /** Text before versions; defaults to 'v'. Use '' for commit previews. */
14
+ prefix?: string;
15
+ /** Manifest path or absolute URL; defaults to '/versions.json'. */
16
+ manifestUrl?: string;
13
17
  }
14
18
  /**
15
19
  * Two-segment version chip: `[ label | v<version> ]`.
@@ -33,4 +37,4 @@ export interface VersionBadgeProps {
33
37
  * radius, hairline border. The two-segment (label | version) layout is
34
38
  * bespoke, so this component does not compose `Chip` internally.
35
39
  */
36
- export declare function VersionBadge({ label, version, collapsed, dropdown, }: VersionBadgeProps): import("react").JSX.Element;
40
+ export declare function VersionBadge({ label, version, collapsed, dropdown, prefix, manifestUrl, }: VersionBadgeProps): import("react").JSX.Element;
@@ -1,46 +1,39 @@
1
1
  import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import { useState, useRef, useEffect } from "react";
3
3
  import { siteConfig } from "../site-config.js";
4
+ import { badgeScope, manifestForScope, resolveBadgeVersion, loadVersionManifest } from "../lib/version-badge.js";
4
5
  const SPRING = "cubic-bezier(0.4, 0.7, 0.3, 1)";
5
6
  function VersionBadge({
6
7
  label,
7
8
  version,
8
9
  collapsed = false,
9
- dropdown = false
10
+ dropdown = false,
11
+ prefix = "v",
12
+ manifestUrl = "/versions.json"
10
13
  }) {
11
14
  const transition = `opacity 0.4s ease, transform 0.4s ${SPRING}, width 0.4s ${SPRING}, margin 0.4s ${SPRING}, padding 0.4s ${SPRING}`;
12
15
  const [open, setOpen] = useState(false);
13
- const [manifest, setManifest] = useState(null);
14
- const [displayVersion, setDisplayVersion] = useState(version);
16
+ const [manifestState, setManifestState] = useState(null);
17
+ const [hostname, setHostname] = useState("");
18
+ const scope = badgeScope(label, version, dropdown, manifestUrl);
19
+ const manifest = manifestForScope(manifestState, scope);
20
+ const displayVersion = resolveBadgeVersion(version, dropdown, hostname, manifest, manifestUrl);
15
21
  const wrapperRef = useRef(null);
16
22
  useEffect(() => {
23
+ setHostname(window.location.hostname);
24
+ }, []);
25
+ useEffect(() => {
26
+ setOpen(false);
27
+ setManifestState(null);
17
28
  if (!dropdown) return;
18
29
  let cancelled = false;
19
- const origin = (siteConfig.url || "").replace(/\/+$/, "");
20
- const isLocal = typeof window !== "undefined" && /^(localhost|127\.|0\.0\.0\.0)/.test(window.location.hostname);
21
- const sameOrigin = () => fetch("/versions.json").then((r) => r.ok ? r.json() : null);
22
- const fromOrigin = () => fetch(`${origin}/versions.json`).then((r) => r.ok ? r.json() : Promise.reject(new Error(String(r.status))));
23
- const load = !origin ? sameOrigin() : isLocal ? sameOrigin().then((m) => m ?? fromOrigin().catch(() => null)) : fromOrigin().catch(sameOrigin);
24
- load.then((m) => {
25
- if (!cancelled && m) setManifest(m);
26
- }).catch(() => {
30
+ loadVersionManifest(manifestUrl, siteConfig.url || "", window.location.hostname).then((manifest2) => {
31
+ if (!cancelled && manifest2) setManifestState({ scope, manifest: manifest2 });
27
32
  });
28
33
  return () => {
29
34
  cancelled = true;
30
35
  };
31
- }, [dropdown]);
32
- useEffect(() => {
33
- if (typeof window === "undefined") return;
34
- const host = window.location.hostname;
35
- const m = host.match(/^v(\d+(?:-\d+)+(?:[a-z]+\d+)?)\./i);
36
- if (m) {
37
- setDisplayVersion(m[1].replace(/-/g, "."));
38
- } else if (host.startsWith("stable.") && manifest?.stable) {
39
- setDisplayVersion(manifest.stable);
40
- } else if (manifest?.current) {
41
- setDisplayVersion(manifest.current);
42
- }
43
- }, [manifest]);
36
+ }, [scope, dropdown, manifestUrl]);
44
37
  useEffect(() => {
45
38
  if (!open) return;
46
39
  function onDown(e) {
@@ -144,11 +137,11 @@ function VersionBadge({
144
137
  },
145
138
  "aria-haspopup": "listbox",
146
139
  "aria-expanded": open,
147
- title: `Switch ${label} version (current v${displayVersion})`,
140
+ title: `Switch ${label} version (current ${prefix}${displayVersion})`,
148
141
  className: "text-doc-template-ink hover:bg-doc-template-search",
149
142
  style: versionSegStyle,
150
143
  children: [
151
- "v",
144
+ prefix,
152
145
  displayVersion,
153
146
  chevron
154
147
  ]
@@ -159,7 +152,7 @@ function VersionBadge({
159
152
  className: "inline-flex items-center text-doc-template-ink",
160
153
  style: versionSegStyle,
161
154
  children: [
162
- "v",
155
+ prefix,
163
156
  displayVersion
164
157
  ]
165
158
  }
@@ -250,11 +243,11 @@ function VersionBadge({
250
243
  children: [
251
244
  name,
252
245
  name === "stable" && manifest.stable && /* @__PURE__ */ jsxs("span", { style: { opacity: 0.5, marginLeft: 6 }, children: [
253
- "v",
246
+ prefix,
254
247
  manifest.stable
255
248
  ] }),
256
249
  name === "latest" && manifest.current && /* @__PURE__ */ jsxs("span", { style: { opacity: 0.5, marginLeft: 6 }, children: [
257
- "v",
250
+ prefix,
258
251
  manifest.current
259
252
  ] })
260
253
  ]
@@ -293,7 +286,7 @@ function VersionBadge({
293
286
  },
294
287
  children: [
295
288
  /* @__PURE__ */ jsxs("span", { children: [
296
- "v",
289
+ prefix,
297
290
  v.version,
298
291
  isCurrent && /* @__PURE__ */ jsx("span", { style: { opacity: 0.55, marginLeft: 6 }, children: "current" })
299
292
  ] }),
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { SiteConfig as _SiteConfig } from './site-config';
1
+ import { SiteConfig as _SiteConfig, SearchConfig as _SearchConfig } from './site-config';
2
2
  import { PageFrontmatter as _PageFrontmatter } from './lib/navigation';
3
- import { TabDef as _TabDef } from './lib/tabs';
3
+ import { TabEntry as _TabEntry } from './lib/tabs';
4
4
  /**
5
5
  * @dreamlake/dockit — the DreamLake documentation shell as a library.
6
6
  *
@@ -11,17 +11,17 @@ import { TabDef as _TabDef } from './lib/tabs';
11
11
  *
12
12
  * initDocs({
13
13
  * site: { brand: 'DreamLake', subtitle: 'Dockit', … },
14
- * pages: import.meta.glob('./pages/** /+Page.mdx', { eager: true }),
15
- * rawPages: import.meta.glob('./pages/** /+Page.mdx', { eager: true, query: '?raw', import: 'default' }),
14
+ * pageMetadata: import.meta.glob('./pages/** /+Page.mdx', { eager: true, query: '?frontmatter', import: 'default' }),
15
+ * rawPages: import.meta.env.DEV ? import.meta.glob('./pages/** /+Page.mdx', { eager: true, query: '?raw', import: 'default' }) : undefined,
16
16
  * sectionOrder: ['Getting started', 'Reference'],
17
17
  * tabs: [{ id: 'overview', label: 'Get Started', numeral: 'I', landing: '/', urlPrefix: '/get-started' }],
18
18
  * })
19
19
  */
20
- export { siteConfig, defineSiteConfig, type SiteConfig, type VersionChip, } from './site-config';
20
+ export { siteConfig, defineSiteConfig, type SiteConfig, type VersionChip, type SearchConfig, type MergeIndexDef, } from './site-config';
21
21
  export { pages, groupedPages, groupedVisiblePages, initNavigation, getAdjacentPages, normalizePath, findPage, type PageMeta, type PageFrontmatter, type PageModule, type NavGroup, } from './lib/navigation';
22
22
  export { searchIndex, rawMarkdown, initSearchIndex, search, type SearchEntry, type SearchHit, } from './lib/search-index';
23
23
  export { searchPages, defaultResults, type SearchResult, type SubResult, } from './lib/pagefind-search';
24
- export { TABS, initTabs, tabForUrl, urlInTab, type TabDef } from './lib/tabs';
24
+ export { TABS, initTabs, tabForUrl, urlInTab, isExternalTab, internalTabs, type TabDef, type ExternalTabDef, type TabEntry, } from './lib/tabs';
25
25
  export { useHiddenToggle } from './lib/use-hidden-toggle';
26
26
  export { useLineNumbers } from './lib/use-line-numbers';
27
27
  export { useLocalStorage, DOC_PREFIX, docKey } from './lib/use-local-storage';
@@ -47,17 +47,28 @@ export { Layout, useMerge } from './renderer/Layout';
47
47
  export interface InitDocsOptions {
48
48
  /** Site branding, URLs, version chips, git hash. */
49
49
  site: Partial<_SiteConfig>;
50
- /** Eager glob of every `+Page.mdx` module (frontmatter drives nav). */
51
- pages: Record<string, {
50
+ /** Metadata-only glob: `{ eager: true, query: '?frontmatter', import: 'default' }`.
51
+ * Keeps compiled page bodies out of the shared startup bundle.
52
+ * Use this or `pages`, not both. */
53
+ pageMetadata?: Record<string, _PageFrontmatter>;
54
+ /** Backward-compatible module map. Prefer `pageMetadata` for large sites. */
55
+ pages?: Record<string, {
52
56
  frontmatter?: _PageFrontmatter;
53
57
  }>;
54
- /** Eager `?raw` glob of the same pages — feeds the dev search index and
55
- * the palette's markdown preview. Optional but recommended. */
58
+ /** Raw sources for fallback/dev search. Usually pass a `?raw` glob only
59
+ * when `import.meta.env.DEV`; production Pagefind searches built HTML.
60
+ * Omitting this retains title/description search if Pagefind is unavailable. */
56
61
  rawPages?: Record<string, string>;
57
62
  /** Authored sidebar section order; unknown sections fall to the end. */
58
63
  sectionOrder?: string[];
59
- /** Topbar tab lenses. Omit (or pass []) for a tabless site. */
60
- tabs?: _TabDef[];
64
+ /** Topbar tab lenses, plus optional external-link entries
65
+ * (`{ label, href, external: true }`). Omit (or pass []) for a
66
+ * tabless site. */
67
+ tabs?: _TabEntry[];
68
+ /** Search-layer options — e.g. `mergeIndexes` to merge remote Pagefind
69
+ * indexes into the palette. Equivalent to `site.search`; this
70
+ * top-level key wins when both are given. */
71
+ search?: _SearchConfig;
61
72
  }
62
73
  /** One call wires the whole shell: site config, navigation, search, tabs. */
63
74
  export declare function initDocs(opts: InitDocsOptions): void;
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { initSearchIndex } from "./lib/search-index.js";
6
6
  import { rawMarkdown, search, searchIndex } from "./lib/search-index.js";
7
7
  import { defaultResults, searchPages } from "./lib/pagefind-search.js";
8
8
  import { initTabs } from "./lib/tabs.js";
9
- import { TABS, tabForUrl, urlInTab } from "./lib/tabs.js";
9
+ import { TABS, internalTabs, isExternalTab, tabForUrl, urlInTab } from "./lib/tabs.js";
10
10
  import { useHiddenToggle } from "./lib/use-hidden-toggle.js";
11
11
  import { useLineNumbers } from "./lib/use-line-numbers.js";
12
12
  import { DOC_PREFIX, docKey, useLocalStorage } from "./lib/use-local-storage.js";
@@ -30,8 +30,13 @@ import { Topbar } from "./components/Topbar.js";
30
30
  import { VersionBadge } from "./components/VersionBadge.js";
31
31
  import { Layout, useMerge } from "./renderer/Layout.js";
32
32
  function initDocs(opts) {
33
- defineSiteConfig(opts.site);
34
- initNavigation(opts.pages, opts.sectionOrder ?? []);
33
+ if (opts.pages && opts.pageMetadata) {
34
+ throw new Error("initDocs: provide pageMetadata or pages, not both");
35
+ }
36
+ const modules = opts.pageMetadata ? Object.fromEntries(Object.entries(opts.pageMetadata).map(([file, frontmatter]) => [file, { frontmatter }])) : opts.pages;
37
+ if (!modules) throw new Error("initDocs: pageMetadata or pages is required");
38
+ defineSiteConfig(opts.search ? { ...opts.site, search: opts.search } : opts.site);
39
+ initNavigation(modules, opts.sectionOrder ?? []);
35
40
  initSearchIndex(opts.rawPages ?? {});
36
41
  initTabs(opts.tabs ?? []);
37
42
  }
@@ -67,6 +72,8 @@ export {
67
72
  initNavigation,
68
73
  initSearchIndex,
69
74
  initTabs,
75
+ internalTabs,
76
+ isExternalTab,
70
77
  mdxComponents,
71
78
  normalizePath,
72
79
  pages,
@@ -0,0 +1,3 @@
1
+ import { Plugin } from 'vite';
2
+ /** Load only YAML metadata, without importing or compiling the MDX body. */
3
+ export declare function mdxFrontmatterLoader(): Plugin;
@@ -0,0 +1,29 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parse } from "yaml";
3
+ function mdxFrontmatterLoader() {
4
+ const prefix = "\0dockit-frontmatter:";
5
+ return {
6
+ name: "dockit-frontmatter",
7
+ enforce: "pre",
8
+ async resolveId(id, importer) {
9
+ if (!id.endsWith(".mdx?frontmatter")) return null;
10
+ const resolved = await this.resolve(id.slice(0, -"?frontmatter".length), importer, { skipSelf: true });
11
+ return resolved ? prefix + resolved.id : null;
12
+ },
13
+ async load(id) {
14
+ if (!id.startsWith(prefix)) return null;
15
+ const file = id.slice(prefix.length);
16
+ this.addWatchFile(file);
17
+ const source = await readFile(file, "utf8");
18
+ const match = source.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)(?:\r?\n|$)/);
19
+ const metadata = match ? parse(match[1]) ?? {} : {};
20
+ if (typeof metadata !== "object" || Array.isArray(metadata)) {
21
+ throw new Error(`Expected a YAML frontmatter mapping in ${file}`);
22
+ }
23
+ return `export default ${JSON.stringify(metadata)};`;
24
+ }
25
+ };
26
+ }
27
+ export {
28
+ mdxFrontmatterLoader
29
+ };
@@ -13,6 +13,10 @@ export interface SearchResult {
13
13
  excerptHtml: string;
14
14
  subResults: SubResult[];
15
15
  score: number;
16
+ /** Label of the merged remote index this result came from (see
17
+ * `search.mergeIndexes` in the site config). Unset for local results.
18
+ * Remote results carry absolute `path`/sub-result URLs on that site. */
19
+ sourceLabel?: string;
16
20
  }
17
21
  /** Default (empty-query) results — computed lazily because the
18
22
  * `searchIndex` singleton is only populated once the consuming site's
@@ -1,7 +1,9 @@
1
1
  import { searchIndex, search } from "./search-index.js";
2
2
  import { pages } from "./navigation.js";
3
+ import { siteConfig } from "../site-config.js";
3
4
  let pagefind = null;
4
5
  let initPromise = null;
6
+ const mergedOriginLabels = /* @__PURE__ */ new Map();
5
7
  async function getPagefind() {
6
8
  if (pagefind) return pagefind;
7
9
  if (initPromise) return initPromise;
@@ -13,6 +15,14 @@ async function getPagefind() {
13
15
  path
14
16
  );
15
17
  await pf.options({ excerptLength: 200 });
18
+ for (const idx of siteConfig.search?.mergeIndexes ?? []) {
19
+ try {
20
+ const origin = new URL(idx.url).origin;
21
+ await pf.mergeIndex(idx.url, { baseUrl: `${origin}/` });
22
+ mergedOriginLabels.set(origin, idx.label);
23
+ } catch {
24
+ }
25
+ }
16
26
  pagefind = pf;
17
27
  return pf;
18
28
  } catch {
@@ -21,6 +31,14 @@ async function getPagefind() {
21
31
  })();
22
32
  return initPromise;
23
33
  }
34
+ function sourceLabelFor(url) {
35
+ if (!/^https?:\/\//.test(url)) return void 0;
36
+ try {
37
+ return mergedOriginLabels.get(new URL(url).origin);
38
+ } catch {
39
+ return void 0;
40
+ }
41
+ }
24
42
  const STOP_WORDS = /* @__PURE__ */ new Set([
25
43
  "a",
26
44
  "an",
@@ -189,7 +207,8 @@ async function searchPages(query, limit = 30, showHidden = false) {
189
207
  snippet: r.excerpt?.replace(/<[^>]+>/g, "").trim() ?? "",
190
208
  excerptHtml: r.excerpt ?? "",
191
209
  subResults,
192
- score
210
+ score,
211
+ sourceLabel: sourceLabelFor(path)
193
212
  };
194
213
  });
195
214
  mapped.sort((a, b) => b.score - a.score);
@@ -0,0 +1,10 @@
1
+ /** Start one visible search preview; return the React effect cleanup. */
2
+ export declare function loadSearchPreview({ open, path, singleColumn, cache, onPreview, extract, request, }: {
3
+ open: boolean;
4
+ path?: string;
5
+ singleColumn: boolean;
6
+ cache: Map<string, string>;
7
+ onPreview: (html: string) => void;
8
+ extract: (html: string) => string;
9
+ request?: typeof fetch;
10
+ }): (() => void) | undefined;
@@ -0,0 +1,33 @@
1
+ function loadSearchPreview({
2
+ open,
3
+ path,
4
+ singleColumn,
5
+ cache,
6
+ onPreview,
7
+ extract,
8
+ request = fetch
9
+ }) {
10
+ if (!open || !path || singleColumn || /^https?:\/\//.test(path)) {
11
+ onPreview("");
12
+ return;
13
+ }
14
+ const cached = cache.get(path);
15
+ if (cached !== void 0) {
16
+ onPreview(cached);
17
+ return;
18
+ }
19
+ onPreview("");
20
+ const controller = new AbortController();
21
+ request(path, { signal: controller.signal }).then((response) => response.ok ? response.text() : "").then((html) => {
22
+ if (controller.signal.aborted) return;
23
+ const content = extract(html);
24
+ cache.set(path, content);
25
+ onPreview(content);
26
+ }).catch(() => {
27
+ if (!controller.signal.aborted) onPreview("");
28
+ });
29
+ return () => controller.abort();
30
+ }
31
+ export {
32
+ loadSearchPreview
33
+ };
@@ -13,11 +13,26 @@ export interface TabDef {
13
13
  * chips; set to `[]` to show none on this tab. */
14
14
  versionChips?: VersionChip[];
15
15
  }
16
+ /** An external-link entry in the tab strip — renders alongside the tab
17
+ * lenses with an ↗ affordance, but is a plain link out to another site
18
+ * (e.g. a sibling docs property). It scopes no pages, is never "active",
19
+ * and does not participate in `tabForUrl`. */
20
+ export interface ExternalTabDef {
21
+ label: string;
22
+ /** Absolute URL the tab links out to (e.g. "https://cli.dreamlake.ai"). */
23
+ href: string;
24
+ external: true;
25
+ }
26
+ export type TabEntry = TabDef | ExternalTabDef;
27
+ export declare function isExternalTab(tab: TabEntry): tab is ExternalTabDef;
16
28
  /** Module-level singleton, populated by `initTabs()`. Mutated in place —
17
29
  * never reassigned — so importing components keep a live reference. */
18
- export declare const TABS: TabDef[];
30
+ export declare const TABS: TabEntry[];
19
31
  /** Populate the tab singleton from the consuming site. */
20
- export declare function initTabs(tabs: TabDef[]): void;
32
+ export declare function initTabs(tabs: TabEntry[]): void;
33
+ /** The page-scoping (non-external) tabs, in order. All URL→tab logic
34
+ * runs over this list — external entries are chrome, not lenses. */
35
+ export declare function internalTabs(): TabDef[];
21
36
  /** Derive active tab from a page URL. The site root `/` resolves to the
22
37
  * first tab. Unknown prefixes also fall back to the first tab so orphan
23
38
  * pages never leave the sidebar empty. With no tabs registered, always
package/dist/lib/tabs.js CHANGED
@@ -1,8 +1,14 @@
1
+ function isExternalTab(tab) {
2
+ return tab.external === true;
3
+ }
1
4
  const TABS = [];
2
5
  function initTabs(tabs) {
3
6
  TABS.length = 0;
4
7
  TABS.push(...tabs);
5
8
  }
9
+ function internalTabs() {
10
+ return TABS.filter((t) => !isExternalTab(t));
11
+ }
6
12
  function firstSegment(url) {
7
13
  const path = url.split(/[?#]/)[0];
8
14
  const trimmed = path.replace(/^\/+/, "").replace(/\/+$/, "");
@@ -10,14 +16,15 @@ function firstSegment(url) {
10
16
  return trimmed.split("/")[0];
11
17
  }
12
18
  function tabForUrl(url) {
13
- if (TABS.length === 0) return "";
14
- if (!url) return TABS[0].id;
19
+ const tabs = internalTabs();
20
+ if (tabs.length === 0) return "";
21
+ if (!url) return tabs[0].id;
15
22
  const seg = firstSegment(url);
16
- if (!seg) return TABS[0].id;
17
- for (const tab of TABS) {
23
+ if (!seg) return tabs[0].id;
24
+ for (const tab of tabs) {
18
25
  if (tab.urlPrefix === `/${seg}`) return tab.id;
19
26
  }
20
- return TABS[0].id;
27
+ return tabs[0].id;
21
28
  }
22
29
  function urlInTab(url, tabId) {
23
30
  return tabForUrl(url) === tabId;
@@ -25,6 +32,8 @@ function urlInTab(url, tabId) {
25
32
  export {
26
33
  TABS,
27
34
  initTabs,
35
+ internalTabs,
36
+ isExternalTab,
28
37
  tabForUrl,
29
38
  urlInTab
30
39
  };
@@ -0,0 +1,22 @@
1
+ /** State and resolution helpers shared by independently scoped version badges. */
2
+ export interface VersionEntry {
3
+ version: string;
4
+ url: string;
5
+ date?: string;
6
+ }
7
+ export interface VersionManifest {
8
+ current?: string;
9
+ stable?: string;
10
+ aliases?: Record<string, string>;
11
+ versions?: VersionEntry[];
12
+ }
13
+ export interface BadgeManifestState {
14
+ scope: string;
15
+ manifest: VersionManifest;
16
+ }
17
+ export declare function badgeScope(label: string, version: string, dropdown: boolean, manifestUrl: string): string;
18
+ /** Never render a previous tab's manifest, even before effects have run. */
19
+ export declare function manifestForScope(state: BadgeManifestState | null, scope: string): VersionManifest | null;
20
+ export declare function resolveBadgeVersion(version: string, dropdown: boolean, hostname: string, manifest: VersionManifest | null, manifestUrl?: string): string;
21
+ /** Production first, with a same-origin fallback; local development reverses it. */
22
+ export declare function loadVersionManifest(manifestUrl: string, origin: string, hostname: string, request?: typeof fetch): Promise<VersionManifest | null>;
@@ -0,0 +1,44 @@
1
+ function badgeScope(label, version, dropdown, manifestUrl) {
2
+ return JSON.stringify([label, version, dropdown, manifestUrl]);
3
+ }
4
+ function manifestForScope(state, scope) {
5
+ return state?.scope === scope ? state.manifest : null;
6
+ }
7
+ function resolveBadgeVersion(version, dropdown, hostname, manifest, manifestUrl = "/versions.json") {
8
+ if (!dropdown) return version;
9
+ const match = hostname.match(/^v(\d+(?:-\d+)+(?:[a-z]+\d+)?)\./i);
10
+ if (match) {
11
+ const fromHost = match[1].replace(/-/g, ".");
12
+ if (manifestUrl === "/versions.json" || manifest?.versions?.some((entry) => entry.version === fromHost)) {
13
+ return fromHost;
14
+ }
15
+ }
16
+ if (hostname.startsWith("stable.") && manifest?.stable) return manifest.stable;
17
+ return manifest?.current || version;
18
+ }
19
+ async function loadVersionManifest(manifestUrl, origin, hostname, request = fetch) {
20
+ const read = async (url) => {
21
+ try {
22
+ const response = await request(url);
23
+ return response.ok ? await response.json() : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ };
28
+ if (/^(?:https?:)?\/\//.test(manifestUrl)) return read(manifestUrl);
29
+ const localUrl = `/${manifestUrl.replace(/^\/+/, "")}`;
30
+ const productionUrl = origin ? `${origin.replace(/\/+$/, "")}${localUrl}` : localUrl;
31
+ const local = /^(localhost|127\.|0\.0\.0\.0)/.test(hostname);
32
+ const urls = [...new Set(local ? [localUrl, productionUrl] : [productionUrl, localUrl])];
33
+ for (const url of urls) {
34
+ const manifest = await read(url);
35
+ if (manifest) return manifest;
36
+ }
37
+ return null;
38
+ }
39
+ export {
40
+ badgeScope,
41
+ loadVersionManifest,
42
+ manifestForScope,
43
+ resolveBadgeVersion
44
+ };
@@ -138,7 +138,7 @@ function Layout({ children }) {
138
138
  /* @__PURE__ */ jsxs(
139
139
  "div",
140
140
  {
141
- className: isFullscreen ? "grid items-start mx-auto grid-cols-[minmax(0,1fr)] md:grid-cols-[240px_minmax(0,1fr)]" : "grid items-start mx-auto min-h-screen grid-cols-[minmax(0,1fr)] md:grid-cols-[240px_minmax(0,1fr)] lg:grid-cols-[240px_minmax(0,1fr)_240px]",
141
+ className: isFullscreen ? "grid items-start mx-auto grid-cols-[minmax(0,1fr)] md:grid-cols-[var(--doc-sidebar-width,280px)_minmax(0,1fr)]" : "grid items-start mx-auto min-h-screen grid-cols-[minmax(0,1fr)] md:grid-cols-[var(--doc-sidebar-width,280px)_minmax(0,1fr)] lg:grid-cols-[var(--doc-sidebar-width,280px)_minmax(0,1fr)_var(--doc-toc-width,280px)]",
142
142
  style: isFullscreen ? { height: "calc(100vh - 40px)" } : { maxWidth: 1320 },
143
143
  children: [
144
144
  /* @__PURE__ */ jsx(Sidebar, {}),
@@ -8,7 +8,7 @@ async function onRenderClient(pageContext) {
8
8
  const container = document.getElementById("root");
9
9
  const tree = /* @__PURE__ */ jsx(PageContextProvider, { pageContext, children: /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsx(Page, { ...pageProps }) }) });
10
10
  if (!root) {
11
- if (container.innerHTML !== "") {
11
+ if (pageContext.isHydration && container.innerHTML !== "") {
12
12
  root = ReactDOM.hydrateRoot(container, tree);
13
13
  } else {
14
14
  root = ReactDOM.createRoot(container);
@@ -16,6 +16,28 @@ export interface VersionChip {
16
16
  label: string;
17
17
  version: string;
18
18
  dropdown?: boolean;
19
+ /** Version prefix; defaults to 'v'. Use '' for commit previews. */
20
+ prefix?: string;
21
+ /** Product-specific manifest path or URL; defaults to '/versions.json'. */
22
+ manifestUrl?: string;
23
+ }
24
+ /** One remote Pagefind index merged into the site's own search via
25
+ * `pagefind.mergeIndex()` — Pagefind's first-class multisite API. The
26
+ * remote site must serve its `/pagefind/` bundle with CORS
27
+ * (`Access-Control-Allow-Origin`) and a compatible index format (same
28
+ * Pagefind 1.x major). An index that fails to load degrades silently to
29
+ * local-only search. */
30
+ export interface MergeIndexDef {
31
+ /** Absolute URL of the remote bundle directory, e.g.
32
+ * "https://cli.dreamlake.ai/pagefind/". */
33
+ url: string;
34
+ /** Badge label shown on results from this index (e.g. "CLI"). */
35
+ label: string;
36
+ }
37
+ /** Search-layer options (runtime Pagefind behavior). */
38
+ export interface SearchConfig {
39
+ /** Remote Pagefind indexes to merge into the palette's results. */
40
+ mergeIndexes?: MergeIndexDef[];
19
41
  }
20
42
  export interface SiteConfig {
21
43
  /** Brand name in the top-left of the shell (e.g. "DreamLake"). */
@@ -72,6 +94,10 @@ export interface SiteConfig {
72
94
  * — http(s), protocol-relative, or absolute-path; anything else is
73
95
  * silently dropped. */
74
96
  headScripts?: string[];
97
+ /** Runtime search options — e.g. remote Pagefind indexes to merge into
98
+ * the palette (`search.mergeIndexes`). Also settable via the top-level
99
+ * `search` key of `initDocs()`. */
100
+ search?: SearchConfig;
75
101
  /** Search-engine visibility. DEFAULT FALSE per org policy: every page
76
102
  * gets `<meta name="robots" content="noindex, nofollow">` unless a site
77
103
  * explicitly opts in with `indexable: true`. Page-level `noindex`
package/dist/styles.css CHANGED
@@ -308,3 +308,10 @@
308
308
  from { opacity: 0; transform: scale(0.6) rotate(-90deg); }
309
309
  to { opacity: 1; transform: scale(1) rotate(0deg); }
310
310
  }
311
+
312
+ /* Stable, narrow scrollbar space keeps section labels from reflowing on overflow. */
313
+ .doc-sidebar { min-width: 0; scrollbar-width: thin; scrollbar-gutter: stable; }
314
+ .doc-sidebar::-webkit-scrollbar { width: 6px; }
315
+ .doc-sidebar::-webkit-scrollbar-thumb { background: var(--color-doc-template-faint-strong); border-radius: 3px; }
316
+ .doc-sidebar .sidebar-section > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
317
+ .doc-sidebar .sidebar-section > span:last-child { flex-shrink: 0; white-space: nowrap; }
package/dist/vike.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  declare const _default: {
8
8
  passToClient: string[];
9
9
  clientRouting: boolean;
10
+ hydrationCanBeAborted: boolean;
10
11
  prerender: boolean;
11
12
  };
12
13
  export default _default;
package/dist/vike.js CHANGED
@@ -1,6 +1,8 @@
1
1
  const vike = {
2
2
  passToClient: ["pageProps", "urlPathname"],
3
3
  clientRouting: true,
4
+ // React can supersede hydration when a reader follows a link immediately.
5
+ hydrationCanBeAborted: true,
4
6
  prerender: true
5
7
  };
6
8
  export {
package/dist/vite.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mdxFrontmatterLoader } from "./lib/mdx-frontmatter-loader.js";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import react from "@vitejs/plugin-react";
3
4
  import vike from "vike/plugin";
@@ -31,6 +32,7 @@ function dockit() {
31
32
  return [
32
33
  tailwindcss(),
33
34
  mdxRawLoader(),
35
+ mdxFrontmatterLoader(),
34
36
  {
35
37
  enforce: "pre",
36
38
  ...mdx({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreamlake/dockit",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "The DreamLake documentation shell — Vike + React + Tailwind v4 + MDX docs framework with sidebar, TOC, search, and theming",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,7 +60,8 @@
60
60
  "remark-frontmatter": "^5.0.0",
61
61
  "remark-gfm": "^4.0.0",
62
62
  "remark-mdx-frontmatter": "^5.2.0",
63
- "shiki": "^1.24.0"
63
+ "shiki": "^1.24.0",
64
+ "yaml": "^2.9.0"
64
65
  },
65
66
  "devDependencies": {
66
67
  "@mdx-js/react": "^3.1.0",
@@ -78,6 +79,7 @@
78
79
  },
79
80
  "scripts": {
80
81
  "build": "vite build",
81
- "typecheck": "tsc --noEmit"
82
+ "typecheck": "tsc --noEmit",
83
+ "test": "node --test tests/*.test.mjs"
82
84
  }
83
85
  }