@blinkk/root-cms 3.0.1-alpha.1 → 3.0.1-beta.2

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.
@@ -0,0 +1,921 @@
1
+ // core/ai.ts
2
+ import crypto from "crypto";
3
+ import { promises as fs } from "fs";
4
+ import path from "path";
5
+ import { createAnthropic } from "@ai-sdk/anthropic";
6
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
7
+ import { createOpenAI } from "@ai-sdk/openai";
8
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
9
+ import {
10
+ convertToModelMessages,
11
+ generateImage as generateImageSdk,
12
+ generateText,
13
+ stepCountIs,
14
+ streamText
15
+ } from "ai";
16
+ import { Timestamp } from "firebase-admin/firestore";
17
+
18
+ // core/ai-tools.ts
19
+ import { tool } from "ai";
20
+ import { z } from "zod";
21
+ var READ_ONLY_CMS_TOOL_NAMES = [
22
+ "collections_list",
23
+ "docs_list",
24
+ "docs_search",
25
+ "doc_get",
26
+ "doc_getVersion",
27
+ "doc_listVersions",
28
+ "schema_get"
29
+ ];
30
+ function createReadOnlyCmsTools() {
31
+ const all = createCmsTools();
32
+ const out = {};
33
+ for (const name of READ_ONLY_CMS_TOOL_NAMES) {
34
+ if (all[name]) {
35
+ out[name] = all[name];
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ function createCmsTools() {
41
+ return {
42
+ collections_list: tool({
43
+ description: "List all CMS collections defined in the project. Returns each collection id along with optional name/description metadata.",
44
+ inputSchema: z.object({})
45
+ }),
46
+ docs_list: tool({
47
+ description: "List documents inside a CMS collection. Returns up to `limit` docs (default 25, max 100). Use this to explore content before reading individual docs.",
48
+ inputSchema: z.object({
49
+ collectionId: z.string().describe('Collection id, e.g. "Pages" or "BlogPosts".'),
50
+ mode: z.enum(["draft", "published"]).default("draft").describe("Whether to read draft or published versions."),
51
+ limit: z.number().int().min(1).max(100).default(25)
52
+ })
53
+ }),
54
+ docs_search: tool({
55
+ description: "Run a full-text search across all indexed CMS docs. Returns the top matching doc ids ordered by relevance.",
56
+ inputSchema: z.object({
57
+ query: z.string().min(1),
58
+ limit: z.number().int().min(1).max(50).default(10)
59
+ })
60
+ }),
61
+ doc_get: tool({
62
+ description: "Read a single CMS document. Returns the doc fields plus system metadata. Use this when you need the full content of a doc.",
63
+ inputSchema: z.object({
64
+ docId: z.string().describe(
65
+ 'Full doc id in the form "Collection/slug" (e.g. "Pages/home").'
66
+ ),
67
+ mode: z.enum(["draft", "published"]).default("draft")
68
+ })
69
+ }),
70
+ doc_getVersion: tool({
71
+ description: 'Read a specific version of a CMS document. Use versionId "draft" or "published" for the current draft/published state, or a numeric timestamp for a historical version (from `doc_listVersions`).',
72
+ inputSchema: z.object({
73
+ docId: z.string().describe(
74
+ 'Full doc id in the form "Collection/slug" (e.g. "Pages/home").'
75
+ ),
76
+ versionId: z.string().describe(
77
+ 'Version identifier: "draft", "published", or a numeric timestamp.'
78
+ )
79
+ })
80
+ }),
81
+ doc_set: tool({
82
+ description: "Replace the entire draft fields payload of a CMS document. Pass the full JSON object that should become the new draft contents \u2014 any fields omitted will be removed. The payload is validated against the collection schema and the call is rejected on validation errors. Prefer `doc_updateField` for targeted edits. Only writes the draft version; users must publish separately. Always confirm with the user before calling.",
83
+ inputSchema: z.object({
84
+ docId: z.string().describe(
85
+ 'Full doc id in the form "Collection/slug" (e.g. "Pages/home").'
86
+ ),
87
+ fields: z.record(z.string(), z.any()).describe(
88
+ "New fields object. Replaces the existing draft fields entirely."
89
+ )
90
+ })
91
+ }),
92
+ doc_create: tool({
93
+ description: "Create a new draft CMS document with the given slug. Fails if the doc already exists. Pass optional initial fields (validated against the collection schema). Always confirm with the user before calling.",
94
+ inputSchema: z.object({
95
+ docId: z.string().describe(
96
+ 'Full doc id in the form "Collection/slug" (e.g. "BlogPosts/my-new-post").'
97
+ ),
98
+ fields: z.record(z.string(), z.any()).optional().describe("Optional initial fields for the new doc.")
99
+ })
100
+ }),
101
+ doc_updateField: tool({
102
+ description: 'Update a single field on a draft CMS document by JSON path. Use dotted paths (e.g. "hero.title") and array indices (e.g. "sections.0.heading"). The value is validated against the field schema and the call is rejected if the shape is wrong (e.g. passing a string to a richtext field). Only updates the draft version; users must publish separately. Always confirm with the user before calling.',
103
+ inputSchema: z.object({
104
+ docId: z.string(),
105
+ path: z.string().describe("Dotted JSON path within the fields object."),
106
+ value: z.any().describe("JSON value to set at the path.")
107
+ })
108
+ }),
109
+ // `doc_publish` and `doc_delete` are intentionally omitted — see the
110
+ // safety policy comment on `createCmsTools`. Users must run these from
111
+ // the CMS UI themselves.
112
+ doc_duplicate: tool({
113
+ description: "Duplicate an existing CMS document to a new slug. Copies all draft fields to the target doc id. Fails if the target already exists. Always confirm with the user before calling.",
114
+ inputSchema: z.object({
115
+ fromDocId: z.string().describe('Source doc id to copy from (e.g. "Pages/home").'),
116
+ toDocId: z.string().describe('Target doc id for the copy (e.g. "Pages/home-copy").')
117
+ })
118
+ }),
119
+ // `doc_revertDraft` is intentionally omitted — see the safety policy
120
+ // comment on `createCmsTools`. Discarding draft edits is destructive
121
+ // and must be triggered by the user from the CMS UI.
122
+ doc_listVersions: tool({
123
+ description: "List version history for a CMS document. Returns versions ordered by most recent first. Use the versionId from the results with `doc_getVersion` to read a specific version.",
124
+ inputSchema: z.object({
125
+ docId: z.string().describe(
126
+ 'Full doc id in the form "Collection/slug" (e.g. "Pages/home").'
127
+ ),
128
+ limit: z.number().int().min(1).max(50).default(10)
129
+ })
130
+ }),
131
+ doc_translateField: tool({
132
+ description: "Translate a text value into one or more target locales using AI. Returns the translated strings keyed by locale. Use this to help with localization workflows.",
133
+ inputSchema: z.object({
134
+ sourceText: z.string().min(1).describe("The source text to translate."),
135
+ targetLocales: z.array(z.string()).min(1).describe(
136
+ 'Array of locale codes to translate into (e.g. ["es", "fr", "de"]).'
137
+ ),
138
+ description: z.string().optional().describe(
139
+ "Optional context about the text to improve translation quality."
140
+ )
141
+ })
142
+ }),
143
+ schema_get: tool({
144
+ description: "Get the field schema for a CMS collection. Returns the full field definitions including types, labels, and validation rules. Use this to understand what fields a collection supports before creating or editing docs.",
145
+ inputSchema: z.object({
146
+ collectionId: z.string().describe('Collection id, e.g. "Pages" or "BlogPosts".')
147
+ })
148
+ })
149
+ };
150
+ }
151
+
152
+ // core/ai.ts
153
+ var ROOT_MD_FILENAME = "ROOT.md";
154
+ function resolveLanguageModel(model) {
155
+ const modelId = model.modelId || model.id;
156
+ switch (model.provider) {
157
+ case "openai": {
158
+ const provider = createOpenAI({
159
+ apiKey: model.apiKey,
160
+ baseURL: model.baseURL,
161
+ headers: model.headers
162
+ });
163
+ return provider(modelId);
164
+ }
165
+ case "openai-compatible": {
166
+ if (!model.baseURL) {
167
+ throw new Error(
168
+ `model "${model.id}" requires a baseURL for provider "openai-compatible"`
169
+ );
170
+ }
171
+ const provider = createOpenAICompatible({
172
+ name: model.id,
173
+ baseURL: model.baseURL,
174
+ apiKey: model.apiKey,
175
+ headers: model.headers
176
+ });
177
+ return provider(modelId);
178
+ }
179
+ case "anthropic": {
180
+ const provider = createAnthropic({
181
+ apiKey: model.apiKey,
182
+ baseURL: model.baseURL,
183
+ headers: model.headers
184
+ });
185
+ return provider(modelId);
186
+ }
187
+ case "google": {
188
+ const provider = createGoogleGenerativeAI({
189
+ apiKey: model.apiKey,
190
+ baseURL: model.baseURL,
191
+ headers: model.headers
192
+ });
193
+ return provider(modelId);
194
+ }
195
+ default: {
196
+ throw new Error(`unknown ai provider: ${model.provider}`);
197
+ }
198
+ }
199
+ }
200
+ function resolveImageModel(model) {
201
+ const modelId = model.modelId || model.id;
202
+ switch (model.provider) {
203
+ case "openai": {
204
+ const provider = createOpenAI({
205
+ apiKey: model.apiKey,
206
+ baseURL: model.baseURL,
207
+ headers: model.headers
208
+ });
209
+ return provider.image(modelId);
210
+ }
211
+ case "google": {
212
+ const provider = createGoogleGenerativeAI({
213
+ apiKey: model.apiKey,
214
+ baseURL: model.baseURL,
215
+ headers: model.headers
216
+ });
217
+ return provider.image(modelId);
218
+ }
219
+ default: {
220
+ throw new Error(
221
+ `provider "${model.provider}" does not support image generation`
222
+ );
223
+ }
224
+ }
225
+ }
226
+ function serializeAiConfig(config) {
227
+ return {
228
+ defaultModel: config.defaultModel || config.models[0]?.id,
229
+ models: config.models.map((m) => ({
230
+ id: m.id,
231
+ label: m.label || m.id,
232
+ description: m.description,
233
+ provider: m.provider,
234
+ capabilities: {
235
+ tools: m.capabilities?.tools !== false,
236
+ reasoning: m.capabilities?.reasoning ?? false,
237
+ attachments: m.capabilities?.attachments ?? false
238
+ }
239
+ })),
240
+ imageGenerationEnabled: !!config.imageModels?.length
241
+ };
242
+ }
243
+ function getAiConfig(rootConfig) {
244
+ const cmsPlugin = rootConfig.plugins?.find((p) => p.name === "root-cms");
245
+ const ai = cmsPlugin?.getConfig().ai;
246
+ if (!ai || !Array.isArray(ai.models) || ai.models.length === 0) {
247
+ return null;
248
+ }
249
+ return ai;
250
+ }
251
+ function findModel(config, modelId) {
252
+ if (modelId) {
253
+ const match = config.models.find((m) => m.id === modelId);
254
+ if (match) {
255
+ return match;
256
+ }
257
+ }
258
+ const defaultId = config.defaultModel || config.models[0]?.id;
259
+ return config.models.find((m) => m.id === defaultId) || null;
260
+ }
261
+ function findImageModel(config, modelId) {
262
+ const imageModels = config.imageModels || [];
263
+ if (imageModels.length === 0) {
264
+ return null;
265
+ }
266
+ if (modelId) {
267
+ const match = imageModels.find((m) => m.id === modelId);
268
+ if (match) {
269
+ return match;
270
+ }
271
+ }
272
+ const defaultId = config.defaultImageModel || imageModels[0]?.id;
273
+ return imageModels.find((m) => m.id === defaultId) || null;
274
+ }
275
+ function requireDefaultModel(rootConfig) {
276
+ const config = getAiConfig(rootConfig);
277
+ if (!config) {
278
+ throw new Error("AI is not configured. Set `ai` on the cmsPlugin config.");
279
+ }
280
+ const model = findModel(config);
281
+ if (!model) {
282
+ throw new Error("No AI chat model configured.");
283
+ }
284
+ return { config, model };
285
+ }
286
+ var ChatStore = class {
287
+ constructor(cmsClient, user) {
288
+ this.cmsClient = cmsClient;
289
+ this.user = user;
290
+ }
291
+ collection() {
292
+ return this.cmsClient.db.collection(
293
+ `Projects/${this.cmsClient.projectId}/AiChats`
294
+ );
295
+ }
296
+ async createChat(options) {
297
+ const id = options?.id || crypto.randomUUID();
298
+ const now = Timestamp.now();
299
+ const record = {
300
+ id,
301
+ createdBy: this.user,
302
+ createdAt: now,
303
+ modifiedAt: now,
304
+ modelId: options?.modelId,
305
+ messages: []
306
+ };
307
+ await this.collection().doc(id).set(record);
308
+ return record;
309
+ }
310
+ async getChat(id) {
311
+ const snap = await this.collection().doc(id).get();
312
+ if (!snap.exists) {
313
+ return null;
314
+ }
315
+ const data = snap.data();
316
+ if (data.createdBy !== this.user) {
317
+ return null;
318
+ }
319
+ return data;
320
+ }
321
+ async listChats(options) {
322
+ const limit = options?.limit ?? 50;
323
+ const res = await this.collection().where("createdBy", "==", this.user).get();
324
+ const records = res.docs.map((d) => d.data());
325
+ records.sort((a, b) => {
326
+ const aMs = a.modifiedAt?.toMillis?.() ?? 0;
327
+ const bMs = b.modifiedAt?.toMillis?.() ?? 0;
328
+ return bMs - aMs;
329
+ });
330
+ return records.slice(0, limit);
331
+ }
332
+ async deleteChat(id) {
333
+ const chat = await this.getChat(id);
334
+ if (!chat) {
335
+ return;
336
+ }
337
+ await this.collection().doc(id).delete();
338
+ }
339
+ async updateMessages(id, messages, options) {
340
+ const updates = {
341
+ messages,
342
+ modifiedAt: Timestamp.now()
343
+ };
344
+ if (options?.modelId) {
345
+ updates.modelId = options.modelId;
346
+ }
347
+ if (options?.title) {
348
+ updates.title = options.title;
349
+ }
350
+ await this.collection().doc(id).update(updates);
351
+ }
352
+ };
353
+ async function readRootMd(rootDir) {
354
+ const filePath = path.join(rootDir, ROOT_MD_FILENAME);
355
+ try {
356
+ const contents = await fs.readFile(filePath, "utf8");
357
+ const trimmed = contents.trim();
358
+ return trimmed || null;
359
+ } catch (err) {
360
+ if (err && err.code === "ENOENT") {
361
+ return null;
362
+ }
363
+ console.error(`failed to read ${ROOT_MD_FILENAME}:`, err);
364
+ return null;
365
+ }
366
+ }
367
+ function buildSystemPrompt(basePrompt, rootMd) {
368
+ if (!rootMd) {
369
+ return basePrompt;
370
+ }
371
+ return [
372
+ basePrompt,
373
+ "",
374
+ `The project includes a \`${ROOT_MD_FILENAME}\` file with site-specific`,
375
+ "instructions, conventions and context provided by the developer. Treat",
376
+ "these instructions as authoritative for this project and follow them",
377
+ "when responding or calling tools.",
378
+ "",
379
+ `<${ROOT_MD_FILENAME}>`,
380
+ rootMd,
381
+ `</${ROOT_MD_FILENAME}>`
382
+ ].join("\n");
383
+ }
384
+ function deriveChatTitle(messages) {
385
+ const first = messages.find((m) => m.role === "user");
386
+ if (!first) {
387
+ return "New chat";
388
+ }
389
+ const text = first.parts.filter((p) => p.type === "text").map((p) => p.text).join(" ").trim();
390
+ if (!text) {
391
+ return "New chat";
392
+ }
393
+ return text.length > 60 ? `${text.slice(0, 57)}\u2026` : text;
394
+ }
395
+ async function generateChatTitle(model, messages) {
396
+ const fallback = deriveChatTitle(messages);
397
+ if (fallback === "New chat") {
398
+ return fallback;
399
+ }
400
+ try {
401
+ const result = await generateText({
402
+ model,
403
+ system: "Generate a short title (max 50 characters) summarizing the following conversation. Return only the title text, no quotes or punctuation at the end.",
404
+ prompt: messages.slice(0, 6).map((m) => {
405
+ const text = m.parts.filter((p) => p.type === "text").map((p) => p.text).join(" ");
406
+ return `${m.role}: ${text}`;
407
+ }).join("\n"),
408
+ maxOutputTokens: 30
409
+ });
410
+ const title = result.text.trim().replace(/["']+$/g, "").replace(/^["']+/g, "");
411
+ if (title) {
412
+ return title.length > 80 ? `${title.slice(0, 77)}\u2026` : title;
413
+ }
414
+ } catch (err) {
415
+ console.error("failed to generate chat title:", err);
416
+ }
417
+ return fallback;
418
+ }
419
+ async function runChatStream(options) {
420
+ const { rootConfig, model, config, messages, cmsClient, user, chatId } = options;
421
+ const languageModel = resolveLanguageModel(model);
422
+ const tools = model.capabilities?.tools === false ? {} : createCmsTools();
423
+ const basePrompt = config.systemPrompt || [
424
+ "You are an assistant embedded in the Root CMS admin UI.",
425
+ "Help the user explore and edit content, answer questions about",
426
+ "the project, and use the provided tools to read and write CMS docs.",
427
+ "Be concise and use markdown for rich responses."
428
+ ].join(" ");
429
+ const rootMd = await readRootMd(rootConfig.rootDir);
430
+ const systemPrompt = buildSystemPrompt(basePrompt, rootMd);
431
+ const modelMessages = await convertToModelMessages(messages, { tools });
432
+ const result = streamText({
433
+ model: languageModel,
434
+ system: systemPrompt,
435
+ messages: modelMessages,
436
+ tools,
437
+ stopWhen: stepCountIs(config.maxSteps ?? 10)
438
+ });
439
+ return result.toUIMessageStreamResponse({
440
+ sendReasoning: model.capabilities?.reasoning ?? false,
441
+ originalMessages: messages,
442
+ onFinish: async ({ messages: finalMessages }) => {
443
+ const store = new ChatStore(cmsClient, user);
444
+ const title = await generateChatTitle(languageModel, finalMessages);
445
+ const updates = {
446
+ modelId: model.id,
447
+ title
448
+ };
449
+ try {
450
+ await store.updateMessages(
451
+ chatId,
452
+ stripUndefined(finalMessages),
453
+ updates
454
+ );
455
+ } catch (err) {
456
+ console.error("failed to persist chat history:", err);
457
+ }
458
+ }
459
+ });
460
+ }
461
+ async function runEditObjectStream(options) {
462
+ const { rootConfig, model, config, messages, editData } = options;
463
+ const languageModel = resolveLanguageModel(model);
464
+ const tools = model.capabilities?.tools === false ? {} : createReadOnlyCmsTools();
465
+ const editPromptText = (await import("./edit-XX3LAGK6.js")).default;
466
+ const promptParts = [
467
+ stripLegacyEditOutputSpec(editPromptText),
468
+ "",
469
+ "Output format:",
470
+ "- Begin with a brief 1-2 sentence message describing what you changed.",
471
+ "- Then emit a single fenced code block tagged ```json containing the",
472
+ " COMPLETE proposed new JSON object (including any unmodified fields).",
473
+ "- The code block must be the LAST content in your response. The CMS UI",
474
+ " parses it and shows the result in a diff viewer for the user to",
475
+ " approve before saving.",
476
+ "",
477
+ "Tool policy:",
478
+ "- The available tools are READ-ONLY. Use them only when you need extra",
479
+ " context (e.g. inspecting the schema or referencing other CMS docs).",
480
+ "- You MUST NOT attempt to call write tools (e.g. doc_set, doc_create,",
481
+ " doc_updateField). The user approves and saves changes manually via",
482
+ " the modal's Save button."
483
+ ];
484
+ try {
485
+ const rootCmsDefsPath = path.join(rootConfig.rootDir, "root-cms.d.ts");
486
+ const rootCmsDefs = await fs.readFile(rootCmsDefsPath, "utf8");
487
+ if (rootCmsDefs.trim()) {
488
+ promptParts.push(
489
+ "",
490
+ "Here is the `root-cms.d.ts` file for this project:",
491
+ "```",
492
+ rootCmsDefs,
493
+ "```"
494
+ );
495
+ }
496
+ } catch (err) {
497
+ if (err?.code !== "ENOENT") {
498
+ console.error("failed to read root-cms.d.ts:", err);
499
+ }
500
+ }
501
+ promptParts.push(
502
+ "",
503
+ "The JSON you must edit is:",
504
+ "```json",
505
+ JSON.stringify(editData ?? {}, null, 2),
506
+ "```"
507
+ );
508
+ const basePrompt = promptParts.join("\n");
509
+ const rootMd = await readRootMd(rootConfig.rootDir);
510
+ const systemPrompt = buildSystemPrompt(basePrompt, rootMd);
511
+ const modelMessages = await convertToModelMessages(messages, { tools });
512
+ const result = streamText({
513
+ model: languageModel,
514
+ system: systemPrompt,
515
+ messages: modelMessages,
516
+ tools,
517
+ stopWhen: stepCountIs(config.maxSteps ?? 10)
518
+ });
519
+ return result.toUIMessageStreamResponse({
520
+ sendReasoning: model.capabilities?.reasoning ?? false,
521
+ originalMessages: messages
522
+ });
523
+ }
524
+ function stripLegacyEditOutputSpec(prompt) {
525
+ const marker = "Finally, when you provide your response, it MUST be structured";
526
+ const idx = prompt.indexOf(marker);
527
+ if (idx === -1) {
528
+ return prompt;
529
+ }
530
+ return prompt.slice(0, idx).trimEnd();
531
+ }
532
+ function stripUndefined(value) {
533
+ if (Array.isArray(value)) {
534
+ return value.filter((v) => v !== void 0).map((v) => stripUndefined(v));
535
+ }
536
+ if (value && typeof value === "object") {
537
+ const out = {};
538
+ for (const [k, v] of Object.entries(value)) {
539
+ if (v === void 0) {
540
+ continue;
541
+ }
542
+ out[k] = stripUndefined(v);
543
+ }
544
+ return out;
545
+ }
546
+ return value;
547
+ }
548
+ async function summarizeDiff(rootConfig, options) {
549
+ const { model } = requireDefaultModel(rootConfig);
550
+ const languageModel = resolveLanguageModel(model);
551
+ const beforeJson = JSON.stringify(options.before ?? null, null, 2);
552
+ const afterJson = JSON.stringify(options.after ?? null, null, 2);
553
+ const system = [
554
+ "Summarize CMS document changes in 2-4 bullet points.",
555
+ "Focus on content changes only. Ignore metadata like timestamps.",
556
+ 'If no meaningful changes, say "No significant changes."'
557
+ ].join("\n");
558
+ const prompt = [
559
+ "Before:",
560
+ beforeJson,
561
+ "",
562
+ "After:",
563
+ afterJson,
564
+ "",
565
+ "What changed?"
566
+ ].join("\n");
567
+ const result = await generateText({
568
+ model: languageModel,
569
+ system,
570
+ prompt,
571
+ // Respond more quickly with less creativity.
572
+ temperature: 0.3
573
+ });
574
+ return result.text?.trim() || "";
575
+ }
576
+ async function generatePublishMessage(rootConfig, options) {
577
+ const { model } = requireDefaultModel(rootConfig);
578
+ const languageModel = resolveLanguageModel(model);
579
+ const beforeJson = JSON.stringify(options.before ?? null, null, 2);
580
+ const afterJson = JSON.stringify(options.after ?? null, null, 2);
581
+ const system = [
582
+ "You are an assistant that generates concise commit-style messages for CMS document changes.",
583
+ "Generate a single short sentence (maximum 60 characters) describing the most important change.",
584
+ 'Use imperative mood like "Add feature" or "Update content" or "Fix typo".',
585
+ "Focus on the key content change, ignore structural metadata changes.",
586
+ "Do not use punctuation at the end.",
587
+ 'Examples: "Add new hero image", "Update pricing details", "Fix typo in headline"'
588
+ ].join("\n");
589
+ const prompt = [
590
+ "Previous version JSON:",
591
+ "```json",
592
+ beforeJson,
593
+ "```",
594
+ "",
595
+ "Updated version JSON:",
596
+ "```json",
597
+ afterJson,
598
+ "```",
599
+ "",
600
+ "Generate a commit message for these changes."
601
+ ].join("\n");
602
+ const result = await generateText({
603
+ model: languageModel,
604
+ system,
605
+ prompt
606
+ });
607
+ return result.text?.trim() || "";
608
+ }
609
+ async function translateString(rootConfig, options) {
610
+ const { model } = requireDefaultModel(rootConfig);
611
+ const languageModel = resolveLanguageModel(model);
612
+ const system = [
613
+ "You are a professional translator assistant.",
614
+ "Translate the given source text into the requested target languages.",
615
+ "Maintain the tone, style, and intent of the original text.",
616
+ "Return ONLY a valid JSON object with locale codes as keys and translations as values.",
617
+ "Do not include any markdown formatting, code blocks, or explanatory text."
618
+ ].join("\n");
619
+ const userPromptParts = [
620
+ `Source text: "${options.sourceText}"`,
621
+ ""
622
+ ];
623
+ if (options.description) {
624
+ userPromptParts.push(`Context/Description: ${options.description}`, "");
625
+ }
626
+ if (options.existingTranslations && Object.keys(options.existingTranslations).length > 0) {
627
+ userPromptParts.push("Existing translations for reference:");
628
+ Object.entries(options.existingTranslations).forEach(
629
+ ([locale, translation]) => {
630
+ if (translation) {
631
+ userPromptParts.push(`- ${locale}: "${translation}"`);
632
+ }
633
+ }
634
+ );
635
+ userPromptParts.push("");
636
+ }
637
+ userPromptParts.push(
638
+ `Target locales: ${options.targetLocales.join(", ")}`,
639
+ "",
640
+ "Provide translations as a JSON object with locale codes as keys."
641
+ );
642
+ const result = await generateText({
643
+ model: languageModel,
644
+ system,
645
+ prompt: userPromptParts.join("\n")
646
+ });
647
+ const responseText = result.text || "{}";
648
+ const jsonText = extractJsonFromResponse(responseText);
649
+ try {
650
+ return JSON.parse(jsonText);
651
+ } catch (err) {
652
+ console.error("failed to parse AI translation response:", responseText);
653
+ throw new Error("Invalid response format from AI translation");
654
+ }
655
+ }
656
+ async function generateAltText(rootConfig, options) {
657
+ const { model } = requireDefaultModel(rootConfig);
658
+ const languageModel = resolveLanguageModel(model);
659
+ const system = [
660
+ "Create a descriptive and concise alt text for the attached image.",
661
+ "",
662
+ "- The alt text should be a brief but comprehensive description of the image, including key subjects, the setting, and any relevant details or actions.",
663
+ "- The alt text should not exceed 125 characters.",
664
+ "- Only provide one generation, and include only that data in the response. No surrounding text, clarifications, etc. Just the alt text."
665
+ ].join("\n");
666
+ const result = await generateText({
667
+ model: languageModel,
668
+ system,
669
+ messages: [
670
+ {
671
+ role: "user",
672
+ content: [
673
+ { type: "text", text: "Generate alt text for the image above." },
674
+ { type: "image", image: new URL(options.imageUrl) }
675
+ ]
676
+ }
677
+ ]
678
+ });
679
+ return result.text?.trim() || "";
680
+ }
681
+ async function generateImage(rootConfig, options) {
682
+ const config = getAiConfig(rootConfig);
683
+ if (!config) {
684
+ throw new Error("AI is not configured. Set `ai` on the cmsPlugin config.");
685
+ }
686
+ const imageModelConfig = findImageModel(config, options.modelId);
687
+ if (!imageModelConfig) {
688
+ throw new Error(
689
+ "No image model configured. Set `ai.imageModels` on the cmsPlugin config."
690
+ );
691
+ }
692
+ const imageModel = resolveImageModel(imageModelConfig);
693
+ const result = await generateImageSdk({
694
+ model: imageModel,
695
+ prompt: options.prompt,
696
+ aspectRatio: options.aspectRatio
697
+ });
698
+ if (!result.image) {
699
+ throw new Error("No image generated");
700
+ }
701
+ const mediaType = result.image.mediaType || "image/png";
702
+ return { imageUrl: `data:${mediaType};base64,${result.image.base64}` };
703
+ }
704
+ function extractJsonFromResponse(responseText) {
705
+ let jsonText = responseText.trim();
706
+ if (jsonText.startsWith("```")) {
707
+ const lines = jsonText.split("\n");
708
+ jsonText = lines.slice(1, -1).join("\n");
709
+ if (jsonText.startsWith("json")) {
710
+ jsonText = jsonText.substring(4).trim();
711
+ }
712
+ }
713
+ return jsonText;
714
+ }
715
+
716
+ // package.json
717
+ var package_default = {
718
+ name: "@blinkk/root-cms",
719
+ version: "3.0.1-beta.2",
720
+ author: "s@blinkk.com",
721
+ license: "MIT",
722
+ engines: {
723
+ node: ">=16.0.0"
724
+ },
725
+ repository: {
726
+ type: "git",
727
+ url: "git+https://github.com/blinkk/rootjs.git",
728
+ directory: "packages/root-cms"
729
+ },
730
+ files: [
731
+ "dist/**/*"
732
+ ],
733
+ bin: {
734
+ "root-cms": "./bin/root-cms.js"
735
+ },
736
+ type: "module",
737
+ module: "./dist/index.js",
738
+ types: "./dist/index.d.ts",
739
+ exports: {
740
+ ".": {
741
+ types: "./dist/core.d.ts",
742
+ import: "./dist/core.js"
743
+ },
744
+ "./client": {
745
+ types: "./dist/client.d.ts",
746
+ import: "./dist/client.js"
747
+ },
748
+ "./core": {
749
+ types: "./dist/core.d.ts",
750
+ import: "./dist/core.js"
751
+ },
752
+ "./functions": {
753
+ types: "./dist/functions.d.ts",
754
+ import: "./dist/functions.js"
755
+ },
756
+ "./plugin": {
757
+ types: "./dist/plugin.d.ts",
758
+ import: "./dist/plugin.js"
759
+ },
760
+ "./project": {
761
+ types: "./dist/project.d.ts",
762
+ import: "./dist/project.js"
763
+ },
764
+ "./richtext": {
765
+ types: "./dist/richtext.d.ts",
766
+ import: "./dist/richtext.js"
767
+ }
768
+ },
769
+ scripts: {
770
+ build: 'rm -rf dist && concurrently -n "core,signin,ui" npm:build:core npm:build:signin npm:build:ui',
771
+ "build:core": "tsup-node --config=./core/tsup.config.ts",
772
+ "//": "NOTE: esbuild is used here because tsup doesn't currently support aliases.",
773
+ "build:ui": "esbuild ui/ui.tsx --bundle --minify --alias:react=@preact/compat --alias:react-dom=@preact/compat --tsconfig=ui/tsconfig.json --outdir=dist/ui --legal-comments=external",
774
+ "build:signin": "esbuild signin/signin.tsx --bundle --minify --tsconfig=signin/tsconfig.json --outdir=dist/ui --legal-comments=external",
775
+ dev: 'rm -rf dist && concurrently -k -n "core,ui" npm:dev:core npm:dev:signin npm:dev:ui',
776
+ "dev:core": "pnpm build:core --watch",
777
+ "dev:signin": "pnpm build:signin --watch",
778
+ "dev:ui": "pnpm build:ui --watch",
779
+ test: "pnpm build && firebase emulators:exec 'vitest run --exclude=**/*.visual.test.tsx'",
780
+ "test:visual": "vitest run --config=vitest.config.visual.ts",
781
+ "test:watch": "pnpm build && firebase emulators:exec 'vitest'"
782
+ },
783
+ dependencies: {
784
+ "@ag-grid-community/client-side-row-model": "32.3.9",
785
+ "@ag-grid-community/core": "32.3.9",
786
+ "@ag-grid-community/react": "32.3.9",
787
+ "@ag-grid-community/styles": "32.3.9",
788
+ "@ai-sdk/anthropic": "3.0.74",
789
+ "@ai-sdk/google": "3.0.67",
790
+ "@ai-sdk/openai": "3.0.60",
791
+ "@ai-sdk/openai-compatible": "2.0.46",
792
+ "@ai-sdk/react": "3.0.177",
793
+ ai: "6.0.175",
794
+ zod: "4.4.3",
795
+ "@google-cloud/firestore": "7.11.3",
796
+ "@hello-pangea/dnd": "18.0.1",
797
+ "@types/cli-progress": "3.11.6",
798
+ "body-parser": "1.20.2",
799
+ "cli-progress": "3.12.0",
800
+ commander: "11.0.0",
801
+ "csv-parse": "5.5.2",
802
+ "csv-stringify": "6.4.4",
803
+ "date-fns": "4.1.0",
804
+ "date-fns-tz": "3.2.0",
805
+ diff: "8.0.2",
806
+ "dts-dom": "3.7.0",
807
+ "@mantine/spotlight": "4.2.12",
808
+ "fnv-plus": "1.3.1",
809
+ jsonwebtoken: "9.0.2",
810
+ kleur: "4.1.5",
811
+ minisearch: "7.2.0",
812
+ sirv: "2.0.3",
813
+ "tiny-glob": "0.2.9"
814
+ },
815
+ "//": "NOTE(stevenle): due to compat issues with mantine and preact, mantine is pinned to v4.2.12",
816
+ devDependencies: {
817
+ "@babel/core": "7.17.9",
818
+ "@blinkk/root": "workspace:*",
819
+ "@editorjs/editorjs": "2.30.8",
820
+ "@editorjs/header": "2.8.8",
821
+ "@editorjs/image": "2.10.2",
822
+ "@editorjs/list": "2.0.6",
823
+ "@editorjs/nested-list": "1.4.3",
824
+ "@editorjs/raw": "2.5.1",
825
+ "@editorjs/table": "2.4.4",
826
+ "@editorjs/underline": "1.2.1",
827
+ "@emotion/react": "11.10.5",
828
+ "@firebase/app-compat": "0.5.2",
829
+ "@firebase/app-types": "0.9.3",
830
+ "@firebase/rules-unit-testing": "5.0.0",
831
+ "@lexical/code": "0.33.1",
832
+ "@lexical/html": "0.33.1",
833
+ "@lexical/link": "0.33.1",
834
+ "@lexical/list": "0.33.1",
835
+ "@lexical/markdown": "0.33.1",
836
+ "@lexical/react": "0.33.1",
837
+ "@lexical/rich-text": "0.33.1",
838
+ "@lexical/selection": "0.33.1",
839
+ "@lexical/table": "0.33.1",
840
+ "@lexical/utils": "0.33.1",
841
+ "@mantine/core": "4.2.12",
842
+ "@mantine/hooks": "4.2.12",
843
+ "@mantine/modals": "4.2.12",
844
+ "@mantine/notifications": "4.2.12",
845
+ "@preact/compat": "18.3.1",
846
+ "@tabler/icons-preact": "3.35.0",
847
+ "@testing-library/preact": "3.2.4",
848
+ "@testing-library/user-event": "14.6.1",
849
+ "@types/body-parser": "1.19.3",
850
+ "@types/fnv-plus": "1.3.2",
851
+ "@types/gapi": "0.0.47",
852
+ "@types/gapi.client.drive-v3": "0.0.4",
853
+ "@types/gapi.client.sheets-v4": "0.0.4",
854
+ "@types/google.accounts": "0.0.14",
855
+ "@types/jsonwebtoken": "9.0.1",
856
+ "@types/node": "24.3.1",
857
+ "@vitest/browser": "4.1.2",
858
+ "@vitest/browser-playwright": "4.1.2",
859
+ concurrently: "7.6.0",
860
+ esbuild: "0.25.9",
861
+ firebase: "12.2.1",
862
+ "firebase-admin": "13.5.0",
863
+ "firebase-functions": "6.4.0",
864
+ "firebase-tools": "14.15.2",
865
+ "highlight.js": "11.6.0",
866
+ jsdom: "27.2.0",
867
+ "json-diff-kit": "1.0.29",
868
+ lexical: "0.33.1",
869
+ marked: "9.1.1",
870
+ "mdast-util-from-markdown": "2.0.1",
871
+ "mdast-util-gfm": "3.0.0",
872
+ "micromark-extension-gfm": "3.0.0",
873
+ playwright: "1.56.1",
874
+ preact: "10.27.1",
875
+ "preact-iso": "2.11.1",
876
+ "preact-render-to-string": "6.6.7",
877
+ react: "npm:@preact/compat@18.3.1",
878
+ "react-dom": "npm:@preact/compat@18.3.1",
879
+ "react-easy-crop": "5.5.6",
880
+ "react-json-view-compare": "2.0.2",
881
+ tsup: "8.5.0",
882
+ typescript: "5.9.2",
883
+ vite: "8.0.3",
884
+ vitest: "4.1.2",
885
+ yjs: "13.6.27"
886
+ },
887
+ peerDependencies: {
888
+ "@blinkk/root": "3.0.1-beta.2",
889
+ "firebase-admin": ">=11",
890
+ "firebase-functions": ">=4"
891
+ },
892
+ peerDependenciesMeta: {
893
+ "firebase-functions": {
894
+ optional: true
895
+ }
896
+ }
897
+ };
898
+
899
+ // core/server-version.ts
900
+ var SERVER_STARTUP_TS = String(Math.floor((/* @__PURE__ */ new Date()).getTime() / 1e3));
901
+ function getServerVersion() {
902
+ if (process.env.NODE_ENV === "development") {
903
+ return SERVER_STARTUP_TS;
904
+ }
905
+ return package_default?.version || "root-3.0.0";
906
+ }
907
+
908
+ export {
909
+ serializeAiConfig,
910
+ getAiConfig,
911
+ findModel,
912
+ ChatStore,
913
+ runChatStream,
914
+ runEditObjectStream,
915
+ summarizeDiff,
916
+ generatePublishMessage,
917
+ translateString,
918
+ generateAltText,
919
+ generateImage,
920
+ getServerVersion
921
+ };