mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Add Cloudflare Pages artifact deployment
Adds Cloudflare Pages artifact deployment support.
This commit is contained in:
parent
8630fd380a
commit
09eb88f683
30 changed files with 2496 additions and 378 deletions
|
|
@ -38,6 +38,7 @@
|
||||||
"@open-design/sidecar": "workspace:*",
|
"@open-design/sidecar": "workspace:*",
|
||||||
"@open-design/sidecar-proto": "workspace:*",
|
"@open-design/sidecar-proto": "workspace:*",
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
|
"blake3-wasm": "2.1.5",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ function migrate(db) {
|
||||||
status TEXT NOT NULL DEFAULT 'ready',
|
status TEXT NOT NULL DEFAULT 'ready',
|
||||||
status_message TEXT,
|
status_message TEXT,
|
||||||
reachable_at INTEGER,
|
reachable_at INTEGER,
|
||||||
|
provider_metadata_json TEXT,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
UNIQUE(project_id, file_name, provider_id),
|
UNIQUE(project_id, file_name, provider_id),
|
||||||
|
|
@ -192,6 +193,9 @@ function migrate(db) {
|
||||||
if (!deploymentCols.some((c) => c.name === 'reachable_at')) {
|
if (!deploymentCols.some((c) => c.name === 'reachable_at')) {
|
||||||
db.exec(`ALTER TABLE deployments ADD COLUMN reachable_at INTEGER`);
|
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);
|
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,
|
provider_id AS providerId, url, deployment_id AS deploymentId,
|
||||||
deployment_count AS deploymentCount, target, status,
|
deployment_count AS deploymentCount, target, status,
|
||||||
status_message AS statusMessage, reachable_at AS reachableAt,
|
status_message AS statusMessage, reachable_at AS reachableAt,
|
||||||
|
provider_metadata_json AS providerMetadataJson,
|
||||||
created_at AS createdAt, updated_at AS updatedAt`;
|
created_at AS createdAt, updated_at AS updatedAt`;
|
||||||
|
|
||||||
export function listDeployments(db, projectId) {
|
export function listDeployments(db, projectId) {
|
||||||
|
|
@ -260,15 +265,20 @@ export function upsertDeployment(db, deployment) {
|
||||||
status: deployment.status ?? existing?.status ?? 'ready',
|
status: deployment.status ?? existing?.status ?? 'ready',
|
||||||
statusMessage: deployment.statusMessage ?? null,
|
statusMessage: deployment.statusMessage ?? null,
|
||||||
reachableAt: deployment.reachableAt ?? null,
|
reachableAt: deployment.reachableAt ?? null,
|
||||||
|
providerMetadata:
|
||||||
|
deployment.providerMetadata === undefined
|
||||||
|
? existing?.providerMetadata
|
||||||
|
: deployment.providerMetadata,
|
||||||
createdAt: existing?.createdAt ?? deployment.createdAt ?? now,
|
createdAt: existing?.createdAt ?? deployment.createdAt ?? now,
|
||||||
updatedAt: deployment.updatedAt ?? now,
|
updatedAt: deployment.updatedAt ?? now,
|
||||||
};
|
};
|
||||||
|
const providerMetadataJson = stringifyJsonObjectOrNull(next.providerMetadata);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO deployments
|
`INSERT INTO deployments
|
||||||
(id, project_id, file_name, provider_id, url, deployment_id,
|
(id, project_id, file_name, provider_id, url, deployment_id,
|
||||||
deployment_count, target, status, status_message, reachable_at,
|
deployment_count, target, status, status_message, reachable_at,
|
||||||
created_at, updated_at)
|
provider_metadata_json, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(project_id, file_name, provider_id) DO UPDATE SET
|
ON CONFLICT(project_id, file_name, provider_id) DO UPDATE SET
|
||||||
url = excluded.url,
|
url = excluded.url,
|
||||||
deployment_id = excluded.deployment_id,
|
deployment_id = excluded.deployment_id,
|
||||||
|
|
@ -277,6 +287,7 @@ export function upsertDeployment(db, deployment) {
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
status_message = excluded.status_message,
|
status_message = excluded.status_message,
|
||||||
reachable_at = excluded.reachable_at,
|
reachable_at = excluded.reachable_at,
|
||||||
|
provider_metadata_json = excluded.provider_metadata_json,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at`,
|
||||||
).run(
|
).run(
|
||||||
next.id,
|
next.id,
|
||||||
|
|
@ -290,6 +301,7 @@ export function upsertDeployment(db, deployment) {
|
||||||
next.status,
|
next.status,
|
||||||
next.statusMessage,
|
next.statusMessage,
|
||||||
next.reachableAt,
|
next.reachableAt,
|
||||||
|
providerMetadataJson,
|
||||||
next.createdAt,
|
next.createdAt,
|
||||||
next.updatedAt,
|
next.updatedAt,
|
||||||
);
|
);
|
||||||
|
|
@ -297,6 +309,7 @@ export function upsertDeployment(db, deployment) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDeployment(row) {
|
function normalizeDeployment(row) {
|
||||||
|
const providerMetadata = parseJsonOrUndef(row.providerMetadataJson);
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
projectId: row.projectId,
|
projectId: row.projectId,
|
||||||
|
|
@ -309,11 +322,20 @@ function normalizeDeployment(row) {
|
||||||
status: row.status || 'ready',
|
status: row.status || 'ready',
|
||||||
statusMessage: row.statusMessage ?? undefined,
|
statusMessage: row.statusMessage ?? undefined,
|
||||||
reachableAt: row.reachableAt == null ? undefined : Number(row.reachableAt),
|
reachableAt: row.reachableAt == null ? undefined : Number(row.reachableAt),
|
||||||
|
providerMetadata:
|
||||||
|
providerMetadata && typeof providerMetadata === 'object' && !Array.isArray(providerMetadata)
|
||||||
|
? providerMetadata
|
||||||
|
: undefined,
|
||||||
createdAt: Number(row.createdAt),
|
createdAt: Number(row.createdAt),
|
||||||
updatedAt: Number(row.updatedAt),
|
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 ----------
|
// ---------- projects ----------
|
||||||
|
|
||||||
const PROJECT_COLS = `id, name, skill_id AS skillId,
|
const PROJECT_COLS = `id, name, skill_id AS skillId,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,19 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { hash as blake3Hash } from 'blake3-wasm';
|
||||||
import { readProjectFile, validateProjectPath } from './projects.js';
|
import { readProjectFile, validateProjectPath } from './projects.js';
|
||||||
|
|
||||||
export const VERCEL_PROVIDER_ID = 'vercel-self';
|
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_TOKEN_MASK = 'saved-vercel-token';
|
||||||
|
export const SAVED_CLOUDFLARE_TOKEN_MASK = 'saved-cloudflare-token';
|
||||||
|
|
||||||
const VERCEL_API = 'https://api.vercel.com';
|
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 =
|
const VERCEL_PROTECTED_MESSAGE =
|
||||||
'Deployment is protected by Vercel. Disable Deployment Protection or use a custom domain to make this link public.';
|
'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');
|
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() {
|
export async function readVercelConfig() {
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(deployConfigPath(), 'utf8');
|
const raw = await readFile(deployConfigPath(VERCEL_PROVIDER_ID), 'utf8');
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
return {
|
return {
|
||||||
token: typeof parsed.token === 'string' ? parsed.token : '',
|
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) {
|
export async function writeVercelConfig(input) {
|
||||||
const current = await readVercelConfig();
|
const current = await readVercelConfig();
|
||||||
const tokenInput = typeof input?.token === 'string' ? input.token.trim() : '';
|
const tokenInput = typeof input?.token === 'string' ? input.token.trim() : '';
|
||||||
|
|
@ -54,15 +76,39 @@ export async function writeVercelConfig(input) {
|
||||||
teamSlug:
|
teamSlug:
|
||||||
typeof input?.teamSlug === 'string' ? input.teamSlug.trim() : current.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 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 {
|
try {
|
||||||
fs.chmodSync(file, 0o600);
|
fs.chmodSync(file, 0o600);
|
||||||
} catch {
|
} catch {
|
||||||
// Best effort on filesystems that do not support chmod.
|
// Best effort on filesystems that do not support chmod.
|
||||||
}
|
}
|
||||||
return publicDeployConfig(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publicDeployConfig(config) {
|
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
|
// 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
|
// 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
|
// 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 candidates = deploymentUrlCandidates(ready, created);
|
||||||
const link = await waitForReachableDeploymentUrl(candidates.length ? candidates : [initialUrl]);
|
const link = await waitForReachableDeploymentUrl(
|
||||||
|
candidates.length ? candidates : [initialUrl],
|
||||||
|
{ providerLabel: 'Vercel' },
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerId: VERCEL_PROVIDER_ID,
|
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) {
|
export function extractHtmlReferences(html) {
|
||||||
const refs = [];
|
const refs = [];
|
||||||
for (const tag of parseHtmlTags(html)) {
|
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 plan = await buildDeployFilePlan(projectsRoot, projectId, entryName, options);
|
||||||
const { warnings, totalBytes, totalFiles } = analyzeDeployPlan(plan);
|
const { warnings, totalBytes, totalFiles } = analyzeDeployPlan(plan);
|
||||||
return {
|
return {
|
||||||
providerId: VERCEL_PROVIDER_ID,
|
providerId: options.providerId || VERCEL_PROVIDER_ID,
|
||||||
entry: plan.entryPath,
|
entry: plan.entryPath,
|
||||||
files: plan.files.map((f) => ({
|
files: plan.files.map((f) => ({
|
||||||
path: f.file,
|
path: f.file,
|
||||||
|
|
@ -731,7 +1035,7 @@ async function pollVercelDeployment(config, id) {
|
||||||
|
|
||||||
export async function waitForReachableDeploymentUrl(
|
export async function waitForReachableDeploymentUrl(
|
||||||
urls,
|
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 candidates = [...new Set((urls || []).map(normalizeDeploymentUrl).filter(Boolean))];
|
||||||
const fallbackUrl = candidates[0] || '';
|
const fallbackUrl = candidates[0] || '';
|
||||||
|
|
@ -739,7 +1043,7 @@ export async function waitForReachableDeploymentUrl(
|
||||||
return {
|
return {
|
||||||
status: 'link-delayed',
|
status: 'link-delayed',
|
||||||
url: '',
|
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',
|
status: 'link-delayed',
|
||||||
url: fallbackUrl,
|
url: fallbackUrl,
|
||||||
statusMessage:
|
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}` : '';
|
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) {
|
async function readVercelJson(resp) {
|
||||||
try {
|
try {
|
||||||
return await resp.json();
|
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) {
|
function vercelError(json, status) {
|
||||||
const code = json?.error?.code;
|
const code = json?.error?.code;
|
||||||
const message = json?.error?.message || json?.message || `Vercel request failed (${status}).`;
|
const message = json?.error?.message || json?.message || `Vercel request failed (${status}).`;
|
||||||
|
|
@ -900,9 +1257,20 @@ function deploymentUrl(json) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeVercelProjectName(raw) {
|
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)
|
return String(raw)
|
||||||
|
.normalize('NFKD')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9-]/g, '-')
|
.replace(/[^a-z0-9-]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
.slice(0, 80) || `od-${randomUUID().slice(0, 8)}`;
|
.slice(0, maxLength)
|
||||||
|
.replace(/-+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,13 +142,17 @@ import { CHAT_TOOL_ENDPOINTS, CHAT_TOOL_OPERATIONS, toolTokenRegistry } from './
|
||||||
import {
|
import {
|
||||||
buildDeployFileSet,
|
buildDeployFileSet,
|
||||||
checkDeploymentUrl,
|
checkDeploymentUrl,
|
||||||
|
CLOUDFLARE_PAGES_PROVIDER_ID,
|
||||||
|
cloudflarePagesProjectNameForProject,
|
||||||
DeployError,
|
DeployError,
|
||||||
|
deployToCloudflarePages,
|
||||||
deployToVercel,
|
deployToVercel,
|
||||||
|
isDeployProviderId,
|
||||||
prepareDeployPreflight,
|
prepareDeployPreflight,
|
||||||
publicDeployConfig,
|
publicDeployConfigForProvider,
|
||||||
readVercelConfig,
|
readDeployConfig,
|
||||||
VERCEL_PROVIDER_ID,
|
VERCEL_PROVIDER_ID,
|
||||||
writeVercelConfig,
|
writeDeployConfig,
|
||||||
} from './deploy.js';
|
} from './deploy.js';
|
||||||
|
|
||||||
/** @typedef {import('@open-design/contracts').ApiErrorCode} ApiErrorCode */
|
/** @typedef {import('@open-design/contracts').ApiErrorCode} ApiErrorCode */
|
||||||
|
|
@ -886,6 +890,46 @@ function sendApiError(res, status, code, message, init = {}) {
|
||||||
.json(createCompatApiErrorResponse(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.
|
// Filename slug for the Content-Disposition header on archive downloads.
|
||||||
// Browsers reject quotes and control bytes; we keep Unicode letters/digits
|
// Browsers reject quotes and control bytes; we keep Unicode letters/digits
|
||||||
// so a project name with non-ASCII characters (e.g. "café-design")
|
// 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 --------------------------------------------------------------
|
// ---- Deploy --------------------------------------------------------------
|
||||||
|
|
||||||
app.get('/api/deploy/config', async (_req, res) => {
|
app.get('/api/deploy/config', async (req, res) => {
|
||||||
try {
|
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} */
|
/** @type {import('@open-design/contracts').DeployConfigResponse} */
|
||||||
const body = publicDeployConfig(await readVercelConfig());
|
const body = publicDeployConfigForProvider(providerId, await readDeployConfig(providerId));
|
||||||
res.json(body);
|
res.json(body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendApiError(res, 500, 'INTERNAL_ERROR', String(err?.message || 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) => {
|
app.put('/api/deploy/config', async (req, res) => {
|
||||||
try {
|
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} */
|
/** @type {import('@open-design/contracts').DeployConfigResponse} */
|
||||||
const body = await writeVercelConfig(req.body || {});
|
const body = await writeDeployConfig(providerId, input);
|
||||||
res.json(body);
|
res.json(body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || 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) => {
|
app.post('/api/projects/:id/deploy', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { fileName, providerId = VERCEL_PROVIDER_ID } = req.body || {};
|
const { fileName, providerId = VERCEL_PROVIDER_ID } = req.body || {};
|
||||||
if (providerId !== VERCEL_PROVIDER_ID) {
|
if (!isDeployProviderId(providerId)) {
|
||||||
return sendApiError(
|
return sendApiError(
|
||||||
res,
|
res,
|
||||||
400,
|
400,
|
||||||
|
|
@ -2842,11 +2897,25 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
||||||
req.params.id,
|
req.params.id,
|
||||||
fileName,
|
fileName,
|
||||||
);
|
);
|
||||||
const result = await deployToVercel({
|
const project = getProject(db, req.params.id);
|
||||||
config: await readVercelConfig(),
|
const cloudflarePagesProjectName =
|
||||||
files,
|
providerId === CLOUDFLARE_PAGES_PROVIDER_ID
|
||||||
projectId: req.params.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();
|
const now = Date.now();
|
||||||
/** @type {import('@open-design/contracts').DeployProjectFileResponse} */
|
/** @type {import('@open-design/contracts').DeployProjectFileResponse} */
|
||||||
const body = upsertDeployment(db, {
|
const body = upsertDeployment(db, {
|
||||||
|
|
@ -2861,6 +2930,10 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
||||||
status: result.status,
|
status: result.status,
|
||||||
statusMessage: result.statusMessage,
|
statusMessage: result.statusMessage,
|
||||||
reachableAt: result.reachableAt,
|
reachableAt: result.reachableAt,
|
||||||
|
providerMetadata:
|
||||||
|
providerId === CLOUDFLARE_PAGES_PROVIDER_ID
|
||||||
|
? cloudflarePagesDeploymentMetadata(cloudflarePagesProjectName)
|
||||||
|
: prior?.providerMetadata,
|
||||||
createdAt: prior?.createdAt ?? now,
|
createdAt: prior?.createdAt ?? now,
|
||||||
updatedAt: 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) => {
|
app.post('/api/projects/:id/deploy/preflight', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { fileName, providerId = VERCEL_PROVIDER_ID } = req.body || {};
|
const { fileName, providerId = VERCEL_PROVIDER_ID } = req.body || {};
|
||||||
if (providerId !== VERCEL_PROVIDER_ID) {
|
if (!isDeployProviderId(providerId)) {
|
||||||
return sendApiError(
|
return sendApiError(
|
||||||
res,
|
res,
|
||||||
400,
|
400,
|
||||||
|
|
@ -2900,6 +2973,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
||||||
PROJECTS_DIR,
|
PROJECTS_DIR,
|
||||||
req.params.id,
|
req.params.id,
|
||||||
fileName,
|
fileName,
|
||||||
|
{ providerId },
|
||||||
);
|
);
|
||||||
res.json(body);
|
res.json(body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -2937,11 +3011,19 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
||||||
'deployment not found',
|
'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();
|
const now = Date.now();
|
||||||
/** @type {import('@open-design/contracts').CheckDeploymentLinkResponse} */
|
/** @type {import('@open-design/contracts').CheckDeploymentLinkResponse} */
|
||||||
const body = upsertDeployment(db, {
|
const body = upsertDeployment(db, {
|
||||||
...existing,
|
...existing,
|
||||||
|
url: checkUrl || existing.url,
|
||||||
status: result.reachable ? 'ready' : result.status || 'link-delayed',
|
status: result.reachable ? 'ready' : result.status || 'link-delayed',
|
||||||
statusMessage: result.reachable
|
statusMessage: result.reachable
|
||||||
? 'Public link is ready.'
|
? 'Public link is ready.'
|
||||||
|
|
|
||||||
308
apps/daemon/tests/deploy-routes.test.ts
Normal file
308
apps/daemon/tests/deploy-routes.test.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
analyzeDeployPlan,
|
analyzeDeployPlan,
|
||||||
buildDeployFilePlan,
|
buildDeployFilePlan,
|
||||||
buildDeployFileSet,
|
buildDeployFileSet,
|
||||||
checkDeploymentUrl,
|
checkDeploymentUrl,
|
||||||
|
chunkCloudflarePagesAssetUploads,
|
||||||
|
CLOUDFLARE_PAGES_ASSET_MAX_BYTES,
|
||||||
|
CLOUDFLARE_PAGES_PROVIDER_ID,
|
||||||
|
cloudflarePagesAssetHash,
|
||||||
|
cloudflarePagesProjectNameForProject,
|
||||||
DEPLOY_PREFLIGHT_LARGE_ASSET_BYTES,
|
DEPLOY_PREFLIGHT_LARGE_ASSET_BYTES,
|
||||||
DEPLOY_PREFLIGHT_LARGE_HTML_BYTES,
|
DEPLOY_PREFLIGHT_LARGE_HTML_BYTES,
|
||||||
deploymentUrlCandidates,
|
deploymentUrlCandidates,
|
||||||
|
deployToCloudflarePages,
|
||||||
|
deployConfigPath,
|
||||||
extractCssReferences,
|
extractCssReferences,
|
||||||
extractHtmlReferences,
|
extractHtmlReferences,
|
||||||
extractInlineCssReferences,
|
extractInlineCssReferences,
|
||||||
|
|
@ -20,10 +27,17 @@ import {
|
||||||
isVercelProtectedResponse,
|
isVercelProtectedResponse,
|
||||||
normalizeDeployHookScriptUrl,
|
normalizeDeployHookScriptUrl,
|
||||||
prepareDeployPreflight,
|
prepareDeployPreflight,
|
||||||
|
publicDeployConfig,
|
||||||
|
readVercelConfig,
|
||||||
resolveReferencedPath,
|
resolveReferencedPath,
|
||||||
rewriteCssReferences,
|
rewriteCssReferences,
|
||||||
rewriteEntryHtmlReferences,
|
rewriteEntryHtmlReferences,
|
||||||
|
SAVED_CLOUDFLARE_TOKEN_MASK,
|
||||||
|
SAVED_TOKEN_MASK,
|
||||||
|
VERCEL_PROVIDER_ID,
|
||||||
waitForReachableDeploymentUrl,
|
waitForReachableDeploymentUrl,
|
||||||
|
writeCloudflarePagesConfig,
|
||||||
|
writeVercelConfig,
|
||||||
} from '../src/deploy.js';
|
} from '../src/deploy.js';
|
||||||
import { ensureProject } from '../src/projects.js';
|
import { ensureProject } from '../src/projects.js';
|
||||||
|
|
||||||
|
|
@ -34,6 +48,143 @@ async function setupProject() {
|
||||||
return { projectsRoot: path.join(root, 'projects'), projectId, dir };
|
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', () => {
|
describe('deploy file set', () => {
|
||||||
it('deploys a single html file as index.html', async () => {
|
it('deploys a single html file as index.html', async () => {
|
||||||
const { projectsRoot, projectId, dir } = await setupProject();
|
const { projectsRoot, projectId, dir } = await setupProject();
|
||||||
|
|
@ -619,6 +770,16 @@ describe('deploy plan and analyzer', () => {
|
||||||
expect(codes).not.toContain('broken-reference');
|
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 () => {
|
it('preflight reports broken references instead of throwing', async () => {
|
||||||
const { projectsRoot, projectId, dir } = await setupProject();
|
const { projectsRoot, projectId, dir } = await setupProject();
|
||||||
await writeFile(
|
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', () => {
|
describe('deployment link readiness', () => {
|
||||||
async function withServer(
|
async function withServer(
|
||||||
handler: (req: IncomingMessage, res: ServerResponse) => void,
|
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 () => {
|
it('marks a Vercel authentication page as protected', async () => {
|
||||||
await withServer((_req, res) => {
|
await withServer((_req, res) => {
|
||||||
res.writeHead(401, {
|
res.writeHead(401, {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
fetchLiveArtifactCode,
|
fetchLiveArtifactCode,
|
||||||
fetchLiveArtifactRefreshes,
|
fetchLiveArtifactRefreshes,
|
||||||
checkDeploymentLink,
|
checkDeploymentLink,
|
||||||
|
CLOUDFLARE_PAGES_PROVIDER_ID,
|
||||||
|
DEFAULT_DEPLOY_PROVIDER_ID,
|
||||||
deployProjectFile,
|
deployProjectFile,
|
||||||
fetchDeployConfig,
|
fetchDeployConfig,
|
||||||
fetchProjectDeployments,
|
fetchProjectDeployments,
|
||||||
|
|
@ -19,6 +21,11 @@ import {
|
||||||
LiveArtifactRefreshError,
|
LiveArtifactRefreshError,
|
||||||
refreshLiveArtifact,
|
refreshLiveArtifact,
|
||||||
updateDeployConfig,
|
updateDeployConfig,
|
||||||
|
type WebDeployConfigResponse,
|
||||||
|
type WebDeploymentInfo,
|
||||||
|
type WebDeployProjectFileResponse,
|
||||||
|
type WebDeployProviderId,
|
||||||
|
type WebUpdateDeployConfigRequest,
|
||||||
writeProjectTextFile,
|
writeProjectTextFile,
|
||||||
} from '../providers/registry';
|
} from '../providers/registry';
|
||||||
import type { ProjectFilePreview } 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 { saveTemplate } from '../state/projects';
|
||||||
import type {
|
import type {
|
||||||
LiveArtifactEventItem,
|
LiveArtifactEventItem,
|
||||||
DeployConfigResponse,
|
|
||||||
DeployProjectFileResponse,
|
|
||||||
LiveArtifact,
|
LiveArtifact,
|
||||||
LiveArtifactRefreshLogEntry,
|
LiveArtifactRefreshLogEntry,
|
||||||
LiveArtifactViewerTab,
|
LiveArtifactViewerTab,
|
||||||
|
|
@ -75,6 +80,23 @@ type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) =>
|
||||||
type SlideState = { active: number; count: number };
|
type SlideState = { active: number; count: number };
|
||||||
type BoardTool = 'inspect' | 'pod';
|
type BoardTool = 'inspect' | 'pod';
|
||||||
type StrokePoint = { x: number; y: number };
|
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;
|
const MAX_BRIDGE_COORDINATE = 1_000_000;
|
||||||
|
|
||||||
// The five basic style facets the inspect panel exposes. Kept narrow on
|
// 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_BUTTON_CLASS = 'markdown-code-copy';
|
||||||
const MARKDOWN_COPY_TOAST_CLASS = 'markdown-code-toast';
|
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> {
|
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
@ -2753,18 +2806,21 @@ function HtmlViewer({
|
||||||
const [templateName, setTemplateName] = useState('');
|
const [templateName, setTemplateName] = useState('');
|
||||||
const [templateDescription, setTemplateDescription] = useState('');
|
const [templateDescription, setTemplateDescription] = useState('');
|
||||||
const [templateSaveError, setTemplateSaveError] = useState<string | null>(null);
|
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 [deployModalOpen, setDeployModalOpen] = useState(false);
|
||||||
const [deployConfig, setDeployConfig] = useState<DeployConfigResponse | null>(null);
|
const [deployConfig, setDeployConfig] = useState<WebDeployConfigResponse | null>(null);
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
const [deployPhase, setDeployPhase] = useState<'idle' | 'deploying' | 'preparing-link'>('idle');
|
const [deployPhase, setDeployPhase] = useState<'idle' | 'deploying' | 'preparing-link'>('idle');
|
||||||
const [savingDeployConfig, setSavingDeployConfig] = useState(false);
|
const [savingDeployConfig, setSavingDeployConfig] = useState(false);
|
||||||
const [deployError, setDeployError] = useState<string | null>(null);
|
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 [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 [teamId, setTeamId] = useState('');
|
||||||
const [teamSlug, setTeamSlug] = useState('');
|
const [teamSlug, setTeamSlug] = useState('');
|
||||||
|
const [cloudflareAccountId, setCloudflareAccountId] = useState('');
|
||||||
const [inTabPresent, setInTabPresent] = useState(false);
|
const [inTabPresent, setInTabPresent] = useState(false);
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
const [boardMode, setBoardMode] = useState(false);
|
const [boardMode, setBoardMode] = useState(false);
|
||||||
|
|
@ -2819,6 +2875,69 @@ function HtmlViewer({
|
||||||
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
||||||
const previewStateKey = `${projectId}:${file.name}`;
|
const previewStateKey = `${projectId}:${file.name}`;
|
||||||
const previewScale = zoom / 100;
|
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
|
// 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
|
// back to the host every time a slide settles. Host renders prev/next
|
||||||
// controls in the toolbar and reflects the count beside them.
|
// controls in the toolbar and reflects the count beside them.
|
||||||
|
|
@ -2856,16 +2975,16 @@ function HtmlViewer({
|
||||||
setDeployPhase('idle');
|
setDeployPhase('idle');
|
||||||
void fetchProjectDeployments(projectId).then((items) => {
|
void fetchProjectDeployments(projectId).then((items) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const current = items.find(
|
const nextDeploymentsByProvider = deploymentMapForCurrentFile(items);
|
||||||
(item) => item.fileName === file.name && item.providerId === 'vercel-self',
|
const current = nextDeploymentsByProvider[deployProviderId] ?? null;
|
||||||
);
|
setDeploymentsByProvider(nextDeploymentsByProvider);
|
||||||
setDeployment(current ?? null);
|
setDeployment(current ?? null);
|
||||||
setDeployResult(current ?? null);
|
setDeployResult(current ?? null);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [projectId, file.name]);
|
}, [projectId, file.name, deployProviderId]);
|
||||||
|
|
||||||
// Detect deck-shaped HTML even when the project's skill didn't declare
|
// Detect deck-shaped HTML even when the project's skill didn't declare
|
||||||
// `mode: deck`. Freeform projects often produce a deck because the user
|
// `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);
|
setShareMenuOpen(false);
|
||||||
setDeployModalOpen(true);
|
setDeployModalOpen(true);
|
||||||
setDeployError(null);
|
setDeployError(null);
|
||||||
setCopiedDeployLink(false);
|
setCopiedDeployLink(false);
|
||||||
setDeployPhase('idle');
|
setDeployPhase('idle');
|
||||||
const [config, deployments] = await Promise.all([
|
await loadDeployProvider(nextProviderId, { fallbackToExisting: true });
|
||||||
fetchDeployConfig(),
|
}
|
||||||
fetchProjectDeployments(projectId),
|
|
||||||
]);
|
async function changeDeployProvider(nextProviderId: WebDeployProviderId) {
|
||||||
if (config) {
|
if (nextProviderId === deployProviderId) return;
|
||||||
setDeployConfig(config);
|
setDeployError(null);
|
||||||
setVercelToken(config.tokenMask || '');
|
setDeployPhase('idle');
|
||||||
setTeamId(config.teamId || '');
|
await loadDeployProvider(nextProviderId);
|
||||||
setTeamSlug(config.teamSlug || '');
|
|
||||||
}
|
|
||||||
const current = deployments.find(
|
|
||||||
(item) => item.fileName === file.name && item.providerId === 'vercel-self',
|
|
||||||
);
|
|
||||||
setDeployment(current ?? null);
|
|
||||||
setDeployResult(current ?? null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDeployConfig() {
|
async function saveDeployConfig() {
|
||||||
setSavingDeployConfig(true);
|
setSavingDeployConfig(true);
|
||||||
setDeployError(null);
|
setDeployError(null);
|
||||||
try {
|
try {
|
||||||
const config = await updateDeployConfig({
|
if (deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID) {
|
||||||
token: vercelToken,
|
if (!deployToken.trim()) {
|
||||||
teamId,
|
throw new Error(t('fileViewer.cloudflareApiTokenRequired'));
|
||||||
teamSlug,
|
}
|
||||||
});
|
if (!cloudflareAccountId.trim()) {
|
||||||
if (!config) throw new Error(t('fileViewer.deployConfigSaveFailed'));
|
throw new Error(t('fileViewer.cloudflareAccountIdRequired'));
|
||||||
setDeployConfig(config);
|
}
|
||||||
setVercelToken(config.tokenMask || '');
|
}
|
||||||
setTeamId(config.teamId || '');
|
const config = await updateDeployConfig(buildDeployConfigRequest(deployProviderId));
|
||||||
setTeamSlug(config.teamSlug || '');
|
if (!config || config.providerId !== deployProviderId) {
|
||||||
|
throw new Error(t('fileViewer.deployProviderConfigSaveFailed', { provider: deployProviderLabel }));
|
||||||
|
}
|
||||||
|
syncDeployFormFromConfig(deployProviderId, config);
|
||||||
return config;
|
return config;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployConfigSaveFailed'));
|
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployProviderConfigSaveFailed', { provider: deployProviderLabel }));
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setSavingDeployConfig(false);
|
setSavingDeployConfig(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deployToVercel() {
|
async function deployToSelectedProvider() {
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
setDeployPhase('deploying');
|
setDeployPhase('deploying');
|
||||||
setDeployError(null);
|
setDeployError(null);
|
||||||
setCopiedDeployLink(false);
|
setCopiedDeployLink(false);
|
||||||
try {
|
try {
|
||||||
const typedToken = vercelToken.trim();
|
const typedToken = deployToken.trim();
|
||||||
const hasNewToken = typedToken && typedToken !== deployConfig?.tokenMask;
|
const hasNewToken = typedToken && typedToken !== deployConfig?.tokenMask;
|
||||||
const needsConfigSave =
|
const needsConfigSave =
|
||||||
hasNewToken ||
|
hasNewToken ||
|
||||||
teamId.trim() !== (deployConfig?.teamId || '') ||
|
teamId.trim() !== (deployConfig?.teamId || '') ||
|
||||||
teamSlug.trim() !== (deployConfig?.teamSlug || '') ||
|
teamSlug.trim() !== (deployConfig?.teamSlug || '') ||
|
||||||
|
cloudflareAccountId.trim() !== (deployConfig?.accountId || '') ||
|
||||||
!deployConfig?.configured;
|
!deployConfig?.configured;
|
||||||
if (needsConfigSave) {
|
if (needsConfigSave) {
|
||||||
const nextConfig = await saveDeployConfig();
|
const nextConfig = await saveDeployConfig();
|
||||||
|
if (!nextConfig) return;
|
||||||
if (!nextConfig?.configured) {
|
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');
|
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);
|
setDeployment(next);
|
||||||
setDeployResult(next);
|
setDeployResult(next);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setDeploying(false);
|
setDeploying(false);
|
||||||
setDeployPhase('idle');
|
setDeployPhase('idle');
|
||||||
|
|
@ -3628,6 +3753,10 @@ function HtmlViewer({
|
||||||
setDeployPhase('preparing-link');
|
setDeployPhase('preparing-link');
|
||||||
try {
|
try {
|
||||||
const next = await checkDeploymentLink(projectId, current.id);
|
const next = await checkDeploymentLink(projectId, current.id);
|
||||||
|
setDeploymentsByProvider((items) => ({
|
||||||
|
...items,
|
||||||
|
[next.providerId]: next,
|
||||||
|
}));
|
||||||
setDeployment(next);
|
setDeployment(next);
|
||||||
setDeployResult(next);
|
setDeployResult(next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -3746,9 +3875,34 @@ function HtmlViewer({
|
||||||
const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed';
|
const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed';
|
||||||
const activeDeploymentProtected = activeDeployment?.status === 'protected';
|
const activeDeploymentProtected = activeDeployment?.status === 'protected';
|
||||||
const activeDeploymentNeedsRetry = activeDeploymentDelayed || activeDeploymentProtected;
|
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
|
const copyDeployLabel = copiedDeployLink
|
||||||
? t('fileViewer.copied')
|
? t('fileViewer.copied')
|
||||||
: t('fileViewer.copyDeployLink');
|
: t('fileViewer.copyDeployLink');
|
||||||
|
const copyDeployMenuLabel = (providerLabel: string) =>
|
||||||
|
copiedDeployLink
|
||||||
|
? t('fileViewer.copied')
|
||||||
|
: `${t('fileViewer.copyDeployLink')} · ${providerLabel}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="viewer html-viewer">
|
<div className="viewer html-viewer">
|
||||||
|
|
@ -4082,36 +4236,38 @@ function HtmlViewer({
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="share-menu-divider" />
|
<div className="share-menu-divider" />
|
||||||
<button
|
{DEPLOY_PROVIDER_OPTIONS.map((option) => (
|
||||||
type="button"
|
<button
|
||||||
className="share-menu-item"
|
key={option.id}
|
||||||
role="menuitem"
|
type="button"
|
||||||
onClick={() => {
|
className="share-menu-item"
|
||||||
void openDeployModal();
|
role="menuitem"
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
void openDeployModal(option.id);
|
||||||
<span className="share-menu-icon"><Icon name="upload" size={14} /></span>
|
}}
|
||||||
<span>
|
>
|
||||||
{activeDeployedUrl
|
<span className="share-menu-icon"><Icon name="upload" size={14} /></span>
|
||||||
? t('fileViewer.redeployToVercel')
|
<span>{deployActionLabelFor(option.id)}</span>
|
||||||
: t('fileViewer.deployToVercel')}
|
</button>
|
||||||
</span>
|
))}
|
||||||
</button>
|
{deployCopyLinks.length > 0 ? (
|
||||||
<button
|
<div className="share-menu-divider" />
|
||||||
type="button"
|
) : null}
|
||||||
className="share-menu-item"
|
{deployCopyLinks.map((item) => (
|
||||||
role="menuitem"
|
<button
|
||||||
disabled={!activeDeployedUrl}
|
key={`copy-${item.providerId}`}
|
||||||
onClick={() => {
|
type="button"
|
||||||
setShareMenuOpen(false);
|
className="share-menu-item"
|
||||||
void copyDeployLink(activeDeployedUrl);
|
role="menuitem"
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
setShareMenuOpen(false);
|
||||||
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
|
void copyDeployLink(item.url);
|
||||||
<span>
|
}}
|
||||||
{copyDeployLabel}
|
>
|
||||||
</span>
|
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
|
||||||
</button>
|
<span>{copyDeployMenuLabel(item.providerLabel)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4364,27 +4520,42 @@ function HtmlViewer({
|
||||||
<div className="modal-backdrop" role="presentation">
|
<div className="modal-backdrop" role="presentation">
|
||||||
<div className="modal deploy-modal" role="dialog" aria-modal="true">
|
<div className="modal deploy-modal" role="dialog" aria-modal="true">
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<div className="kicker">VERCEL</div>
|
<div className="kicker">{deployProviderLabel}</div>
|
||||||
<h2>{t('fileViewer.deployModalTitle')}</h2>
|
<h2>{t('fileViewer.deployToProvider', { provider: deployProviderLabel })}</h2>
|
||||||
<p className="subtitle">{t('fileViewer.deployModalSubtitle')}</p>
|
<p className="subtitle">{t('fileViewer.deployModalSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="deploy-form">
|
<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">
|
<div className="field-label-row">
|
||||||
<label htmlFor="vercel-token">{t('fileViewer.vercelToken')}</label>
|
<label htmlFor="deploy-token">{t(deployProvider.tokenLabelKey)}</label>
|
||||||
<a
|
<a
|
||||||
href="https://vercel.com/account/settings/tokens"
|
href={deployProvider.tokenLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
{t('fileViewer.vercelTokenGetLink')}
|
{t(deployProvider.tokenLinkKey)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="vercel-token"
|
id="deploy-token"
|
||||||
type="password"
|
type="password"
|
||||||
value={vercelToken}
|
value={deployToken}
|
||||||
placeholder={t('fileViewer.vercelTokenPlaceholder')}
|
placeholder={t(deployProvider.tokenPlaceholderKey, { provider: deployProviderLabel })}
|
||||||
onChange={(e) => setVercelToken(e.target.value)}
|
onChange={(e) => setDeployToken(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="deploy-config-actions">
|
<div className="deploy-config-actions">
|
||||||
<button
|
<button
|
||||||
|
|
@ -4399,27 +4570,43 @@ function HtmlViewer({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{deployConfig?.configured ? (
|
{deployConfig?.configured ? (
|
||||||
<p className="hint">{t('fileViewer.vercelTokenReuseHint')}</p>
|
<p className="hint">{t(deployProvider.tokenReuseHintKey, { provider: deployProviderLabel })}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="deploy-field-grid">
|
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
|
||||||
<label>
|
<p className="hint">{t('fileViewer.cloudflareApiTokenScopeHint')}</p>
|
||||||
<span>{t('fileViewer.vercelTeamId')}</span>
|
) : null}
|
||||||
<input
|
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
|
||||||
value={teamId}
|
<div className="deploy-field-grid single-field">
|
||||||
placeholder={t('fileViewer.optional')}
|
<label>
|
||||||
onChange={(e) => setTeamId(e.target.value)}
|
<span>{t('fileViewer.cloudflareAccountId')}</span>
|
||||||
/>
|
<input
|
||||||
</label>
|
value={cloudflareAccountId}
|
||||||
<label>
|
onChange={(e) => setCloudflareAccountId(e.target.value)}
|
||||||
<span>{t('fileViewer.vercelTeamSlug')}</span>
|
/>
|
||||||
<input
|
<span className="field-hint">{t('fileViewer.cloudflareAccountIdHint')}</span>
|
||||||
value={teamSlug}
|
</label>
|
||||||
placeholder={t('fileViewer.optional')}
|
</div>
|
||||||
onChange={(e) => setTeamSlug(e.target.value)}
|
) : (
|
||||||
/>
|
<div className="deploy-field-grid">
|
||||||
</label>
|
<label>
|
||||||
</div>
|
<span>{t('fileViewer.vercelTeamId')}</span>
|
||||||
<p className="hint">{t('fileViewer.vercelPreviewOnly')}</p>
|
<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}
|
{deployError ? <p className="deploy-error">{deployError}</p> : null}
|
||||||
{activeDeployedUrl ? (
|
{activeDeployedUrl ? (
|
||||||
<div
|
<div
|
||||||
|
|
@ -4468,16 +4655,16 @@ function HtmlViewer({
|
||||||
>
|
>
|
||||||
<Icon name="copy" size={14} />
|
<Icon name="copy" size={14} />
|
||||||
<span>{copyDeployLabel}</span>
|
<span>{copyDeployLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
className={`ghost-link ${activeDeploymentReady ? '' : 'disabled'}`}
|
className={`ghost-link ${activeDeploymentProtected ? 'disabled' : ''}`}
|
||||||
href={activeDeploymentReady ? activeDeployedUrl : undefined}
|
href={activeDeploymentProtected ? undefined : activeDeployedUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
aria-disabled={!activeDeploymentReady}
|
aria-disabled={activeDeploymentProtected}
|
||||||
>
|
>
|
||||||
<Icon name="upload" size={14} />
|
<Icon name="upload" size={14} />
|
||||||
{t('fileViewer.open')}
|
{t('fileViewer.open')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4496,14 +4683,10 @@ function HtmlViewer({
|
||||||
className="viewer-action primary"
|
className="viewer-action primary"
|
||||||
disabled={deploying || savingDeployConfig || deployPhase !== 'idle'}
|
disabled={deploying || savingDeployConfig || deployPhase !== 'idle'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void deployToVercel();
|
void deployToSelectedProvider();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{deployPhase === 'deploying'
|
{deployButtonLabel}
|
||||||
? t('fileViewer.deployingToVercel')
|
|
||||||
: deployPhase === 'preparing-link'
|
|
||||||
? t('fileViewer.preparingPublicLink')
|
|
||||||
: t('fileViewer.deployToVercel')}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -692,34 +692,48 @@ export const ar: Dict = {
|
||||||
'fileViewer.templateNameDefault': 'قالب بدون عنوان',
|
'fileViewer.templateNameDefault': 'قالب بدون عنوان',
|
||||||
'fileViewer.templateDescPrompt':
|
'fileViewer.templateDescPrompt':
|
||||||
'وصف قصير (اختياري - ما الذي يجعل هذا القالب مفيداً؟)',
|
'وصف قصير (اختياري - ما الذي يجعل هذا القالب مفيداً؟)',
|
||||||
'fileViewer.deployToVercel': 'نشر على Vercel',
|
'fileViewer.deployToVercel': 'النشر إلى Vercel',
|
||||||
'fileViewer.redeployToVercel': 'إعادة النشر',
|
'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.preparingPublicLink': 'جاري تحضير الرابط العام...',
|
||||||
'fileViewer.copyDeployLink': 'نسخ الرابط',
|
'fileViewer.copyDeployLink': 'نسخ الرابط',
|
||||||
'fileViewer.deployModalTitle': 'نشر على Vercel',
|
'fileViewer.deployModalTitle': 'النشر',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': 'استخدم حساب منصة النشر المحددة لنشر معاينة HTML هذه.',
|
||||||
'انشر هذا الـ HTML كمعاينة Vercel باستخدام حسابك الخاص.',
|
|
||||||
'fileViewer.vercelToken': 'رمز Vercel',
|
'fileViewer.vercelToken': 'رمز Vercel',
|
||||||
'fileViewer.vercelTokenGetLink': 'احصل على رمز Vercel',
|
'fileViewer.vercelTokenGetLink': 'احصل على رمز Vercel',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'الصق رمز Vercel الخاص بك',
|
'fileViewer.vercelTokenPlaceholder': 'الصق رمز Vercel الخاص بك',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': 'سيتم استخدام الرمز المحفوظ. أدخل رمزاً جديداً لاستبداله.',
|
||||||
'سيتم استخدام الرمز المحفوظ. أدخل رمزاً جديداً لاستبداله.',
|
|
||||||
'fileViewer.vercelTokenRequired': 'أدخل واحفظ رمز Vercel أولاً.',
|
'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.vercelTeamId': 'معرف الفريق',
|
||||||
'fileViewer.vercelTeamSlug': 'اسم الفريق اللطيف',
|
'fileViewer.vercelTeamSlug': 'اسم الفريق اللطيف',
|
||||||
|
'fileViewer.cloudflareAccountId': 'معرف الحساب',
|
||||||
|
'fileViewer.cloudflareAccountIdHint': 'مطلوب. اعثر على معرف الحساب في لوحة Cloudflare.',
|
||||||
|
'fileViewer.cloudflareAccountIdRequired': 'أدخل واحفظ Cloudflare Account ID أولاً.',
|
||||||
'fileViewer.optional': 'اختياري',
|
'fileViewer.optional': 'اختياري',
|
||||||
'fileViewer.vercelPreviewOnly': 'النشر للمعاينة فقط حالياً.',
|
'fileViewer.vercelPreviewOnly': 'النشر للمعاينة فقط حالياً.',
|
||||||
|
'fileViewer.cloudflarePagesPreviewHint': 'تستخدم Cloudflare Pages أسلوب Direct Upload.',
|
||||||
'fileViewer.savingConfig': 'جاري الحفظ...',
|
'fileViewer.savingConfig': 'جاري الحفظ...',
|
||||||
'fileViewer.deployConfigSaveFailed': 'تعذر حفظ إعدادات Vercel.',
|
'fileViewer.deployConfigSaveFailed': 'تعذر حفظ إعدادات Vercel.',
|
||||||
'fileViewer.deployFailed': 'فشل النشر. تحقق من إعدادات Vercel وحاول مرة أخرى.',
|
'fileViewer.deployFailed': 'فشل النشر. تحقق من إعدادات Vercel وحاول مرة أخرى.',
|
||||||
|
'fileViewer.deployProviderConfigSaveFailed': 'تعذر حفظ إعدادات {provider}.',
|
||||||
|
'fileViewer.deployProviderFailed': 'فشل النشر إلى {provider}. تحقق من الإعدادات وحاول مرة أخرى.',
|
||||||
'fileViewer.deployResultLabel': 'رابط النشر',
|
'fileViewer.deployResultLabel': 'رابط النشر',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'الرابط العام معلق',
|
'fileViewer.deployLinkPreparingLabel': 'الرابط العام معلق',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'تم نشر الموقع، لكن الرابط العام ما زال قيد التحضير.',
|
||||||
'تم نشر موقعك. Vercel لا يزال يحضر الرابط العام.',
|
'fileViewer.deployLinkProtectedLabel': 'حماية النشر مفعلة',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'حماية Vercel مفعلة',
|
'fileViewer.deployLinkProtected': 'تم نشر الموقع، لكن رابط المعاينة هذا يتطلب المصادقة. عطّل Deployment Protection أو استخدم نطاقاً مخصصاً.',
|
||||||
'fileViewer.deployLinkProtected':
|
|
||||||
'تم نشر موقعك، لكن Vercel يتطلب المصادقة لهذا الرابط. عطل حماية النشر أو استخدم دومين مخصص.',
|
|
||||||
'fileViewer.retryLink': 'إعادة المحاولة الآن',
|
'fileViewer.retryLink': 'إعادة المحاولة الآن',
|
||||||
|
|
||||||
'questionForm.submit': 'إرسال',
|
'questionForm.submit': 'إرسال',
|
||||||
|
|
|
||||||
|
|
@ -648,32 +648,46 @@ export const de: Dict = {
|
||||||
'Kurze Beschreibung (optional — was macht dieses Template nützlich?)',
|
'Kurze Beschreibung (optional — was macht dieses Template nützlich?)',
|
||||||
'fileViewer.deployToVercel': 'Auf Vercel deployen',
|
'fileViewer.deployToVercel': 'Auf Vercel deployen',
|
||||||
'fileViewer.redeployToVercel': 'Erneut 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.preparingPublicLink': 'Öffentlicher Link wird vorbereitet…',
|
||||||
'fileViewer.copyDeployLink': 'Link kopieren',
|
'fileViewer.copyDeployLink': 'Link kopieren',
|
||||||
'fileViewer.deployModalTitle': 'Auf Vercel deployen',
|
'fileViewer.deployModalTitle': 'Bereitstellen',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': 'Verwende das Konto des gewählten Anbieters, um diese HTML-Vorschau zu deployen.',
|
||||||
'Deployen Sie dieses HTML-Artifact als Vercel Preview mit Ihrem eigenen Konto.',
|
|
||||||
'fileViewer.vercelToken': 'Vercel Token',
|
'fileViewer.vercelToken': 'Vercel Token',
|
||||||
'fileViewer.vercelTokenGetLink': 'Vercel Token abrufen',
|
'fileViewer.vercelTokenGetLink': 'Vercel Token abrufen',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Vercel Token einfügen',
|
'fileViewer.vercelTokenPlaceholder': 'Vercel Token einfügen',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': 'Gespeicherter Token wird verwendet. Gib einen neuen Token ein, um ihn zu ersetzen.',
|
||||||
'Gespeicherter Token wird verwendet. Geben Sie einen neuen Token ein, um ihn zu ersetzen.',
|
'fileViewer.vercelTokenRequired': 'Gib zuerst einen Vercel Token ein und speichere ihn.',
|
||||||
'fileViewer.vercelTokenRequired': 'Geben und speichern Sie zuerst einen Vercel Token.',
|
'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.vercelTeamId': 'Team-ID',
|
||||||
'fileViewer.vercelTeamSlug': 'Team-Slug',
|
'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.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.savingConfig': 'Speichern…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Vercel-Einstellungen konnten nicht gespeichert werden.',
|
'fileViewer.deployConfigSaveFailed': 'Vercel-Einstellungen konnten nicht gespeichert werden.',
|
||||||
'fileViewer.deployFailed': 'Deployment fehlgeschlagen. Prüfen Sie die Vercel-Einstellungen und versuchen Sie es erneut.',
|
'fileViewer.deployFailed': 'Deployment fehlgeschlagen. Prüfe die Vercel-Einstellungen und versuche es erneut.',
|
||||||
'fileViewer.deployResultLabel': 'Bereitgestellte URL',
|
'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.deployLinkPreparingLabel': 'Öffentlicher Link ausstehend',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'Die Seite wurde deployt. Der Anbieter bereitet den öffentlichen Link noch vor.',
|
||||||
'Ihre Site ist deployed. Vercel bereitet den öffentlichen Link noch vor.',
|
'fileViewer.deployLinkProtectedLabel': 'Deployment-Schutz aktiviert',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Vercel Protection aktiviert',
|
'fileViewer.deployLinkProtected': 'Die Seite wurde deployt, aber dieser Vorschau-Link erfordert eine Anmeldung. Deaktiviere Deployment Protection oder nutze eine eigene Domain.',
|
||||||
'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.retryLink': 'Jetzt erneut versuchen',
|
'fileViewer.retryLink': 'Jetzt erneut versuchen',
|
||||||
|
|
||||||
'questionForm.submit': 'Absenden',
|
'questionForm.submit': 'Absenden',
|
||||||
|
|
|
||||||
|
|
@ -726,31 +726,45 @@ export const en: Dict = {
|
||||||
'fileViewer.deployToVercel': 'Deploy to Vercel',
|
'fileViewer.deployToVercel': 'Deploy to Vercel',
|
||||||
'fileViewer.redeployToVercel': 'Redeploy',
|
'fileViewer.redeployToVercel': 'Redeploy',
|
||||||
'fileViewer.deployingToVercel': 'Deploying to Vercel…',
|
'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.preparingPublicLink': 'Preparing public link…',
|
||||||
'fileViewer.copyDeployLink': 'Copy link',
|
'fileViewer.copyDeployLink': 'Copy link',
|
||||||
'fileViewer.deployModalTitle': 'Deploy to Vercel',
|
'fileViewer.deployModalTitle': 'Deploy',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': 'Use the selected provider account to deploy this HTML preview.',
|
||||||
'Deploy this HTML artifact as a Vercel Preview using your own account.',
|
|
||||||
'fileViewer.vercelToken': 'Vercel token',
|
'fileViewer.vercelToken': 'Vercel token',
|
||||||
'fileViewer.vercelTokenGetLink': 'Get Vercel token',
|
'fileViewer.vercelTokenGetLink': 'Get Vercel token',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Paste your Vercel token',
|
'fileViewer.vercelTokenPlaceholder': 'Paste your Vercel token',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': 'Saved token will be used. Enter a new token to replace it.',
|
||||||
'Saved token will be used. Enter a new token to replace it.',
|
|
||||||
'fileViewer.vercelTokenRequired': 'Enter and save a Vercel token first.',
|
'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.vercelTeamId': 'Team ID',
|
||||||
'fileViewer.vercelTeamSlug': 'Team slug',
|
'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.optional': 'Optional',
|
||||||
'fileViewer.vercelPreviewOnly': 'Deploys are Preview-only for now.',
|
'fileViewer.vercelPreviewOnly': 'Deploys are Preview-only for now.',
|
||||||
|
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages deploys use Direct Upload.',
|
||||||
'fileViewer.savingConfig': 'Saving…',
|
'fileViewer.savingConfig': 'Saving…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Could not save Vercel settings.',
|
'fileViewer.deployConfigSaveFailed': 'Could not save Vercel settings.',
|
||||||
'fileViewer.deployFailed': 'Deploy failed. Check Vercel settings and try again.',
|
'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.deployResultLabel': 'Deployed URL',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'Public link pending',
|
'fileViewer.deployLinkPreparingLabel': 'Public link pending',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'Your site is deployed. The public link is still being prepared.',
|
||||||
'Your site is deployed. Vercel is still preparing the public link.',
|
'fileViewer.deployLinkProtectedLabel': 'Deployment protection enabled',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Vercel protection enabled',
|
'fileViewer.deployLinkProtected': 'Your site deployed, but this preview link is requiring authentication. Disable Deployment Protection or use a custom domain.',
|
||||||
'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.retryLink': 'Retry now',
|
||||||
|
|
||||||
'questionForm.submit': 'Submit',
|
'questionForm.submit': 'Submit',
|
||||||
|
|
|
||||||
|
|
@ -650,31 +650,45 @@ export const esES: Dict = {
|
||||||
'fileViewer.deployToVercel': 'Desplegar en Vercel',
|
'fileViewer.deployToVercel': 'Desplegar en Vercel',
|
||||||
'fileViewer.redeployToVercel': 'Volver a desplegar',
|
'fileViewer.redeployToVercel': 'Volver a desplegar',
|
||||||
'fileViewer.deployingToVercel': 'Desplegando en Vercel…',
|
'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.preparingPublicLink': 'Preparando enlace público…',
|
||||||
'fileViewer.copyDeployLink': 'Copiar enlace',
|
'fileViewer.copyDeployLink': 'Copiar enlace',
|
||||||
'fileViewer.deployModalTitle': 'Desplegar en Vercel',
|
'fileViewer.deployModalTitle': 'Desplegar',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': 'Usa la cuenta del proveedor seleccionado para desplegar esta vista previa HTML.',
|
||||||
'Despliega este artefacto HTML como Preview de Vercel usando tu propia cuenta.',
|
|
||||||
'fileViewer.vercelToken': 'Token de Vercel',
|
'fileViewer.vercelToken': 'Token de Vercel',
|
||||||
'fileViewer.vercelTokenGetLink': 'Obtener token de Vercel',
|
'fileViewer.vercelTokenGetLink': 'Obtener token de Vercel',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Pega tu token de Vercel',
|
'fileViewer.vercelTokenPlaceholder': 'Pega tu token de Vercel',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': 'Se usará el token guardado. Introduce uno nuevo para sustituirlo.',
|
||||||
'Se usará el token guardado. Introduce uno nuevo para reemplazarlo.',
|
|
||||||
'fileViewer.vercelTokenRequired': 'Introduce y guarda primero un token de Vercel.',
|
'fileViewer.vercelTokenRequired': 'Introduce y guarda primero un token de Vercel.',
|
||||||
'fileViewer.vercelTeamId': 'Team ID',
|
'fileViewer.cloudflareApiToken': 'Token de API de Cloudflare',
|
||||||
'fileViewer.vercelTeamSlug': 'Team slug',
|
'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.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.savingConfig': 'Guardando…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'No se pudieron guardar los ajustes de Vercel.',
|
'fileViewer.deployConfigSaveFailed': 'No se pudo guardar la configuración de Vercel.',
|
||||||
'fileViewer.deployFailed': 'El despliegue falló. Revisa los ajustes de Vercel e inténtalo de nuevo.',
|
'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.deployResultLabel': 'URL desplegada',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'Enlace público pendiente',
|
'fileViewer.deployLinkPreparingLabel': 'Enlace público pendiente',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'El sitio se ha desplegado. El proveedor aún está preparando el enlace público.',
|
||||||
'Tu sitio está desplegado. Vercel todavía está preparando el enlace público.',
|
'fileViewer.deployLinkProtectedLabel': 'Protección del despliegue activada',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Protección de Vercel 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.deployLinkProtected':
|
|
||||||
'Tu sitio se desplegó, pero Vercel exige autenticación para este enlace de preview. Desactiva Deployment Protection o usa un dominio personalizado.',
|
|
||||||
'fileViewer.retryLink': 'Reintentar ahora',
|
'fileViewer.retryLink': 'Reintentar ahora',
|
||||||
|
|
||||||
'questionForm.submit': 'Enviar',
|
'questionForm.submit': 'Enviar',
|
||||||
|
|
|
||||||
|
|
@ -814,35 +814,49 @@ export const fa: Dict = {
|
||||||
'qf.cardRefs': 'مراجع:',
|
'qf.cardRefs': 'مراجع:',
|
||||||
'qf.cardSampleText': 'روباه قهوهای سریع · ۰۱۲۳',
|
'qf.cardSampleText': 'روباه قهوهای سریع · ۰۱۲۳',
|
||||||
|
|
||||||
'fileViewer.deployToVercel': 'Deploy to Vercel',
|
'fileViewer.deployToVercel': 'استقرار روی Vercel',
|
||||||
'fileViewer.redeployToVercel': 'Redeploy',
|
'fileViewer.redeployToVercel': 'استقرار دوباره',
|
||||||
'fileViewer.deployingToVercel': 'Deploying to Vercel…',
|
'fileViewer.deployingToVercel': 'در حال استقرار روی Vercel…',
|
||||||
'fileViewer.preparingPublicLink': 'Preparing public link…',
|
'fileViewer.deployProviderLabel': 'ارائهدهنده استقرار',
|
||||||
'fileViewer.copyDeployLink': 'Copy link',
|
'fileViewer.vercelProvider': 'Vercel',
|
||||||
'fileViewer.deployModalTitle': 'Deploy to Vercel',
|
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployToProvider': 'استقرار روی {provider}',
|
||||||
'Deploy this HTML artifact as a Vercel Preview using your own account.',
|
'fileViewer.redeployToProvider': 'استقرار دوباره روی {provider}',
|
||||||
'fileViewer.vercelToken': 'Vercel token',
|
'fileViewer.deployingToProvider': 'در حال استقرار روی {provider}…',
|
||||||
'fileViewer.vercelTokenGetLink': 'Get Vercel token',
|
'fileViewer.preparingPublicLink': 'در حال آمادهسازی لینک عمومی…',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Paste your Vercel token',
|
'fileViewer.copyDeployLink': 'کپی لینک',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.deployModalTitle': 'استقرار',
|
||||||
'Saved token will be used. Enter a new token to replace it.',
|
'fileViewer.deployModalSubtitle': 'از حساب ارائهدهنده انتخابشده برای استقرار این پیشنمایش HTML استفاده کنید.',
|
||||||
'fileViewer.vercelTokenRequired': 'Enter and save a Vercel token first.',
|
'fileViewer.vercelToken': 'توکن Vercel',
|
||||||
'fileViewer.vercelTeamId': 'Team ID',
|
'fileViewer.vercelTokenGetLink': 'دریافت توکن Vercel',
|
||||||
'fileViewer.vercelTeamSlug': 'Team slug',
|
'fileViewer.vercelTokenPlaceholder': 'توکن Vercel خود را وارد کنید',
|
||||||
'fileViewer.optional': 'Optional',
|
'fileViewer.vercelTokenReuseHint': 'از توکن ذخیرهشده استفاده میشود. برای جایگزینی، توکن جدید وارد کنید.',
|
||||||
'fileViewer.vercelPreviewOnly': 'Deploys are Preview-only for now.',
|
'fileViewer.vercelTokenRequired': 'ابتدا یک توکن Vercel وارد و ذخیره کنید.',
|
||||||
'fileViewer.savingConfig': 'Saving…',
|
'fileViewer.cloudflareApiToken': 'توکن API کلادفلر',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Could not save Vercel settings.',
|
'fileViewer.cloudflareApiTokenGetLink': 'دریافت توکن API کلادفلر',
|
||||||
'fileViewer.deployFailed': 'Deploy failed. Check Vercel settings and try again.',
|
'fileViewer.cloudflareApiTokenPlaceholder': 'توکن API کلادفلر خود را وارد کنید',
|
||||||
'fileViewer.deployResultLabel': 'Deployed URL',
|
'fileViewer.cloudflareApiTokenReuseHint': 'از توکن API کلادفلر ذخیرهشده استفاده میشود. برای جایگزینی، توکن جدید وارد کنید.',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'Public link pending',
|
'fileViewer.cloudflareApiTokenRequired': 'ابتدا یک توکن API کلادفلر وارد و ذخیره کنید.',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.cloudflareApiTokenScopeHint': 'توکن به مجوز Account: Cloudflare Pages: Edit و دسترسی خواندن حساب نیاز دارد.',
|
||||||
'Your site is deployed. Vercel is still preparing the public link.',
|
'fileViewer.vercelTeamId': 'شناسه تیم',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Vercel protection enabled',
|
'fileViewer.vercelTeamSlug': 'اسلاگ تیم',
|
||||||
'fileViewer.deployLinkProtected':
|
'fileViewer.cloudflareAccountId': 'شناسه حساب',
|
||||||
'Your site deployed, but Vercel is requiring authentication for this preview link. Disable Deployment Protection or use a custom domain.',
|
'fileViewer.cloudflareAccountIdHint': 'ضروری است. شناسه حساب را در داشبورد Cloudflare پیدا کنید.',
|
||||||
'fileViewer.retryLink': 'Retry now',
|
'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.toolSelect': 'انتخاب (غیرفعال)',
|
||||||
'sketch.toolPen': 'قلم',
|
'sketch.toolPen': 'قلم',
|
||||||
|
|
|
||||||
|
|
@ -695,31 +695,45 @@ export const fr: Dict = {
|
||||||
'fileViewer.deployToVercel': 'Déployer sur Vercel',
|
'fileViewer.deployToVercel': 'Déployer sur Vercel',
|
||||||
'fileViewer.redeployToVercel': 'Redéployer',
|
'fileViewer.redeployToVercel': 'Redéployer',
|
||||||
'fileViewer.deployingToVercel': 'Déploiement sur Vercel…',
|
'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.preparingPublicLink': 'Préparation du lien public…',
|
||||||
'fileViewer.copyDeployLink': 'Copier le lien',
|
'fileViewer.copyDeployLink': 'Copier le lien',
|
||||||
'fileViewer.deployModalTitle': 'Déployer sur Vercel',
|
'fileViewer.deployModalTitle': 'Déployer',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': 'Utilisez le compte du fournisseur sélectionné pour déployer cet aperçu HTML.',
|
||||||
'Déployez cet artefact HTML en tant que Vercel Preview avec votre propre compte.',
|
|
||||||
'fileViewer.vercelToken': 'Jeton Vercel',
|
'fileViewer.vercelToken': 'Jeton Vercel',
|
||||||
'fileViewer.vercelTokenGetLink': 'Obtenir un jeton Vercel',
|
'fileViewer.vercelTokenGetLink': 'Obtenir un jeton Vercel',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Collez votre jeton Vercel',
|
'fileViewer.vercelTokenPlaceholder': 'Collez votre jeton Vercel',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': 'Le jeton enregistré sera utilisé. Saisissez un nouveau jeton pour le remplacer.',
|
||||||
'Le jeton enregistré sera utilisé. Entrez un nouveau jeton pour le remplacer.',
|
'fileViewer.vercelTokenRequired': 'Saisissez et enregistrez d’abord un jeton Vercel.',
|
||||||
'fileViewer.vercelTokenRequired': 'Entrez et enregistrez d\'abord un jeton Vercel.',
|
'fileViewer.cloudflareApiToken': 'Jeton API Cloudflare',
|
||||||
'fileViewer.vercelTeamId': 'ID d\'équipe',
|
'fileViewer.cloudflareApiTokenGetLink': 'Obtenir un jeton API Cloudflare',
|
||||||
'fileViewer.vercelTeamSlug': 'Slug d\'équipe',
|
'fileViewer.cloudflareApiTokenPlaceholder': 'Collez votre jeton API Cloudflare',
|
||||||
'fileViewer.optional': 'Optionnel',
|
'fileViewer.cloudflareApiTokenReuseHint': 'Le jeton API Cloudflare enregistré sera utilisé. Saisissez un nouveau jeton pour le remplacer.',
|
||||||
'fileViewer.vercelPreviewOnly': 'Les déploiements sont en Preview uniquement pour l\'instant.',
|
'fileViewer.cloudflareApiTokenRequired': 'Saisissez et enregistrez d’abord un jeton API Cloudflare.',
|
||||||
|
'fileViewer.cloudflareApiTokenScopeHint': 'Le jeton doit avoir Account: Cloudflare Pages: Edit ainsi qu’un 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 l’ID du compte dans le tableau de bord Cloudflare.',
|
||||||
|
'fileViewer.cloudflareAccountIdRequired': 'Saisissez et enregistrez d’abord 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.savingConfig': 'Enregistrement…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Impossible d\'enregistrer les paramètres Vercel.',
|
'fileViewer.deployConfigSaveFailed': 'Impossible d’enregistrer les réglages Vercel.',
|
||||||
'fileViewer.deployFailed': 'Le déploiement a échoué. Vérifiez les paramètres Vercel et réessayez.',
|
'fileViewer.deployFailed': 'Échec du déploiement. Vérifiez les réglages Vercel et réessayez.',
|
||||||
|
'fileViewer.deployProviderConfigSaveFailed': 'Impossible d’enregistrer 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.deployResultLabel': 'URL déployée',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'Lien public en attente',
|
'fileViewer.deployLinkPreparingLabel': 'Lien public en attente',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'Le site est déployé. Le fournisseur prépare encore le lien public.',
|
||||||
'Votre site est déployé. Vercel prépare encore le lien public.',
|
'fileViewer.deployLinkProtectedLabel': 'Protection du déploiement activée',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Protection Vercel 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.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.retryLink': 'Réessayer maintenant',
|
'fileViewer.retryLink': 'Réessayer maintenant',
|
||||||
|
|
||||||
'questionForm.submit': 'Envoyer',
|
'questionForm.submit': 'Envoyer',
|
||||||
|
|
|
||||||
|
|
@ -695,31 +695,45 @@ export const hu: Dict = {
|
||||||
'fileViewer.deployToVercel': 'Telepítés Vercelre',
|
'fileViewer.deployToVercel': 'Telepítés Vercelre',
|
||||||
'fileViewer.redeployToVercel': 'Újratelepítés',
|
'fileViewer.redeployToVercel': 'Újratelepítés',
|
||||||
'fileViewer.deployingToVercel': 'Telepítés Vercelre…',
|
'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.preparingPublicLink': 'Nyilvános link előkészítése…',
|
||||||
'fileViewer.copyDeployLink': 'Link másolása',
|
'fileViewer.copyDeployLink': 'Link másolása',
|
||||||
'fileViewer.deployModalTitle': 'Telepítés Vercelre',
|
'fileViewer.deployModalTitle': 'Telepítés',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': 'A kiválasztott szolgáltató fiókjával telepítheted ezt a HTML-előnézetet.',
|
||||||
'Telepítsd ezt a HTML-artefaktumot Vercel Preview-ként a saját fiókodból.',
|
|
||||||
'fileViewer.vercelToken': 'Vercel token',
|
'fileViewer.vercelToken': 'Vercel token',
|
||||||
'fileViewer.vercelTokenGetLink': 'Vercel token kérése',
|
'fileViewer.vercelTokenGetLink': 'Vercel token kérése',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Illeszd be a Vercel tokenedet',
|
'fileViewer.vercelTokenPlaceholder': 'Illeszd be a Vercel tokenedet',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': 'A mentett tokent használjuk. Adj meg újat a cseréhez.',
|
||||||
'A mentett tokent használjuk. Adj meg újat a cseréhez.',
|
|
||||||
'fileViewer.vercelTokenRequired': 'Előbb adj meg és ments el egy Vercel tokent.',
|
'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.vercelTeamId': 'Team ID',
|
||||||
'fileViewer.vercelTeamSlug': 'Team slug',
|
'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.optional': 'Opcionális',
|
||||||
'fileViewer.vercelPreviewOnly': 'A telepítések egyelőre csak Preview-k.',
|
'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.savingConfig': 'Mentés…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'A Vercel beállítások nem menthetők.',
|
'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.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.deployResultLabel': 'Telepített URL',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'Nyilvános link várólistán',
|
'fileViewer.deployLinkPreparingLabel': 'Nyilvános link várólistán',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'A webhely telepítve van. A szolgáltató még készíti a nyilvános linket.',
|
||||||
'Az oldal telepítve. A Vercel még készíti a nyilvános linket.',
|
'fileViewer.deployLinkProtectedLabel': 'Telepítési védelem bekapcsolva',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Vercel-védelem aktív',
|
'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.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.retryLink': 'Újra most',
|
'fileViewer.retryLink': 'Újra most',
|
||||||
|
|
||||||
'questionForm.submit': 'Beküldés',
|
'questionForm.submit': 'Beküldés',
|
||||||
|
|
|
||||||
|
|
@ -648,31 +648,45 @@ export const ja: Dict = {
|
||||||
'fileViewer.deployToVercel': 'Vercel にデプロイ',
|
'fileViewer.deployToVercel': 'Vercel にデプロイ',
|
||||||
'fileViewer.redeployToVercel': '再デプロイ',
|
'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.preparingPublicLink': '公開リンクを準備中…',
|
||||||
'fileViewer.copyDeployLink': 'リンクをコピー',
|
'fileViewer.copyDeployLink': 'リンクをコピー',
|
||||||
'fileViewer.deployModalTitle': 'Vercel にデプロイ',
|
'fileViewer.deployModalTitle': 'デプロイ',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': '選択したプロバイダーのアカウントを使用して、この HTML プレビューをデプロイします。',
|
||||||
'この HTML アーティファクトをご自身のアカウントを使用して Vercel Preview としてデプロイします。',
|
|
||||||
'fileViewer.vercelToken': 'Vercel トークン',
|
'fileViewer.vercelToken': 'Vercel トークン',
|
||||||
'fileViewer.vercelTokenGetLink': 'Vercel トークンを取得',
|
'fileViewer.vercelTokenGetLink': 'Vercel トークンを取得',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Vercel トークンを貼り付け',
|
'fileViewer.vercelTokenPlaceholder': 'Vercel トークンを貼り付け',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': '保存済みトークンが使用されます。新しいトークンを入力すると置き換えられます。',
|
||||||
'保存済みトークンが使用されます。新しいトークンを入力すると置き換えられます。',
|
|
||||||
'fileViewer.vercelTokenRequired': '最初に Vercel トークンを入力して保存してください。',
|
'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.vercelTeamId': 'チーム ID',
|
||||||
'fileViewer.vercelTeamSlug': 'チームスラッグ',
|
'fileViewer.vercelTeamSlug': 'チームスラッグ',
|
||||||
|
'fileViewer.cloudflareAccountId': 'アカウント ID',
|
||||||
|
'fileViewer.cloudflareAccountIdHint': '必須です。Cloudflare ダッシュボードでアカウント ID を確認できます。',
|
||||||
|
'fileViewer.cloudflareAccountIdRequired': '最初に Cloudflare Account ID を入力して保存してください。',
|
||||||
'fileViewer.optional': '省略可',
|
'fileViewer.optional': '省略可',
|
||||||
'fileViewer.vercelPreviewOnly': 'デプロイは現在 Preview のみです。',
|
'fileViewer.vercelPreviewOnly': 'デプロイは現在 Preview のみです。',
|
||||||
|
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages は Direct Upload を使用します。',
|
||||||
'fileViewer.savingConfig': '保存中…',
|
'fileViewer.savingConfig': '保存中…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Vercel の設定を保存できませんでした。',
|
'fileViewer.deployConfigSaveFailed': 'Vercel の設定を保存できませんでした。',
|
||||||
'fileViewer.deployFailed': 'デプロイに失敗しました。Vercel の設定を確認して再試行してください。',
|
'fileViewer.deployFailed': 'デプロイに失敗しました。Vercel の設定を確認して再試行してください。',
|
||||||
|
'fileViewer.deployProviderConfigSaveFailed': '{provider} の設定を保存できませんでした。',
|
||||||
|
'fileViewer.deployProviderFailed': '{provider} のデプロイに失敗しました。設定を確認して再試行してください。',
|
||||||
'fileViewer.deployResultLabel': 'デプロイ URL',
|
'fileViewer.deployResultLabel': 'デプロイ URL',
|
||||||
'fileViewer.deployLinkPreparingLabel': '公開リンク準備中',
|
'fileViewer.deployLinkPreparingLabel': '公開リンク準備中',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'サイトはデプロイされました。プロバイダーが公開リンクを準備中です。',
|
||||||
'サイトはデプロイされました。Vercel が公開リンクを準備中です。',
|
'fileViewer.deployLinkProtectedLabel': 'デプロイ保護が有効',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Vercel の保護が有効',
|
'fileViewer.deployLinkProtected': 'サイトはデプロイされましたが、このプレビューリンクには認証が必要です。Deployment Protection を無効にするか、カスタムドメインを使用してください。',
|
||||||
'fileViewer.deployLinkProtected':
|
|
||||||
'サイトはデプロイされましたが、Vercel がこのプレビューリンクに認証を要求しています。デプロイ保護を無効にするかカスタムドメインを使用してください。',
|
|
||||||
'fileViewer.retryLink': '今すぐ再試行',
|
'fileViewer.retryLink': '今すぐ再試行',
|
||||||
|
|
||||||
'questionForm.submit': '送信',
|
'questionForm.submit': '送信',
|
||||||
|
|
|
||||||
|
|
@ -695,31 +695,45 @@ export const ko: Dict = {
|
||||||
'fileViewer.deployToVercel': 'Vercel에 배포',
|
'fileViewer.deployToVercel': 'Vercel에 배포',
|
||||||
'fileViewer.redeployToVercel': '다시 배포',
|
'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.preparingPublicLink': '공개 링크 준비 중…',
|
||||||
'fileViewer.copyDeployLink': '링크 복사',
|
'fileViewer.copyDeployLink': '링크 복사',
|
||||||
'fileViewer.deployModalTitle': 'Vercel에 배포',
|
'fileViewer.deployModalTitle': '배포',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': '선택한 플랫폼 계정으로 이 HTML 미리보기를 배포합니다.',
|
||||||
'사용자 개인 계정을 사용하여 이 HTML 결과물을 Vercel Preview로 배포합니다.',
|
|
||||||
'fileViewer.vercelToken': 'Vercel 토큰',
|
'fileViewer.vercelToken': 'Vercel 토큰',
|
||||||
'fileViewer.vercelTokenGetLink': 'Vercel 토큰 발급 받기',
|
'fileViewer.vercelTokenGetLink': 'Vercel 토큰 발급 받기',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Vercel 토큰을 붙여넣으세요',
|
'fileViewer.vercelTokenPlaceholder': 'Vercel 토큰을 붙여넣으세요',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': '저장된 토큰이 사용됩니다. 변경하려면 새 토큰을 입력하세요.',
|
||||||
'저장된 토큰이 사용됩니다. 변경하려면 새 토큰을 입력하세요.',
|
|
||||||
'fileViewer.vercelTokenRequired': '먼저 Vercel 토큰을 입력하고 저장하세요.',
|
'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.vercelTeamId': '팀 ID (Team ID)',
|
||||||
'fileViewer.vercelTeamSlug': '팀 슬러그 (Team slug)',
|
'fileViewer.vercelTeamSlug': '팀 슬러그 (Team slug)',
|
||||||
|
'fileViewer.cloudflareAccountId': '계정 ID',
|
||||||
|
'fileViewer.cloudflareAccountIdHint': '필수입니다. Cloudflare 대시보드에서 계정 ID를 확인하세요.',
|
||||||
|
'fileViewer.cloudflareAccountIdRequired': '먼저 Cloudflare Account ID를 입력하고 저장하세요.',
|
||||||
'fileViewer.optional': '선택 사항',
|
'fileViewer.optional': '선택 사항',
|
||||||
'fileViewer.vercelPreviewOnly': '현재 배포는 Preview 모드만 지원합니다.',
|
'fileViewer.vercelPreviewOnly': '현재 배포는 Preview 모드만 지원합니다.',
|
||||||
|
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages 배포는 Direct Upload를 사용합니다.',
|
||||||
'fileViewer.savingConfig': '설정 저장 중…',
|
'fileViewer.savingConfig': '설정 저장 중…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Vercel 설정을 저장하지 못했습니다.',
|
'fileViewer.deployConfigSaveFailed': 'Vercel 설정을 저장하지 못했습니다.',
|
||||||
'fileViewer.deployFailed': '배포 실패. Vercel 설정을 확인하고 다시 시도해 주세요.',
|
'fileViewer.deployFailed': '배포 실패. Vercel 설정을 확인하고 다시 시도해 주세요.',
|
||||||
|
'fileViewer.deployProviderConfigSaveFailed': '{provider} 설정을 저장하지 못했습니다.',
|
||||||
|
'fileViewer.deployProviderFailed': '{provider} 배포에 실패했습니다. 설정을 확인하고 다시 시도해 주세요.',
|
||||||
'fileViewer.deployResultLabel': '배포된 URL',
|
'fileViewer.deployResultLabel': '배포된 URL',
|
||||||
'fileViewer.deployLinkPreparingLabel': '공개 링크 보류 중',
|
'fileViewer.deployLinkPreparingLabel': '공개 링크 보류 중',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': '사이트가 배포되었습니다. 플랫폼에서 공개 링크를 아직 준비 중입니다.',
|
||||||
'사이트 배포는 완료되었으나, Vercel 측에서 공개 링크를 준비 중입니다.',
|
'fileViewer.deployLinkProtectedLabel': '배포 보호가 활성화됨',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Vercel 보호 설정 됨',
|
'fileViewer.deployLinkProtected': '사이트가 배포되었지만 이 미리보기 링크에는 인증이 필요합니다. Deployment Protection을 끄거나 사용자 지정 도메인을 사용하세요.',
|
||||||
'fileViewer.deployLinkProtected':
|
|
||||||
'배포는 완료되었지만, Vercel 계정 설정에 의해 이 링크가 보호되어 있습니다. Vercel Deployment Protection을 비활성화하거나 커스텀 도메인을 사용하세요.',
|
|
||||||
'fileViewer.retryLink': '지금 다시 시도',
|
'fileViewer.retryLink': '지금 다시 시도',
|
||||||
|
|
||||||
'questionForm.submit': '제출',
|
'questionForm.submit': '제출',
|
||||||
|
|
|
||||||
|
|
@ -695,31 +695,45 @@ export const pl: Dict = {
|
||||||
'fileViewer.deployToVercel': 'Wdróż na Vercel',
|
'fileViewer.deployToVercel': 'Wdróż na Vercel',
|
||||||
'fileViewer.redeployToVercel': 'Wdróż ponownie',
|
'fileViewer.redeployToVercel': 'Wdróż ponownie',
|
||||||
'fileViewer.deployingToVercel': 'Wdrażanie na Vercel…',
|
'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.preparingPublicLink': 'Przygotowywanie publicznego linku…',
|
||||||
'fileViewer.copyDeployLink': 'Kopiuj link',
|
'fileViewer.copyDeployLink': 'Kopiuj link',
|
||||||
'fileViewer.deployModalTitle': 'Wdróż na Vercel',
|
'fileViewer.deployModalTitle': 'Wdróż',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': 'Użyj konta wybranego dostawcy, aby wdrożyć ten podgląd HTML.',
|
||||||
'Wdróż ten artefakt HTML jako podgląd Vercel (Preview) przy użyciu własnego konta.',
|
|
||||||
'fileViewer.vercelToken': 'Token Vercel',
|
'fileViewer.vercelToken': 'Token Vercel',
|
||||||
'fileViewer.vercelTokenGetLink': 'Pobierz token Vercel',
|
'fileViewer.vercelTokenGetLink': 'Pobierz token Vercel',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Wklej swój token Vercel',
|
'fileViewer.vercelTokenPlaceholder': 'Wklej swój token Vercel',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': 'Zapisany token zostanie użyty. Wprowadź nowy, aby go zastąpić.',
|
||||||
'Zapisany token zostanie użyty. Wprowadź nowy, aby go zastąpić.',
|
|
||||||
'fileViewer.vercelTokenRequired': 'Najpierw wprowadź i zapisz token Vercel.',
|
'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.vercelTeamId': 'ID zespołu',
|
||||||
'fileViewer.vercelTeamSlug': 'Slug 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.optional': 'Opcjonalnie',
|
||||||
'fileViewer.vercelPreviewOnly': 'Wdrożenia są obecnie dostępne tylko jako Podgląd (Preview).',
|
'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.savingConfig': 'Zapisywanie…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Nie udało się zapisać ustawień Vercel.',
|
'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.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.deployResultLabel': 'Wdrożony URL',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'Oczekiwanie na link publiczny',
|
'fileViewer.deployLinkPreparingLabel': 'Oczekiwanie na link publiczny',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'Strona została wdrożona. Dostawca wciąż przygotowuje publiczny link.',
|
||||||
'Strona została wdrożona. Vercel wciąż przygotowuje link publiczny.',
|
'fileViewer.deployLinkProtectedLabel': 'Ochrona wdrożenia włączona',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Ochrona Vercel 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.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.retryLink': 'Ponów teraz',
|
'fileViewer.retryLink': 'Ponów teraz',
|
||||||
|
|
||||||
'questionForm.submit': 'Wyślij',
|
'questionForm.submit': 'Wyślij',
|
||||||
|
|
|
||||||
|
|
@ -722,35 +722,49 @@ export const ptBR: Dict = {
|
||||||
'liveArtifact.refresh.statusReady': 'Pronto para atualizar',
|
'liveArtifact.refresh.statusReady': 'Pronto para atualizar',
|
||||||
'liveArtifact.refresh.statusSucceeded': 'Atualizado',
|
'liveArtifact.refresh.statusSucceeded': 'Atualizado',
|
||||||
'liveArtifact.refresh.statusFailed': 'Atualização falhou',
|
'liveArtifact.refresh.statusFailed': 'Atualização falhou',
|
||||||
'fileViewer.deployToVercel': 'Deploy to Vercel',
|
'fileViewer.deployToVercel': 'Implantar na Vercel',
|
||||||
'fileViewer.redeployToVercel': 'Redeploy',
|
'fileViewer.redeployToVercel': 'Implantar novamente',
|
||||||
'fileViewer.deployingToVercel': 'Deploying to Vercel…',
|
'fileViewer.deployingToVercel': 'Implantando na Vercel…',
|
||||||
'fileViewer.preparingPublicLink': 'Preparing public link…',
|
'fileViewer.deployProviderLabel': 'Provedor',
|
||||||
'fileViewer.copyDeployLink': 'Copy link',
|
'fileViewer.vercelProvider': 'Vercel',
|
||||||
'fileViewer.deployModalTitle': 'Deploy to Vercel',
|
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployToProvider': 'Implantar em {provider}',
|
||||||
'Deploy this HTML artifact as a Vercel Preview using your own account.',
|
'fileViewer.redeployToProvider': 'Implantar novamente em {provider}',
|
||||||
'fileViewer.vercelToken': 'Vercel token',
|
'fileViewer.deployingToProvider': 'Implantando em {provider}…',
|
||||||
'fileViewer.vercelTokenGetLink': 'Get Vercel token',
|
'fileViewer.preparingPublicLink': 'Preparando link público…',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Paste your Vercel token',
|
'fileViewer.copyDeployLink': 'Copiar link',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.deployModalTitle': 'Implantar',
|
||||||
'Saved token will be used. Enter a new token to replace it.',
|
'fileViewer.deployModalSubtitle': 'Use a conta do provedor selecionado para implantar esta prévia HTML.',
|
||||||
'fileViewer.vercelTokenRequired': 'Enter and save a Vercel token first.',
|
'fileViewer.vercelToken': 'Token da Vercel',
|
||||||
'fileViewer.vercelTeamId': 'Team ID',
|
'fileViewer.vercelTokenGetLink': 'Obter token da Vercel',
|
||||||
'fileViewer.vercelTeamSlug': 'Team slug',
|
'fileViewer.vercelTokenPlaceholder': 'Cole seu token da Vercel',
|
||||||
'fileViewer.optional': 'Optional',
|
'fileViewer.vercelTokenReuseHint': 'O token salvo será usado. Insira um novo token para substituí-lo.',
|
||||||
'fileViewer.vercelPreviewOnly': 'Deploys are Preview-only for now.',
|
'fileViewer.vercelTokenRequired': 'Insira e salve primeiro um token da Vercel.',
|
||||||
'fileViewer.savingConfig': 'Saving…',
|
'fileViewer.cloudflareApiToken': 'Token de API da Cloudflare',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Could not save Vercel settings.',
|
'fileViewer.cloudflareApiTokenGetLink': 'Obter token de API da Cloudflare',
|
||||||
'fileViewer.deployFailed': 'Deploy failed. Check Vercel settings and try again.',
|
'fileViewer.cloudflareApiTokenPlaceholder': 'Cole seu token de API da Cloudflare',
|
||||||
'fileViewer.deployResultLabel': 'Deployed URL',
|
'fileViewer.cloudflareApiTokenReuseHint': 'O token de API da Cloudflare salvo será usado. Insira um novo token para substituí-lo.',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'Public link pending',
|
'fileViewer.cloudflareApiTokenRequired': 'Insira e salve primeiro um token de API da Cloudflare.',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.cloudflareApiTokenScopeHint': 'O token precisa de Account: Cloudflare Pages: Edit e acesso de leitura à conta.',
|
||||||
'Your site is deployed. Vercel is still preparing the public link.',
|
'fileViewer.vercelTeamId': 'ID da equipe',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Vercel protection enabled',
|
'fileViewer.vercelTeamSlug': 'Slug da equipe',
|
||||||
'fileViewer.deployLinkProtected':
|
'fileViewer.cloudflareAccountId': 'ID da conta',
|
||||||
'Your site deployed, but Vercel is requiring authentication for this preview link. Disable Deployment Protection or use a custom domain.',
|
'fileViewer.cloudflareAccountIdHint': 'Obrigatório. Encontre o ID da conta no painel da Cloudflare.',
|
||||||
'fileViewer.retryLink': 'Retry now',
|
'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.submit': 'Enviar',
|
||||||
'questionForm.skip': 'Pular',
|
'questionForm.skip': 'Pular',
|
||||||
|
|
|
||||||
|
|
@ -723,33 +723,47 @@ export const ru: Dict = {
|
||||||
'liveArtifact.refresh.statusSucceeded': 'Актуально',
|
'liveArtifact.refresh.statusSucceeded': 'Актуально',
|
||||||
'liveArtifact.refresh.statusFailed': 'Обновление не удалось',
|
'liveArtifact.refresh.statusFailed': 'Обновление не удалось',
|
||||||
'fileViewer.deployToVercel': 'Развернуть на Vercel',
|
'fileViewer.deployToVercel': 'Развернуть на Vercel',
|
||||||
'fileViewer.redeployToVercel': 'Развернуть заново',
|
'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.preparingPublicLink': 'Подготовка публичной ссылки…',
|
||||||
'fileViewer.copyDeployLink': 'Скопировать ссылку',
|
'fileViewer.copyDeployLink': 'Скопировать ссылку',
|
||||||
'fileViewer.deployModalTitle': 'Развернуть на Vercel',
|
'fileViewer.deployModalTitle': 'Развернуть',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': 'Используйте аккаунт выбранного провайдера, чтобы развернуть этот HTML-просмотр.',
|
||||||
'Разверните этот HTML-артефакт как Vercel Preview в своей учётной записи.',
|
|
||||||
'fileViewer.vercelToken': 'Токен Vercel',
|
'fileViewer.vercelToken': 'Токен Vercel',
|
||||||
'fileViewer.vercelTokenGetLink': 'Получить токен Vercel',
|
'fileViewer.vercelTokenGetLink': 'Получить токен Vercel',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Вставьте токен Vercel',
|
'fileViewer.vercelTokenPlaceholder': 'Вставьте токен Vercel',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': 'Будет использован сохранённый токен. Введите новый, чтобы заменить его.',
|
||||||
'Будет использован сохранённый токен. Введите новый, чтобы заменить его.',
|
|
||||||
'fileViewer.vercelTokenRequired': 'Сначала введите и сохраните токен Vercel.',
|
'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.vercelTeamId': 'ID команды',
|
||||||
'fileViewer.vercelTeamSlug': 'Слаг команды',
|
'fileViewer.vercelTeamSlug': 'Слаг команды',
|
||||||
|
'fileViewer.cloudflareAccountId': 'ID аккаунта',
|
||||||
|
'fileViewer.cloudflareAccountIdHint': 'Обязательно. ID аккаунта можно найти в панели Cloudflare.',
|
||||||
|
'fileViewer.cloudflareAccountIdRequired': 'Сначала введите и сохраните Cloudflare Account ID.',
|
||||||
'fileViewer.optional': 'Необязательно',
|
'fileViewer.optional': 'Необязательно',
|
||||||
'fileViewer.vercelPreviewOnly': 'Пока поддерживаются только Preview-развёртывания.',
|
'fileViewer.vercelPreviewOnly': 'Пока поддерживаются только Preview-развёртывания.',
|
||||||
|
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages использует Direct Upload.',
|
||||||
'fileViewer.savingConfig': 'Сохранение…',
|
'fileViewer.savingConfig': 'Сохранение…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Не удалось сохранить настройки Vercel.',
|
'fileViewer.deployConfigSaveFailed': 'Не удалось сохранить настройки Vercel.',
|
||||||
'fileViewer.deployFailed': 'Развёртывание не удалось. Проверьте настройки Vercel и попробуйте снова.',
|
'fileViewer.deployFailed': 'Развёртывание не удалось. Проверьте настройки Vercel и попробуйте снова.',
|
||||||
|
'fileViewer.deployProviderConfigSaveFailed': 'Не удалось сохранить настройки {provider}.',
|
||||||
|
'fileViewer.deployProviderFailed': 'Развёртывание на {provider} не удалось. Проверьте настройки и попробуйте снова.',
|
||||||
'fileViewer.deployResultLabel': 'URL развёрнутого сайта',
|
'fileViewer.deployResultLabel': 'URL развёрнутого сайта',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'Публичная ссылка готовится',
|
'fileViewer.deployLinkPreparingLabel': 'Публичная ссылка готовится',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'Сайт развёрнут. Провайдер всё ещё готовит публичную ссылку.',
|
||||||
'Сайт уже развернут. Vercel ещё подготавливает публичную ссылку.',
|
'fileViewer.deployLinkProtectedLabel': 'Защита развёртывания включена',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Включена защита Vercel',
|
'fileViewer.deployLinkProtected': 'Сайт развёрнут, но эта ссылка предпросмотра требует аутентификации. Отключите Deployment Protection или используйте собственный домен.',
|
||||||
'fileViewer.deployLinkProtected':
|
|
||||||
'Сайт развернут, но Vercel требует аутентификацию для этой preview-ссылки. Отключите Deployment Protection или используйте собственный домен.',
|
|
||||||
'fileViewer.retryLink': 'Повторить',
|
'fileViewer.retryLink': 'Повторить',
|
||||||
|
|
||||||
'questionForm.submit': 'Отправить',
|
'questionForm.submit': 'Отправить',
|
||||||
|
|
|
||||||
|
|
@ -686,31 +686,45 @@ export const tr: Dict = {
|
||||||
'fileViewer.deployToVercel': 'Vercel’e yayınla',
|
'fileViewer.deployToVercel': 'Vercel’e yayınla',
|
||||||
'fileViewer.redeployToVercel': 'Yeniden yayınla',
|
'fileViewer.redeployToVercel': 'Yeniden yayınla',
|
||||||
'fileViewer.deployingToVercel': 'Vercel’e yayınlanıyor…',
|
'fileViewer.deployingToVercel': 'Vercel’e 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.preparingPublicLink': 'Herkese açık bağlantı hazırlanıyor…',
|
||||||
'fileViewer.copyDeployLink': 'Bağlantıyı kopyala',
|
'fileViewer.copyDeployLink': 'Bağlantıyı kopyala',
|
||||||
'fileViewer.deployModalTitle': 'Vercel’e yayınla',
|
'fileViewer.deployModalTitle': 'Yayınla',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': 'Bu HTML önizlemesini seçilen sağlayıcı hesabıyla yayınlayın.',
|
||||||
'Kendi hesabınızı kullanarak bu HTML eserini bir Vercel Önizlemesi olarak yayınlayın.',
|
|
||||||
'fileViewer.vercelToken': 'Vercel tokeni',
|
'fileViewer.vercelToken': 'Vercel tokeni',
|
||||||
'fileViewer.vercelTokenGetLink': 'Vercel tokenini al',
|
'fileViewer.vercelTokenGetLink': 'Vercel tokenini al',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Vercel tokeninizi yapıştırın',
|
'fileViewer.vercelTokenPlaceholder': 'Vercel tokeninizi yapıştırın',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': 'Kaydedilmiş token kullanılacak. Değiştirmek için yeni bir token girin.',
|
||||||
'Kaydedilmiş token kullanılacak. Değiştirmek için yeni bir token girin.',
|
|
||||||
'fileViewer.vercelTokenRequired': 'Önce bir Vercel tokeni girin ve kaydedin.',
|
'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.vercelTeamId': 'Takım ID',
|
||||||
'fileViewer.vercelTeamSlug': 'Takım slug’ı',
|
'fileViewer.vercelTeamSlug': 'Takım slug’ı',
|
||||||
|
'fileViewer.cloudflareAccountId': 'Hesap ID',
|
||||||
|
'fileViewer.cloudflareAccountIdHint': 'Zorunlu. Hesap ID’sini Cloudflare panosunda bulabilirsiniz.',
|
||||||
|
'fileViewer.cloudflareAccountIdRequired': 'Önce Cloudflare Account ID girin ve kaydedin.',
|
||||||
'fileViewer.optional': 'Opsiyonel',
|
'fileViewer.optional': 'Opsiyonel',
|
||||||
'fileViewer.vercelPreviewOnly': 'Yayınlanmış içerikler şimdilik yalnızca önizlenebilir.',
|
'fileViewer.vercelPreviewOnly': 'Yayınlanmış içerikler şimdilik yalnızca önizlenebilir.',
|
||||||
|
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages yayınları Direct Upload kullanır.',
|
||||||
'fileViewer.savingConfig': 'Kaydediliyor…',
|
'fileViewer.savingConfig': 'Kaydediliyor…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Vercel ayarları kaydedilemedi.',
|
'fileViewer.deployConfigSaveFailed': 'Vercel ayarları kaydedilemedi.',
|
||||||
'fileViewer.deployFailed': 'Yayınlama başarısız oldu. Vercel ayarlarınızı kontrol edin ve yeniden deneyin.',
|
'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.deployResultLabel': 'Yayınlanmış URL',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'Herkese açık link bekleniyor',
|
'fileViewer.deployLinkPreparingLabel': 'Herkese açık link bekleniyor',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'Site yayınlandı. Sağlayıcı herkese açık bağlantıyı hâlâ hazırlıyor.',
|
||||||
'Siteniz yayınlandı. Vercel hala herkese açık linki hazırlıyor.',
|
'fileViewer.deployLinkProtectedLabel': 'Yayın koruması etkin',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Vercel koruması açıldı',
|
'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.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.retryLink': 'Şimdi yeniden dene',
|
'fileViewer.retryLink': 'Şimdi yeniden dene',
|
||||||
|
|
||||||
'questionForm.submit': 'Gönder',
|
'questionForm.submit': 'Gönder',
|
||||||
|
|
|
||||||
|
|
@ -724,33 +724,47 @@ export const uk: Dict = {
|
||||||
'fileViewer.templateDescPrompt':
|
'fileViewer.templateDescPrompt':
|
||||||
'Короткий опис (необов\'язково — що робить цей шаблон корисним?)',
|
'Короткий опис (необов\'язково — що робить цей шаблон корисним?)',
|
||||||
'fileViewer.deployToVercel': 'Розгорнути на Vercel',
|
'fileViewer.deployToVercel': 'Розгорнути на Vercel',
|
||||||
'fileViewer.redeployToVercel': 'Повторно розгорнути',
|
'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.preparingPublicLink': 'Підготовка публічного посилання…',
|
||||||
'fileViewer.copyDeployLink': 'Копіювати посилання',
|
'fileViewer.copyDeployLink': 'Копіювати посилання',
|
||||||
'fileViewer.deployModalTitle': 'Розгорнути на Vercel',
|
'fileViewer.deployModalTitle': 'Розгорнути',
|
||||||
'fileViewer.deployModalSubtitle':
|
'fileViewer.deployModalSubtitle': 'Використайте акаунт вибраного провайдера, щоб розгорнути цей HTML-перегляд.',
|
||||||
'Розгорніть цей HTML артефакт як Vercel Preview за допомогою свого облікового запису.',
|
|
||||||
'fileViewer.vercelToken': 'Токен Vercel',
|
'fileViewer.vercelToken': 'Токен Vercel',
|
||||||
'fileViewer.vercelTokenGetLink': 'Отримати токен Vercel',
|
'fileViewer.vercelTokenGetLink': 'Отримати токен Vercel',
|
||||||
'fileViewer.vercelTokenPlaceholder': 'Вставте свій токен Vercel',
|
'fileViewer.vercelTokenPlaceholder': 'Вставте свій токен Vercel',
|
||||||
'fileViewer.vercelTokenReuseHint':
|
'fileViewer.vercelTokenReuseHint': 'Збережений токен буде використаний. Введіть новий токен, щоб замінити його.',
|
||||||
'Збережений токен буде використаний. Введіть новий токен, щоб замінити його.',
|
|
||||||
'fileViewer.vercelTokenRequired': 'Спочатку введіть та збережіть токен Vercel.',
|
'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.vercelTeamId': 'ID команди',
|
||||||
'fileViewer.vercelTeamSlug': 'Слаг команди',
|
'fileViewer.vercelTeamSlug': 'Слаг команди',
|
||||||
'fileViewer.optional': 'Необов\'язково',
|
'fileViewer.cloudflareAccountId': 'ID акаунта',
|
||||||
|
'fileViewer.cloudflareAccountIdHint': 'Обов’язково. ID акаунта можна знайти в панелі Cloudflare.',
|
||||||
|
'fileViewer.cloudflareAccountIdRequired': 'Спочатку введіть і збережіть Cloudflare Account ID.',
|
||||||
|
'fileViewer.optional': 'Необов’язково',
|
||||||
'fileViewer.vercelPreviewOnly': 'Розгортання наразі лише для попереднього перегляду.',
|
'fileViewer.vercelPreviewOnly': 'Розгортання наразі лише для попереднього перегляду.',
|
||||||
|
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages використовує Direct Upload.',
|
||||||
'fileViewer.savingConfig': 'Збереження…',
|
'fileViewer.savingConfig': 'Збереження…',
|
||||||
'fileViewer.deployConfigSaveFailed': 'Не вдалося зберегти налаштування Vercel.',
|
'fileViewer.deployConfigSaveFailed': 'Не вдалося зберегти налаштування Vercel.',
|
||||||
'fileViewer.deployFailed': 'Розгортання не вдалося. Перевірте налаштування Vercel та спробуйте ще раз.',
|
'fileViewer.deployFailed': 'Розгортання не вдалося. Перевірте налаштування Vercel та спробуйте ще раз.',
|
||||||
|
'fileViewer.deployProviderConfigSaveFailed': 'Не вдалося зберегти налаштування {provider}.',
|
||||||
|
'fileViewer.deployProviderFailed': 'Розгортання на {provider} не вдалося. Перевірте налаштування й спробуйте ще раз.',
|
||||||
'fileViewer.deployResultLabel': 'URL розгортання',
|
'fileViewer.deployResultLabel': 'URL розгортання',
|
||||||
'fileViewer.deployLinkPreparingLabel': 'Публічне посилання очікує',
|
'fileViewer.deployLinkPreparingLabel': 'Публічне посилання очікує',
|
||||||
'fileViewer.deployLinkDelayed':
|
'fileViewer.deployLinkDelayed': 'Сайт розгорнуто. Провайдер ще готує публічне посилання.',
|
||||||
'Ваш сайт розгорнуто. Vercel все ще готує публічне посилання.',
|
'fileViewer.deployLinkProtectedLabel': 'Захист розгортання ввімкнено',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Увімкнено захист Vercel',
|
'fileViewer.deployLinkProtected': 'Сайт розгорнуто, але це посилання попереднього перегляду вимагає автентифікації. Вимкніть Deployment Protection або використайте власний домен.',
|
||||||
'fileViewer.deployLinkProtected':
|
|
||||||
'Ваш сайт розгорнуто, але Vercel вимагає автентифікацію для цього посилання попереднього перегляду. Вимкніть захист розгортання або використовуйте користувацький домен.',
|
|
||||||
'fileViewer.retryLink': 'Повторити зараз',
|
'fileViewer.retryLink': 'Повторити зараз',
|
||||||
|
|
||||||
'questionForm.submit': 'Надіслати',
|
'questionForm.submit': 'Надіслати',
|
||||||
|
|
|
||||||
|
|
@ -712,28 +712,45 @@ export const zhCN: Dict = {
|
||||||
'fileViewer.deployToVercel': '部署到 Vercel',
|
'fileViewer.deployToVercel': '部署到 Vercel',
|
||||||
'fileViewer.redeployToVercel': '重新部署',
|
'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.preparingPublicLink': '正在准备公开链接…',
|
||||||
'fileViewer.copyDeployLink': '复制链接',
|
'fileViewer.copyDeployLink': '复制链接',
|
||||||
'fileViewer.deployModalTitle': '部署到 Vercel',
|
'fileViewer.deployModalTitle': '部署',
|
||||||
'fileViewer.deployModalSubtitle': '使用你自己的 Vercel 账号部署此 HTML 作品预览。',
|
'fileViewer.deployModalSubtitle': '使用所选平台账号部署当前 HTML 预览。',
|
||||||
'fileViewer.vercelToken': 'Vercel token',
|
'fileViewer.vercelToken': 'Vercel token',
|
||||||
'fileViewer.vercelTokenGetLink': '获取 Vercel token',
|
'fileViewer.vercelTokenGetLink': '获取 Vercel token',
|
||||||
'fileViewer.vercelTokenPlaceholder': '粘贴你的 Vercel token',
|
'fileViewer.vercelTokenPlaceholder': '粘贴你的 Vercel token',
|
||||||
'fileViewer.vercelTokenReuseHint': '将使用已保存的 token。输入新 token 可替换。',
|
'fileViewer.vercelTokenReuseHint': '将使用已保存的 token。输入新 token 可替换。',
|
||||||
'fileViewer.vercelTokenRequired': '请先输入并保存 Vercel 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.vercelTeamId': 'Team ID',
|
||||||
'fileViewer.vercelTeamSlug': 'Team slug',
|
'fileViewer.vercelTeamSlug': 'Team slug',
|
||||||
|
'fileViewer.cloudflareAccountId': 'Account ID',
|
||||||
|
'fileViewer.cloudflareAccountIdHint': '必填。可在 Cloudflare 控制台中找到账号 ID。',
|
||||||
|
'fileViewer.cloudflareAccountIdRequired': '请先输入并保存 Cloudflare Account ID。',
|
||||||
'fileViewer.optional': '可选',
|
'fileViewer.optional': '可选',
|
||||||
'fileViewer.vercelPreviewOnly': '当前仅部署 Preview。',
|
'fileViewer.vercelPreviewOnly': '当前仅部署 Preview。',
|
||||||
|
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages 使用 Direct Upload。',
|
||||||
'fileViewer.savingConfig': '保存中…',
|
'fileViewer.savingConfig': '保存中…',
|
||||||
'fileViewer.deployConfigSaveFailed': '保存 Vercel 设置失败。',
|
'fileViewer.deployConfigSaveFailed': '保存 Vercel 设置失败。',
|
||||||
'fileViewer.deployFailed': '部署失败,请检查 Vercel 设置后重试。',
|
'fileViewer.deployFailed': '部署失败,请检查 Vercel 设置后重试。',
|
||||||
|
'fileViewer.deployProviderConfigSaveFailed': '无法保存 {provider} 设置。',
|
||||||
|
'fileViewer.deployProviderFailed': '{provider} 部署失败。请检查设置后重试。',
|
||||||
'fileViewer.deployResultLabel': '部署链接',
|
'fileViewer.deployResultLabel': '部署链接',
|
||||||
'fileViewer.deployLinkPreparingLabel': '公开链接准备中',
|
'fileViewer.deployLinkPreparingLabel': '公开链接准备中',
|
||||||
'fileViewer.deployLinkDelayed': '站点已经部署,Vercel 仍在准备公开链接。',
|
'fileViewer.deployLinkDelayed': '站点已经部署,平台仍在准备公开链接。',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Vercel 访问保护已开启',
|
'fileViewer.deployLinkProtectedLabel': '部署访问保护已开启',
|
||||||
'fileViewer.deployLinkProtected':
|
'fileViewer.deployLinkProtected': '站点已部署,但此预览链接要求登录后才能访问。请关闭 Deployment Protection 或使用自定义域名。',
|
||||||
'站点已部署,但 Vercel 要求登录后才能访问此预览链接。请关闭 Deployment Protection 或使用自定义域名。',
|
|
||||||
'fileViewer.retryLink': '立即重试',
|
'fileViewer.retryLink': '立即重试',
|
||||||
|
|
||||||
'questionForm.submit': '提交',
|
'questionForm.submit': '提交',
|
||||||
|
|
|
||||||
|
|
@ -712,28 +712,45 @@ export const zhTW: Dict = {
|
||||||
'fileViewer.deployToVercel': '部署到 Vercel',
|
'fileViewer.deployToVercel': '部署到 Vercel',
|
||||||
'fileViewer.redeployToVercel': '重新部署',
|
'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.preparingPublicLink': '正在準備公開連結…',
|
||||||
'fileViewer.copyDeployLink': '複製連結',
|
'fileViewer.copyDeployLink': '複製連結',
|
||||||
'fileViewer.deployModalTitle': '部署到 Vercel',
|
'fileViewer.deployModalTitle': '部署',
|
||||||
'fileViewer.deployModalSubtitle': '使用你自己的 Vercel 帳號部署此 HTML 作品預覽。',
|
'fileViewer.deployModalSubtitle': '使用所選平台帳號部署目前 HTML 預覽。',
|
||||||
'fileViewer.vercelToken': 'Vercel token',
|
'fileViewer.vercelToken': 'Vercel token',
|
||||||
'fileViewer.vercelTokenGetLink': '取得 Vercel token',
|
'fileViewer.vercelTokenGetLink': '取得 Vercel token',
|
||||||
'fileViewer.vercelTokenPlaceholder': '貼上你的 Vercel token',
|
'fileViewer.vercelTokenPlaceholder': '貼上你的 Vercel token',
|
||||||
'fileViewer.vercelTokenReuseHint': '將使用已儲存的 token。輸入新 token 可替換。',
|
'fileViewer.vercelTokenReuseHint': '將使用已儲存的 token。輸入新 token 可替換。',
|
||||||
'fileViewer.vercelTokenRequired': '請先輸入並儲存 Vercel 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.vercelTeamId': 'Team ID',
|
||||||
'fileViewer.vercelTeamSlug': 'Team slug',
|
'fileViewer.vercelTeamSlug': 'Team slug',
|
||||||
|
'fileViewer.cloudflareAccountId': 'Account ID',
|
||||||
|
'fileViewer.cloudflareAccountIdHint': '必填。可在 Cloudflare 控制台中找到帳號 ID。',
|
||||||
|
'fileViewer.cloudflareAccountIdRequired': '請先輸入並儲存 Cloudflare Account ID。',
|
||||||
'fileViewer.optional': '可選',
|
'fileViewer.optional': '可選',
|
||||||
'fileViewer.vercelPreviewOnly': '目前僅部署 Preview。',
|
'fileViewer.vercelPreviewOnly': '目前僅部署 Preview。',
|
||||||
|
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages 使用 Direct Upload。',
|
||||||
'fileViewer.savingConfig': '儲存中…',
|
'fileViewer.savingConfig': '儲存中…',
|
||||||
'fileViewer.deployConfigSaveFailed': '儲存 Vercel 設定失敗。',
|
'fileViewer.deployConfigSaveFailed': '儲存 Vercel 設定失敗。',
|
||||||
'fileViewer.deployFailed': '部署失敗,請檢查 Vercel 設定後重試。',
|
'fileViewer.deployFailed': '部署失敗,請檢查 Vercel 設定後重試。',
|
||||||
|
'fileViewer.deployProviderConfigSaveFailed': '無法儲存 {provider} 設定。',
|
||||||
|
'fileViewer.deployProviderFailed': '{provider} 部署失敗。請檢查設定後重試。',
|
||||||
'fileViewer.deployResultLabel': '部署連結',
|
'fileViewer.deployResultLabel': '部署連結',
|
||||||
'fileViewer.deployLinkPreparingLabel': '公開連結準備中',
|
'fileViewer.deployLinkPreparingLabel': '公開連結準備中',
|
||||||
'fileViewer.deployLinkDelayed': '站點已部署,Vercel 仍在準備公開連結。',
|
'fileViewer.deployLinkDelayed': '站點已部署,平台仍在準備公開連結。',
|
||||||
'fileViewer.deployLinkProtectedLabel': 'Vercel 存取保護已開啟',
|
'fileViewer.deployLinkProtectedLabel': '部署存取保護已開啟',
|
||||||
'fileViewer.deployLinkProtected':
|
'fileViewer.deployLinkProtected': '站點已部署,但此預覽連結要求登入後才能存取。請關閉 Deployment Protection 或使用自訂網域。',
|
||||||
'站點已部署,但 Vercel 要求登入後才能存取此預覽連結。請關閉 Deployment Protection 或使用自訂網域。',
|
|
||||||
'fileViewer.retryLink': '立即重試',
|
'fileViewer.retryLink': '立即重試',
|
||||||
|
|
||||||
'questionForm.submit': '提交',
|
'questionForm.submit': '提交',
|
||||||
|
|
|
||||||
|
|
@ -778,6 +778,12 @@ export interface Dict {
|
||||||
'liveArtifact.refresh.statusReady': string;
|
'liveArtifact.refresh.statusReady': string;
|
||||||
'liveArtifact.refresh.statusSucceeded': string;
|
'liveArtifact.refresh.statusSucceeded': string;
|
||||||
'liveArtifact.refresh.statusFailed': 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.deployToVercel': string;
|
||||||
'fileViewer.redeployToVercel': string;
|
'fileViewer.redeployToVercel': string;
|
||||||
'fileViewer.deployingToVercel': string;
|
'fileViewer.deployingToVercel': string;
|
||||||
|
|
@ -790,13 +796,25 @@ export interface Dict {
|
||||||
'fileViewer.vercelTokenPlaceholder': string;
|
'fileViewer.vercelTokenPlaceholder': string;
|
||||||
'fileViewer.vercelTokenReuseHint': string;
|
'fileViewer.vercelTokenReuseHint': string;
|
||||||
'fileViewer.vercelTokenRequired': 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.vercelTeamId': string;
|
||||||
'fileViewer.vercelTeamSlug': string;
|
'fileViewer.vercelTeamSlug': string;
|
||||||
|
'fileViewer.cloudflareAccountId': string;
|
||||||
|
'fileViewer.cloudflareAccountIdHint': string;
|
||||||
|
'fileViewer.cloudflareAccountIdRequired': string;
|
||||||
'fileViewer.optional': string;
|
'fileViewer.optional': string;
|
||||||
'fileViewer.vercelPreviewOnly': string;
|
'fileViewer.vercelPreviewOnly': string;
|
||||||
|
'fileViewer.cloudflarePagesPreviewHint': string;
|
||||||
'fileViewer.savingConfig': string;
|
'fileViewer.savingConfig': string;
|
||||||
'fileViewer.deployConfigSaveFailed': string;
|
'fileViewer.deployConfigSaveFailed': string;
|
||||||
'fileViewer.deployFailed': string;
|
'fileViewer.deployFailed': string;
|
||||||
|
'fileViewer.deployProviderConfigSaveFailed': string;
|
||||||
|
'fileViewer.deployProviderFailed': string;
|
||||||
'fileViewer.deployResultLabel': string;
|
'fileViewer.deployResultLabel': string;
|
||||||
'fileViewer.deployLinkPreparingLabel': string;
|
'fileViewer.deployLinkPreparingLabel': string;
|
||||||
'fileViewer.deployLinkDelayed': string;
|
'fileViewer.deployLinkDelayed': string;
|
||||||
|
|
|
||||||
|
|
@ -7018,6 +7018,11 @@ button.connector-action.is-loading {
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
.deploy-provider-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.field-label-row {
|
.field-label-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|
@ -7069,7 +7074,8 @@ button.connector-action.is-loading {
|
||||||
border: 1px solid color-mix(in srgb, #1f9d55 28%, var(--border));
|
border: 1px solid color-mix(in srgb, #1f9d55 28%, var(--border));
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.deploy-form input {
|
.deploy-form input,
|
||||||
|
.deploy-form select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
@ -7088,11 +7094,19 @@ button.connector-action.is-loading {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.deploy-field-grid.single-field {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
.deploy-field-grid label {
|
.deploy-field-grid label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
.field-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
.deploy-error {
|
.deploy-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,28 @@ import type {
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { ArtifactManifest } from '../artifacts/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[]> {
|
export async function fetchAgents(options?: { throwOnError?: boolean }): Promise<AgentInfo[]> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/agents');
|
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 {
|
try {
|
||||||
const resp = await fetch('/api/deploy/config');
|
const resp = await fetch(`/api/deploy/config${deployProviderQuery(providerId)}`);
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) return null;
|
||||||
return (await resp.json()) as DeployConfigResponse;
|
return (await resp.json()) as WebDeployConfigResponse;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDeployConfig(
|
export async function updateDeployConfig(
|
||||||
input: UpdateDeployConfigRequest,
|
input: WebUpdateDeployConfigRequest,
|
||||||
): Promise<DeployConfigResponse | null> {
|
): Promise<WebDeployConfigResponse | null> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/deploy/config', {
|
const resp = await fetch('/api/deploy/config', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) {
|
||||||
return (await resp.json()) as DeployConfigResponse;
|
const payload = (await resp.json().catch(() => null)) as
|
||||||
} catch {
|
| { 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProjectDeployments(
|
export async function fetchProjectDeployments(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<ProjectDeploymentsResponse['deployments']> {
|
): Promise<WebDeploymentInfo[]> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/deployments`);
|
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/deployments`);
|
||||||
if (!resp.ok) return [];
|
if (!resp.ok) return [];
|
||||||
const json = (await resp.json()) as ProjectDeploymentsResponse;
|
const json = (await resp.json()) as ProjectDeploymentsResponse;
|
||||||
return json.deployments ?? [];
|
return (json.deployments ?? []) as WebDeploymentInfo[];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -379,11 +409,12 @@ export async function fetchProjectDeployments(
|
||||||
export async function deployProjectFile(
|
export async function deployProjectFile(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
): Promise<DeployProjectFileResponse> {
|
providerId: WebDeployProviderId = DEFAULT_DEPLOY_PROVIDER_ID,
|
||||||
|
): Promise<WebDeployProjectFileResponse> {
|
||||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/deploy`, {
|
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/deploy`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ fileName, providerId: 'vercel-self' }),
|
body: JSON.stringify({ fileName, providerId }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const payload = (await resp.json().catch(() => null)) as
|
const payload = (await resp.json().catch(() => null)) as
|
||||||
|
|
@ -391,13 +422,13 @@ export async function deployProjectFile(
|
||||||
| null;
|
| null;
|
||||||
throw new Error(payload?.error?.message || payload?.message || `Deploy failed (${resp.status})`);
|
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(
|
export async function checkDeploymentLink(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
deploymentId: string,
|
deploymentId: string,
|
||||||
): Promise<DeployProjectFileResponse> {
|
): Promise<WebDeployProjectFileResponse> {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/api/projects/${encodeURIComponent(projectId)}/deployments/${encodeURIComponent(deploymentId)}/check-link`,
|
`/api/projects/${encodeURIComponent(projectId)}/deployments/${encodeURIComponent(deploymentId)}/check-link`,
|
||||||
{ method: 'POST' },
|
{ method: 'POST' },
|
||||||
|
|
@ -408,7 +439,7 @@ export async function checkDeploymentLink(
|
||||||
| null;
|
| null;
|
||||||
throw new Error(payload?.error?.message || payload?.message || `Link check failed (${resp.status})`);
|
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.
|
// Project files — all paths are scoped under .od/projects/<id>/ on disk.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// @vitest-environment jsdom
|
// @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 { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const { saveTemplateMock } = vi.hoisted(() => ({
|
const { saveTemplateMock } = vi.hoisted(() => ({
|
||||||
saveTemplateMock: vi.fn(),
|
saveTemplateMock: vi.fn(),
|
||||||
|
|
@ -30,6 +30,13 @@ import {
|
||||||
import type { InspectOverrideMap } from '../../src/components/FileViewer';
|
import type { InspectOverrideMap } from '../../src/components/FileViewer';
|
||||||
import type { LiveArtifact, ProjectFile } from '../../src/types';
|
import type { LiveArtifact, ProjectFile } from '../../src/types';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
Reflect.deleteProperty(navigator, 'clipboard');
|
||||||
|
});
|
||||||
|
|
||||||
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
|
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
|
||||||
return {
|
return {
|
||||||
name: 'asset.png',
|
name: 'asset.png',
|
||||||
|
|
@ -187,6 +194,241 @@ describe('FileViewer SVG artifacts', () => {
|
||||||
expect(markup).not.toContain('data-od-render-mode="url-load"');
|
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', () => {
|
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 file = baseFile({ name: 'unsafe.svg', path: 'unsafe.svg', mime: 'image/svg+xml' });
|
||||||
const unsafeSource = [
|
const unsafeSource = [
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CLOUDFLARE_PAGES_PROVIDER_ID,
|
||||||
|
DEFAULT_DEPLOY_PROVIDER_ID,
|
||||||
|
deployProjectFile,
|
||||||
|
fetchDeployConfig,
|
||||||
fetchAppVersionInfo,
|
fetchAppVersionInfo,
|
||||||
fetchConnectorDiscovery,
|
fetchConnectorDiscovery,
|
||||||
fetchProjectFileText,
|
fetchProjectFileText,
|
||||||
|
isDeployProviderId,
|
||||||
|
updateDeployConfig,
|
||||||
uploadProjectFiles,
|
uploadProjectFiles,
|
||||||
} from '../../src/providers/registry';
|
} from '../../src/providers/registry';
|
||||||
|
|
||||||
|
|
@ -188,3 +194,108 @@ describe('uploadProjectFiles', () => {
|
||||||
expect(result.failed[0]).toMatchObject({ name: 'c.txt' });
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ export interface MessagesResponse {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeployProviderId = 'vercel-self';
|
export type DeployProviderId = 'vercel-self' | 'cloudflare-pages';
|
||||||
export type DeploymentStatus =
|
export type DeploymentStatus =
|
||||||
| 'deploying'
|
| 'deploying'
|
||||||
| 'preparing-link'
|
| 'preparing-link'
|
||||||
|
|
@ -175,13 +175,18 @@ export interface DeployConfigResponse {
|
||||||
tokenMask: string;
|
tokenMask: string;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
teamSlug: string;
|
teamSlug: string;
|
||||||
|
accountId?: string;
|
||||||
|
projectName?: string;
|
||||||
target: 'preview';
|
target: 'preview';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDeployConfigRequest {
|
export interface UpdateDeployConfigRequest {
|
||||||
|
providerId?: DeployProviderId;
|
||||||
token?: string;
|
token?: string;
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
teamSlug?: string;
|
teamSlug?: string;
|
||||||
|
accountId?: string;
|
||||||
|
projectName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeploymentInfo {
|
export interface DeploymentInfo {
|
||||||
|
|
@ -196,6 +201,7 @@ export interface DeploymentInfo {
|
||||||
status: DeploymentStatus;
|
status: DeploymentStatus;
|
||||||
statusMessage?: string;
|
statusMessage?: string;
|
||||||
reachableAt?: number;
|
reachableAt?: number;
|
||||||
|
providerMetadata?: Record<string, unknown>;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ importers:
|
||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
specifier: ^12.9.0
|
specifier: ^12.9.0
|
||||||
version: 12.9.0
|
version: 12.9.0
|
||||||
|
blake3-wasm:
|
||||||
|
specifier: 2.1.5
|
||||||
|
version: 2.1.5
|
||||||
chokidar:
|
chokidar:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.0
|
version: 5.0.0
|
||||||
|
|
@ -1849,6 +1852,9 @@ packages:
|
||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
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:
|
body-parser@1.20.5:
|
||||||
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
|
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
|
||||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||||
|
|
@ -5928,6 +5934,8 @@ snapshots:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
|
blake3-wasm@2.1.5: {}
|
||||||
|
|
||||||
body-parser@1.20.5:
|
body-parser@1.20.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue