@dreamlake/dockit 0.2.29 → 0.2.31

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.
@@ -2,6 +2,8 @@ import { jsxs, Fragment, jsx } from "react/jsx-runtime";
2
2
  import { useContext, useRef, useState } from "react";
3
3
  import { CodeTabsContext } from "./code-tabs-context.js";
4
4
  import { useLineNumbers } from "../lib/use-line-numbers.js";
5
+ import { copyWithGlyphBurst } from "../lib/glyph-burst.js";
6
+ const BURST = { color: "var(--color-doc-template-ink)" };
5
7
  const headerBtnStyle = (active) => ({
6
8
  display: "inline-flex",
7
9
  alignItems: "center",
@@ -25,17 +27,14 @@ function CodeBlock({ children, filename, lang }) {
25
27
  const [copied, setCopied] = useState(false);
26
28
  const [wrap, setWrap] = useState(false);
27
29
  const [showLines, setShowLines] = useLineNumbers();
28
- function copy() {
30
+ function copy(button) {
29
31
  const el = wrapRef.current?.querySelector("pre code");
30
32
  if (!el) return;
31
- navigator.clipboard.writeText(el.innerText).then(
32
- () => {
33
- setCopied(true);
34
- setTimeout(() => setCopied(false), 1400);
35
- },
36
- () => {
37
- }
38
- );
33
+ void copyWithGlyphBurst(button, el.innerText, BURST).then((ok) => {
34
+ if (!ok) return;
35
+ setCopied(true);
36
+ setTimeout(() => setCopied(false), 1400);
37
+ });
39
38
  }
40
39
  return /* @__PURE__ */ jsxs(Fragment, { children: [
41
40
  embedded && (filename || lang) && /* @__PURE__ */ jsx("span", { className: "dockit-code-tabs-label", title: filename || lang, children: filename || lang }),
@@ -115,7 +114,7 @@ function CodeBlock({ children, filename, lang }) {
115
114
  children: "Wrap"
116
115
  }
117
116
  ),
118
- /* @__PURE__ */ jsx("button", { type: "button", onClick: copy, style: headerBtnStyle(copied), children: copied ? "Copied" : "Copy" })
117
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: (e) => copy(e.currentTarget), style: headerBtnStyle(copied), children: copied ? "Copied" : "Copy" })
119
118
  ]
120
119
  }
121
120
  )
@@ -181,7 +180,7 @@ function CodeBlock({ children, filename, lang }) {
181
180
  children: "Wrap"
182
181
  }
183
182
  ),
184
- /* @__PURE__ */ jsx("button", { type: "button", onClick: copy, style: headerBtnStyle(copied), children: copied ? "Copied" : "Copy" })
183
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: (e) => copy(e.currentTarget), style: headerBtnStyle(copied), children: copied ? "Copied" : "Copy" })
185
184
  ]
186
185
  }
187
186
  ),
