@smartcat/sanity-functions 1.0.0

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/bin/cli.js ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/cli.ts
4
+ import { createRequire } from "node:module";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawn } from "node:child_process";
9
+ import { createInterface } from "node:readline/promises";
10
+ import { createClient } from "@sanity/client";
11
+
12
+ // src/cli/resolve.ts
13
+ function isComplete(creds) {
14
+ return Boolean(creds?.server && creds?.workspaceId && creds?.apiKey);
15
+ }
16
+ function credsFromEnv(env) {
17
+ const creds = {
18
+ server: env.SMARTCAT_API_SERVER ?? "",
19
+ workspaceId: env.SMARTCAT_WORKSPACE_ID ?? "",
20
+ apiKey: env.SMARTCAT_API_KEY ?? ""
21
+ };
22
+ return isComplete(creds) ? creds : null;
23
+ }
24
+ async function resolveCreds(env, readConfig, prompt) {
25
+ const fromEnv = credsFromEnv(env);
26
+ if (fromEnv) return fromEnv;
27
+ const fromConfig = readConfig();
28
+ if (isComplete(fromConfig)) return fromConfig;
29
+ return prompt();
30
+ }
31
+ function resolveSanityAuth(env) {
32
+ const token = env.SANITY_AUTH_TOKEN;
33
+ return token ? { mode: "token", token } : { mode: "login" };
34
+ }
35
+ function resolveProjectId(env, flag, readCliConfig) {
36
+ if (flag) return flag;
37
+ const fromEnv = env.SANITY_STUDIO_PROJECT_ID || env.SANITY_PROJECT_ID;
38
+ if (fromEnv) return fromEnv;
39
+ const fromConfig = readCliConfig();
40
+ if (fromConfig) return fromConfig;
41
+ throw new Error(
42
+ "Could not resolve a Sanity project id. Pass --project <id>, set SANITY_STUDIO_PROJECT_ID (or SANITY_PROJECT_ID), or configure it in sanity.cli.ts."
43
+ );
44
+ }
45
+ function isUnknownVersion(version) {
46
+ return version === null || version === "unknown";
47
+ }
48
+ function skewWarning(pluginVersion, deployedVersion) {
49
+ if (isUnknownVersion(pluginVersion) || isUnknownVersion(deployedVersion)) return null;
50
+ if (pluginVersion === deployedVersion) return null;
51
+ return `Version mismatch: @smartcat/sanity-plugin is v${pluginVersion} but the deployed Functions are v${deployedVersion}. Redeploy with the deploy CLI to bring them in sync.`;
52
+ }
53
+
54
+ // src/cli/deploy.ts
55
+ async function deploy(deps) {
56
+ const creds = await resolveCreds(deps.env, deps.readSmartcatConfig, async () => {
57
+ const entered = await deps.promptSmartcatCreds();
58
+ deps.writeSmartcatConfig(entered);
59
+ return entered;
60
+ });
61
+ const sanityAuth = resolveSanityAuth(deps.env);
62
+ const projectId = resolveProjectId(deps.env, deps.args.project, deps.readSanityCliConfig);
63
+ const childEnv = {
64
+ ...deps.env,
65
+ SMARTCAT_API_SERVER: creds.server,
66
+ SMARTCAT_WORKSPACE_ID: creds.workspaceId,
67
+ SMARTCAT_API_KEY: creds.apiKey
68
+ };
69
+ if (sanityAuth.mode === "token") childEnv.SANITY_AUTH_TOKEN = sanityAuth.token;
70
+ const stack = deps.args.stack || deps.env.SANITY_STACK || "production";
71
+ const cliArgs = ["blueprints", "deploy", "--project-id", projectId, "--stack", stack];
72
+ const result = await deps.runSanityCli(cliArgs, { cwd: deps.blueprintCwd, env: childEnv });
73
+ if (result.stdout) deps.log(result.stdout);
74
+ if (result.code !== 0) {
75
+ if (result.stderr) deps.log(result.stderr);
76
+ if (sanityAuth.mode === "login") {
77
+ deps.log("Run: npx sanity login (or set SANITY_AUTH_TOKEN) and re-run deploy.");
78
+ }
79
+ return { code: result.code };
80
+ }
81
+ const deployedVersion = await deps.fetchDeployedVersion({ projectId, sanityAuth });
82
+ const warning = skewWarning(deps.installedPluginVersion, deployedVersion);
83
+ if (warning) deps.log(warning);
84
+ return { code: 0 };
85
+ }
86
+
87
+ // bin/cli.ts
88
+ var packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
89
+ var cwd = process.cwd();
90
+ var smartcatConfigPath = path.join(cwd, ".smartcat", "credentials.json");
91
+ function parseArgs(argv) {
92
+ const rest = [...argv];
93
+ const command = rest[0] && !rest[0].startsWith("-") ? rest.shift() : void 0;
94
+ const out = { command };
95
+ for (let i = 0; i < rest.length; i++) {
96
+ if (rest[i] === "--project" && rest[i + 1] !== void 0) out.project = rest[++i];
97
+ else if (rest[i] === "--stack" && rest[i + 1] !== void 0) out.stack = rest[++i];
98
+ }
99
+ return out;
100
+ }
101
+ function readSmartcatConfig() {
102
+ if (!existsSync(smartcatConfigPath)) return null;
103
+ try {
104
+ return JSON.parse(readFileSync(smartcatConfigPath, "utf8"));
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+ function writeSmartcatConfig(creds) {
110
+ mkdirSync(path.dirname(smartcatConfigPath), { recursive: true });
111
+ writeFileSync(smartcatConfigPath, `${JSON.stringify(creds, null, 2)}
112
+ `);
113
+ }
114
+ async function promptSmartcatCreds() {
115
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
116
+ try {
117
+ console.log(
118
+ "No Smartcat credentials found (SMARTCAT_API_SERVER/_WORKSPACE_ID/_API_KEY). Enter them once \u2014 they will be saved to .smartcat/credentials.json (gitignored)."
119
+ );
120
+ const server = await rl.question("Smartcat API server (e.g. us.smartcat.com): ");
121
+ const workspaceId = await rl.question("Smartcat workspace id: ");
122
+ const apiKey = await rl.question("Smartcat API key: ");
123
+ return { server: server.trim(), workspaceId: workspaceId.trim(), apiKey: apiKey.trim() };
124
+ } finally {
125
+ rl.close();
126
+ }
127
+ }
128
+ function readSanityCliConfig() {
129
+ for (const file of ["sanity.cli.ts", "sanity.config.ts"]) {
130
+ const filePath = path.join(cwd, file);
131
+ if (!existsSync(filePath)) continue;
132
+ const match = readFileSync(filePath, "utf8").match(/projectId\s*:\s*['"]([a-z0-9]+)['"]/i);
133
+ if (match) return match[1];
134
+ }
135
+ return null;
136
+ }
137
+ function runSanityCli(args, opts) {
138
+ return new Promise((resolve, reject) => {
139
+ const child = spawn("npx", ["sanity@latest", ...args], { cwd: opts.cwd, env: opts.env, shell: true });
140
+ let stdout = "";
141
+ let stderr = "";
142
+ child.stdout?.on("data", (chunk) => {
143
+ stdout += chunk;
144
+ });
145
+ child.stderr?.on("data", (chunk) => {
146
+ stderr += chunk;
147
+ });
148
+ child.on("error", reject);
149
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
150
+ });
151
+ }
152
+ async function fetchDeployedVersion(opts) {
153
+ if (opts.sanityAuth.mode !== "token") return null;
154
+ try {
155
+ const dataset = process.env.SANITY_STUDIO_DATASET || process.env.SANITY_DATASET || "production";
156
+ const client = createClient({
157
+ projectId: opts.projectId,
158
+ dataset,
159
+ apiVersion: "2025-02-01",
160
+ token: opts.sanityAuth.token,
161
+ useCdn: false
162
+ });
163
+ const version = await client.fetch('*[_id == "smartcat.settings"][0].addonVersion');
164
+ return version ?? null;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+ function readInstalledPluginVersion() {
170
+ try {
171
+ const require2 = createRequire(path.join(cwd, "package.json"));
172
+ const pkgPath = require2.resolve("@smartcat/sanity-plugin/package.json");
173
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
174
+ return typeof pkg.version === "string" ? pkg.version : null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+ async function main() {
180
+ const { command, project, stack } = parseArgs(process.argv.slice(2));
181
+ if (command !== "deploy") {
182
+ console.error(
183
+ 'Usage: smartcat-sanity-functions deploy [--project <id>] [--stack <name>, defaults to "production"]'
184
+ );
185
+ process.exitCode = 1;
186
+ return;
187
+ }
188
+ const deps = {
189
+ env: process.env,
190
+ args: { project, stack },
191
+ blueprintCwd: packageRoot,
192
+ readSmartcatConfig,
193
+ writeSmartcatConfig,
194
+ promptSmartcatCreds,
195
+ readSanityCliConfig,
196
+ runSanityCli,
197
+ fetchDeployedVersion,
198
+ installedPluginVersion: readInstalledPluginVersion(),
199
+ log: (line) => console.log(line)
200
+ };
201
+ const result = await deploy(deps);
202
+ process.exitCode = result.code;
203
+ }
204
+ main().catch((err) => {
205
+ console.error(err instanceof Error ? err.message : err);
206
+ process.exitCode = 1;
207
+ });
package/bin/cli.ts ADDED
@@ -0,0 +1,162 @@
1
+ import {createRequire} from 'node:module'
2
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'
3
+ import path from 'node:path'
4
+ import {fileURLToPath} from 'node:url'
5
+ import {spawn} from 'node:child_process'
6
+ import {createInterface} from 'node:readline/promises'
7
+ import {createClient} from '@sanity/client'
8
+ import {deploy} from '../src/cli/deploy'
9
+ import type {DeployDeps, SanityCliResult} from '../src/cli/deploy'
10
+ import type {SmartcatCreds} from '../src/cli/resolve'
11
+
12
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
13
+ const cwd = process.cwd()
14
+ const smartcatConfigPath = path.join(cwd, '.smartcat', 'credentials.json')
15
+
16
+ function parseArgs(argv: string[]): {command?: string; project?: string; stack?: string} {
17
+ const rest = [...argv]
18
+ const command = rest[0] && !rest[0].startsWith('-') ? rest.shift() : undefined
19
+ const out: {command?: string; project?: string; stack?: string} = {command}
20
+ for (let i = 0; i < rest.length; i++) {
21
+ if (rest[i] === '--project' && rest[i + 1] !== undefined) out.project = rest[++i]
22
+ else if (rest[i] === '--stack' && rest[i + 1] !== undefined) out.stack = rest[++i]
23
+ }
24
+ return out
25
+ }
26
+
27
+ /** Reads Smartcat creds saved by a previous interactive run. `null` if missing/unreadable. */
28
+ function readSmartcatConfig(): Partial<SmartcatCreds> | null {
29
+ if (!existsSync(smartcatConfigPath)) return null
30
+ try {
31
+ return JSON.parse(readFileSync(smartcatConfigPath, 'utf8'))
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+
37
+ /** Saves prompted Smartcat creds to the gitignored `.smartcat/credentials.json`. */
38
+ function writeSmartcatConfig(creds: SmartcatCreds): void {
39
+ mkdirSync(path.dirname(smartcatConfigPath), {recursive: true})
40
+ writeFileSync(smartcatConfigPath, `${JSON.stringify(creds, null, 2)}\n`)
41
+ }
42
+
43
+ async function promptSmartcatCreds(): Promise<SmartcatCreds> {
44
+ const rl = createInterface({input: process.stdin, output: process.stdout})
45
+ try {
46
+ console.log(
47
+ 'No Smartcat credentials found (SMARTCAT_API_SERVER/_WORKSPACE_ID/_API_KEY). ' +
48
+ 'Enter them once — they will be saved to .smartcat/credentials.json (gitignored).',
49
+ )
50
+ const server = await rl.question('Smartcat API server (e.g. us.smartcat.com): ')
51
+ const workspaceId = await rl.question('Smartcat workspace id: ')
52
+ const apiKey = await rl.question('Smartcat API key: ')
53
+ return {server: server.trim(), workspaceId: workspaceId.trim(), apiKey: apiKey.trim()}
54
+ } finally {
55
+ rl.close()
56
+ }
57
+ }
58
+
59
+ /** Best-effort Sanity project id extraction from the customer's `sanity.cli.ts`/`sanity.config.ts`
60
+ * in the current working directory. Regex-based (no TS execution) since either file may import
61
+ * things this CLI has no business loading. */
62
+ function readSanityCliConfig(): string | null {
63
+ for (const file of ['sanity.cli.ts', 'sanity.config.ts']) {
64
+ const filePath = path.join(cwd, file)
65
+ if (!existsSync(filePath)) continue
66
+ const match = readFileSync(filePath, 'utf8').match(/projectId\s*:\s*['"]([a-z0-9]+)['"]/i)
67
+ if (match) return match[1]
68
+ }
69
+ return null
70
+ }
71
+
72
+ /** Shells out to the Sanity CLI via `npx sanity@latest` — avoids `@sanity/cli` as a direct
73
+ * dependency of this package; customers already have Sanity CLI access via `npx`. */
74
+ function runSanityCli(args: string[], opts: {cwd: string; env: NodeJS.ProcessEnv}): Promise<SanityCliResult> {
75
+ return new Promise((resolve, reject) => {
76
+ // `shell: true` is required for `npx` to resolve on Windows (spawning `npx.cmd` directly
77
+ // without a shell fails with EINVAL there). Args come from resolved config/flags supplied by
78
+ // whoever is running their own deploy — not an attacker-controlled boundary.
79
+ const child = spawn('npx', ['sanity@latest', ...args], {cwd: opts.cwd, env: opts.env, shell: true})
80
+ let stdout = ''
81
+ let stderr = ''
82
+ child.stdout?.on('data', (chunk: Buffer) => {
83
+ stdout += chunk
84
+ })
85
+ child.stderr?.on('data', (chunk: Buffer) => {
86
+ stderr += chunk
87
+ })
88
+ child.on('error', reject)
89
+ child.on('close', (code) => resolve({code: code ?? 1, stdout, stderr}))
90
+ })
91
+ }
92
+
93
+ /** Reads the version the deployed Functions stamped onto `smartcat.settings`. Only attempted in
94
+ * token mode — an interactive `sanity login` session isn't a bearer token this CLI can reuse to
95
+ * query the content API directly, so the skew check is skipped rather than skipping the deploy. */
96
+ async function fetchDeployedVersion(opts: {
97
+ projectId: string
98
+ sanityAuth: {mode: 'token'; token: string} | {mode: 'login'}
99
+ }): Promise<string | null> {
100
+ if (opts.sanityAuth.mode !== 'token') return null
101
+ try {
102
+ const dataset = process.env.SANITY_STUDIO_DATASET || process.env.SANITY_DATASET || 'production'
103
+ const client = createClient({
104
+ projectId: opts.projectId,
105
+ dataset,
106
+ apiVersion: '2025-02-01',
107
+ token: opts.sanityAuth.token,
108
+ useCdn: false,
109
+ })
110
+ const version = await client.fetch<string | null>('*[_id == "smartcat.settings"][0].addonVersion')
111
+ return version ?? null
112
+ } catch {
113
+ return null
114
+ }
115
+ }
116
+
117
+ /** Installed `@smartcat/sanity-plugin` version, resolved from the *customer's* `node_modules`
118
+ * (relative to `cwd`, not this package's own install location). `null` if not found. */
119
+ function readInstalledPluginVersion(): string | null {
120
+ try {
121
+ const require = createRequire(path.join(cwd, 'package.json'))
122
+ const pkgPath = require.resolve('@smartcat/sanity-plugin/package.json')
123
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
124
+ return typeof pkg.version === 'string' ? pkg.version : null
125
+ } catch {
126
+ return null
127
+ }
128
+ }
129
+
130
+ async function main(): Promise<void> {
131
+ const {command, project, stack} = parseArgs(process.argv.slice(2))
132
+
133
+ if (command !== 'deploy') {
134
+ console.error(
135
+ 'Usage: smartcat-sanity-functions deploy [--project <id>] [--stack <name>, defaults to "production"]',
136
+ )
137
+ process.exitCode = 1
138
+ return
139
+ }
140
+
141
+ const deps: DeployDeps = {
142
+ env: process.env,
143
+ args: {project, stack},
144
+ blueprintCwd: packageRoot,
145
+ readSmartcatConfig,
146
+ writeSmartcatConfig,
147
+ promptSmartcatCreds,
148
+ readSanityCliConfig,
149
+ runSanityCli,
150
+ fetchDeployedVersion,
151
+ installedPluginVersion: readInstalledPluginVersion(),
152
+ log: (line) => console.log(line),
153
+ }
154
+
155
+ const result = await deploy(deps)
156
+ process.exitCode = result.code
157
+ }
158
+
159
+ main().catch((err) => {
160
+ console.error(err instanceof Error ? err.message : err)
161
+ process.exitCode = 1
162
+ })
@@ -0,0 +1,34 @@
1
+ import {documentEventHandler} from '@sanity/functions'
2
+ import {createClient} from '@sanity/client'
3
+ import {SmartcatClient} from '@smartcat/sanity-plugin/smartcat'
4
+ import {runExportUpload} from '@smartcat/sanity-plugin/export'
5
+
6
+ /**
7
+ * Thin export Function: triggered when a project's status becomes "queued".
8
+ * The browser has already serialized content into the project's `outbox`; this
9
+ * function just creates the Smartcat project and uploads those files.
10
+ *
11
+ * Requires SMARTCAT_API_SERVER, SMARTCAT_WORKSPACE_ID, SMARTCAT_API_KEY env vars.
12
+ */
13
+ export const handler = documentEventHandler<{_id: string}>(async ({context, event}) => {
14
+ const sanity = createClient({...context.clientOptions, apiVersion: '2025-02-01'})
15
+ const smartcat = new SmartcatClient({
16
+ server: process.env.SMARTCAT_API_SERVER as string,
17
+ workspaceId: process.env.SMARTCAT_WORKSPACE_ID as string,
18
+ apiKey: process.env.SMARTCAT_API_KEY as string,
19
+ })
20
+
21
+ try {
22
+ const result = await runExportUpload({sanity, smartcat, projectId: event.data._id})
23
+ console.log(
24
+ `Exported ${event.data._id} → Smartcat ${result.smartcatProjectId} ` +
25
+ `(${result.created} created, ${result.updated} updated, ${result.deleted} deleted, ${result.failed} failed)`,
26
+ )
27
+ } catch (err) {
28
+ // The failure is already recorded on the document (status "error" + lastError)
29
+ // by runExportUpload. Swallow it here so the platform does NOT retry: createProject
30
+ // is not idempotent, and a retry can duplicate the Smartcat project or overwrite a
31
+ // concurrent success with a stale error. The user re-sends to retry.
32
+ console.error(`Export failed for ${event.data._id}:`, err instanceof Error ? err.message : err)
33
+ }
34
+ })
@@ -0,0 +1,35 @@
1
+ import {documentEventHandler} from '@sanity/functions'
2
+ import {createClient} from '@sanity/client'
3
+ import {SmartcatClient} from '@smartcat/sanity-plugin/smartcat'
4
+ import {runImportDownload} from '@smartcat/sanity-plugin/import'
5
+
6
+ /**
7
+ * Thin import Function: triggered when a project's status becomes "importing".
8
+ * Downloads each translated document's LocJSON from Smartcat into the project's
9
+ * `inbox` and sets status to "downloaded". The browser then turns the inbox into
10
+ * locale-variant documents.
11
+ *
12
+ * Requires SMARTCAT_API_SERVER, SMARTCAT_WORKSPACE_ID, SMARTCAT_API_KEY env vars.
13
+ */
14
+ export const handler = documentEventHandler<{_id: string}>(async ({context, event}) => {
15
+ const sanity = createClient({...context.clientOptions, apiVersion: '2025-02-01'})
16
+ const smartcat = new SmartcatClient({
17
+ server: process.env.SMARTCAT_API_SERVER as string,
18
+ workspaceId: process.env.SMARTCAT_WORKSPACE_ID as string,
19
+ apiKey: process.env.SMARTCAT_API_KEY as string,
20
+ })
21
+
22
+ const result = await runImportDownload({
23
+ sanity,
24
+ smartcat,
25
+ projectId: event.data._id,
26
+ // Mirror the run's log into the Function console, so `sanity functions
27
+ // logs smartcat-import` tells the whole story (batch sizes, timings,
28
+ // fallbacks) without reading the project document.
29
+ onLog: (line) => console.log(`[${line.level}] ${line.message}`),
30
+ })
31
+ console.log(
32
+ `Downloaded ${result.downloaded} translated file(s) for ${event.data._id}` +
33
+ (result.remaining ? ` (${result.remaining} deferred — run Import again to continue)` : ''),
34
+ )
35
+ })
@@ -0,0 +1,35 @@
1
+ import {documentEventHandler} from '@sanity/functions'
2
+ import {createClient} from '@sanity/client'
3
+ import {SmartcatClient} from '@smartcat/sanity-plugin/smartcat'
4
+ import {runProgressSync} from '@smartcat/sanity-plugin/progress'
5
+
6
+ /**
7
+ * Thin progress Function: triggered when the dashboard bumps a project's
8
+ * `progressRequestedAt` (on page load or the "Refresh progress" button).
9
+ * Fetches the Smartcat project and mirrors its per-document, per-stage progress
10
+ * onto the project's `progress` array. Read-only with respect to Smartcat — it
11
+ * never downloads or creates variants.
12
+ *
13
+ * Requires SMARTCAT_API_SERVER, SMARTCAT_WORKSPACE_ID, SMARTCAT_API_KEY env vars.
14
+ */
15
+ export const handler = documentEventHandler<{_id: string}>(async ({context, event}) => {
16
+ const sanity = createClient({...context.clientOptions, apiVersion: '2025-02-01'})
17
+ const smartcat = new SmartcatClient({
18
+ server: process.env.SMARTCAT_API_SERVER as string,
19
+ workspaceId: process.env.SMARTCAT_WORKSPACE_ID as string,
20
+ apiKey: process.env.SMARTCAT_API_KEY as string,
21
+ // Abort the Smartcat call at 9m30s — safely under this Function's 10-minute
22
+ // hard timeout — so a slow project fetch fails fast and runProgressSync can
23
+ // record it (functionLog + lastError) before the platform would kill the run.
24
+ requestTimeoutMs: 570_000,
25
+ })
26
+
27
+ const {summary, error} = await runProgressSync({sanity, smartcat, projectId: event.data._id})
28
+ if (error) {
29
+ console.error(`Progress sync failed for ${event.data._id}: ${error}`)
30
+ } else {
31
+ console.log(
32
+ `Synced progress for ${event.data._id}: ${summary.complete}/${summary.total} targets complete`,
33
+ )
34
+ }
35
+ })
@@ -0,0 +1,33 @@
1
+ import {documentEventHandler} from '@sanity/functions'
2
+ import {createClient} from '@sanity/client'
3
+ import {SmartcatClient} from '@smartcat/sanity-plugin/smartcat'
4
+ import {runTemplatesSync} from '@smartcat/sanity-plugin/templates'
5
+ import packageJson from '../../package.json'
6
+
7
+ /**
8
+ * Thin templates Function: triggered when the Studio bumps the settings
9
+ * singleton's `templatesRequestedAt` (on first load or when a create dialog
10
+ * opens). Fetches the workspace's project templates from Smartcat and caches
11
+ * them onto `smartcat.settings`. Read-only with respect to Smartcat.
12
+ *
13
+ * Also stamps `addonVersion` (this package's own version) onto the settings singleton on
14
+ * every run — the deploy CLI's skew check reads it back to compare against the Studio's
15
+ * installed `@smartcat/sanity-plugin` version. This is the only Function that already
16
+ * patches the settings doc, so it doubles as the version heartbeat; the stamp only refreshes
17
+ * when this Function actually fires (Studio init / create-dialog open), not immediately on
18
+ * deploy.
19
+ *
20
+ * Requires SMARTCAT_API_SERVER, SMARTCAT_WORKSPACE_ID, SMARTCAT_API_KEY env vars.
21
+ */
22
+ export const handler = documentEventHandler<{_id: string}>(async ({context, event}) => {
23
+ const sanity = createClient({...context.clientOptions, apiVersion: '2025-02-01'})
24
+ const smartcat = new SmartcatClient({
25
+ server: process.env.SMARTCAT_API_SERVER as string,
26
+ workspaceId: process.env.SMARTCAT_WORKSPACE_ID as string,
27
+ apiKey: process.env.SMARTCAT_API_KEY as string,
28
+ })
29
+
30
+ const {count} = await runTemplatesSync({sanity, smartcat, settingsId: event.data._id})
31
+ await sanity.patch(event.data._id).set({addonVersion: packageJson.version}).commit()
32
+ console.log(`Synced ${count} Smartcat templates (addon v${packageJson.version})`)
33
+ })
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@smartcat/sanity-functions",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "smartcat-sanity-functions": "./bin/cli.js"
9
+ },
10
+ "files": [
11
+ "functions",
12
+ "sanity.blueprint.ts",
13
+ "bin"
14
+ ],
15
+ "dependencies": {
16
+ "@smartcat/sanity-plugin": "1.0.0",
17
+ "@sanity/blueprints": "^0.20.1",
18
+ "@sanity/functions": "^1.3.1",
19
+ "@sanity/client": "^7.22.1"
20
+ },
21
+ "devDependencies": {
22
+ "esbuild": "^0.28.1"
23
+ },
24
+ "peerDependencies": {},
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "registry": "https://registry.npmjs.org/"
28
+ },
29
+ "scripts": {
30
+ "build": "npm run build:cli",
31
+ "build:cli": "esbuild bin/cli.ts --bundle --platform=node --format=esm --outfile=bin/cli.js --packages=external --banner:js=\"#!/usr/bin/env node\""
32
+ }
33
+ }
@@ -0,0 +1,88 @@
1
+ import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'
2
+
3
+ const PROJECT_TYPE = 'smartcat.translationProject'
4
+ const SETTINGS_TYPE = 'smartcat.settings'
5
+
6
+ /**
7
+ * Shared Smartcat credentials for all three Functions (Option B): declared once
8
+ * here and resolved from the deploy-time environment, so the secret stays out of
9
+ * this committed file. Make sure these are present in your shell/CI (e.g. sourced
10
+ * from `.env`) when running `sanity blueprints deploy`.
11
+ */
12
+ const smartcatEnv = {
13
+ SMARTCAT_API_SERVER: process.env.SMARTCAT_API_SERVER as string,
14
+ SMARTCAT_WORKSPACE_ID: process.env.SMARTCAT_WORKSPACE_ID as string,
15
+ SMARTCAT_API_KEY: process.env.SMARTCAT_API_KEY as string,
16
+ }
17
+
18
+ export default defineBlueprint({
19
+ resources: [
20
+ defineDocumentFunction({
21
+ name: 'smartcat-export',
22
+ // Uploads are batched (50 files per request) and parallelized (concurrency
23
+ // 10), so this 900s timeout has ample headroom even for a few hundred files.
24
+ timeout: 900,
25
+ env: smartcatEnv,
26
+ event: {
27
+ on: ['create', 'update'],
28
+ // Fires only on the transition INTO "queued" — not on every update while
29
+ // queued. The handler patches the project mid-run (e.g. persisting
30
+ // smartcatProjectId before uploads) while status is still "queued"; without
31
+ // the delta guard those writes re-trigger the export, spawning concurrent
32
+ // runs that collide on Smartcat (409 "document already exists").
33
+ filter: `_type == "${PROJECT_TYPE}" && status == "queued" && delta::changedAny(status)`,
34
+ projection: '{_id}',
35
+ },
36
+ }),
37
+ defineDocumentFunction({
38
+ name: 'smartcat-import',
39
+ // Downloads are batched (50 docs per Smartcat task, 4 tasks in parallel)
40
+ // and the run self-limits to a 10-minute budget, deferring the rest to
41
+ // the next cycle — so it always exits well before this hard timeout.
42
+ timeout: 900,
43
+ env: smartcatEnv,
44
+ event: {
45
+ on: ['create', 'update'],
46
+ // Fires only on the transition INTO "importing" (dashboard click or
47
+ // auto-continue). The delta guard stops unrelated patches (e.g. the
48
+ // progress Function writing while an import is running) from spawning
49
+ // duplicate concurrent runs. The handler always leaves "importing"
50
+ // (→ "downloaded"/"error") thanks to its internal time budget.
51
+ filter: `_type == "${PROJECT_TYPE}" && status == "importing" && delta::changedAny(status)`,
52
+ projection: '{_id}',
53
+ },
54
+ }),
55
+ defineDocumentFunction({
56
+ name: 'smartcat-progress',
57
+ // Read-only: one getProject call plus a patch. Usually quick, but a project
58
+ // with many documents returns a large unpaginated payload that can be slow,
59
+ // and the call has no in-code timeout — give it 10 minutes of headroom.
60
+ timeout: 600,
61
+ env: smartcatEnv,
62
+ event: {
63
+ on: ['create', 'update'],
64
+ // Fires when the dashboard bumps `progressRequestedAt` (page load / refresh).
65
+ // The handler writes `progress`/`progressSyncedAt` but never touches
66
+ // `progressRequestedAt`, so delta::changedAny stops it re-triggering.
67
+ filter: `_type == "${PROJECT_TYPE}" && defined(smartcatProjectId) && delta::changedAny(progressRequestedAt)`,
68
+ projection: '{_id}',
69
+ },
70
+ }),
71
+ defineDocumentFunction({
72
+ name: 'smartcat-templates',
73
+ // Read-only: one getTemplates call plus a patch. The default 10s is plenty.
74
+ timeout: 30,
75
+ env: smartcatEnv,
76
+ event: {
77
+ on: ['create', 'update'],
78
+ // Fires whenever a refresh is pending — on the singleton's first creation
79
+ // (no sync yet) or when the Studio bumps `templatesRequestedAt` past the
80
+ // last sync. The handler writes a later `templatesSyncedAt`, so the
81
+ // condition flips false on its own write and it won't re-trigger.
82
+ // (delta::changedAny is avoided here: it doesn't match the create event.)
83
+ filter: `_type == "${SETTINGS_TYPE}" && defined(templatesRequestedAt) && (!defined(templatesSyncedAt) || templatesRequestedAt > templatesSyncedAt)`,
84
+ projection: '{_id}',
85
+ },
86
+ }),
87
+ ],
88
+ })