blodemd 0.0.5 → 0.0.6

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 (184) hide show
  1. package/dev-server/app/[[...slug]]/page.tsx +139 -0
  2. package/dev-server/app/blodemd-dev/invalidate/route.ts +12 -0
  3. package/dev-server/app/blodemd-dev/static/[...path]/route.ts +32 -0
  4. package/dev-server/app/blodemd-dev/version/route.ts +14 -0
  5. package/dev-server/app/blodemd-internal/proxy/route.ts +86 -0
  6. package/dev-server/app/error.tsx +24 -0
  7. package/dev-server/app/globals.css +4 -0
  8. package/dev-server/app/layout.tsx +38 -0
  9. package/dev-server/app/not-found.tsx +18 -0
  10. package/dev-server/app/search/route.ts +17 -0
  11. package/dev-server/components/dev-reload-script.tsx +86 -0
  12. package/dev-server/components/providers.tsx +15 -0
  13. package/dev-server/lib/dev-state.ts +8 -0
  14. package/dev-server/lib/local-content-source.ts +103 -0
  15. package/dev-server/lib/local-runtime.tsx +558 -0
  16. package/dev-server/next.config.js +46 -0
  17. package/dev-server/package.json +57 -0
  18. package/dev-server/postcss.config.mjs +7 -0
  19. package/dev-server/public/glide-variable.woff2 +0 -0
  20. package/dev-server/tsconfig.json +49 -0
  21. package/dist/cli.mjs +108 -39
  22. package/dist/cli.mjs.map +1 -1
  23. package/docs/app/globals.css +455 -0
  24. package/docs/components/api/api-playground.tsx +295 -0
  25. package/docs/components/api/api-reference.tsx +121 -0
  26. package/docs/components/content/collection-index.tsx +114 -0
  27. package/docs/components/docs/contextual-menu.tsx +406 -0
  28. package/docs/components/docs/copy-page-menu.tsx +255 -0
  29. package/docs/components/docs/doc-header.tsx +192 -0
  30. package/docs/components/docs/doc-shell.tsx +289 -0
  31. package/docs/components/docs/doc-sidebar.tsx +206 -0
  32. package/docs/components/docs/doc-toc.tsx +45 -0
  33. package/docs/components/docs/mobile-nav.tsx +207 -0
  34. package/docs/components/mdx/accordion.tsx +83 -0
  35. package/docs/components/mdx/badge.tsx +79 -0
  36. package/docs/components/mdx/callout.tsx +88 -0
  37. package/docs/components/mdx/card.tsx +104 -0
  38. package/docs/components/mdx/code-block.tsx +75 -0
  39. package/docs/components/mdx/code-group.tsx +94 -0
  40. package/docs/components/mdx/color.tsx +87 -0
  41. package/docs/components/mdx/columns.tsx +25 -0
  42. package/docs/components/mdx/expandable.tsx +45 -0
  43. package/docs/components/mdx/field-layout.tsx +77 -0
  44. package/docs/components/mdx/frame.tsx +23 -0
  45. package/docs/components/mdx/get-text-content.ts +18 -0
  46. package/docs/components/mdx/icon.tsx +56 -0
  47. package/docs/components/mdx/index.tsx +96 -0
  48. package/docs/components/mdx/installer.tsx +20 -0
  49. package/docs/components/mdx/panel.tsx +11 -0
  50. package/docs/components/mdx/param-field.tsx +56 -0
  51. package/docs/components/mdx/preview.tsx +36 -0
  52. package/docs/components/mdx/prompt.tsx +63 -0
  53. package/docs/components/mdx/request-example.tsx +27 -0
  54. package/docs/components/mdx/response-field.tsx +42 -0
  55. package/docs/components/mdx/steps.tsx +92 -0
  56. package/docs/components/mdx/tabs.tsx +88 -0
  57. package/docs/components/mdx/tile.tsx +43 -0
  58. package/docs/components/mdx/tooltip.tsx +71 -0
  59. package/docs/components/mdx/tree.tsx +120 -0
  60. package/docs/components/mdx/type-table.tsx +71 -0
  61. package/docs/components/mdx/update.tsx +44 -0
  62. package/docs/components/mdx/video.tsx +12 -0
  63. package/docs/components/mdx/view.tsx +66 -0
  64. package/docs/components/providers.tsx +15 -0
  65. package/docs/components/ui/breadcrumb.tsx +92 -0
  66. package/docs/components/ui/button.tsx +90 -0
  67. package/docs/components/ui/card.tsx +92 -0
  68. package/docs/components/ui/command.tsx +139 -0
  69. package/docs/components/ui/dialog.tsx +97 -0
  70. package/docs/components/ui/field.tsx +237 -0
  71. package/docs/components/ui/input.tsx +105 -0
  72. package/docs/components/ui/label.tsx +22 -0
  73. package/docs/components/ui/popover.tsx +72 -0
  74. package/docs/components/ui/search.tsx +380 -0
  75. package/docs/components/ui/separator.tsx +26 -0
  76. package/docs/components/ui/sheet.tsx +104 -0
  77. package/docs/components/ui/sidebar.tsx +433 -0
  78. package/docs/components/ui/theme-toggle.tsx +62 -0
  79. package/docs/components/ui/tooltip.tsx +53 -0
  80. package/docs/lib/contextual-options.ts +193 -0
  81. package/docs/lib/docs-collection.ts +22 -0
  82. package/docs/lib/mdx.ts +90 -0
  83. package/docs/lib/navigation.ts +288 -0
  84. package/docs/lib/openapi.ts +158 -0
  85. package/docs/lib/routes.ts +10 -0
  86. package/docs/lib/server-cache.ts +83 -0
  87. package/docs/lib/shiki.ts +35 -0
  88. package/docs/lib/theme.ts +29 -0
  89. package/docs/lib/toc.ts +2 -0
  90. package/docs/lib/utils.ts +5 -0
  91. package/package.json +33 -4
  92. package/packages/@repo/common/dist/index.d.ts +9 -0
  93. package/packages/@repo/common/dist/index.d.ts.map +1 -0
  94. package/packages/@repo/common/dist/index.js +42 -0
  95. package/packages/@repo/common/package.json +34 -0
  96. package/packages/@repo/common/src/common.unit.test.ts +55 -0
  97. package/packages/@repo/common/src/index.ts +51 -0
  98. package/packages/@repo/contracts/dist/api-key.d.ts +30 -0
  99. package/packages/@repo/contracts/dist/api-key.d.ts.map +1 -0
  100. package/packages/@repo/contracts/dist/api-key.js +20 -0
  101. package/packages/@repo/contracts/dist/dates.d.ts +4 -0
  102. package/packages/@repo/contracts/dist/dates.d.ts.map +1 -0
  103. package/packages/@repo/contracts/dist/dates.js +2 -0
  104. package/packages/@repo/contracts/dist/deployment.d.ts +71 -0
  105. package/packages/@repo/contracts/dist/deployment.d.ts.map +1 -0
  106. package/packages/@repo/contracts/dist/deployment.js +46 -0
  107. package/packages/@repo/contracts/dist/domain.d.ts +94 -0
  108. package/packages/@repo/contracts/dist/domain.d.ts.map +1 -0
  109. package/packages/@repo/contracts/dist/domain.js +36 -0
  110. package/packages/@repo/contracts/dist/ids.d.ts +14 -0
  111. package/packages/@repo/contracts/dist/ids.d.ts.map +1 -0
  112. package/packages/@repo/contracts/dist/ids.js +10 -0
  113. package/packages/@repo/contracts/dist/index.d.ts +10 -0
  114. package/packages/@repo/contracts/dist/index.d.ts.map +1 -0
  115. package/packages/@repo/contracts/dist/index.js +11 -0
  116. package/packages/@repo/contracts/dist/pagination.d.ts +23 -0
  117. package/packages/@repo/contracts/dist/pagination.d.ts.map +1 -0
  118. package/packages/@repo/contracts/dist/pagination.js +15 -0
  119. package/packages/@repo/contracts/dist/project.d.ts +25 -0
  120. package/packages/@repo/contracts/dist/project.d.ts.map +1 -0
  121. package/packages/@repo/contracts/dist/project.js +23 -0
  122. package/packages/@repo/contracts/dist/tenant.d.ts +99 -0
  123. package/packages/@repo/contracts/dist/tenant.d.ts.map +1 -0
  124. package/packages/@repo/contracts/dist/tenant.js +36 -0
  125. package/packages/@repo/contracts/dist/user.d.ts +9 -0
  126. package/packages/@repo/contracts/dist/user.d.ts.map +1 -0
  127. package/packages/@repo/contracts/dist/user.js +9 -0
  128. package/packages/@repo/contracts/package.json +37 -0
  129. package/packages/@repo/contracts/src/api-key.ts +27 -0
  130. package/packages/@repo/contracts/src/dates.ts +4 -0
  131. package/packages/@repo/contracts/src/deployment.ts +73 -0
  132. package/packages/@repo/contracts/src/domain.ts +51 -0
  133. package/packages/@repo/contracts/src/ids.ts +22 -0
  134. package/packages/@repo/contracts/src/index.ts +11 -0
  135. package/packages/@repo/contracts/src/pagination.ts +21 -0
  136. package/packages/@repo/contracts/src/project.ts +30 -0
  137. package/packages/@repo/contracts/src/tenant.ts +54 -0
  138. package/packages/@repo/contracts/src/user.ts +12 -0
  139. package/packages/@repo/models/dist/docs-config.d.ts +985 -0
  140. package/packages/@repo/models/dist/docs-config.d.ts.map +1 -0
  141. package/packages/@repo/models/dist/docs-config.js +548 -0
  142. package/packages/@repo/models/dist/index.d.ts +3 -0
  143. package/packages/@repo/models/dist/index.d.ts.map +1 -0
  144. package/packages/@repo/models/dist/index.js +3 -0
  145. package/packages/@repo/models/dist/tenant.d.ts +25 -0
  146. package/packages/@repo/models/dist/tenant.d.ts.map +1 -0
  147. package/packages/@repo/models/dist/tenant.js +1 -0
  148. package/packages/@repo/models/package.json +37 -0
  149. package/packages/@repo/models/src/docs-config.ts +648 -0
  150. package/packages/@repo/models/src/index.ts +3 -0
  151. package/packages/@repo/models/src/tenant.ts +29 -0
  152. package/packages/@repo/prebuild/dist/index.d.ts +2 -0
  153. package/packages/@repo/prebuild/dist/index.d.ts.map +1 -0
  154. package/packages/@repo/prebuild/dist/index.js +2 -0
  155. package/packages/@repo/prebuild/dist/openapi.d.ts +43 -0
  156. package/packages/@repo/prebuild/dist/openapi.d.ts.map +1 -0
  157. package/packages/@repo/prebuild/dist/openapi.js +58 -0
  158. package/packages/@repo/prebuild/package.json +39 -0
  159. package/packages/@repo/prebuild/src/index.ts +2 -0
  160. package/packages/@repo/prebuild/src/openapi.ts +116 -0
  161. package/packages/@repo/previewing/dist/blob-source.d.ts +16 -0
  162. package/packages/@repo/previewing/dist/blob-source.d.ts.map +1 -0
  163. package/packages/@repo/previewing/dist/blob-source.js +110 -0
  164. package/packages/@repo/previewing/dist/content-source.d.ts +12 -0
  165. package/packages/@repo/previewing/dist/content-source.d.ts.map +1 -0
  166. package/packages/@repo/previewing/dist/content-source.js +1 -0
  167. package/packages/@repo/previewing/dist/fs-source.d.ts +11 -0
  168. package/packages/@repo/previewing/dist/fs-source.d.ts.map +1 -0
  169. package/packages/@repo/previewing/dist/fs-source.js +79 -0
  170. package/packages/@repo/previewing/dist/index.d.ts +120 -0
  171. package/packages/@repo/previewing/dist/index.d.ts.map +1 -0
  172. package/packages/@repo/previewing/dist/index.js +984 -0
  173. package/packages/@repo/previewing/package.json +41 -0
  174. package/packages/@repo/previewing/src/blob-source.ts +167 -0
  175. package/packages/@repo/previewing/src/content-source.ts +12 -0
  176. package/packages/@repo/previewing/src/fs-source.ts +111 -0
  177. package/packages/@repo/previewing/src/index.ts +1490 -0
  178. package/packages/@repo/previewing/src/index.unit.test.ts +290 -0
  179. package/packages/@repo/validation/dist/index.d.ts +12 -0
  180. package/packages/@repo/validation/dist/index.d.ts.map +1 -0
  181. package/packages/@repo/validation/dist/index.js +30 -0
  182. package/packages/@repo/validation/package.json +37 -0
  183. package/packages/@repo/validation/src/index.ts +59 -0
  184. package/packages/@repo/validation/src/mintlify-docs-schema.json +5016 -0
