blodemd 0.0.11 → 0.0.13

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 (86) hide show
  1. package/README.md +11 -47
  2. package/dev-server/app/layout.tsx +1 -1
  3. package/dev-server/next.config.js +19 -9
  4. package/dev-server/tsconfig.json +0 -3
  5. package/dist/cli.mjs +732 -123
  6. package/dist/cli.mjs.map +1 -1
  7. package/docs/app/globals.css +15 -1
  8. package/docs/components/api/api-playground.tsx +2 -2
  9. package/docs/components/docs/copy-page-menu.tsx +55 -27
  10. package/docs/components/docs/doc-header.tsx +1 -1
  11. package/docs/components/docs/doc-shell.tsx +89 -88
  12. package/docs/components/docs/doc-sidebar.tsx +6 -3
  13. package/docs/components/docs/doc-toc.tsx +1 -1
  14. package/docs/components/docs/mobile-nav.tsx +8 -16
  15. package/docs/components/docs/sidebar-scroll-area.tsx +58 -0
  16. package/docs/components/git/repo-picker.tsx +526 -0
  17. package/docs/components/mdx/agent-instructions.tsx +17 -0
  18. package/docs/components/mdx/code-block.tsx +6 -1
  19. package/docs/components/mdx/code-group.tsx +1 -1
  20. package/docs/components/mdx/iframe.tsx +62 -0
  21. package/docs/components/mdx/index.tsx +4 -0
  22. package/docs/components/mdx/tabs.tsx +5 -5
  23. package/docs/components/mdx/video.tsx +45 -12
  24. package/docs/components/third-parties.tsx +29 -0
  25. package/docs/components/ui/badge.tsx +61 -0
  26. package/docs/components/ui/breadcrumb.tsx +61 -41
  27. package/docs/components/ui/button-group.tsx +83 -0
  28. package/docs/components/ui/button.tsx +30 -55
  29. package/docs/components/ui/command.tsx +32 -4
  30. package/docs/components/ui/copy-button.tsx +12 -19
  31. package/docs/components/ui/dialog.tsx +50 -1
  32. package/docs/components/ui/input.tsx +16 -97
  33. package/docs/components/ui/kbd.tsx +98 -0
  34. package/docs/components/ui/morph-icon.tsx +79 -0
  35. package/docs/components/ui/popover.tsx +225 -30
  36. package/docs/components/ui/search.tsx +0 -9
  37. package/docs/components/ui/sheet.tsx +30 -1
  38. package/docs/components/ui/sidebar.tsx +332 -7
  39. package/docs/components/ui/site-footer.tsx +6 -4
  40. package/docs/components/ui/skeleton.tsx +11 -0
  41. package/docs/components/ui/switch.tsx +32 -0
  42. package/docs/components/ui/tabs.tsx +138 -0
  43. package/docs/lib/api-client.ts +72 -0
  44. package/docs/lib/contextual-options.ts +9 -0
  45. package/docs/lib/dashboard-session.ts +167 -0
  46. package/docs/lib/db.ts +13 -0
  47. package/docs/lib/env.ts +4 -3
  48. package/docs/lib/etag.ts +22 -0
  49. package/docs/lib/github-install.ts +33 -0
  50. package/docs/lib/project-authz.ts +46 -0
  51. package/docs/lib/routes.ts +5 -1
  52. package/docs/lib/supabase.ts +30 -6
  53. package/docs/lib/tenancy.ts +1 -0
  54. package/docs/lib/tenant-static.ts +206 -4
  55. package/docs/lib/tenants.ts +5 -1
  56. package/docs/lib/time-ago.ts +24 -0
  57. package/docs/lib/use-tab-observer.ts +71 -0
  58. package/package.json +3 -1
  59. package/packages/@repo/common/package.json +2 -2
  60. package/packages/@repo/contracts/dist/git.d.ts +28 -0
  61. package/packages/@repo/contracts/dist/git.d.ts.map +1 -0
  62. package/packages/@repo/contracts/dist/git.js +24 -0
  63. package/packages/@repo/contracts/dist/index.d.ts +1 -1
  64. package/packages/@repo/contracts/dist/index.d.ts.map +1 -1
  65. package/packages/@repo/contracts/dist/index.js +1 -1
  66. package/packages/@repo/contracts/package.json +2 -2
  67. package/packages/@repo/contracts/src/git.ts +31 -0
  68. package/packages/@repo/contracts/src/index.ts +1 -1
  69. package/packages/@repo/models/dist/docs-config.d.ts +6 -0
  70. package/packages/@repo/models/dist/docs-config.d.ts.map +1 -1
  71. package/packages/@repo/models/dist/docs-config.js +1 -0
  72. package/packages/@repo/models/package.json +2 -2
  73. package/packages/@repo/models/src/docs-config.ts +1 -0
  74. package/packages/@repo/prebuild/package.json +2 -2
  75. package/packages/@repo/previewing/dist/index.d.ts +3 -0
  76. package/packages/@repo/previewing/dist/index.d.ts.map +1 -1
  77. package/packages/@repo/previewing/dist/index.js +48 -0
  78. package/packages/@repo/previewing/package.json +2 -2
  79. package/packages/@repo/previewing/src/index.ts +56 -0
  80. package/packages/@repo/validation/package.json +2 -2
  81. package/packages/@repo/validation/src/blodemd-docs-schema.json +1 -0
  82. package/scripts/prepare-package.mjs +14 -0
  83. package/packages/@repo/contracts/dist/api-key.d.ts +0 -30
  84. package/packages/@repo/contracts/dist/api-key.d.ts.map +0 -1
  85. package/packages/@repo/contracts/dist/api-key.js +0 -20
  86. package/packages/@repo/contracts/src/api-key.ts +0 -27
