blodemd 0.0.8 → 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
@@ -0,0 +1,529 @@
1
+ import { slugify } from "@repo/common";
2
+ import type { TenantResolution } from "@repo/contracts";
3
+ import type { Tenant } from "@repo/models";
4
+ import type { UtilityIndex } from "@repo/previewing";
5
+ import {
6
+ buildContentIndex,
7
+ buildPageMetadataMap,
8
+ getPrebuiltUtilityLlmPagePath,
9
+ loadContentSource,
10
+ loadPrebuiltContentIndex,
11
+ loadPrebuiltUtilityIndex,
12
+ loadSiteConfig,
13
+ PREBUILT_UTILITY_LLMS_FULL_PATH,
14
+ PREBUILT_UTILITY_LLMS_PATH,
15
+ PREBUILT_UTILITY_SITEMAP_PATH,
16
+ UTILITY_DOCS_ROOT_TOKEN,
17
+ } from "@repo/previewing";
18
+
19
+ import {
20
+ getTenantContentSource,
21
+ resolveSiteConfigAssets,
22
+ } from "@/lib/content-source";
23
+ import {
24
+ getDocsCollection,
25
+ getDocsCollectionWithNavigation,
26
+ getDocsNavigation,
27
+ } from "@/lib/docs-collection";
28
+ import {
29
+ buildNavigation,
30
+ enrichNavWithMetadata,
31
+ flattenNav,
32
+ getVisibleNavigation,
33
+ } from "@/lib/navigation";
34
+ import type { OpenApiEntry } from "@/lib/openapi";
35
+ import { loadOpenApiRegistry } from "@/lib/openapi";
36
+ import { toDocHref } from "@/lib/routes";
37
+ import { createTimedPromiseCache } from "@/lib/server-cache";
38
+ import { getRequestProtocol } from "@/lib/tenancy";
39
+ import type { TenantRequestContext } from "@/lib/tenant-utility-context";
40
+
41
+ const TENANT_STATIC_CACHE_TTL_MS = 30 * 60 * 1000;
42
+
43
+ const getTenantStaticCacheKey = (tenant: Tenant) =>
44
+ [
45
+ tenant.id,
46
+ tenant.slug,
47
+ tenant.activeDeploymentId ?? "",
48
+ tenant.activeDeploymentManifestUrl ?? "",
49
+ tenant.docsPath ?? "",
50
+ tenant.pathPrefix ?? "",
51
+ tenant.primaryDomain,
52
+ ].join(":");
53
+
54
+ const tenantUrlDataCache = createTimedPromiseCache<
55
+ string,
56
+ Awaited<ReturnType<typeof buildTenantUrlData>>
57
+ >({
58
+ maxEntries: 512,
59
+ ttlMs: TENANT_STATIC_CACHE_TTL_MS,
60
+ });
61
+
62
+ const tenantUtilityIndexCache = createTimedPromiseCache<string, UtilityIndex>({
63
+ maxEntries: 512,
64
+ ttlMs: TENANT_STATIC_CACHE_TTL_MS,
65
+ });
66
+
67
+ const isMissingContentFileError = (error: unknown) => {
68
+ if (
69
+ typeof error === "object" &&
70
+ error !== null &&
71
+ "code" in error &&
72
+ (error as { code?: string }).code === "ENOENT"
73
+ ) {
74
+ return true;
75
+ }
76
+
77
+ return error instanceof Error && error.message.includes("not found");
78
+ };
79
+
80
+ const getCanonicalHost = (
81
+ tenant: Tenant,
82
+ requestedHost?: string,
83
+ strategy?: TenantResolution["strategy"] | null
84
+ ) =>
85
+ (strategy === "custom-domain" || strategy === "path") && requestedHost
86
+ ? requestedHost
87
+ : tenant.primaryDomain;
88
+
89
+ const getCanonicalBasePath = (
90
+ tenant: Tenant,
91
+ basePath?: string,
92
+ strategy?: TenantResolution["strategy"] | null
93
+ ) => {
94
+ if (strategy === "path") {
95
+ return basePath || `/${tenant.slug}`;
96
+ }
97
+ if (strategy === "custom-domain") {
98
+ return basePath ?? tenant.pathPrefix ?? "";
99
+ }
100
+ return basePath ?? "";
101
+ };
102
+
103
+ export const getTenantRequestContextFromHeaders = (
104
+ tenant: Tenant,
105
+ headerStore: Pick<Headers, "get">
106
+ ): TenantRequestContext => ({
107
+ basePath: headerStore.get("x-tenant-base-path") ?? tenant.pathPrefix ?? "",
108
+ protocol: getRequestProtocol(headerStore),
109
+ requestedHost: headerStore.get("x-tenant-domain") ?? undefined,
110
+ strategy: (headerStore.get("x-tenant-strategy") ?? null) as
111
+ | TenantResolution["strategy"]
112
+ | null,
113
+ });
114
+
115
+ export const getStaticTenantRequestContext = (
116
+ tenant: Tenant
117
+ ): TenantRequestContext => ({
118
+ basePath: tenant.pathPrefix ?? "",
119
+ protocol: "https",
120
+ requestedHost: tenant.primaryDomain,
121
+ strategy: tenant.customDomains.length > 0 ? "custom-domain" : null,
122
+ });
123
+
124
+ export const getCanonicalOrigin = (
125
+ tenant: Tenant,
126
+ context: TenantRequestContext = {}
127
+ ) =>
128
+ `${context.protocol ?? "https"}://${getCanonicalHost(
129
+ tenant,
130
+ context.requestedHost,
131
+ context.strategy
132
+ )}`;
133
+
134
+ export const getCanonicalDocBasePath = (
135
+ tenant: Tenant,
136
+ context: TenantRequestContext = {}
137
+ ) => getCanonicalBasePath(tenant, context.basePath, context.strategy);
138
+
139
+ const renderUtilityTemplate = (
140
+ source: string,
141
+ tenant: Tenant,
142
+ context: TenantRequestContext = {}
143
+ ) =>
144
+ source.replaceAll(
145
+ UTILITY_DOCS_ROOT_TOKEN,
146
+ `${getCanonicalOrigin(tenant, context)}${getCanonicalDocBasePath(
147
+ tenant,
148
+ context
149
+ )}`
150
+ );
151
+
152
+ const buildTenantUrlData = async (tenant: Tenant) => {
153
+ const contentSource = getTenantContentSource(tenant);
154
+ const configResult = await loadSiteConfig(contentSource);
155
+ if (!configResult.ok) {
156
+ throw new Error("Invalid config");
157
+ }
158
+
159
+ const config = await resolveSiteConfigAssets(
160
+ configResult.config,
161
+ contentSource
162
+ );
163
+ const contentIndex =
164
+ (await loadPrebuiltContentIndex(contentSource)) ??
165
+ (await buildContentIndex(contentSource, config));
166
+ if (contentIndex.errors.length) {
167
+ throw new Error("Invalid content");
168
+ }
169
+
170
+ const docsCollection = getDocsCollection(config);
171
+ const docsNavigation = getDocsNavigation(config);
172
+ const docsCollectionWithNavigation = getDocsCollectionWithNavigation(config);
173
+ const registry = await loadOpenApiRegistry(
174
+ docsCollectionWithNavigation,
175
+ contentSource
176
+ );
177
+ const metadataMap = buildPageMetadataMap(contentIndex);
178
+ const rawNav = docsNavigation
179
+ ? buildNavigation(
180
+ docsNavigation,
181
+ registry,
182
+ docsCollection?.slugPrefix ?? ""
183
+ )
184
+ : [];
185
+ const nav = enrichNavWithMetadata(rawNav, metadataMap);
186
+ const visibleNav = getVisibleNavigation(nav);
187
+ const hiddenSlugs = new Set<string>();
188
+ for (const [slug, meta] of metadataMap) {
189
+ if (meta.hidden || meta.noindex) {
190
+ hiddenSlugs.add(slug);
191
+ }
192
+ }
193
+
194
+ const navPages = flattenNav(visibleNav).map((page) => page.path);
195
+ const contentPages = contentIndex.entries
196
+ .filter(
197
+ (entry) =>
198
+ !(entry.kind === "entry" && entry.hidden) &&
199
+ !hiddenSlugs.has(entry.slug)
200
+ )
201
+ .map((entry) => entry.slug);
202
+
203
+ return {
204
+ config,
205
+ contentIndex,
206
+ contentSource,
207
+ hiddenSlugs,
208
+ pages: [...new Set([...navPages, ...contentPages])],
209
+ registry,
210
+ };
211
+ };
212
+
213
+ const loadTenantUrlData = async (tenant: Tenant) =>
214
+ await tenantUrlDataCache.getOrCreate(
215
+ getTenantStaticCacheKey(tenant),
216
+ async () => await buildTenantUrlData(tenant)
217
+ );
218
+
219
+ const formatOpenApiPageText = (entry: OpenApiEntry): string => {
220
+ const parts = [
221
+ `Method: ${entry.operation.method}`,
222
+ `Path: ${entry.operation.path}`,
223
+ ];
224
+
225
+ if (entry.operation.description) {
226
+ parts.push(entry.operation.description);
227
+ }
228
+ if (entry.operation.tags.length) {
229
+ parts.push(`Tags: ${entry.operation.tags.join(", ")}`);
230
+ }
231
+ if (entry.operation.parameters.length) {
232
+ parts.push(
233
+ `Parameters:\n${JSON.stringify(entry.operation.parameters, null, 2)}`
234
+ );
235
+ }
236
+ if (entry.operation.requestBody) {
237
+ parts.push(
238
+ `Request Body:\n${JSON.stringify(entry.operation.requestBody, null, 2)}`
239
+ );
240
+ }
241
+ if (entry.operation.responses) {
242
+ parts.push(
243
+ `Responses:\n${JSON.stringify(entry.operation.responses, null, 2)}`
244
+ );
245
+ }
246
+
247
+ return parts.join("\n\n");
248
+ };
249
+
250
+ const resolveLlmPages = (
251
+ data: Awaited<ReturnType<typeof loadTenantUrlData>>
252
+ ): (
253
+ | {
254
+ kind: "content";
255
+ relativePath: string;
256
+ slug: string;
257
+ title: string;
258
+ description?: string;
259
+ }
260
+ | {
261
+ kind: "openapi";
262
+ entry: OpenApiEntry;
263
+ slug: string;
264
+ title: string;
265
+ description?: string;
266
+ }
267
+ )[] =>
268
+ data.pages
269
+ .map((slug) => {
270
+ const contentEntry = data.contentIndex.bySlug.get(slug);
271
+ if (
272
+ contentEntry &&
273
+ contentEntry.kind === "entry" &&
274
+ !contentEntry.hidden &&
275
+ !data.hiddenSlugs.has(contentEntry.slug)
276
+ ) {
277
+ return {
278
+ description: contentEntry.description,
279
+ kind: "content" as const,
280
+ relativePath: contentEntry.relativePath,
281
+ slug: contentEntry.slug,
282
+ title: contentEntry.title,
283
+ };
284
+ }
285
+
286
+ const openApiEntry = data.registry.bySlug.get(slug);
287
+ if (!openApiEntry) {
288
+ return null;
289
+ }
290
+
291
+ return {
292
+ description: openApiEntry.operation.description,
293
+ entry: openApiEntry,
294
+ kind: "openapi" as const,
295
+ slug,
296
+ title: openApiEntry.operation.summary ?? openApiEntry.identifier,
297
+ };
298
+ })
299
+ .filter((page) => page !== null);
300
+
301
+ const FRONTMATTER_REGEX = /^---\s*\n[\s\S]*?\n---\s*\n?/;
302
+ const LEADING_H1_REGEX = /^#\s+([^\r\n]+)(?:\r?\n(?:\r?\n)?)?/;
303
+
304
+ const stripFrontmatter = (source: string) =>
305
+ source.replace(FRONTMATTER_REGEX, "").trim();
306
+
307
+ const stripMatchingLeadingH1 = (source: string, title: string) => {
308
+ const trimmed = source.trimStart();
309
+ const match = LEADING_H1_REGEX.exec(trimmed);
310
+ if (!match) {
311
+ return trimmed.trim();
312
+ }
313
+
314
+ const [headingLine = "", headingTitle = ""] = match;
315
+ if (slugify(headingTitle) !== slugify(title)) {
316
+ return trimmed.trim();
317
+ }
318
+
319
+ return trimmed.slice(headingLine.length).trim();
320
+ };
321
+
322
+ const formatMarkdownPage = (title: string, source: string) => {
323
+ const content = stripMatchingLeadingH1(source, title);
324
+ if (!content) {
325
+ return `# ${title}`;
326
+ }
327
+
328
+ return `# ${title}\n\n${content}`;
329
+ };
330
+
331
+ const formatMarkdownPageSection = (
332
+ title: string,
333
+ url: string,
334
+ source: string
335
+ ) => {
336
+ const content = stripMatchingLeadingH1(source, title);
337
+ if (!content) {
338
+ return `# ${title} (${url})`;
339
+ }
340
+
341
+ return `# ${title} (${url})\n\n${content}`;
342
+ };
343
+
344
+ const buildRuntimeUtilityIndex = async (
345
+ tenant: Tenant
346
+ ): Promise<UtilityIndex> => {
347
+ const data = await loadTenantUrlData(tenant);
348
+ const pages = await Promise.all(
349
+ resolveLlmPages(data).map(async (page) => {
350
+ if (page.kind === "openapi") {
351
+ return {
352
+ content: formatOpenApiPageText(page.entry),
353
+ description: page.description,
354
+ slug: page.slug,
355
+ title: page.title,
356
+ };
357
+ }
358
+
359
+ const raw = await loadContentSource(
360
+ data.contentSource,
361
+ page.relativePath
362
+ );
363
+ return {
364
+ content: stripFrontmatter(raw),
365
+ description: page.description,
366
+ slug: page.slug,
367
+ title: page.title,
368
+ };
369
+ })
370
+ );
371
+
372
+ return {
373
+ description: data.config.description,
374
+ name: data.config.name,
375
+ pages,
376
+ };
377
+ };
378
+
379
+ const loadTenantUtilityIndex = async (tenant: Tenant) =>
380
+ await tenantUtilityIndexCache.getOrCreate(
381
+ getTenantStaticCacheKey(tenant),
382
+ async () => {
383
+ const prebuilt = await loadPrebuiltUtilityIndex(
384
+ getTenantContentSource(tenant)
385
+ );
386
+ if (prebuilt) {
387
+ return prebuilt;
388
+ }
389
+
390
+ return await buildRuntimeUtilityIndex(tenant);
391
+ }
392
+ );
393
+
394
+ const loadTenantUtilityTemplate = async (tenant: Tenant, path: string) => {
395
+ const contentSource = getTenantContentSource(tenant);
396
+ try {
397
+ return await contentSource.readFile(path);
398
+ } catch (error) {
399
+ if (isMissingContentFileError(error)) {
400
+ return null;
401
+ }
402
+
403
+ throw error;
404
+ }
405
+ };
406
+
407
+ export const clearTenantStaticCaches = () => {
408
+ tenantUrlDataCache.clear();
409
+ tenantUtilityIndexCache.clear();
410
+ };
411
+
412
+ export const clearTenantStaticCachesForTenant = (tenantId: string) => {
413
+ tenantUrlDataCache.deleteByPrefix(tenantId);
414
+ tenantUtilityIndexCache.deleteByPrefix(tenantId);
415
+ };
416
+
417
+ export const buildTenantSitemapXml = async (
418
+ tenant: Tenant,
419
+ context: TenantRequestContext = {}
420
+ ) => {
421
+ const prebuilt = await loadTenantUtilityTemplate(
422
+ tenant,
423
+ PREBUILT_UTILITY_SITEMAP_PATH
424
+ );
425
+ if (prebuilt) {
426
+ return renderUtilityTemplate(prebuilt, tenant, context);
427
+ }
428
+
429
+ const { pages } = await loadTenantUtilityIndex(tenant);
430
+ const origin = getCanonicalOrigin(tenant, context);
431
+ const basePath = getCanonicalDocBasePath(tenant, context);
432
+ const urls = pages.map(
433
+ (page) => `${origin}${toDocHref(page.slug, basePath)}`
434
+ );
435
+
436
+ return `<?xml version="1.0" encoding="UTF-8"?>
437
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
438
+ ${urls.map((url) => ` <url><loc>${url}</loc></url>`).join("\n")}
439
+ </urlset>`;
440
+ };
441
+
442
+ export const buildTenantRobotsTxt = (
443
+ tenant: Tenant,
444
+ context: TenantRequestContext = {}
445
+ ) => {
446
+ const origin = getCanonicalOrigin(tenant, context);
447
+ const basePath = getCanonicalDocBasePath(tenant, context);
448
+ return `User-agent: *
449
+ Allow: /
450
+ Sitemap: ${origin}${toDocHref("sitemap.xml", basePath)}
451
+
452
+ # LLM-friendly content
453
+ # ${origin}${toDocHref("llms.txt", basePath)} - Index of all documentation pages
454
+ # ${origin}${toDocHref("llms-full.txt", basePath)} - Full documentation content
455
+ # Append .md to any page URL for raw markdown`;
456
+ };
457
+
458
+ export const buildTenantLlmsTxt = async (
459
+ tenant: Tenant,
460
+ context: TenantRequestContext = {}
461
+ ) => {
462
+ const prebuilt = await loadTenantUtilityTemplate(
463
+ tenant,
464
+ PREBUILT_UTILITY_LLMS_PATH
465
+ );
466
+ if (prebuilt) {
467
+ return renderUtilityTemplate(prebuilt, tenant, context);
468
+ }
469
+
470
+ const data = await loadTenantUtilityIndex(tenant);
471
+ const origin = getCanonicalOrigin(tenant, context);
472
+ const basePath = getCanonicalDocBasePath(tenant, context);
473
+
474
+ const lines = [
475
+ `# ${data.name}`,
476
+ data.description ? `> ${data.description}` : null,
477
+ "",
478
+ `Sitemap: ${origin}${toDocHref("sitemap.xml", basePath)}`,
479
+ "",
480
+ "## Docs",
481
+ ...data.pages.map((page) => {
482
+ const url = `${origin}${toDocHref(page.slug, basePath)}`;
483
+ const desc = page.description ? `: ${page.description}` : "";
484
+ return `- [${page.title}](${url})${desc}`;
485
+ }),
486
+ ];
487
+
488
+ return lines.filter((line) => line !== null).join("\n");
489
+ };
490
+
491
+ export const buildTenantLlmsFullTxt = async (
492
+ tenant: Tenant,
493
+ context: TenantRequestContext = {}
494
+ ) => {
495
+ const prebuilt = await loadTenantUtilityTemplate(
496
+ tenant,
497
+ PREBUILT_UTILITY_LLMS_FULL_PATH
498
+ );
499
+ if (prebuilt) {
500
+ return renderUtilityTemplate(prebuilt, tenant, context);
501
+ }
502
+
503
+ const data = await loadTenantUtilityIndex(tenant);
504
+ const origin = getCanonicalOrigin(tenant, context);
505
+ const basePath = getCanonicalDocBasePath(tenant, context);
506
+ const parts = data.pages.map((page) => {
507
+ const url = `${origin}${toDocHref(page.slug, basePath)}`;
508
+ return formatMarkdownPageSection(page.title, url, page.content);
509
+ });
510
+
511
+ return parts.join("\n\n");
512
+ };
513
+
514
+ export const getLlmPageText = async (tenant: Tenant, slug: string) => {
515
+ const prebuilt = await loadTenantUtilityTemplate(
516
+ tenant,
517
+ getPrebuiltUtilityLlmPagePath(slug)
518
+ );
519
+ if (prebuilt) {
520
+ return prebuilt;
521
+ }
522
+
523
+ const data = await loadTenantUtilityIndex(tenant);
524
+ const page = data.pages.find((item) => item.slug === slug);
525
+ if (!page) {
526
+ return null;
527
+ }
528
+ return formatMarkdownPage(page.title, page.content);
529
+ };
@@ -0,0 +1,62 @@
1
+ import type { TenantResolution } from "@repo/contracts";
2
+
3
+ export interface TenantRequestContext {
4
+ basePath?: string;
5
+ protocol?: string;
6
+ requestedHost?: string;
7
+ strategy?: TenantResolution["strategy"] | null;
8
+ }
9
+
10
+ const TENANT_UTILITY_CONTEXT_PARAMS = {
11
+ basePath: "__blodemd_base_path",
12
+ host: "__blodemd_host",
13
+ protocol: "__blodemd_protocol",
14
+ strategy: "__blodemd_strategy",
15
+ } as const;
16
+
17
+ export const applyTenantUtilityContextSearchParams = (
18
+ url: URL,
19
+ context: TenantRequestContext
20
+ ) => {
21
+ if (context.requestedHost) {
22
+ url.searchParams.set(
23
+ TENANT_UTILITY_CONTEXT_PARAMS.host,
24
+ context.requestedHost
25
+ );
26
+ }
27
+
28
+ url.searchParams.set(
29
+ TENANT_UTILITY_CONTEXT_PARAMS.basePath,
30
+ context.basePath ?? ""
31
+ );
32
+ url.searchParams.set(
33
+ TENANT_UTILITY_CONTEXT_PARAMS.protocol,
34
+ context.protocol ?? "https"
35
+ );
36
+ url.searchParams.set(
37
+ TENANT_UTILITY_CONTEXT_PARAMS.strategy,
38
+ context.strategy ?? ""
39
+ );
40
+ };
41
+
42
+ export const getTenantRequestContextFromUrl = (
43
+ url: URL
44
+ ): TenantRequestContext | null => {
45
+ const requestedHost = url.searchParams.get(
46
+ TENANT_UTILITY_CONTEXT_PARAMS.host
47
+ );
48
+ const basePath = url.searchParams.get(TENANT_UTILITY_CONTEXT_PARAMS.basePath);
49
+ const protocol = url.searchParams.get(TENANT_UTILITY_CONTEXT_PARAMS.protocol);
50
+ const strategy = url.searchParams.get(TENANT_UTILITY_CONTEXT_PARAMS.strategy);
51
+
52
+ if (!(requestedHost || basePath !== null || protocol || strategy)) {
53
+ return null;
54
+ }
55
+
56
+ return {
57
+ basePath: basePath ?? undefined,
58
+ protocol: protocol || undefined,
59
+ requestedHost: requestedHost || undefined,
60
+ strategy: (strategy || null) as TenantRequestContext["strategy"],
61
+ };
62
+ };
@@ -0,0 +1,68 @@
1
+ import { TenantSchema } from "@repo/contracts";
2
+ import type { Tenant } from "@repo/models";
3
+
4
+ import { getTenantDocsPath } from "./content-root";
5
+ import { getTenantEdgeSlugRecord, isEdgeConfigEnabled } from "./edge-config";
6
+ import { docsApiBase } from "./env";
7
+ import { createTimedPromiseCache } from "./server-cache";
8
+
9
+ export const getProjectTag = (slug: string) => `project:${slug}`;
10
+
11
+ const TENANT_REVALIDATE_SECONDS = 3600;
12
+
13
+ const mapTenant = (tenant: {
14
+ activeDeploymentId?: string;
15
+ activeDeploymentManifestUrl?: string;
16
+ id: string;
17
+ slug: string;
18
+ name: string;
19
+ description?: string;
20
+ primaryDomain: string;
21
+ subdomain: string;
22
+ customDomains: string[];
23
+ pathPrefix?: string;
24
+ status: "active" | "disabled";
25
+ }): Tenant => ({
26
+ ...tenant,
27
+ docsPath: getTenantDocsPath(tenant.slug),
28
+ });
29
+
30
+ const tenantCache = createTimedPromiseCache<string, Tenant | null>({
31
+ maxEntries: 512,
32
+ ttlMs: TENANT_REVALIDATE_SECONDS * 1000,
33
+ });
34
+
35
+ const fetchTenantFromApi = async (slug: string): Promise<Tenant | null> => {
36
+ const url = new URL(`/tenants/${slug}`, docsApiBase);
37
+ const response = await fetch(url.toString(), {
38
+ next: {
39
+ revalidate: TENANT_REVALIDATE_SECONDS,
40
+ tags: [getProjectTag(slug), "tenants"],
41
+ },
42
+ });
43
+ if (!response.ok) {
44
+ return null;
45
+ }
46
+ const json = (await response.json()) as unknown;
47
+ const parsed = TenantSchema.safeParse(json);
48
+ if (!parsed.success) {
49
+ return null;
50
+ }
51
+ return mapTenant(parsed.data);
52
+ };
53
+
54
+ const fetchTenant = async (slug: string): Promise<Tenant | null> => {
55
+ if (isEdgeConfigEnabled()) {
56
+ const edgeRecord = await getTenantEdgeSlugRecord(slug);
57
+ return edgeRecord ? mapTenant(edgeRecord.tenant) : null;
58
+ }
59
+
60
+ return await fetchTenantFromApi(slug);
61
+ };
62
+
63
+ export const clearTenantCache = () => {
64
+ tenantCache.clear();
65
+ };
66
+
67
+ export const getTenantBySlug = async (slug: string) =>
68
+ await tenantCache.getOrCreate(slug, async () => await fetchTenant(slug));
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ const MOBILE_BREAKPOINT = 768;
4
+
5
+ export const useIsMobile = () => {
6
+ const [isMobile, setIsMobile] = useState<boolean | undefined>();
7
+
8
+ useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
12
+ };
13
+ mql.addEventListener("change", onChange);
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
15
+ return () => mql.removeEventListener("change", onChange);
16
+ }, []);
17
+
18
+ return !!isMobile;
19
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blodemd",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Blode.md CLI",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,6 +40,7 @@
40
40
  "@base-ui/react": "^1.3.0",
41
41
  "@clack/prompts": "^1.0.0",
42
42
  "@mdx-js/mdx": "^3.1.1",
43
+ "@repo/common": "*",
43
44
  "@shikijs/rehype": "^4.0.2",
44
45
  "@tailwindcss/postcss": "^4.2.2",
45
46
  "@types/node": "^22.19.15",
@@ -81,6 +82,6 @@
81
82
  "ultracite": "^7.3.2"
82
83
  },
83
84
  "engines": {
84
- "node": ">=20.17.0 <25"
85
+ "node": ">=20.17.0"
85
86
  }
86
87
  }