feat: support Cloudflare Pages custom domains (#851)

* Support Cloudflare Pages custom domains without hiding pages.dev fallback

Keep the default Pages preview as the first public link while optional owned-zone binding provisions DNS and Pages custom-domain state in parallel.

Constraint: Cloudflare deploys must use the existing direct-upload API path with no Wrangler dependency.

Constraint: pages.dev must stay visible even while custom-domain verification is pending.

Rejected: Vercel custom-domain support | outside requested Cloudflare-only scope.

Rejected: overwriting arbitrary CNAME records | risks taking over user-managed DNS.

Confidence: high

Scope-risk: moderate

Directive: Do not expose providerMetadata through public deploy contracts; keep custom-domain DNS ownership checks conservative.

Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/deploy.test.ts tests/deploy-routes.test.ts

Tested: pnpm --filter @open-design/contracts build && pnpm --filter @open-design/contracts typecheck && pnpm --filter @open-design/contracts test

Tested: pnpm --filter @open-design/web typecheck && pnpm --filter @open-design/web test -- providers/registry.test.ts components/FileViewer.test.tsx i18n/locales.test.ts

Tested: pnpm i18n:check && pnpm guard && pnpm typecheck

Tested: pnpm --filter @open-design/daemon build && pnpm --filter @open-design/web build && git diff --check

Not-tested: real Cloudflare account/token/domain smoke test

* Preserve Cloudflare fallback correctness under large accounts and races

Constraint: Cloudflare Pages keeps pages.dev as the primary usable fallback while custom domains remain optional typed metadata.
Rejected: Treating custom-domain DNS or binding failure as a top-level deployment failure | pages.dev can still be ready and usable.
Confidence: high
Scope-risk: moderate
Directive: Keep custom-domain finality tied to Cloudflare Pages API active status plus URL reachability; do not expose providerMetadata.
Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/deploy.test.ts tests/deploy-routes.test.ts; pnpm --filter @open-design/web test -- components/FileViewer.test.tsx i18n/locales.test.ts providers/registry.test.ts; pnpm --filter @open-design/daemon typecheck; pnpm --filter @open-design/web typecheck; pnpm i18n:check; git diff --check; pnpm guard; pnpm typecheck; pnpm --filter @open-design/daemon build; pnpm --filter @open-design/web build
Not-tested: Real Cloudflare token/account/zone smoke test.

* Keep impeccable design notes local

Constraint: .impeccable.md is local assistant/design context and should not be part of the PR diff.
Rejected: Keeping the file tracked while adding it to .gitignore | tracked files are not ignored by Git.
Confidence: high
Scope-risk: narrow
Directive: Keep .impeccable.md untracked and ignored; do not rely on it for required project documentation.
Tested: git check-ignore -v .impeccable.md; git diff --check
Not-tested: Full workspace tests not rerun for ignore-only metadata change.
This commit is contained in:
kami 2026-05-08 11:11:22 +08:00 committed by GitHub
parent 77824ec029
commit 2eae7da24b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 3467 additions and 161 deletions

3
.gitignore vendored
View file

@ -51,3 +51,6 @@ tsconfig.tsbuildinfo
task.md
specs/change/active
.ralph/
# Local design assistant context
.impeccable.md

View file

@ -250,6 +250,19 @@ export function upsertDeployment(db, deployment) {
deployment.providerId,
);
const now = Date.now();
const inputProviderMetadata =
deployment.providerMetadata === undefined
? existing?.providerMetadata
: deployment.providerMetadata;
const providerMetadata =
deployment.cloudflarePages && typeof deployment.cloudflarePages === 'object'
? {
...(inputProviderMetadata && typeof inputProviderMetadata === 'object' && !Array.isArray(inputProviderMetadata)
? inputProviderMetadata
: {}),
cloudflarePages: deployment.cloudflarePages,
}
: inputProviderMetadata;
const next = {
id: existing?.id ?? deployment.id,
projectId: deployment.projectId,
@ -265,10 +278,7 @@ export function upsertDeployment(db, deployment) {
status: deployment.status ?? existing?.status ?? 'ready',
statusMessage: deployment.statusMessage ?? null,
reachableAt: deployment.reachableAt ?? null,
providerMetadata:
deployment.providerMetadata === undefined
? existing?.providerMetadata
: deployment.providerMetadata,
providerMetadata,
createdAt: existing?.createdAt ?? deployment.createdAt ?? now,
updatedAt: deployment.updatedAt ?? now,
};
@ -310,6 +320,10 @@ export function upsertDeployment(db, deployment) {
function normalizeDeployment(row) {
const providerMetadata = parseJsonOrUndef(row.providerMetadataJson);
const normalizedProviderMetadata =
providerMetadata && typeof providerMetadata === 'object' && !Array.isArray(providerMetadata)
? providerMetadata
: undefined;
return {
id: row.id,
projectId: row.projectId,
@ -322,10 +336,13 @@ function normalizeDeployment(row) {
status: row.status || 'ready',
statusMessage: row.statusMessage ?? undefined,
reachableAt: row.reachableAt == null ? undefined : Number(row.reachableAt),
providerMetadata:
providerMetadata && typeof providerMetadata === 'object' && !Array.isArray(providerMetadata)
? providerMetadata
cloudflarePages:
normalizedProviderMetadata?.cloudflarePages &&
typeof normalizedProviderMetadata.cloudflarePages === 'object' &&
!Array.isArray(normalizedProviderMetadata.cloudflarePages)
? normalizedProviderMetadata.cloudflarePages
: undefined,
providerMetadata: normalizedProviderMetadata,
createdAt: Number(row.createdAt),
updatedAt: Number(row.updatedAt),
};

View file

@ -14,6 +14,8 @@ export const SAVED_CLOUDFLARE_TOKEN_MASK = 'saved-cloudflare-token';
const VERCEL_API = 'https://api.vercel.com';
const CLOUDFLARE_API = 'https://api.cloudflare.com/client/v4';
const CLOUDFLARE_API_PAGE_SIZE = 100;
const CLOUDFLARE_API_MAX_PAGES = 100;
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;
@ -57,9 +59,10 @@ export async function readCloudflarePagesConfig() {
token: typeof parsed.token === 'string' ? parsed.token : '',
accountId: typeof parsed.accountId === 'string' ? parsed.accountId : '',
projectName: typeof parsed.projectName === 'string' ? parsed.projectName : '',
cloudflarePages: normalizeCloudflarePagesConfigHints(parsed.cloudflarePages),
};
} catch (err) {
if (err && err.code === 'ENOENT') return { token: '', accountId: '', projectName: '' };
if (err && err.code === 'ENOENT') return { token: '', accountId: '', projectName: '', cloudflarePages: {} };
throw err;
}
}
@ -83,6 +86,7 @@ export async function writeVercelConfig(input) {
export async function writeCloudflarePagesConfig(input) {
const current = await readCloudflarePagesConfig();
const tokenInput = typeof input?.token === 'string' ? input.token.trim() : '';
const cloudflarePages = normalizeCloudflarePagesConfigHints(input?.cloudflarePages, current.cloudflarePages);
const next = {
token:
tokenInput && tokenInput !== SAVED_CLOUDFLARE_TOKEN_MASK
@ -95,6 +99,7 @@ export async function writeCloudflarePagesConfig(input) {
// mirroring Vercel's automatic `od-${projectId}` deployment name.
projectName: '',
};
if (Object.keys(cloudflarePages).length > 0) next.cloudflarePages = cloudflarePages;
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);
@ -123,7 +128,8 @@ export function publicDeployConfig(config) {
}
export function publicCloudflarePagesConfig(config) {
return {
const cloudflarePages = normalizeCloudflarePagesConfigHints(config?.cloudflarePages);
const body = {
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
configured: Boolean(config?.token && config?.accountId),
tokenMask: config?.token ? SAVED_CLOUDFLARE_TOKEN_MASK : '',
@ -133,6 +139,8 @@ export function publicCloudflarePagesConfig(config) {
projectName: config?.projectName || '',
target: 'preview',
};
if (Object.keys(cloudflarePages).length > 0) body.cloudflarePages = cloudflarePages;
return body;
}
export async function readDeployConfig(providerId = VERCEL_PROVIDER_ID) {
@ -154,6 +162,35 @@ export function isDeployProviderId(value) {
return value === VERCEL_PROVIDER_ID || value === CLOUDFLARE_PAGES_PROVIDER_ID;
}
function normalizeCloudflarePagesConfigHints(input, fallback = {}) {
const hasSource = Boolean(input && typeof input === 'object');
const source = hasSource ? input : {};
const prior = !hasSource && fallback && typeof fallback === 'object' ? fallback : {};
const lastZoneId =
typeof source.lastZoneId === 'string'
? source.lastZoneId.trim()
: typeof prior.lastZoneId === 'string'
? prior.lastZoneId.trim()
: '';
const lastZoneName =
typeof source.lastZoneName === 'string'
? normalizeCloudflareZoneName(source.lastZoneName)
: typeof prior.lastZoneName === 'string'
? normalizeCloudflareZoneName(prior.lastZoneName)
: '';
const lastDomainPrefix =
typeof source.lastDomainPrefix === 'string'
? normalizeCloudflareDomainPrefix(source.lastDomainPrefix)
: typeof prior.lastDomainPrefix === 'string'
? normalizeCloudflareDomainPrefix(prior.lastDomainPrefix)
: '';
return {
...(lastZoneId ? { lastZoneId } : {}),
...(lastZoneName ? { lastZoneName } : {}),
...(lastDomainPrefix ? { lastDomainPrefix } : {}),
};
}
// Walk the entry HTML and any referenced CSS, producing the full set of
// files that would be uploaded for a deploy along with the lists of
// missing and invalid references. Does not throw on a partial result so
@ -314,11 +351,53 @@ export async function deployToVercel({ config, files, projectId }) {
};
}
export async function deployToCloudflarePages({ config, files }) {
export async function listCloudflarePagesZones(config) {
if (!config?.token) throw new DeployError('Cloudflare API token is required.', 400);
if (!config?.accountId) throw new DeployError('Cloudflare account ID is required.', 400);
const zones = await fetchCloudflarePaginatedResult(
config,
(page, perPage) => {
const params = new URLSearchParams({
'account.id': config.accountId,
status: 'active',
type: 'full',
page: String(page),
per_page: String(perPage),
});
return `${CLOUDFLARE_API}/zones?${params.toString()}`;
},
'Cloudflare zones lookup failed.',
);
return {
zones: zones
.map((zone) => ({
id: typeof zone?.id === 'string' ? zone.id : '',
name: normalizeCloudflareZoneName(zone?.name),
status: typeof zone?.status === 'string' ? zone.status : undefined,
type: typeof zone?.type === 'string' ? zone.type : undefined,
}))
.filter((zone) => zone.id && zone.name),
cloudflarePages: normalizeCloudflarePagesConfigHints(config?.cloudflarePages),
};
}
export async function deployToCloudflarePages(input) {
const {
config,
files,
projectId = '',
cloudflarePages = undefined,
priorMetadata = undefined,
} = input || {};
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);
const customDomainSelection = await validateCloudflarePagesDeploySelection(
config,
normalizeCloudflarePagesDeploySelection(cloudflarePages),
);
await ensureCloudflarePagesProject(config);
const uploadToken = await getCloudflarePagesUploadToken(config);
@ -348,15 +427,468 @@ export async function deployToCloudflarePages({ config, files }) {
productionUrl ? [productionUrl] : [deployment?.url],
{ providerLabel: 'Cloudflare Pages' },
);
const pagesDevUrl = productionUrl || link.url || deploymentUrl(deployment);
const pagesDev = {
url: pagesDevUrl,
status: normalizeDeploymentLinkStatus(link.status),
statusMessage: link.statusMessage,
reachableAt: link.reachableAt,
};
const customDomain = customDomainSelection
? await setupCloudflarePagesCustomDomain({
config,
projectId,
selection: customDomainSelection,
pagesDevUrl,
priorMetadata,
})
: undefined;
const cloudflarePagesInfo = {
projectName: config.projectName,
pagesDev,
...(customDomain ? { customDomain } : {}),
};
const aggregate = aggregateCloudflarePagesStatus(pagesDev, customDomain);
return {
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
url: productionUrl || link.url || deploymentUrl(deployment),
url: pagesDevUrl,
deploymentId: deployment?.id,
target: 'preview',
status: link.status,
statusMessage: link.statusMessage,
status: aggregate.status,
statusMessage: aggregate.statusMessage,
reachableAt: link.reachableAt,
cloudflarePages: cloudflarePagesInfo,
providerMetadata: cloudflarePagesProviderMetadata(config.projectName, cloudflarePagesInfo, { projectId }),
};
}
function normalizeDeploymentLinkStatus(status) {
return status === 'ready' || status === 'protected' || status === 'failed'
? status
: 'link-delayed';
}
function normalizeCloudflarePagesDeploySelection(input) {
if (!input || typeof input !== 'object') return null;
const rawZoneId = typeof input.zoneId === 'string' ? input.zoneId.trim() : '';
const rawZoneName = typeof input.zoneName === 'string' ? input.zoneName.trim() : '';
const rawPrefix = typeof input.domainPrefix === 'string' ? input.domainPrefix.trim() : '';
if (!rawZoneId && !rawZoneName && !rawPrefix) return null;
const zoneName = normalizeCloudflareZoneName(rawZoneName);
const domainPrefix = normalizeCloudflareDomainPrefix(rawPrefix);
if (!rawZoneId) throw new DeployError('Cloudflare zone is required for a custom domain.', 400);
if (!zoneName || !isValidCloudflareZoneName(zoneName)) {
throw new DeployError('Select a valid Cloudflare domain for the custom domain.', 400);
}
if (!domainPrefix) {
throw new DeployError('Enter a valid subdomain prefix, for example "demo".', 400);
}
return {
zoneId: rawZoneId,
zoneName,
domainPrefix,
hostname: `${domainPrefix}.${zoneName}`,
};
}
async function validateCloudflarePagesDeploySelection(config, selection) {
if (!selection) return null;
const resp = await fetch(`${CLOUDFLARE_API}/zones/${encodeURIComponent(selection.zoneId)}`, {
headers: cloudflareHeaders(config),
});
const json = await readCloudflareJson(resp);
if (!resp.ok || json?.success === false) {
throw cloudflareError(json, resp.status, 'Cloudflare zone lookup failed.');
}
const zone = json?.result ?? json;
const zoneName = normalizeCloudflareZoneName(zone?.name);
if (!zoneName || zoneName !== selection.zoneName) {
throw new DeployError('Cloudflare zone selection no longer matches the selected domain.', 400, {
errorCode: 'cloudflare_zone_mismatch',
});
}
if (zone?.status && zone.status !== 'active') {
throw new DeployError('Cloudflare custom domains require an active zone.', 400, {
errorCode: 'cloudflare_zone_inactive',
});
}
if (zone?.type && zone.type !== 'full') {
throw new DeployError('Cloudflare custom domains require a full DNS zone.', 400, {
errorCode: 'cloudflare_zone_not_full',
});
}
return { ...selection, zoneName };
}
async function setupCloudflarePagesCustomDomain({ config, projectId, selection, pagesDevUrl, priorMetadata }) {
const pagesTarget = normalizeHostname(hostnameFromUrl(pagesDevUrl) || `${config.projectName}.pages.dev`);
const marker = cloudflarePagesDnsMarker(projectId, config.projectName, pagesTarget);
const base = {
hostname: selection.hostname,
url: `https://${selection.hostname}`,
zoneId: selection.zoneId,
zoneName: selection.zoneName,
domainPrefix: selection.domainPrefix,
};
let dns;
try {
dns = await ensureCloudflarePagesCnameRecord({
config,
selection,
target: pagesTarget,
marker,
priorMetadata,
});
} catch (err) {
const details = err instanceof DeployError && err.details && typeof err.details === 'object'
? err.details
: {};
return {
...base,
status: details.errorCode === 'cloudflare_dns_record_conflict' ? 'conflict' : 'failed',
statusMessage: err?.message || 'Cloudflare DNS record setup failed.',
errorCode: details.errorCode || 'cloudflare_dns_record_failed',
errorMessage: err?.message || 'Cloudflare DNS record setup failed.',
dnsStatus: details.dnsStatus || (details.errorCode === 'cloudflare_dns_record_conflict' ? 'conflict' : 'failed'),
dnsRecordId: details.dnsRecordId,
dnsOwnership: details.dnsOwnership || 'external',
domainStatus: 'skipped',
};
}
let domain;
try {
domain = await ensureCloudflarePagesDomain(config, selection.hostname);
} catch (err) {
const details = err instanceof DeployError && err.details && typeof err.details === 'object'
? err.details
: {};
return {
...base,
status: details.errorCode === 'cloudflare_domain_already_bound' ? 'conflict' : 'failed',
statusMessage: err?.message || 'Cloudflare Pages custom domain setup failed.',
errorCode: details.errorCode || 'cloudflare_domain_setup_failed',
errorMessage: err?.message || 'Cloudflare Pages custom domain setup failed.',
dnsStatus: dns.dnsStatus,
dnsRecordId: dns.dnsRecordId,
dnsOwnership: dns.dnsOwnership,
domainStatus: details.domainStatus || 'failed',
};
}
const domainStatus = normalizeCloudflarePagesDomainStatus(domain?.status);
const customLink = domainStatus === 'active'
? await checkDeploymentUrl(base.url)
: null;
const ready = domainStatus === 'active' && customLink?.reachable;
const failed = domainStatus === 'failed';
return {
...base,
status: ready ? 'ready' : failed ? 'failed' : 'pending',
statusMessage: ready
? 'Custom domain is ready.'
: failed
? 'Cloudflare Pages reported a custom-domain error.'
: customLink?.statusMessage || 'Custom domain is being verified by Cloudflare Pages.',
errorCode: failed ? 'cloudflare_domain_setup_failed' : undefined,
dnsStatus: dns.dnsStatus,
dnsRecordId: dns.dnsRecordId,
dnsOwnership: dns.dnsOwnership,
domainStatus,
pagesDomainStatus: typeof domain?.status === 'string' ? domain.status : undefined,
validationData: domain?.validation_data,
verificationData: domain?.verification_data,
};
}
async function ensureCloudflarePagesCnameRecord({ config, selection, target, marker, priorMetadata }) {
const records = await listCloudflareDnsRecords(config, selection.zoneId, selection.hostname);
const targetHost = normalizeHostname(target);
const exact = findExactCloudflarePagesCname(records, selection, targetHost);
if (exact) {
return cloudflarePagesCnameReuseResult(exact, marker);
}
const conflicting = findCloudflarePagesHostnameRecord(records, selection);
if (conflicting) {
if (canPatchCloudflarePagesCname(conflicting, selection, marker, priorMetadata)) {
const patched = await patchCloudflareDnsRecord(config, selection.zoneId, conflicting.id, {
type: 'CNAME',
name: selection.hostname,
content: targetHost,
proxied: true,
ttl: 1,
comment: marker,
});
return {
dnsStatus: 'patched',
dnsRecordId: patched?.id || conflicting.id,
dnsOwnership: 'marked',
marker,
};
}
throw cloudflarePagesDnsConflictError(selection, conflicting);
}
try {
const created = await createCloudflareDnsRecord(config, selection.zoneId, {
type: 'CNAME',
name: selection.hostname,
content: targetHost,
proxied: true,
ttl: 1,
comment: marker,
});
return {
dnsStatus: 'created',
dnsRecordId: created?.id,
dnsOwnership: 'marked',
marker,
};
} catch (err) {
const racedRecord = await maybeReuseCloudflarePagesCnameAfterDuplicate({
err,
config,
selection,
targetHost,
marker,
});
if (racedRecord) return racedRecord;
if (!(err instanceof DeployError) || !isCloudflareCommentError(err.details || err.message)) throw err;
try {
const created = await createCloudflareDnsRecord(config, selection.zoneId, {
type: 'CNAME',
name: selection.hostname,
content: targetHost,
proxied: true,
ttl: 1,
});
return {
dnsStatus: 'created',
dnsRecordId: created?.id,
dnsOwnership: 'unmarked',
marker,
};
} catch (retryErr) {
const racedRetryRecord = await maybeReuseCloudflarePagesCnameAfterDuplicate({
err: retryErr,
config,
selection,
targetHost,
marker,
});
if (racedRetryRecord) return racedRetryRecord;
throw retryErr;
}
}
}
function findExactCloudflarePagesCname(records, selection, targetHost) {
return records.find((record) => (
String(record?.type || '').toUpperCase() === 'CNAME' &&
normalizeHostname(record?.name) === selection.hostname &&
normalizeHostname(record?.content) === targetHost
));
}
function findCloudflarePagesHostnameRecord(records, selection) {
return records.find((record) => normalizeHostname(record?.name) === selection.hostname);
}
function cloudflarePagesCnameReuseResult(record, marker) {
return {
dnsStatus: 'reused',
dnsRecordId: typeof record.id === 'string' ? record.id : undefined,
dnsOwnership: record.comment === marker ? 'marked' : 'unmarked',
marker,
};
}
function cloudflarePagesDnsConflictError(selection, conflicting) {
return new DeployError(
`Cloudflare DNS already has a different record for ${selection.hostname}.`,
409,
{
errorCode: 'cloudflare_dns_record_conflict',
dnsStatus: 'conflict',
dnsRecordId: conflicting.id,
dnsOwnership: 'external',
},
);
}
async function maybeReuseCloudflarePagesCnameAfterDuplicate({ err, config, selection, targetHost, marker }) {
if (!(err instanceof DeployError) || !isCloudflareAlreadyExists(err.details || err.message)) return null;
const racedRecords = await listCloudflareDnsRecords(config, selection.zoneId, selection.hostname);
const exact = findExactCloudflarePagesCname(racedRecords, selection, targetHost);
if (exact) return cloudflarePagesCnameReuseResult(exact, marker);
const conflicting = findCloudflarePagesHostnameRecord(racedRecords, selection);
if (conflicting) throw cloudflarePagesDnsConflictError(selection, conflicting);
throw err;
}
async function listCloudflareDnsRecords(config, zoneId, hostname) {
const params = new URLSearchParams({
name: hostname,
per_page: '100',
});
const resp = await fetch(`${cloudflareZoneDnsRecordsUrl(zoneId)}?${params.toString()}`, {
headers: cloudflareHeaders(config),
});
const json = await readCloudflareJson(resp);
if (!resp.ok || json?.success === false) {
throw cloudflareError(json, resp.status, 'Cloudflare DNS record lookup failed.');
}
return Array.isArray(json?.result) ? json.result : [];
}
async function createCloudflareDnsRecord(config, zoneId, body) {
const resp = await fetch(cloudflareZoneDnsRecordsUrl(zoneId), {
method: 'POST',
headers: cloudflareHeaders(config, { 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
});
const json = await readCloudflareJson(resp);
if (!resp.ok || json?.success === false) {
throw cloudflareError(json, resp.status, 'Cloudflare DNS record creation failed.');
}
return json?.result ?? json;
}
async function patchCloudflareDnsRecord(config, zoneId, dnsRecordId, body) {
const resp = await fetch(`${cloudflareZoneDnsRecordsUrl(zoneId)}/${encodeURIComponent(dnsRecordId)}`, {
method: 'PATCH',
headers: cloudflareHeaders(config, { 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
});
const json = await readCloudflareJson(resp);
if (!resp.ok || json?.success === false) {
throw cloudflareError(json, resp.status, 'Cloudflare DNS record update failed.');
}
return json?.result ?? json;
}
function canPatchCloudflarePagesCname(record, selection, marker, priorMetadata) {
const prior = priorMetadata?.cloudflarePagesCustomDomain;
return (
record &&
String(record.type || '').toUpperCase() === 'CNAME' &&
typeof record.id === 'string' &&
record.id &&
record.id === prior?.dnsRecordId &&
normalizeHostname(record.name) === selection.hostname &&
record.comment === marker &&
prior?.marker === marker
);
}
async function ensureCloudflarePagesDomain(config, hostname) {
const existing = await findCloudflarePagesDomain(config, hostname);
if (existing) return existing;
const resp = await fetch(cloudflarePagesProjectUrl(config, 'domains'), {
method: 'POST',
headers: cloudflareHeaders(config, { 'Content-Type': 'application/json' }),
body: JSON.stringify({ name: hostname }),
});
const json = await readCloudflareJson(resp);
if (!resp.ok || json?.success === false) {
if (isCloudflareAlreadyExists(json)) {
const retry = await findCloudflarePagesDomain(config, hostname);
if (retry) return retry;
throw new DeployError(
`Cloudflare Pages says ${hostname} is already bound to another project.`,
409,
{
errorCode: 'cloudflare_domain_already_bound',
domainStatus: 'conflict',
},
);
}
throw cloudflareError(json, resp.status, 'Cloudflare Pages custom domain setup failed.');
}
return json?.result ?? json;
}
async function findCloudflarePagesDomain(config, hostname) {
const domains = await fetchCloudflarePaginatedResult(
config,
(page, perPage) => {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
return `${cloudflarePagesProjectUrl(config, 'domains')}?${params.toString()}`;
},
'Cloudflare Pages custom domain lookup failed.',
);
return domains.find((domain) => normalizeHostname(domain?.name) === normalizeHostname(hostname)) || null;
}
export async function readCloudflarePagesDomain(config, hostname) {
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);
return findCloudflarePagesDomain(config, hostname);
}
function normalizeCloudflarePagesDomainStatus(status) {
const value = String(status || '').toLowerCase();
if (value === 'active') return 'active';
if (value === 'error' || value === 'blocked' || value === 'deactivated') return 'failed';
return 'pending';
}
export function aggregateCloudflarePagesStatus(pagesDev, customDomain) {
if (!customDomain) {
return {
status: pagesDev.status,
statusMessage: pagesDev.statusMessage,
};
}
if (customDomain.status === 'ready') {
return {
status: pagesDev.status === 'ready' ? 'ready' : 'link-delayed',
statusMessage: pagesDev.status === 'ready'
? 'Cloudflare Pages and custom domain are ready.'
: pagesDev.statusMessage || 'Cloudflare Pages is still preparing its pages.dev link.',
};
}
if (customDomain.status === 'pending') {
return {
status: 'link-delayed',
statusMessage: customDomain.statusMessage || 'Custom domain is still being prepared.',
};
}
const customFailureMessage = customDomain.errorMessage || customDomain.statusMessage || 'Custom domain setup failed.';
return {
status: pagesDev.status,
statusMessage: pagesDev.status === 'ready'
? `pages.dev is ready. ${customFailureMessage}`
: pagesDev.statusMessage || customFailureMessage,
};
}
function cloudflarePagesProviderMetadata(projectName, cloudflarePagesInfo, { projectId = '' } = {}) {
const custom = cloudflarePagesInfo?.customDomain;
return {
cloudflarePagesProjectName: projectName,
cloudflarePages: cloudflarePagesInfo,
...(custom ? {
cloudflarePagesCustomDomain: {
projectId,
pagesProjectName: projectName,
hostname: custom.hostname,
zoneId: custom.zoneId,
zoneName: custom.zoneName,
domainPrefix: custom.domainPrefix,
marker: cloudflarePagesDnsMarker(projectId, projectName, hostnameFromUrl(cloudflarePagesInfo.pagesDev?.url)),
dnsRecordId: custom.dnsRecordId,
dnsOwnership: custom.dnsOwnership,
},
} : {}),
};
}
@ -1193,6 +1725,10 @@ function cloudflarePagesProductionUrl(config) {
return config?.projectName ? `https://${config.projectName}.pages.dev` : '';
}
function cloudflareZoneDnsRecordsUrl(zoneId) {
return `${CLOUDFLARE_API}/zones/${encodeURIComponent(zoneId)}/dns_records`;
}
export function cloudflarePagesProjectNameForProject(projectId, projectName = '') {
const idSuffix = safeDnsLabel(projectId).slice(0, 12) || randomUUID().slice(0, 8);
const nameBase = safeDnsLabel(projectName) || 'project';
@ -1223,6 +1759,41 @@ async function readCloudflareJson(resp) {
}
}
async function fetchCloudflarePaginatedResult(config, buildUrl, fallback, options = {}) {
const results = [];
const perPage = options.perPage || CLOUDFLARE_API_PAGE_SIZE;
for (let page = 1; page <= CLOUDFLARE_API_MAX_PAGES; page += 1) {
const resp = await fetch(buildUrl(page, perPage), {
headers: cloudflareHeaders(config),
});
const json = await readCloudflareJson(resp);
if (!resp.ok || json?.success === false) {
throw cloudflareError(json, resp.status, fallback);
}
const pageItems = Array.isArray(json?.result) ? json.result : [];
results.push(...pageItems);
if (!shouldFetchNextCloudflarePage(json?.result_info, page, perPage, pageItems.length)) break;
}
return results;
}
function shouldFetchNextCloudflarePage(resultInfo, page, perPage, itemCount) {
if (itemCount <= 0) return false;
const totalPages = Number(resultInfo?.total_pages);
if (Number.isFinite(totalPages) && totalPages > 0) return page < totalPages;
const totalCount = Number(resultInfo?.total_count);
const responsePerPage = Number(resultInfo?.per_page);
const effectivePerPage = Number.isFinite(responsePerPage) && responsePerPage > 0
? responsePerPage
: perPage;
if (Number.isFinite(totalCount) && totalCount >= 0) {
return page * effectivePerPage < totalCount;
}
const count = Number(resultInfo?.count);
if (Number.isFinite(count) && count >= 0) return count >= effectivePerPage;
return itemCount >= perPage;
}
async function readVercelJson(resp) {
try {
return await resp.json();
@ -1241,6 +1812,22 @@ function cloudflareError(json, status, fallback) {
return new DeployError(message, status, json);
}
function isCloudflareAlreadyExists(body) {
const text = JSON.stringify(body || {}).toLowerCase();
return (
text.includes('already exists') ||
text.includes('already exist') ||
text.includes('already bound') ||
text.includes('already been taken') ||
text.includes('already in use') ||
text.includes('duplicate')
);
}
function isCloudflareCommentError(value) {
return /comment/i.test(typeof value === 'string' ? value : JSON.stringify(value || {}));
}
function vercelError(json, status) {
const code = json?.error?.code;
const message = json?.error?.message || json?.message || `Vercel request failed (${status}).`;
@ -1256,6 +1843,49 @@ function deploymentUrl(json) {
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
}
function hostnameFromUrl(raw) {
const normalized = normalizeDeploymentUrl(raw);
if (!normalized) return '';
try {
return new URL(normalized).hostname.toLowerCase();
} catch {
return normalizeHostname(raw);
}
}
function normalizeHostname(raw) {
return String(raw || '')
.trim()
.toLowerCase()
.replace(/^https?:\/\//i, '')
.split('/')[0]
.replace(/\.$/, '');
}
function normalizeCloudflareZoneName(raw) {
return normalizeHostname(raw);
}
function isValidCloudflareZoneName(raw) {
const name = normalizeCloudflareZoneName(raw);
if (!name || name.length > 253 || name.includes('..')) return false;
return name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
}
function normalizeCloudflareDomainPrefix(raw) {
const prefix = String(raw || '').trim().toLowerCase();
if (!prefix || prefix === '@' || prefix.includes('.') || prefix.includes('*')) return '';
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(prefix) ? prefix : '';
}
function cloudflarePagesDnsMarker(projectId, projectName, pagesTarget) {
return `od:cfp:${shortCloudflareHash(projectId || projectName)}:${shortCloudflareHash(pagesTarget || projectName)}`;
}
function shortCloudflareHash(value) {
return blake3Hash(String(value || '')).toString('hex').slice(0, 12);
}
function safeVercelProjectName(raw) {
return safeProjectLabel(raw, 80) || `od-${randomUUID().slice(0, 8)}`;
}

View file

@ -144,6 +144,7 @@ import { composioConnectorProvider } from './connectors/composio.js';
import { configureComposioConfigStore, readComposioConfig, readPublicComposioConfig, writeComposioConfig } from './connectors/composio-config.js';
import { CHAT_TOOL_ENDPOINTS, CHAT_TOOL_OPERATIONS, toolTokenRegistry } from './tool-tokens.js';
import {
aggregateCloudflarePagesStatus,
buildDeployFileSet,
checkDeploymentUrl,
CLOUDFLARE_PAGES_PROVIDER_ID,
@ -152,9 +153,11 @@ import {
deployToCloudflarePages,
deployToVercel,
isDeployProviderId,
listCloudflarePagesZones,
prepareDeployPreflight,
publicDeployConfigForProvider,
readDeployConfig,
readCloudflarePagesDomain,
VERCEL_PROVIDER_ID,
writeDeployConfig,
} from './deploy.js';
@ -956,6 +959,89 @@ function cloudflarePagesProjectNameForDeploy(db, projectId, projectName, prior)
return cloudflarePagesProjectNameForProject(projectId, projectName);
}
function publicDeployment(deployment) {
if (!deployment || typeof deployment !== 'object') return deployment;
const { providerMetadata: _providerMetadata, ...publicShape } = deployment;
return publicShape;
}
function publicDeployments(deployments) {
return (deployments || []).map(publicDeployment);
}
async function checkCloudflarePagesDeploymentLinks(existing) {
const current = existing.cloudflarePages || {};
const projectName = current.projectName || cloudflarePagesProjectNameFromDeployment(existing);
const config = await readDeployConfig(CLOUDFLARE_PAGES_PROVIDER_ID);
const pagesDevUrl = current.pagesDev?.url || existing.url;
const pagesDevResult = await checkDeploymentUrl(pagesDevUrl);
const pagesDev = {
...(current.pagesDev || {}),
url: pagesDevUrl,
status: pagesDevResult.reachable ? 'ready' : pagesDevResult.status || 'link-delayed',
statusMessage: pagesDevResult.reachable
? 'Public link is ready.'
: pagesDevResult.statusMessage || current.pagesDev?.statusMessage || 'Cloudflare Pages is still preparing the pages.dev link.',
reachableAt: pagesDevResult.reachable ? Date.now() : current.pagesDev?.reachableAt,
};
let customDomain = current.customDomain;
if (customDomain?.url && customDomain.status !== 'conflict') {
let pagesDomain = null;
if (config?.token && config?.accountId && projectName) {
try {
pagesDomain = await readCloudflarePagesDomain({ ...config, projectName }, customDomain.hostname);
} catch {
pagesDomain = null;
}
}
const customResult = await checkDeploymentUrl(customDomain.url);
const pagesDomainStatus = pagesDomain?.status || customDomain.pagesDomainStatus;
const failedByApi = ['error', 'blocked', 'deactivated'].includes(String(pagesDomainStatus || '').toLowerCase());
const activeByApi = String(pagesDomainStatus || '').toLowerCase() === 'active';
const readyByReachability = customResult.reachable && activeByApi;
customDomain = {
...customDomain,
domainStatus: pagesDomain
? pagesDomain.status === 'active'
? 'active'
: failedByApi
? 'failed'
: 'pending'
: customDomain.domainStatus,
pagesDomainStatus,
validationData: pagesDomain?.validation_data ?? customDomain.validationData,
verificationData: pagesDomain?.verification_data ?? customDomain.verificationData,
status: readyByReachability
? 'ready'
: customDomain.status === 'failed' || failedByApi
? 'failed'
: 'pending',
statusMessage: readyByReachability
? 'Custom domain is ready.'
: failedByApi
? 'Cloudflare Pages reported a custom-domain error.'
: customResult.statusMessage || customDomain.statusMessage || 'Custom domain is still being prepared.',
};
}
const cloudflarePages = {
...current,
projectName,
pagesDev,
...(customDomain ? { customDomain } : {}),
};
const aggregate = aggregateCloudflarePagesStatus(pagesDev, customDomain);
return {
url: pagesDev.url,
status: aggregate.status,
statusMessage: aggregate.statusMessage,
cloudflarePages,
providerMetadata: {
...(existing.providerMetadata || {}),
cloudflarePages,
},
};
}
// Filename slug for the Content-Disposition header on archive downloads.
// Browsers reject quotes and control bytes; we keep Unicode letters/digits
// so a project name with non-ASCII characters (e.g. "café-design")
@ -3089,10 +3175,25 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
}
});
app.get('/api/deploy/cloudflare-pages/zones', async (_req, res) => {
try {
/** @type {import('@open-design/contracts').CloudflarePagesZonesResponse} */
const body = await listCloudflarePagesZones(await readDeployConfig(CLOUDFLARE_PAGES_PROVIDER_ID));
res.json(body);
} catch (err) {
const status = err instanceof DeployError ? err.status : 400;
const init =
err instanceof DeployError && err.details
? { details: err.details }
: {};
sendApiError(res, status, 'BAD_REQUEST', String(err?.message || err), init);
}
});
app.get('/api/projects/:id/deployments', (req, res) => {
try {
/** @type {import('@open-design/contracts').ProjectDeploymentsResponse} */
const body = { deployments: listDeployments(db, req.params.id) };
const body = { deployments: publicDeployments(listDeployments(db, req.params.id)) };
res.json(body);
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
@ -3101,7 +3202,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.post('/api/projects/:id/deploy', async (req, res) => {
try {
const { fileName, providerId = VERCEL_PROVIDER_ID } = req.body || {};
const { fileName, providerId = VERCEL_PROVIDER_ID, cloudflarePages } = req.body || {};
if (!isDeployProviderId(providerId)) {
return sendApiError(
res,
@ -3135,6 +3236,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
},
files,
projectId: req.params.id,
cloudflarePages,
priorMetadata: prior?.providerMetadata,
})
: await deployToVercel({
config: await readDeployConfig(VERCEL_PROVIDER_ID),
@ -3155,14 +3258,15 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
status: result.status,
statusMessage: result.statusMessage,
reachableAt: result.reachableAt,
cloudflarePages: result.cloudflarePages,
providerMetadata:
providerId === CLOUDFLARE_PAGES_PROVIDER_ID
? cloudflarePagesDeploymentMetadata(cloudflarePagesProjectName)
? (result.providerMetadata ?? cloudflarePagesDeploymentMetadata(cloudflarePagesProjectName))
: prior?.providerMetadata,
createdAt: prior?.createdAt ?? now,
updatedAt: now,
});
res.json(body);
res.json(publicDeployment(body));
} catch (err) {
const status = err instanceof DeployError ? err.status : 400;
const init =
@ -3241,6 +3345,18 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
existing.providerId === CLOUDFLARE_PAGES_PROVIDER_ID
? cloudflarePagesProjectNameFromDeployment(existing)
: '';
if (existing.providerId === CLOUDFLARE_PAGES_PROVIDER_ID && existing.cloudflarePages?.pagesDev?.url) {
const checked = await checkCloudflarePagesDeploymentLinks(existing);
const now = Date.now();
/** @type {import('@open-design/contracts').CheckDeploymentLinkResponse} */
const body = upsertDeployment(db, {
...existing,
...checked,
reachableAt: checked.status === 'ready' ? now : existing.reachableAt,
updatedAt: now,
});
return res.json(publicDeployment(body));
}
const checkUrl = stableCloudflareProjectName
? `https://${stableCloudflareProjectName}.pages.dev`
: existing.url;
@ -3258,7 +3374,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
reachableAt: result.reachable ? now : existing.reachableAt,
updatedAt: now,
});
res.json(body);
res.json(publicDeployment(body));
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
}

View file

@ -6,7 +6,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
CLOUDFLARE_PAGES_PROVIDER_ID,
cloudflarePagesProjectNameForProject,
deployConfigPath,
VERCEL_PROVIDER_ID,
SAVED_CLOUDFLARE_TOKEN_MASK,
} from '../src/deploy.js';
import { ensureProject } from '../src/projects.js';
@ -96,6 +98,68 @@ describe('deploy provider routes', () => {
}
});
it('lists Cloudflare Pages zones for saved account credentials', async () => {
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-zones-'));
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',
cloudflarePages: {
lastZoneId: 'zone-1',
lastZoneName: 'example.com',
lastDomainPrefix: 'demo',
},
}),
});
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);
if (url.startsWith(baseUrl)) return realFetch(input, init);
expect(url).toContain('/zones?');
expect(url).toContain('account.id=account_123');
return new Response(JSON.stringify({
success: true,
result: [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
try {
const zonesResp = await fetch(`${baseUrl}/api/deploy/cloudflare-pages/zones`);
expect(zonesResp.status).toBe(200);
expect(await zonesResp.json()).toEqual({
zones: [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }],
cloudflarePages: {
lastZoneId: 'zone-1',
lastZoneName: 'example.com',
lastDomainPrefix: 'demo',
},
});
} 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 });
}
});
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');
@ -276,10 +340,15 @@ describe('deploy provider routes', () => {
deploymentId: 'cf_dep_123',
url: `https://${expectedPagesProject}.pages.dev`,
status: 'ready',
providerMetadata: {
cloudflarePagesProjectName: expectedPagesProject,
cloudflarePages: {
projectName: expectedPagesProject,
pagesDev: {
url: `https://${expectedPagesProject}.pages.dev`,
status: 'ready',
},
},
});
expect(deployment).not.toHaveProperty('providerMetadata');
const renameResp = await fetch(`${baseUrl}/api/projects/${projectId}`, {
method: 'PATCH',
@ -305,4 +374,392 @@ describe('deploy provider routes', () => {
await rm(stateRoot, { recursive: true, force: true });
}
});
it('rejects invalid Cloudflare custom-domain selection before Pages deploy', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-invalid-domain-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = `cf-invalid-${Date.now()}`;
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Invalid domain test',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.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);
if (url.startsWith(baseUrl)) return realFetch(input, init);
throw new Error(`No external fetch expected before invalid-prefix rejection: ${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,
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'bad.prefix',
},
}),
});
expect(deployResp.status).toBe(400);
expect(await deployResp.text()).toMatch(/valid subdomain prefix/i);
} 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 });
}
});
it('refreshes Cloudflare Pages custom-domain API status during check-link', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-domain-check-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = `cf-domain-check-${Date.now()}`;
const expectedPagesProject = cloudflarePagesProjectNameForProject(projectId, 'Domain check test');
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Domain check test',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.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;
let domainListCount = 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.startsWith(baseUrl)) return realFetch(input, init);
if (url.endsWith(`/pages/projects/${expectedPagesProject}`) && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: { name: expectedPagesProject } }), {
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') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/pages/assets/upsert-hashes') && method === 'POST') {
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') {
return new Response(JSON.stringify({
success: true,
result: { id: 'cf_dep_domain_check', 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 });
}
if (url.endsWith('/zones/zone-1') && method === 'GET') {
return new Response(JSON.stringify({
success: true,
result: { id: 'zone-1', name: 'example.com', status: 'active', type: 'full' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/zones/zone-1/dns_records?') && method === 'GET') {
return new Response(JSON.stringify({ success: true, result: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/zones/zone-1/dns_records') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}'));
return new Response(JSON.stringify({ success: true, result: { id: 'dns-1', ...body } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.includes(`/pages/projects/${expectedPagesProject}/domains?`) && method === 'GET') {
domainListCount += 1;
const result =
domainListCount === 1
? []
: [{
name: 'demo.example.com',
status: domainListCount === 2 ? 'pending' : 'active',
validation_data: { txt_name: '_cf-custom-hostname.demo.example.com' },
verification_data: { cname: `${expectedPagesProject}.pages.dev` },
}];
return new Response(JSON.stringify({ success: true, result }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith(`/pages/projects/${expectedPagesProject}/domains`) && method === 'POST') {
expect(JSON.parse(String(init?.body ?? '{}'))).toEqual({ name: 'demo.example.com' });
return new Response(JSON.stringify({
success: true,
result: { name: 'demo.example.com', status: 'pending' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://demo.example.com' && 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,
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
}),
});
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,
url: `https://${expectedPagesProject}.pages.dev`,
status: 'link-delayed',
cloudflarePages: {
pagesDev: { url: `https://${expectedPagesProject}.pages.dev`, status: 'ready' },
customDomain: {
hostname: 'demo.example.com',
status: 'pending',
domainStatus: 'pending',
},
},
});
expect(deployment).not.toHaveProperty('providerMetadata');
const pendingResp = await fetch(`${baseUrl}/api/projects/${projectId}/deployments/${deployment.id}/check-link`, {
method: 'POST',
});
expect(pendingResp.status).toBe(200);
const pending = await pendingResp.json();
expect(pending).toMatchObject({
url: `https://${expectedPagesProject}.pages.dev`,
status: 'link-delayed',
cloudflarePages: {
customDomain: {
hostname: 'demo.example.com',
status: 'pending',
domainStatus: 'pending',
pagesDomainStatus: 'pending',
},
},
});
expect(pending).not.toHaveProperty('providerMetadata');
const readyResp = await fetch(`${baseUrl}/api/projects/${projectId}/deployments/${deployment.id}/check-link`, {
method: 'POST',
});
expect(readyResp.status).toBe(200);
const ready = await readyResp.json();
expect(ready).toMatchObject({
url: `https://${expectedPagesProject}.pages.dev`,
status: 'ready',
cloudflarePages: {
customDomain: {
hostname: 'demo.example.com',
status: 'ready',
domainStatus: 'active',
pagesDomainStatus: 'active',
validationData: { txt_name: '_cf-custom-hostname.demo.example.com' },
verificationData: { cname: `${expectedPagesProject}.pages.dev` },
},
},
});
expect(ready).not.toHaveProperty('providerMetadata');
} 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 });
}
});
it('keeps Vercel deploy payload free of Cloudflare custom-domain fields', async () => {
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests');
const stateRoot = await mkdtemp(path.join(os.tmpdir(), 'od-deploy-route-vercel-payload-'));
const priorStateRoot = process.env.OD_USER_STATE_DIR;
process.env.OD_USER_STATE_DIR = stateRoot;
const projectId = `vercel-payload-${Date.now()}`;
const dir = await ensureProject(path.join(dataDir, 'projects'), projectId);
await writeFile(path.join(dir, 'index.html'), '<!doctype html><h1>Hello</h1>');
try {
const createProjectResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Vercel payload test',
skillId: null,
designSystemId: null,
}),
});
expect(createProjectResp.status).toBe(200);
const saveResp = await fetch(`${baseUrl}/api/deploy/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: VERCEL_PROVIDER_ID,
token: 'vercel-token-secret',
}),
});
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.includes('/v13/deployments') && method === 'POST') {
const body = JSON.parse(String(init?.body ?? '{}'));
expect(body).not.toHaveProperty('cloudflarePages');
expect(JSON.stringify(body)).not.toContain('example.com');
return new Response(JSON.stringify({
id: 'vercel-dep-1',
readyState: 'READY',
url: 'vercel.example',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/v13/deployments/vercel-dep-1') && method === 'GET') {
return new Response(JSON.stringify({
id: 'vercel-dep-1',
readyState: 'READY',
url: 'vercel.example',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://vercel.example' && 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: VERCEL_PROVIDER_ID,
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
}),
});
expect(deployResp.status).toBe(200);
expect(await deployResp.json()).toMatchObject({
providerId: VERCEL_PROVIDER_ID,
url: 'https://vercel.example',
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 });
}
});
});

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ import {
CLOUDFLARE_PAGES_PROVIDER_ID,
DEFAULT_DEPLOY_PROVIDER_ID,
deployProjectFile,
fetchCloudflarePagesZones,
fetchDeployConfig,
fetchProjectDeployments,
fetchProjectFilePreview,
@ -22,6 +23,7 @@ import {
refreshLiveArtifact,
updateDeployConfig,
type WebDeployConfigResponse,
type WebCloudflarePagesDeploySelection,
type WebDeploymentInfo,
type WebDeployProjectFileResponse,
type WebDeployProviderId,
@ -97,6 +99,19 @@ type DeployProviderOption = {
accountIdLabelKey?: 'fileViewer.cloudflareAccountId';
accountIdHintKey?: 'fileViewer.cloudflareAccountIdHint';
};
type CloudflarePagesZoneOption = {
id: string;
name: string;
status?: string;
type?: string;
};
type DeployResultCard = {
id: string;
label: string;
url: string;
status: string;
message?: string;
};
const MAX_BRIDGE_COORDINATE = 1_000_000;
// The five basic style facets the inspect panel exposes. Kept narrow on
@ -164,6 +179,22 @@ function getDeployProviderOption(providerId: WebDeployProviderId): DeployProvide
return DEPLOY_PROVIDER_OPTIONS.find((option) => option.id === providerId) ?? DEPLOY_PROVIDER_OPTIONS[0]!;
}
function normalizeCloudflareDomainPrefixInput(raw: string): string {
return raw.trim().toLowerCase();
}
function isValidCloudflareDomainPrefixInput(raw: string): boolean {
const prefix = normalizeCloudflareDomainPrefixInput(raw);
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(prefix);
}
function deployResultState(status?: string): 'ready' | 'delayed' | 'protected' | 'failed' {
if (status === 'protected') return 'protected';
if (status === 'failed' || status === 'conflict') return 'failed';
if (status === 'link-delayed' || status === 'pending') return 'delayed';
return 'ready';
}
async function copyTextToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
@ -2815,12 +2846,18 @@ function HtmlViewer({
const [savingDeployConfig, setSavingDeployConfig] = useState(false);
const [deployError, setDeployError] = useState<string | null>(null);
const [deployResult, setDeployResult] = useState<WebDeployProjectFileResponse | null>(null);
const [copiedDeployLink, setCopiedDeployLink] = useState(false);
const [copiedDeployLink, setCopiedDeployLink] = useState<string | null>(null);
const [deployProviderId, setDeployProviderId] = useState<WebDeployProviderId>(DEFAULT_DEPLOY_PROVIDER_ID);
const [deployToken, setDeployToken] = useState('');
const [teamId, setTeamId] = useState('');
const [teamSlug, setTeamSlug] = useState('');
const [cloudflareAccountId, setCloudflareAccountId] = useState('');
const [cloudflareZones, setCloudflareZones] = useState<CloudflarePagesZoneOption[]>([]);
const [cloudflareZonesLoading, setCloudflareZonesLoading] = useState(false);
const [cloudflareZonesError, setCloudflareZonesError] = useState<string | null>(null);
const [cloudflareZoneId, setCloudflareZoneId] = useState('');
const [cloudflareDomainPrefix, setCloudflareDomainPrefix] = useState('');
const deployProviderLoadSeqRef = useRef(0);
const [inTabPresent, setInTabPresent] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [boardMode, setBoardMode] = useState(false);
@ -2898,6 +2935,22 @@ function HtmlViewer({
setTeamId(matchingConfig?.teamId || '');
setTeamSlug(matchingConfig?.teamSlug || '');
setCloudflareAccountId(matchingConfig?.accountId || '');
setCloudflareZoneId(matchingConfig?.cloudflarePages?.lastZoneId || '');
setCloudflareDomainPrefix(matchingConfig?.cloudflarePages?.lastDomainPrefix || '');
}
function cloudflareConfigHintsFromForm() {
const zone = cloudflareZones.find((item) => item.id === cloudflareZoneId);
const hints = {
...(cloudflareZoneId.trim() ? { lastZoneId: cloudflareZoneId.trim() } : {}),
...((zone?.name || deployConfig?.cloudflarePages?.lastZoneName)
? { lastZoneName: zone?.name || deployConfig?.cloudflarePages?.lastZoneName }
: {}),
...(cloudflareDomainPrefix.trim()
? { lastDomainPrefix: normalizeCloudflareDomainPrefixInput(cloudflareDomainPrefix) }
: {}),
};
return Object.keys(hints).length > 0 ? hints : undefined;
}
function buildDeployConfigRequest(providerId: WebDeployProviderId): WebUpdateDeployConfigRequest {
@ -2907,6 +2960,7 @@ function HtmlViewer({
providerId,
token,
accountId: cloudflareAccountId.trim(),
cloudflarePages: cloudflareConfigHintsFromForm(),
};
}
return {
@ -2921,6 +2975,8 @@ function HtmlViewer({
providerId: WebDeployProviderId,
options?: { fallbackToExisting?: boolean },
) {
const requestSeq = ++deployProviderLoadSeqRef.current;
setDeployProviderId(providerId);
const deployments = await fetchProjectDeployments(projectId);
const nextDeploymentsByProvider = deploymentMapForCurrentFile(deployments);
const exactDeployment = nextDeploymentsByProvider[providerId] ?? null;
@ -2931,13 +2987,48 @@ function HtmlViewer({
// 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);
if (requestSeq !== deployProviderLoadSeqRef.current) {
return { config: null, currentDeployment: null };
}
syncDeployFormFromConfig(providerId, config);
setDeploymentsByProvider(nextDeploymentsByProvider);
setDeployment(currentDeployment ?? null);
setDeployResult(currentDeployment ?? null);
if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID && config?.configured) {
void loadCloudflareZones(config, { requestSeq });
}
return { config, currentDeployment };
}
async function loadCloudflareZones(
config: WebDeployConfigResponse | null = deployConfig,
options?: { requestSeq?: number },
) {
if (!config?.configured || config.providerId !== CLOUDFLARE_PAGES_PROVIDER_ID) return;
const requestSeq = options?.requestSeq ?? deployProviderLoadSeqRef.current;
setCloudflareZonesLoading(true);
setCloudflareZonesError(null);
try {
const response = await fetchCloudflarePagesZones();
if (requestSeq !== deployProviderLoadSeqRef.current) return;
const zones = response?.zones ?? [];
setCloudflareZones(zones);
const hintedZoneId = response?.cloudflarePages?.lastZoneId || config.cloudflarePages?.lastZoneId || '';
const nextZoneId = hintedZoneId && zones.some((zone) => zone.id === hintedZoneId)
? hintedZoneId
: zones[0]?.id || '';
setCloudflareZoneId(nextZoneId);
const hintedPrefix = response?.cloudflarePages?.lastDomainPrefix || config.cloudflarePages?.lastDomainPrefix || '';
if (hintedPrefix) setCloudflareDomainPrefix(hintedPrefix);
} catch (err) {
if (requestSeq !== deployProviderLoadSeqRef.current) return;
setCloudflareZones([]);
setCloudflareZonesError(err instanceof Error ? err.message : t('fileViewer.cloudflareZonesLoadFailed'));
} finally {
if (requestSeq === deployProviderLoadSeqRef.current) setCloudflareZonesLoading(false);
}
}
// Slide deck nav state: the iframe posts the active index + total count
// back to the host every time a slide settles. Host renders prev/next
// controls in the toolbar and reflects the count beside them.
@ -2971,7 +3062,7 @@ function HtmlViewer({
let cancelled = false;
setDeployResult(null);
setDeployError(null);
setCopiedDeployLink(false);
setCopiedDeployLink(null);
setDeployPhase('idle');
void fetchProjectDeployments(projectId).then((items) => {
if (cancelled) return;
@ -3667,7 +3758,7 @@ function HtmlViewer({
setShareMenuOpen(false);
setDeployModalOpen(true);
setDeployError(null);
setCopiedDeployLink(false);
setCopiedDeployLink(null);
setDeployPhase('idle');
await loadDeployProvider(nextProviderId, { fallbackToExisting: true });
}
@ -3696,6 +3787,9 @@ function HtmlViewer({
throw new Error(t('fileViewer.deployProviderConfigSaveFailed', { provider: deployProviderLabel }));
}
syncDeployFormFromConfig(deployProviderId, config);
if (deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID) {
await loadCloudflareZones(config);
}
return config;
} catch (err) {
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployProviderConfigSaveFailed', { provider: deployProviderLabel }));
@ -3705,19 +3799,45 @@ function HtmlViewer({
}
}
function buildCloudflarePagesDeploySelection(): WebCloudflarePagesDeploySelection | undefined {
if (deployProviderId !== CLOUDFLARE_PAGES_PROVIDER_ID) return undefined;
const prefix = normalizeCloudflareDomainPrefixInput(cloudflareDomainPrefix);
if (!prefix) return undefined;
if (!isValidCloudflareDomainPrefixInput(prefix)) {
throw new Error(t('fileViewer.cloudflareDomainPrefixInvalid'));
}
const zone = cloudflareZones.find((item) => item.id === cloudflareZoneId);
if (!zone) {
throw new Error(t('fileViewer.cloudflareZoneRequired'));
}
return {
zoneId: zone.id,
zoneName: zone.name,
domainPrefix: prefix,
};
}
async function deployToSelectedProvider() {
setDeploying(true);
setDeployPhase('deploying');
setDeployError(null);
setCopiedDeployLink(false);
setCopiedDeployLink(null);
try {
const cloudflarePagesSelection = buildCloudflarePagesDeploySelection();
const typedToken = deployToken.trim();
const hasNewToken = typedToken && typedToken !== deployConfig?.tokenMask;
const cloudflareHints = cloudflareConfigHintsFromForm();
const cloudflareHintsChanged = deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID && Boolean(
cloudflareHints?.lastZoneId !== deployConfig?.cloudflarePages?.lastZoneId ||
cloudflareHints?.lastZoneName !== deployConfig?.cloudflarePages?.lastZoneName ||
cloudflareHints?.lastDomainPrefix !== deployConfig?.cloudflarePages?.lastDomainPrefix,
);
const needsConfigSave =
hasNewToken ||
teamId.trim() !== (deployConfig?.teamId || '') ||
teamSlug.trim() !== (deployConfig?.teamSlug || '') ||
cloudflareAccountId.trim() !== (deployConfig?.accountId || '') ||
cloudflareHintsChanged ||
!deployConfig?.configured;
if (needsConfigSave) {
const nextConfig = await saveDeployConfig();
@ -3728,7 +3848,7 @@ function HtmlViewer({
}
}
setDeployPhase('preparing-link');
const next = await deployProjectFile(projectId, file.name, deployProviderId);
const next = await deployProjectFile(projectId, file.name, deployProviderId, cloudflarePagesSelection);
setDeploymentsByProvider((current) => ({
...current,
[next.providerId]: next,
@ -3782,8 +3902,10 @@ function HtmlViewer({
document.execCommand('copy');
document.body.removeChild(textarea);
}
setCopiedDeployLink(true);
window.setTimeout(() => setCopiedDeployLink(false), 1800);
setCopiedDeployLink(safeUrl);
window.setTimeout(() => {
setCopiedDeployLink((current) => (current === safeUrl ? null : current));
}, 1800);
}
function presentInThisTab() {
@ -3871,12 +3993,63 @@ function HtmlViewer({
const boardAvailable = source !== null;
const activeDeployment = deployResult || deployment;
const activeDeployedUrl = activeDeployment?.url?.trim() || '';
const activeDeploymentReady = activeDeployment?.status === 'ready';
const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed';
const activeDeploymentProtected = activeDeployment?.status === 'protected';
const activeDeploymentNeedsRetry = activeDeploymentDelayed || activeDeploymentProtected;
const activeCloudflarePages = activeDeployment?.providerId === CLOUDFLARE_PAGES_PROVIDER_ID
? activeDeployment.cloudflarePages
: undefined;
const activeCloudflareCustomDomain = activeCloudflarePages?.customDomain;
const deployProvider = getDeployProviderOption(deployProviderId);
const deployProviderLabel = t(deployProvider.labelKey);
const selectedCloudflareZone = cloudflareZones.find((zone) => zone.id === cloudflareZoneId) ?? null;
const normalizedCloudflarePrefix = normalizeCloudflareDomainPrefixInput(cloudflareDomainPrefix);
const cloudflareHostnamePreview =
selectedCloudflareZone && normalizedCloudflarePrefix
? `${normalizedCloudflarePrefix}.${selectedCloudflareZone.name}`
: '';
const deployResultCards: DeployResultCard[] = activeCloudflarePages
? (() => {
const cards: DeployResultCard[] = [];
const pagesDevUrl = activeCloudflarePages.pagesDev?.url || activeDeployedUrl;
if (pagesDevUrl) {
cards.push({
id: 'pages-dev',
label: t('fileViewer.cloudflarePagesDevLinkLabel'),
url: pagesDevUrl,
status: activeCloudflarePages.pagesDev?.status || activeDeployment?.status || 'link-delayed',
message: activeCloudflarePages.pagesDev?.statusMessage,
});
}
if (activeCloudflareCustomDomain?.url) {
cards.push({
id: 'custom-domain',
label: t('fileViewer.cloudflareCustomDomainLinkLabel'),
url: activeCloudflareCustomDomain.url,
status: activeCloudflareCustomDomain.status,
message:
activeCloudflareCustomDomain.errorMessage ||
activeCloudflareCustomDomain.statusMessage,
});
}
return cards;
})()
: activeDeployedUrl
? [{
id: 'default',
label: activeDeploymentProtected
? t('fileViewer.deployLinkProtectedLabel')
: activeDeploymentDelayed
? t('fileViewer.deployLinkPreparingLabel')
: t('fileViewer.deployResultLabel'),
url: activeDeployedUrl,
status: activeDeployment?.status || 'ready',
message: activeDeploymentProtected
? t('fileViewer.deployLinkProtected')
: activeDeploymentDelayed
? t('fileViewer.deployLinkDelayed')
: activeDeployment?.statusMessage,
}]
: [];
const deployActionLabelFor = (providerId: WebDeployProviderId) => {
const option = getDeployProviderOption(providerId);
const label = t(option.labelKey);
@ -3896,13 +4069,20 @@ function HtmlViewer({
: deployPhase === 'preparing-link'
? t('fileViewer.preparingPublicLink')
: t('fileViewer.deployToProvider', { provider: deployProviderLabel });
const copyDeployLabel = copiedDeployLink
? t('fileViewer.copied')
: t('fileViewer.copyDeployLink');
const copyDeployMenuLabel = (providerLabel: string) =>
copiedDeployLink
const copyDeployLabel = (url: string) =>
copiedDeployLink === url.trim()
? t('fileViewer.copied')
: t('fileViewer.copyDeployLink');
const copyDeployMenuLabel = (providerLabel: string, url: string) =>
copiedDeployLink === url.trim()
? t('fileViewer.copied')
: `${t('fileViewer.copyDeployLink')} · ${providerLabel}`;
const statusLabelFor = (state: ReturnType<typeof deployResultState>) => {
if (state === 'ready') return t('fileViewer.deployLinkReady');
if (state === 'protected') return t('fileViewer.deployLinkProtectedLabel');
if (state === 'failed') return t('fileViewer.deployLinkFailed');
return t('fileViewer.deployLinkPreparingLabel');
};
return (
<div className="viewer html-viewer">
@ -4265,7 +4445,7 @@ function HtmlViewer({
}}
>
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
<span>{copyDeployMenuLabel(item.providerLabel)}</span>
<span>{copyDeployMenuLabel(item.providerLabel, item.url)}</span>
</button>
))}
</div>
@ -4542,13 +4722,21 @@ function HtmlViewer({
</label>
<div className="field-label-row">
<label htmlFor="deploy-token">{t(deployProvider.tokenLabelKey)}</label>
<a
href={deployProvider.tokenLink}
target="_blank"
rel="noreferrer noopener"
>
{t(deployProvider.tokenLinkKey)}
</a>
<div className="field-label-note">
{deployConfig?.configured ? (
<p className="hint">{t(deployProvider.tokenReuseHintKey, { provider: deployProviderLabel })}</p>
) : null}
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
<p className="hint">{t('fileViewer.cloudflareApiTokenScopeHint')}</p>
) : null}
<a
href={deployProvider.tokenLink}
target="_blank"
rel="noreferrer noopener"
>
{t(deployProvider.tokenLinkKey)}
</a>
</div>
</div>
<input
id="deploy-token"
@ -4569,23 +4757,74 @@ function HtmlViewer({
{savingDeployConfig ? t('fileViewer.savingConfig') : t('fileViewer.save')}
</button>
</div>
{deployConfig?.configured ? (
<p className="hint">{t(deployProvider.tokenReuseHintKey, { provider: deployProviderLabel })}</p>
) : null}
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
<p className="hint">{t('fileViewer.cloudflareApiTokenScopeHint')}</p>
) : null}
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
<div className="deploy-field-grid single-field">
<label>
<span>{t('fileViewer.cloudflareAccountId')}</span>
<input
value={cloudflareAccountId}
onChange={(e) => setCloudflareAccountId(e.target.value)}
/>
<span className="field-hint">{t('fileViewer.cloudflareAccountIdHint')}</span>
</label>
</div>
<>
<div className="deploy-field-grid single-field">
<label>
<span>{t('fileViewer.cloudflareAccountId')}</span>
<input
value={cloudflareAccountId}
onChange={(e) => setCloudflareAccountId(e.target.value)}
/>
<span className="field-hint">{t('fileViewer.cloudflareAccountIdHint')}</span>
</label>
</div>
<div className="deploy-field-grid cloudflare-domain-grid">
<label>
<span>{t('fileViewer.cloudflareDomainPrefixLabel')}</span>
<input
value={cloudflareDomainPrefix}
placeholder={t('fileViewer.cloudflareDomainPrefixPlaceholder')}
onChange={(e) => setCloudflareDomainPrefix(e.target.value)}
/>
</label>
<label>
<span>{t('fileViewer.cloudflareZoneLabel')}</span>
<select
value={cloudflareZoneId}
disabled={cloudflareZonesLoading || (!deployConfig?.configured && !cloudflareZones.length)}
onChange={(e) => setCloudflareZoneId(e.target.value)}
>
{cloudflareZones.length === 0 ? (
<option value="">{t('fileViewer.cloudflareZonePlaceholder')}</option>
) : null}
{cloudflareZones.map((zone) => (
<option key={zone.id} value={zone.id}>
{zone.name}
</option>
))}
</select>
</label>
</div>
<div className="deploy-config-actions secondary">
<button
type="button"
className="ghost-link button-like"
disabled={cloudflareZonesLoading || !deployConfig?.configured}
onClick={() => {
void loadCloudflareZones();
}}
>
{cloudflareZonesLoading ? t('fileViewer.cloudflareZonesLoading') : t('fileViewer.cloudflareZonesRefresh')}
</button>
</div>
{cloudflareZonesError ? (
<p className="deploy-error">{cloudflareZonesError}</p>
) : cloudflareZonesLoading ? (
<p className="hint">{t('fileViewer.cloudflareZonesLoading')}</p>
) : deployConfig?.configured && cloudflareZones.length === 0 ? (
<p className="hint">{t('fileViewer.cloudflareZonesEmpty')}</p>
) : (
<p className="hint">{t('fileViewer.cloudflareCustomDomainHint')}</p>
)}
{cloudflareDomainPrefix.trim() && !isValidCloudflareDomainPrefixInput(cloudflareDomainPrefix) ? (
<p className="deploy-error">{t('fileViewer.cloudflareDomainPrefixInvalid')}</p>
) : cloudflareHostnamePreview ? (
<p className="hint">
{t('fileViewer.cloudflareHostnamePreview', { hostname: cloudflareHostnamePreview })}
</p>
) : null}
</>
) : (
<div className="deploy-field-grid">
<label>
@ -4608,64 +4847,82 @@ function HtmlViewer({
)}
<p className="hint">{t(deployProvider.previewHintKey)}</p>
{deployError ? <p className="deploy-error">{deployError}</p> : null}
{activeDeployedUrl ? (
<div
className={`deploy-result ${
activeDeploymentProtected ? 'protected' : activeDeploymentDelayed ? 'delayed' : 'ready'
}`}
>
<div className="deploy-result-label">
{activeDeploymentProtected
? t('fileViewer.deployLinkProtectedLabel')
: activeDeploymentDelayed
? t('fileViewer.deployLinkPreparingLabel')
: t('fileViewer.deployResultLabel')}
</div>
{activeDeploymentNeedsRetry ? (
<p className="deploy-result-message">
{activeDeploymentProtected
? t('fileViewer.deployLinkProtected')
: t('fileViewer.deployLinkDelayed')}
</p>
) : null}
<a href={activeDeployedUrl} target="_blank" rel="noreferrer noopener">
{activeDeployedUrl}
</a>
<div className="deploy-result-actions">
{activeDeploymentNeedsRetry ? (
<button
type="button"
className="viewer-action"
disabled={deployPhase === 'preparing-link'}
onClick={() => {
void retryDeploymentLink();
}}
>
{deployPhase === 'preparing-link'
? t('fileViewer.preparingPublicLink')
: t('fileViewer.retryLink')}
</button>
{deployResultCards.length > 0 ? (
<div className={`deploy-result-block ${deployResultState(activeDeployment?.status)}`}>
<div className="deploy-result-summary">
<div className="deploy-result-summary-head">
<div className="deploy-result-label">{t('fileViewer.deployResultLabel')}</div>
<div className={`deploy-result-badge ${deployResultState(activeDeployment?.status)}`}>
{statusLabelFor(deployResultState(activeDeployment?.status))}
</div>
</div>
{activeDeployment?.statusMessage ? (
<p className="deploy-result-message">{activeDeployment.statusMessage}</p>
) : null}
<button
type="button"
className="viewer-action"
onClick={() => {
void copyDeployLink(activeDeployedUrl);
}}
>
<Icon name="copy" size={14} />
<span>{copyDeployLabel}</span>
</button>
<a
className={`ghost-link ${activeDeploymentProtected ? 'disabled' : ''}`}
href={activeDeploymentProtected ? undefined : activeDeployedUrl}
target="_blank"
rel="noreferrer noopener"
aria-disabled={activeDeploymentProtected}
>
<Icon name="upload" size={14} />
{t('fileViewer.open')}
</a>
<div className="deploy-result-links">
{deployResultCards.map((card) => {
const state = deployResultState(card.status);
const canRetry = state === 'delayed' || state === 'protected';
const isDisabled = state === 'protected' || state === 'failed';
return (
<div key={card.id} className={`deploy-result-link ${state}`}>
<div className="deploy-result-link-main">
<div className="deploy-result-link-head">
<span className="deploy-result-link-label">{card.label}</span>
<span className={`deploy-result-link-state ${state}`}>{statusLabelFor(state)}</span>
</div>
{card.message ? (
<p className="deploy-result-link-message">{card.message}</p>
) : null}
<a
className="deploy-result-url"
href={card.url}
target="_blank"
rel="noreferrer noopener"
>
{card.url}
</a>
</div>
<div className="deploy-result-actions">
{canRetry ? (
<button
type="button"
className="viewer-action"
disabled={deployPhase === 'preparing-link'}
onClick={() => {
void retryDeploymentLink();
}}
>
{deployPhase === 'preparing-link'
? t('fileViewer.preparingPublicLink')
: t('fileViewer.retryLink')}
</button>
) : null}
<button
type="button"
className="viewer-action"
onClick={() => {
void copyDeployLink(card.url);
}}
>
<Icon name="copy" size={14} />
<span>{copyDeployLabel(card.url)}</span>
</button>
<a
className={`ghost-link ${isDisabled ? 'disabled' : ''}`}
href={isDisabled ? undefined : card.url}
target="_blank"
rel="noreferrer noopener"
aria-disabled={isDisabled}
>
<Icon name="upload" size={14} />
{t('fileViewer.open')}
</a>
</div>
</div>
);
})}
</div>
</div>
</div>
) : null}

View file

@ -715,12 +715,26 @@ export const ar: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': 'الصق رمز Cloudflare API الخاص بك',
'fileViewer.cloudflareApiTokenReuseHint': 'سيتم استخدام رمز Cloudflare API المحفوظ. أدخل رمزاً جديداً لاستبداله.',
'fileViewer.cloudflareApiTokenRequired': 'أدخل واحفظ رمز Cloudflare API أولاً.',
'fileViewer.cloudflareApiTokenScopeHint': 'يحتاج الرمز إلى صلاحية Account: Cloudflare Pages: Edit مع صلاحية قراءة الحساب.',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'معرف الفريق',
'fileViewer.vercelTeamSlug': 'اسم الفريق اللطيف',
'fileViewer.cloudflareAccountId': 'معرف الحساب',
'fileViewer.cloudflareAccountIdHint': 'مطلوب. اعثر على معرف الحساب في لوحة Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'أدخل واحفظ Cloudflare Account ID أولاً.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'اختياري',
'fileViewer.vercelPreviewOnly': 'النشر للمعاينة فقط حالياً.',
'fileViewer.cloudflarePagesPreviewHint': 'تستخدم Cloudflare Pages أسلوب Direct Upload.',
@ -730,8 +744,10 @@ export const ar: Dict = {
'fileViewer.deployProviderConfigSaveFailed': 'تعذر حفظ إعدادات {provider}.',
'fileViewer.deployProviderFailed': 'فشل النشر إلى {provider}. تحقق من الإعدادات وحاول مرة أخرى.',
'fileViewer.deployResultLabel': 'رابط النشر',
'fileViewer.deployLinkReady': 'جاهز',
'fileViewer.deployLinkPreparingLabel': 'الرابط العام معلق',
'fileViewer.deployLinkDelayed': 'تم نشر الموقع، لكن الرابط العام ما زال قيد التحضير.',
'fileViewer.deployLinkFailed': 'فشل النطاق المخصص',
'fileViewer.deployLinkProtectedLabel': 'حماية النشر مفعلة',
'fileViewer.deployLinkProtected': 'تم نشر الموقع، لكن رابط المعاينة هذا يتطلب المصادقة. عطّل Deployment Protection أو استخدم نطاقاً مخصصاً.',
'fileViewer.retryLink': 'إعادة المحاولة الآن',

View file

@ -669,12 +669,26 @@ export const de: Dict = {
'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.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'Team-ID',
'fileViewer.vercelTeamSlug': 'Team-Slug',
'fileViewer.cloudflareAccountId': 'Account-ID',
'fileViewer.cloudflareAccountIdHint': 'Erforderlich. Die Account-ID findest du im Cloudflare-Dashboard.',
'fileViewer.cloudflareAccountIdRequired': 'Gib zuerst eine Cloudflare Account ID ein und speichere sie.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'Optional',
'fileViewer.vercelPreviewOnly': 'Deployments sind derzeit nur Preview-Deployments.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages nutzt Direct Upload.',
@ -684,8 +698,10 @@ export const de: Dict = {
'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.deployLinkReady': 'Bereit',
'fileViewer.deployLinkPreparingLabel': 'Öffentlicher Link ausstehend',
'fileViewer.deployLinkDelayed': 'Die Seite wurde deployt. Der Anbieter bereitet den öffentlichen Link noch vor.',
'fileViewer.deployLinkFailed': 'Benutzerdefinierte Domain fehlgeschlagen',
'fileViewer.deployLinkProtectedLabel': 'Deployment-Schutz aktiviert',
'fileViewer.deployLinkProtected': 'Die Seite wurde deployt, aber dieser Vorschau-Link erfordert eine Anmeldung. Deaktiviere Deployment Protection oder nutze eine eigene Domain.',
'fileViewer.retryLink': 'Jetzt erneut versuchen',

View file

@ -746,12 +746,26 @@ export const en: Dict = {
'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.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': 'Required. Find the account ID in the Cloudflare dashboard.',
'fileViewer.cloudflareAccountIdRequired': 'Enter and save a Cloudflare Account ID first.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'Optional',
'fileViewer.vercelPreviewOnly': 'Deploys are Preview-only for now.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages deploys use Direct Upload.',
@ -761,8 +775,10 @@ export const en: Dict = {
'fileViewer.deployProviderConfigSaveFailed': 'Could not save {provider} settings.',
'fileViewer.deployProviderFailed': '{provider} deploy failed. Check settings and try again.',
'fileViewer.deployResultLabel': 'Deployed URL',
'fileViewer.deployLinkReady': 'Ready',
'fileViewer.deployLinkPreparingLabel': 'Public link pending',
'fileViewer.deployLinkDelayed': 'Your site is deployed. The public link is still being prepared.',
'fileViewer.deployLinkFailed': 'Custom domain failed',
'fileViewer.deployLinkProtectedLabel': 'Deployment protection enabled',
'fileViewer.deployLinkProtected': 'Your site deployed, but this preview link is requiring authentication. Disable Deployment Protection or use a custom domain.',
'fileViewer.retryLink': 'Retry now',

View file

@ -670,12 +670,26 @@ export const esES: Dict = {
'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.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'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.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'Opcional',
'fileViewer.vercelPreviewOnly': 'Los despliegues son solo Preview por ahora.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages usa Direct Upload.',
@ -685,8 +699,10 @@ export const esES: Dict = {
'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.deployLinkReady': 'Listo',
'fileViewer.deployLinkPreparingLabel': 'Enlace público pendiente',
'fileViewer.deployLinkDelayed': 'El sitio se ha desplegado. El proveedor aún está preparando el enlace público.',
'fileViewer.deployLinkFailed': 'Falló el dominio personalizado',
'fileViewer.deployLinkProtectedLabel': 'Protección del despliegue activada',
'fileViewer.deployLinkProtected': 'El sitio se ha desplegado, pero este enlace de vista previa requiere autenticación. Desactiva Deployment Protection o usa un dominio personalizado.',
'fileViewer.retryLink': 'Reintentar ahora',

View file

@ -837,12 +837,26 @@ export const fa: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': 'توکن API کلادفلر خود را وارد کنید',
'fileViewer.cloudflareApiTokenReuseHint': 'از توکن API کلادفلر ذخیره‌شده استفاده می‌شود. برای جایگزینی، توکن جدید وارد کنید.',
'fileViewer.cloudflareApiTokenRequired': 'ابتدا یک توکن API کلادفلر وارد و ذخیره کنید.',
'fileViewer.cloudflareApiTokenScopeHint': 'توکن به مجوز Account: Cloudflare Pages: Edit و دسترسی خواندن حساب نیاز دارد.',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'شناسه تیم',
'fileViewer.vercelTeamSlug': 'اسلاگ تیم',
'fileViewer.cloudflareAccountId': 'شناسه حساب',
'fileViewer.cloudflareAccountIdHint': 'ضروری است. شناسه حساب را در داشبورد Cloudflare پیدا کنید.',
'fileViewer.cloudflareAccountIdRequired': 'ابتدا Cloudflare Account ID را وارد و ذخیره کنید.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'اختیاری',
'fileViewer.vercelPreviewOnly': 'استقرارها فعلاً فقط Preview هستند.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages از Direct Upload استفاده می‌کند.',
@ -852,8 +866,10 @@ export const fa: Dict = {
'fileViewer.deployProviderConfigSaveFailed': 'ذخیره تنظیمات {provider} ممکن نبود.',
'fileViewer.deployProviderFailed': 'استقرار روی {provider} ناموفق بود. تنظیمات را بررسی و دوباره تلاش کنید.',
'fileViewer.deployResultLabel': 'URL مستقرشده',
'fileViewer.deployLinkReady': 'آماده',
'fileViewer.deployLinkPreparingLabel': 'لینک عمومی در انتظار است',
'fileViewer.deployLinkDelayed': 'سایت مستقر شده است. ارائه‌دهنده هنوز لینک عمومی را آماده می‌کند.',
'fileViewer.deployLinkFailed': 'دامنه سفارشی ناموفق بود',
'fileViewer.deployLinkProtectedLabel': 'محافظت استقرار فعال است',
'fileViewer.deployLinkProtected': 'سایت مستقر شده، اما این لینک پیش‌نمایش نیاز به احراز هویت دارد. Deployment Protection را غیرفعال کنید یا از دامنه سفارشی استفاده کنید.',
'fileViewer.retryLink': 'همین حالا دوباره تلاش کنید',

View file

@ -715,12 +715,26 @@ export const fr: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': 'Collez votre jeton API Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'Le jeton API Cloudflare enregistré sera utilisé. Saisissez un nouveau jeton pour le remplacer.',
'fileViewer.cloudflareApiTokenRequired': 'Saisissez et enregistrez dabord un jeton API Cloudflare.',
'fileViewer.cloudflareApiTokenScopeHint': 'Le jeton doit avoir Account: Cloudflare Pages: Edit ainsi quun accès en lecture au compte.',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'ID déquipe',
'fileViewer.vercelTeamSlug': 'Slug déquipe',
'fileViewer.cloudflareAccountId': 'ID de compte',
'fileViewer.cloudflareAccountIdHint': 'Obligatoire. Trouvez lID du compte dans le tableau de bord Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Saisissez et enregistrez dabord un Cloudflare Account ID.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'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.',
@ -730,8 +744,10 @@ export const fr: Dict = {
'fileViewer.deployProviderConfigSaveFailed': 'Impossible denregistrer les réglages {provider}.',
'fileViewer.deployProviderFailed': 'Échec du déploiement {provider}. Vérifiez les réglages et réessayez.',
'fileViewer.deployResultLabel': 'URL déployée',
'fileViewer.deployLinkReady': 'Prêt',
'fileViewer.deployLinkPreparingLabel': 'Lien public en attente',
'fileViewer.deployLinkDelayed': 'Le site est déployé. Le fournisseur prépare encore le lien public.',
'fileViewer.deployLinkFailed': 'Le domaine personnalisé a échoué',
'fileViewer.deployLinkProtectedLabel': 'Protection du déploiement activée',
'fileViewer.deployLinkProtected': 'Le site est déployé, mais ce lien de prévisualisation exige une authentification. Désactivez Deployment Protection ou utilisez un domaine personnalisé.',
'fileViewer.retryLink': 'Réessayer maintenant',

View file

@ -715,12 +715,26 @@ export const hu: Dict = {
'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.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareAccountId': 'Fiók ID',
'fileViewer.cloudflareAccountIdHint': 'Kötelező. A fiók ID-t a Cloudflare irányítópulton találod.',
'fileViewer.cloudflareAccountIdRequired': 'Előbb add meg és mentsd el a Cloudflare Account ID-t.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'Opcionális',
'fileViewer.vercelPreviewOnly': 'A telepítések egyelőre csak Preview-k.',
'fileViewer.cloudflarePagesPreviewHint': 'A Cloudflare Pages Direct Uploadot használ.',
@ -730,8 +744,10 @@ export const hu: Dict = {
'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.deployLinkReady': 'Kész',
'fileViewer.deployLinkPreparingLabel': 'Nyilvános link várólistán',
'fileViewer.deployLinkDelayed': 'A webhely telepítve van. A szolgáltató még készíti a nyilvános linket.',
'fileViewer.deployLinkFailed': 'Az egyéni domain sikertelen',
'fileViewer.deployLinkProtectedLabel': 'Telepítési védelem bekapcsolva',
'fileViewer.deployLinkProtected': 'A webhely telepítve van, de ez az előnézeti link hitelesítést kér. Kapcsold ki a Deployment Protectiont, vagy használj saját domaint.',
'fileViewer.retryLink': 'Újra most',

View file

@ -730,10 +730,24 @@ export const id: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': 'Tempelkan Cloudflare API token Anda',
'fileViewer.cloudflareApiTokenReuseHint': 'Cloudflare API token yang tersimpan akan digunakan. Masukkan token baru untuk menggantinya.',
'fileViewer.cloudflareApiTokenRequired': 'Masukkan dan simpan Cloudflare API token terlebih dahulu.',
'fileViewer.cloudflareApiTokenScopeHint': 'Token memerlukan Account: Cloudflare Pages: Edit ditambah akses baca akun.',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': 'Wajib. Temukan account ID di dashboard Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Masukkan dan simpan Cloudflare Account ID terlebih dahulu.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.cloudflarePagesPreviewHint': 'Deploy Cloudflare Pages menggunakan Direct Upload.',
'fileViewer.deployProviderConfigSaveFailed': 'Tidak dapat menyimpan pengaturan {provider}.',
'fileViewer.deployProviderFailed': 'Deploy {provider} gagal. Periksa pengaturan dan coba lagi.',
@ -754,8 +768,10 @@ export const id: Dict = {
'fileViewer.deployConfigSaveFailed': 'Gagal menyimpan konfigurasi deploy.',
'fileViewer.deployFailed': 'Deploy gagal.',
'fileViewer.deployResultLabel': 'Hasil deploy',
'fileViewer.deployLinkReady': 'Siap',
'fileViewer.deployLinkPreparingLabel': 'Link deploy sedang disiapkan',
'fileViewer.deployLinkDelayed': 'Link publik belum siap. Coba lagi sebentar lagi.',
'fileViewer.deployLinkFailed': 'Domain kustom gagal',
'fileViewer.deployLinkProtectedLabel': 'Deployment dilindungi',
'fileViewer.deployLinkProtected': 'Vercel meminta autentikasi untuk membuka link ini.',
'fileViewer.retryLink': 'Coba link lagi',

View file

@ -668,12 +668,26 @@ export const ja: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': 'Cloudflare API トークンを貼り付け',
'fileViewer.cloudflareApiTokenReuseHint': '保存済みの Cloudflare API トークンが使用されます。新しいトークンを入力すると置き換えられます。',
'fileViewer.cloudflareApiTokenRequired': '最初に Cloudflare API トークンを入力して保存してください。',
'fileViewer.cloudflareApiTokenScopeHint': 'トークンには Account: Cloudflare Pages: Edit とアカウント読み取り権限が必要です。',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'チーム ID',
'fileViewer.vercelTeamSlug': 'チームスラッグ',
'fileViewer.cloudflareAccountId': 'アカウント ID',
'fileViewer.cloudflareAccountIdHint': '必須です。Cloudflare ダッシュボードでアカウント ID を確認できます。',
'fileViewer.cloudflareAccountIdRequired': '最初に Cloudflare Account ID を入力して保存してください。',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': '省略可',
'fileViewer.vercelPreviewOnly': 'デプロイは現在 Preview のみです。',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages は Direct Upload を使用します。',
@ -683,8 +697,10 @@ export const ja: Dict = {
'fileViewer.deployProviderConfigSaveFailed': '{provider} の設定を保存できませんでした。',
'fileViewer.deployProviderFailed': '{provider} のデプロイに失敗しました。設定を確認して再試行してください。',
'fileViewer.deployResultLabel': 'デプロイ URL',
'fileViewer.deployLinkReady': '準備完了',
'fileViewer.deployLinkPreparingLabel': '公開リンク準備中',
'fileViewer.deployLinkDelayed': 'サイトはデプロイされました。プロバイダーが公開リンクを準備中です。',
'fileViewer.deployLinkFailed': 'カスタムドメインに失敗しました',
'fileViewer.deployLinkProtectedLabel': 'デプロイ保護が有効',
'fileViewer.deployLinkProtected': 'サイトはデプロイされましたが、このプレビューリンクには認証が必要です。Deployment Protection を無効にするか、カスタムドメインを使用してください。',
'fileViewer.retryLink': '今すぐ再試行',

View file

@ -715,12 +715,26 @@ export const ko: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': 'Cloudflare API 토큰을 붙여넣으세요',
'fileViewer.cloudflareApiTokenReuseHint': '저장된 Cloudflare API 토큰이 사용됩니다. 변경하려면 새 토큰을 입력하세요.',
'fileViewer.cloudflareApiTokenRequired': '먼저 Cloudflare API 토큰을 입력하고 저장하세요.',
'fileViewer.cloudflareApiTokenScopeHint': '토큰에는 Account: Cloudflare Pages: Edit 권한과 계정 읽기 권한이 필요합니다.',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': '팀 ID (Team ID)',
'fileViewer.vercelTeamSlug': '팀 슬러그 (Team slug)',
'fileViewer.cloudflareAccountId': '계정 ID',
'fileViewer.cloudflareAccountIdHint': '필수입니다. Cloudflare 대시보드에서 계정 ID를 확인하세요.',
'fileViewer.cloudflareAccountIdRequired': '먼저 Cloudflare Account ID를 입력하고 저장하세요.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': '선택 사항',
'fileViewer.vercelPreviewOnly': '현재 배포는 Preview 모드만 지원합니다.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages 배포는 Direct Upload를 사용합니다.',
@ -730,8 +744,10 @@ export const ko: Dict = {
'fileViewer.deployProviderConfigSaveFailed': '{provider} 설정을 저장하지 못했습니다.',
'fileViewer.deployProviderFailed': '{provider} 배포에 실패했습니다. 설정을 확인하고 다시 시도해 주세요.',
'fileViewer.deployResultLabel': '배포된 URL',
'fileViewer.deployLinkReady': '준비됨',
'fileViewer.deployLinkPreparingLabel': '공개 링크 보류 중',
'fileViewer.deployLinkDelayed': '사이트가 배포되었습니다. 플랫폼에서 공개 링크를 아직 준비 중입니다.',
'fileViewer.deployLinkFailed': '사용자 지정 도메인 실패',
'fileViewer.deployLinkProtectedLabel': '배포 보호가 활성화됨',
'fileViewer.deployLinkProtected': '사이트가 배포되었지만 이 미리보기 링크에는 인증이 필요합니다. Deployment Protection을 끄거나 사용자 지정 도메인을 사용하세요.',
'fileViewer.retryLink': '지금 다시 시도',

View file

@ -715,12 +715,26 @@ export const pl: Dict = {
'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.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'ID zespołu',
'fileViewer.vercelTeamSlug': 'Slug zespołu',
'fileViewer.cloudflareAccountId': 'ID konta',
'fileViewer.cloudflareAccountIdHint': 'Wymagane. ID konta znajdziesz w panelu Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Najpierw wprowadź i zapisz Cloudflare Account ID.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'Opcjonalnie',
'fileViewer.vercelPreviewOnly': 'Wdrożenia są obecnie dostępne tylko jako Podgląd (Preview).',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages używa Direct Upload.',
@ -730,8 +744,10 @@ export const pl: Dict = {
'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.deployLinkReady': 'Gotowe',
'fileViewer.deployLinkPreparingLabel': 'Oczekiwanie na link publiczny',
'fileViewer.deployLinkDelayed': 'Strona została wdrożona. Dostawca wciąż przygotowuje publiczny link.',
'fileViewer.deployLinkFailed': 'Domena niestandardowa nie powiodła się',
'fileViewer.deployLinkProtectedLabel': 'Ochrona wdrożenia włączona',
'fileViewer.deployLinkProtected': 'Strona została wdrożona, ale ten link podglądu wymaga uwierzytelnienia. Wyłącz Deployment Protection albo użyj własnej domeny.',
'fileViewer.retryLink': 'Ponów teraz',

View file

@ -745,12 +745,26 @@ export const ptBR: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': 'Cole seu token de API da Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'O token de API da Cloudflare salvo será usado. Insira um novo token para substituí-lo.',
'fileViewer.cloudflareApiTokenRequired': 'Insira e salve primeiro um token de API da Cloudflare.',
'fileViewer.cloudflareApiTokenScopeHint': 'O token precisa de Account: Cloudflare Pages: Edit e acesso de leitura à conta.',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'ID da equipe',
'fileViewer.vercelTeamSlug': 'Slug da equipe',
'fileViewer.cloudflareAccountId': 'ID da conta',
'fileViewer.cloudflareAccountIdHint': 'Obrigatório. Encontre o ID da conta no painel da Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Insira e salve primeiro um Cloudflare Account ID.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'Opcional',
'fileViewer.vercelPreviewOnly': 'As implantações são apenas Preview por enquanto.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages usa Direct Upload.',
@ -760,8 +774,10 @@ export const ptBR: Dict = {
'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.deployLinkReady': 'Pronto',
'fileViewer.deployLinkPreparingLabel': 'Link público pendente',
'fileViewer.deployLinkDelayed': 'Seu site foi implantado. O provedor ainda está preparando o link público.',
'fileViewer.deployLinkFailed': 'Falha no domínio personalizado',
'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',

View file

@ -745,12 +745,26 @@ export const ru: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': 'Вставьте токен API Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'Будет использован сохранённый токен API Cloudflare. Введите новый, чтобы заменить его.',
'fileViewer.cloudflareApiTokenRequired': 'Сначала введите и сохраните токен API Cloudflare.',
'fileViewer.cloudflareApiTokenScopeHint': 'Токену нужны права Account: Cloudflare Pages: Edit и доступ на чтение аккаунта.',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'ID команды',
'fileViewer.vercelTeamSlug': 'Слаг команды',
'fileViewer.cloudflareAccountId': 'ID аккаунта',
'fileViewer.cloudflareAccountIdHint': 'Обязательно. ID аккаунта можно найти в панели Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Сначала введите и сохраните Cloudflare Account ID.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'Необязательно',
'fileViewer.vercelPreviewOnly': 'Пока поддерживаются только Preview-развёртывания.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages использует Direct Upload.',
@ -760,8 +774,10 @@ export const ru: Dict = {
'fileViewer.deployProviderConfigSaveFailed': 'Не удалось сохранить настройки {provider}.',
'fileViewer.deployProviderFailed': 'Развёртывание на {provider} не удалось. Проверьте настройки и попробуйте снова.',
'fileViewer.deployResultLabel': 'URL развёрнутого сайта',
'fileViewer.deployLinkReady': 'Готово',
'fileViewer.deployLinkPreparingLabel': 'Публичная ссылка готовится',
'fileViewer.deployLinkDelayed': 'Сайт развёрнут. Провайдер всё ещё готовит публичную ссылку.',
'fileViewer.deployLinkFailed': 'Пользовательский домен не настроен',
'fileViewer.deployLinkProtectedLabel': 'Защита развёртывания включена',
'fileViewer.deployLinkProtected': 'Сайт развёрнут, но эта ссылка предпросмотра требует аутентификации. Отключите Deployment Protection или используйте собственный домен.',
'fileViewer.retryLink': 'Повторить',

View file

@ -706,12 +706,26 @@ export const tr: Dict = {
'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.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'Takım ID',
'fileViewer.vercelTeamSlug': 'Takım slugı',
'fileViewer.cloudflareAccountId': 'Hesap ID',
'fileViewer.cloudflareAccountIdHint': 'Zorunlu. Hesap IDsini Cloudflare panosunda bulabilirsiniz.',
'fileViewer.cloudflareAccountIdRequired': 'Önce Cloudflare Account ID girin ve kaydedin.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'Opsiyonel',
'fileViewer.vercelPreviewOnly': 'Yayınlanmış içerikler şimdilik yalnızca önizlenebilir.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages yayınları Direct Upload kullanır.',
@ -721,8 +735,10 @@ export const tr: Dict = {
'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.deployLinkReady': 'Hazır',
'fileViewer.deployLinkPreparingLabel': 'Herkese açık link bekleniyor',
'fileViewer.deployLinkDelayed': 'Site yayınlandı. Sağlayıcı herkese açık bağlantıyı hâlâ hazırlıyor.',
'fileViewer.deployLinkFailed': 'Özel alan adı başarısız',
'fileViewer.deployLinkProtectedLabel': 'Yayın koruması etkin',
'fileViewer.deployLinkProtected': 'Site yayınlandı, ancak bu önizleme bağlantısı kimlik doğrulaması istiyor. Deployment Protectionı kapatın veya özel alan adı kullanın.',
'fileViewer.retryLink': 'Şimdi yeniden dene',

View file

@ -746,12 +746,26 @@ export const uk: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': 'Вставте токен API Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'Збережений токен API Cloudflare буде використаний. Введіть новий токен, щоб замінити його.',
'fileViewer.cloudflareApiTokenRequired': 'Спочатку введіть та збережіть токен API Cloudflare.',
'fileViewer.cloudflareApiTokenScopeHint': 'Токену потрібні права Account: Cloudflare Pages: Edit і доступ на читання акаунта.',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit is required for deploys. Zone Read is required to list domains. DNS Edit is only needed when binding a custom domain.',
'fileViewer.vercelTeamId': 'ID команди',
'fileViewer.vercelTeamSlug': 'Слаг команди',
'fileViewer.cloudflareAccountId': 'ID акаунта',
'fileViewer.cloudflareAccountIdHint': 'Обов’язково. ID акаунта можна знайти в панелі Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Спочатку введіть і збережіть Cloudflare Account ID.',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': 'Необов’язково',
'fileViewer.vercelPreviewOnly': 'Розгортання наразі лише для попереднього перегляду.',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages використовує Direct Upload.',
@ -761,8 +775,10 @@ export const uk: Dict = {
'fileViewer.deployProviderConfigSaveFailed': 'Не вдалося зберегти налаштування {provider}.',
'fileViewer.deployProviderFailed': 'Розгортання на {provider} не вдалося. Перевірте налаштування й спробуйте ще раз.',
'fileViewer.deployResultLabel': 'URL розгортання',
'fileViewer.deployLinkReady': 'Готово',
'fileViewer.deployLinkPreparingLabel': 'Публічне посилання очікує',
'fileViewer.deployLinkDelayed': 'Сайт розгорнуто. Провайдер ще готує публічне посилання.',
'fileViewer.deployLinkFailed': 'Не вдалося налаштувати власний домен',
'fileViewer.deployLinkProtectedLabel': 'Захист розгортання ввімкнено',
'fileViewer.deployLinkProtected': 'Сайт розгорнуто, але це посилання попереднього перегляду вимагає автентифікації. Вимкніть Deployment Protection або використайте власний домен.',
'fileViewer.retryLink': 'Повторити зараз',

View file

@ -732,12 +732,26 @@ export const zhCN: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': '粘贴你的 Cloudflare API token',
'fileViewer.cloudflareApiTokenReuseHint': '将使用已保存的 Cloudflare API token。输入新 token 可替换。',
'fileViewer.cloudflareApiTokenRequired': '请先输入并保存 Cloudflare API token。',
'fileViewer.cloudflareApiTokenScopeHint': 'Token 需要 Account: Cloudflare Pages: Edit 权限,以及账号读取权限。',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit 是部署必需权限;列出域名需要 Zone Read只有绑定自定义域名时才需要 DNS Edit。',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': '必填。可在 Cloudflare 控制台中找到账号 ID。',
'fileViewer.cloudflareAccountIdRequired': '请先输入并保存 Cloudflare Account ID。',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': '可选',
'fileViewer.vercelPreviewOnly': '当前仅部署 Preview。',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages 使用 Direct Upload。',
@ -747,8 +761,10 @@ export const zhCN: Dict = {
'fileViewer.deployProviderConfigSaveFailed': '无法保存 {provider} 设置。',
'fileViewer.deployProviderFailed': '{provider} 部署失败。请检查设置后重试。',
'fileViewer.deployResultLabel': '部署链接',
'fileViewer.deployLinkReady': '已就绪',
'fileViewer.deployLinkPreparingLabel': '公开链接准备中',
'fileViewer.deployLinkDelayed': '站点已经部署,平台仍在准备公开链接。',
'fileViewer.deployLinkFailed': '自定义域名失败',
'fileViewer.deployLinkProtectedLabel': '部署访问保护已开启',
'fileViewer.deployLinkProtected': '站点已部署,但此预览链接要求登录后才能访问。请关闭 Deployment Protection 或使用自定义域名。',
'fileViewer.retryLink': '立即重试',

View file

@ -732,12 +732,26 @@ export const zhTW: Dict = {
'fileViewer.cloudflareApiTokenPlaceholder': '貼上你的 Cloudflare API token',
'fileViewer.cloudflareApiTokenReuseHint': '將使用已儲存的 Cloudflare API token。輸入新 token 可替換。',
'fileViewer.cloudflareApiTokenRequired': '請先輸入並儲存 Cloudflare API token。',
'fileViewer.cloudflareApiTokenScopeHint': 'Token 需要 Account: Cloudflare Pages: Edit 權限,以及帳號讀取權限。',
'fileViewer.cloudflareApiTokenScopeHint': 'Pages Edit 是部署必需權限;列出網域需要 Zone Read只有綁定自訂網域時才需要 DNS Edit。',
'fileViewer.vercelTeamId': 'Team ID',
'fileViewer.vercelTeamSlug': 'Team slug',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': '必填。可在 Cloudflare 控制台中找到帳號 ID。',
'fileViewer.cloudflareAccountIdRequired': '請先輸入並儲存 Cloudflare Account ID。',
'fileViewer.cloudflareZoneLabel': 'Domain',
'fileViewer.cloudflareZonePlaceholder': 'Save Cloudflare settings to load domains',
'fileViewer.cloudflareZoneRequired': 'Select a Cloudflare domain first.',
'fileViewer.cloudflareZonesLoading': 'Loading Cloudflare domains…',
'fileViewer.cloudflareZonesRefresh': 'Refresh domains',
'fileViewer.cloudflareZonesLoadFailed': 'Could not load Cloudflare domains.',
'fileViewer.cloudflareZonesEmpty': 'No active full Cloudflare domains were found for this account.',
'fileViewer.cloudflareDomainPrefixLabel': 'Subdomain prefix',
'fileViewer.cloudflareDomainPrefixPlaceholder': 'demo',
'fileViewer.cloudflareDomainPrefixInvalid': 'Use one DNS label only: lowercase letters, numbers, and hyphens.',
'fileViewer.cloudflareHostnamePreview': 'Custom domain preview: {hostname}',
'fileViewer.cloudflareCustomDomainHint': 'Optional: choose a Cloudflare domain and prefix to bind a custom subdomain. pages.dev will still be available.',
'fileViewer.cloudflarePagesDevLinkLabel': 'pages.dev URL',
'fileViewer.cloudflareCustomDomainLinkLabel': 'Custom domain',
'fileViewer.optional': '可選',
'fileViewer.vercelPreviewOnly': '目前僅部署 Preview。',
'fileViewer.cloudflarePagesPreviewHint': 'Cloudflare Pages 使用 Direct Upload。',
@ -747,8 +761,10 @@ export const zhTW: Dict = {
'fileViewer.deployProviderConfigSaveFailed': '無法儲存 {provider} 設定。',
'fileViewer.deployProviderFailed': '{provider} 部署失敗。請檢查設定後重試。',
'fileViewer.deployResultLabel': '部署連結',
'fileViewer.deployLinkReady': '已就緒',
'fileViewer.deployLinkPreparingLabel': '公開連結準備中',
'fileViewer.deployLinkDelayed': '站點已部署,平台仍在準備公開連結。',
'fileViewer.deployLinkFailed': '自訂網域失敗',
'fileViewer.deployLinkProtectedLabel': '部署存取保護已開啟',
'fileViewer.deployLinkProtected': '站點已部署,但此預覽連結要求登入後才能存取。請關閉 Deployment Protection 或使用自訂網域。',
'fileViewer.retryLink': '立即重試',

View file

@ -807,6 +807,20 @@ export interface Dict {
'fileViewer.cloudflareAccountId': string;
'fileViewer.cloudflareAccountIdHint': string;
'fileViewer.cloudflareAccountIdRequired': string;
'fileViewer.cloudflareZoneLabel': string;
'fileViewer.cloudflareZonePlaceholder': string;
'fileViewer.cloudflareZoneRequired': string;
'fileViewer.cloudflareZonesLoading': string;
'fileViewer.cloudflareZonesRefresh': string;
'fileViewer.cloudflareZonesLoadFailed': string;
'fileViewer.cloudflareZonesEmpty': string;
'fileViewer.cloudflareDomainPrefixLabel': string;
'fileViewer.cloudflareDomainPrefixPlaceholder': string;
'fileViewer.cloudflareDomainPrefixInvalid': string;
'fileViewer.cloudflareHostnamePreview': string;
'fileViewer.cloudflareCustomDomainHint': string;
'fileViewer.cloudflarePagesDevLinkLabel': string;
'fileViewer.cloudflareCustomDomainLinkLabel': string;
'fileViewer.optional': string;
'fileViewer.vercelPreviewOnly': string;
'fileViewer.cloudflarePagesPreviewHint': string;
@ -816,8 +830,10 @@ export interface Dict {
'fileViewer.deployProviderConfigSaveFailed': string;
'fileViewer.deployProviderFailed': string;
'fileViewer.deployResultLabel': string;
'fileViewer.deployLinkReady': string;
'fileViewer.deployLinkPreparingLabel': string;
'fileViewer.deployLinkDelayed': string;
'fileViewer.deployLinkFailed': string;
'fileViewer.deployLinkProtectedLabel': string;
'fileViewer.deployLinkProtected': string;
'fileViewer.retryLink': string;

View file

@ -7172,6 +7172,19 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
justify-content: space-between;
gap: 16px;
}
.field-label-note {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
max-width: min(420px, 70%);
text-align: right;
}
.field-label-note .hint {
margin: 0;
font-size: 12px;
line-height: 1.35;
}
.field-label-row a {
color: var(--accent);
font-size: 13px;
@ -7237,6 +7250,9 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.cloudflare-domain-grid {
grid-template-columns: minmax(180px, 0.85fr) minmax(220px, 1.15fr);
}
.deploy-field-grid.single-field {
grid-template-columns: minmax(0, 1fr);
}
@ -7254,50 +7270,164 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
margin: 0;
color: var(--red);
}
.deploy-result {
.deploy-result-block {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-panel);
box-shadow: var(--shadow-xs);
}
.deploy-result-block.ready {
border-color: color-mix(in srgb, var(--green) 26%, var(--border));
}
.deploy-result-block.delayed {
border-color: color-mix(in srgb, #b7791f 34%, var(--border));
}
.deploy-result-block.protected,
.deploy-result-block.failed {
border-color: color-mix(in srgb, #c96442 38%, var(--border));
}
.deploy-result-summary {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border-radius: var(--radius-lg);
gap: 6px;
padding: 14px 16px 12px;
background: color-mix(in srgb, var(--bg-subtle) 64%, var(--bg-panel));
}
.deploy-result.ready {
border: 1px solid color-mix(in srgb, var(--green) 35%, var(--border));
background: color-mix(in srgb, var(--green) 10%, var(--bg-panel));
.deploy-result-summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.deploy-result.delayed {
border: 1px solid color-mix(in srgb, #b7791f 42%, var(--border));
background: color-mix(in srgb, #b7791f 10%, var(--bg-panel));
.deploy-result-link-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.deploy-result.protected {
border: 1px solid color-mix(in srgb, #c96442 48%, var(--border));
background: color-mix(in srgb, #c96442 10%, var(--bg-panel));
.deploy-result-badge,
.deploy-result-link-state {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 9px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
}
.deploy-result-badge.ready,
.deploy-result-link-state.ready {
color: #137a3d;
background: color-mix(in srgb, var(--green) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--green) 28%, var(--border));
}
.deploy-result-badge.delayed,
.deploy-result-link-state.delayed {
color: #9a5b12;
background: color-mix(in srgb, #b7791f 14%, transparent);
border: 1px solid color-mix(in srgb, #b7791f 28%, var(--border));
}
.deploy-result-badge.protected,
.deploy-result-badge.failed,
.deploy-result-link-state.protected,
.deploy-result-link-state.failed {
color: #a34828;
background: color-mix(in srgb, #c96442 14%, transparent);
border: 1px solid color-mix(in srgb, #c96442 30%, var(--border));
}
.deploy-result-label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--green);
}
.deploy-result.delayed .deploy-result-label {
color: #9a5b12;
}
.deploy-result.protected .deploy-result-label {
color: #a34828;
color: var(--text);
}
.deploy-result-message {
margin: 0;
color: var(--muted);
color: var(--text-muted);
}
.deploy-result a {
.deploy-result-links {
display: grid;
border-top: 1px solid var(--border);
}
.deploy-result-link {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px 16px;
padding: 14px 16px;
background: var(--bg-panel);
}
.deploy-result-link + .deploy-result-link {
border-top: 1px solid var(--border);
}
.deploy-result-link-main {
display: grid;
min-width: 0;
gap: 6px;
}
.deploy-result-link-label {
color: var(--text);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.01em;
}
.deploy-result-link-message {
margin: 0;
color: var(--text-muted);
}
.deploy-result-url {
min-width: 0;
font-weight: 500;
overflow-wrap: anywhere;
}
.deploy-result-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
gap: 2px;
padding: 3px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-subtle);
}
.deploy-result-actions .viewer-action,
.deploy-result-actions .ghost-link {
min-height: 28px;
border: 0;
background: transparent;
}
.deploy-result-actions .viewer-action:hover:not(:disabled),
.deploy-result-actions .ghost-link:hover {
background: var(--bg-panel);
}
@media (max-width: 640px) {
.deploy-form .field-label-row {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.deploy-form .field-label-note {
align-items: flex-start;
max-width: none;
text-align: left;
}
.deploy-field-grid,
.cloudflare-domain-grid {
grid-template-columns: minmax(0, 1fr);
}
.deploy-result-link {
grid-template-columns: minmax(0, 1fr);
}
.deploy-result-actions {
justify-content: flex-start;
flex-wrap: wrap;
width: fit-content;
}
}
.ghost-link.disabled,
.ghost-link[aria-disabled='true'] {

View file

@ -18,6 +18,8 @@ import type {
PreviewComment,
PreviewCommentStatus,
PreviewCommentUpsertRequest,
CloudflarePagesDeploySelection,
CloudflarePagesZonesResponse,
DeployConfigResponse,
DeployProjectFileResponse,
DesignSystemDetail,
@ -48,6 +50,8 @@ export type WebDeployConfigResponse = DeployConfigResponse;
export type WebUpdateDeployConfigRequest = UpdateDeployConfigRequest;
export type WebDeploymentInfo = ProjectDeploymentsResponse['deployments'][number];
export type WebDeployProjectFileResponse = DeployProjectFileResponse;
export type WebCloudflarePagesDeploySelection = CloudflarePagesDeploySelection;
export type WebCloudflarePagesZonesResponse = CloudflarePagesZonesResponse;
export function isDeployProviderId(value: unknown): value is WebDeployProviderId {
return typeof value === 'string' && (DEPLOY_PROVIDER_IDS as readonly string[]).includes(value);
@ -417,6 +421,22 @@ export async function updateDeployConfig(
}
}
export async function fetchCloudflarePagesZones(): Promise<WebCloudflarePagesZonesResponse | null> {
try {
const resp = await fetch('/api/deploy/cloudflare-pages/zones');
if (!resp.ok) {
const payload = (await resp.json().catch(() => null)) as
| { error?: { message?: string }; message?: string }
| null;
throw new Error(payload?.error?.message || payload?.message || `Could not load Cloudflare zones (${resp.status})`);
}
return (await resp.json()) as WebCloudflarePagesZonesResponse;
} catch (err) {
if (err instanceof Error) throw err;
return null;
}
}
export async function fetchProjectDeployments(
projectId: string,
): Promise<WebDeploymentInfo[]> {
@ -434,11 +454,17 @@ export async function deployProjectFile(
projectId: string,
fileName: string,
providerId: WebDeployProviderId = DEFAULT_DEPLOY_PROVIDER_ID,
cloudflarePages?: WebCloudflarePagesDeploySelection,
): Promise<WebDeployProjectFileResponse> {
const body = {
fileName,
providerId,
...(cloudflarePages ? { cloudflarePages } : {}),
};
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, providerId }),
body: JSON.stringify(body),
});
if (!resp.ok) {
const payload = (await resp.json().catch(() => null)) as

View file

@ -52,7 +52,13 @@ import type {
UpdateDeployConfigRequest,
} from '@open-design/contracts';
export type { PreviewCommentMember, PreviewCommentSelectionKind } from '@open-design/contracts';
export type {
CloudflarePagesDeploySelection,
CloudflarePagesDeploymentInfo,
CloudflarePagesZonesResponse,
PreviewCommentMember,
PreviewCommentSelectionKind,
} from '@open-design/contracts';
export type ExecMode = 'daemon' | 'api';
export type ApiProtocol = 'anthropic' | 'openai' | 'azure' | 'google';

View file

@ -50,6 +50,14 @@ function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
};
}
function deferredResponse() {
let resolve!: (value: Response) => void;
const promise = new Promise<Response>((next) => {
resolve = next;
});
return { promise, resolve };
}
describe('FileViewer SVG artifacts', () => {
it('routes SVG artifacts to the SVG viewer instead of the generic image viewer', () => {
const file = baseFile({
@ -225,7 +233,13 @@ describe('FileViewer SVG artifacts', () => {
expect(await screen.findByRole('dialog')).toBeTruthy();
expect(screen.getByText('Account ID')).toBeTruthy();
expect(screen.getByText(/Cloudflare Pages: Edit/i)).toBeTruthy();
expect(screen.getByText(/Pages Edit is required/i)).toBeTruthy();
expect(screen.getByText(/Zone Read is required to list domains/i)).toBeTruthy();
expect(screen.getByText(/DNS Edit is only needed when binding a custom domain/i)).toBeTruthy();
expect(screen.queryByText(/Pages Read\/Write/i)).toBeNull();
const subdomainInput = screen.getByLabelText('Subdomain prefix');
const domainSelect = screen.getByLabelText('Domain');
expect(Boolean(subdomainInput.compareDocumentPosition(domainSelect) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
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();
@ -310,6 +324,211 @@ describe('FileViewer SVG artifacts', () => {
expect((screen.getByLabelText(/Cloudflare API token/i) as HTMLInputElement).value).toBe('saved-cloudflare-token');
});
it('ignores stale deploy config loads after switching providers', 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 delayedCloudflareConfig = deferredResponse();
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: [] }), { status: 200 });
}
if (url === '/api/deploy/config?providerId=cloudflare-pages') {
return delayedCloudflareConfig.promise;
}
if (url === '/api/deploy/config?providerId=vercel-self') {
return new Response(JSON.stringify({
providerId: 'vercel-self',
configured: true,
tokenMask: 'saved-vercel-token',
}), { status: 200 });
}
if (url === '/api/deploy/cloudflare-pages/zones') {
return new Response(JSON.stringify({
zones: [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }],
}), { 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');
});
fireEvent.change(providerSelect, { target: { value: 'vercel-self' } });
await waitFor(() => {
expect((providerSelect as HTMLSelectElement).value).toBe('vercel-self');
});
expect((screen.getByLabelText(/Vercel token/i) as HTMLInputElement).value).toBe('saved-vercel-token');
delayedCloudflareConfig.resolve(new Response(JSON.stringify({
providerId: 'cloudflare-pages',
configured: true,
tokenMask: 'saved-cloudflare-token',
accountId: 'account-123',
cloudflarePages: {
lastZoneId: 'zone-1',
lastDomainPrefix: 'demo',
},
}), { status: 200 }));
await waitFor(() => {
expect((providerSelect as HTMLSelectElement).value).toBe('vercel-self');
expect((screen.getByLabelText(/Vercel token/i) as HTMLInputElement).value).toBe('saved-vercel-token');
});
expect(screen.queryByLabelText(/Cloudflare API token/i)).toBeNull();
});
it('loads Cloudflare domains, sends the selected custom domain, and renders both links', 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'],
},
});
let deployBody: any = null;
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 === '/api/projects/project-1/deployments') {
return new Response(JSON.stringify({ deployments: [] }), { status: 200 });
}
if (url === '/api/deploy/config?providerId=cloudflare-pages') {
return new Response(JSON.stringify({
providerId: 'cloudflare-pages',
configured: true,
tokenMask: 'saved-cloudflare-token',
teamId: '',
teamSlug: '',
accountId: 'account-123',
target: 'preview',
}), { status: 200 });
}
if (url === '/api/deploy/cloudflare-pages/zones') {
return new Response(JSON.stringify({
zones: [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }],
}), { status: 200 });
}
if (url === '/api/deploy/config' && method === 'PUT') {
const body = JSON.parse(String(init?.body ?? '{}'));
return new Response(JSON.stringify({
providerId: 'cloudflare-pages',
configured: true,
tokenMask: 'saved-cloudflare-token',
teamId: '',
teamSlug: '',
accountId: body.accountId,
cloudflarePages: body.cloudflarePages,
target: 'preview',
}), { status: 200 });
}
if (url === '/api/projects/project-1/deploy' && method === 'POST') {
deployBody = JSON.parse(String(init?.body ?? '{}'));
return new Response(JSON.stringify({
id: 'cloudflare-deploy',
projectId: 'project-1',
fileName: 'index.html',
providerId: 'cloudflare-pages',
url: 'https://demo-pages.pages.dev',
deploymentId: 'cf-dep-1',
deploymentCount: 1,
target: 'preview',
status: 'ready',
cloudflarePages: {
projectName: 'demo-pages',
pagesDev: {
url: 'https://demo-pages.pages.dev',
status: 'ready',
},
customDomain: {
hostname: 'demo.example.com',
url: 'https://demo.example.com',
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
status: 'ready',
dnsStatus: 'created',
domainStatus: 'active',
},
},
createdAt: 1,
updatedAt: 2,
}), { 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 zoneSelect = await screen.findByRole('combobox', { name: /Domain/i });
await waitFor(() => {
expect((zoneSelect as HTMLSelectElement).value).toBe('zone-1');
});
fireEvent.change(screen.getByLabelText(/Subdomain prefix/i), { target: { value: 'demo' } });
const deployButtons = screen.getAllByRole('button', { name: /Deploy to Cloudflare Pages/i });
fireEvent.click(deployButtons[deployButtons.length - 1]!);
const pagesDevLabel = await screen.findByText('pages.dev URL');
const customDomainLabel = await screen.findByText('Custom domain');
expect(customDomainLabel).toBeTruthy();
expect(pagesDevLabel.closest('.deploy-result-block')).toBe(customDomainLabel.closest('.deploy-result-block'));
expect(screen.getByText('https://demo-pages.pages.dev')).toBeTruthy();
expect(screen.getByText('https://demo.example.com')).toBeTruthy();
expect(deployBody).toMatchObject({
fileName: 'index.html',
providerId: 'cloudflare-pages',
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
});
});
it('shows separate copy links for existing Vercel and Cloudflare deployments', async () => {
const file = baseFile({
name: 'index.html',

View file

@ -5,6 +5,7 @@ import {
connectConnector,
DEFAULT_DEPLOY_PROVIDER_ID,
deployProjectFile,
fetchCloudflarePagesZones,
fetchDeployConfig,
fetchAppVersionInfo,
fetchConnectorDiscovery,
@ -257,6 +258,21 @@ describe('deploy provider registry helpers', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/deploy/config?providerId=cloudflare-pages');
});
it('fetches Cloudflare Pages zones from the deploy helper route', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
zones: [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }],
cloudflarePages: { lastZoneId: 'zone-1', lastDomainPrefix: 'demo' },
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(fetchCloudflarePagesZones()).resolves.toEqual({
zones: [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }],
cloudflarePages: { lastZoneId: 'zone-1', lastDomainPrefix: 'demo' },
});
expect(fetchMock).toHaveBeenCalledWith('/api/deploy/cloudflare-pages/zones');
});
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,
@ -291,7 +307,7 @@ describe('deploy provider registry helpers', () => {
});
});
it('passes the selected Cloudflare Pages provider id through deploy requests', async () => {
it('passes the selected Cloudflare Pages provider id and custom domain through deploy requests', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
id: 'deployment-row-1',
projectId: 'project-1',
@ -308,7 +324,11 @@ describe('deploy provider registry helpers', () => {
vi.stubGlobal('fetch', fetchMock);
await expect(
deployProjectFile('project-1', 'index.html', CLOUDFLARE_PAGES_PROVIDER_ID),
deployProjectFile('project-1', 'index.html', CLOUDFLARE_PAGES_PROVIDER_ID, {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
}),
).resolves.toMatchObject({
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
deploymentId: 'cf-deployment-1',
@ -321,6 +341,11 @@ describe('deploy provider registry helpers', () => {
body: JSON.stringify({
fileName: 'index.html',
providerId: CLOUDFLARE_PAGES_PROVIDER_ID,
cloudflarePages: {
zoneId: 'zone-1',
zoneName: 'example.com',
domainPrefix: 'demo',
},
}),
});
});

View file

@ -191,6 +191,92 @@ export type DeploymentStatus =
| 'protected'
| 'failed';
export interface CloudflarePagesConfigHints {
lastZoneId?: string;
lastZoneName?: string;
lastDomainPrefix?: string;
}
export interface CloudflarePagesZoneInfo {
id: string;
name: string;
status?: string;
type?: string;
}
export interface CloudflarePagesZonesResponse {
zones: CloudflarePagesZoneInfo[];
cloudflarePages?: CloudflarePagesConfigHints;
}
export interface CloudflarePagesDeploySelection {
zoneId: string;
zoneName: string;
domainPrefix: string;
}
export type DeploymentLinkStatus =
| 'ready'
| 'link-delayed'
| 'protected'
| 'failed';
export interface DeploymentLinkInfo {
url: string;
status: DeploymentLinkStatus;
statusMessage?: string;
reachableAt?: number;
}
export type CloudflarePagesDnsStatus =
| 'skipped'
| 'created'
| 'reused'
| 'unmarked'
| 'patched'
| 'conflict'
| 'failed';
export type CloudflarePagesDomainStatus =
| 'skipped'
| 'pending'
| 'active'
| 'conflict'
| 'failed';
export type CloudflarePagesCustomDomainStatus =
| 'pending'
| 'ready'
| 'conflict'
| 'failed';
export type CloudflarePagesDnsOwnership = 'marked' | 'unmarked' | 'external';
export interface CloudflarePagesCustomDomainInfo {
hostname: string;
url: string;
zoneId: string;
zoneName: string;
domainPrefix: string;
status: CloudflarePagesCustomDomainStatus;
statusMessage?: string;
errorCode?: string;
errorMessage?: string;
dnsStatus?: CloudflarePagesDnsStatus;
dnsRecordId?: string;
dnsOwnership?: CloudflarePagesDnsOwnership;
domainStatus?: CloudflarePagesDomainStatus;
pagesDomainStatus?: string;
validationData?: unknown;
verificationData?: unknown;
}
export interface CloudflarePagesDeploymentInfo {
projectName: string;
pagesDev: DeploymentLinkInfo;
customDomain?: CloudflarePagesCustomDomainInfo;
}
export interface DeployConfigResponse {
providerId: DeployProviderId;
configured: boolean;
@ -199,6 +285,7 @@ export interface DeployConfigResponse {
teamSlug: string;
accountId?: string;
projectName?: string;
cloudflarePages?: CloudflarePagesConfigHints;
target: 'preview';
}
@ -209,6 +296,7 @@ export interface UpdateDeployConfigRequest {
teamSlug?: string;
accountId?: string;
projectName?: string;
cloudflarePages?: CloudflarePagesConfigHints;
}
export interface DeploymentInfo {
@ -223,7 +311,7 @@ export interface DeploymentInfo {
status: DeploymentStatus;
statusMessage?: string;
reachableAt?: number;
providerMetadata?: Record<string, unknown>;
cloudflarePages?: CloudflarePagesDeploymentInfo;
createdAt: number;
updatedAt: number;
}
@ -235,6 +323,7 @@ export interface ProjectDeploymentsResponse {
export interface DeployProjectFileRequest {
fileName: string;
providerId?: DeployProviderId;
cloudflarePages?: CloudflarePagesDeploySelection;
}
export interface DeployProjectFileResponse extends DeploymentInfo {}