@@ -0,0 +1,72 @@
1
+ import { docsApiBase } from "./env";
2
+
3
+ interface ApiRequestOptions extends Omit<RequestInit, "body" | "headers"> {
4
+ accessToken?: string | null;
5
+ body?: unknown;
6
+ headers?: Record<string, string>;
7
+ }
8
+
9
+ export class ApiError extends Error {
10
+ status: number;
11
+ details?: unknown;
12
+
13
+ constructor(message: string, status: number, details?: unknown) {
14
+ super(message);
15
+ this.name = "ApiError";
16
+ this.status = status;
17
+ this.details = details;
18
+ }
19
+ }
20
+
21
+ const buildUrl = (path: string): string => {
22
+ if (path.startsWith("http://") || path.startsWith("https://")) {
23
+ return path;
24
+ }
25
+ const url = new URL(path.startsWith("/") ? path : `/${path}`, docsApiBase);
26
+ return url.toString();
27
+ };
28
+
29
+ export const apiFetch = async <T = unknown>(
30
+ path: string,
31
+ options: ApiRequestOptions = {}
32
+ ): Promise<T> => {
33
+ const { accessToken, body, headers, ...rest } = options;
34
+ const finalHeaders: Record<string, string> = {
35
+ Accept: "application/json",
36
+ ...headers,
37
+ };
38
+ if (accessToken) {
39
+ finalHeaders.Authorization = `Bearer ${accessToken}`;
40
+ }
41
+ let serializedBody: string | undefined;
42
+ if (body !== undefined) {
43
+ finalHeaders["Content-Type"] ??= "application/json";
44
+ serializedBody = JSON.stringify(body);
45
+ }
46
+
47
+ const response = await fetch(buildUrl(path), {
48
+ ...rest,
49
+ body: serializedBody,
50
+ headers: finalHeaders,
51
+ });
52
+
53
+ const text = await response.text();
54
+ let json: unknown;
55
+ if (text) {
56
+ try {
57
+ json = JSON.parse(text);
58
+ } catch {
59
+ json = text;
60
+ }
61
+ }
62
+
63
+ if (!response.ok) {
64
+ const message =
65
+ typeof json === "object" && json && "error" in json
66
+ ? String((json as { error: unknown }).error)
67
+ : `Request failed: ${response.status}`;
68
+ throw new ApiError(message, response.status, json);
69
+ }
70
+
71
+ return json as T;
72
+ };
@@ -69,6 +69,12 @@ export const builtinOptions: Record<
69
69
  title: "Connect to Devin",
70
70
  type: "link",
71
71
  },
