diff --git a/packages/cli/cli-v2/src/commands/docs/preview/delete/__test__/command.test.ts b/packages/cli/cli-v2/src/commands/docs/preview/delete/__test__/command.test.ts new file mode 100644 index 000000000000..2a687b3fd639 --- /dev/null +++ b/packages/cli/cli-v2/src/commands/docs/preview/delete/__test__/command.test.ts @@ -0,0 +1,81 @@ +import type { FernToken } from "@fern-api/auth"; +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestContext } from "../../../../../__test__/utils/createTestContext.js"; +import type { Context } from "../../../../../context/Context.js"; +import { DeleteCommand } from "../command.js"; + +const { listAllDocsUrls, deleteDocsSite } = vi.hoisted(() => ({ + listAllDocsUrls: vi.fn(), + deleteDocsSite: vi.fn() +})); + +vi.mock("@fern-api/core", async (importOriginal) => ({ + ...(await importOriginal()), + createFdrService: () => ({ + docs: { v2: { read: { listAllDocsUrls }, write: { deleteDocsSite } } } + }) +})); + +const HOSTNAME = "acme-preview-mr-2.docs.buildwithfern.com"; + +async function createContext(): Promise { + const context = await createTestContext({ cwd: AbsoluteFilePath.of(process.cwd()) }); + vi.spyOn(context, "getTokenOrPrompt").mockResolvedValue({ type: "user", value: "token" } satisfies FernToken); + vi.spyOn(context, "loadWorkspaceOrThrow").mockResolvedValue({ org: "acme" } as Awaited< + ReturnType + >); + return context; +} + +describe("DeleteCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("deletes the preview at the basepath resolved from an id", async () => { + listAllDocsUrls.mockResolvedValue({ urls: [{ domain: HOSTNAME, basePath: "/docs" }] }); + const context = await createContext(); + + await new DeleteCommand().handle(context, { "log-level": "info", id: "mr-2" }); + + expect(deleteDocsSite).toHaveBeenCalledWith({ url: `${HOSTNAME}/docs` }); + }); + + it("resolves the basepath of a preview passed as a bare hostname", async () => { + listAllDocsUrls.mockResolvedValue({ urls: [{ domain: HOSTNAME, basePath: "/docs" }] }); + const context = await createContext(); + + await new DeleteCommand().handle(context, { "log-level": "info", target: HOSTNAME }); + + expect(deleteDocsSite).toHaveBeenCalledWith({ url: `${HOSTNAME}/docs` }); + }); + + it("deletes the URL as given when it already includes a basepath", async () => { + const context = await createContext(); + + await new DeleteCommand().handle(context, { "log-level": "info", url: `https://${HOSTNAME}/docs/` }); + + expect(listAllDocsUrls).not.toHaveBeenCalled(); + expect(deleteDocsSite).toHaveBeenCalledWith({ url: `${HOSTNAME}/docs` }); + }); + + it("fails when the id matches no preview deployment", async () => { + listAllDocsUrls.mockResolvedValue({ urls: [] }); + const context = await createContext(); + + await expect(new DeleteCommand().handle(context, { "log-level": "info", id: "mr-2" })).rejects.toThrow( + "No preview deployment found" + ); + expect(deleteDocsSite).not.toHaveBeenCalled(); + }); + + it("rejects a non-preview URL without authenticating", async () => { + const context = await createContext(); + + await expect( + new DeleteCommand().handle(context, { "log-level": "info", url: "docs.acme.com" }) + ).rejects.toThrow("Invalid preview URL"); + expect(context.getTokenOrPrompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/cli-v2/src/commands/docs/preview/delete/command.ts b/packages/cli/cli-v2/src/commands/docs/preview/delete/command.ts index cced2bb231c3..50c9e7177e74 100644 --- a/packages/cli/cli-v2/src/commands/docs/preview/delete/command.ts +++ b/packages/cli/cli-v2/src/commands/docs/preview/delete/command.ts @@ -1,5 +1,12 @@ import { createFdrService } from "@fern-api/core"; -import { buildPreviewDomain, isPreviewUrl as isPreviewUrlUtil } from "@fern-api/docs-preview"; +import { assertNever } from "@fern-api/core-utils"; +import { + buildPreviewDomain, + isPreviewUrl as isPreviewUrlUtil, + lookupPreviewSiteUrl, + PreviewSiteLookup, + splitPreviewUrl +} from "@fern-api/docs-preview"; import { CliError } from "@fern-api/task-context"; import chalk from "chalk"; import type { Argv } from "yargs"; @@ -17,12 +24,13 @@ export declare namespace DeleteCommand { export class DeleteCommand { public async handle(context: Context, args: DeleteCommand.Args): Promise { - const resolvedUrl = await this.resolveUrl(context, args); + const resolved = this.resolveTarget(args); - if (!isPreviewUrlUtil(resolvedUrl)) { + // Validate the URL before asking the user to log in + if (resolved.type === "url" && !isPreviewUrlUtil(resolved.value)) { throw new CliError({ message: - `Invalid preview URL: ${resolvedUrl}\n` + + `Invalid preview URL: ${resolved.value}\n` + ` Preview URLs follow the pattern: {org}-preview-{hash}.docs.buildwithfern.com`, code: CliError.Code.ConfigError }); @@ -31,6 +39,8 @@ export class DeleteCommand { const token = await context.getTokenOrPrompt(); const fdr = createFdrService({ token: token.value, headers: context.headers }); + const resolvedUrl = await this.resolveUrl(context, resolved, fdr); + context.stderr.debug(`Deleting preview site: ${resolvedUrl}`); try { @@ -74,17 +84,86 @@ export class DeleteCommand { return { type: "id", value: args.target }; } - private async resolveUrl(context: Context, args: DeleteCommand.Args): Promise { - const resolved = this.resolveTarget(args); - + /** + * Resolves the target to the URL FDR stores for the site, which includes the + * basepath the preview was published under. A preview ID — and likewise a + * bare hostname — only determines the hostname, so the deployment has to be + * looked up rather than assumed to live at the root. + */ + private async resolveUrl( + context: Context, + resolved: { type: "url"; value: string } | { type: "id"; value: string }, + fdr: ReturnType + ): Promise { if (resolved.type === "id") { const workspace = await context.loadWorkspaceOrThrow(); - const url = buildPreviewDomain({ orgId: workspace.org, previewId: resolved.value }); + const hostname = buildPreviewDomain({ orgId: workspace.org, previewId: resolved.value }); + const url = await this.lookupSiteUrl({ fdr, hostname, target: `preview ID "${resolved.value}"` }); context.stderr.debug(`Resolved preview ID "${resolved.value}" to URL: ${url}`); return url; } - return resolved.value; + const { hostname, path } = splitPreviewUrl(resolved.value); + if (path !== "") { + return `${hostname}${path}`; + } + return this.lookupSiteUrl({ fdr, hostname, target: hostname }); + } + + private async lookupSiteUrl({ + fdr, + hostname, + target + }: { + fdr: ReturnType; + hostname: string; + /** How to refer to what the user asked to delete, e.g. `preview ID "mr-2"`. */ + target: string; + }): Promise { + let lookup: PreviewSiteLookup; + try { + lookup = await lookupPreviewSiteUrl({ + listPreviewUrls: (listArgs) => fdr.docs.v2.read.listAllDocsUrls(listArgs), + hostname + }); + } catch (error) { + if ((error as Record)?.error === "UnauthorizedError") { + throw CliError.unauthorized( + "You do not have permissions to list preview deployments. Reach out to support@buildwithfern.com" + ); + } + throw new CliError({ + message: `Failed to look up the preview deployment for ${target}.`, + code: CliError.Code.InternalError + }); + } + + switch (lookup.type) { + case "found": + return lookup.url; + case "notFound": + throw CliError.notFound( + `No preview deployment found for ${target} (${hostname}).\n` + + " Run 'fern docs preview list' to see the preview deployments you can delete." + ); + case "ambiguous": + throw new CliError({ + message: + `${hostname} serves more than one preview site:\n` + + `${lookup.urls.map((url) => ` ${url}`).join("\n")}\n` + + " Pass the full URL of the one you want to delete.", + code: CliError.Code.ConfigError + }); + case "scanLimitReached": + throw new CliError({ + message: + `Could not find ${target} (${hostname}) in the first ${lookup.pagesScanned} pages of preview deployments.\n` + + " Pass the full preview URL, including its basepath, instead.", + code: CliError.Code.ConfigError + }); + default: + assertNever(lookup); + } } } diff --git a/packages/cli/cli/changes/unreleased/fix-preview-delete-id-basepath.yml b/packages/cli/cli/changes/unreleased/fix-preview-delete-id-basepath.yml new file mode 100644 index 000000000000..a0a8aa3dc269 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-preview-delete-id-basepath.yml @@ -0,0 +1,6 @@ +- summary: | + `fern docs preview delete` now resolves the preview's full URL, including its basepath, when given + a preview ID or a bare hostname. Previously the target was resolved to the hostname alone, so + deleting a preview published under a basepath reported success without deleting the site. The + command now fails with a clear message when the target doesn't match exactly one preview deployment. + type: fix diff --git a/packages/cli/cli/src/commands/docs-preview/deleteDocsPreview.ts b/packages/cli/cli/src/commands/docs-preview/deleteDocsPreview.ts index 15ab4eb9e1a3..43ac2003a04d 100644 --- a/packages/cli/cli/src/commands/docs-preview/deleteDocsPreview.ts +++ b/packages/cli/cli/src/commands/docs-preview/deleteDocsPreview.ts @@ -1,13 +1,21 @@ import { FernToken } from "@fern-api/auth"; import { getFernDirectory, loadProjectConfig } from "@fern-api/configuration-loader"; import { createFdrService } from "@fern-api/core"; -import { buildPreviewDomain, isPreviewUrl } from "@fern-api/docs-preview"; +import { assertNever } from "@fern-api/core-utils"; +import { + buildPreviewDomain, + isPreviewUrl, + lookupPreviewSiteUrl, + PreviewSiteLookup, + splitPreviewUrl +} from "@fern-api/docs-preview"; import { askToLogin } from "@fern-api/login"; import { CliError } from "@fern-api/task-context"; import chalk from "chalk"; import { CliContext } from "../../cli-context/CliContext.js"; -async function resolvePreviewUrlFromId({ +/** The hostname a preview ID maps to, per the org of the local Fern project. */ +async function resolveHostnameFromId({ cliContext, previewId }: { @@ -31,6 +39,74 @@ async function resolvePreviewUrlFromId({ return buildPreviewDomain({ orgId: projectConfig.organization, previewId }); } +/** + * Resolves the URL FDR stores for the preview served from `hostname` — including + * the basepath it was published under, which a hostname alone does not identify. + */ +async function resolveSiteUrlForHostname({ + cliContext, + hostname, + target, + token +}: { + cliContext: CliContext; + hostname: string; + /** How to refer to what the user asked to delete, e.g. `preview ID "mr-2"`. */ + target: string; + token: FernToken; +}): Promise { + const fdr = createFdrService({ token: token.value }); + + let lookup: PreviewSiteLookup; + try { + lookup = await lookupPreviewSiteUrl({ + listPreviewUrls: (args) => fdr.docs.v2.read.listAllDocsUrls(args), + hostname + }); + } catch (error) { + const errorType = (error as Record)?.error; + if (errorType === "UnauthorizedError") { + return cliContext.failAndThrow( + "Unauthorized to list preview deployments. Please run 'fern login' to refresh your credentials, or set the FERN_TOKEN environment variable.", + undefined, + { code: CliError.Code.NetworkError } + ); + } + return cliContext.failAndThrow(`Failed to look up the preview deployment for ${target}.`, error, { + code: CliError.Code.NetworkError + }); + } + + switch (lookup.type) { + case "found": + return lookup.url; + case "notFound": + return cliContext.failAndThrow( + `No preview deployment found for ${target} (${hostname}).\n` + + "Run 'fern docs preview list' to see the preview deployments you can delete.", + undefined, + { code: CliError.Code.ConfigError } + ); + case "ambiguous": + return cliContext.failAndThrow( + `${hostname} serves more than one preview site:\n` + + `${lookup.urls.map((url) => ` ${url}`).join("\n")}\n` + + "Pass the full URL of the one you want to delete.", + undefined, + { code: CliError.Code.ConfigError } + ); + case "scanLimitReached": + return cliContext.failAndThrow( + `Could not find ${target} (${hostname}) in the first ${lookup.pagesScanned} pages of preview deployments.\n` + + "Pass the full preview URL, including its basepath, instead.", + undefined, + { code: CliError.Code.ConfigError } + ); + default: + assertNever(lookup); + } +} + function resolveTarget({ target, url, @@ -71,19 +147,10 @@ export async function deleteDocsPreview({ }): Promise { const resolved = resolveTarget({ target, url: previewUrl, id: previewId }); - let resolvedUrl: string; - - if (resolved.type === "id") { - resolvedUrl = await resolvePreviewUrlFromId({ cliContext, previewId: resolved.value }); - cliContext.logger.debug(`Resolved preview ID "${resolved.value}" to URL: ${resolvedUrl}`); - } else { - resolvedUrl = resolved.value; - } - - // Validate that the URL is a preview URL before proceeding - if (!isPreviewUrl(resolvedUrl)) { + // Validate that the URL is a preview URL before asking the user to log in + if (resolved.type === "url" && !isPreviewUrl(resolved.value)) { cliContext.failAndThrow( - `Invalid preview URL: ${resolvedUrl}\n` + + `Invalid preview URL: ${resolved.value}\n` + "Only preview sites can be deleted with this command.\n" + "Preview URLs follow the pattern: {org}-preview-{hash}.docs.buildwithfern.com\n" + "Example: acme-preview-abc123.docs.buildwithfern.com", @@ -104,6 +171,28 @@ export async function deleteDocsPreview({ return; } + let resolvedUrl: string; + + if (resolved.type === "id") { + const hostname = await resolveHostnameFromId({ cliContext, previewId: resolved.value }); + resolvedUrl = await resolveSiteUrlForHostname({ + cliContext, + hostname, + target: `preview ID "${resolved.value}"`, + token + }); + cliContext.logger.debug(`Resolved preview ID "${resolved.value}" to URL: ${resolvedUrl}`); + } else { + const { hostname, path } = splitPreviewUrl(resolved.value); + // A bare hostname doesn't identify a preview published under a basepath — + // FDR keys the site on hostname + basepath — so look the site up unless + // the user already told us which basepath they mean. + resolvedUrl = + path === "" + ? await resolveSiteUrlForHostname({ cliContext, hostname, target: hostname, token }) + : `${hostname}${path}`; + } + await cliContext.runTask(async (context) => { context.logger.info(`Deleting preview site: ${resolvedUrl}`); diff --git a/packages/cli/cli/src/commands/docs-preview/listDocsPreview.ts b/packages/cli/cli/src/commands/docs-preview/listDocsPreview.ts index ff07792209bb..6766d455bf54 100644 --- a/packages/cli/cli/src/commands/docs-preview/listDocsPreview.ts +++ b/packages/cli/cli/src/commands/docs-preview/listDocsPreview.ts @@ -1,5 +1,6 @@ import { FernToken } from "@fern-api/auth"; import { createFdrService } from "@fern-api/core"; +import { toPreviewSiteUrl } from "@fern-api/docs-preview"; import { askToLogin } from "@fern-api/login"; import { CliError } from "@fern-api/task-context"; import chalk from "chalk"; @@ -12,7 +13,7 @@ interface PreviewDeployment { } /** A single docs-url entry as returned by FDR's `listAllDocsUrls`. */ -type DocsUrlItem = Awaited< +export type DocsUrlItem = Awaited< ReturnType["docs"]["v2"]["read"]["listAllDocsUrls"]> >["urls"][number]; @@ -26,7 +27,7 @@ type DocsUrlItem = Awaited< */ export function toPreviewDeployments(urls: readonly DocsUrlItem[]): PreviewDeployment[] { return urls.map((item) => ({ - url: item.basePath != null ? `${item.domain}${item.basePath}` : item.domain, + url: toPreviewSiteUrl(item), organizationId: item.organizationId, updatedAt: item.updatedAt })); diff --git a/packages/cli/docs-preview/src/__test__/lookupPreviewSite.test.ts b/packages/cli/docs-preview/src/__test__/lookupPreviewSite.test.ts new file mode 100644 index 000000000000..e4a37f51ff36 --- /dev/null +++ b/packages/cli/docs-preview/src/__test__/lookupPreviewSite.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +import { lookupPreviewSiteUrl, MAX_PREVIEW_PAGES, PREVIEW_PAGE_SIZE } from "../lookupPreviewSite.js"; + +function item(domain: string, basePath?: string) { + return { domain, basePath }; +} + +const HOSTNAME = "acme-preview-mr-2.docs.buildwithfern.com"; + +describe("lookupPreviewSiteUrl", () => { + it("preserves the basepath of a preview published under one", async () => { + const listPreviewUrls = vi.fn().mockResolvedValue({ + urls: [item("acme-preview-mr-1.docs.buildwithfern.com"), item(HOSTNAME, "/docs")] + }); + + expect(await lookupPreviewSiteUrl({ listPreviewUrls, hostname: HOSTNAME })).toEqual({ + type: "found", + url: `${HOSTNAME}/docs` + }); + }); + + it("returns the bare hostname for a root preview", async () => { + const listPreviewUrls = vi.fn().mockResolvedValue({ urls: [item(HOSTNAME)] }); + + expect(await lookupPreviewSiteUrl({ listPreviewUrls, hostname: HOSTNAME })).toEqual({ + type: "found", + url: HOSTNAME + }); + }); + + it("reports every site on the host when it serves more than one basepath", async () => { + const listPreviewUrls = vi.fn().mockResolvedValue({ + urls: [item(HOSTNAME, "/docs"), item(HOSTNAME, "/api")] + }); + + expect(await lookupPreviewSiteUrl({ listPreviewUrls, hostname: HOSTNAME })).toEqual({ + type: "ambiguous", + urls: [`${HOSTNAME}/docs`, `${HOSTNAME}/api`] + }); + }); + + it("reports not found when no preview matches the hostname", async () => { + const listPreviewUrls = vi + .fn() + .mockResolvedValue({ urls: [item("other-preview-mr-2.docs.buildwithfern.com", "/docs")] }); + + expect(await lookupPreviewSiteUrl({ listPreviewUrls, hostname: HOSTNAME })).toEqual({ type: "notFound" }); + }); + + it("matches case-insensitively", async () => { + const listPreviewUrls = vi.fn().mockResolvedValue({ urls: [item(HOSTNAME.toUpperCase(), "/docs")] }); + + expect(await lookupPreviewSiteUrl({ listPreviewUrls, hostname: HOSTNAME })).toEqual({ + type: "found", + url: `${HOSTNAME.toUpperCase()}/docs` + }); + }); + + it("keeps paging while pages come back full", async () => { + const fullPage = Array.from({ length: PREVIEW_PAGE_SIZE }, (_, i) => + item(`acme-preview-filler-${i}.docs.buildwithfern.com`) + ); + const listPreviewUrls = vi + .fn() + .mockResolvedValueOnce({ urls: fullPage }) + .mockResolvedValueOnce({ urls: [item(HOSTNAME, "/docs")] }); + + expect(await lookupPreviewSiteUrl({ listPreviewUrls, hostname: HOSTNAME })).toEqual({ + type: "found", + url: `${HOSTNAME}/docs` + }); + expect(listPreviewUrls).toHaveBeenCalledTimes(2); + expect(listPreviewUrls).toHaveBeenLastCalledWith({ page: 2, limit: PREVIEW_PAGE_SIZE, preview: true }); + }); + + it("distinguishes an exhausted scan from a missing preview", async () => { + const fullPage = Array.from({ length: PREVIEW_PAGE_SIZE }, (_, i) => + item(`acme-preview-filler-${i}.docs.buildwithfern.com`) + ); + const listPreviewUrls = vi.fn().mockResolvedValue({ urls: fullPage }); + + expect(await lookupPreviewSiteUrl({ listPreviewUrls, hostname: HOSTNAME })).toEqual({ + type: "scanLimitReached", + pagesScanned: MAX_PREVIEW_PAGES + }); + }); +}); diff --git a/packages/cli/docs-preview/src/index.ts b/packages/cli/docs-preview/src/index.ts index 089ba1f90a6d..6ca6f8208408 100644 --- a/packages/cli/docs-preview/src/index.ts +++ b/packages/cli/docs-preview/src/index.ts @@ -1,3 +1,15 @@ -export { buildPreviewDomain, isPreviewUrl, PREVIEW_URL_PATTERN, sanitizePreviewId } from "./previewUrlUtils.js"; +export { + lookupPreviewSiteUrl, + type PreviewDocsUrl, + type PreviewSiteLookup, + toPreviewSiteUrl +} from "./lookupPreviewSite.js"; +export { + buildPreviewDomain, + isPreviewUrl, + PREVIEW_URL_PATTERN, + sanitizePreviewId, + splitPreviewUrl +} from "./previewUrlUtils.js"; export { runAppPreviewServer } from "./runAppPreviewServer.js"; export { runPreviewServer } from "./runPreviewServer.js"; diff --git a/packages/cli/docs-preview/src/lookupPreviewSite.ts b/packages/cli/docs-preview/src/lookupPreviewSite.ts new file mode 100644 index 000000000000..cf1e39ac434e --- /dev/null +++ b/packages/cli/docs-preview/src/lookupPreviewSite.ts @@ -0,0 +1,68 @@ +/** FDR caps `limit` on the docs-url listing at 1000. */ +export const PREVIEW_PAGE_SIZE = 1000; +/** Bounds the scan so a server that keeps returning full pages can't loop forever. */ +export const MAX_PREVIEW_PAGES = 50; + +/** The shape of a docs-url entry from FDR's `listAllDocsUrls` that a lookup needs. */ +export interface PreviewDocsUrl { + domain: string; + basePath?: string; +} + +export type ListPreviewUrls = (args: { + page: number; + limit: number; + preview: true; +}) => Promise<{ urls: readonly T[] }>; + +/** + * The URL that identifies a docs site to FDR: the hostname plus its basepath, if + * it has one. Deletion is keyed on this full URL, not on the hostname alone. + */ +export function toPreviewSiteUrl(item: PreviewDocsUrl): string { + return item.basePath != null ? `${item.domain}${item.basePath}` : item.domain; +} + +export type PreviewSiteLookup = + | { type: "found"; url: string } + | { type: "notFound" } + /** The host serves several sites, each under its own basepath. */ + | { type: "ambiguous"; urls: string[] } + | { type: "scanLimitReached"; pagesScanned: number }; + +/** + * Resolves the URL FDR stores for the preview served from `hostname`. A preview + * published under a basepath is keyed on hostname + basepath, so the deployment + * has to be looked up rather than assumed to live at the root. + */ +export async function lookupPreviewSiteUrl({ + listPreviewUrls, + hostname +}: { + listPreviewUrls: ListPreviewUrls; + hostname: string; +}): Promise { + const normalizedHostname = hostname.toLowerCase(); + const matches: T[] = []; + let scannedEveryPage = false; + let pagesScanned = 0; + + for (let page = 1; page <= MAX_PREVIEW_PAGES; page++) { + const { urls } = await listPreviewUrls({ page, limit: PREVIEW_PAGE_SIZE, preview: true }); + pagesScanned = page; + matches.push(...urls.filter((item) => item.domain.toLowerCase() === normalizedHostname)); + if (urls.length < PREVIEW_PAGE_SIZE) { + scannedEveryPage = true; + break; + } + } + + const [match, ...rest] = matches; + if (match == null) { + return scannedEveryPage ? { type: "notFound" } : { type: "scanLimitReached", pagesScanned }; + } + if (rest.length > 0) { + return { type: "ambiguous", urls: matches.map(toPreviewSiteUrl) }; + } + return { type: "found", url: toPreviewSiteUrl(match) }; +} diff --git a/packages/cli/docs-preview/src/previewUrlUtils.ts b/packages/cli/docs-preview/src/previewUrlUtils.ts index 7487b105402b..2d09d372625f 100644 --- a/packages/cli/docs-preview/src/previewUrlUtils.ts +++ b/packages/cli/docs-preview/src/previewUrlUtils.ts @@ -16,21 +16,33 @@ const DOMAIN_SUFFIX = "docs.buildwithfern.com"; */ const SUBDOMAIN_LIMIT = 62; -export function isPreviewUrl(url: string): boolean { - let hostname = url.toLowerCase().trim(); +/** + * Splits a preview URL into the hostname and the basepath it was published + * under, if any. The scheme is stripped and the hostname lowercased; the path + * keeps its leading slash and loses trailing ones ("host/docs/" -> "/docs"). + */ +export function splitPreviewUrl(url: string): { hostname: string; path: string } { + let rest = url.trim(); - if (hostname.startsWith("https://")) { - hostname = hostname.slice(8); - } else if (hostname.startsWith("http://")) { - hostname = hostname.slice(7); + if (rest.startsWith("https://")) { + rest = rest.slice(8); + } else if (rest.startsWith("http://")) { + rest = rest.slice(7); } - const slashIndex = hostname.indexOf("/"); - if (slashIndex !== -1) { - hostname = hostname.slice(0, slashIndex); + const slashIndex = rest.indexOf("/"); + if (slashIndex === -1) { + return { hostname: rest.toLowerCase(), path: "" }; } - return PREVIEW_URL_PATTERN.test(hostname); + return { + hostname: rest.slice(0, slashIndex).toLowerCase(), + path: rest.slice(slashIndex).replace(/\/+$/, "") + }; +} + +export function isPreviewUrl(url: string): boolean { + return PREVIEW_URL_PATTERN.test(splitPreviewUrl(url).hostname); } /** diff --git a/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/__test__/valid-changelog-slug.test.ts b/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/__test__/valid-changelog-slug.test.ts index 7fdcdbb01a33..e199fa59f74f 100644 --- a/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/__test__/valid-changelog-slug.test.ts +++ b/packages/cli/yaml/docs-validator/src/rules/valid-changelog-slug/__test__/valid-changelog-slug.test.ts @@ -1,4 +1,4 @@ -import type { docsYml } from "@fern-api/configuration-loader"; +import type { DocsConfigurationWithResolvedRedirects } from "@fern-api/configuration-loader"; import { describe, expect, it } from "vitest"; import type { RuleContext } from "../../../Rule.js"; @@ -11,7 +11,7 @@ import { ValidChangelogSlugRule } from "../valid-changelog-slug.js"; -async function violationsFor(config: docsYml.RawSchemas.DocsConfiguration): Promise { +async function violationsFor(config: DocsConfigurationWithResolvedRedirects): Promise { const visitor = await ValidChangelogSlugRule.create({} as RuleContext); const fileVisitor = visitor.file; if (fileVisitor == null) {