@camox/api 0.2.0-alpha.5
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.
- package/LICENSE.md +110 -0
- package/README.md +21 -0
- package/package.json +54 -0
- package/src/authorization.ts +110 -0
- package/src/db.ts +45 -0
- package/src/durable-objects/ai-job-scheduler.ts +135 -0
- package/src/durable-objects/project-room.ts +16 -0
- package/src/index.ts +125 -0
- package/src/lib/broadcast-invalidation.ts +17 -0
- package/src/lib/content-markdown.ts +117 -0
- package/src/lib/cross-domain.ts +186 -0
- package/src/lib/lexical-state.ts +196 -0
- package/src/lib/query-keys.ts +36 -0
- package/src/lib/resolve-environment.ts +218 -0
- package/src/lib/schedule-ai-job.ts +21 -0
- package/src/lib/slug.ts +42 -0
- package/src/middleware.ts +10 -0
- package/src/orpc.ts +65 -0
- package/src/router.ts +19 -0
- package/src/routes/auth.ts +110 -0
- package/src/routes/block-definitions.ts +216 -0
- package/src/routes/blocks.ts +800 -0
- package/src/routes/files.ts +463 -0
- package/src/routes/layouts.ts +164 -0
- package/src/routes/pages.ts +818 -0
- package/src/routes/projects.ts +267 -0
- package/src/routes/repeatable-items.ts +463 -0
- package/src/schema.ts +310 -0
- package/src/types.ts +29 -0
- package/src/worker.ts +3 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export function contentToMarkdown(
|
|
2
|
+
toMarkdown: readonly string[],
|
|
3
|
+
schemaProperties: Record<string, any>,
|
|
4
|
+
content: Record<string, unknown>,
|
|
5
|
+
{ insideList = false } = {},
|
|
6
|
+
): string {
|
|
7
|
+
const parts: string[] = [];
|
|
8
|
+
|
|
9
|
+
for (const line of toMarkdown) {
|
|
10
|
+
const resolved = resolveLine(line, schemaProperties, content);
|
|
11
|
+
if (resolved !== null) parts.push(resolved);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return parts.join(insideList ? "\n" : "\n\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PLACEHOLDER_RE = /\{\{(\w+)\}\}/g;
|
|
18
|
+
|
|
19
|
+
function resolveLine(
|
|
20
|
+
line: string,
|
|
21
|
+
schemaProperties: Record<string, any>,
|
|
22
|
+
content: Record<string, unknown>,
|
|
23
|
+
): string | null {
|
|
24
|
+
const placeholders = [...line.matchAll(PLACEHOLDER_RE)].map((m) => m[1]);
|
|
25
|
+
if (placeholders.length === 0) return line;
|
|
26
|
+
|
|
27
|
+
const resolvedValues = placeholders.map((key) =>
|
|
28
|
+
resolveField(schemaProperties[key], content[key]),
|
|
29
|
+
);
|
|
30
|
+
if (resolvedValues.every((v) => !v)) return null;
|
|
31
|
+
|
|
32
|
+
return line.replace(PLACEHOLDER_RE, (_match, key: string) => {
|
|
33
|
+
return resolveField(schemaProperties[key], content[key]) ?? "";
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveField(schema: any, value: unknown): string | undefined {
|
|
38
|
+
if (value == null) return undefined;
|
|
39
|
+
const fieldType: string | undefined = schema?.fieldType;
|
|
40
|
+
|
|
41
|
+
if (fieldType === "String") {
|
|
42
|
+
const text = String(value);
|
|
43
|
+
if (!text) return undefined;
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (fieldType === "Link") {
|
|
48
|
+
const link = value as Record<string, unknown>;
|
|
49
|
+
const text = link.text ?? "";
|
|
50
|
+
const href = link.href ?? link.pageId ?? "";
|
|
51
|
+
if (!text && !href) return undefined;
|
|
52
|
+
return `[${text}](${href})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (fieldType === "Image") {
|
|
56
|
+
const img = value as Record<string, unknown>;
|
|
57
|
+
const alt = img.alt ?? "";
|
|
58
|
+
const filename = img.filename ?? "";
|
|
59
|
+
return ``;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (fieldType === "File") {
|
|
63
|
+
const file = value as Record<string, unknown>;
|
|
64
|
+
const filename = file.filename ?? "";
|
|
65
|
+
const url = file.url ?? "";
|
|
66
|
+
return `[${filename}](${url})`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (fieldType === "Embed") {
|
|
70
|
+
const url = String(value);
|
|
71
|
+
return url || undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (fieldType === "RepeatableItem") {
|
|
75
|
+
if (!Array.isArray(value)) return undefined;
|
|
76
|
+
const itemSchema = schema?.items?.properties;
|
|
77
|
+
if (!itemSchema) return undefined;
|
|
78
|
+
|
|
79
|
+
const itemToMarkdown: readonly string[] | undefined = schema?.toMarkdown;
|
|
80
|
+
|
|
81
|
+
const itemParts: string[] = [];
|
|
82
|
+
for (const item of value) {
|
|
83
|
+
const itemContent =
|
|
84
|
+
item && typeof item === "object" && "content" in item ? (item as any).content : item;
|
|
85
|
+
if (!itemContent || typeof itemContent !== "object") continue;
|
|
86
|
+
|
|
87
|
+
let md: string;
|
|
88
|
+
if (itemToMarkdown) {
|
|
89
|
+
md = contentToMarkdown(itemToMarkdown, itemSchema, itemContent as Record<string, unknown>, {
|
|
90
|
+
insideList: true,
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
const fieldParts: string[] = [];
|
|
94
|
+
for (const key of Object.keys(itemSchema)) {
|
|
95
|
+
const resolved = resolveField(
|
|
96
|
+
itemSchema[key],
|
|
97
|
+
(itemContent as Record<string, unknown>)[key],
|
|
98
|
+
);
|
|
99
|
+
if (resolved) fieldParts.push(resolved);
|
|
100
|
+
}
|
|
101
|
+
md = fieldParts.join(" — ");
|
|
102
|
+
}
|
|
103
|
+
if (!md) continue;
|
|
104
|
+
|
|
105
|
+
const lines = md.split("\n");
|
|
106
|
+
const listItem = [`- ${lines[0]}`, ...lines.slice(1).map((l) => ` ${l}`)].join("\n");
|
|
107
|
+
itemParts.push(listItem);
|
|
108
|
+
}
|
|
109
|
+
return itemParts.length > 0 ? itemParts.join("\n") : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (fieldType === "Boolean" || fieldType === "Enum") {
|
|
113
|
+
return String(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
2
|
+
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
|
3
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
4
|
+
import { generateRandomString } from "better-auth/crypto";
|
|
5
|
+
import { oneTimeToken as oneTimeTokenPlugin } from "better-auth/plugins";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cross-domain authentication plugin for better-auth.
|
|
10
|
+
*
|
|
11
|
+
* When the API server and frontend live on completely different domains,
|
|
12
|
+
* browsers won't send or accept cookies across origins. This plugin works
|
|
13
|
+
* around that by:
|
|
14
|
+
*
|
|
15
|
+
* 1. Reading cookies from a custom `Better-Auth-Cookie` request header
|
|
16
|
+
* (sent by the client plugin) and injecting them as real cookies.
|
|
17
|
+
* 2. Moving `set-cookie` response headers into a custom
|
|
18
|
+
* `Set-Better-Auth-Cookie` header so the client can persist them in
|
|
19
|
+
* localStorage.
|
|
20
|
+
* 3. Rewriting relative callback URLs to absolute URLs using `siteUrl`.
|
|
21
|
+
* 4. Generating a one-time token after OAuth callbacks and redirecting
|
|
22
|
+
* to the site with `?ott=<token>` so the frontend can exchange it
|
|
23
|
+
* for a session.
|
|
24
|
+
*
|
|
25
|
+
* Adapted from `@convex-dev/better-auth/plugins/cross-domain`.
|
|
26
|
+
*/
|
|
27
|
+
export function crossDomain({ siteUrl }: { siteUrl: string }) {
|
|
28
|
+
const oneTimeToken = oneTimeTokenPlugin();
|
|
29
|
+
|
|
30
|
+
const rewriteCallbackURL = (callbackURL?: string) => {
|
|
31
|
+
if (!callbackURL) return callbackURL;
|
|
32
|
+
if (!callbackURL.startsWith("/")) return callbackURL;
|
|
33
|
+
if (!siteUrl) return callbackURL;
|
|
34
|
+
return new URL(callbackURL, siteUrl).toString();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const isExpoNative = (ctx: { headers?: Headers }) => {
|
|
38
|
+
return ctx.headers?.has("expo-origin");
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id: "cross-domain",
|
|
43
|
+
init() {
|
|
44
|
+
return {
|
|
45
|
+
options: {
|
|
46
|
+
trustedOrigins: [siteUrl],
|
|
47
|
+
},
|
|
48
|
+
context: {
|
|
49
|
+
oauthConfig: {
|
|
50
|
+
storeStateStrategy: "database",
|
|
51
|
+
// We can't relay the state cookie across a 302 redirect from the
|
|
52
|
+
// identity provider. The state token is still verified against the
|
|
53
|
+
// database, so this only means we can't prevent an OAuth flow
|
|
54
|
+
// started in one browser from completing in another.
|
|
55
|
+
skipStateCookieCheck: true,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
hooks: {
|
|
61
|
+
before: [
|
|
62
|
+
// Inject the `Better-Auth-Cookie` header as a real cookie header
|
|
63
|
+
{
|
|
64
|
+
matcher(ctx) {
|
|
65
|
+
return (
|
|
66
|
+
Boolean(
|
|
67
|
+
ctx.request?.headers.has("better-auth-cookie") ||
|
|
68
|
+
ctx.headers?.has("better-auth-cookie"),
|
|
69
|
+
) && !isExpoNative(ctx)
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
73
|
+
const existingHeaders = (ctx.request?.headers || ctx.headers) as Headers;
|
|
74
|
+
const headers = new Headers(Object.fromEntries(existingHeaders?.entries()));
|
|
75
|
+
if (headers.get("authorization")) return;
|
|
76
|
+
const cookie = headers.get("better-auth-cookie");
|
|
77
|
+
if (!cookie) return;
|
|
78
|
+
headers.append("cookie", cookie);
|
|
79
|
+
return { context: { headers } };
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
// Rewrite relative callbackURL on email-verification GET requests
|
|
83
|
+
{
|
|
84
|
+
matcher: (ctx) =>
|
|
85
|
+
Boolean(
|
|
86
|
+
ctx.method === "GET" && ctx.path?.startsWith("/verify-email") && !isExpoNative(ctx),
|
|
87
|
+
),
|
|
88
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
89
|
+
if (ctx.query?.callbackURL) {
|
|
90
|
+
ctx.query.callbackURL = rewriteCallbackURL(ctx.query.callbackURL);
|
|
91
|
+
}
|
|
92
|
+
return { context: ctx };
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
// Rewrite relative callback URLs on POST requests
|
|
96
|
+
{
|
|
97
|
+
matcher: (ctx) => Boolean(ctx.method === "POST" && !isExpoNative(ctx)),
|
|
98
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
99
|
+
if (ctx.body?.callbackURL) {
|
|
100
|
+
ctx.body.callbackURL = rewriteCallbackURL(ctx.body.callbackURL);
|
|
101
|
+
}
|
|
102
|
+
if (ctx.body?.newUserCallbackURL) {
|
|
103
|
+
ctx.body.newUserCallbackURL = rewriteCallbackURL(ctx.body.newUserCallbackURL);
|
|
104
|
+
}
|
|
105
|
+
if (ctx.body?.errorCallbackURL) {
|
|
106
|
+
ctx.body.errorCallbackURL = rewriteCallbackURL(ctx.body.errorCallbackURL);
|
|
107
|
+
}
|
|
108
|
+
return { context: ctx };
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
after: [
|
|
113
|
+
// Move `set-cookie` → `Set-Better-Auth-Cookie` header
|
|
114
|
+
{
|
|
115
|
+
matcher(ctx) {
|
|
116
|
+
return (
|
|
117
|
+
Boolean(
|
|
118
|
+
ctx.request?.headers.has("better-auth-cookie") ||
|
|
119
|
+
ctx.headers?.has("better-auth-cookie"),
|
|
120
|
+
) && !isExpoNative(ctx)
|
|
121
|
+
);
|
|
122
|
+
},
|
|
123
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
124
|
+
const setCookie = ctx.context.responseHeaders?.get("set-cookie");
|
|
125
|
+
if (!setCookie) return;
|
|
126
|
+
ctx.context.responseHeaders?.delete("set-cookie");
|
|
127
|
+
ctx.setHeader("Set-Better-Auth-Cookie", setCookie);
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
// After OAuth / magic-link callbacks, generate a one-time token and
|
|
131
|
+
// redirect to the site URL with `?ott=<token>`
|
|
132
|
+
{
|
|
133
|
+
matcher: (ctx) =>
|
|
134
|
+
Boolean(
|
|
135
|
+
(ctx.path?.startsWith("/callback") ||
|
|
136
|
+
ctx.path?.startsWith("/oauth2/callback") ||
|
|
137
|
+
ctx.path?.startsWith("/magic-link/verify")) &&
|
|
138
|
+
!isExpoNative(ctx),
|
|
139
|
+
),
|
|
140
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
141
|
+
const session = ctx.context.newSession;
|
|
142
|
+
if (!session) {
|
|
143
|
+
ctx.context.logger.error("No session found");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const token = generateRandomString(32);
|
|
147
|
+
const expiresAt = new Date(Date.now() + 3 * 60 * 1000);
|
|
148
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
149
|
+
value: session.session.token,
|
|
150
|
+
identifier: `one-time-token:${token}`,
|
|
151
|
+
expiresAt,
|
|
152
|
+
});
|
|
153
|
+
const redirectTo = ctx.context.responseHeaders?.get("location");
|
|
154
|
+
if (!redirectTo) {
|
|
155
|
+
ctx.context.logger.error("No redirect to found");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const url = new URL(redirectTo);
|
|
159
|
+
url.searchParams.set("ott", token);
|
|
160
|
+
throw ctx.redirect(url.toString());
|
|
161
|
+
}),
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
endpoints: {
|
|
166
|
+
verifyOneTimeToken: createAuthEndpoint(
|
|
167
|
+
"/cross-domain/one-time-token/verify",
|
|
168
|
+
{
|
|
169
|
+
method: "POST",
|
|
170
|
+
body: z.object({
|
|
171
|
+
token: z.string(),
|
|
172
|
+
}),
|
|
173
|
+
},
|
|
174
|
+
async (ctx) => {
|
|
175
|
+
const response = await oneTimeToken.endpoints.verifyOneTimeToken({
|
|
176
|
+
...ctx,
|
|
177
|
+
returnHeaders: false,
|
|
178
|
+
returnStatus: false,
|
|
179
|
+
});
|
|
180
|
+
await setSessionCookie(ctx, response);
|
|
181
|
+
return response;
|
|
182
|
+
},
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
} satisfies BetterAuthPlugin;
|
|
186
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
const FORMAT_FLAGS = {
|
|
2
|
+
bold: 1,
|
|
3
|
+
italic: 2,
|
|
4
|
+
} as const;
|
|
5
|
+
|
|
6
|
+
const MARKDOWN_WRAPPERS: Record<string, (text: string) => string> = {
|
|
7
|
+
bold: (text) => `**${text}**`,
|
|
8
|
+
italic: (text) => `*${text}*`,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function lexicalTextToMarkdown(text: string, format: number): string {
|
|
12
|
+
let result = text;
|
|
13
|
+
for (const [key, wrapper] of Object.entries(MARKDOWN_WRAPPERS)) {
|
|
14
|
+
const flag = FORMAT_FLAGS[key as keyof typeof FORMAT_FLAGS];
|
|
15
|
+
if (flag && format & flag) {
|
|
16
|
+
result = wrapper(result);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isLexicalState(value: unknown): boolean {
|
|
23
|
+
if (typeof value === "object" && value !== null) {
|
|
24
|
+
return (value as any)?.root?.type === "root";
|
|
25
|
+
}
|
|
26
|
+
if (typeof value !== "string") return false;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(value);
|
|
29
|
+
return parsed?.root?.type === "root";
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function plainTextToLexicalState(text: string): Record<string, unknown> {
|
|
36
|
+
return {
|
|
37
|
+
root: {
|
|
38
|
+
children: [
|
|
39
|
+
{
|
|
40
|
+
children: [
|
|
41
|
+
{
|
|
42
|
+
detail: 0,
|
|
43
|
+
format: 0,
|
|
44
|
+
mode: "normal",
|
|
45
|
+
style: "",
|
|
46
|
+
text,
|
|
47
|
+
type: "text",
|
|
48
|
+
version: 1,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
direction: "ltr",
|
|
52
|
+
format: "",
|
|
53
|
+
indent: 0,
|
|
54
|
+
type: "paragraph",
|
|
55
|
+
version: 1,
|
|
56
|
+
textFormat: 0,
|
|
57
|
+
textStyle: "",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
direction: "ltr",
|
|
61
|
+
format: "",
|
|
62
|
+
indent: 0,
|
|
63
|
+
type: "root",
|
|
64
|
+
version: 1,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function lexicalStateToPlainText(serialized: string | Record<string, unknown>): string {
|
|
70
|
+
try {
|
|
71
|
+
const parsed = typeof serialized === "object" ? serialized : JSON.parse(serialized);
|
|
72
|
+
return extractText(parsed.root);
|
|
73
|
+
} catch {
|
|
74
|
+
return typeof serialized === "string" ? serialized : "";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractText(node: any): string {
|
|
79
|
+
if (node.type === "text") return node.text ?? "";
|
|
80
|
+
if (node.type === "linebreak") return "\n";
|
|
81
|
+
if (!node.children) return "";
|
|
82
|
+
|
|
83
|
+
const parts: string[] = [];
|
|
84
|
+
for (const child of node.children) {
|
|
85
|
+
parts.push(extractText(child));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (node.type === "paragraph" || node.type === "inline-paragraph" || node.type === "heading") {
|
|
89
|
+
return parts.join("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return parts.join("\n\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function lexicalStateToMarkdown(serialized: string | Record<string, unknown>): string {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = typeof serialized === "object" ? serialized : JSON.parse(serialized);
|
|
98
|
+
return extractMarkdown(parsed.root);
|
|
99
|
+
} catch {
|
|
100
|
+
return typeof serialized === "string" ? serialized : "";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractMarkdownFromNode(node: any): string {
|
|
105
|
+
if (node.type === "text") {
|
|
106
|
+
return lexicalTextToMarkdown(node.text ?? "", node.format ?? 0);
|
|
107
|
+
}
|
|
108
|
+
if (node.type === "linebreak") return "\n";
|
|
109
|
+
if (!node.children) return "";
|
|
110
|
+
|
|
111
|
+
const parts: string[] = [];
|
|
112
|
+
for (const child of node.children) {
|
|
113
|
+
parts.push(extractMarkdownFromNode(child));
|
|
114
|
+
}
|
|
115
|
+
return parts.join("");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extractMarkdown(node: any): string {
|
|
119
|
+
if (!node.children) return "";
|
|
120
|
+
|
|
121
|
+
const paragraphs: string[] = [];
|
|
122
|
+
for (const child of node.children) {
|
|
123
|
+
paragraphs.push(extractMarkdownFromNode(child));
|
|
124
|
+
}
|
|
125
|
+
return paragraphs.join("\n\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface TextSegment {
|
|
129
|
+
text: string;
|
|
130
|
+
format: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseInlineMarkdown(text: string): any[] {
|
|
134
|
+
const segments: TextSegment[] = [];
|
|
135
|
+
const regex = /(\*{1,3})((?:(?!\1).)+)\1/g;
|
|
136
|
+
let lastIndex = 0;
|
|
137
|
+
let match;
|
|
138
|
+
|
|
139
|
+
while ((match = regex.exec(text)) !== null) {
|
|
140
|
+
if (match.index > lastIndex) {
|
|
141
|
+
segments.push({ text: text.slice(lastIndex, match.index), format: 0 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const stars = match[1].length;
|
|
145
|
+
let format = 0;
|
|
146
|
+
if (stars === 1) format = FORMAT_FLAGS.italic;
|
|
147
|
+
else if (stars === 2) format = FORMAT_FLAGS.bold;
|
|
148
|
+
else if (stars === 3) format = FORMAT_FLAGS.bold | FORMAT_FLAGS.italic;
|
|
149
|
+
|
|
150
|
+
segments.push({ text: match[2], format });
|
|
151
|
+
lastIndex = match.index + match[0].length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (lastIndex < text.length) {
|
|
155
|
+
segments.push({ text: text.slice(lastIndex), format: 0 });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (segments.length === 0) {
|
|
159
|
+
segments.push({ text, format: 0 });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return segments.map((seg) => ({
|
|
163
|
+
detail: 0,
|
|
164
|
+
format: seg.format,
|
|
165
|
+
mode: "normal",
|
|
166
|
+
style: "",
|
|
167
|
+
text: seg.text,
|
|
168
|
+
type: "text",
|
|
169
|
+
version: 1,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function markdownToLexicalState(markdown: string): Record<string, unknown> {
|
|
174
|
+
const paragraphs = markdown.split(/\n\n+/);
|
|
175
|
+
const children = paragraphs.map((para) => ({
|
|
176
|
+
children: parseInlineMarkdown(para),
|
|
177
|
+
direction: "ltr" as const,
|
|
178
|
+
format: "" as const,
|
|
179
|
+
indent: 0,
|
|
180
|
+
type: "paragraph" as const,
|
|
181
|
+
version: 1,
|
|
182
|
+
textFormat: 0,
|
|
183
|
+
textStyle: "",
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
root: {
|
|
188
|
+
children,
|
|
189
|
+
direction: "ltr",
|
|
190
|
+
format: "",
|
|
191
|
+
indent: 0,
|
|
192
|
+
type: "root",
|
|
193
|
+
version: 1,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use TypeScript to enforce 'camox' as the first query key to ensure they're all namespaced.
|
|
3
|
+
* This is because the Tanstack Query client on the frontend may be shared with the user's routes,
|
|
4
|
+
* so we ensure there won't be any key collisions.
|
|
5
|
+
*/
|
|
6
|
+
export type QueryKey = [first: "camox", ...rest: Array<string | number>];
|
|
7
|
+
type QueryKeyGroup = Record<string, QueryKey | ((...args: any[]) => QueryKey)>;
|
|
8
|
+
|
|
9
|
+
export const queryKeys = {
|
|
10
|
+
pages: {
|
|
11
|
+
list: ["camox", "pages", "list"],
|
|
12
|
+
getByPath: (path: string) => ["camox", "pages", "getByPath", path],
|
|
13
|
+
getByPathAll: ["camox", "pages", "getByPath"],
|
|
14
|
+
getById: (id: number) => ["camox", "pages", "getById", id],
|
|
15
|
+
},
|
|
16
|
+
files: {
|
|
17
|
+
list: ["camox", "files", "list"],
|
|
18
|
+
get: (id: number) => ["camox", "files", "get", id],
|
|
19
|
+
},
|
|
20
|
+
blocks: {
|
|
21
|
+
get: (id: number) => ["camox", "blocks", "get", id],
|
|
22
|
+
getUsageCounts: ["camox", "blocks", "getUsageCounts"],
|
|
23
|
+
getPageMarkdown: (pageId: number) => ["camox", "blocks", "getPageMarkdown", pageId],
|
|
24
|
+
},
|
|
25
|
+
repeatableItems: {
|
|
26
|
+
get: (id: number) => ["camox", "repeatableItems", "get", id],
|
|
27
|
+
},
|
|
28
|
+
layouts: {
|
|
29
|
+
all: ["camox", "layouts"],
|
|
30
|
+
},
|
|
31
|
+
} satisfies Record<string, QueryKeyGroup>;
|
|
32
|
+
|
|
33
|
+
export type InvalidationMessage = {
|
|
34
|
+
type: "invalidate";
|
|
35
|
+
targets: QueryKey[];
|
|
36
|
+
};
|