@@ -0,0 +1,406 @@
1
+ "use client";
2
+
3
+ import type { ContextualOption } from "@repo/models";
4
+ import {
5
+ Checkmark1Icon,
6
+ ChevronDownSmallIcon,
7
+ ClaudeaiIcon,
8
+ CodeAssistantIcon,
9
+ CodeBracketsIcon,
10
+ CodeIcon,
11
+ CodeLinesIcon,
12
+ CopySimpleIcon,
13
+ GoogleColoredIcon,
14
+ GrokIcon,
15
+ MarkdownIcon,
16
+ OpenaiIcon,
17
+ PerplexityIcon,
18
+ Plugin1Icon,
19
+ SparkleIcon,
20
+ WindIcon,
21
+ } from "blode-icons-react";
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
23
+ import type { ComponentType, ReactNode, SVGProps } from "react";
24
+
25
+ import {
26
+ buildBuiltinUrl,
27
+ builtinOptions,
28
+ resolveCustomHref,
29
+ } from "@/lib/contextual-options";
30
+
31
+ type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
32
+
33
+ const iconMap: Record<string, IconComponent> = {
34
+ ClaudeaiIcon,
35
+ CodeAssistantIcon,
36
+ CodeBracketsIcon,
37
+ CodeIcon,
38
+ CodeLinesIcon,
39
+ CopySimpleIcon,
40
+ GoogleColoredIcon,
41
+ GrokIcon,
42
+ MarkdownIcon,
43
+ OpenaiIcon,
44
+ PerplexityIcon,
45
+ Plugin1Icon,
46
+ SparkleIcon,
47
+ WindIcon,
48
+ };
49
+
50
+ const getBuiltinIcon = (iconName: string): IconComponent =>
51
+ iconMap[iconName] ?? CopySimpleIcon;
52
+
53
+ type ActionId = "copy" | "mcp" | "add-mcp" | "assistant";
54
+
55
+ interface ResolvedOption {
56
+ key: string;
57
+ title: string;
58
+ description: string;
59
+ icon: IconComponent;
60
+ type: "action" | "link";
61
+ action?: ActionId;
62
+ href?: string;
63
+ }
64
+
65
+ interface ContextualContext {
66
+ pageUrl: string;
67
+ pageContent: string;
68
+ pagePath: string;
69
+ mcpServerUrl?: string;
70
+ }
71
+
72
+ const resolveOptions = (
73
+ options: ContextualOption[],
74
+ context: ContextualContext
75
+ ): ResolvedOption[] => {
76
+ const resolved: ResolvedOption[] = [];
77
+ for (const option of options) {
78
+ if (typeof option === "string") {
79
+ const definition = builtinOptions[option];
80
+ if (!definition) {
81
+ continue;
82
+ }
83
+ if (definition.type === "action") {
84
+ resolved.push({
85
+ action: option as ActionId,
86
+ description: definition.description,
87
+ icon: getBuiltinIcon(definition.iconName),
88
+ key: option,
89
+ title: definition.title,
90
+ type: "action",
91
+ });
92
+ } else {
93
+ const href = buildBuiltinUrl(option, context);
94
+ if (href) {
95
+ resolved.push({
96
+ description: definition.description,
97
+ href,
98
+ icon: getBuiltinIcon(definition.iconName),
99
+ key: option,
100
+ title: definition.title,
101
+ type: "link",
102
+ });
103
+ }
104
+ }
105
+ } else {
106
+ resolved.push({
107
+ description: option.description,
108
+ href: resolveCustomHref(option.href, context),
109
+ icon: CopySimpleIcon,
110
+ key: `custom-${option.title}`,
111
+ title: option.title,
112
+ type: "link",
113
+ });
114
+ }
115
+ }
116
+ return resolved;
117
+ };
118
+
119
+ const useContextualActions = (content: string, title: string) => {
120
+ const [copiedId, setCopiedId] = useState<string | null>(null);
121
+
122
+ const handleAction = useCallback(
123
+ async (action: string, key?: string) => {
124
+ const id = key ?? action;
125
+ switch (action) {
126
+ case "copy": {
127
+ await navigator.clipboard.writeText(`# ${title}\n\n${content}`);
128
+ setCopiedId(id);
129
+ setTimeout(() => setCopiedId(null), 2000);
130
+ break;
131
+ }
132
+ default: {
133
+ break;
134
+ }
135
+ }
136
+ },
137
+ [content, title]
138
+ );
139
+
140
+ return { copiedId, handleAction };
141
+ };
142
+
143
+ const usePageContext = (
144
+ content: string,
145
+ title: string,
146
+ pagePath: string
147
+ ): ContextualContext => {
148
+ const [pageUrl, setPageUrl] = useState("");
149
+
150
+ useEffect(() => {
151
+ setPageUrl(window.location.href);
152
+ }, []);
153
+
154
+ return {
155
+ mcpServerUrl: undefined,
156
+ pageContent: `# ${title}\n\n${content}`,
157
+ pagePath,
158
+ pageUrl,
159
+ };
160
+ };
161
+
162
+ const MenuIcon = ({ children }: { children: ReactNode }) => (
163
+ <div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border">
164
+ {children}
165
+ </div>
166
+ );
167
+
168
+ const MenuItem = ({
169
+ children,
170
+ href,
171
+ onSelect,
172
+ }: {
173
+ children: ReactNode;
174
+ href?: string;
175
+ onSelect?: () => void;
176
+ }) =>
177
+ href ? (
178
+ <a
179
+ className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors hover:bg-secondary/25"
180
+ href={href}
181
+ onClick={onSelect}
182
+ rel="noopener noreferrer"
183
+ target="_blank"
184
+ >
185
+ {children}
186
+ </a>
187
+ ) : (
188
+ <button
189
+ className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors hover:bg-secondary/25"
190
+ onClick={onSelect}
191
+ type="button"
192
+ >
193
+ {children}
194
+ </button>
195
+ );
196
+
197
+ const ExternalArrow = () => (
198
+ <svg
199
+ aria-hidden="true"
200
+ className="ml-1 inline-block size-3"
201
+ fill="none"
202
+ stroke="currentColor"
203
+ strokeLinecap="round"
204
+ strokeLinejoin="round"
205
+ strokeWidth={2}
206
+ viewBox="0 0 24 24"
207
+ >
208
+ <path d="M7 17L17 7M7 7h10v10" />
209
+ </svg>
210
+ );
211
+
212
+ interface ContextualMenuProps {
213
+ options: ContextualOption[];
214
+ content: string;
215
+ title: string;
216
+ pagePath: string;
217
+ }
218
+
219
+ export const ContextualMenu = ({
220
+ options,
221
+ content,
222
+ title,
223
+ pagePath,
224
+ }: ContextualMenuProps) => {
225
+ const menuRef = useRef<HTMLDivElement>(null);
226
+ const context = usePageContext(content, title, pagePath);
227
+ const { copiedId, handleAction } = useContextualActions(content, title);
228
+ const [menuOpen, setMenuOpen] = useState(false);
229
+
230
+ const resolved = useMemo(
231
+ () => resolveOptions(options, context),
232
+ [context, options]
233
+ );
234
+
235
+ const [primaryOption] = resolved;
236
+
237
+ useEffect(() => {
238
+ if (!menuOpen) {
239
+ return;
240
+ }
241
+
242
+ const handlePointerDown = (event: MouseEvent) => {
243
+ if (menuRef.current?.contains(event.target as Node)) {
244
+ return;
245
+ }
246
+ setMenuOpen(false);
247
+ };
248
+
249
+ document.addEventListener("mousedown", handlePointerDown);
250
+ return () => document.removeEventListener("mousedown", handlePointerDown);
251
+ }, [menuOpen]);
252
+
253
+ const closeMenu = useCallback(() => {
254
+ setMenuOpen(false);
255
+ }, []);
256
+
257
+ const toggleMenu = useCallback(() => {
258
+ setMenuOpen((current) => !current);
259
+ }, []);
260
+
261
+ const handlePrimaryAction = useCallback(async () => {
262
+ if (!primaryOption) {
263
+ return;
264
+ }
265
+
266
+ if (primaryOption.type === "link" && primaryOption.href) {
267
+ window.open(primaryOption.href, "_blank", "noopener,noreferrer");
268
+ return;
269
+ }
270
+
271
+ await handleAction(primaryOption.action ?? "", primaryOption.key);
272
+ }, [handleAction, primaryOption]);
273
+
274
+ const actionHandlers = useMemo(
275
+ () =>
276
+ Object.fromEntries(
277
+ resolved
278
+ .filter((item) => item.type === "action")
279
+ .map((item) => [
280
+ item.key,
281
+ async () => {
282
+ await handleAction(item.action ?? "", item.key);
283
+ closeMenu();
284
+ },
285
+ ])
286
+ ),
287
+ [closeMenu, handleAction, resolved]
288
+ );
289
+
290
+ if (!primaryOption) {
291
+ return null;
292
+ }
293
+
294
+ const isCopied = copiedId === primaryOption.key;
295
+
296
+ return (
297
+ <div className="relative flex shrink-0 items-center" ref={menuRef}>
298
+ <button
299
+ className="inline-flex items-center gap-2 rounded-l-xl border border-r-0 border-border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-secondary/25"
300
+ onClick={handlePrimaryAction}
301
+ type="button"
302
+ >
303
+ {primaryOption.type === "action" && isCopied ? (
304
+ <Checkmark1Icon aria-hidden="true" className="size-[18px]" />
305
+ ) : (
306
+ <primaryOption.icon aria-hidden="true" className="size-[18px]" />
307
+ )}
308
+ <span>
309
+ {primaryOption.type === "action" && isCopied
310
+ ? "Copied"
311
+ : primaryOption.title}
312
+ </span>
313
+ </button>
314
+
315
+ {resolved.length > 1 ? (
316
+ <>
317
+ <button
318
+ aria-expanded={menuOpen}
319
+ aria-label="More actions"
320
+ className="inline-flex items-center self-stretch rounded-r-xl border border-border px-2 transition-colors hover:bg-secondary/25"
321
+ onClick={toggleMenu}
322
+ type="button"
323
+ >
324
+ <ChevronDownSmallIcon
325
+ aria-hidden="true"
326
+ className="size-[18px] text-muted-foreground"
327
+ />
328
+ </button>
329
+ {menuOpen ? (
330
+ <div className="absolute right-0 top-[calc(100%+0.25rem)] z-50 min-w-[320px] rounded-xl border border-border bg-background p-1 shadow-lg">
331
+ {resolved.slice(1).map((item) => (
332
+ <MenuItem
333
+ href={item.type === "link" ? item.href : undefined}
334
+ key={item.key}
335
+ onSelect={
336
+ item.type === "action"
337
+ ? actionHandlers[item.key]
338
+ : closeMenu
339
+ }
340
+ >
341
+ <MenuIcon>
342
+ {item.type === "action" && copiedId === item.key ? (
343
+ <Checkmark1Icon
344
+ aria-hidden="true"
345
+ className="size-[18px]"
346
+ />
347
+ ) : (
348
+ <item.icon aria-hidden="true" className="size-[18px]" />
349
+ )}
350
+ </MenuIcon>
351
+ <div className="flex-1">
352
+ <div className="font-medium">
353
+ {item.title}
354
+ {item.type === "link" ? <ExternalArrow /> : null}
355
+ </div>
356
+ <div className="text-xs text-muted-foreground">
357
+ {item.description}
358
+ </div>
359
+ </div>
360
+ </MenuItem>
361
+ ))}
362
+ </div>
363
+ ) : null}
364
+ </>
365
+ ) : null}
366
+ </div>
367
+ );
368
+ };
369
+
370
+ export const ContextualTocItems = ({
371
+ content,
372
+ options,
373
+ title,
374
+ pagePath,
375
+ }: ContextualMenuProps) => {
376
+ const context = usePageContext(content, title, pagePath);
377
+ const resolved = useMemo(
378
+ () =>
379
+ resolveOptions(options, context).filter(
380
+ (item): item is ResolvedOption & { href: string; type: "link" } =>
381
+ item.type === "link" && Boolean(item.href)
382
+ ),
383
+ [context, options]
384
+ );
385
+
386
+ if (!resolved.length) {
387
+ return null;
388
+ }
389
+
390
+ return (
391
+ <div className="grid gap-1.5">
392
+ {resolved.map((item) => (
393
+ <a
394
+ className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
395
+ href={item.href}
396
+ key={item.key}
397
+ rel="noopener noreferrer"
398
+ target="_blank"
399
+ >
400
+ <item.icon aria-hidden="true" className="size-3.5 shrink-0" />
401
+ <span className="truncate">{item.title}</span>
402
+ </a>
403
+ ))}
404
+ </div>
405
+ );
406
+ };
@@ -0,0 +1,255 @@
1
+ "use client";
2
+
3
+ import { slugify } from "@repo/common";
4
+ import {
5
+ Checkmark1Icon,
6
+ ChevronDownSmallIcon,
7
+ ClaudeaiIcon,
8
+ CopySimpleIcon,
9
+ OpenaiIcon,
10
+ } from "blode-icons-react";
11
+ import type React from "react";
12
+ import { useCallback, useEffect, useRef, useState } from "react";
13
+
14
+ interface CopyPageMenuProps {
15
+ content?: string;
16
+ contentUrl?: string;
17
+ title: string;
18
+ }
19
+
20
+ const LEADING_H1_REGEX = /^#\s+([^\r\n]+)(?:\r?\n(?:\r?\n)?)?/;
21
+
22
+ const stripMatchingLeadingH1 = (source: string, title: string) => {
23
+ const trimmed = source.trimStart();
24
+ const match = LEADING_H1_REGEX.exec(trimmed);
25
+ if (!match) {
26
+ return trimmed.trim();
27
+ }
28
+
29
+ const [headingLine = "", headingTitle = ""] = match;
30
+ if (slugify(headingTitle) !== slugify(title)) {
31
+ return trimmed.trim();
32
+ }
33
+
34
+ return trimmed.slice(headingLine.length).trim();
35
+ };
36
+
37
+ const formatMarkdownForCopy = (source: string, title: string) => {
38
+ const content = stripMatchingLeadingH1(source, title);
39
+ if (!content) {
40
+ return `# ${title}`;
41
+ }
42
+
43
+ return `# ${title}\n\n${content}`;
44
+ };
45
+
46
+ const MenuItem = ({
47
+ children,
48
+ href,
49
+ onSelect,
50
+ }: {
51
+ children: React.ReactNode;
52
+ href?: string;
53
+ onSelect?: () => void;
54
+ }) =>
55
+ href ? (
56
+ <a
57
+ className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors hover:bg-secondary/25"
58
+ href={href}
59
+ onClick={onSelect}
60
+ rel="noopener noreferrer"
61
+ target="_blank"
62
+ >
63
+ {children}
64
+ </a>
65
+ ) : (
66
+ <button
67
+ className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors hover:bg-secondary/25"
68
+ onClick={onSelect}
69
+ type="button"
70
+ >
71
+ {children}
72
+ </button>
73
+ );
74
+
75
+ const MenuIcon = ({ children }: { children: React.ReactNode }) => (
76
+ <div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border">
77
+ {children}
78
+ </div>
79
+ );
80
+
81
+ const ExternalArrow = () => (
82
+ <svg
83
+ aria-hidden="true"
84
+ className="ml-1 inline-block size-3"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ strokeLinecap="round"
88
+ strokeLinejoin="round"
89
+ strokeWidth={2}
90
+ viewBox="0 0 24 24"
91
+ >
92
+ <path d="M7 17L17 7M7 7h10v10" />
93
+ </svg>
94
+ );
95
+
96
+ export const CopyPageMenu = ({
97
+ content,
98
+ contentUrl,
99
+ title,
100
+ }: CopyPageMenuProps) => {
101
+ const menuRef = useRef<HTMLDivElement>(null);
102
+ const [copied, setCopied] = useState(false);
103
+ const [menuOpen, setMenuOpen] = useState(false);
104
+ const [pageUrl, setPageUrl] = useState("");
105
+ const [resolvedContent, setResolvedContent] = useState(content ?? "");
106
+
107
+ useEffect(() => {
108
+ setPageUrl(window.location.href);
109
+ }, []);
110
+
111
+ useEffect(() => {
112
+ if (content !== undefined) {
113
+ setResolvedContent(content);
114
+ }
115
+ }, [content]);
116
+
117
+ useEffect(() => {
118
+ if (!menuOpen) {
119
+ return;
120
+ }
121
+
122
+ const handlePointerDown = (event: MouseEvent) => {
123
+ if (menuRef.current?.contains(event.target as Node)) {
124
+ return;
125
+ }
126
+ setMenuOpen(false);
127
+ };
128
+
129
+ document.addEventListener("mousedown", handlePointerDown);
130
+ return () => document.removeEventListener("mousedown", handlePointerDown);
131
+ }, [menuOpen]);
132
+
133
+ const closeMenu = useCallback(() => {
134
+ setMenuOpen(false);
135
+ }, []);
136
+
137
+ const toggleMenu = useCallback(() => {
138
+ setMenuOpen((current) => !current);
139
+ }, []);
140
+
141
+ const getContent = useCallback(async () => {
142
+ if (resolvedContent) {
143
+ return resolvedContent;
144
+ }
145
+
146
+ if (!contentUrl) {
147
+ return "";
148
+ }
149
+
150
+ const response = await fetch(contentUrl, {
151
+ headers: {
152
+ accept: "text/markdown,text/plain;q=0.9,*/*;q=0.8",
153
+ },
154
+ });
155
+ if (!response.ok) {
156
+ throw new Error(`Failed to load page markdown: ${response.status}`);
157
+ }
158
+
159
+ const nextContent = await response.text();
160
+ setResolvedContent(nextContent);
161
+ return nextContent;
162
+ }, [contentUrl, resolvedContent]);
163
+
164
+ const handleCopy = useCallback(async () => {
165
+ const nextContent = await getContent();
166
+ const markdown = formatMarkdownForCopy(nextContent, title);
167
+ await navigator.clipboard.writeText(markdown);
168
+ setCopied(true);
169
+ closeMenu();
170
+ setTimeout(() => setCopied(false), 2000);
171
+ }, [closeMenu, getContent, title]);
172
+
173
+ const chatgptUrl = pageUrl
174
+ ? `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read from ${pageUrl} so I can ask questions about it.`)}`
175
+ : "#";
176
+ const claudeUrl = pageUrl
177
+ ? `https://claude.ai/new?q=${encodeURIComponent(`Read from ${pageUrl} so I can ask questions about it.`)}`
178
+ : "#";
179
+
180
+ return (
181
+ <div className="relative flex shrink-0 items-center" ref={menuRef}>
182
+ <button
183
+ className="inline-flex items-center gap-2 rounded-l-xl border border-r-0 border-border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-secondary/25"
184
+ onClick={handleCopy}
185
+ type="button"
186
+ >
187
+ {copied ? (
188
+ <Checkmark1Icon aria-hidden="true" className="size-[18px]" />
189
+ ) : (
190
+ <CopySimpleIcon aria-hidden="true" className="size-[18px]" />
191
+ )}
192
+ <span>{copied ? "Copied" : "Copy page"}</span>
193
+ </button>
194
+
195
+ <button
196
+ aria-expanded={menuOpen}
197
+ aria-label="More actions"
198
+ className="inline-flex items-center self-stretch rounded-r-xl border border-border px-2 transition-colors hover:bg-secondary/25"
199
+ onClick={toggleMenu}
200
+ type="button"
201
+ >
202
+ <ChevronDownSmallIcon
203
+ aria-hidden="true"
204
+ className="size-[18px] text-muted-foreground"
205
+ />
206
+ </button>
207
+
208
+ {menuOpen ? (
209
+ <div className="absolute right-0 top-[calc(100%+0.25rem)] z-50 min-w-[280px] rounded-xl border border-border bg-background p-1 shadow-lg">
210
+ <MenuItem onSelect={handleCopy}>
211
+ <MenuIcon>
212
+ <CopySimpleIcon aria-hidden="true" className="size-[18px]" />
213
+ </MenuIcon>
214
+ <div>
215
+ <div className="font-medium">Copy page</div>
216
+ <div className="text-xs text-muted-foreground">
217
+ Copy page as Markdown for LLMs
218
+ </div>
219
+ </div>
220
+ </MenuItem>
221
+
222
+ <MenuItem href={chatgptUrl} onSelect={closeMenu}>
223
+ <MenuIcon>
224
+ <OpenaiIcon aria-hidden="true" className="size-[18px]" />
225
+ </MenuIcon>
226
+ <div className="flex-1">
227
+ <div className="font-medium">
228
+ Open in ChatGPT
229
+ <ExternalArrow />
230
+ </div>
231
+ <div className="text-xs text-muted-foreground">
232
+ Ask questions about this page
233
+ </div>
234
+ </div>
235
+ </MenuItem>
236
+
237
+ <MenuItem href={claudeUrl} onSelect={closeMenu}>
238
+ <MenuIcon>
239
+ <ClaudeaiIcon aria-hidden="true" className="size-[18px]" />
240
+ </MenuIcon>
241
+ <div className="flex-1">
242
+ <div className="font-medium">
243
+ Open in Claude
244
+ <ExternalArrow />
245
+ </div>
246
+ <div className="text-xs text-muted-foreground">
247
+ Ask questions about this page
248
+ </div>
249
+ </div>
250
+ </MenuItem>
251
+ </div>
252
+ ) : null}
253
+ </div>
254
+ );
255
+ };