Add Cloudflare Pages artifact deployment

Adds Cloudflare Pages artifact deployment support.
This commit is contained in:
kami 2026-05-07 20:04:22 +08:00 committed by GitHub
parent 8630fd380a
commit 09eb88f683
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2496 additions and 378 deletions

View file

@ -38,6 +38,7 @@
"@open-design/sidecar": "workspace:*",
"@open-design/sidecar-proto": "workspace:*",
"better-sqlite3": "^12.9.0",
"blake3-wasm": "2.1.5",
"chokidar": "^5.0.0",
"express": "^4.19.2",
"jszip": "^3.10.1",

View file

@ -137,6 +137,7 @@ function migrate(db) {
status TEXT NOT NULL DEFAULT 'ready',
status_message TEXT,
reachable_at INTEGER,
provider_metadata_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(project_id, file_name, provider_id),
@ -192,6 +193,9 @@ function migrate(db) {
if (!deploymentCols.some((c) => c.name === 'reachable_at')) {
db.exec(`ALTER TABLE deployments ADD COLUMN reachable_at INTEGER`);
}
if (!deploymentCols.some((c) => c.name === 'provider_metadata_json')) {
db.exec(`ALTER TABLE deployments ADD COLUMN provider_metadata_json TEXT`);
}
migrateCritique(db);
}
@ -201,6 +205,7 @@ const DEPLOYMENT_COLS = `id, project_id AS projectId, file_name AS fileName,
provider_id AS providerId, url, deployment_id AS deploymentId,
deployment_count AS deploymentCount, target, status,
status_message AS statusMessage, reachable_at AS reachableAt,
provider_metadata_json AS providerMetadataJson,
created_at AS createdAt, updated_at AS updatedAt`;
export function listDeployments(db, projectId) {
@ -260,15 +265,20 @@ export function upsertDeployment(db, deployment) {
status: deployment.status ?? existing?.status ?? 'ready',
statusMessage: deployment.statusMessage ?? null,
reachableAt: deployment.reachableAt ?? null,
providerMetadata:
deployment.providerMetadata === undefined
? existing?.providerMetadata
: deployment.providerMetadata,
createdAt: existing?.createdAt ?? deployment.createdAt ?? now,
updatedAt: deployment.updatedAt ?? now,
};
const providerMetadataJson = stringifyJsonObjectOrNull(next.providerMetadata);
db.prepare(
`INSERT INTO deployments
(id, project_id, file_name, provider_id, url, deployment_id,
deployment_count, target, status, status_message, reachable_at,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
provider_metadata_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, file_name, provider_id) DO UPDATE SET
url = excluded.url,
deployment_id = excluded.deployment_id,
@ -277,6 +287,7 @@ export function upsertDeployment(db, deployment) {
status = excluded.status,
status_message = excluded.status_message,
reachable_at = excluded.reachable_at,
provider_metadata_json = excluded.provider_metadata_json,
updated_at = excluded.updated_at`,
).run(
next.id,
@ -290,6 +301,7 @@ export function upsertDeployment(db, deployment) {
next.status,
next.statusMessage,
next.reachableAt,
providerMetadataJson,
next.createdAt,
next.updatedAt,
);
@ -297,6 +309,7 @@ export function upsertDeployment(db, deployment) {
}
function normalizeDeployment(row) {
const providerMetadata = parseJsonOrUndef(row.providerMetadataJson);
return {
id: row.id,
projectId: row.projectId,
@ -309,11 +322,20 @@ function normalizeDeployment(row) {
status: row.status || 'ready',
statusMessage: row.statusMessage ?? undefined,
reachableAt: row.reachableAt == null ? undefined : Number(row.reachableAt),
providerMetadata:
providerMetadata && typeof providerMetadata === 'object' && !Array.isArray(providerMetadata)
? providerMetadata
: undefined,
createdAt: Number(row.createdAt),
updatedAt: Number(row.updatedAt),
};
}
function stringifyJsonObjectOrNull(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return Object.keys(value).length > 0 ? JSON.stringify(value) : null;
}
// ---------- projects ----------
const PROJECT_COLS = `id, name, skill_id AS skillId,

View file

@ -4,12 +4,19 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { hash as blake3Hash } from 'blake3-wasm';
import { readProjectFile, validateProjectPath } from './projects.js';
export const VERCEL_PROVIDER_ID = 'vercel-self';
export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages';
export const SAVED_TOKEN_MASK = 'saved-vercel-token';
export const SAVED_CLOUDFLARE_TOKEN_MASK = 'saved-cloudflare-token';
const VERCEL_API = 'https://api.vercel.com';
const CLOUDFLARE_API = 'https://api.cloudflare.com/client/v4';
export const CLOUDFLARE_PAGES_ASSET_UPLOAD_MAX_FILES = 100;
export const CLOUDFLARE_PAGES_ASSET_UPLOAD_MAX_BODY_BYTES = 75 * 1024 * 1024;
export const CLOUDFLARE_PAGES_ASSET_MAX_BYTES = 25 * 1024 * 1024;
const VERCEL_PROTECTED_MESSAGE =
'Deployment is protected by Vercel. Disable Deployment Protection or use a custom domain to make this link public.';
@ -22,14 +29,14 @@ export class DeployError extends Error {
}
}
export function deployConfigPath() {
export function deployConfigPath(providerId = VERCEL_PROVIDER_ID) {
const base = process.env.OD_USER_STATE_DIR || path.join(os.homedir(), '.open-design');
return path.join(base, 'vercel.json');
return path.join(base, providerId === CLOUDFLARE_PAGES_PROVIDER_ID ? 'cloudflare-pages.json' : 'vercel.json');
}
export async function readVercelConfig() {
try {
const raw = await readFile(deployConfigPath(), 'utf8');
const raw = await readFile(deployConfigPath(VERCEL_PROVIDER_ID), 'utf8');
const parsed = JSON.parse(raw);
return {
token: typeof parsed.token === 'string' ? parsed.token : '',
@ -42,6 +49,21 @@ export async function readVercelConfig() {
}
}
export async function readCloudflarePagesConfig() {
try {
const raw = await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8');
const parsed = JSON.parse(raw);
return {
token: typeof parsed.token === 'string' ? parsed.token : '',
accountId: typeof parsed.accountId === 'string' ? parsed.accountId : '',
projectName: typeof parsed.projectName === 'string' ? parsed.projectName : '',
};
} catch (err) {
if (err && err.code === 'ENOENT') return { token: '', accountId: '', projectName: '' };
throw err;
}
}
export async function writeVercelConfig(input) {
const current = await readVercelConfig();
const tokenInput = typeof input?.token === 'string' ? input.token.trim() : '';
@ -54,15 +76,39 @@ export async function writeVercelConfig(input) {
teamSlug:
typeof input?.teamSlug === 'string' ? input.teamSlug.trim() : current.teamSlug,
};
const file = deployConfigPath();
await writeDeployConfigFile(deployConfigPath(VERCEL_PROVIDER_ID), next);
return publicDeployConfig(next);
}
export async function writeCloudflarePagesConfig(input) {
const current = await readCloudflarePagesConfig();
const tokenInput = typeof input?.token === 'string' ? input.token.trim() : '';
const next = {
token:
tokenInput && tokenInput !== SAVED_CLOUDFLARE_TOKEN_MASK
? tokenInput
: current.token,
accountId: typeof input?.accountId === 'string' ? input.accountId.trim() : current.accountId,
// Legacy installs may already have a saved Cloudflare Pages projectName.
// New writes intentionally stop treating it as user configuration: the
// deploy route derives a Pages project name from the current OD project,
// mirroring Vercel's automatic `od-${projectId}` deployment name.
projectName: '',
};
if (!next.token) throw new DeployError('Cloudflare API token is required.', 400);
if (!next.accountId) throw new DeployError('Cloudflare account ID is required.', 400);
await writeDeployConfigFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), next);
return publicCloudflarePagesConfig(next);
}
async function writeDeployConfigFile(file, config) {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
try {
fs.chmodSync(file, 0o600);
} catch {
// Best effort on filesystems that do not support chmod.
}
return publicDeployConfig(next);
}
export function publicDeployConfig(config) {
@ -76,6 +122,38 @@ export function publicDeployConfig(config) {
};
}
export function publicCloudflarePagesConfig(config) {
return {
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: Boolean(config?.token && config?.accountId),
tokenMask: config?.token ? SAVED_CLOUDFLARE_TOKEN_MASK : '',
teamId: '',
teamSlug: '',
accountId: config?.accountId || '',
projectName: config?.projectName || '',
target: 'preview',
};
}
export async function readDeployConfig(providerId = VERCEL_PROVIDER_ID) {
if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID) return readCloudflarePagesConfig();
return readVercelConfig();
}
export async function writeDeployConfig(providerId = VERCEL_PROVIDER_ID, input = {}) {
if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID) return writeCloudflarePagesConfig(input);
return writeVercelConfig(input);
}
export function publicDeployConfigForProvider(providerId = VERCEL_PROVIDER_ID, config = {}) {
if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID) return publicCloudflarePagesConfig(config);
return publicDeployConfig(config);
}
export function isDeployProviderId(value) {
return value === VERCEL_PROVIDER_ID || value === CLOUDFLARE_PAGES_PROVIDER_ID;
}
// Walk the entry HTML and any referenced CSS, producing the full set of
// files that would be uploaded for a deploy along with the lists of
// missing and invalid references. Does not throw on a partial result so
@ -220,7 +298,10 @@ export async function deployToVercel({ config, files, projectId }) {
}
const candidates = deploymentUrlCandidates(ready, created);
const link = await waitForReachableDeploymentUrl(candidates.length ? candidates : [initialUrl]);
const link = await waitForReachableDeploymentUrl(
candidates.length ? candidates : [initialUrl],
{ providerLabel: 'Vercel' },
);
return {
providerId: VERCEL_PROVIDER_ID,
@ -233,6 +314,229 @@ export async function deployToVercel({ config, files, projectId }) {
};
}
export async function deployToCloudflarePages({ config, files }) {
if (!config?.token) throw new DeployError('Cloudflare API token is required.', 400);
if (!config?.accountId) throw new DeployError('Cloudflare account ID is required.', 400);
if (!config?.projectName) throw new DeployError('Cloudflare Pages project name could not be generated.', 400);
await ensureCloudflarePagesProject(config);
const uploadToken = await getCloudflarePagesUploadToken(config);
await uploadCloudflarePagesAssets(uploadToken, files);
const form = new FormData();
const manifest = {};
for (const file of files) {
manifest[`/${file.file}`] = cloudflarePagesAssetHash(file);
}
form.append('manifest', JSON.stringify(manifest));
form.append('branch', 'main');
const deployResp = await fetch(cloudflarePagesProjectUrl(config, 'deployments'), {
method: 'POST',
headers: cloudflareHeaders(config),
body: form,
});
const deployed = await readCloudflareJson(deployResp);
if (!deployResp.ok || deployed?.success === false) {
throw cloudflareError(deployed, deployResp.status, 'Cloudflare Pages deployment failed.');
}
const deployment = deployed?.result ?? deployed;
const productionUrl = cloudflarePagesProductionUrl(config);
const link = await waitForReachableDeploymentUrl(
productionUrl ? [productionUrl] : [deployment?.url],
{ providerLabel: 'Cloudflare Pages' },
);
return {
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
url: productionUrl || link.url || deploymentUrl(deployment),
deploymentId: deployment?.id,
target: 'preview',
status: link.status,
statusMessage: link.statusMessage,
reachableAt: link.reachableAt,
};
}
async function ensureCloudflarePagesProject(config) {
const getResp = await fetch(cloudflarePagesProjectUrl(config), {
headers: cloudflareHeaders(config),
});
const found = await readCloudflareJson(getResp);
if (getResp.ok && found?.success !== false) return found?.result ?? found;
if (getResp.status !== 404) {
throw cloudflareError(found, getResp.status, 'Cloudflare Pages project lookup failed.');
}
const createResp = await fetch(cloudflareAccountPagesProjectsUrl(config), {
method: 'POST',
headers: cloudflareHeaders(config, { 'Content-Type': 'application/json' }),
body: JSON.stringify({
name: config.projectName,
production_branch: 'main',
}),
});
const created = await readCloudflareJson(createResp);
if (!createResp.ok || created?.success === false) {
if (isCloudflarePagesProjectAlreadyExists(created)) {
const retryResp = await fetch(cloudflarePagesProjectUrl(config), {
headers: cloudflareHeaders(config),
});
const retryFound = await readCloudflareJson(retryResp);
if (retryResp.ok && retryFound?.success !== false) {
return retryFound?.result ?? retryFound;
}
}
throw cloudflareError(created, createResp.status, 'Cloudflare Pages project creation failed.');
}
return created?.result ?? created;
}
function isCloudflarePagesProjectAlreadyExists(body) {
const text = JSON.stringify(body || {}).toLowerCase();
return (
text.includes('already exists') ||
text.includes('already exist') ||
text.includes('project exists') ||
text.includes('project name is taken') ||
text.includes('duplicate')
);
}
async function getCloudflarePagesUploadToken(config) {
const tokenResp = await fetch(cloudflarePagesProjectUrl(config, 'upload-token'), {
headers: cloudflareHeaders(config),
});
const tokenBody = await readCloudflareJson(tokenResp);
const jwt = tokenBody?.result?.jwt || tokenBody?.jwt;
if (!tokenResp.ok || tokenBody?.success === false || !jwt) {
throw cloudflareError(tokenBody, tokenResp.status, 'Cloudflare Pages upload token request failed.');
}
return jwt;
}
async function uploadCloudflarePagesAssets(uploadToken, files) {
const uniqueFiles = new Map();
for (const file of files) {
const data = Buffer.from(file.data);
if (data.length > CLOUDFLARE_PAGES_ASSET_MAX_BYTES) {
throw new DeployError(
`Cloudflare Pages assets must be ${formatMib(CLOUDFLARE_PAGES_ASSET_MAX_BYTES)} or smaller: ${file.file} is ${formatMib(data.length)}.`,
400,
);
}
const hash = cloudflarePagesAssetHash({ ...file, data });
if (!uniqueFiles.has(hash)) {
uniqueFiles.set(hash, {
hash,
data,
contentType: file.contentType || 'application/octet-stream',
});
}
}
const hashes = Array.from(uniqueFiles.keys());
const missing = await cloudflarePagesMissingAssetHashes(uploadToken, hashes);
if (missing.length > 0) {
const missingFiles = missing.map((hash) => {
const file = uniqueFiles.get(hash);
if (!file) throw new DeployError(`Cloudflare reported an unknown asset hash: ${hash}`, 502);
return {
...file,
hash,
};
});
for (const batch of chunkCloudflarePagesAssetUploads(missingFiles)) {
const payload = batch.map((file) => ({
key: file.hash,
value: file.data.toString('base64'),
metadata: {
contentType: file.contentType,
},
base64: true,
}));
const uploadResp = await fetch(`${CLOUDFLARE_API}/pages/assets/upload`, {
method: 'POST',
headers: cloudflareAssetHeaders(uploadToken, { 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
});
const uploaded = await readCloudflareJson(uploadResp);
if (!uploadResp.ok || uploaded?.success === false) {
throw cloudflareError(uploaded, uploadResp.status, 'Cloudflare Pages asset upload failed.');
}
}
}
const upsertResp = await fetch(`${CLOUDFLARE_API}/pages/assets/upsert-hashes`, {
method: 'POST',
headers: cloudflareAssetHeaders(uploadToken, { 'Content-Type': 'application/json' }),
body: JSON.stringify({ hashes }),
});
const upserted = await readCloudflareJson(upsertResp);
if (!upsertResp.ok || upserted?.success === false) {
throw cloudflareError(upserted, upsertResp.status, 'Cloudflare Pages asset hash update failed.');
}
}
export function chunkCloudflarePagesAssetUploads(
files,
{
maxFiles = CLOUDFLARE_PAGES_ASSET_UPLOAD_MAX_FILES,
maxBytes = CLOUDFLARE_PAGES_ASSET_UPLOAD_MAX_BODY_BYTES,
} = {},
) {
const chunks = [];
let current = [];
let currentBytes = 2; // JSON array brackets.
for (const file of files) {
const nextBytes = estimateCloudflarePagesAssetUploadPayloadBytes(file);
const wouldExceedCount = current.length >= maxFiles;
const wouldExceedBytes = current.length > 0 && currentBytes + nextBytes > maxBytes;
if (wouldExceedCount || wouldExceedBytes) {
chunks.push(current);
current = [];
currentBytes = 2;
}
current.push(file);
currentBytes += nextBytes;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
function estimateCloudflarePagesAssetUploadPayloadBytes(file) {
const data = Buffer.from(file?.data ?? '');
const encodedBytes = Math.ceil(data.length / 3) * 4;
const contentTypeBytes = Buffer.byteLength(file?.contentType || 'application/octet-stream');
const hashBytes = Buffer.byteLength(file?.hash || '');
// Conservative JSON/object overhead for `key`, `value`, `metadata`, and commas.
return encodedBytes + contentTypeBytes + hashBytes + 128;
}
async function cloudflarePagesMissingAssetHashes(uploadToken, hashes) {
const resp = await fetch(`${CLOUDFLARE_API}/pages/assets/check-missing`, {
method: 'POST',
headers: cloudflareAssetHeaders(uploadToken, { 'Content-Type': 'application/json' }),
body: JSON.stringify({ hashes }),
});
const json = await readCloudflareJson(resp);
if (!resp.ok || json?.success === false) {
throw cloudflareError(json, resp.status, 'Cloudflare Pages asset lookup failed.');
}
const result = json?.result ?? json;
return Array.isArray(result) ? result : Array.isArray(result?.hashes) ? result.hashes : hashes;
}
export function cloudflarePagesAssetHash(file) {
const data = Buffer.from(file.data);
const extension = path.posix.extname(file.file).slice(1);
return blake3Hash(`${data.toString('base64')}${extension}`).toString('hex').slice(0, 32);
}
export function extractHtmlReferences(html) {
const refs = [];
for (const tag of parseHtmlTags(html)) {
@ -536,7 +840,7 @@ export async function prepareDeployPreflight(projectsRoot, projectId, entryName,
const plan = await buildDeployFilePlan(projectsRoot, projectId, entryName, options);
const { warnings, totalBytes, totalFiles } = analyzeDeployPlan(plan);
return {
providerId: VERCEL_PROVIDER_ID,
providerId: options.providerId || VERCEL_PROVIDER_ID,
entry: plan.entryPath,
files: plan.files.map((f) => ({
path: f.file,
@ -731,7 +1035,7 @@ async function pollVercelDeployment(config, id) {
export async function waitForReachableDeploymentUrl(
urls,
{ timeoutMs = 60_000, intervalMs = 2_000 } = {},
{ timeoutMs = 60_000, intervalMs = 2_000, providerLabel = 'Deployment provider' } = {},
) {
const candidates = [...new Set((urls || []).map(normalizeDeploymentUrl).filter(Boolean))];
const fallbackUrl = candidates[0] || '';
@ -739,7 +1043,7 @@ export async function waitForReachableDeploymentUrl(
return {
status: 'link-delayed',
url: '',
statusMessage: 'Vercel did not return a public deployment URL.',
statusMessage: `${providerLabel} did not return a public deployment URL.`,
};
}
@ -773,7 +1077,7 @@ export async function waitForReachableDeploymentUrl(
status: 'link-delayed',
url: fallbackUrl,
statusMessage:
lastMessage || 'Vercel returned a deployment URL, but it is not reachable yet.',
lastMessage || `${providerLabel} returned a deployment URL, but it is not reachable yet.`,
};
}
@ -876,6 +1180,49 @@ function vercelTeamQuery(config) {
return s ? `?${s}` : '';
}
function cloudflareAccountPagesProjectsUrl(config) {
return `${CLOUDFLARE_API}/accounts/${encodeURIComponent(config.accountId)}/pages/projects`;
}
function cloudflarePagesProjectUrl(config, suffix = '') {
const base = `${cloudflareAccountPagesProjectsUrl(config)}/${encodeURIComponent(config.projectName)}`;
return suffix ? `${base}/${suffix}` : base;
}
function cloudflarePagesProductionUrl(config) {
return config?.projectName ? `https://${config.projectName}.pages.dev` : '';
}
export function cloudflarePagesProjectNameForProject(projectId, projectName = '') {
const idSuffix = safeDnsLabel(projectId).slice(0, 12) || randomUUID().slice(0, 8);
const nameBase = safeDnsLabel(projectName) || 'project';
const fixedLength = 'od--'.length + idSuffix.length;
const baseLength = Math.max(1, 63 - fixedLength);
return safeDnsLabel(`od-${nameBase.slice(0, baseLength)}-${idSuffix}`);
}
function cloudflareHeaders(config, extra = {}) {
return {
Authorization: `Bearer ${config.token}`,
...extra,
};
}
function cloudflareAssetHeaders(token, extra = {}) {
return {
Authorization: `Bearer ${token}`,
...extra,
};
}
async function readCloudflareJson(resp) {
try {
return await resp.json();
} catch {
return {};
}
}
async function readVercelJson(resp) {
try {
return await resp.json();
@ -884,6 +1231,16 @@ async function readVercelJson(resp) {
}
}
function cloudflareError(json, status, fallback) {
const message =
json?.errors?.find?.((err) => err?.message)?.message ||
json?.messages?.find?.((item) => item?.message)?.message ||
json?.message ||
fallback ||
`Cloudflare request failed (${status}).`;
return new DeployError(message, status, json);
}
function vercelError(json, status) {
const code = json?.error?.code;
const message = json?.error?.message || json?.message || `Vercel request failed (${status}).`;
@ -900,9 +1257,20 @@ function deploymentUrl(json) {
}
function safeVercelProjectName(raw) {
return safeProjectLabel(raw, 80) || `od-${randomUUID().slice(0, 8)}`;
}
function safeDnsLabel(raw) {
return safeProjectLabel(raw, 63);
}
function safeProjectLabel(raw, maxLength) {
return String(raw)
.normalize('NFKD')
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || `od-${randomUUID().slice(0, 8)}`;
.slice(0, maxLength)
.replace(/-+$/g, '');
}

View file

@ -142,13 +142,17 @@ import { CHAT_TOOL_ENDPOINTS, CHAT_TOOL_OPERATIONS, toolTokenRegistry } from './
import {
buildDeployFileSet,
checkDeploymentUrl,
CLOUDFLARE_PAGES_PROVIDER_ID,
cloudflarePagesProjectNameForProject,
DeployError,
deployToCloudflarePages,
deployToVercel,
isDeployProviderId,
prepareDeployPreflight,
publicDeployConfig,
readVercelConfig,
publicDeployConfigForProvider,
readDeployConfig,
VERCEL_PROVIDER_ID,
writeVercelConfig,
writeDeployConfig,
} from './deploy.js';
/** @typedef {import('@open-design/contracts').ApiErrorCode} ApiErrorCode */
@ -886,6 +890,46 @@ function sendApiError(res, status, code, message, init = {}) {
.json(createCompatApiErrorResponse(code, message, init));
}
const CLOUDFLARE_PAGES_PROJECT_METADATA_KEY = 'cloudflarePagesProjectName';
function cloudflarePagesDeploymentMetadata(projectName) {
const normalized = typeof projectName === 'string' ? projectName.trim() : '';
return normalized
? { [CLOUDFLARE_PAGES_PROJECT_METADATA_KEY]: normalized }
: undefined;
}
function cloudflarePagesProjectNameFromDeployment(deployment) {
const value = deployment?.providerMetadata?.[CLOUDFLARE_PAGES_PROJECT_METADATA_KEY];
if (typeof value === 'string' && value.trim()) return value.trim();
return cloudflarePagesProjectNameFromUrl(deployment?.url);
}
function cloudflarePagesProjectNameFromUrl(rawUrl) {
if (typeof rawUrl !== 'string' || !rawUrl.trim()) return '';
try {
const host = new URL(rawUrl).hostname.toLowerCase();
if (!host.endsWith('.pages.dev')) return '';
const labels = host.slice(0, -'.pages.dev'.length).split('.').filter(Boolean);
return labels.at(-1) || '';
} catch {
return '';
}
}
function cloudflarePagesProjectNameForDeploy(db, projectId, projectName, prior) {
const priorName = cloudflarePagesProjectNameFromDeployment(prior);
if (priorName) return priorName;
for (const deployment of listDeployments(db, projectId)) {
if (deployment.providerId !== CLOUDFLARE_PAGES_PROVIDER_ID) continue;
const stableName = cloudflarePagesProjectNameFromDeployment(deployment);
if (stableName) return stableName;
}
return cloudflarePagesProjectNameForProject(projectId, projectName);
}
// Filename slug for the Content-Disposition header on archive downloads.
// Browsers reject quotes and control bytes; we keep Unicode letters/digits
// so a project name with non-ASCII characters (e.g. "café-design")
@ -2791,10 +2835,15 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
// ---- Deploy --------------------------------------------------------------
app.get('/api/deploy/config', async (_req, res) => {
app.get('/api/deploy/config', async (req, res) => {
try {
const providerId =
typeof req.query.providerId === 'string' ? req.query.providerId : VERCEL_PROVIDER_ID;
if (!isDeployProviderId(providerId)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'unsupported deploy provider');
}
/** @type {import('@open-design/contracts').DeployConfigResponse} */
const body = publicDeployConfig(await readVercelConfig());
const body = publicDeployConfigForProvider(providerId, await readDeployConfig(providerId));
res.json(body);
} catch (err) {
sendApiError(res, 500, 'INTERNAL_ERROR', String(err?.message || err));
@ -2803,8 +2852,14 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.put('/api/deploy/config', async (req, res) => {
try {
const input = req.body || {};
const providerId =
typeof input.providerId === 'string' ? input.providerId : VERCEL_PROVIDER_ID;
if (!isDeployProviderId(providerId)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'unsupported deploy provider');
}
/** @type {import('@open-design/contracts').DeployConfigResponse} */
const body = await writeVercelConfig(req.body || {});
const body = await writeDeployConfig(providerId, input);
res.json(body);
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
@ -2824,7 +2879,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.post('/api/projects/:id/deploy', async (req, res) => {
try {
const { fileName, providerId = VERCEL_PROVIDER_ID } = req.body || {};
if (providerId !== VERCEL_PROVIDER_ID) {
if (!isDeployProviderId(providerId)) {
return sendApiError(
res,
400,
@ -2842,11 +2897,25 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
req.params.id,
fileName,
);
const result = await deployToVercel({
config: await readVercelConfig(),
files,
projectId: req.params.id,
});
const project = getProject(db, req.params.id);
const cloudflarePagesProjectName =
providerId === CLOUDFLARE_PAGES_PROVIDER_ID
? cloudflarePagesProjectNameForDeploy(db, req.params.id, project?.name, prior)
: '';
const result = providerId === CLOUDFLARE_PAGES_PROVIDER_ID
? await deployToCloudflarePages({
config: {
...await readDeployConfig(CLOUDFLARE_PAGES_PROVIDER_ID),
projectName: cloudflarePagesProjectName,
},
files,
projectId: req.params.id,
})
: await deployToVercel({
config: await readDeployConfig(VERCEL_PROVIDER_ID),
files,
projectId: req.params.id,
});
const now = Date.now();
/** @type {import('@open-design/contracts').DeployProjectFileResponse} */
const body = upsertDeployment(db, {
@ -2861,6 +2930,10 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
status: result.status,
statusMessage: result.statusMessage,
reachableAt: result.reachableAt,
providerMetadata:
providerId === CLOUDFLARE_PAGES_PROVIDER_ID
? cloudflarePagesDeploymentMetadata(cloudflarePagesProjectName)
: prior?.providerMetadata,
createdAt: prior?.createdAt ?? now,
updatedAt: now,
});
@ -2884,7 +2957,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.post('/api/projects/:id/deploy/preflight', async (req, res) => {
try {
const { fileName, providerId = VERCEL_PROVIDER_ID } = req.body || {};
if (providerId !== VERCEL_PROVIDER_ID) {
if (!isDeployProviderId(providerId)) {
return sendApiError(
res,
400,
@ -2900,6 +2973,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
PROJECTS_DIR,
req.params.id,
fileName,
{ providerId },
);
res.json(body);
} catch (err) {
@ -2937,11 +3011,19 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
'deployment not found',
);
}
const result = await checkDeploymentUrl(existing.url);
const stableCloudflareProjectName =
existing.providerId === CLOUDFLARE_PAGES_PROVIDER_ID
? cloudflarePagesProjectNameFromDeployment(existing)
: '';
const checkUrl = stableCloudflareProjectName
? `https://${stableCloudflareProjectName}.pages.dev`
: existing.url;
const result = await checkDeploymentUrl(checkUrl);
const now = Date.now();
/** @type {import('@open-design/contracts').CheckDeploymentLinkResponse} */
const body = upsertDeployment(db, {
...existing,
url: checkUrl || existing.url,
status: result.reachable ? 'ready' : result.status || 'link-delayed',
statusMessage: result.reachable
? 'Public link is ready.'

View file

@ -0,0 +1,308 @@
import type http from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
CLOUDFLARE_PAGES_PROVIDER_ID,
deployConfigPath,
SAVED_CLOUDFLARE_TOKEN_MASK,
} from '../src/deploy.js';
import { ensureProject } from '../src/projects.js';
import { startServer } from '../src/server.js';
describe('deploy provider routes', () => {
let server: http.Server;
let baseUrl: string;
beforeAll(async () => {
const started = await startServer({ port: 0, returnServer: true }) as {
url: string;
server: http.Server;
};
baseUrl = started.url;
server = started.server;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
it('dispatches deploy config reads and writes by providerId', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-config-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
}),
});
expect(saveResp.status).toBe(200);
expect(await saveResp.json()).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
tokenMask: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_123',
projectName: '',
});
const getResp = await fetch(
`${baseUrl}/api/deploy/config?providerId=${CLOUDFLARE_PAGES_PROVIDER_ID}`,
);
expect(getResp.status).toBe(200);
expect(await getResp.json()).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
tokenMask: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_123',
projectName: '',
});
expect(JSON.parse(await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8'))).toEqual({
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: '',
});
const maskedResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_456',
}),
});
expect(maskedResp.status).toBe(200);
expect(await maskedResp.json()).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
tokenMask: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_456',
projectName: '',
});
expect(JSON.parse(await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8'))).toEqual({
token: 'cloudflare-token-secret',
accountId: 'account_456',
projectName: '',
});
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('dispatches deploy preflight by providerId', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const projectId = `deploy-route-${Date.now()}`;
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><meta name="viewport" content="width=device-width"><h1>Hello</h1>',
);
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy/preflight`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
}),
});
expect(resp.status).toBe(200);
expect(await resp.json()).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
entry: 'index.html',
totalFiles: 1,
});
});
it('derives Cloudflare Pages project names from the Open Design project', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-auto-project-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = 'cf-route-123456';
const expectedPagesProject = 'od-ai-cf-route-123';
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'AI 生图网站',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.status).toBe(200);
const createFileResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'index.html',
content: '<!doctype html><h1>Hello</h1>',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Index',
entry: 'index.html',
renderer: 'html',
exports: ['html'],
},
}),
});
expect(createFileResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cloudflare-token-secret',
accountId: 'account_123',
}),
});
expect(saveResp.status).toBe(200);
const realFetch = globalThis.fetch;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
const method = init?.method || (input instanceof Request ? input.method : 'GET');
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url.endsWith(`/pages/projects/${expectedPagesProject}`) && method === 'GET') {
return new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}'));
expect(body).toMatchObject({
name: expectedPagesProject,
production_branch: 'main',
});
return new Response(JSON.stringify({ success: true, result: { name: body.name } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/upload-token`) && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { jwt: 'pages-upload-jwt' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/check-missing') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}')) as { hashes?: string[] };
expect(Array.isArray(body.hashes)).toBe(true);
expect(body.hashes?.length).toBeGreaterThan(0);
return new Response(JSON.stringify({ success: true, result: body.hashes }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upload') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '[]')) as Array<{
key?: string;
value?: string;
metadata?: { contentType?: string };
base64?: boolean;
}>;
expect(body).toHaveLength(1);
expect(body[0]?.base64).toBe(true);
expect(body[0]?.metadata?.contentType).toMatch(/^text\/html/);
expect(body[0]?.key).toMatch(/^[a-f0-9]{32}$/);
expect(body[0]?.value).toEqual(expect.any(String));
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upsert-hashes') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}')) as { hashes?: string[] };
expect(Array.isArray(body.hashes)).toBe(true);
expect(body.hashes?.length).toBeGreaterThan(0);
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/deployments`) && method === 'POST') {
const form = init?.body as FormData;
const manifest = JSON.parse(String(form.get('manifest') ?? '{}')) as Record<string, string>;
expect(Object.keys(manifest)).toContain('/index.html');
expect(form.get('branch')).toBe('main');
expect(form.get('pages_build_output_dir')).toBeNull();
return new Response(JSON.stringify({
success: true,
result: { id: 'cf_dep_123', url: `https://d34527d9.${expectedPagesProject}.pages.dev` },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === `https://${expectedPagesProject}.pages.dev` && method === 'HEAD') {
return new Response('', { status: 200 });
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
try {
const deployResp = await fetch(`${baseUrl}/api/projects/${projectId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
}),
});
const deployBody = await deployResp.text();
expect(deployResp.status, deployBody).toBe(200);
const deployment = JSON.parse(deployBody) as { id: string };
expect(deployment).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
deploymentId: 'cf_dep_123',
url: `https://${expectedPagesProject}.pages.dev`,
status: 'ready',
providerMetadata: {
cloudflarePagesProjectName: expectedPagesProject,
},
});
const renameResp = await fetch(`${baseUrl}/api/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Renamed project after deploy' }),
});
expect(renameResp.status).toBe(200);
const checkResp = await fetch(`${baseUrl}/api/projects/${projectId}/deployments/${deployment.id}/check-link`, {
method: 'POST',
});
expect(checkResp.status).toBe(200);
expect(await checkResp.json()).toMatchObject({
url: `https://${expectedPagesProject}.pages.dev`,
status: 'ready',
});
} finally {
vi.unstubAllGlobals();
}
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
});

View file

@ -1,18 +1,25 @@
import { mkdtemp, writeFile, mkdir } from 'node:fs/promises';
import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises';
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
analyzeDeployPlan,
buildDeployFilePlan,
buildDeployFileSet,
checkDeploymentUrl,
chunkCloudflarePagesAssetUploads,
CLOUDFLARE_PAGES_ASSET_MAX_BYTES,
CLOUDFLARE_PAGES_PROVIDER_ID,
cloudflarePagesAssetHash,
cloudflarePagesProjectNameForProject,
DEPLOY_PREFLIGHT_LARGE_ASSET_BYTES,
DEPLOY_PREFLIGHT_LARGE_HTML_BYTES,
deploymentUrlCandidates,
deployToCloudflarePages,
deployConfigPath,
extractCssReferences,
extractHtmlReferences,
extractInlineCssReferences,
@ -20,10 +27,17 @@ import {
isVercelProtectedResponse,
normalizeDeployHookScriptUrl,
prepareDeployPreflight,
publicDeployConfig,
readVercelConfig,
resolveReferencedPath,
rewriteCssReferences,
rewriteEntryHtmlReferences,
SAVED_CLOUDFLARE_TOKEN_MASK,
SAVED_TOKEN_MASK,
VERCEL_PROVIDER_ID,
waitForReachableDeploymentUrl,
writeCloudflarePagesConfig,
writeVercelConfig,
} from '../src/deploy.js';
import { ensureProject } from '../src/projects.js';
@ -34,6 +48,143 @@ async function setupProject() {
return { projectsRoot: path.join(root, 'projects'), projectId, dir };
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('deploy config', () => {
it('stores Vercel credentials in vercel.json and returns only the public mask', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-config-test-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const saved = await writeVercelConfig({
token: 'vercel-token-secret',
teamId: 'team_123',
teamSlug: 'design-team',
});
expect(path.basename(deployConfigPath())).toBe('vercel.json');
expect(saved).toEqual({
providerId: VERCEL_PROVIDER_ID,
configured: true,
tokenMask: SAVED_TOKEN_MASK,
teamId: 'team_123',
teamSlug: 'design-team',
target: 'preview',
});
expect(JSON.parse(await readFile(deployConfigPath(), 'utf8'))).toEqual({
token: 'vercel-token-secret',
teamId: 'team_123',
teamSlug: 'design-team',
});
const maskedUpdate = await writeVercelConfig({
token: SAVED_TOKEN_MASK,
teamSlug: 'renamed-team',
});
expect(maskedUpdate.tokenMask).toBe(SAVED_TOKEN_MASK);
expect(await readVercelConfig()).toEqual({
token: 'vercel-token-secret',
teamId: 'team_123',
teamSlug: 'renamed-team',
});
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('keeps Vercel public config provider metadata stable', () => {
expect(publicDeployConfig({
token: 'vercel-token-secret',
teamId: '',
teamSlug: '',
})).toEqual({
providerId: VERCEL_PROVIDER_ID,
configured: true,
tokenMask: SAVED_TOKEN_MASK,
teamId: '',
teamSlug: '',
target: 'preview',
});
});
it('stores Cloudflare Pages credentials separately from vercel.json', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-config-test-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
const saved = await writeCloudflarePagesConfig({
token: 'cloudflare-token-secret',
accountId: 'account_123',
});
expect(path.basename(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID))).toBe('cloudflare-pages.json');
expect(path.basename(deployConfigPath(VERCEL_PROVIDER_ID))).toBe('vercel.json');
expect(saved).toEqual({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
tokenMask: SAVED_CLOUDFLARE_TOKEN_MASK,
teamId: '',
teamSlug: '',
accountId: 'account_123',
projectName: '',
target: 'preview',
});
expect(JSON.parse(await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8'))).toEqual({
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: '',
});
const maskedUpdate = await writeCloudflarePagesConfig({
token: SAVED_CLOUDFLARE_TOKEN_MASK,
accountId: 'account_456',
});
expect(maskedUpdate.tokenMask).toBe(SAVED_CLOUDFLARE_TOKEN_MASK);
expect(maskedUpdate.accountId).toBe('account_456');
expect(JSON.parse(await readFile(deployConfigPath(CLOUDFLARE_PAGES_PROVIDER_ID), 'utf8'))).toEqual({
token: 'cloudflare-token-secret',
accountId: 'account_456',
projectName: '',
});
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
it('requires Cloudflare Pages token and account id while deriving project names automatically', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-config-required-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
try {
await expect(writeCloudflarePagesConfig({
token: 'cloudflare-token-secret',
})).rejects.toThrow(/account ID is required/i);
await expect(writeCloudflarePagesConfig({
accountId: 'account_123',
})).rejects.toThrow(/API token is required/i);
expect(cloudflarePagesProjectNameForProject('project-123', 'AI 生图网站')).toBe(
'od-ai-project-123',
);
expect(cloudflarePagesProjectNameForProject('12345678', '中文项目')).toBe(
'od-project-12345678',
);
} finally {
if (priorStateRoot === undefined) delete process.env.OD_USER_STATE_DIR;
else process.env.OD_USER_STATE_DIR = priorStateRoot;
await rm(stateRoot, { recursive: true, force: true });
}
});
});
describe('deploy file set', () => {
it('deploys a single html file as index.html', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
@ -619,6 +770,16 @@ describe('deploy plan and analyzer', () => {
expect(codes).not.toContain('broken-reference');
});
it('preflight preserves provider identity when requested', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
const result = await prepareDeployPreflight(projectsRoot, projectId, 'index.html', {
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
});
expect(result.providerId).toBe(CLOUDFLARE_PAGES_PROVIDER_ID);
});
it('preflight reports broken references instead of throwing', async () => {
const { projectsRoot, projectId, dir } = await setupProject();
await writeFile(
@ -650,6 +811,328 @@ describe('deploy plan and analyzer', () => {
});
});
describe('cloudflare pages deploys', () => {
it('chunks asset uploads before posting to Cloudflare Pages', () => {
const chunks = chunkCloudflarePagesAssetUploads(
[
{ hash: 'a'.repeat(32), data: Buffer.from('one'), contentType: 'text/plain' },
{ hash: 'b'.repeat(32), data: Buffer.from('two'), contentType: 'text/plain' },
{ hash: 'c'.repeat(32), data: Buffer.from('three'), contentType: 'text/plain' },
],
{ maxFiles: 2, maxBytes: 10_000 },
);
expect(chunks.map((chunk) => chunk.map((file) => file.hash))).toEqual([
['a'.repeat(32), 'b'.repeat(32)],
['c'.repeat(32)],
]);
});
it('rejects Cloudflare Pages assets above the per-file upload limit', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
const method =
init?.method || (input instanceof Request ? input.method : 'GET');
if (url.endsWith('/pages/projects/demo-pages') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { name: 'demo-pages' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/upload-token') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { jwt: 'pages-upload-jwt' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
await expect(deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
files: [
{
file: 'huge.bin',
data: Buffer.alloc(CLOUDFLARE_PAGES_ASSET_MAX_BYTES + 1),
contentType: 'application/octet-stream',
sourcePath: 'huge.bin',
},
],
})).rejects.toThrow(/25\.00 MiB or smaller/);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('creates missing projects and uploads assets before submitting a manifest', async () => {
const requests: Array<{ url: string; method: string; body?: any; headers: Headers }> = [];
const indexHash = cloudflarePagesAssetHash({
file: 'index.html',
data: Buffer.from('hello index'),
});
const assetHash = cloudflarePagesAssetHash({
file: 'assets/style.css',
data: Buffer.from('body { color: red; }'),
});
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
const method =
init?.method || (input instanceof Request ? input.method : 'GET');
const headers = new Headers(
init?.headers || (input instanceof Request ? input.headers : undefined),
);
requests.push({ url, method, body: init?.body, headers });
if (url.endsWith('/pages/projects/demo-pages') && method === 'GET') {
return new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}'));
expect(body).toEqual({
name: 'demo-pages',
production_branch: 'main',
});
return new Response(JSON.stringify({ success: true, result: { name: body.name } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/upload-token') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { jwt: 'pages-upload-jwt' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/check-missing') && method === 'POST') {
expect(headers.get('authorization')).toBe('Bearer pages-upload-jwt');
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({
hashes: [indexHash, assetHash],
});
return new Response(JSON.stringify({ success: true, result: [indexHash, assetHash] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upload') && method === 'POST') {
expect(headers.get('authorization')).toBe('Bearer pages-upload-jwt');
expect(JSON.parse(String(init?.body ?? '[]'))).toEqual([
{
key: indexHash,
value: Buffer.from('hello index').toString('base64'),
metadata: { contentType: 'text/html' },
base64: true,
},
{
key: assetHash,
value: Buffer.from('body { color: red; }').toString('base64'),
metadata: { contentType: 'text/css' },
base64: true,
},
]);
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upsert-hashes') && method === 'POST') {
expect(headers.get('authorization')).toBe('Bearer pages-upload-jwt');
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({
hashes: [indexHash, assetHash],
});
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/deployments') && method === 'POST') {
const form = init?.body as FormData;
expect(form).toBeInstanceOf(FormData);
const manifest = JSON.parse(String(form?.get('manifest') ?? '{}')) as Record<string, string>;
expect(form.get('branch')).toBe('main');
expect(form.get('pages_build_output_dir')).toBeNull();
expect(manifest).toEqual({
'/index.html': indexHash,
'/assets/style.css': assetHash,
});
expect(form.get(indexHash)).toBeNull();
expect(form.get(assetHash)).toBeNull();
return new Response(JSON.stringify({
success: true,
result: { id: 'dep_123', url: 'https://d34527d9.demo-pages.pages.dev' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo-pages.pages.dev' && method === 'HEAD') {
return new Response('', { status: 200 });
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
files: [
{
file: 'index.html',
data: Buffer.from('hello index'),
contentType: 'text/html',
sourcePath: 'index.html',
},
{
file: 'assets/style.css',
data: Buffer.from('body { color: red; }'),
contentType: 'text/css',
sourcePath: 'assets/style.css',
},
],
});
expect(result).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
deploymentId: 'dep_123',
url: 'https://demo-pages.pages.dev',
status: 'ready',
});
expect(requests).toHaveLength(8);
expect(requests[0]?.headers.get('authorization')).toBe('Bearer cloudflare-token-secret');
});
it('treats concurrent Cloudflare Pages project creation races as already satisfied', async () => {
const indexHash = cloudflarePagesAssetHash({
file: 'index.html',
data: Buffer.from('hello index'),
});
let projectLookupCount = 0;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
const method =
init?.method || (input instanceof Request ? input.method : 'GET');
if (url.endsWith('/pages/projects/demo-pages') && method === 'GET') {
projectLookupCount += 1;
if (projectLookupCount === 1) {
return new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), {
status: 404,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true, result: { name: 'demo-pages' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects') && method === 'POST') {
return new Response(
JSON.stringify({ success: false, errors: [{ message: 'Project already exists' }] }),
{ status: 409, headers: { 'content-type': 'application/json' } },
);
}
if (url.endsWith('/pages/projects/demo-pages/upload-token') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { jwt: 'pages-upload-jwt' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/check-missing') && method === 'POST') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upsert-hashes') && method === 'POST') {
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({ hashes: [indexHash] });
return new Response(JSON.stringify({ success: true, result: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/projects/demo-pages/deployments') && method === 'POST') {
return new Response(JSON.stringify({
success: true,
result: { id: 'dep_123', url: 'https://d34527d9.demo-pages.pages.dev' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo-pages.pages.dev' && method === 'HEAD') {
return new Response('', { status: 200 });
}
throw new Error(`Unexpected fetch: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const result = await deployToCloudflarePages({
config: {
token: 'cloudflare-token-secret',
accountId: 'account_123',
projectName: 'demo-pages',
},
files: [
{
file: 'index.html',
data: Buffer.from('hello index'),
contentType: 'text/html',
sourcePath: 'index.html',
},
],
});
expect(result).toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
deploymentId: 'dep_123',
url: 'https://demo-pages.pages.dev',
status: 'ready',
});
expect(projectLookupCount).toBe(2);
});
});
describe('deployment link readiness', () => {
async function withServer(
handler: (req: IncomingMessage, res: ServerResponse) => void,
@ -687,6 +1170,17 @@ describe('deployment link readiness', () => {
});
});
it('uses provider-specific copy for missing public URLs', async () => {
const result = await waitForReachableDeploymentUrl([], {
providerLabel: 'Cloudflare Pages',
});
expect(result).toMatchObject({
status: 'link-delayed',
statusMessage: 'Cloudflare Pages did not return a public deployment URL.',
});
});
it('marks a Vercel authentication page as protected', async () => {
await withServer((_req, res) => {
res.writeHead(401, {

View file

@ -8,6 +8,8 @@ import {
fetchLiveArtifactCode,
fetchLiveArtifactRefreshes,
checkDeploymentLink,
CLOUDFLARE_PAGES_PROVIDER_ID,
DEFAULT_DEPLOY_PROVIDER_ID,
deployProjectFile,
fetchDeployConfig,
fetchProjectDeployments,
@ -19,6 +21,11 @@ import {
LiveArtifactRefreshError,
refreshLiveArtifact,
updateDeployConfig,
type WebDeployConfigResponse,
type WebDeploymentInfo,
type WebDeployProjectFileResponse,
type WebDeployProviderId,
type WebUpdateDeployConfigRequest,
writeProjectTextFile,
} from '../providers/registry';
import type { ProjectFilePreview } from '../providers/registry';
@ -38,8 +45,6 @@ import { parseForceInline, shouldUrlLoadHtmlPreview } from './file-viewer-render
import { saveTemplate } from '../state/projects';
import type {
LiveArtifactEventItem,
DeployConfigResponse,
DeployProjectFileResponse,
LiveArtifact,
LiveArtifactRefreshLogEntry,
LiveArtifactViewerTab,
@ -75,6 +80,23 @@ type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) =>
type SlideState = { active: number; count: number };
type BoardTool = 'inspect' | 'pod';
type StrokePoint = { x: number; y: number };
type DeployProviderOption = {
id: WebDeployProviderId;
labelKey: 'fileViewer.vercelProvider' | 'fileViewer.cloudflarePagesProvider';
tokenLink: string;
tokenLinkKey: 'fileViewer.vercelTokenGetLink' | 'fileViewer.cloudflareApiTokenGetLink';
tokenPlaceholderKey:
| 'fileViewer.vercelTokenPlaceholder'
| 'fileViewer.cloudflareApiTokenPlaceholder';
tokenReuseHintKey: 'fileViewer.vercelTokenReuseHint' | 'fileViewer.cloudflareApiTokenReuseHint';
tokenRequiredKey: 'fileViewer.vercelTokenRequired' | 'fileViewer.cloudflareApiTokenRequired';
previewHintKey: 'fileViewer.vercelPreviewOnly' | 'fileViewer.cloudflarePagesPreviewHint';
tokenLabelKey:
| 'fileViewer.vercelToken'
| 'fileViewer.cloudflareApiToken';
accountIdLabelKey?: 'fileViewer.cloudflareAccountId';
accountIdHintKey?: 'fileViewer.cloudflareAccountIdHint';
};
const MAX_BRIDGE_COORDINATE = 1_000_000;
// The five basic style facets the inspect panel exposes. Kept narrow on
@ -111,6 +133,37 @@ const MARKDOWN_COPY_BLOCK_ATTR = 'data-copy-code-block';
const MARKDOWN_COPY_BUTTON_CLASS = 'markdown-code-copy';
const MARKDOWN_COPY_TOAST_CLASS = 'markdown-code-toast';
const DEPLOY_PROVIDER_OPTIONS: DeployProviderOption[] = [
{
id: DEFAULT_DEPLOY_PROVIDER_ID,
labelKey: 'fileViewer.vercelProvider',
tokenLink: 'https://vercel.com/account/settings/tokens',
tokenLinkKey: 'fileViewer.vercelTokenGetLink',
tokenPlaceholderKey: 'fileViewer.vercelTokenPlaceholder',
tokenReuseHintKey: 'fileViewer.vercelTokenReuseHint',
tokenRequiredKey: 'fileViewer.vercelTokenRequired',
previewHintKey: 'fileViewer.vercelPreviewOnly',
tokenLabelKey: 'fileViewer.vercelToken',
},
{
id: CLOUDFLARE_PAGES_PROVIDER_ID,
labelKey: 'fileViewer.cloudflarePagesProvider',
tokenLink: 'https://dash.cloudflare.com/profile/api-tokens',
tokenLinkKey: 'fileViewer.cloudflareApiTokenGetLink',
tokenPlaceholderKey: 'fileViewer.cloudflareApiTokenPlaceholder',
tokenReuseHintKey: 'fileViewer.cloudflareApiTokenReuseHint',
tokenRequiredKey: 'fileViewer.cloudflareApiTokenRequired',
previewHintKey: 'fileViewer.cloudflarePagesPreviewHint',
tokenLabelKey: 'fileViewer.cloudflareApiToken',
accountIdLabelKey: 'fileViewer.cloudflareAccountId',
accountIdHintKey: 'fileViewer.cloudflareAccountIdHint',
},
];
function getDeployProviderOption(providerId: WebDeployProviderId): DeployProviderOption {
return DEPLOY_PROVIDER_OPTIONS.find((option) => option.id === providerId) ?? DEPLOY_PROVIDER_OPTIONS[0]!;
}
async function copyTextToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
@ -2753,18 +2806,21 @@ function HtmlViewer({
const [templateName, setTemplateName] = useState('');
const [templateDescription, setTemplateDescription] = useState('');
const [templateSaveError, setTemplateSaveError] = useState<string | null>(null);
const [deployment, setDeployment] = useState<DeployProjectFileResponse | null>(null);
const [deployment, setDeployment] = useState<WebDeploymentInfo | null>(null);
const [deploymentsByProvider, setDeploymentsByProvider] = useState<Partial<Record<WebDeployProviderId, WebDeploymentInfo>>>({});
const [deployModalOpen, setDeployModalOpen] = useState(false);
const [deployConfig, setDeployConfig] = useState<DeployConfigResponse | null>(null);
const [deployConfig, setDeployConfig] = useState<WebDeployConfigResponse | null>(null);
const [deploying, setDeploying] = useState(false);
const [deployPhase, setDeployPhase] = useState<'idle' | 'deploying' | 'preparing-link'>('idle');
const [savingDeployConfig, setSavingDeployConfig] = useState(false);
const [deployError, setDeployError] = useState<string | null>(null);
const [deployResult, setDeployResult] = useState<DeployProjectFileResponse | null>(null);
const [deployResult, setDeployResult] = useState<WebDeployProjectFileResponse | null>(null);
const [copiedDeployLink, setCopiedDeployLink] = useState(false);
const [vercelToken, setVercelToken] = useState('');
const [deployProviderId, setDeployProviderId] = useState<WebDeployProviderId>(DEFAULT_DEPLOY_PROVIDER_ID);
const [deployToken, setDeployToken] = useState('');
const [teamId, setTeamId] = useState('');
const [teamSlug, setTeamSlug] = useState('');
const [cloudflareAccountId, setCloudflareAccountId] = useState('');
const [inTabPresent, setInTabPresent] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [boardMode, setBoardMode] = useState(false);
@ -2819,6 +2875,69 @@ function HtmlViewer({
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
const previewStateKey = `${projectId}:${file.name}`;
const previewScale = zoom / 100;
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
for (const option of DEPLOY_PROVIDER_OPTIONS) {
const deploymentForProvider = items.find(
(item) => item.fileName === file.name && item.providerId === option.id && item.url?.trim(),
);
if (deploymentForProvider) next[option.id] = deploymentForProvider;
}
return next;
}
function syncDeployFormFromConfig(
providerId: WebDeployProviderId,
config: WebDeployConfigResponse | null,
) {
const matchingConfig = config?.providerId === providerId ? config : null;
setDeployProviderId(providerId);
setDeployConfig(matchingConfig);
setDeployToken(matchingConfig?.tokenMask || '');
setTeamId(matchingConfig?.teamId || '');
setTeamSlug(matchingConfig?.teamSlug || '');
setCloudflareAccountId(matchingConfig?.accountId || '');
}
function buildDeployConfigRequest(providerId: WebDeployProviderId): WebUpdateDeployConfigRequest {
const token = deployToken.trim();
if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID) {
return {
providerId,
token,
accountId: cloudflareAccountId.trim(),
};
}
return {
providerId,
token,
teamId: teamId.trim(),
teamSlug: teamSlug.trim(),
};
}
async function loadDeployProvider(
providerId: WebDeployProviderId,
options?: { fallbackToExisting?: boolean },
) {
const deployments = await fetchProjectDeployments(projectId);
const nextDeploymentsByProvider = deploymentMapForCurrentFile(deployments);
const exactDeployment = nextDeploymentsByProvider[providerId] ?? null;
const fallbackDeployment = options?.fallbackToExisting
? Object.values(nextDeploymentsByProvider)[0] ?? null
: null;
const currentDeployment = exactDeployment ?? fallbackDeployment;
// Use the explicit providerId for config/form so a fallback deployment from
// another provider only fills the existing-URL display, never the form/credentials.
const config = await fetchDeployConfig(providerId);
syncDeployFormFromConfig(providerId, config);
setDeploymentsByProvider(nextDeploymentsByProvider);
setDeployment(currentDeployment ?? null);
setDeployResult(currentDeployment ?? null);
return { config, currentDeployment };
}
// Slide deck nav state: the iframe posts the active index + total count
// back to the host every time a slide settles. Host renders prev/next
// controls in the toolbar and reflects the count beside them.
@ -2856,16 +2975,16 @@ function HtmlViewer({
setDeployPhase('idle');
void fetchProjectDeployments(projectId).then((items) => {
if (cancelled) return;
const current = items.find(
(item) => item.fileName === file.name && item.providerId === 'vercel-self',
);
const nextDeploymentsByProvider = deploymentMapForCurrentFile(items);
const current = nextDeploymentsByProvider[deployProviderId] ?? null;
setDeploymentsByProvider(nextDeploymentsByProvider);
setDeployment(current ?? null);
setDeployResult(current ?? null);
});
return () => {
cancelled = true;
};
}, [projectId, file.name]);
}, [projectId, file.name, deployProviderId]);
// Detect deck-shaped HTML even when the project's skill didn't declare
// `mode: deck`. Freeform projects often produce a deck because the user
@ -3544,77 +3663,83 @@ function HtmlViewer({
}
}
async function openDeployModal() {
async function openDeployModal(nextProviderId: WebDeployProviderId = deployProviderId) {
setShareMenuOpen(false);
setDeployModalOpen(true);
setDeployError(null);
setCopiedDeployLink(false);
setDeployPhase('idle');
const [config, deployments] = await Promise.all([
fetchDeployConfig(),
fetchProjectDeployments(projectId),
]);
if (config) {
setDeployConfig(config);
setVercelToken(config.tokenMask || '');
setTeamId(config.teamId || '');
setTeamSlug(config.teamSlug || '');
}
const current = deployments.find(
(item) => item.fileName === file.name && item.providerId === 'vercel-self',
);
setDeployment(current ?? null);
setDeployResult(current ?? null);
await loadDeployProvider(nextProviderId, { fallbackToExisting: true });
}
async function changeDeployProvider(nextProviderId: WebDeployProviderId) {
if (nextProviderId === deployProviderId) return;
setDeployError(null);
setDeployPhase('idle');
await loadDeployProvider(nextProviderId);
}
async function saveDeployConfig() {
setSavingDeployConfig(true);
setDeployError(null);
try {
const config = await updateDeployConfig({
token: vercelToken,
teamId,
teamSlug,
});
if (!config) throw new Error(t('fileViewer.deployConfigSaveFailed'));
setDeployConfig(config);
setVercelToken(config.tokenMask || '');
setTeamId(config.teamId || '');
setTeamSlug(config.teamSlug || '');
if (deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID) {
if (!deployToken.trim()) {
throw new Error(t('fileViewer.cloudflareApiTokenRequired'));
}
if (!cloudflareAccountId.trim()) {
throw new Error(t('fileViewer.cloudflareAccountIdRequired'));
}
}
const config = await updateDeployConfig(buildDeployConfigRequest(deployProviderId));
if (!config || config.providerId !== deployProviderId) {
throw new Error(t('fileViewer.deployProviderConfigSaveFailed', { provider: deployProviderLabel }));
}
syncDeployFormFromConfig(deployProviderId, config);
return config;
} catch (err) {
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployConfigSaveFailed'));
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployProviderConfigSaveFailed', { provider: deployProviderLabel }));
return null;
} finally {
setSavingDeployConfig(false);
}
}
async function deployToVercel() {
async function deployToSelectedProvider() {
setDeploying(true);
setDeployPhase('deploying');
setDeployError(null);
setCopiedDeployLink(false);
try {
const typedToken = vercelToken.trim();
const typedToken = deployToken.trim();
const hasNewToken = typedToken && typedToken !== deployConfig?.tokenMask;
const needsConfigSave =
hasNewToken ||
teamId.trim() !== (deployConfig?.teamId || '') ||
teamSlug.trim() !== (deployConfig?.teamSlug || '') ||
cloudflareAccountId.trim() !== (deployConfig?.accountId || '') ||
!deployConfig?.configured;
if (needsConfigSave) {
const nextConfig = await saveDeployConfig();
if (!nextConfig) return;
if (!nextConfig?.configured) {
throw new Error(t('fileViewer.vercelTokenRequired'));
const option = getDeployProviderOption(deployProviderId);
throw new Error(t(option.tokenRequiredKey, { provider: t(option.labelKey) }));
}
}
setDeployPhase('preparing-link');
const next = await deployProjectFile(projectId, file.name);
const next = await deployProjectFile(projectId, file.name, deployProviderId);
setDeploymentsByProvider((current) => ({
...current,
[next.providerId]: next,
}));
setDeployment(next);
setDeployResult(next);
} catch (err) {
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployFailed'));
const option = getDeployProviderOption(deployProviderId);
setDeployError(
err instanceof Error ? err.message : t('fileViewer.deployProviderFailed', { provider: t(option.labelKey) }),
);
} finally {
setDeploying(false);
setDeployPhase('idle');
@ -3628,6 +3753,10 @@ function HtmlViewer({
setDeployPhase('preparing-link');
try {
const next = await checkDeploymentLink(projectId, current.id);
setDeploymentsByProvider((items) => ({
...items,
[next.providerId]: next,
}));
setDeployment(next);
setDeployResult(next);
} catch (err) {
@ -3746,9 +3875,34 @@ function HtmlViewer({
const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed';
const activeDeploymentProtected = activeDeployment?.status === 'protected';
const activeDeploymentNeedsRetry = activeDeploymentDelayed || activeDeploymentProtected;
const deployProvider = getDeployProviderOption(deployProviderId);
const deployProviderLabel = t(deployProvider.labelKey);
const deployActionLabelFor = (providerId: WebDeployProviderId) => {
const option = getDeployProviderOption(providerId);
const label = t(option.labelKey);
const hasActiveDeploymentForProvider = Boolean(deploymentsByProvider[providerId]?.url?.trim());
return hasActiveDeploymentForProvider
? t('fileViewer.redeployToProvider', { provider: label })
: t('fileViewer.deployToProvider', { provider: label });
};
const deployCopyLinks = DEPLOY_PROVIDER_OPTIONS.map((option) => ({
providerId: option.id,
providerLabel: t(option.labelKey),
url: deploymentsByProvider[option.id]?.url?.trim() || '',
})).filter((item) => item.url);
const deployButtonLabel =
deployPhase === 'deploying'
? t('fileViewer.deployingToProvider', { provider: deployProviderLabel })
: deployPhase === 'preparing-link'
? t('fileViewer.preparingPublicLink')
: t('fileViewer.deployToProvider', { provider: deployProviderLabel });
const copyDeployLabel = copiedDeployLink
? t('fileViewer.copied')
: t('fileViewer.copyDeployLink');
const copyDeployMenuLabel = (providerLabel: string) =>
copiedDeployLink
? t('fileViewer.copied')
: `${t('fileViewer.copyDeployLink')} · ${providerLabel}`;
return (
<div className="viewer html-viewer">
@ -4082,36 +4236,38 @@ function HtmlViewer({
</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
void openDeployModal();
}}
>
<span className="share-menu-icon"><Icon name="upload" size={14} /></span>
<span>
{activeDeployedUrl
? t('fileViewer.redeployToVercel')
: t('fileViewer.deployToVercel')}
</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
disabled={!activeDeployedUrl}
onClick={() => {
setShareMenuOpen(false);
void copyDeployLink(activeDeployedUrl);
}}
>
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
<span>
{copyDeployLabel}
</span>
</button>
{DEPLOY_PROVIDER_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
void openDeployModal(option.id);
}}
>
<span className="share-menu-icon"><Icon name="upload" size={14} /></span>
<span>{deployActionLabelFor(option.id)}</span>
</button>
))}
{deployCopyLinks.length > 0 ? (
<div className="share-menu-divider" />
) : null}
{deployCopyLinks.map((item) => (
<button
key={`copy-${item.providerId}`}
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
void copyDeployLink(item.url);
}}
>
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
<span>{copyDeployMenuLabel(item.providerLabel)}</span>
</button>
))}
</div>
) : null}
</div>
@ -4364,27 +4520,42 @@ function HtmlViewer({
<div className="modal-backdrop" role="presentation">
<div className="modal deploy-modal" role="dialog" aria-modal="true">
<div className="modal-head">
<div className="kicker">VERCEL</div>
<h2>{t('fileViewer.deployModalTitle')}</h2>
<div className="kicker">{deployProviderLabel}</div>
<h2>{t('fileViewer.deployToProvider', { provider: deployProviderLabel })}</h2>
<p className="subtitle">{t('fileViewer.deployModalSubtitle')}</p>
</div>
<div className="deploy-form">
<label className="deploy-provider-field">
<span>{t('fileViewer.deployProviderLabel')}</span>
<select
value={deployProviderId}
onChange={(e) => {
void changeDeployProvider(e.target.value as WebDeployProviderId);
}}
>
{DEPLOY_PROVIDER_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{t(option.labelKey)}
</option>
))}
</select>
</label>
<div className="field-label-row">
<label htmlFor="vercel-token">{t('fileViewer.vercelToken')}</label>
<label htmlFor="deploy-token">{t(deployProvider.tokenLabelKey)}</label>
<a
href="https://vercel.com/account/settings/tokens"
href={deployProvider.tokenLink}
target="_blank"
rel="noreferrer noopener"
>
{t('fileViewer.vercelTokenGetLink')}
{t(deployProvider.tokenLinkKey)}
</a>
</div>
<input
id="vercel-token"
id="deploy-token"
type="password"
value={vercelToken}
placeholder={t('fileViewer.vercelTokenPlaceholder')}
onChange={(e) => setVercelToken(e.target.value)}
value={deployToken}
placeholder={t(deployProvider.tokenPlaceholderKey, { provider: deployProviderLabel })}
onChange={(e) => setDeployToken(e.target.value)}
/>
<div className="deploy-config-actions">
<button
@ -4399,27 +4570,43 @@ function HtmlViewer({
</button>
</div>
{deployConfig?.configured ? (
<p className="hint">{t('fileViewer.vercelTokenReuseHint')}</p>
<p className="hint">{t(deployProvider.tokenReuseHintKey, { provider: deployProviderLabel })}</p>
) : null}
<div className="deploy-field-grid">
<label>
<span>{t('fileViewer.vercelTeamId')}</span>
<input
value={teamId}
placeholder={t('fileViewer.optional')}
onChange={(e) => setTeamId(e.target.value)}
/>
</label>
<label>
<span>{t('fileViewer.vercelTeamSlug')}</span>
<input
value={teamSlug}
placeholder={t('fileViewer.optional')}
onChange={(e) => setTeamSlug(e.target.value)}
/>
</label>
</div>
<p className="hint">{t('fileViewer.vercelPreviewOnly')}</p>
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
<p className="hint">{t('fileViewer.cloudflareApiTokenScopeHint')}</p>
) : null}
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
<div className="deploy-field-grid single-field">
<label>
<span>{t('fileViewer.cloudflareAccountId')}</span>
<input
value={cloudflareAccountId}
onChange={(e) => setCloudflareAccountId(e.target.value)}
/>
<span className="field-hint">{t('fileViewer.cloudflareAccountIdHint')}</span>
</label>
</div>
) : (
<div className="deploy-field-grid">
<label>
<span>{t('fileViewer.vercelTeamId')}</span>
<input
value={teamId}
placeholder={t('fileViewer.optional')}
onChange={(e) => setTeamId(e.target.value)}
/>
</label>
<label>
<span>{t('fileViewer.vercelTeamSlug')}</span>
<input
value={teamSlug}
placeholder={t('fileViewer.optional')}
onChange={(e) => setTeamSlug(e.target.value)}
/>
</label>
</div>
)}
<p className="hint">{t(deployProvider.previewHintKey)}</p>
{deployError ? <p className="deploy-error">{deployError}</p> : null}
{activeDeployedUrl ? (
<div
@ -4468,16 +4655,16 @@ function HtmlViewer({
>
<Icon name="copy" size={14} />
<span>{copyDeployLabel}</span>
</button>
<a
className={`ghost-link ${activeDeploymentReady ? '' : 'disabled'}`}
href={activeDeploymentReady ? activeDeployedUrl : undefined}
target="_blank"
rel="noreferrer noopener"
aria-disabled={!activeDeploymentReady}
>
<Icon name="upload" size={14} />
{t('fileViewer.open')}
</button>
<a
className={`ghost-link ${activeDeploymentProtected ? 'disabled' : ''}`}
href={activeDeploymentProtected ? undefined : activeDeployedUrl}
target="_blank"
rel="noreferrer noopener"
aria-disabled={activeDeploymentProtected}
>
<Icon name="upload" size={14} />
{t('fileViewer.open')}
</a>
</div>
</div>
@ -4496,14 +4683,10 @@ function HtmlViewer({
className="viewer-action primary"
disabled={deploying || savingDeployConfig || deployPhase !== 'idle'}
onClick={() => {
void deployToVercel();
void deployToSelectedProvider();
}}
>
{deployPhase === 'deploying'
? t('fileViewer.deployingToVercel')
: deployPhase === 'preparing-link'
? t('fileViewer.preparingPublicLink')
: t('fileViewer.deployToVercel')}
{deployButtonLabel}
</button>
</div>
</div>

View file

@ -692,34 +692,48 @@ export const ar: Dict = {
'fileViewer.templateNameDefault': 'قالب بدون عنوان',
'fileViewer.templateDescPrompt':
'وصف قصير (اختياري - ما الذي يجعل هذا القالب مفيداً؟)',
'fileViewer.deployToVercel': 'نشر على Vercel',
'fileViewer.deployToVercel': 'النشر إلى Vercel',
'fileViewer.redeployToVercel': 'إعادة النشر',
'fileViewer.deployingToVercel': 'جاري النشر على Vercel...',
'fileViewer.deployingToVercel': 'جارٍ النشر إلى Vercel…',
'fileViewer.deployProviderLabel': 'منصة النشر',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'النشر إلى {provider}',
'fileViewer.redeployToProvider': 'إعادة النشر إلى {provider}',
'fileViewer.deployingToProvider': 'جارٍ النشر إلى {provider}…',
'fileViewer.preparingPublicLink': 'جاري تحضير الرابط العام...',
'fileViewer.copyDeployLink': 'نسخ الرابط',
'fileViewer.deployModalTitle': 'نشر على Vercel',
'fileViewer.deployModalSubtitle':
'انشر هذا الـ HTML كمعاينة Vercel باستخدام حسابك الخاص.',
'fileViewer.deployModalTitle': 'النشر',
'fileViewer.deployModalSubtitle': 'استخدم حساب منصة النشر المحددة لنشر معاينة HTML هذه.',
'fileViewer.vercelToken': 'رمز Vercel',
'fileViewer.vercelTokenGetLink': 'احصل على رمز Vercel',
'fileViewer.vercelTokenPlaceholder': 'الصق رمز Vercel الخاص بك',
'fileViewer.vercelTokenReuseHint':
'سيتم استخدام الرمز المحفوظ. أدخل رمزاً جديداً لاستبداله.',
'fileViewer.vercelTokenReuseHint': 'سيتم استخدام الرمز المحفوظ. أدخل رمزاً جديداً لاستبداله.',
'fileViewer.vercelTokenRequired': 'أدخل واحفظ رمز Vercel أولاً.',
'fileViewer.cloudflareApiToken': 'رمز Cloudflare API',
'fileViewer.cloudflareApiTokenGetLink': 'احصل على رمز Cloudflare API',
'fileViewer.cloudflareApiTokenPlaceholder': 'الصق رمز Cloudflare API الخاص بك',
'fileViewer.cloudflareApiTokenReuseHint': 'سيتم استخدام رمز Cloudflare API المحفوظ. أدخل رمزاً جديداً لاستبداله.',
'fileViewer.cloudflareApiTokenRequired': 'أدخل واحفظ رمز Cloudflare API أولاً.',
'fileViewer.cloudflareApiTokenScopeHint': 'يحتاج الرمز إلى صلاحية Account: Cloudflare Pages: Edit مع صلاحية قراءة الحساب.',
'fileViewer.vercelTeamId': 'معرف الفريق',
'fileViewer.vercelTeamSlug': 'اسم الفريق اللطيف',
'fileViewer.cloudflareAccountId': 'معرف الحساب',
'fileViewer.cloudflareAccountIdHint': 'مطلوب. اعثر على معرف الحساب في لوحة Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'أدخل واحفظ Cloudflare Account ID أولاً.',
'fileViewer.optional': 'اختياري',
'fileViewer.vercelPreviewOnly': 'النشر للمعاينة فقط حالياً.',
'fileViewer.cloudflarePagesPreviewHint': 'تستخدم Cloudflare Pages أسلوب Direct Upload.',
'fileViewer.savingConfig': 'جاري الحفظ...',
'fileViewer.deployConfigSaveFailed': 'تعذر حفظ إعدادات Vercel.',
'fileViewer.deployFailed': 'فشل النشر. تحقق من إعدادات Vercel وحاول مرة أخرى.',
'fileViewer.deployProviderConfigSaveFailed': 'تعذر حفظ إعدادات {provider}.',
'fileViewer.deployProviderFailed': 'فشل النشر إلى {provider}. تحقق من الإعدادات وحاول مرة أخرى.',
'fileViewer.deployResultLabel': 'رابط النشر',
'fileViewer.deployLinkPreparingLabel': 'الرابط العام معلق',
'fileViewer.deployLinkDelayed':
'تم نشر موقعك. Vercel لا يزال يحضر الرابط العام.',
'fileViewer.deployLinkProtectedLabel': 'حماية Vercel مفعلة',
'fileViewer.deployLinkProtected':
'تم نشر موقعك، لكن Vercel يتطلب المصادقة لهذا الرابط. عطل حماية النشر أو استخدم دومين مخصص.',
'fileViewer.deployLinkDelayed': 'تم نشر الموقع، لكن الرابط العام ما زال قيد التحضير.',
'fileViewer.deployLinkProtectedLabel': 'حماية النشر مفعلة',
'fileViewer.deployLinkProtected': 'تم نشر الموقع، لكن رابط المعاينة هذا يتطلب المصادقة. عطّل Deployment Protection أو استخدم نطاقاً مخصصاً.',
'fileViewer.retryLink': 'إعادة المحاولة الآن',
'questionForm.submit': 'إرسال',

View file

@ -648,32 +648,46 @@ export const de: Dict = {
'Kurze Beschreibung (optional — was macht dieses Template nützlich?)',
'fileViewer.deployToVercel': 'Auf Vercel deployen',
'fileViewer.redeployToVercel': 'Erneut deployen',
'fileViewer.deployingToVercel': 'Deployment auf Vercel läuft…',
'fileViewer.deployingToVercel': 'Deployment auf Vercel…',
'fileViewer.deployProviderLabel': 'Anbieter',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'Auf {provider} deployen',
'fileViewer.redeployToProvider': 'Erneut auf {provider} deployen',
'fileViewer.deployingToProvider': 'Deployment auf {provider}…',
'fileViewer.preparingPublicLink': 'Öffentlicher Link wird vorbereitet…',
'fileViewer.copyDeployLink': 'Link kopieren',
'fileViewer.deployModalTitle': 'Auf Vercel deployen',
'fileViewer.deployModalSubtitle':
'Deployen Sie dieses HTML-Artifact als Vercel Preview mit Ihrem eigenen Konto.',
'fileViewer.deployModalTitle': 'Bereitstellen',
'fileViewer.deployModalSubtitle': 'Verwende das Konto des gewählten Anbieters, um diese HTML-Vorschau zu deployen.',
'fileViewer.vercelToken': 'Vercel Token',
'fileViewer.vercelTokenGetLink': 'Vercel Token abrufen',
'fileViewer.vercelTokenPlaceholder': 'Vercel Token einfügen',
'fileViewer.vercelTokenReuseHint':
'Gespeicherter Token wird verwendet. Geben Sie einen neuen Token ein, um ihn zu ersetzen.',
'fileViewer.vercelTokenRequired': 'Geben und speichern Sie zuerst einen Vercel Token.',
'fileViewer.vercelTokenReuseHint': 'Gespeicherter Token wird verwendet. Gib einen neuen Token ein, um ihn zu ersetzen.',
'fileViewer.vercelTokenRequired': 'Gib zuerst einen Vercel Token ein und speichere ihn.',
'fileViewer.cloudflareApiToken': 'Cloudflare API-Token',
'fileViewer.cloudflareApiTokenGetLink': 'Cloudflare API-Token abrufen',
'fileViewer.cloudflareApiTokenPlaceholder': 'Cloudflare API-Token einfügen',
'fileViewer.cloudflareApiTokenReuseHint': 'Der gespeicherte Cloudflare API-Token wird verwendet. Gib einen neuen Token ein, um ihn zu ersetzen.',
'fileViewer.cloudflareApiTokenRequired': 'Gib zuerst einen Cloudflare API-Token ein und speichere ihn.',
'fileViewer.cloudflareApiTokenScopeHint': 'Der Token benötigt Account: Cloudflare Pages: Edit sowie Lesezugriff auf das Konto.',
'fileViewer.vercelTeamId': 'Team-ID',
'fileViewer.vercelTeamSlug': 'Team-Slug',
'fileViewer.cloudflareAccountId': 'Account-ID',
'fileViewer.cloudflareAccountIdHint': 'Erforderlich. Die Account-ID findest du im Cloudflare-Dashboard.',
'fileViewer.cloudflareAccountIdRequired': 'Gib zuerst eine Cloudflare Account ID ein und speichere sie.',
'fileViewer.optional': 'Optional',
'fileViewer.vercelPreviewOnly': 'Deployments sind vorerst nur Previews.',
'fileViewer.vercelPreviewOnly': 'Deployments sind derzeit nur Preview-Deployments.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages nutzt Direct Upload.',
'fileViewer.savingConfig': 'Speichern…',
'fileViewer.deployConfigSaveFailed': 'Vercel-Einstellungen konnten nicht gespeichert werden.',
'fileViewer.deployFailed': 'Deployment fehlgeschlagen. Prüfen Sie die Vercel-Einstellungen und versuchen Sie es erneut.',
'fileViewer.deployResultLabel': 'Bereitgestellte URL',
'fileViewer.deployFailed': 'Deployment fehlgeschlagen. Prüfe die Vercel-Einstellungen und versuche es erneut.',
'fileViewer.deployProviderConfigSaveFailed': '{provider}-Einstellungen konnten nicht gespeichert werden.',
'fileViewer.deployProviderFailed': '{provider}-Deployment fehlgeschlagen. Prüfe die Einstellungen und versuche es erneut.',
'fileViewer.deployResultLabel': 'Deployment-URL',
'fileViewer.deployLinkPreparingLabel': 'Öffentlicher Link ausstehend',
'fileViewer.deployLinkDelayed':
'Ihre Site ist deployed. Vercel bereitet den öffentlichen Link noch vor.',
'fileViewer.deployLinkProtectedLabel': 'Vercel Protection aktiviert',
'fileViewer.deployLinkProtected':
'Ihre Site wurde deployed, aber Vercel verlangt Authentifizierung für diesen Preview-Link. Deaktivieren Sie Deployment Protection oder verwenden Sie eine Custom Domain.',
'fileViewer.deployLinkDelayed': 'Die Seite wurde deployt. Der Anbieter bereitet den öffentlichen Link noch vor.',
'fileViewer.deployLinkProtectedLabel': 'Deployment-Schutz aktiviert',
'fileViewer.deployLinkProtected': 'Die Seite wurde deployt, aber dieser Vorschau-Link erfordert eine Anmeldung. Deaktiviere Deployment Protection oder nutze eine eigene Domain.',
'fileViewer.retryLink': 'Jetzt erneut versuchen',
'questionForm.submit': 'Absenden',

View file

@ -726,31 +726,45 @@ export const en: Dict = {
'fileViewer.deployToVercel': 'Deploy to Vercel',
'fileViewer.redeployToVercel': 'Redeploy',
'fileViewer.deployingToVercel': 'Deploying to Vercel…',
'fileViewer.deployProviderLabel': 'Provider',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'Deploy to {provider}',
'fileViewer.redeployToProvider': 'Redeploy to {provider}',
'fileViewer.deployingToProvider': 'Deploying to {provider}…',
'fileViewer.preparingPublicLink': 'Preparing public link…',
'fileViewer.copyDeployLink': 'Copy link',
'fileViewer.deployModalTitle': 'Deploy to Vercel',
'fileViewer.deployModalSubtitle':
'Deploy this HTML artifact as a Vercel Preview using your own account.',
'fileViewer.deployModalTitle': 'Deploy',
'fileViewer.deployModalSubtitle': 'Use the selected provider account to deploy this HTML preview.',
'fileViewer.vercelToken': 'Vercel token',
'fileViewer.vercelTokenGetLink': 'Get Vercel token',
'fileViewer.vercelTokenPlaceholder': 'Paste your Vercel token',
'fileViewer.vercelTokenReuseHint':
'Saved token will be used. Enter a new token to replace it.',
'fileViewer.vercelTokenReuseHint': 'Saved token will be used. Enter a new token to replace it.',
'fileViewer.vercelTokenRequired': 'Enter and save a Vercel token first.',
'fileViewer.cloudflareApiToken': 'Cloudflare API token',
'fileViewer.cloudflareApiTokenGetLink': 'Get Cloudflare API token',
'fileViewer.cloudflareApiTokenPlaceholder': 'Paste your Cloudflare API token',
'fileViewer.cloudflareApiTokenReuseHint': 'The saved Cloudflare API token will be used. Enter a new token to replace it.',
'fileViewer.cloudflareApiTokenRequired': 'Enter and save a Cloudflare API token first.',
'fileViewer.cloudflareApiTokenScopeHint': 'Token needs Account: Cloudflare Pages: Edit plus account read access.',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': 'Required. Find the account ID in the Cloudflare dashboard.',
'fileViewer.cloudflareAccountIdRequired': 'Enter and save a Cloudflare Account ID first.',
'fileViewer.optional': 'Optional',
'fileViewer.vercelPreviewOnly': 'Deploys are Preview-only for now.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages deploys use Direct Upload.',
'fileViewer.savingConfig': 'Saving…',
'fileViewer.deployConfigSaveFailed': 'Could not save Vercel settings.',
'fileViewer.deployFailed': 'Deploy failed. Check Vercel settings and try again.',
'fileViewer.deployProviderConfigSaveFailed': 'Could not save {provider} settings.',
'fileViewer.deployProviderFailed': '{provider} deploy failed. Check settings and try again.',
'fileViewer.deployResultLabel': 'Deployed URL',
'fileViewer.deployLinkPreparingLabel': 'Public link pending',
'fileViewer.deployLinkDelayed':
'Your site is deployed. Vercel is still preparing the public link.',
'fileViewer.deployLinkProtectedLabel': 'Vercel protection enabled',
'fileViewer.deployLinkProtected':
'Your site deployed, but Vercel is requiring authentication for this preview link. Disable Deployment Protection or use a custom domain.',
'fileViewer.deployLinkDelayed': 'Your site is deployed. The public link is still being prepared.',
'fileViewer.deployLinkProtectedLabel': 'Deployment protection enabled',
'fileViewer.deployLinkProtected': 'Your site deployed, but this preview link is requiring authentication. Disable Deployment Protection or use a custom domain.',
'fileViewer.retryLink': 'Retry now',
'questionForm.submit': 'Submit',

View file

@ -650,31 +650,45 @@ export const esES: Dict = {
'fileViewer.deployToVercel': 'Desplegar en Vercel',
'fileViewer.redeployToVercel': 'Volver a desplegar',
'fileViewer.deployingToVercel': 'Desplegando en Vercel…',
'fileViewer.deployProviderLabel': 'Proveedor',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'Desplegar en {provider}',
'fileViewer.redeployToProvider': 'Volver a desplegar en {provider}',
'fileViewer.deployingToProvider': 'Desplegando en {provider}…',
'fileViewer.preparingPublicLink': 'Preparando enlace público…',
'fileViewer.copyDeployLink': 'Copiar enlace',
'fileViewer.deployModalTitle': 'Desplegar en Vercel',
'fileViewer.deployModalSubtitle':
'Despliega este artefacto HTML como Preview de Vercel usando tu propia cuenta.',
'fileViewer.deployModalTitle': 'Desplegar',
'fileViewer.deployModalSubtitle': 'Usa la cuenta del proveedor seleccionado para desplegar esta vista previa HTML.',
'fileViewer.vercelToken': 'Token de Vercel',
'fileViewer.vercelTokenGetLink': 'Obtener token de Vercel',
'fileViewer.vercelTokenPlaceholder': 'Pega tu token de Vercel',
'fileViewer.vercelTokenReuseHint':
'Se usará el token guardado. Introduce uno nuevo para reemplazarlo.',
'fileViewer.vercelTokenReuseHint': 'Se usará el token guardado. Introduce uno nuevo para sustituirlo.',
'fileViewer.vercelTokenRequired': 'Introduce y guarda primero un token de Vercel.',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareApiToken': 'Token de API de Cloudflare',
'fileViewer.cloudflareApiTokenGetLink': 'Obtener token de API de Cloudflare',
'fileViewer.cloudflareApiTokenPlaceholder': 'Pega tu token de API de Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'Se usará el token de API de Cloudflare guardado. Introduce uno nuevo para sustituirlo.',
'fileViewer.cloudflareApiTokenRequired': 'Introduce y guarda primero un token de API de Cloudflare.',
'fileViewer.cloudflareApiTokenScopeHint': 'El token necesita Account: Cloudflare Pages: Edit y acceso de lectura a la cuenta.',
'fileViewer.vercelTeamId': 'ID del equipo',
'fileViewer.vercelTeamSlug': 'Slug del equipo',
'fileViewer.cloudflareAccountId': 'ID de cuenta',
'fileViewer.cloudflareAccountIdHint': 'Obligatorio. Encuentra el ID de cuenta en el panel de Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Introduce y guarda primero un Cloudflare Account ID.',
'fileViewer.optional': 'Opcional',
'fileViewer.vercelPreviewOnly': 'Por ahora los despliegues son solo Preview.',
'fileViewer.vercelPreviewOnly': 'Los despliegues son solo Preview por ahora.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages usa Direct Upload.',
'fileViewer.savingConfig': 'Guardando…',
'fileViewer.deployConfigSaveFailed': 'No se pudieron guardar los ajustes de Vercel.',
'fileViewer.deployFailed': 'El despliegue falló. Revisa los ajustes de Vercel e inténtalo de nuevo.',
'fileViewer.deployConfigSaveFailed': 'No se pudo guardar la configuración de Vercel.',
'fileViewer.deployFailed': 'El despliegue falló. Revisa la configuración de Vercel e inténtalo de nuevo.',
'fileViewer.deployProviderConfigSaveFailed': 'No se pudo guardar la configuración de {provider}.',
'fileViewer.deployProviderFailed': 'El despliegue en {provider} falló. Revisa la configuración e inténtalo de nuevo.',
'fileViewer.deployResultLabel': 'URL desplegada',
'fileViewer.deployLinkPreparingLabel': 'Enlace público pendiente',
'fileViewer.deployLinkDelayed':
'Tu sitio está desplegado. Vercel todavía está preparando el enlace público.',
'fileViewer.deployLinkProtectedLabel': 'Protección de Vercel activada',
'fileViewer.deployLinkProtected':
'Tu sitio se desplegó, pero Vercel exige autenticación para este enlace de preview. Desactiva Deployment Protection o usa un dominio personalizado.',
'fileViewer.deployLinkDelayed': 'El sitio se ha desplegado. El proveedor aún está preparando el enlace público.',
'fileViewer.deployLinkProtectedLabel': 'Protección del despliegue activada',
'fileViewer.deployLinkProtected': 'El sitio se ha desplegado, pero este enlace de vista previa requiere autenticación. Desactiva Deployment Protection o usa un dominio personalizado.',
'fileViewer.retryLink': 'Reintentar ahora',
'questionForm.submit': 'Enviar',

View file

@ -814,35 +814,49 @@ export const fa: Dict = {
'qf.cardRefs': 'مراجع:',
'qf.cardSampleText': 'روباه قهوه‌ای سریع · ۰۱۲۳',
'fileViewer.deployToVercel': 'Deploy to Vercel',
'fileViewer.redeployToVercel': 'Redeploy',
'fileViewer.deployingToVercel': 'Deploying to Vercel…',
'fileViewer.preparingPublicLink': 'Preparing public link…',
'fileViewer.copyDeployLink': 'Copy link',
'fileViewer.deployModalTitle': 'Deploy to Vercel',
'fileViewer.deployModalSubtitle':
'Deploy this HTML artifact as a Vercel Preview using your own account.',
'fileViewer.vercelToken': 'Vercel token',
'fileViewer.vercelTokenGetLink': 'Get Vercel token',
'fileViewer.vercelTokenPlaceholder': 'Paste your Vercel token',
'fileViewer.vercelTokenReuseHint':
'Saved token will be used. Enter a new token to replace it.',
'fileViewer.vercelTokenRequired': 'Enter and save a Vercel token first.',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.optional': 'Optional',
'fileViewer.vercelPreviewOnly': 'Deploys are Preview-only for now.',
'fileViewer.savingConfig': 'Saving…',
'fileViewer.deployConfigSaveFailed': 'Could not save Vercel settings.',
'fileViewer.deployFailed': 'Deploy failed. Check Vercel settings and try again.',
'fileViewer.deployResultLabel': 'Deployed URL',
'fileViewer.deployLinkPreparingLabel': 'Public link pending',
'fileViewer.deployLinkDelayed':
'Your site is deployed. Vercel is still preparing the public link.',
'fileViewer.deployLinkProtectedLabel': 'Vercel protection enabled',
'fileViewer.deployLinkProtected':
'Your site deployed, but Vercel is requiring authentication for this preview link. Disable Deployment Protection or use a custom domain.',
'fileViewer.retryLink': 'Retry now',
'fileViewer.deployToVercel': 'استقرار روی Vercel',
'fileViewer.redeployToVercel': 'استقرار دوباره',
'fileViewer.deployingToVercel': 'در حال استقرار روی Vercel…',
'fileViewer.deployProviderLabel': 'ارائه‌دهنده استقرار',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'استقرار روی {provider}',
'fileViewer.redeployToProvider': 'استقرار دوباره روی {provider}',
'fileViewer.deployingToProvider': 'در حال استقرار روی {provider}…',
'fileViewer.preparingPublicLink': 'در حال آماده‌سازی لینک عمومی…',
'fileViewer.copyDeployLink': 'کپی لینک',
'fileViewer.deployModalTitle': 'استقرار',
'fileViewer.deployModalSubtitle': 'از حساب ارائه‌دهنده انتخاب‌شده برای استقرار این پیش‌نمایش HTML استفاده کنید.',
'fileViewer.vercelToken': 'توکن Vercel',
'fileViewer.vercelTokenGetLink': 'دریافت توکن Vercel',
'fileViewer.vercelTokenPlaceholder': 'توکن Vercel خود را وارد کنید',
'fileViewer.vercelTokenReuseHint': 'از توکن ذخیره‌شده استفاده می‌شود. برای جایگزینی، توکن جدید وارد کنید.',
'fileViewer.vercelTokenRequired': 'ابتدا یک توکن Vercel وارد و ذخیره کنید.',
'fileViewer.cloudflareApiToken': 'توکن API کلادفلر',
'fileViewer.cloudflareApiTokenGetLink': 'دریافت توکن API کلادفلر',
'fileViewer.cloudflareApiTokenPlaceholder': 'توکن API کلادفلر خود را وارد کنید',
'fileViewer.cloudflareApiTokenReuseHint': 'از توکن API کلادفلر ذخیره‌شده استفاده می‌شود. برای جایگزینی، توکن جدید وارد کنید.',
'fileViewer.cloudflareApiTokenRequired': 'ابتدا یک توکن API کلادفلر وارد و ذخیره کنید.',
'fileViewer.cloudflareApiTokenScopeHint': 'توکن به مجوز Account: Cloudflare Pages: Edit و دسترسی خواندن حساب نیاز دارد.',
'fileViewer.vercelTeamId': 'شناسه تیم',
'fileViewer.vercelTeamSlug': 'اسلاگ تیم',
'fileViewer.cloudflareAccountId': 'شناسه حساب',
'fileViewer.cloudflareAccountIdHint': 'ضروری است. شناسه حساب را در داشبورد Cloudflare پیدا کنید.',
'fileViewer.cloudflareAccountIdRequired': 'ابتدا Cloudflare Account ID را وارد و ذخیره کنید.',
'fileViewer.optional': 'اختیاری',
'fileViewer.vercelPreviewOnly': 'استقرارها فعلاً فقط Preview هستند.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages از Direct Upload استفاده می‌کند.',
'fileViewer.savingConfig': 'در حال ذخیره…',
'fileViewer.deployConfigSaveFailed': 'ذخیره تنظیمات Vercel ممکن نبود.',
'fileViewer.deployFailed': 'استقرار ناموفق بود. تنظیمات Vercel را بررسی و دوباره تلاش کنید.',
'fileViewer.deployProviderConfigSaveFailed': 'ذخیره تنظیمات {provider} ممکن نبود.',
'fileViewer.deployProviderFailed': 'استقرار روی {provider} ناموفق بود. تنظیمات را بررسی و دوباره تلاش کنید.',
'fileViewer.deployResultLabel': 'URL مستقرشده',
'fileViewer.deployLinkPreparingLabel': 'لینک عمومی در انتظار است',
'fileViewer.deployLinkDelayed': 'سایت مستقر شده است. ارائه‌دهنده هنوز لینک عمومی را آماده می‌کند.',
'fileViewer.deployLinkProtectedLabel': 'محافظت استقرار فعال است',
'fileViewer.deployLinkProtected': 'سایت مستقر شده، اما این لینک پیش‌نمایش نیاز به احراز هویت دارد. Deployment Protection را غیرفعال کنید یا از دامنه سفارشی استفاده کنید.',
'fileViewer.retryLink': 'همین حالا دوباره تلاش کنید',
'sketch.toolSelect': 'انتخاب (غیرفعال)',
'sketch.toolPen': 'قلم',

View file

@ -695,31 +695,45 @@ export const fr: Dict = {
'fileViewer.deployToVercel': 'Déployer sur Vercel',
'fileViewer.redeployToVercel': 'Redéployer',
'fileViewer.deployingToVercel': 'Déploiement sur Vercel…',
'fileViewer.deployProviderLabel': 'Fournisseur',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'Déployer sur {provider}',
'fileViewer.redeployToProvider': 'Redéployer sur {provider}',
'fileViewer.deployingToProvider': 'Déploiement sur {provider}…',
'fileViewer.preparingPublicLink': 'Préparation du lien public…',
'fileViewer.copyDeployLink': 'Copier le lien',
'fileViewer.deployModalTitle': 'Déployer sur Vercel',
'fileViewer.deployModalSubtitle':
'Déployez cet artefact HTML en tant que Vercel Preview avec votre propre compte.',
'fileViewer.deployModalTitle': 'Déployer',
'fileViewer.deployModalSubtitle': 'Utilisez le compte du fournisseur sélectionné pour déployer cet aperçu HTML.',
'fileViewer.vercelToken': 'Jeton Vercel',
'fileViewer.vercelTokenGetLink': 'Obtenir un jeton Vercel',
'fileViewer.vercelTokenPlaceholder': 'Collez votre jeton Vercel',
'fileViewer.vercelTokenReuseHint':
'Le jeton enregistré sera utilisé. Entrez un nouveau jeton pour le remplacer.',
'fileViewer.vercelTokenRequired': 'Entrez et enregistrez d\'abord un jeton Vercel.',
'fileViewer.vercelTeamId': 'ID d\'équipe',
'fileViewer.vercelTeamSlug': 'Slug d\'équipe',
'fileViewer.optional': 'Optionnel',
'fileViewer.vercelPreviewOnly': 'Les déploiements sont en Preview uniquement pour l\'instant.',
'fileViewer.vercelTokenReuseHint': 'Le jeton enregistré sera utilisé. Saisissez un nouveau jeton pour le remplacer.',
'fileViewer.vercelTokenRequired': 'Saisissez et enregistrez dabord un jeton Vercel.',
'fileViewer.cloudflareApiToken': 'Jeton API Cloudflare',
'fileViewer.cloudflareApiTokenGetLink': 'Obtenir un jeton API Cloudflare',
'fileViewer.cloudflareApiTokenPlaceholder': 'Collez votre jeton API Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'Le jeton API Cloudflare enregistré sera utilisé. Saisissez un nouveau jeton pour le remplacer.',
'fileViewer.cloudflareApiTokenRequired': 'Saisissez et enregistrez dabord un jeton API Cloudflare.',
'fileViewer.cloudflareApiTokenScopeHint': 'Le jeton doit avoir Account: Cloudflare Pages: Edit ainsi quun accès en lecture au compte.',
'fileViewer.vercelTeamId': 'ID déquipe',
'fileViewer.vercelTeamSlug': 'Slug déquipe',
'fileViewer.cloudflareAccountId': 'ID de compte',
'fileViewer.cloudflareAccountIdHint': 'Obligatoire. Trouvez lID du compte dans le tableau de bord Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Saisissez et enregistrez dabord un Cloudflare Account ID.',
'fileViewer.optional': 'Facultatif',
'fileViewer.vercelPreviewOnly': 'Les déploiements sont en mode Preview pour le moment.',
'fileViewer.cloudflarePagesPreviewHint': 'Les déploiements Cloudflare Pages utilisent Direct Upload.',
'fileViewer.savingConfig': 'Enregistrement…',
'fileViewer.deployConfigSaveFailed': 'Impossible d\'enregistrer les paramètres Vercel.',
'fileViewer.deployFailed': 'Le déploiement a échoué. Vérifiez les paramètres Vercel et réessayez.',
'fileViewer.deployConfigSaveFailed': 'Impossible denregistrer les réglages Vercel.',
'fileViewer.deployFailed': 'Échec du déploiement. Vérifiez les réglages Vercel et réessayez.',
'fileViewer.deployProviderConfigSaveFailed': 'Impossible denregistrer les réglages {provider}.',
'fileViewer.deployProviderFailed': 'Échec du déploiement {provider}. Vérifiez les réglages et réessayez.',
'fileViewer.deployResultLabel': 'URL déployée',
'fileViewer.deployLinkPreparingLabel': 'Lien public en attente',
'fileViewer.deployLinkDelayed':
'Votre site est déployé. Vercel prépare encore le lien public.',
'fileViewer.deployLinkProtectedLabel': 'Protection Vercel activée',
'fileViewer.deployLinkProtected':
'Votre site est déployé, mais Vercel exige une authentification pour ce lien de prévisualisation. Désactivez la protection de déploiement ou utilisez un domaine personnalisé.',
'fileViewer.deployLinkDelayed': 'Le site est déployé. Le fournisseur prépare encore le lien public.',
'fileViewer.deployLinkProtectedLabel': 'Protection du déploiement activée',
'fileViewer.deployLinkProtected': 'Le site est déployé, mais ce lien de prévisualisation exige une authentification. Désactivez Deployment Protection ou utilisez un domaine personnalisé.',
'fileViewer.retryLink': 'Réessayer maintenant',
'questionForm.submit': 'Envoyer',

View file

@ -695,31 +695,45 @@ export const hu: Dict = {
'fileViewer.deployToVercel': 'Telepítés Vercelre',
'fileViewer.redeployToVercel': 'Újratelepítés',
'fileViewer.deployingToVercel': 'Telepítés Vercelre…',
'fileViewer.deployProviderLabel': 'Szolgáltató',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'Telepítés ide: {provider}',
'fileViewer.redeployToProvider': 'Újratelepítés ide: {provider}',
'fileViewer.deployingToProvider': 'Telepítés ide: {provider}…',
'fileViewer.preparingPublicLink': 'Nyilvános link előkészítése…',
'fileViewer.copyDeployLink': 'Link másolása',
'fileViewer.deployModalTitle': 'Telepítés Vercelre',
'fileViewer.deployModalSubtitle':
'Telepítsd ezt a HTML-artefaktumot Vercel Preview-ként a saját fiókodból.',
'fileViewer.deployModalTitle': 'Telepítés',
'fileViewer.deployModalSubtitle': 'A kiválasztott szolgáltató fiókjával telepítheted ezt a HTML-előnézetet.',
'fileViewer.vercelToken': 'Vercel token',
'fileViewer.vercelTokenGetLink': 'Vercel token kérése',
'fileViewer.vercelTokenPlaceholder': 'Illeszd be a Vercel tokenedet',
'fileViewer.vercelTokenReuseHint':
'A mentett tokent használjuk. Adj meg újat a cseréhez.',
'fileViewer.vercelTokenReuseHint': 'A mentett tokent használjuk. Adj meg újat a cseréhez.',
'fileViewer.vercelTokenRequired': 'Előbb adj meg és ments el egy Vercel tokent.',
'fileViewer.cloudflareApiToken': 'Cloudflare API token',
'fileViewer.cloudflareApiTokenGetLink': 'Cloudflare API token kérése',
'fileViewer.cloudflareApiTokenPlaceholder': 'Illeszd be a Cloudflare API tokenedet',
'fileViewer.cloudflareApiTokenReuseHint': 'A mentett Cloudflare API tokent használjuk. Adj meg újat a cseréhez.',
'fileViewer.cloudflareApiTokenRequired': 'Előbb adj meg és ments el egy Cloudflare API tokent.',
'fileViewer.cloudflareApiTokenScopeHint': 'A tokenhez Account: Cloudflare Pages: Edit és fiók-olvasási hozzáférés szükséges.',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareAccountId': 'Fiók ID',
'fileViewer.cloudflareAccountIdHint': 'Kötelező. A fiók ID-t a Cloudflare irányítópulton találod.',
'fileViewer.cloudflareAccountIdRequired': 'Előbb add meg és mentsd el a Cloudflare Account ID-t.',
'fileViewer.optional': 'Opcionális',
'fileViewer.vercelPreviewOnly': 'A telepítések egyelőre csak Preview-k.',
'fileViewer.cloudflarePagesPreviewHint': 'A Cloudflare Pages Direct Uploadot használ.',
'fileViewer.savingConfig': 'Mentés…',
'fileViewer.deployConfigSaveFailed': 'A Vercel beállítások nem menthetők.',
'fileViewer.deployFailed': 'A telepítés sikertelen. Ellenőrizd a Vercel beállításokat, és próbáld újra.',
'fileViewer.deployProviderConfigSaveFailed': 'A {provider} beállításai nem menthetők.',
'fileViewer.deployProviderFailed': 'A {provider} telepítés sikertelen. Ellenőrizd a beállításokat, és próbáld újra.',
'fileViewer.deployResultLabel': 'Telepített URL',
'fileViewer.deployLinkPreparingLabel': 'Nyilvános link várólistán',
'fileViewer.deployLinkDelayed':
'Az oldal telepítve. A Vercel még készíti a nyilvános linket.',
'fileViewer.deployLinkProtectedLabel': 'Vercel-védelem aktív',
'fileViewer.deployLinkProtected':
'Az oldal telepítve, de a Vercel hitelesítést kér ehhez az előnézeti linkhez. Kapcsold ki a Deployment Protection-t, vagy használj egyedi domaint.',
'fileViewer.deployLinkDelayed': 'A webhely telepítve van. A szolgáltató még készíti a nyilvános linket.',
'fileViewer.deployLinkProtectedLabel': 'Telepítési védelem bekapcsolva',
'fileViewer.deployLinkProtected': 'A webhely telepítve van, de ez az előnézeti link hitelesítést kér. Kapcsold ki a Deployment Protectiont, vagy használj saját domaint.',
'fileViewer.retryLink': 'Újra most',
'questionForm.submit': 'Beküldés',

View file

@ -648,31 +648,45 @@ export const ja: Dict = {
'fileViewer.deployToVercel': 'Vercel にデプロイ',
'fileViewer.redeployToVercel': '再デプロイ',
'fileViewer.deployingToVercel': 'Vercel にデプロイ中…',
'fileViewer.deployProviderLabel': 'プロバイダー',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': '{provider} にデプロイ',
'fileViewer.redeployToProvider': '{provider} に再デプロイ',
'fileViewer.deployingToProvider': '{provider} にデプロイ中…',
'fileViewer.preparingPublicLink': '公開リンクを準備中…',
'fileViewer.copyDeployLink': 'リンクをコピー',
'fileViewer.deployModalTitle': 'Vercel にデプロイ',
'fileViewer.deployModalSubtitle':
'この HTML アーティファクトをご自身のアカウントを使用して Vercel Preview としてデプロイします。',
'fileViewer.deployModalTitle': 'デプロイ',
'fileViewer.deployModalSubtitle': '選択したプロバイダーのアカウントを使用して、この HTML プレビューをデプロイします。',
'fileViewer.vercelToken': 'Vercel トークン',
'fileViewer.vercelTokenGetLink': 'Vercel トークンを取得',
'fileViewer.vercelTokenPlaceholder': 'Vercel トークンを貼り付け',
'fileViewer.vercelTokenReuseHint':
'保存済みトークンが使用されます。新しいトークンを入力すると置き換えられます。',
'fileViewer.vercelTokenReuseHint': '保存済みトークンが使用されます。新しいトークンを入力すると置き換えられます。',
'fileViewer.vercelTokenRequired': '最初に Vercel トークンを入力して保存してください。',
'fileViewer.cloudflareApiToken': 'Cloudflare API トークン',
'fileViewer.cloudflareApiTokenGetLink': 'Cloudflare API トークンを取得',
'fileViewer.cloudflareApiTokenPlaceholder': 'Cloudflare API トークンを貼り付け',
'fileViewer.cloudflareApiTokenReuseHint': '保存済みの Cloudflare API トークンが使用されます。新しいトークンを入力すると置き換えられます。',
'fileViewer.cloudflareApiTokenRequired': '最初に Cloudflare API トークンを入力して保存してください。',
'fileViewer.cloudflareApiTokenScopeHint': 'トークンには Account: Cloudflare Pages: Edit とアカウント読み取り権限が必要です。',
'fileViewer.vercelTeamId': 'チーム ID',
'fileViewer.vercelTeamSlug': 'チームスラッグ',
'fileViewer.cloudflareAccountId': 'アカウント ID',
'fileViewer.cloudflareAccountIdHint': '必須です。Cloudflare ダッシュボードでアカウント ID を確認できます。',
'fileViewer.cloudflareAccountIdRequired': '最初に Cloudflare Account ID を入力して保存してください。',
'fileViewer.optional': '省略可',
'fileViewer.vercelPreviewOnly': 'デプロイは現在 Preview のみです。',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages は Direct Upload を使用します。',
'fileViewer.savingConfig': '保存中…',
'fileViewer.deployConfigSaveFailed': 'Vercel の設定を保存できませんでした。',
'fileViewer.deployFailed': 'デプロイに失敗しました。Vercel の設定を確認して再試行してください。',
'fileViewer.deployProviderConfigSaveFailed': '{provider} の設定を保存できませんでした。',
'fileViewer.deployProviderFailed': '{provider} のデプロイに失敗しました。設定を確認して再試行してください。',
'fileViewer.deployResultLabel': 'デプロイ URL',
'fileViewer.deployLinkPreparingLabel': '公開リンク準備中',
'fileViewer.deployLinkDelayed':
'サイトはデプロイされました。Vercel が公開リンクを準備中です。',
'fileViewer.deployLinkProtectedLabel': 'Vercel の保護が有効',
'fileViewer.deployLinkProtected':
'サイトはデプロイされましたが、Vercel がこのプレビューリンクに認証を要求しています。デプロイ保護を無効にするかカスタムドメインを使用してください。',
'fileViewer.deployLinkDelayed': 'サイトはデプロイされました。プロバイダーが公開リンクを準備中です。',
'fileViewer.deployLinkProtectedLabel': 'デプロイ保護が有効',
'fileViewer.deployLinkProtected': 'サイトはデプロイされましたが、このプレビューリンクには認証が必要です。Deployment Protection を無効にするか、カスタムドメインを使用してください。',
'fileViewer.retryLink': '今すぐ再試行',
'questionForm.submit': '送信',

View file

@ -695,31 +695,45 @@ export const ko: Dict = {
'fileViewer.deployToVercel': 'Vercel에 배포',
'fileViewer.redeployToVercel': '다시 배포',
'fileViewer.deployingToVercel': 'Vercel에 배포 중…',
'fileViewer.deployProviderLabel': '배포 플랫폼',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': '{provider}에 배포',
'fileViewer.redeployToProvider': '{provider}에 다시 배포',
'fileViewer.deployingToProvider': '{provider}에 배포 중…',
'fileViewer.preparingPublicLink': '공개 링크 준비 중…',
'fileViewer.copyDeployLink': '링크 복사',
'fileViewer.deployModalTitle': 'Vercel에 배포',
'fileViewer.deployModalSubtitle':
'사용자 개인 계정을 사용하여 이 HTML 결과물을 Vercel Preview로 배포합니다.',
'fileViewer.deployModalTitle': '배포',
'fileViewer.deployModalSubtitle': '선택한 플랫폼 계정으로 이 HTML 미리보기를 배포합니다.',
'fileViewer.vercelToken': 'Vercel 토큰',
'fileViewer.vercelTokenGetLink': 'Vercel 토큰 발급 받기',
'fileViewer.vercelTokenPlaceholder': 'Vercel 토큰을 붙여넣으세요',
'fileViewer.vercelTokenReuseHint':
'저장된 토큰이 사용됩니다. 변경하려면 새 토큰을 입력하세요.',
'fileViewer.vercelTokenReuseHint': '저장된 토큰이 사용됩니다. 변경하려면 새 토큰을 입력하세요.',
'fileViewer.vercelTokenRequired': '먼저 Vercel 토큰을 입력하고 저장하세요.',
'fileViewer.cloudflareApiToken': 'Cloudflare API 토큰',
'fileViewer.cloudflareApiTokenGetLink': 'Cloudflare API 토큰 받기',
'fileViewer.cloudflareApiTokenPlaceholder': 'Cloudflare API 토큰을 붙여넣으세요',
'fileViewer.cloudflareApiTokenReuseHint': '저장된 Cloudflare API 토큰이 사용됩니다. 변경하려면 새 토큰을 입력하세요.',
'fileViewer.cloudflareApiTokenRequired': '먼저 Cloudflare API 토큰을 입력하고 저장하세요.',
'fileViewer.cloudflareApiTokenScopeHint': '토큰에는 Account: Cloudflare Pages: Edit 권한과 계정 읽기 권한이 필요합니다.',
'fileViewer.vercelTeamId': '팀 ID (Team ID)',
'fileViewer.vercelTeamSlug': '팀 슬러그 (Team slug)',
'fileViewer.cloudflareAccountId': '계정 ID',
'fileViewer.cloudflareAccountIdHint': '필수입니다. Cloudflare 대시보드에서 계정 ID를 확인하세요.',
'fileViewer.cloudflareAccountIdRequired': '먼저 Cloudflare Account ID를 입력하고 저장하세요.',
'fileViewer.optional': '선택 사항',
'fileViewer.vercelPreviewOnly': '현재 배포는 Preview 모드만 지원합니다.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages 배포는 Direct Upload를 사용합니다.',
'fileViewer.savingConfig': '설정 저장 중…',
'fileViewer.deployConfigSaveFailed': 'Vercel 설정을 저장하지 못했습니다.',
'fileViewer.deployFailed': '배포 실패. Vercel 설정을 확인하고 다시 시도해 주세요.',
'fileViewer.deployProviderConfigSaveFailed': '{provider} 설정을 저장하지 못했습니다.',
'fileViewer.deployProviderFailed': '{provider} 배포에 실패했습니다. 설정을 확인하고 다시 시도해 주세요.',
'fileViewer.deployResultLabel': '배포된 URL',
'fileViewer.deployLinkPreparingLabel': '공개 링크 보류 중',
'fileViewer.deployLinkDelayed':
'사이트 배포는 완료되었으나, Vercel 측에서 공개 링크를 준비 중입니다.',
'fileViewer.deployLinkProtectedLabel': 'Vercel 보호 설정 됨',
'fileViewer.deployLinkProtected':
'배포는 완료되었지만, Vercel 계정 설정에 의해 이 링크가 보호되어 있습니다. Vercel Deployment Protection을 비활성화하거나 커스텀 도메인을 사용하세요.',
'fileViewer.deployLinkDelayed': '사이트가 배포되었습니다. 플랫폼에서 공개 링크를 아직 준비 중입니다.',
'fileViewer.deployLinkProtectedLabel': '배포 보호가 활성화됨',
'fileViewer.deployLinkProtected': '사이트가 배포되었지만 이 미리보기 링크에는 인증이 필요합니다. Deployment Protection을 끄거나 사용자 지정 도메인을 사용하세요.',
'fileViewer.retryLink': '지금 다시 시도',
'questionForm.submit': '제출',

View file

@ -695,31 +695,45 @@ export const pl: Dict = {
'fileViewer.deployToVercel': 'Wdróż na Vercel',
'fileViewer.redeployToVercel': 'Wdróż ponownie',
'fileViewer.deployingToVercel': 'Wdrażanie na Vercel…',
'fileViewer.deployProviderLabel': 'Dostawca',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'Wdróż na {provider}',
'fileViewer.redeployToProvider': 'Wdróż ponownie na {provider}',
'fileViewer.deployingToProvider': 'Wdrażanie na {provider}…',
'fileViewer.preparingPublicLink': 'Przygotowywanie publicznego linku…',
'fileViewer.copyDeployLink': 'Kopiuj link',
'fileViewer.deployModalTitle': 'Wdróż na Vercel',
'fileViewer.deployModalSubtitle':
'Wdróż ten artefakt HTML jako podgląd Vercel (Preview) przy użyciu własnego konta.',
'fileViewer.deployModalTitle': 'Wdróż',
'fileViewer.deployModalSubtitle': 'Użyj konta wybranego dostawcy, aby wdrożyć ten podgląd HTML.',
'fileViewer.vercelToken': 'Token Vercel',
'fileViewer.vercelTokenGetLink': 'Pobierz token Vercel',
'fileViewer.vercelTokenPlaceholder': 'Wklej swój token Vercel',
'fileViewer.vercelTokenReuseHint':
'Zapisany token zostanie użyty. Wprowadź nowy, aby go zastąpić.',
'fileViewer.vercelTokenReuseHint': 'Zapisany token zostanie użyty. Wprowadź nowy, aby go zastąpić.',
'fileViewer.vercelTokenRequired': 'Najpierw wprowadź i zapisz token Vercel.',
'fileViewer.cloudflareApiToken': 'Token API Cloudflare',
'fileViewer.cloudflareApiTokenGetLink': 'Pobierz token API Cloudflare',
'fileViewer.cloudflareApiTokenPlaceholder': 'Wklej swój token API Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'Zapisany token API Cloudflare zostanie użyty. Wprowadź nowy, aby go zastąpić.',
'fileViewer.cloudflareApiTokenRequired': 'Najpierw wprowadź i zapisz token API Cloudflare.',
'fileViewer.cloudflareApiTokenScopeHint': 'Token wymaga uprawnienia Account: Cloudflare Pages: Edit oraz dostępu do odczytu konta.',
'fileViewer.vercelTeamId': 'ID zespołu',
'fileViewer.vercelTeamSlug': 'Slug zespołu',
'fileViewer.cloudflareAccountId': 'ID konta',
'fileViewer.cloudflareAccountIdHint': 'Wymagane. ID konta znajdziesz w panelu Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Najpierw wprowadź i zapisz Cloudflare Account ID.',
'fileViewer.optional': 'Opcjonalnie',
'fileViewer.vercelPreviewOnly': 'Wdrożenia są obecnie dostępne tylko jako Podgląd (Preview).',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages używa Direct Upload.',
'fileViewer.savingConfig': 'Zapisywanie…',
'fileViewer.deployConfigSaveFailed': 'Nie udało się zapisać ustawień Vercel.',
'fileViewer.deployFailed': 'Wdrożenie nie powiodło się. Sprawdź ustawienia Vercel i spróbuj ponownie.',
'fileViewer.deployProviderConfigSaveFailed': 'Nie udało się zapisać ustawień {provider}.',
'fileViewer.deployProviderFailed': 'Wdrożenie na {provider} nie powiodło się. Sprawdź ustawienia i spróbuj ponownie.',
'fileViewer.deployResultLabel': 'Wdrożony URL',
'fileViewer.deployLinkPreparingLabel': 'Oczekiwanie na link publiczny',
'fileViewer.deployLinkDelayed':
'Strona została wdrożona. Vercel wciąż przygotowuje link publiczny.',
'fileViewer.deployLinkProtectedLabel': 'Ochrona Vercel włączona',
'fileViewer.deployLinkProtected':
'Strona została wdrożona, ale Vercel wymaga uwierzytelnienia dla tego linku podglądu. Wyłącz Deployment Protection lub użyj własnej domeny.',
'fileViewer.deployLinkDelayed': 'Strona została wdrożona. Dostawca wciąż przygotowuje publiczny link.',
'fileViewer.deployLinkProtectedLabel': 'Ochrona wdrożenia włączona',
'fileViewer.deployLinkProtected': 'Strona została wdrożona, ale ten link podglądu wymaga uwierzytelnienia. Wyłącz Deployment Protection albo użyj własnej domeny.',
'fileViewer.retryLink': 'Ponów teraz',
'questionForm.submit': 'Wyślij',

View file

@ -722,35 +722,49 @@ export const ptBR: Dict = {
'liveArtifact.refresh.statusReady': 'Pronto para atualizar',
'liveArtifact.refresh.statusSucceeded': 'Atualizado',
'liveArtifact.refresh.statusFailed': 'Atualização falhou',
'fileViewer.deployToVercel': 'Deploy to Vercel',
'fileViewer.redeployToVercel': 'Redeploy',
'fileViewer.deployingToVercel': 'Deploying to Vercel…',
'fileViewer.preparingPublicLink': 'Preparing public link…',
'fileViewer.copyDeployLink': 'Copy link',
'fileViewer.deployModalTitle': 'Deploy to Vercel',
'fileViewer.deployModalSubtitle':
'Deploy this HTML artifact as a Vercel Preview using your own account.',
'fileViewer.vercelToken': 'Vercel token',
'fileViewer.vercelTokenGetLink': 'Get Vercel token',
'fileViewer.vercelTokenPlaceholder': 'Paste your Vercel token',
'fileViewer.vercelTokenReuseHint':
'Saved token will be used. Enter a new token to replace it.',
'fileViewer.vercelTokenRequired': 'Enter and save a Vercel token first.',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.optional': 'Optional',
'fileViewer.vercelPreviewOnly': 'Deploys are Preview-only for now.',
'fileViewer.savingConfig': 'Saving…',
'fileViewer.deployConfigSaveFailed': 'Could not save Vercel settings.',
'fileViewer.deployFailed': 'Deploy failed. Check Vercel settings and try again.',
'fileViewer.deployResultLabel': 'Deployed URL',
'fileViewer.deployLinkPreparingLabel': 'Public link pending',
'fileViewer.deployLinkDelayed':
'Your site is deployed. Vercel is still preparing the public link.',
'fileViewer.deployLinkProtectedLabel': 'Vercel protection enabled',
'fileViewer.deployLinkProtected':
'Your site deployed, but Vercel is requiring authentication for this preview link. Disable Deployment Protection or use a custom domain.',
'fileViewer.retryLink': 'Retry now',
'fileViewer.deployToVercel': 'Implantar na Vercel',
'fileViewer.redeployToVercel': 'Implantar novamente',
'fileViewer.deployingToVercel': 'Implantando na Vercel…',
'fileViewer.deployProviderLabel': 'Provedor',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'Implantar em {provider}',
'fileViewer.redeployToProvider': 'Implantar novamente em {provider}',
'fileViewer.deployingToProvider': 'Implantando em {provider}…',
'fileViewer.preparingPublicLink': 'Preparando link público…',
'fileViewer.copyDeployLink': 'Copiar link',
'fileViewer.deployModalTitle': 'Implantar',
'fileViewer.deployModalSubtitle': 'Use a conta do provedor selecionado para implantar esta prévia HTML.',
'fileViewer.vercelToken': 'Token da Vercel',
'fileViewer.vercelTokenGetLink': 'Obter token da Vercel',
'fileViewer.vercelTokenPlaceholder': 'Cole seu token da Vercel',
'fileViewer.vercelTokenReuseHint': 'O token salvo será usado. Insira um novo token para substituí-lo.',
'fileViewer.vercelTokenRequired': 'Insira e salve primeiro um token da Vercel.',
'fileViewer.cloudflareApiToken': 'Token de API da Cloudflare',
'fileViewer.cloudflareApiTokenGetLink': 'Obter token de API da Cloudflare',
'fileViewer.cloudflareApiTokenPlaceholder': 'Cole seu token de API da Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'O token de API da Cloudflare salvo será usado. Insira um novo token para substituí-lo.',
'fileViewer.cloudflareApiTokenRequired': 'Insira e salve primeiro um token de API da Cloudflare.',
'fileViewer.cloudflareApiTokenScopeHint': 'O token precisa de Account: Cloudflare Pages: Edit e acesso de leitura à conta.',
'fileViewer.vercelTeamId': 'ID da equipe',
'fileViewer.vercelTeamSlug': 'Slug da equipe',
'fileViewer.cloudflareAccountId': 'ID da conta',
'fileViewer.cloudflareAccountIdHint': 'Obrigatório. Encontre o ID da conta no painel da Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Insira e salve primeiro um Cloudflare Account ID.',
'fileViewer.optional': 'Opcional',
'fileViewer.vercelPreviewOnly': 'As implantações são apenas Preview por enquanto.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages usa Direct Upload.',
'fileViewer.savingConfig': 'Salvando…',
'fileViewer.deployConfigSaveFailed': 'Não foi possível salvar as configurações da Vercel.',
'fileViewer.deployFailed': 'Falha na implantação. Verifique as configurações da Vercel e tente novamente.',
'fileViewer.deployProviderConfigSaveFailed': 'Não foi possível salvar as configurações de {provider}.',
'fileViewer.deployProviderFailed': 'A implantação em {provider} falhou. Verifique as configurações e tente novamente.',
'fileViewer.deployResultLabel': 'URL implantada',
'fileViewer.deployLinkPreparingLabel': 'Link público pendente',
'fileViewer.deployLinkDelayed': 'Seu site foi implantado. O provedor ainda está preparando o link público.',
'fileViewer.deployLinkProtectedLabel': 'Proteção da implantação ativada',
'fileViewer.deployLinkProtected': 'O site foi implantado, mas este link de prévia exige autenticação. Desative Deployment Protection ou use um domínio personalizado.',
'fileViewer.retryLink': 'Tentar novamente agora',
'questionForm.submit': 'Enviar',
'questionForm.skip': 'Pular',

View file

@ -723,33 +723,47 @@ export const ru: Dict = {
'liveArtifact.refresh.statusSucceeded': 'Актуально',
'liveArtifact.refresh.statusFailed': 'Обновление не удалось',
'fileViewer.deployToVercel': 'Развернуть на Vercel',
'fileViewer.redeployToVercel': 'Развернуть заново',
'fileViewer.redeployToVercel': 'Развернуть повторно',
'fileViewer.deployingToVercel': 'Развёртывание на Vercel…',
'fileViewer.deployProviderLabel': 'Провайдер',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'Развернуть на {provider}',
'fileViewer.redeployToProvider': 'Развернуть повторно на {provider}',
'fileViewer.deployingToProvider': 'Развёртывание на {provider}…',
'fileViewer.preparingPublicLink': 'Подготовка публичной ссылки…',
'fileViewer.copyDeployLink': 'Скопировать ссылку',
'fileViewer.deployModalTitle': 'Развернуть на Vercel',
'fileViewer.deployModalSubtitle':
'Разверните этот HTML-артефакт как Vercel Preview в своей учётной записи.',
'fileViewer.deployModalTitle': 'Развернуть',
'fileViewer.deployModalSubtitle': 'Используйте аккаунт выбранного провайдера, чтобы развернуть этот HTML-просмотр.',
'fileViewer.vercelToken': 'Токен Vercel',
'fileViewer.vercelTokenGetLink': 'Получить токен Vercel',
'fileViewer.vercelTokenPlaceholder': 'Вставьте токен Vercel',
'fileViewer.vercelTokenReuseHint':
'Будет использован сохранённый токен. Введите новый, чтобы заменить его.',
'fileViewer.vercelTokenReuseHint': 'Будет использован сохранённый токен. Введите новый, чтобы заменить его.',
'fileViewer.vercelTokenRequired': 'Сначала введите и сохраните токен Vercel.',
'fileViewer.cloudflareApiToken': 'Токен API Cloudflare',
'fileViewer.cloudflareApiTokenGetLink': 'Получить токен API Cloudflare',
'fileViewer.cloudflareApiTokenPlaceholder': 'Вставьте токен API Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'Будет использован сохранённый токен API Cloudflare. Введите новый, чтобы заменить его.',
'fileViewer.cloudflareApiTokenRequired': 'Сначала введите и сохраните токен API Cloudflare.',
'fileViewer.cloudflareApiTokenScopeHint': 'Токену нужны права Account: Cloudflare Pages: Edit и доступ на чтение аккаунта.',
'fileViewer.vercelTeamId': 'ID команды',
'fileViewer.vercelTeamSlug': 'Слаг команды',
'fileViewer.cloudflareAccountId': 'ID аккаунта',
'fileViewer.cloudflareAccountIdHint': 'Обязательно. ID аккаунта можно найти в панели Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Сначала введите и сохраните Cloudflare Account ID.',
'fileViewer.optional': 'Необязательно',
'fileViewer.vercelPreviewOnly': 'Пока поддерживаются только Preview-развёртывания.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages использует Direct Upload.',
'fileViewer.savingConfig': 'Сохранение…',
'fileViewer.deployConfigSaveFailed': 'Не удалось сохранить настройки Vercel.',
'fileViewer.deployFailed': 'Развёртывание не удалось. Проверьте настройки Vercel и попробуйте снова.',
'fileViewer.deployProviderConfigSaveFailed': 'Не удалось сохранить настройки {provider}.',
'fileViewer.deployProviderFailed': 'Развёртывание на {provider} не удалось. Проверьте настройки и попробуйте снова.',
'fileViewer.deployResultLabel': 'URL развёрнутого сайта',
'fileViewer.deployLinkPreparingLabel': 'Публичная ссылка готовится',
'fileViewer.deployLinkDelayed':
'Сайт уже развернут. Vercel ещё подготавливает публичную ссылку.',
'fileViewer.deployLinkProtectedLabel': 'Включена защита Vercel',
'fileViewer.deployLinkProtected':
'Сайт развернут, но Vercel требует аутентификацию для этой preview-ссылки. Отключите Deployment Protection или используйте собственный домен.',
'fileViewer.deployLinkDelayed': 'Сайт развёрнут. Провайдер всё ещё готовит публичную ссылку.',
'fileViewer.deployLinkProtectedLabel': 'Защита развёртывания включена',
'fileViewer.deployLinkProtected': 'Сайт развёрнут, но эта ссылка предпросмотра требует аутентификации. Отключите Deployment Protection или используйте собственный домен.',
'fileViewer.retryLink': 'Повторить',
'questionForm.submit': 'Отправить',

View file

@ -686,31 +686,45 @@ export const tr: Dict = {
'fileViewer.deployToVercel': 'Vercele yayınla',
'fileViewer.redeployToVercel': 'Yeniden yayınla',
'fileViewer.deployingToVercel': 'Vercele yayınlanıyor…',
'fileViewer.deployProviderLabel': 'Yayınlama sağlayıcısı',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': '{provider}e yayınla',
'fileViewer.redeployToProvider': '{provider}e yeniden yayınla',
'fileViewer.deployingToProvider': '{provider}e yayınlanıyor…',
'fileViewer.preparingPublicLink': 'Herkese açık bağlantı hazırlanıyor…',
'fileViewer.copyDeployLink': 'Bağlantıyı kopyala',
'fileViewer.deployModalTitle': 'Vercele yayınla',
'fileViewer.deployModalSubtitle':
'Kendi hesabınızı kullanarak bu HTML eserini bir Vercel Önizlemesi olarak yayınlayın.',
'fileViewer.deployModalTitle': 'Yayınla',
'fileViewer.deployModalSubtitle': 'Bu HTML önizlemesini seçilen sağlayıcı hesabıyla yayınlayın.',
'fileViewer.vercelToken': 'Vercel tokeni',
'fileViewer.vercelTokenGetLink': 'Vercel tokenini al',
'fileViewer.vercelTokenPlaceholder': 'Vercel tokeninizi yapıştırın',
'fileViewer.vercelTokenReuseHint':
'Kaydedilmiş token kullanılacak. Değiştirmek için yeni bir token girin.',
'fileViewer.vercelTokenReuseHint': 'Kaydedilmiş token kullanılacak. Değiştirmek için yeni bir token girin.',
'fileViewer.vercelTokenRequired': 'Önce bir Vercel tokeni girin ve kaydedin.',
'fileViewer.cloudflareApiToken': 'Cloudflare API tokeni',
'fileViewer.cloudflareApiTokenGetLink': 'Cloudflare API tokenini al',
'fileViewer.cloudflareApiTokenPlaceholder': 'Cloudflare API tokeninizi yapıştırın',
'fileViewer.cloudflareApiTokenReuseHint': 'Kaydedilmiş Cloudflare API tokeni kullanılacak. Değiştirmek için yeni bir token girin.',
'fileViewer.cloudflareApiTokenRequired': 'Önce bir Cloudflare API tokeni girin ve kaydedin.',
'fileViewer.cloudflareApiTokenScopeHint': 'Token için Account: Cloudflare Pages: Edit ve hesap okuma erişimi gerekir.',
'fileViewer.vercelTeamId': 'Takım ID',
'fileViewer.vercelTeamSlug': 'Takım slugı',
'fileViewer.cloudflareAccountId': 'Hesap ID',
'fileViewer.cloudflareAccountIdHint': 'Zorunlu. Hesap IDsini Cloudflare panosunda bulabilirsiniz.',
'fileViewer.cloudflareAccountIdRequired': 'Önce Cloudflare Account ID girin ve kaydedin.',
'fileViewer.optional': 'Opsiyonel',
'fileViewer.vercelPreviewOnly': 'Yayınlanmış içerikler şimdilik yalnızca önizlenebilir.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages yayınları Direct Upload kullanır.',
'fileViewer.savingConfig': 'Kaydediliyor…',
'fileViewer.deployConfigSaveFailed': 'Vercel ayarları kaydedilemedi.',
'fileViewer.deployFailed': 'Yayınlama başarısız oldu. Vercel ayarlarınızı kontrol edin ve yeniden deneyin.',
'fileViewer.deployProviderConfigSaveFailed': '{provider} ayarları kaydedilemedi.',
'fileViewer.deployProviderFailed': '{provider} yayını başarısız oldu. Ayarları kontrol edip yeniden deneyin.',
'fileViewer.deployResultLabel': 'Yayınlanmış URL',
'fileViewer.deployLinkPreparingLabel': 'Herkese açık link bekleniyor',
'fileViewer.deployLinkDelayed':
'Siteniz yayınlandı. Vercel hala herkese açık linki hazırlıyor.',
'fileViewer.deployLinkProtectedLabel': 'Vercel korumasııldı',
'fileViewer.deployLinkProtected':
'Siteniz yayınlandı, Ancak Vercel bu linki önizlemek için doğrulama gerektiriyor. Yayınlama Korumasını devre dışı bırakın veya özel bir domain kullanın.',
'fileViewer.deployLinkDelayed': 'Site yayınlandı. Sağlayıcı herkese açık bağlantıyı hâlâ hazırlıyor.',
'fileViewer.deployLinkProtectedLabel': 'Yayın koruması etkin',
'fileViewer.deployLinkProtected': 'Site yayınlandı, ancak bu önizleme bağlantısı kimlik doğrulaması istiyor. Deployment Protectionı kapatın veya özel alan adı kullanın.',
'fileViewer.retryLink': 'Şimdi yeniden dene',
'questionForm.submit': 'Gönder',

View file

@ -724,33 +724,47 @@ export const uk: Dict = {
'fileViewer.templateDescPrompt':
'Короткий опис (необов\'язково — що робить цей шаблон корисним?)',
'fileViewer.deployToVercel': 'Розгорнути на Vercel',
'fileViewer.redeployToVercel': 'Повторно розгорнути',
'fileViewer.redeployToVercel': 'Розгорнути повторно',
'fileViewer.deployingToVercel': 'Розгортання на Vercel…',
'fileViewer.deployProviderLabel': 'Провайдер',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'Розгорнути на {provider}',
'fileViewer.redeployToProvider': 'Розгорнути повторно на {provider}',
'fileViewer.deployingToProvider': 'Розгортання на {provider}…',
'fileViewer.preparingPublicLink': 'Підготовка публічного посилання…',
'fileViewer.copyDeployLink': 'Копіювати посилання',
'fileViewer.deployModalTitle': 'Розгорнути на Vercel',
'fileViewer.deployModalSubtitle':
'Розгорніть цей HTML артефакт як Vercel Preview за допомогою свого облікового запису.',
'fileViewer.deployModalTitle': 'Розгорнути',
'fileViewer.deployModalSubtitle': 'Використайте акаунт вибраного провайдера, щоб розгорнути цей HTML-перегляд.',
'fileViewer.vercelToken': 'Токен Vercel',
'fileViewer.vercelTokenGetLink': 'Отримати токен Vercel',
'fileViewer.vercelTokenPlaceholder': 'Вставте свій токен Vercel',
'fileViewer.vercelTokenReuseHint':
'Збережений токен буде використаний. Введіть новий токен, щоб замінити його.',
'fileViewer.vercelTokenReuseHint': 'Збережений токен буде використаний. Введіть новий токен, щоб замінити його.',
'fileViewer.vercelTokenRequired': 'Спочатку введіть та збережіть токен Vercel.',
'fileViewer.cloudflareApiToken': 'Токен API Cloudflare',
'fileViewer.cloudflareApiTokenGetLink': 'Отримати токен API Cloudflare',
'fileViewer.cloudflareApiTokenPlaceholder': 'Вставте токен API Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'Збережений токен API Cloudflare буде використаний. Введіть новий токен, щоб замінити його.',
'fileViewer.cloudflareApiTokenRequired': 'Спочатку введіть та збережіть токен API Cloudflare.',
'fileViewer.cloudflareApiTokenScopeHint': 'Токену потрібні права Account: Cloudflare Pages: Edit і доступ на читання акаунта.',
'fileViewer.vercelTeamId': 'ID команди',
'fileViewer.vercelTeamSlug': 'Слаг команди',
'fileViewer.optional': 'Необов\'язково',
'fileViewer.cloudflareAccountId': 'ID акаунта',
'fileViewer.cloudflareAccountIdHint': 'Обов’язково. ID акаунта можна знайти в панелі Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Спочатку введіть і збережіть Cloudflare Account ID.',
'fileViewer.optional': 'Необов’язково',
'fileViewer.vercelPreviewOnly': 'Розгортання наразі лише для попереднього перегляду.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages використовує Direct Upload.',
'fileViewer.savingConfig': 'Збереження…',
'fileViewer.deployConfigSaveFailed': 'Не вдалося зберегти налаштування Vercel.',
'fileViewer.deployFailed': 'Розгортання не вдалося. Перевірте налаштування Vercel та спробуйте ще раз.',
'fileViewer.deployProviderConfigSaveFailed': 'Не вдалося зберегти налаштування {provider}.',
'fileViewer.deployProviderFailed': 'Розгортання на {provider} не вдалося. Перевірте налаштування й спробуйте ще раз.',
'fileViewer.deployResultLabel': 'URL розгортання',
'fileViewer.deployLinkPreparingLabel': 'Публічне посилання очікує',
'fileViewer.deployLinkDelayed':
'Ваш сайт розгорнуто. Vercel все ще готує публічне посилання.',
'fileViewer.deployLinkProtectedLabel': 'Увімкнено захист Vercel',
'fileViewer.deployLinkProtected':
'Ваш сайт розгорнуто, але Vercel вимагає автентифікацію для цього посилання попереднього перегляду. Вимкніть захист розгортання або використовуйте користувацький домен.',
'fileViewer.deployLinkDelayed': 'Сайт розгорнуто. Провайдер ще готує публічне посилання.',
'fileViewer.deployLinkProtectedLabel': 'Захист розгортання ввімкнено',
'fileViewer.deployLinkProtected': 'Сайт розгорнуто, але це посилання попереднього перегляду вимагає автентифікації. Вимкніть Deployment Protection або використайте власний домен.',
'fileViewer.retryLink': 'Повторити зараз',
'questionForm.submit': 'Надіслати',

View file

@ -712,28 +712,45 @@ export const zhCN: Dict = {
'fileViewer.deployToVercel': '部署到 Vercel',
'fileViewer.redeployToVercel': '重新部署',
'fileViewer.deployingToVercel': '正在部署到 Vercel…',
'fileViewer.deployProviderLabel': '部署平台',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': '部署到 {provider}',
'fileViewer.redeployToProvider': '重新部署到 {provider}',
'fileViewer.deployingToProvider': '正在部署到 {provider}…',
'fileViewer.preparingPublicLink': '正在准备公开链接…',
'fileViewer.copyDeployLink': '复制链接',
'fileViewer.deployModalTitle': '部署到 Vercel',
'fileViewer.deployModalSubtitle': '使用你自己的 Vercel 账号部署此 HTML 作品预览。',
'fileViewer.deployModalTitle': '部署',
'fileViewer.deployModalSubtitle': '使用所选平台账号部署当前 HTML 预览。',
'fileViewer.vercelToken': 'Vercel token',
'fileViewer.vercelTokenGetLink': '获取 Vercel token',
'fileViewer.vercelTokenPlaceholder': '粘贴你的 Vercel token',
'fileViewer.vercelTokenReuseHint': '将使用已保存的 token。输入新 token 可替换。',
'fileViewer.vercelTokenRequired': '请先输入并保存 Vercel token。',
'fileViewer.cloudflareApiToken': 'Cloudflare API token',
'fileViewer.cloudflareApiTokenGetLink': '获取 Cloudflare API token',
'fileViewer.cloudflareApiTokenPlaceholder': '粘贴你的 Cloudflare API token',
'fileViewer.cloudflareApiTokenReuseHint': '将使用已保存的 Cloudflare API token。输入新 token 可替换。',
'fileViewer.cloudflareApiTokenRequired': '请先输入并保存 Cloudflare API token。',
'fileViewer.cloudflareApiTokenScopeHint': 'Token 需要 Account: Cloudflare Pages: Edit 权限,以及账号读取权限。',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': '必填。可在 Cloudflare 控制台中找到账号 ID。',
'fileViewer.cloudflareAccountIdRequired': '请先输入并保存 Cloudflare Account ID。',
'fileViewer.optional': '可选',
'fileViewer.vercelPreviewOnly': '当前仅部署 Preview。',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages 使用 Direct Upload。',
'fileViewer.savingConfig': '保存中…',
'fileViewer.deployConfigSaveFailed': '保存 Vercel 设置失败。',
'fileViewer.deployFailed': '部署失败,请检查 Vercel 设置后重试。',
'fileViewer.deployProviderConfigSaveFailed': '无法保存 {provider} 设置。',
'fileViewer.deployProviderFailed': '{provider} 部署失败。请检查设置后重试。',
'fileViewer.deployResultLabel': '部署链接',
'fileViewer.deployLinkPreparingLabel': '公开链接准备中',
'fileViewer.deployLinkDelayed': '站点已经部署Vercel 仍在准备公开链接。',
'fileViewer.deployLinkProtectedLabel': 'Vercel 访问保护已开启',
'fileViewer.deployLinkProtected':
'站点已部署,但 Vercel 要求登录后才能访问此预览链接。请关闭 Deployment Protection 或使用自定义域名。',
'fileViewer.deployLinkDelayed': '站点已经部署,平台仍在准备公开链接。',
'fileViewer.deployLinkProtectedLabel': '部署访问保护已开启',
'fileViewer.deployLinkProtected': '站点已部署,但此预览链接要求登录后才能访问。请关闭 Deployment Protection 或使用自定义域名。',
'fileViewer.retryLink': '立即重试',
'questionForm.submit': '提交',

View file

@ -712,28 +712,45 @@ export const zhTW: Dict = {
'fileViewer.deployToVercel': '部署到 Vercel',
'fileViewer.redeployToVercel': '重新部署',
'fileViewer.deployingToVercel': '正在部署到 Vercel…',
'fileViewer.deployProviderLabel': '部署平台',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': '部署到 {provider}',
'fileViewer.redeployToProvider': '重新部署到 {provider}',
'fileViewer.deployingToProvider': '正在部署到 {provider}…',
'fileViewer.preparingPublicLink': '正在準備公開連結…',
'fileViewer.copyDeployLink': '複製連結',
'fileViewer.deployModalTitle': '部署到 Vercel',
'fileViewer.deployModalSubtitle': '使用你自己的 Vercel 帳號部署此 HTML 作品預覽。',
'fileViewer.deployModalTitle': '部署',
'fileViewer.deployModalSubtitle': '使用所選平台帳號部署目前 HTML 預覽。',
'fileViewer.vercelToken': 'Vercel token',
'fileViewer.vercelTokenGetLink': '取得 Vercel token',
'fileViewer.vercelTokenPlaceholder': '貼上你的 Vercel token',
'fileViewer.vercelTokenReuseHint': '將使用已儲存的 token。輸入新 token 可替換。',
'fileViewer.vercelTokenRequired': '請先輸入並儲存 Vercel token。',
'fileViewer.cloudflareApiToken': 'Cloudflare API token',
'fileViewer.cloudflareApiTokenGetLink': '取得 Cloudflare API token',
'fileViewer.cloudflareApiTokenPlaceholder': '貼上你的 Cloudflare API token',
'fileViewer.cloudflareApiTokenReuseHint': '將使用已儲存的 Cloudflare API token。輸入新 token 可替換。',
'fileViewer.cloudflareApiTokenRequired': '請先輸入並儲存 Cloudflare API token。',
'fileViewer.cloudflareApiTokenScopeHint': 'Token 需要 Account: Cloudflare Pages: Edit 權限,以及帳號讀取權限。',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': '必填。可在 Cloudflare 控制台中找到帳號 ID。',
'fileViewer.cloudflareAccountIdRequired': '請先輸入並儲存 Cloudflare Account ID。',
'fileViewer.optional': '可選',
'fileViewer.vercelPreviewOnly': '目前僅部署 Preview。',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages 使用 Direct Upload。',
'fileViewer.savingConfig': '儲存中…',
'fileViewer.deployConfigSaveFailed': '儲存 Vercel 設定失敗。',
'fileViewer.deployFailed': '部署失敗,請檢查 Vercel 設定後重試。',
'fileViewer.deployProviderConfigSaveFailed': '無法儲存 {provider} 設定。',
'fileViewer.deployProviderFailed': '{provider} 部署失敗。請檢查設定後重試。',
'fileViewer.deployResultLabel': '部署連結',
'fileViewer.deployLinkPreparingLabel': '公開連結準備中',
'fileViewer.deployLinkDelayed': '站點已部署Vercel 仍在準備公開連結。',
'fileViewer.deployLinkProtectedLabel': 'Vercel 存取保護已開啟',
'fileViewer.deployLinkProtected':
'站點已部署,但 Vercel 要求登入後才能存取此預覽連結。請關閉 Deployment Protection 或使用自訂網域。',
'fileViewer.deployLinkDelayed': '站點已部署,平台仍在準備公開連結。',
'fileViewer.deployLinkProtectedLabel': '部署存取保護已開啟',
'fileViewer.deployLinkProtected': '站點已部署,但此預覽連結要求登入後才能存取。請關閉 Deployment Protection 或使用自訂網域。',
'fileViewer.retryLink': '立即重試',
'questionForm.submit': '提交',

View file

@ -778,6 +778,12 @@ export interface Dict {
'liveArtifact.refresh.statusReady': string;
'liveArtifact.refresh.statusSucceeded': string;
'liveArtifact.refresh.statusFailed': string;
'fileViewer.deployProviderLabel': string;
'fileViewer.vercelProvider': string;
'fileViewer.cloudflarePagesProvider': string;
'fileViewer.deployToProvider': string;
'fileViewer.redeployToProvider': string;
'fileViewer.deployingToProvider': string;
'fileViewer.deployToVercel': string;
'fileViewer.redeployToVercel': string;
'fileViewer.deployingToVercel': string;
@ -790,13 +796,25 @@ export interface Dict {
'fileViewer.vercelTokenPlaceholder': string;
'fileViewer.vercelTokenReuseHint': string;
'fileViewer.vercelTokenRequired': string;
'fileViewer.cloudflareApiToken': string;
'fileViewer.cloudflareApiTokenGetLink': string;
'fileViewer.cloudflareApiTokenPlaceholder': string;
'fileViewer.cloudflareApiTokenReuseHint': string;
'fileViewer.cloudflareApiTokenRequired': string;
'fileViewer.cloudflareApiTokenScopeHint': string;
'fileViewer.vercelTeamId': string;
'fileViewer.vercelTeamSlug': string;
'fileViewer.cloudflareAccountId': string;
'fileViewer.cloudflareAccountIdHint': string;
'fileViewer.cloudflareAccountIdRequired': string;
'fileViewer.optional': string;
'fileViewer.vercelPreviewOnly': string;
'fileViewer.cloudflarePagesPreviewHint': string;
'fileViewer.savingConfig': string;
'fileViewer.deployConfigSaveFailed': string;
'fileViewer.deployFailed': string;
'fileViewer.deployProviderConfigSaveFailed': string;
'fileViewer.deployProviderFailed': string;
'fileViewer.deployResultLabel': string;
'fileViewer.deployLinkPreparingLabel': string;
'fileViewer.deployLinkDelayed': string;

View file

@ -7018,6 +7018,11 @@ button.connector-action.is-loading {
gap: 14px;
margin-top: 18px;
}
.deploy-provider-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label-row {
display: flex;
align-items: baseline;
@ -7069,7 +7074,8 @@ button.connector-action.is-loading {
border: 1px solid color-mix(in srgb, #1f9d55 28%, var(--border));
font-variant-numeric: tabular-nums;
}
.deploy-form input {
.deploy-form input,
.deploy-form select {
width: 100%;
min-height: 44px;
border: 1px solid var(--border);
@ -7088,11 +7094,19 @@ button.connector-action.is-loading {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.deploy-field-grid.single-field {
grid-template-columns: minmax(0, 1fr);
}
.deploy-field-grid label {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-hint {
color: var(--text-muted);
font-size: 12px;
line-height: 1.35;
}
.deploy-error {
margin: 0;
color: var(--red);

View file

@ -35,6 +35,28 @@ import type {
} from '../types';
import type { ArtifactManifest } from '../artifacts/types';
export const DEFAULT_DEPLOY_PROVIDER_ID = 'vercel-self';
export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages';
export const DEPLOY_PROVIDER_IDS = [
DEFAULT_DEPLOY_PROVIDER_ID,
CLOUDFLARE_PAGES_PROVIDER_ID,
] as const;
export type WebDeployProviderId = (typeof DEPLOY_PROVIDER_IDS)[number];
export type WebDeployConfigResponse = DeployConfigResponse;
export type WebUpdateDeployConfigRequest = UpdateDeployConfigRequest;
export type WebDeploymentInfo = ProjectDeploymentsResponse['deployments'][number];
export type WebDeployProjectFileResponse = DeployProjectFileResponse;
export function isDeployProviderId(value: unknown): value is WebDeployProviderId {
return typeof value === 'string' && (DEPLOY_PROVIDER_IDS as readonly string[]).includes(value);
}
function deployProviderQuery(providerId?: WebDeployProviderId): string {
return providerId ? `?providerId=${encodeURIComponent(providerId)}` : '';
}
export async function fetchAgents(options?: { throwOnError?: boolean }): Promise<AgentInfo[]> {
try {
const resp = await fetch('/api/agents');
@ -337,40 +359,48 @@ export async function fetchSkillExample(id: string): Promise<string | null> {
}
}
export async function fetchDeployConfig(): Promise<DeployConfigResponse | null> {
export async function fetchDeployConfig(
providerId?: WebDeployProviderId,
): Promise<WebDeployConfigResponse | null> {
try {
const resp = await fetch('/api/deploy/config');
const resp = await fetch(`/api/deploy/config${deployProviderQuery(providerId)}`);
if (!resp.ok) return null;
return (await resp.json()) as DeployConfigResponse;
return (await resp.json()) as WebDeployConfigResponse;
} catch {
return null;
}
}
export async function updateDeployConfig(
input: UpdateDeployConfigRequest,
): Promise<DeployConfigResponse | null> {
input: WebUpdateDeployConfigRequest,
): Promise<WebDeployConfigResponse | null> {
try {
const resp = await fetch('/api/deploy/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!resp.ok) return null;
return (await resp.json()) as DeployConfigResponse;
} catch {
if (!resp.ok) {
const payload = (await resp.json().catch(() => null)) as
| { error?: { message?: string }; message?: string }
| null;
throw new Error(payload?.error?.message || payload?.message || `Could not save deploy config (${resp.status})`);
}
return (await resp.json()) as WebDeployConfigResponse;
} catch (err) {
if (err instanceof Error) throw err;
return null;
}
}
export async function fetchProjectDeployments(
projectId: string,
): Promise<ProjectDeploymentsResponse['deployments']> {
): Promise<WebDeploymentInfo[]> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/deployments`);
if (!resp.ok) return [];
const json = (await resp.json()) as ProjectDeploymentsResponse;
return json.deployments ?? [];
return (json.deployments ?? []) as WebDeploymentInfo[];
} catch {
return [];
}
@ -379,11 +409,12 @@ export async function fetchProjectDeployments(
export async function deployProjectFile(
projectId: string,
fileName: string,
): Promise<DeployProjectFileResponse> {
providerId: WebDeployProviderId = DEFAULT_DEPLOY_PROVIDER_ID,
): Promise<WebDeployProjectFileResponse> {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, providerId: 'vercel-self' }),
body: JSON.stringify({ fileName, providerId }),
});
if (!resp.ok) {
const payload = (await resp.json().catch(() => null)) as
@ -391,13 +422,13 @@ export async function deployProjectFile(
| null;
throw new Error(payload?.error?.message || payload?.message || `Deploy failed (${resp.status})`);
}
return (await resp.json()) as DeployProjectFileResponse;
return (await resp.json()) as WebDeployProjectFileResponse;
}
export async function checkDeploymentLink(
projectId: string,
deploymentId: string,
): Promise<DeployProjectFileResponse> {
): Promise<WebDeployProjectFileResponse> {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/deployments/${encodeURIComponent(deploymentId)}/check-link`,
{ method: 'POST' },
@ -408,7 +439,7 @@ export async function checkDeploymentLink(
| null;
throw new Error(payload?.error?.message || payload?.message || `Link check failed (${resp.status})`);
}
return (await resp.json()) as DeployProjectFileResponse;
return (await resp.json()) as WebDeployProjectFileResponse;
}
// Project files — all paths are scoped under .od/projects/<id>/ on disk.

View file

@ -1,8 +1,8 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
const { saveTemplateMock } = vi.hoisted(() => ({
saveTemplateMock: vi.fn(),
@ -30,6 +30,13 @@ import {
import type { InspectOverrideMap } from '../../src/components/FileViewer';
import type { LiveArtifact, ProjectFile } from '../../src/types';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
Reflect.deleteProperty(navigator, 'clipboard');
});
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
return {
name: 'asset.png',
@ -187,6 +194,241 @@ describe('FileViewer SVG artifacts', () => {
expect(markup).not.toContain('data-od-render-mode="url-load"');
});
it('shows Cloudflare Pages as a deploy action without requiring a project name input', async () => {
const file = baseFile({
name: 'index.html',
path: 'index.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'index.html',
renderer: 'html',
exports: ['html'],
},
});
render(
<FileViewer
projectId="project-1"
file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
fireEvent.click(screen.getByRole('button', { name: /share/i }));
expect(screen.getByRole('menuitem', { name: /Deploy to Vercel/i })).toBeTruthy();
fireEvent.click(screen.getByRole('menuitem', { name: /Deploy to Cloudflare Pages/i }));
expect(await screen.findByRole('dialog')).toBeTruthy();
expect(screen.getByText('Account ID')).toBeTruthy();
expect(screen.getByText(/Cloudflare Pages: Edit/i)).toBeTruthy();
expect(screen.queryByText('Pages project name')).toBeNull();
expect(screen.queryByText(/generates a Pages project name automatically/i)).toBeNull();
expect(screen.queryByText(/project name is selected automatically/i)).toBeNull();
expect(screen.queryByLabelText('Pages project name')).toBeNull();
});
it('keeps the explicitly selected deploy provider when another provider already has a deployment', async () => {
const file = baseFile({
name: 'index.html',
path: 'index.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'index.html',
renderer: 'html',
exports: ['html'],
},
});
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url === '/api/projects/project-1/deployments') {
return new Response(JSON.stringify({
deployments: [
{
id: 'vercel-deploy',
projectId: 'project-1',
fileName: 'index.html',
providerId: 'vercel-self',
url: 'https://vercel.example',
deploymentCount: 1,
target: 'preview',
status: 'ready',
createdAt: 1,
updatedAt: 2,
},
],
}), { status: 200 });
}
if (url === '/api/deploy/config?providerId=cloudflare-pages') {
return new Response(JSON.stringify({
providerId: 'cloudflare-pages',
configured: true,
tokenMask: 'saved-cloudflare-token',
accountId: 'account-123',
}), { status: 200 });
}
if (url === '/api/deploy/config?providerId=vercel-self') {
return new Response(JSON.stringify({
providerId: 'vercel-self',
configured: true,
tokenMask: 'saved-vercel-token',
}), { status: 200 });
}
return new Response(JSON.stringify({}), { status: 404 });
});
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer
projectId="project-1"
file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
fireEvent.click(screen.getByRole('button', { name: /share/i }));
fireEvent.click(await screen.findByRole('menuitem', { name: /Deploy to Cloudflare Pages/i }));
const providerSelect = await screen.findByRole('combobox', { name: /Provider/i });
await waitFor(() => {
expect((providerSelect as HTMLSelectElement).value).toBe('cloudflare-pages');
});
const calledUrls = fetchMock.mock.calls.map(([input]) => (
typeof input === 'string' ? input : input instanceof Request ? input.url : String(input)
));
expect(calledUrls).toContain('/api/deploy/config?providerId=cloudflare-pages');
expect(calledUrls).not.toContain('/api/deploy/config?providerId=vercel-self');
expect((screen.getByLabelText(/Cloudflare API token/i) as HTMLInputElement).value).toBe('saved-cloudflare-token');
});
it('shows separate copy links for existing Vercel and Cloudflare deployments', async () => {
const file = baseFile({
name: 'index.html',
path: 'index.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'index.html',
renderer: 'html',
exports: ['html'],
},
});
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url === '/api/projects/project-1/deployments') {
return new Response(JSON.stringify({
deployments: [
{
id: 'vercel-deploy',
projectId: 'project-1',
fileName: 'index.html',
providerId: 'vercel-self',
url: 'https://vercel.example',
deploymentCount: 1,
target: 'preview',
status: 'ready',
createdAt: 1,
updatedAt: 2,
},
{
id: 'cloudflare-deploy',
projectId: 'project-1',
fileName: 'index.html',
providerId: 'cloudflare-pages',
url: 'https://cloudflare.pages.dev',
deploymentCount: 1,
target: 'preview',
status: 'ready',
createdAt: 1,
updatedAt: 3,
},
],
}), { status: 200 });
}
return new Response(JSON.stringify({}), { status: 404 });
});
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('fetch', fetchMock);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
render(
<FileViewer
projectId="project-1"
file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
fireEvent.click(screen.getByRole('button', { name: /share/i }));
expect(await screen.findByRole('menuitem', { name: /Copy link · Vercel/i })).toBeTruthy();
const cloudflareCopy = await screen.findByRole('menuitem', { name: /Copy link · Cloudflare Pages/i });
fireEvent.click(cloudflareCopy);
expect(writeText).toHaveBeenCalledWith('https://cloudflare.pages.dev');
});
it('shows one copy link when only one deployment provider has a URL', async () => {
const file = baseFile({
name: 'index.html',
path: 'index.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'index.html',
renderer: 'html',
exports: ['html'],
},
});
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
deployments: [
{
id: 'cloudflare-deploy',
projectId: 'project-1',
fileName: 'index.html',
providerId: 'cloudflare-pages',
url: 'https://cloudflare.pages.dev',
deploymentCount: 1,
target: 'preview',
status: 'ready',
createdAt: 1,
updatedAt: 3,
},
],
}), { status: 200 })));
render(
<FileViewer
projectId="project-1"
file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
fireEvent.click(screen.getByRole('button', { name: /share/i }));
expect(await screen.findByRole('menuitem', { name: /Copy link · Cloudflare Pages/i })).toBeTruthy();
expect(screen.queryByRole('menuitem', { name: /Copy link · Vercel/i })).toBeNull();
});
it('renders unsafe SVG source as escaped text instead of executable markup', () => {
const file = baseFile({ name: 'unsafe.svg', path: 'unsafe.svg', mime: 'image/svg+xml' });
const unsafeSource = [

View file

@ -1,9 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
CLOUDFLARE_PAGES_PROVIDER_ID,
DEFAULT_DEPLOY_PROVIDER_ID,
deployProjectFile,
fetchDeployConfig,
fetchAppVersionInfo,
fetchConnectorDiscovery,
fetchProjectFileText,
isDeployProviderId,
updateDeployConfig,
uploadProjectFiles,
} from '../../src/providers/registry';
@ -188,3 +194,108 @@ describe('uploadProjectFiles', () => {
expect(result.failed[0]).toMatchObject({ name: 'c.txt' });
});
});
describe('deploy provider registry helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('recognizes Vercel and Cloudflare Pages provider ids only', () => {
expect(isDeployProviderId(DEFAULT_DEPLOY_PROVIDER_ID)).toBe(true);
expect(isDeployProviderId(CLOUDFLARE_PAGES_PROVIDER_ID)).toBe(true);
expect(isDeployProviderId('netlify')).toBe(false);
expect(isDeployProviderId(null)).toBe(false);
});
it('fetches provider-specific deploy config via query string', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
tokenMask: 'saved-cloudflare-token',
teamId: '',
teamSlug: '',
accountId: 'account-123',
projectName: '',
target: 'preview',
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(fetchDeployConfig(CLOUDFLARE_PAGES_PROVIDER_ID)).resolves.toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
accountId: 'account-123',
projectName: '',
});
expect(fetchMock).toHaveBeenCalledWith('/api/deploy/config?providerId=cloudflare-pages');
});
it('sends Cloudflare Pages config fields without dropping provider-specific metadata', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: true,
tokenMask: 'saved-cloudflare-token',
teamId: '',
teamSlug: '',
accountId: 'account-123',
projectName: '',
target: 'preview',
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(updateDeployConfig({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cf-token',
accountId: 'account-123',
})).resolves.toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
accountId: 'account-123',
projectName: '',
});
expect(fetchMock).toHaveBeenCalledWith('/api/deploy/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
token: 'cf-token',
accountId: 'account-123',
}),
});
});
it('passes the selected Cloudflare Pages provider id through deploy requests', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
id: 'deployment-row-1',
projectId: 'project-1',
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
url: 'https://open-design-preview.pages.dev',
deploymentId: 'cf-deployment-1',
deploymentCount: 1,
target: 'preview',
status: 'ready',
createdAt: 1,
updatedAt: 2,
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(
deployProjectFile('project-1', 'index.html', CLOUDFLARE_PAGES_PROVIDER_ID),
).resolves.toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
deploymentId: 'cf-deployment-1',
url: 'https://open-design-preview.pages.dev',
});
expect(fetchMock).toHaveBeenCalledWith('/api/projects/project-1/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
}),
});
});
});

View file

@ -160,7 +160,7 @@ export interface MessagesResponse {
messages: ChatMessage[];
}
export type DeployProviderId = 'vercel-self';
export type DeployProviderId = 'vercel-self' | 'cloudflare-pages';
export type DeploymentStatus =
| 'deploying'
| 'preparing-link'
@ -175,13 +175,18 @@ export interface DeployConfigResponse {
tokenMask: string;
teamId: string;
teamSlug: string;
accountId?: string;
projectName?: string;
target: 'preview';
}
export interface UpdateDeployConfigRequest {
providerId?: DeployProviderId;
token?: string;
teamId?: string;
teamSlug?: string;
accountId?: string;
projectName?: string;
}
export interface DeploymentInfo {
@ -196,6 +201,7 @@ export interface DeploymentInfo {
status: DeploymentStatus;
statusMessage?: string;
reachableAt?: number;
providerMetadata?: Record<string, unknown>;
createdAt: number;
updatedAt: number;
}

View file

@ -44,6 +44,9 @@ importers:
better-sqlite3:
specifier: ^12.9.0
version: 12.9.0
blake3-wasm:
specifier: 2.1.5
version: 2.1.5
chokidar:
specifier: ^5.0.0
version: 5.0.0
@ -1849,6 +1852,9 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
body-parser@1.20.5:
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@ -5928,6 +5934,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
blake3-wasm@2.1.5: {}
body-parser@1.20.5:
dependencies:
bytes: 3.1.2