blodemd 0.0.7 → 0.0.9

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 (61) hide show
  1. package/README.md +25 -9
  2. package/dev-server/app/[[...slug]]/page.tsx +1 -0
  3. package/dev-server/next.config.js +11 -13
  4. package/dev-server/package.json +1 -1
  5. package/dev-server/tsconfig.json +3 -0
  6. package/dist/cli.mjs +869 -184
  7. package/dist/cli.mjs.map +1 -1
  8. package/docs/components/api/api-playground.tsx +255 -80
  9. package/docs/components/api/api-reference.tsx +11 -1
  10. package/docs/components/docs/contextual-menu.tsx +227 -142
  11. package/docs/components/docs/copy-page-menu.tsx +132 -85
  12. package/docs/components/docs/doc-header.tsx +13 -3
  13. package/docs/components/docs/doc-shell.tsx +22 -11
  14. package/docs/components/docs/mobile-nav.tsx +0 -6
  15. package/docs/components/mdx/code-group.tsx +171 -62
  16. package/docs/components/mdx/tabs.tsx +131 -26
  17. package/docs/components/ui/input.tsx +0 -1
  18. package/docs/components/ui/search.tsx +241 -132
  19. package/docs/lib/content-root.ts +33 -0
  20. package/docs/lib/content-source.ts +70 -0
  21. package/docs/lib/contextual-options.ts +20 -0
  22. package/docs/lib/docs-runtime.tsx +595 -0
  23. package/docs/lib/edge-config.ts +95 -0
  24. package/docs/lib/env.ts +22 -0
  25. package/docs/lib/openapi-proxy.ts +88 -0
  26. package/docs/lib/platform-config.ts +6 -0
  27. package/docs/lib/routes.ts +39 -0
  28. package/docs/lib/supabase.ts +13 -0
  29. package/docs/lib/tenancy.ts +322 -0
  30. package/docs/lib/tenant-headers.ts +14 -0
  31. package/docs/lib/tenant-static.ts +529 -0
  32. package/docs/lib/tenant-utility-context.ts +62 -0
  33. package/docs/lib/tenants.ts +68 -0
  34. package/docs/lib/use-mobile.ts +19 -0
  35. package/package.json +3 -2
  36. package/packages/@repo/common/dist/index.d.ts +7 -0
  37. package/packages/@repo/common/dist/index.d.ts.map +1 -1
  38. package/packages/@repo/common/dist/index.js +42 -0
  39. package/packages/@repo/common/src/index.ts +50 -0
  40. package/packages/@repo/contracts/dist/project.d.ts +1 -1
  41. package/packages/@repo/contracts/dist/project.js +1 -1
  42. package/packages/@repo/contracts/src/project.ts +1 -1
  43. package/packages/@repo/models/dist/docs-config.d.ts +194 -29
  44. package/packages/@repo/models/dist/docs-config.d.ts.map +1 -1
  45. package/packages/@repo/models/dist/docs-config.js +3 -28
  46. package/packages/@repo/models/src/docs-config.ts +5 -31
  47. package/packages/@repo/previewing/dist/blob-source.d.ts.map +1 -1
  48. package/packages/@repo/previewing/dist/blob-source.js +7 -2
  49. package/packages/@repo/previewing/dist/fs-source.d.ts.map +1 -1
  50. package/packages/@repo/previewing/dist/fs-source.js +2 -3
  51. package/packages/@repo/previewing/dist/index.d.ts.map +1 -1
  52. package/packages/@repo/previewing/dist/index.js +1 -41
  53. package/packages/@repo/previewing/src/blob-source.ts +7 -4
  54. package/packages/@repo/previewing/src/fs-source.ts +2 -3
  55. package/packages/@repo/previewing/src/index.ts +3 -55
  56. package/packages/@repo/validation/dist/index.d.ts +2 -2
  57. package/packages/@repo/validation/dist/index.d.ts.map +1 -1
  58. package/packages/@repo/validation/dist/index.js +2 -2
  59. package/packages/@repo/validation/package.json +1 -0
  60. package/packages/@repo/validation/src/{mintlify-docs-schema.json → blodemd-docs-schema.json} +346 -1794
  61. package/packages/@repo/validation/src/index.ts +4 -4
