mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
77824ec029
commit
2eae7da24b
31 changed files with 3467 additions and 161 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -51,3 +51,6 @@ tsconfig.tsbuildinfo
|
|||
task.md
|
||||
specs/change/active
|
||||
.ralph/
|
||||
|
||||
# Local design assistant context
|
||||
.impeccable.md
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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': 'إعادة المحاولة الآن',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'همین حالا دوباره تلاش کنید',
|
||||
|
|
|
|||
|
|
@ -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 d’abord un jeton API Cloudflare.',
|
||||
'fileViewer.cloudflareApiTokenScopeHint': 'Le jeton doit avoir Account: Cloudflare Pages: Edit ainsi qu’un accès en lecture au compte.',
|
||||
'fileViewer.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 l’ID du compte dans le tableau de bord Cloudflare.',
|
||||
'fileViewer.cloudflareAccountIdRequired': 'Saisissez et enregistrez d’abord 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 d’enregistrer les réglages {provider}.',
|
||||
'fileViewer.deployProviderFailed': 'Échec du déploiement {provider}. Vérifiez les réglages et réessayez.',
|
||||
'fileViewer.deployResultLabel': 'URL déployée',
|
||||
'fileViewer.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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '今すぐ再試行',
|
||||
|
|
|
|||
|
|
@ -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': '지금 다시 시도',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Повторить',
|
||||
|
|
|
|||
|
|
@ -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 ID’sini 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',
|
||||
|
|
|
|||
|
|
@ -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': 'Повторити зараз',
|
||||
|
|
|
|||
|
|
@ -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': '立即重试',
|
||||
|
|
|
|||
|
|
@ -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': '立即重試',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue