@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 +207 -0
- package/bin/cli.ts +162 -0
- package/functions/smartcat-export/index.ts +34 -0
- package/functions/smartcat-import/index.ts +35 -0
- package/functions/smartcat-progress/index.ts +35 -0
- package/functions/smartcat-templates/index.ts +33 -0
- package/package.json +33 -0
- package/sanity.blueprint.ts +88 -0
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
|
+
})
|