72
+ gemini: {
73
+ description: "Ask questions about this page",
74
+ iconName: "GoogleColoredIcon",
75
+ title: "Open in Gemini",
76
+ type: "link",
77
+ },
72
78
  grok: {
73
79
  description: "Ask questions about this page",
74
80
  iconName: "GrokIcon",
@@ -160,6 +166,9 @@ export const buildBuiltinUrl = (
160
166
  case "aistudio": {
161
167
  return `https://aistudio.google.com/prompts/new_chat?q=${encoded(askPrompt(pageUrl))}`;
162
168
  }
169
+ case "gemini": {
170
+ return `https://gemini.google.com/app?q=${encoded(askPrompt(pageUrl))}`;
171
+ }
163
172
  case "devin": {
164
173
  return `https://app.devin.ai/sessions?url=${encoded(pageUrl)}`;
165
174
  }
@@ -0,0 +1,167 @@
1
+ import {
2
+ createRemoteJWKSet,
3
+ decodeProtectedHeader,
4
+ errors as joseErrors,
5
+ jwtVerify,
6
+ } from "jose";
7
+ import { cookies } from "next/headers";
8
+ import { cache } from "react";
9
+
10
+ export interface DashboardSession {
11
+ accessToken: string;
12
+ authId: string;
13
+ userEmail: string;
14
+ userName: string;
15
+ }
16
+
17
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
18
+ const supabaseJwtSecret = process.env.SUPABASE_JWT_SECRET ?? "";
19
+
20
+ let cachedSecret: Uint8Array | null = null;
21
+ const getHmacKey = (): Uint8Array | null => {
22
+ if (!supabaseJwtSecret) {
23
+ return null;
24
+ }
25
+ if (!cachedSecret) {
26
+ cachedSecret = new TextEncoder().encode(supabaseJwtSecret);
27
+ }
28
+ return cachedSecret;
29
+ };
30
+
31
+ let cachedJwks: ReturnType<typeof createRemoteJWKSet> | null = null;
32
+ const getJwks = () => {
33
+ if (!supabaseUrl) {
34
+ return null;
35
+ }
36
+ if (!cachedJwks) {
37
+ cachedJwks = createRemoteJWKSet(
38
+ new URL(`${supabaseUrl.replace(/\/$/, "")}/auth/v1/.well-known/jwks.json`)
39
+ );
40
+ }
41
+ return cachedJwks;
42
+ };
43
+
44
+ const getIssuer = (): string | undefined =>
45
+ supabaseUrl ? `${supabaseUrl.replace(/\/$/, "")}/auth/v1` : undefined;
46
+
47
+ const verifyAccessToken = (token: string) => {
48
+ const header = decodeProtectedHeader(token);
49
+ const options = {
50
+ audience: "authenticated",
51
+ clockTolerance: "5s",
52
+ issuer: getIssuer(),
53
+ };
54
+ if (header.alg === "HS256") {
55
+ const secret = getHmacKey();
56
+ if (!secret) {
57
+ throw new Error("SUPABASE_JWT_SECRET required for HS256 tokens.");
58
+ }
59
+ return jwtVerify(token, secret, { ...options, algorithms: ["HS256"] });
60
+ }
61
+ const jwks = getJwks();
62
+ if (!jwks) {
63
+ throw new Error("SUPABASE_URL required for asymmetric token verification.");
64
+ }
65
+ return jwtVerify(token, jwks, options);
66
+ };
67
+
68
+ // Supabase's auth-helpers cookie name is derived from the project ref. We
69
+ // match the default pattern the browser client writes: `sb-<ref>-auth-token`.
70
+ const getProjectRef = (): string | null => {
71
+ if (!supabaseUrl) {
72
+ return null;
73
+ }
74
+ const match = supabaseUrl.match(/https?:\/\/([^.]+)\./);
75
+ return match?.[1] ?? null;
76
+ };
77
+
78
+ interface StoredSupabaseSession {
79
+ access_token?: unknown;
80
+ user?: {
81
+ email?: unknown;
82
+ user_metadata?: { full_name?: unknown; name?: unknown };
83
+ };
84
+ }
85
+
86
+ const parseCookieValue = (value: string): StoredSupabaseSession | null => {
87
+ // Supabase SSR stores either a JSON string or an array; both are URL-encoded.
88
+ try {
89
+ const decoded = value.startsWith("base64-")
90
+ ? Buffer.from(value.slice(7), "base64").toString("utf8")
91
+ : decodeURIComponent(value);
92
+ const parsed = JSON.parse(decoded);
93
+ if (Array.isArray(parsed)) {
94
+ const [sessionBlob] = parsed;
95
+ return typeof sessionBlob === "object"
96
+ ? (sessionBlob as StoredSupabaseSession)
97
+ : null;
98
+ }
99
+ return parsed as StoredSupabaseSession;
100
+ } catch {
101
+ return null;
102
+ }
103
+ };
104
+
105
+ const readChunkedCookie = async (
106
+ baseName: string
107
+ ): Promise<StoredSupabaseSession | null> => {
108
+ const store = await cookies();
109
+ const direct = store.get(baseName)?.value;
110
+ if (direct) {
111
+ return parseCookieValue(direct);
112
+ }
113
+
114
+ // Supabase splits large cookies into baseName.0, baseName.1, ...
115
+ const chunks: string[] = [];
116
+ for (let i = 0; i < 10; i += 1) {
117
+ const chunk = store.get(`${baseName}.${i}`)?.value;
118
+ if (!chunk) {
119
+ break;
120
+ }
121
+ chunks.push(chunk);
122
+ }
123
+ if (chunks.length === 0) {
124
+ return null;
125
+ }
126
+ return parseCookieValue(chunks.join(""));
127
+ };
128
+
129
+ const asString = (value: unknown): string | null =>
130
+ typeof value === "string" ? value : null;
131
+
132
+ export const getDashboardSession = cache(
133
+ async (): Promise<DashboardSession | null> => {
134
+ const ref = getProjectRef();
135
+ if (!ref) {
136
+ return null;
137
+ }
138
+
139
+ const stored = await readChunkedCookie(`sb-${ref}-auth-token`);
140
+ const accessToken = asString(stored?.access_token);
141
+ if (!accessToken) {
142
+ return null;
143
+ }
144
+
145
+ let authId: string;
146
+ try {
147
+ const { payload } = await verifyAccessToken(accessToken);
148
+ const sub = asString(payload.sub);
149
+ if (!sub) {
150
+ return null;
151
+ }
152
+ authId = sub;
153
+ } catch (error) {
154
+ if (!(error instanceof joseErrors.JOSEError)) {
155
+ throw error;
156
+ }
157
+ return null;
158
+ }
159
+
160
+ const userEmail = asString(stored?.user?.email) ?? "";
161
+ const metadata = stored?.user?.user_metadata;
162
+ const userName =
163
+ asString(metadata?.full_name) ?? asString(metadata?.name) ?? userEmail;
164
+
165
+ return { accessToken, authId, userEmail, userName };
166
+ }
167
+ );
package/docs/lib/db.ts ADDED
@@ -0,0 +1,13 @@
1
+ import {
2
+ DeploymentDao,
3
+ DomainDao,
4
+ GitConnectionDao,
5
+ ProjectDao,
6
+ UserDao,
7
+ } from "@repo/db";
8
+
9
+ export const projectDao = new ProjectDao();
10
+ export const deploymentDao = new DeploymentDao();
11
+ export const userDao = new UserDao();
12
+ export const gitConnectionDao = new GitConnectionDao();
13
+ export const domainDao = new DomainDao();
package/docs/lib/env.ts CHANGED
@@ -10,10 +10,11 @@ const readTrimmedEnv = (name: string) => {
10
10
  return trimmed;
11
11
  };
12
12
 
13
+ // Must reference process.env.NEXT_PUBLIC_API_URL as a literal so Next.js
14
+ // inlines it into the client bundle. Dynamic access via readTrimmedEnv is
15
+ // not replaced at build time and would always be undefined on the client.
13
16
  export const docsApiBase =
14
- readTrimmedEnv("DOCS_API_URL") ??
15
- readTrimmedEnv("NEXT_PUBLIC_API_URL") ??
16
- "http://localhost:4000";
17
+ process.env.NEXT_PUBLIC_API_URL?.trim() || "http://localhost:4000";
17
18
 
18
19
  export const platformAssetPrefix =
19
20
  readTrimmedEnv("PLATFORM_ASSET_PREFIX") ?? "";
@@ -0,0 +1,22 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { NextResponse } from "next/server";
4
+
5
+ export const computeETag = (content: string): string => {
6
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
7
+ return `"${hash}"`;
8
+ };
9
+
10
+ export const handleIfNoneMatch = (
11
+ request: Request,
12
+ etag: string
13
+ ): NextResponse | null => {
14
+ const ifNoneMatch = request.headers.get("if-none-match");
15
+ if (ifNoneMatch && ifNoneMatch === etag) {
16
+ return new NextResponse(null, {
17
+ headers: { ETag: etag },
18
+ status: 304,
19
+ });
20
+ }
21
+ return null;
22
+ };
@@ -0,0 +1,33 @@
1
+ import { apiFetch } from "@/lib/api-client";
2
+
3
+ export const GITHUB_INSTALL_STATE_KEY = "blodemd:install-state";
4
+
5
+ export interface PendingGithubInstall {
6
+ projectId: string;
7
+ projectSlug: string;
8
+ state: string;
9
+ }
10
+
11
+ export const startGithubInstall = async ({
12
+ accessToken,
13
+ projectId,
14
+ projectSlug,
15
+ }: {
16
+ accessToken: string;
17
+ projectId: string;
18
+ projectSlug: string;
19
+ }): Promise<void> => {
20
+ const result = await apiFetch<{ url: string; state: string }>(
21
+ `/projects/${projectId}/git/install-url`,
22
+ { accessToken, method: "POST" }
23
+ );
24
+ sessionStorage.setItem(
25
+ GITHUB_INSTALL_STATE_KEY,
26
+ JSON.stringify({
27
+ projectId,
28
+ projectSlug,
29
+ state: result.state,
30
+ })
31
+ );
32
+ window.location.assign(result.url);
33
+ };
@@ -0,0 +1,46 @@
1
+ import type { ProjectRecord, UserRecord } from "@repo/db";
2
+ import { cache } from "react";
3
+
4
+ import { getDashboardSession } from "./dashboard-session";
5
+ import { projectDao, userDao } from "./db";
6
+
7
+ export interface AuthorizedProjectContext {
8
+ accessToken: string;
9
+ project: ProjectRecord;
10
+ user: UserRecord;
11
+ }
12
+
13
+ export const resolveCurrentUser = cache(
14
+ async (): Promise<{
15
+ accessToken: string;
16
+ user: UserRecord;
17
+ } | null> => {
18
+ const session = await getDashboardSession();
19
+ if (!session) {
20
+ return null;
21
+ }
22
+ const user = await userDao.getByAuthId(session.authId);
23
+ if (!user) {
24
+ return null;
25
+ }
26
+ return { accessToken: session.accessToken, user };
27
+ }
28
+ );
29
+
30
+ export const getAuthorizedProjectBySlug = cache(
31
+ async (slug: string): Promise<AuthorizedProjectContext | null> => {
32
+ const session = await getDashboardSession();
33
+ if (!session) {
34
+ return null;
35
+ }
36
+ const result = await projectDao.getAuthorizedBySlug(session.authId, slug);
37
+ if (!result) {
38
+ return null;
39
+ }
40
+ return {
41
+ accessToken: session.accessToken,
42
+ project: result.project,
43
+ user: result.user,
44
+ };
45
+ }
46
+ );
@@ -27,7 +27,11 @@ export const toDocHref = (path: string, basePath = "") => {
27
27
 
28
28
  export const toMarkdownDocHref = (path: string, basePath = "") => {
29
29
  const href = toDocHref(path, basePath);
30
- return href === "/" ? "/.md" : `${href}.md`;
30
+ // Index pages use /index.md, not just .md appended to the path
31
+ if (href === "/" || href === basePath) {
32
+ return `${href}/index.md`.replaceAll(/\/+/g, "/");
33
+ }
34
+ return `${href}.md`;
31
35
  };
32
36
 
33
37
  export const getMarkdownExportSourcePath = (pathname: string) => {
@@ -1,13 +1,37 @@
1
- import { createClient } from "@supabase/supabase-js";
1
+ import { createBrowserClient, createServerClient } from "@supabase/ssr";
2
+ import type { CookieOptions } from "@supabase/ssr";
3
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
4
 
3
5
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
4
6
  const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "";
5
7
 
6
- let client: ReturnType<typeof createClient> | null = null;
8
+ let browserClient: SupabaseClient | null = null;
7
9
 
8
- export const createSupabaseClient = () => {
9
- if (!client) {
10
- client = createClient(supabaseUrl, supabaseAnonKey);
10
+ export const createSupabaseClient = (): SupabaseClient => {
11
+ if (!browserClient) {
12
+ browserClient = createBrowserClient(supabaseUrl, supabaseAnonKey);
11
13
  }
12
- return client;
14
+ return browserClient;
13
15
  };
16
+
17
+ interface CookieStore {
18
+ get: (name: string) => { value: string } | undefined;
19
+ set?: (name: string, value: string, options?: CookieOptions) => void;
20
+ }
21
+
22
+ export const createSupabaseServerClient = (
23
+ cookieStore: CookieStore
24
+ ): SupabaseClient =>
25
+ createServerClient(supabaseUrl, supabaseAnonKey, {
26
+ cookies: {
27
+ get(name: string) {
28
+ return cookieStore.get(name)?.value;
29
+ },
30
+ remove(name: string, options?: CookieOptions) {
31
+ cookieStore.set?.(name, "", { ...options, maxAge: 0 });
32
+ },
33
+ set(name: string, value: string, options?: CookieOptions) {
34
+ cookieStore.set?.(name, value, options);
35
+ },
36
+ },
37
+ });
@@ -16,6 +16,7 @@ const DEFAULT_RESERVED_PATHS = [
16
16
  "/_next",
17
17
  "/.well-known",
18
18
  "/api",
19
+ "/app",
19
20
  "/docs.json",
20
21
  "/favicon.ico",
21
22
  "/llms.txt",