@@ -8,8 +8,8 @@ import {
8
8
  useDeferredValue,
9
9
  useEffect,
10
10
  useMemo,
11
+ useReducer,
11
12
  useRef,
12
- useState,
13
13
  } from "react";
14
14
  import type {
15
15
  ChangeEvent,
@@ -17,6 +17,12 @@ import type {
17
17
  MouseEvent as ReactMouseEvent,
18
18
  } from "react";
19
19
 
20
+ import {
21
+ Dialog,
22
+ DialogContent,
23
+ DialogDescription,
24
+ DialogTitle,
25
+ } from "@/components/ui/dialog";
20
26
  import { isExternalHref, resolveHref, toDocHref } from "@/lib/routes";
21
27
 
22
28
  export interface SearchItem {
@@ -29,7 +35,33 @@ interface SearchResponse {
29
35
  items: SearchItem[];
30
36
  }
31
37
 
38
+ type SearchStatus = "idle" | "loading" | "ready" | "error";
39
+
40
+ interface SearchState {
41
+ activeIndex: number;
42
+ items: SearchItem[];
43
+ open: boolean;
44
+ query: string;
45
+ status: SearchStatus;
46
+ }
47
+
48
+ type SearchAction =
49
+ | { type: "close" }
50
+ | { type: "load-error" }
51
+ | { type: "load-start" }
52
+ | { items: SearchItem[]; type: "load-success" }
53
+ | { type: "open" }
54
+ | { index: number; type: "set-active-index" }
55
+ | { query: string; type: "set-query" };
56
+
32
57
  const MAX_RESULTS = 12;
58
+ const INITIAL_SEARCH_STATE: SearchState = {
59
+ activeIndex: 0,
60
+ items: [],
61
+ open: false,
62
+ query: "",
63
+ status: "idle",
64
+ };
33
65
 
34
66
  const isEditableTarget = (target: EventTarget | null) =>
35
67
  (target instanceof HTMLElement && target.isContentEditable) ||
@@ -63,18 +95,145 @@ const getWrappedPrevIndex = (current: number, length: number) => {
63
95
  return current - 1;
64
96
  };
65
97
 
98
+ const searchReducer = (
99
+ state: SearchState,
100
+ action: SearchAction
101
+ ): SearchState => {
102
+ switch (action.type) {
103
+ case "close": {
104
+ return {
105
+ ...state,
106
+ activeIndex: 0,
107
+ open: false,
108
+ query: "",
109
+ };
110
+ }
111
+ case "load-error": {
112
+ return {
113
+ ...state,
114
+ status: "error",
115
+ };
116
+ }
117
+ case "load-start": {
118
+ return {
119
+ ...state,
120
+ status: "loading",
121
+ };
122
+ }
123
+ case "load-success": {
124
+ return {
125
+ ...state,
126
+ items: action.items,
127
+ status: "ready",
128
+ };
129
+ }
130
+ case "open": {
131
+ return {
132
+ ...state,
133
+ open: true,
134
+ };
135
+ }
136
+ case "set-active-index": {
137
+ return {
138
+ ...state,
139
+ activeIndex: action.index,
140
+ };
141
+ }
142
+ case "set-query": {
143
+ return {
144
+ ...state,
145
+ activeIndex: 0,
146
+ query: action.query,
147
+ };
148
+ }
149
+ default: {
150
+ return state;
151
+ }
152
+ }
153
+ };
154
+
155
+ const SearchResults = ({
156
+ activeIndex,
157
+ basePath,
158
+ filteredItems,
159
+ onResultClick,
160
+ onResultMouseEnter,
161
+ status,
162
+ }: {
163
+ activeIndex: number;
164
+ basePath: string;
165
+ filteredItems: SearchItem[];
166
+ onResultClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
167
+ onResultMouseEnter: (event: ReactMouseEvent<HTMLButtonElement>) => void;
168
+ status: SearchStatus;
169
+ }) => {
170
+ if (status === "loading") {
171
+ return (
172
+ <div className="px-3 py-10 text-center text-sm text-muted-foreground">
173
+ Loading search index...
174
+ </div>
175
+ );
176
+ }
177
+
178
+ if (status === "error") {
179
+ return (
180
+ <div className="px-3 py-10 text-center text-sm text-muted-foreground">
181
+ Search is temporarily unavailable.
182
+ </div>
183
+ );
184
+ }
185
+
186
+ if (status === "ready" && filteredItems.length === 0) {
187
+ return (
188
+ <div className="px-3 py-10 text-center text-sm text-muted-foreground">
189
+ No results found.
190
+ </div>
191
+ );
192
+ }
193
+
194
+ if (status !== "ready") {
195
+ return null;
196
+ }
197
+
198
+ return (
199
+ <div className="grid gap-1">
200
+ {filteredItems.map((item, index) => {
201
+ const href = item.href
202
+ ? resolveHref(item.href, basePath)
203
+ : toDocHref(item.path, basePath);
204
+ const isActive = index === activeIndex;
205
+
206
+ return (
207
+ <button
208
+ className={
209
+ isActive
210
+ ? "grid gap-1 rounded-xl bg-accent px-3 py-2 text-left text-foreground transition-colors"
211
+ : "grid gap-1 rounded-xl px-3 py-2 text-left text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground"
212
+ }
213
+ data-index={index}
214
+ key={`${item.path}-${item.href ?? "internal"}`}
215
+ onClick={onResultClick}
216
+ onMouseEnter={onResultMouseEnter}
217
+ type="button"
218
+ >
219
+ <span className="text-sm font-medium text-foreground">
220
+ {item.title}
221
+ </span>
222
+ <span className="text-xs">{href}</span>
223
+ </button>
224
+ );
225
+ })}
226
+ </div>
227
+ );
228
+ };
229
+
66
230
  export const Search = ({ basePath }: { basePath: string }) => {
67
231
  const router = useRouter();
68
232
  const inputRef = useRef<HTMLInputElement>(null);
69
233
  const requestRef = useRef<Promise<void> | null>(null);
70
234
  const loadedRef = useRef(false);
71
- const [activeIndex, setActiveIndex] = useState(0);
72
- const [items, setItems] = useState<SearchItem[]>([]);
73
- const [open, setOpen] = useState(false);
74
- const [query, setQuery] = useState("");
75
- const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">(
76
- "idle"
77
- );
235
+ const [state, dispatch] = useReducer(searchReducer, INITIAL_SEARCH_STATE);
236
+ const { activeIndex, items, open, query, status } = state;
78
237
 
79
238
  const deferredQuery = useDeferredValue(query.trim().toLowerCase());
80
239
 
@@ -82,11 +241,12 @@ export const Search = ({ basePath }: { basePath: string }) => {
82
241
  if (loadedRef.current) {
83
242
  return Promise.resolve();
84
243
  }
244
+
85
245
  if (requestRef.current) {
86
246
  return requestRef.current;
87
247
  }
88
248
 
89
- setStatus("loading");
249
+ dispatch({ type: "load-start" });
90
250
  const request = (async () => {
91
251
  try {
92
252
  const response = await fetch(toDocHref("search", basePath), {
@@ -103,11 +263,10 @@ export const Search = ({ basePath }: { basePath: string }) => {
103
263
  const nextItems = Array.isArray(payload.items) ? payload.items : [];
104
264
  loadedRef.current = true;
105
265
  startTransition(() => {
106
- setItems(nextItems);
107
- setStatus("ready");
266
+ dispatch({ items: nextItems, type: "load-success" });
108
267
  });
109
268
  } catch {
110
- setStatus("error");
269
+ dispatch({ type: "load-error" });
111
270
  } finally {
112
271
  requestRef.current = null;
113
272
  }
@@ -126,9 +285,7 @@ export const Search = ({ basePath }: { basePath: string }) => {
126
285
  );
127
286
 
128
287
  const closeSearch = useCallback(() => {
129
- setOpen(false);
130
- setQuery("");
131
- setActiveIndex(0);
288
+ dispatch({ type: "close" });
132
289
  }, []);
133
290
 
134
291
  const runSelection = useCallback(
@@ -137,20 +294,36 @@ export const Search = ({ basePath }: { basePath: string }) => {
137
294
  const href = item.href
138
295
  ? resolveHref(item.href, basePath)
139
296
  : toDocHref(item.path, basePath);
297
+
140
298
  if (item.href && isExternalHref(item.href)) {
141
299
  window.open(href, "_blank", "noopener,noreferrer");
142
300
  return;
143
301
  }
302
+
144
303
  router.push(href);
145
304
  },
146
305
  [basePath, closeSearch, router]
147
306
  );
148
307
 
149
308
  const openSearch = useCallback(async () => {
150
- setOpen(true);
309
+ dispatch({ type: "open" });
151
310
  await loadSearchItems();
152
311
  }, [loadSearchItems]);
153
312
 
313
+ const handleOpenChange = useCallback(
314
+ (nextOpen: boolean) => {
315
+ if (!nextOpen) {
316
+ closeSearch();
317
+ }
318
+ },
319
+ [closeSearch]
320
+ );
321
+
322
+ const handleOpenAutoFocus = useCallback((event: Event) => {
323
+ event.preventDefault();
324
+ inputRef.current?.focus();
325
+ }, []);
326
+
154
327
  const warmSearch = useCallback(async () => {
155
328
  try {
156
329
  await loadSearchItems();
@@ -161,7 +334,7 @@ export const Search = ({ basePath }: { basePath: string }) => {
161
334
 
162
335
  const handleQueryChange = useCallback(
163
336
  (event: ChangeEvent<HTMLInputElement>) => {
164
- setQuery(event.target.value);
337
+ dispatch({ query: event.target.value, type: "set-query" });
165
338
  },
166
339
  []
167
340
  );
@@ -186,29 +359,11 @@ export const Search = ({ basePath }: { basePath: string }) => {
186
359
  return;
187
360
  }
188
361
 
189
- setActiveIndex(index);
362
+ dispatch({ index, type: "set-active-index" });
190
363
  },
191
364
  []
192
365
  );
193
366
 
194
- useEffect(() => {
195
- if (!open) {
196
- return;
197
- }
198
-
199
- inputRef.current?.focus();
200
- const previousOverflow = document.body.style.overflow;
201
- document.body.style.overflow = "hidden";
202
-
203
- return () => {
204
- document.body.style.overflow = previousOverflow;
205
- };
206
- }, [open]);
207
-
208
- useEffect(() => {
209
- setActiveIndex(0);
210
- }, [deferredQuery, open]);
211
-
212
367
  useEffect(() => {
213
368
  const handleKeydown = async (event: KeyboardEvent) => {
214
369
  if (
@@ -224,16 +379,9 @@ export const Search = ({ basePath }: { basePath: string }) => {
224
379
  closeSearch();
225
380
  return;
226
381
  }
227
- await openSearch();
228
- return;
229
- }
230
382
 
231
- if (!open || event.key !== "Escape") {
232
- return;
383
+ await openSearch();
233
384
  }
234
-
235
- event.preventDefault();
236
- closeSearch();
237
385
  };
238
386
 
239
387
  document.addEventListener("keydown", handleKeydown);
@@ -244,17 +392,19 @@ export const Search = ({ basePath }: { basePath: string }) => {
244
392
  (event: ReactKeyboardEvent<HTMLDivElement>) => {
245
393
  if (event.key === "ArrowDown") {
246
394
  event.preventDefault();
247
- setActiveIndex((current) =>
248
- getWrappedNextIndex(current, filteredItems.length)
249
- );
395
+ dispatch({
396
+ index: getWrappedNextIndex(activeIndex, filteredItems.length),
397
+ type: "set-active-index",
398
+ });
250
399
  return;
251
400
  }
252
401
 
253
402
  if (event.key === "ArrowUp") {
254
403
  event.preventDefault();
255
- setActiveIndex((current) =>
256
- getWrappedPrevIndex(current, filteredItems.length)
257
- );
404
+ dispatch({
405
+ index: getWrappedPrevIndex(activeIndex, filteredItems.length),
406
+ type: "set-active-index",
407
+ });
258
408
  return;
259
409
  }
260
410
 
@@ -296,89 +446,48 @@ export const Search = ({ basePath }: { basePath: string }) => {
296
446
  Cmd K
297
447
  </span>
298
448
  </button>
299
- {open ? (
300
- <div className="fixed inset-0 z-50">
301
- <button
302
- aria-label="Close search"
303
- className="absolute inset-0 bg-background/80 backdrop-blur-sm"
304
- onClick={closeSearch}
305
- type="button"
306
- />
307
- <div
308
- aria-modal="true"
309
- className="relative mx-auto mt-[10vh] flex w-[calc(100%-2rem)] max-w-2xl flex-col overflow-hidden rounded-2xl border border-border bg-background shadow-2xl"
310
- onKeyDown={handleDialogKeyDown}
311
- role="dialog"
312
- >
313
- <div className="flex items-center gap-3 border-b border-border px-4 py-3">
314
- <SearchIcon className="size-4 text-muted-foreground" />
315
- <input
316
- aria-label="Search documentation"
317
- className="w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
318
- onChange={handleQueryChange}
319
- placeholder="Search docs..."
320
- ref={inputRef}
321
- type="text"
322
- value={query}
323
- />
324
- <button
325
- className="rounded-md border border-border px-2 py-1 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
326
- onClick={closeSearch}
327
- type="button"
328
- >
329
- Esc
330
- </button>
331
- </div>
332
- <div className="max-h-[min(70vh,32rem)] overflow-y-auto p-2">
333
- {status === "loading" ? (
334
- <div className="px-3 py-10 text-center text-sm text-muted-foreground">
335
- Loading search index...
336
- </div>
337
- ) : null}
338
- {status === "error" ? (
339
- <div className="px-3 py-10 text-center text-sm text-muted-foreground">
340
- Search is temporarily unavailable.
341
- </div>
342
- ) : null}
343
- {status === "ready" && filteredItems.length === 0 ? (
344
- <div className="px-3 py-10 text-center text-sm text-muted-foreground">
345
- No results found.
346
- </div>
347
- ) : null}
348
- {status === "ready" && filteredItems.length > 0 ? (
349
- <div className="grid gap-1">
350
- {filteredItems.map((item, index) => {
351
- const isActive = index === activeIndex;
352
- const href = item.href
353
- ? resolveHref(item.href, basePath)
354
- : toDocHref(item.path, basePath);
355
-
356
- return (
357
- <button
358
- className={`grid gap-1 rounded-xl px-3 py-2 text-left transition-colors ${
359
- isActive
360
- ? "bg-accent text-foreground"
361
- : "text-muted-foreground hover:bg-accent/70 hover:text-foreground"
362
- }`}
363
- data-index={index}
364
- key={`${item.path}-${item.href ?? "internal"}`}
365
- onClick={handleResultClick}
366
- onMouseEnter={handleResultMouseEnter}
367
- type="button"
368
- >
369
- <span className="text-sm font-medium text-foreground">
370
- {item.title}
371
- </span>
372
- <span className="text-xs">{href}</span>
373
- </button>
374
- );
375
- })}
376
- </div>
377
- ) : null}
378
- </div>
449
+ <Dialog onOpenChange={handleOpenChange} open={open}>
450
+ <DialogContent
451
+ className="max-w-2xl gap-0 overflow-hidden p-0"
452
+ onKeyDown={handleDialogKeyDown}
453
+ onOpenAutoFocus={handleOpenAutoFocus}
454
+ showCloseButton={false}
455
+ >
456
+ <DialogTitle className="sr-only">Search documentation</DialogTitle>
457
+ <DialogDescription className="sr-only">
458
+ Search documentation pages and jump directly to a result.
459
+ </DialogDescription>
460
+ <div className="flex items-center gap-3 border-b border-border px-4 py-3">
461
+ <SearchIcon className="size-4 text-muted-foreground" />
462
+ <input
463
+ aria-label="Search documentation"
464
+ className="w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
465
+ onChange={handleQueryChange}
466
+ placeholder="Search docs..."
467
+ ref={inputRef}
468
+ type="text"
469
+ value={query}
470
+ />
471
+ <button
472
+ className="rounded-md border border-border px-2 py-1 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
473
+ onClick={closeSearch}
474
+ type="button"
475
+ >
476
+ Esc
477
+ </button>
478
+ </div>
479
+ <div className="max-h-[min(70vh,32rem)] overflow-y-auto p-2">
480
+ <SearchResults
481
+ activeIndex={activeIndex}
482
+ basePath={basePath}
483
+ filteredItems={filteredItems}
484
+ onResultClick={handleResultClick}
485
+ onResultMouseEnter={handleResultMouseEnter}
486
+ status={status}
487
+ />
379
488
  </div>
380
- </div>
381
- ) : null}
489
+ </DialogContent>
490
+ </Dialog>
382
491
  </>
383
492
  );
384
493
  };
@@ -0,0 +1,33 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const DOCS_CONFIG_FILE = "docs.json";
6
+
7
+ const EXTERNAL_DOCS_ROOTS: Record<string, string> = {
8
+ allmd: path.join(os.homedir(), "Code/mblode/allmd/apps/docs"),
9
+ "dnd-grid": path.join(os.homedir(), "Code/mblode/dnd-grid/apps/docs"),
10
+ donebear: path.join(os.homedir(), "Code/donebear/donebear/apps/docs"),
11
+ shareful: path.join(os.homedir(), "Code/shareful-ai/shareful-ai/apps/docs"),
12
+ stratasync: path.join(os.homedir(), "Code/donebear/stratasync/apps/docs"),
13
+ };
14
+
15
+ const getTenantDocsPathCandidates = (slug: string): string[] =>
16
+ [
17
+ EXTERNAL_DOCS_ROOTS[slug],
18
+ path.join(process.cwd(), "content", slug),
19
+ path.join(process.cwd(), "apps/docs/content", slug),
20
+ ].filter(Boolean) as string[];
21
+
22
+ export const getTenantDocsPath = (slug: string): string => {
23
+ const candidates = getTenantDocsPathCandidates(slug);
24
+ const defaultLocalPath = path.join(process.cwd(), "apps/docs/content", slug);
25
+
26
+ for (const candidate of candidates) {
27
+ if (fs.existsSync(path.join(candidate, DOCS_CONFIG_FILE))) {
28
+ return candidate;
29
+ }
30
+ }
31
+
32
+ return defaultLocalPath;
33
+ };
@@ -0,0 +1,70 @@
1
+ import type { SiteConfig, Tenant } from "@repo/models";
2
+ import type { ContentSource } from "@repo/previewing";
3
+ import { createBlobSource, createFsSource } from "@repo/previewing";
4
+
5
+ import { getTenantDocsPath } from "./content-root";
6
+ import { getProjectTag } from "./tenants";
7
+
8
+ const ABSOLUTE_URL_REGEX = /^https?:\/\//i;
9
+
10
+ const resolveAssetUrl = async (
11
+ source: ContentSource,
12
+ value?: string
13
+ ): Promise<string | undefined> => {
14
+ if (!value || value.startsWith("/") || ABSOLUTE_URL_REGEX.test(value)) {
15
+ return value;
16
+ }
17
+
18
+ const resolved = await source.resolveUrl?.(value);
19
+ return resolved ?? value;
20
+ };
21
+
22
+ export const getTenantContentSource = (tenant: Tenant): ContentSource => {
23
+ if (tenant.activeDeploymentManifestUrl) {
24
+ return createBlobSource(
25
+ tenant.activeDeploymentManifestUrl,
26
+ getProjectTag(tenant.slug)
27
+ );
28
+ }
29
+
30
+ return createFsSource(tenant.docsPath ?? getTenantDocsPath(tenant.slug));
31
+ };
32
+
33
+ export const resolveSiteConfigAssets = async (
34
+ config: SiteConfig,
35
+ source: ContentSource
36
+ ): Promise<SiteConfig> => {
37
+ const [favicon, fontCssUrl, darkLogo, lightLogo, ogImage] = await Promise.all(
38
+ [
39
+ resolveAssetUrl(source, config.favicon),
40
+ resolveAssetUrl(source, config.fonts?.cssUrl),
41
+ resolveAssetUrl(source, config.logo?.dark),
42
+ resolveAssetUrl(source, config.logo?.light),
43
+ resolveAssetUrl(source, config.metadata?.ogImage),
44
+ ]
45
+ );
46
+
47
+ return {
48
+ ...config,
49
+ favicon,
50
+ fonts: config.fonts
51
+ ? {
52
+ ...config.fonts,
53
+ cssUrl: fontCssUrl,
54
+ }
55
+ : config.fonts,
56
+ logo: config.logo
57
+ ? {
58
+ ...config.logo,
59
+ dark: darkLogo,
60
+ light: lightLogo,
61
+ }
62
+ : config.logo,
63
+ metadata: config.metadata
64
+ ? {
65
+ ...config.metadata,
66
+ ogImage,
67
+ }
68
+ : config.metadata,
69
+ };
70
+ };
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ContextualBuiltinOption,
3
3
  ContextualCustomOption,
4
+ ContextualOption,
4
5
  } from "@repo/models";
5
6
 
6
7
  interface BuiltinOptionDefinition {
@@ -113,11 +114,30 @@ interface UrlContext {
113
114
  mcpServerUrl?: string;
114
115
  }
115
116
 
117
+ const PAGE_PLACEHOLDER = "$page";
118
+ const BUILTIN_OPTIONS_REQUIRING_PAGE_CONTENT = new Set<ContextualBuiltinOption>(
119
+ ["assistant", "copy"]
120
+ );
121
+
116
122
  const askPrompt = (url: string) =>
117
123
  `Read from ${url} so I can ask questions about it.`;
118
124
 
119
125
  const encoded = (text: string) => encodeURIComponent(text);
120
126
 
127
+ const customHrefUsesPageContent = (href: ContextualCustomOption["href"]) =>
128
+ typeof href === "string"
129
+ ? href.includes(PAGE_PLACEHOLDER)
130
+ : href.query.some((item) => item.value.includes(PAGE_PLACEHOLDER));
131
+
132
+ export const contextualOptionsRequirePageContent = (
133
+ options: ContextualOption[]
134
+ ) =>
135
+ options.some((option) =>
136
+ typeof option === "string"
137
+ ? BUILTIN_OPTIONS_REQUIRING_PAGE_CONTENT.has(option)
138
+ : customHrefUsesPageContent(option.href)
139
+ );
140
+
121
141
  export const buildBuiltinUrl = (
122
142
  id: ContextualBuiltinOption,
123
143
  context: UrlContext