@@ -8,6 +8,13 @@ export declare function CodeExample({ children }: CodeExampleProps): import("rea
8
8
  export interface CodeTabsProps {
9
9
  children: ReactNode;
10
10
  'aria-label'?: string;
11
+ /**
12
+ * Opt-in sync group. Every CodeTabs with the same `syncKey` shows the same
13
+ * tab label, on this page and others; the choice persists in localStorage
14
+ * (`doc:code-tabs:<syncKey>`) and follows other browser tabs. A group that
15
+ * lacks the chosen label keeps its own selection.
16
+ */
17
+ syncKey?: string;
11
18
  }
12
19
  /** UIKit owns the frame and keyboard navigation; Dockit owns fenced examples. */
13
- export declare function CodeTabs({ children, 'aria-label': label }: CodeTabsProps): import("react").JSX.Element | null;
20
+ export declare function CodeTabs({ children, syncKey, 'aria-label': label }: CodeTabsProps): import("react").JSX.Element | null;
@@ -1,23 +1,35 @@
1
1
  import { jsx, Fragment } from "react/jsx-runtime";
2
- import { Children, isValidElement } from "react";
2
+ import { Children, isValidElement, useState } from "react";
3
3
  import { TabbedContainer } from "../vendor/@dreamlake/uikit/src/components/TabbedContainer/TabbedContainer.js";
4
4
  import { CodeTabsContext } from "./code-tabs-context.js";
5
+ import { useCodeTabsChoice, resolveCodeTab } from "../lib/code-tabs-sync.js";
5
6
  function CodeExample({ children }) {
6
7
  return /* @__PURE__ */ jsx(Fragment, { children });
7
8
  }
8
- function CodeTabs({ children, "aria-label": label = "Code examples" }) {
9
+ function CodeTabs({ children, syncKey, "aria-label": label = "Code examples" }) {
9
10
  const examples = Children.toArray(children).filter(isValidElement);
10
- if (!examples.length) return null;
11
- return /* @__PURE__ */ jsx("div", { "data-code-tabs": true, children: /* @__PURE__ */ jsx(CodeTabsContext.Provider, { value: true, children: /* @__PURE__ */ jsx(
11
+ const items = examples.map((example, index) => ({
12
+ value: String(example.key ?? index),
13
+ label: example.props.label,
14
+ children: example.props.children
15
+ }));
16
+ const [choice, choose] = useCodeTabsChoice(syncKey);
17
+ const [own, setOwn] = useState(void 0);
18
+ const value = resolveCodeTab(items, choice, own);
19
+ if (value !== own && choice !== null && value !== void 0) setOwn(value);
20
+ if (!items.length) return null;
21
+ return /* @__PURE__ */ jsx("div", { "data-code-tabs": true, "data-code-tabs-sync": syncKey, children: /* @__PURE__ */ jsx(CodeTabsContext.Provider, { value: true, children: /* @__PURE__ */ jsx(
12
22
  TabbedContainer,
13
23
  {
14
24
  "aria-label": label,
15
25
  className: "dockit-code-tabs",
16
- items: examples.map((example, index) => ({
17
- value: String(example.key ?? index),
18
- label: example.props.label,
19
- children: example.props.children
20
- }))
26
+ items,
27
+ value,
28
+ onValueChange: (next) => {
29
+ setOwn(next);
30
+ const picked = items.find((item) => item.value === next);
31
+ if (picked) choose(picked.label);
32
+ }
21
33
  }
22
34
  ) }) });
23
35
  }
@@ -1,6 +1,7 @@
1
1
  import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from "react";
3
3
  import { useLineNumbers } from "../lib/use-line-numbers.js";
4
+ import { copyWithGlyphBurst } from "../lib/glyph-burst.js";
4
5
  function langFromFilename(name) {
5
6
  if (!name) return "tsx";
6
7
  const ext = name.split(".").pop()?.toLowerCase();
@@ -175,15 +176,12 @@ function Preview({
175
176
  window.addEventListener("keydown", onKey);
176
177
  return () => window.removeEventListener("keydown", onKey);
177
178
  }, [fullscreen]);
178
- function copy() {
179
- navigator.clipboard.writeText(visibleSource).then(
180
- () => {
181
- setCopied(true);
182
- setTimeout(() => setCopied(false), 1400);
183
- },
184
- () => {
185
- }
186
- );
179
+ function copy(button) {
180
+ void copyWithGlyphBurst(button, visibleSource, { color: "var(--color-doc-template-ink)" }).then((ok) => {
181
+ if (!ok) return;
182
+ setCopied(true);
183
+ setTimeout(() => setCopied(false), 1400);
184
+ });
187
185
  }
188
186
  return /* @__PURE__ */ jsxs(
189
187
  "div",
@@ -264,7 +262,7 @@ function Preview({
264
262
  "button",
265
263
  {
266
264
  type: "button",
267
- onClick: copy,
265
+ onClick: (e) => copy(e.currentTarget),
268
266
  "aria-label": "Copy source",
269
267
  style: headerBtnStyle(copied),
270
268
  children: copied ? "Copied" : "Copy"
@@ -3,6 +3,7 @@ import { useState, useRef, useEffect, useMemo } from "react";
3
3
  import { usePageContext } from "vike-react/usePageContext";
4
4
  import { normalizePath, findPage } from "../lib/navigation.js";
5
5
  import { siteConfig } from "../site-config.js";
6
+ import { useHiddenToggle } from "../lib/use-hidden-toggle.js";
6
7
  const TOC_KEY_PREFIX = "toc-collapsed:";
7
8
  const X0 = 4;
8
9
  const X_INDENT_H3 = 12;
@@ -52,19 +53,21 @@ function scrollToHeading(e, id) {
52
53
  function TOC() {
53
54
  const { urlPathname } = usePageContext();
54
55
  const currentPath = normalizePath(urlPathname);
56
+ const [showDeveloperLinks] = useHiddenToggle();
55
57
  const mdHref = (() => {
56
58
  const meta = findPage(urlPathname);
57
59
  if (!meta || meta.noindex) return null;
58
60
  return currentPath === "/" ? "/index.md" : `${currentPath}.md`;
59
61
  })();
60
62
  const editHref = (() => {
63
+ if (!showDeveloperLinks || !siteConfig.docsRepoUrl) return null;
61
64
  const meta = findPage(urlPathname);
62
65
  if (!meta) return null;
63
66
  const slug = currentPath === "/" ? "index" : currentPath.replace(/^\//, "");
64
67
  const pagesPath = siteConfig.docsPagesPath ?? "docs/pages";
65
- return `${siteConfig.docsRepoUrl}/edit/${siteConfig.docsBranch}/${pagesPath}/${slug}/+Page.mdx`;
68
+ return `${siteConfig.docsRepoUrl}/edit/${encodeURIComponent(siteConfig.docsBranch)}/${pagesPath}/${slug}/+Page.mdx`;
66
69
  })();
67
- const issueHref = `${siteConfig.docsRepoUrl}/issues/new`;
70
+ const issueHref = showDeveloperLinks && siteConfig.docsRepoUrl ? `${siteConfig.docsRepoUrl}/issues/new` : null;
68
71
  const [headings, setHeadings] = useState([]);
69
72
  const [endPos, setEndPos] = useState(null);
70
73
  const [pathD, setPathD] = useState("");
@@ -643,7 +646,7 @@ function TOC() {
643
646
  children: "Edit this page"
644
647
  }
645
648
  ),
646
- /* @__PURE__ */ jsx(
649
+ issueHref && /* @__PURE__ */ jsx(
647
650
  "a",
648
651
  {
649
652
  href: issueHref,
@@ -38,6 +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 [showDeveloperLinks] = useHiddenToggle();
41
42
  const hasTabs = TABS.length > 0;
42
43
  const activeTab = internalTabs().find((t) => t.id === tabForUrl(urlPathname));
43
44
  const versionChips = activeTab?.versionChips ?? siteConfig.versionChips ?? [];
@@ -353,7 +354,7 @@ function Topbar({ searchOpen, onOpenSearch, onCloseSearch, query, setQuery }) {
353
354
  },
354
355
  chip.label
355
356
  )),
356
- siteConfig.gitHash && /* @__PURE__ */ jsxs(
357
+ showDeveloperLinks && siteConfig.gitHash && /* @__PURE__ */ jsxs(
357
358
  "span",
358
359
  {
359
360
  className: "hidden md:inline-flex items-center text-doc-template-muted border border-doc-template-faint",
@@ -553,7 +554,7 @@ function Topbar({ searchOpen, onOpenSearch, onCloseSearch, query, setQuery }) {
553
554
  className: "flex items-stretch gap-2.5 md:gap-3.5 pr-[14px] md:pr-[22px]",
554
555
  style: { gridColumn: 3, gridRow: 1, justifySelf: "end", ...palFade(searchOpen) },
555
556
  children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3.5", children: [
556
- siteConfig.repoUrl && /* @__PURE__ */ jsx(
557
+ showDeveloperLinks && siteConfig.repoUrl && /* @__PURE__ */ jsx(
557
558
  "a",
558
559
  {
559
560
  href: siteConfig.repoUrl,
package/dist/index.d.ts CHANGED
@@ -26,6 +26,7 @@ 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';
28
28
  export { useMediaQuery } from './lib/use-media-query';
29
+ export { glyphBurst, copyWithGlyphBurst, burstGlyphs, type GlyphBurstOptions } from './lib/glyph-burst';
29
30
  export { Callout } from './components/Callout';
30
31
  export { Chip, type ChipProps, type ChipVariant } from './components/Chip';
31
32
  export { ClientOnly } from './components/ClientOnly';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ 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";
13
13
  import { useMediaQuery } from "./lib/use-media-query.js";
14
+ import { burstGlyphs, copyWithGlyphBurst, glyphBurst } from "./lib/glyph-burst.js";
14
15
  import { Callout } from "./components/Callout.js";
15
16
  import { Chip } from "./components/Chip.js";
16
17
  import { ClientOnly } from "./components/ClientOnly.js";
@@ -66,11 +67,14 @@ export {
66
67
  ThemeToggle,
67
68
  Topbar,
68
69
  VersionBadge,
70
+ burstGlyphs,
71
+ copyWithGlyphBurst,
69
72
  defaultResults,
70
73
  defineSiteConfig,
71
74
  docKey,
72
75
  findPage,
73
76
  getAdjacentPages,
77
+ glyphBurst,
74
78
  groupedPages,
75
79
  groupedVisiblePages,
76
80
  initDocs,
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Opt-in sync groups for `<CodeTabs syncKey="…">` (Docusaurus `groupId`).
3
+ *
4
+ * Every CodeTabs sharing a `syncKey` shows the same tab *label*. The
5
+ * choice is persisted at `doc:code-tabs:<syncKey>` through the same
6
+ * `doc:` storage layer as `useLocalStorage`, so it survives navigation and
7
+ * reloads, follows other browser tabs via the native `storage` event, and
8
+ * follows other blocks on the page via the same-tab broadcast. An
9
+ * in-memory copy keeps in-page sync working when storage is blocked.
10
+ */
11
+ export declare const codeTabsStorageKey: (syncKey: string) => string;
12
+ /** Current choice for a group (client only). */
13
+ export declare function readCodeTabsChoice(syncKey: string): string | null;
14
+ /** Choose a label for every block in the group, here and in other tabs. */
15
+ export declare function writeCodeTabsChoice(syncKey: string, label: string): void;
16
+ /** Notify when the group's choice changes from any source. */
17
+ export declare function subscribeCodeTabsChoice(syncKey: string, onChange: () => void): () => void;
18
+ export interface CodeTabItem {
19
+ value: string;
20
+ label: string;
21
+ }
22
+ /**
23
+ * Which tab a block shows: the group's choice when the block has that label;
24
+ * otherwise the block keeps its own last selection, then its first tab.
25
+ */
26
+ export declare function resolveCodeTab(items: readonly CodeTabItem[], choice: string | null, own: string | undefined): string | undefined;
27
+ /**
28
+ * The group's chosen label, or `null` without a `syncKey`/choice.
29
+ *
30
+ * SSR and the hydration pass both see `null`, so markup always matches the
31
+ * server's (first-tab) HTML. A layout effect then re-renders before the
32
+ * browser paints the hydrated tree; client-side navigation reads the stored
33
+ * choice on first render, so no flash there.
34
+ */
35
+ export declare function useCodeTabsChoice(syncKey: string | undefined): [string | null, (label: string) => void];
@@ -0,0 +1,63 @@
1
+ import { useCallback, useSyncExternalStore, useReducer, useLayoutEffect } from "react";
2
+ import { subscribeDocStorage, readDocStorage, writeDocStorage } from "./use-local-storage.js";
3
+ const codeTabsStorageKey = (syncKey) => `code-tabs:${syncKey}`;
4
+ const memory = /* @__PURE__ */ new Map();
5
+ const asLabel = (value) => typeof value === "string" ? value : null;
6
+ function readCodeTabsChoice(syncKey) {
7
+ let choice = memory.get(syncKey);
8
+ if (choice === void 0) {
9
+ choice = asLabel(readDocStorage(codeTabsStorageKey(syncKey)));
10
+ memory.set(syncKey, choice);
11
+ }
12
+ return choice;
13
+ }
14
+ function writeCodeTabsChoice(syncKey, label) {
15
+ memory.set(syncKey, label);
16
+ writeDocStorage(codeTabsStorageKey(syncKey), label);
17
+ }
18
+ function subscribeCodeTabsChoice(syncKey, onChange) {
19
+ return subscribeDocStorage(codeTabsStorageKey(syncKey), (change) => {
20
+ if (change) memory.set(syncKey, asLabel(change.value));
21
+ else memory.delete(syncKey);
22
+ onChange();
23
+ });
24
+ }
25
+ function resolveCodeTab(items, choice, own) {
26
+ const chosen = choice === null ? void 0 : items.find((item) => item.label === choice);
27
+ if (chosen) return chosen.value;
28
+ return items.some((item) => item.value === own) ? own : items[0]?.value;
29
+ }
30
+ const noop = () => () => {
31
+ };
32
+ const serverSnapshot = () => null;
33
+ function useCodeTabsChoice(syncKey) {
34
+ const subscribe = useCallback(
35
+ (onChange) => syncKey ? subscribeCodeTabsChoice(syncKey, onChange) : () => {
36
+ },
37
+ [syncKey]
38
+ );
39
+ const choice = useSyncExternalStore(
40
+ syncKey ? subscribe : noop,
41
+ () => syncKey ? readCodeTabsChoice(syncKey) : null,
42
+ serverSnapshot
43
+ );
44
+ const [, rerender] = useReducer((n) => n + 1, 0);
45
+ useLayoutEffect(() => {
46
+ if (syncKey && readCodeTabsChoice(syncKey) !== choice) rerender();
47
+ }, [syncKey]);
48
+ const choose = useCallback(
49
+ (label) => {
50
+ if (syncKey) writeCodeTabsChoice(syncKey, label);
51
+ },
52
+ [syncKey]
53
+ );
54
+ return [choice, choose];
55
+ }
56
+ export {
57
+ codeTabsStorageKey,
58
+ readCodeTabsChoice,
59
+ resolveCodeTab,
60
+ subscribeCodeTabsChoice,
61
+ useCodeTabsChoice,
62
+ writeCodeTabsChoice
63
+ };
@@ -0,0 +1,3 @@
1
+ import { Plugin } from 'vite';
2
+ /** Encode shell metadata before bundling; never include original-source maps. */
3
+ export declare function devMetadataPlugin(): Plugin;
@@ -0,0 +1,81 @@
1
+ import { transformWithEsbuild } from "vite";
2
+ const fields = /* @__PURE__ */ new Set(["repoUrl", "docsRepoUrl", "docsBranch", "docsPagesPath", "gitHash"]);
3
+ function staticString(node) {
4
+ if (node.type === "Literal" && typeof node.value === "string") return node.value;
5
+ if (node.type === "TemplateLiteral") {
6
+ const parts = node.quasis;
7
+ const expressions = node.expressions;
8
+ let value = parts[0].value.cooked;
9
+ for (let i = 0; i < expressions.length; i++) {
10
+ const part = staticString(expressions[i]);
11
+ if (part === null) return null;
12
+ value += part + parts[i + 1].value.cooked;
13
+ }
14
+ return value;
15
+ }
16
+ return null;
17
+ }
18
+ function devMetadataPlugin() {
19
+ let define = {};
20
+ return {
21
+ name: "dockit-dev-metadata",
22
+ enforce: "pre",
23
+ apply: "build",
24
+ configResolved(config) {
25
+ define = Object.fromEntries(Object.entries(config.define ?? {}).map(([key, value]) => [
26
+ key,
27
+ typeof value === "string" ? value : JSON.stringify(value)
28
+ ]));
29
+ if (config.build.sourcemap) {
30
+ throw new Error("Dockit developer metadata requires build.sourcemap: false");
31
+ }
32
+ },
33
+ async transform(source, id) {
34
+ if (!/(?:^|\/)site\.config\.[cm]?[jt]s$/.test(id)) return null;
35
+ const compiled = await transformWithEsbuild(source, id, {
36
+ loader: "ts",
37
+ target: "esnext",
38
+ define,
39
+ sourcemap: false,
40
+ legalComments: "none"
41
+ });
42
+ const edits = [];
43
+ const visit = (node) => {
44
+ if (node.type === "Property") {
45
+ const key = node.key;
46
+ const name = key.type === "Identifier" && !node.computed ? key.name : key.value;
47
+ if (typeof name === "string" && fields.has(name)) {
48
+ const value = node.value;
49
+ const text = staticString(value);
50
+ if (text === null) {
51
+ this.error(`Dockit ${name} must be a string literal or a build-time define in ${id}`);
52
+ }
53
+ const bytes = Array.from({ length: text.length }, (_, i) => text.charCodeAt(i) ^ 90);
54
+ edits.push({
55
+ start: value.start,
56
+ end: value.end,
57
+ text: `[${bytes.join(",")}].map(n=>String.fromCharCode(n^90)).join("")`
58
+ });
59
+ return;
60
+ }
61
+ }
62
+ for (const value of Object.values(node)) {
63
+ if (Array.isArray(value)) {
64
+ for (const item of value) if (item?.type) visit(item);
65
+ } else if (value && typeof value === "object" && "type" in value) {
66
+ visit(value);
67
+ }
68
+ }
69
+ };
70
+ visit(this.parse(compiled.code));
71
+ let code = compiled.code;
72
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
73
+ code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
74
+ }
75
+ return { code, map: { mappings: "" } };
76
+ }
77
+ };
78
+ }
79
+ export {
80
+ devMetadataPlugin
81
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Glyph burst — the copy-button flourish. After a successful copy, a handful
3
+ * of the copied code's own punctuation (`{ } ( ) ; = …`) pops out of the
4
+ * button in its ink and mono face, arcs under gravity, and fades.
5
+ *
6
+ * Everything happens after the clipboard write resolves, inside one fixed,
7
+ * pointer-transparent overlay that removes itself; only transform and
8
+ * opacity animate, so nothing on the page reflows. Reduced motion, no
9
+ * matchMedia, or no Web Animations API → no burst, plain feedback only.
10
+ *
11
+ * Kept dependency-free on purpose; the DreamLake app ships a twin of this
12
+ * file (lib/notes/glyphBurst.ts) so both copy buttons feel the same.
13
+ */
14
+ /** Up to `count` distinct symbols from `source`, padded with code punctuation. */
15
+ export declare function burstGlyphs(source: string, count?: number): string[];
16
+ export interface GlyphBurstOptions {
17
+ /** CSS font-family for the glyphs; defaults to the origin's computed font. */
18
+ font?: string;
19
+ /** CSS color for the glyphs; defaults to the origin's computed color. */
20
+ color?: string;
21
+ }
22
+ /** Fire-and-forget. Never throws; does nothing under reduced motion. */
23
+ export declare function glyphBurst(origin: Element, source: string, options?: GlyphBurstOptions): void;
24
+ /**
25
+ * Write `text` to the clipboard, then (only on success) burst from `origin`.
26
+ * Resolves true when the copy happened; the burst never delays or fails it.
27
+ */
28
+ export declare function copyWithGlyphBurst(origin: Element, text: string, options?: GlyphBurstOptions): Promise<boolean>;
@@ -0,0 +1,111 @@
1
+ const FALLBACK = "{}();=<>/*[]#:";
2
+ const SYMBOL = /[^\p{L}\p{N}\s_]/u;
3
+ const COUNT = 10;
4
+ function burstGlyphs(source, count = COUNT) {
5
+ const glyphs = [];
6
+ for (const ch of source) {
7
+ if (glyphs.length >= count) break;
8
+ if (SYMBOL.test(ch) && !glyphs.includes(ch)) glyphs.push(ch);
9
+ }
10
+ for (const ch of FALLBACK) {
11
+ if (glyphs.length >= count) break;
12
+ if (!glyphs.includes(ch)) glyphs.push(ch);
13
+ }
14
+ return glyphs;
15
+ }
16
+ function motionAllowed() {
17
+ if (typeof window === "undefined" || typeof document === "undefined")
18
+ return false;
19
+ if (typeof window.matchMedia !== "function") return false;
20
+ return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
21
+ }
22
+ function glyphBurst(origin, source, options = {}) {
23
+ try {
24
+ if (!motionAllowed()) return;
25
+ const rect = origin.getBoundingClientRect();
26
+ if (!rect.width && !rect.height) return;
27
+ const computed = window.getComputedStyle(origin);
28
+ const font = options.font ?? computed.fontFamily;
29
+ const color = options.color ?? computed.color;
30
+ const x = rect.left + rect.width / 2;
31
+ const y = rect.top + rect.height / 2;
32
+ const layer = document.createElement("div");
33
+ layer.setAttribute("aria-hidden", "true");
34
+ layer.setAttribute("data-glyph-burst", "");
35
+ Object.assign(layer.style, {
36
+ position: "fixed",
37
+ inset: "0",
38
+ pointerEvents: "none",
39
+ overflow: "hidden",
40
+ contain: "strict",
41
+ zIndex: "2147483647"
42
+ });
43
+ const glyphs = burstGlyphs(source);
44
+ const animations = [];
45
+ glyphs.forEach((glyph, i) => {
46
+ const span = document.createElement("span");
47
+ span.textContent = glyph;
48
+ Object.assign(span.style, {
49
+ position: "absolute",
50
+ left: `${x}px`,
51
+ top: `${y}px`,
52
+ font: `500 12px/1 ${font}`,
53
+ color,
54
+ whiteSpace: "pre",
55
+ willChange: "transform, opacity"
56
+ });
57
+ layer.append(span);
58
+ if (typeof span.animate !== "function") return;
59
+ const angle = (-150 + 120 * (i + 0.2 + Math.random() * 0.6) / glyphs.length) * Math.PI / 180;
60
+ const speed = 170 + Math.random() * 60;
61
+ const vx = Math.cos(angle) * speed * 0.6;
62
+ const vy = Math.sin(angle) * speed;
63
+ const gravity = 440;
64
+ const spin = (Math.random() - 0.5) * 300;
65
+ const frames = [];
66
+ for (let step = 0; step <= 8; step++) {
67
+ const t = step / 8;
68
+ const dx = vx * t;
69
+ const dy = vy * t + gravity * t * t / 2;
70
+ frames.push({
71
+ offset: t,
72
+ opacity: t < 0.6 ? 0.9 : 0.9 * (1 - (t - 0.6) / 0.4),
73
+ transform: `translate(-50%, -50%) translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px) rotate(${(spin * t).toFixed(1)}deg) scale(${t === 0 ? 0.5 : 1})`
74
+ });
75
+ }
76
+ animations.push(
77
+ span.animate(frames, {
78
+ duration: 760 + Math.random() * 220,
79
+ delay: i * 12,
80
+ easing: "cubic-bezier(.3,.55,.55,1)",
81
+ fill: "both"
82
+ })
83
+ );
84
+ });
85
+ if (!animations.length) return;
86
+ document.body.append(layer);
87
+ let removed = false;
88
+ const remove = () => {
89
+ if (removed) return;
90
+ removed = true;
91
+ layer.remove();
92
+ };
93
+ Promise.all(animations.map((a) => a.finished)).then(remove, remove);
94
+ setTimeout(remove, 1600);
95
+ } catch {
96
+ }
97
+ }
98
+ async function copyWithGlyphBurst(origin, text, options) {
99
+ try {
100
+ await navigator.clipboard.writeText(text);
101
+ } catch {
102
+ return false;
103
+ }
104
+ glyphBurst(origin, text, options);
105
+ return true;
106
+ }
107
+ export {
108
+ burstGlyphs,
109
+ copyWithGlyphBurst,
110
+ glyphBurst
111
+ };
@@ -1,5 +1,19 @@
1
1
  export declare const DOC_PREFIX = "doc:";
2
2
  export declare const docKey: (k: string) => string;
3
+ /** Read and JSON-decode a `doc:` key. Missing, malformed, or blocked
4
+ * storage (privacy mode, sandboxed iframe, SSR) all return `undefined`. */
5
+ export declare function readDocStorage<T>(key: string): T | undefined;
6
+ /** JSON-encode a value into a `doc:` key and notify same-tab subscribers.
7
+ * The broadcast fires even when persistence fails, so live in-page sync
8
+ * survives disabled storage. */
9
+ export declare function writeDocStorage<T>(key: string, value: T): void;
10
+ /** Subscribe to changes of a `doc:` key from this tab (custom event) and
11
+ * other tabs (native `storage` event; `key === null` means storage was
12
+ * cleared). Same-tab writes pass `{ value }`, so subscribers stay in sync
13
+ * even when storage is blocked; cross-tab changes pass `null` (re-read). */
14
+ export declare function subscribeDocStorage(key: string, onChange: (sameTab: {
15
+ value: unknown;
16
+ } | null) => void): () => void;
3
17
  /**
4
18
  * SSR-safe localStorage-backed state. Returns the in-memory React
5
19
  * value and a setter that mirrors the value into localStorage and
@@ -2,50 +2,62 @@ import { useState, useRef, useEffect, useCallback } from "react";
2
2
  const DOC_PREFIX = "doc:";
3
3
  const docKey = (k) => k.startsWith(DOC_PREFIX) ? k : DOC_PREFIX + k;
4
4
  const SAME_TAB_EVENT = "doc:storage";
5
+ function readDocStorage(key) {
6
+ if (typeof window === "undefined") return void 0;
7
+ const fullKey = docKey(key);
8
+ try {
9
+ const raw = localStorage.getItem(fullKey);
10
+ return raw === null ? void 0 : JSON.parse(raw);
11
+ } catch (err) {
12
+ return void 0;
13
+ }
14
+ }
15
+ function writeDocStorage(key, value) {
16
+ if (typeof window === "undefined") return;
17
+ const fullKey = docKey(key);
18
+ try {
19
+ localStorage.setItem(fullKey, JSON.stringify(value));
20
+ } catch {
21
+ }
22
+ window.dispatchEvent(
23
+ new CustomEvent(SAME_TAB_EVENT, { detail: { key: fullKey, value } })
24
+ );
25
+ }
26
+ function subscribeDocStorage(key, onChange) {
27
+ if (typeof window === "undefined") return () => {
28
+ };
29
+ const fullKey = docKey(key);
30
+ const onStorage = (e) => {
31
+ if (e.key === fullKey || e.key === null) onChange(null);
32
+ };
33
+ const onSameTab = (e) => {
34
+ const detail = e.detail;
35
+ if (detail?.key === fullKey) onChange({ value: detail.value });
36
+ };
37
+ window.addEventListener("storage", onStorage);
38
+ window.addEventListener(SAME_TAB_EVENT, onSameTab);
39
+ return () => {
40
+ window.removeEventListener("storage", onStorage);
41
+ window.removeEventListener(SAME_TAB_EVENT, onSameTab);
42
+ };
43
+ }
5
44
  function useLocalStorage(key, defaultValue) {
6
45
  const fullKey = docKey(key);
7
46
  const [value, setValue] = useState(defaultValue);
8
47
  const defaultRef = useRef(defaultValue);
9
48
  defaultRef.current = defaultValue;
10
49
  useEffect(() => {
11
- if (typeof window === "undefined") return;
12
50
  const read = () => {
13
- try {
14
- const raw = localStorage.getItem(fullKey);
15
- if (raw === null) {
16
- setValue(defaultRef.current);
17
- } else {
18
- setValue(JSON.parse(raw));
19
- }
20
- } catch (err) {
21
- }
51
+ const stored = readDocStorage(fullKey);
52
+ setValue(stored === void 0 ? defaultRef.current : stored);
22
53
  };
23
54
  read();
24
- const onStorage = (e) => {
25
- if (e.key === fullKey) read();
26
- };
27
- const onSameTab = (e) => {
28
- const detail = e.detail;
29
- if (detail?.key === fullKey) read();
30
- };
31
- window.addEventListener("storage", onStorage);
32
- window.addEventListener(SAME_TAB_EVENT, onSameTab);
33
- return () => {
34
- window.removeEventListener("storage", onStorage);
35
- window.removeEventListener(SAME_TAB_EVENT, onSameTab);
36
- };
55
+ return subscribeDocStorage(fullKey, () => read());
37
56
  }, [fullKey]);
38
57
  const set = useCallback(
39
58
  (next) => {
40
59
  setValue(next);
41
- if (typeof window === "undefined") return;
42
- try {
43
- localStorage.setItem(fullKey, JSON.stringify(next));
44
- } catch {
45
- }
46
- window.dispatchEvent(
47
- new CustomEvent(SAME_TAB_EVENT, { detail: { key: fullKey } })
48
- );
60
+ writeDocStorage(fullKey, next);
49
61
  },
50
62
  [fullKey]
51
63
  );
@@ -54,5 +66,8 @@ function useLocalStorage(key, defaultValue) {
54
66
  export {
55
67
  DOC_PREFIX,
56
68
  docKey,
57
- useLocalStorage
69
+ readDocStorage,
70
+ subscribeDocStorage,
71
+ useLocalStorage,
72
+ writeDocStorage
58
73
  };
package/dist/vite.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { mdxFrontmatterLoader } from "./lib/mdx-frontmatter-loader.js";
2
+ import { devMetadataPlugin } from "./lib/dev-metadata-plugin.js";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import react from "@vitejs/plugin-react";
4
5
  import vike from "vike/plugin";
@@ -30,6 +31,7 @@ function mdxRawLoader() {
30
31
  }
31
32
  function dockit() {
32
33
  return [
34
+ devMetadataPlugin(),
33
35
  tailwindcss(),
34
36
  mdxRawLoader(),
35
37
  mdxFrontmatterLoader(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreamlake/dockit",
3
- "version": "0.2.29",
3
+ "version": "0.2.31",
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",