diff --git a/src/helpers/asLink.ts b/src/helpers/asLink.ts index 8909e63b..75c096d7 100644 --- a/src/helpers/asLink.ts +++ b/src/helpers/asLink.ts @@ -22,7 +22,7 @@ export type LinkResolverFunction = ( /** * The return type of `asLink()`. */ -type AsLinkReturnType< +export type AsLinkReturnType< LinkResolverFunctionReturnType = string | null | undefined, Field extends LinkField | PrismicDocument | null | undefined = | LinkField @@ -38,16 +38,17 @@ type AsLinkReturnType< : null; /** - * Resolves any type of Link Field or document to a URL + * Resolves any type of Link field or Prismic document to a URL. * - * @typeParam LinkResolverFunctionReturnType - Link resolver function return + * @typeParam LinkResolverFunctionReturnType - Link Resolver function return * type - * @param linkFieldOrDocument - Any kind of Link Field or a document to resolve - * @param linkResolver - An optional link resolver function, without it you're + * @typeParam Field - Link field or Prismic document to resolve to a URL + * @param linkFieldOrDocument - Any kind of Link field or a document to resolve + * @param linkResolver - An optional Link Resolver function. Without it, you are * expected to use the `routes` options from the API * - * @returns Resolved URL, null if provided link is empty - * @see Prismic link resolver documentation: {@link https://prismic.io/docs/route-resolver#link-resolver} + * @returns Resolved URL or, if the provided Link field or document is empty, `null` + * @see Prismic Link Resolver documentation: {@link https://prismic.io/docs/route-resolver#link-resolver} * @see Prismic API `routes` options documentation: {@link https://prismic.io/docs/route-resolver} */ export const asLink = < diff --git a/src/helpers/asLinkAttrs.ts b/src/helpers/asLinkAttrs.ts new file mode 100644 index 00000000..7317e1d9 --- /dev/null +++ b/src/helpers/asLinkAttrs.ts @@ -0,0 +1,127 @@ +import { isInternalURL } from "../lib/isInternalURL"; + +import type { FilledContentRelationshipField } from "../types/value/contentRelationship"; +import type { PrismicDocument } from "../types/value/document"; +import { FilledLinkToWebField, LinkField } from "../types/value/link"; +import type { FilledLinkToMediaField } from "../types/value/linkToMedia"; + +import { AsLinkReturnType, LinkResolverFunction, asLink } from "./asLink"; +import { link as isFilledLink } from "./isFilled"; + +type AsLinkAttrsConfigRelArgs< + LinkResolverFunctionReturnType = ReturnType, + Field extends LinkField | PrismicDocument | null | undefined = + | LinkField + | PrismicDocument + | null + | undefined, +> = { + href: + | NonNullable> + | undefined; + isExternal: boolean; + target?: string; +}; + +type AsLinkAttrsConfig< + LinkResolverFunctionReturnType = ReturnType, + Field extends LinkField | PrismicDocument | null | undefined = + | LinkField + | PrismicDocument + | null + | undefined, +> = { + linkResolver?: LinkResolverFunction; + rel?: ( + args: AsLinkAttrsConfigRelArgs, + ) => string | undefined | void; +}; + +/** + * The return type of `asLinkAttrs()`. + */ +type AsLinkAttrsReturnType< + LinkResolverFunctionReturnType = ReturnType, + Field extends LinkField | PrismicDocument | null | undefined = + | LinkField + | PrismicDocument + | null + | undefined, +> = Field extends + | FilledLinkToWebField + | FilledLinkToMediaField + | FilledContentRelationshipField + | PrismicDocument + ? { + href: + | NonNullable> + | undefined; + target?: string; + rel?: string; + } + : { + href?: undefined; + target?: undefined; + rel?: undefined; + }; + +/** + * Resolves any type of Link field or Prismic document to a set of link attributes. The attributes are designed to be passed to link HTML elements, like ``. + * + * If a resolved URL is external (i.e. starts with a protocol like `https://`), `rel` is returned as `"noreferrer"`. + * + * @typeParam LinkResolverFunctionReturnType - Link Resolver function return + * type + * @typeParam Field - Link field or Prismic document to resolve to link attributes + * @param linkFieldOrDocument - Any kind of Link field or a document to resolve + * @param config - Configuration that determines the output of `asLinkAttrs()` + * + * @returns Resolved set of link attributes or, if the provided Link field or document is empty, and empty object + * @see Prismic Link Resolver documentation: {@link https://prismic.io/docs/route-resolver#link-resolver} + * @see Prismic API `routes` options documentation: {@link https://prismic.io/docs/route-resolver} + */ +export const asLinkAttrs = < + LinkResolverFunctionReturnType = ReturnType, + Field extends LinkField | PrismicDocument | null | undefined = + | LinkField + | PrismicDocument + | null + | undefined, +>( + linkFieldOrDocument: Field, + config: AsLinkAttrsConfig = {}, +): AsLinkAttrsReturnType => { + if ( + linkFieldOrDocument && + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Bug in TypeScript 4.9: https://github.com/microsoft/TypeScript/issues/51501 + ("link_type" in linkFieldOrDocument + ? isFilledLink(linkFieldOrDocument) + : linkFieldOrDocument) + ) { + const target = + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Bug in TypeScript 4.9: https://github.com/microsoft/TypeScript/issues/51501 + "target" in linkFieldOrDocument ? linkFieldOrDocument.target : undefined; + + const rawHref = asLink(linkFieldOrDocument, config.linkResolver); + const href = + rawHref == null ? undefined : (rawHref as NonNullable); + + const isExternal = typeof href === "string" ? !isInternalURL(href) : false; + + const rel = config.rel + ? config.rel({ href, isExternal, target }) + : isExternal + ? "noreferrer" + : undefined; + + return { + href, + target, + rel: rel == null ? undefined : rel, + }; + } + + return {}; +}; diff --git a/src/index.ts b/src/index.ts index d46ee040..ea907ec4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,7 @@ export type { // Primary Helpers API. export { asDate } from "./helpers/asDate"; export { asLink } from "./helpers/asLink"; +export { asLinkAttrs } from "./helpers/asLinkAttrs"; export { asText } from "./helpers/asText"; export { asHTML } from "./helpers/asHTML"; export { asImageSrc } from "./helpers/asImageSrc"; diff --git a/src/lib/isInternalURL.ts b/src/lib/isInternalURL.ts new file mode 100644 index 00000000..c1bb6fa8 --- /dev/null +++ b/src/lib/isInternalURL.ts @@ -0,0 +1,14 @@ +/** + * Determines if a URL is internal or external. + * + * @param url - The URL to check if internal or external. + * + * @returns `true` if `url` is internal, `false` otherwise. + */ +// TODO: This does not detect all relative URLs as internal such as `about` or `./about`. This function assumes relative URLs start with a "/" or "#"`. +export const isInternalURL = (url: string): boolean => { + const isInternal = /^(\/(?!\/)|#)/.test(url); + const isSpecialLink = !isInternal && !/^https?:\/\//.test(url); + + return isInternal && !isSpecialLink; +}; diff --git a/test/helpers-asLink.test.ts b/test/helpers-asLink.test.ts index 6e68f0b5..b7f779ee 100644 --- a/test/helpers-asLink.test.ts +++ b/test/helpers-asLink.test.ts @@ -110,7 +110,7 @@ it("resolves a link to web field", () => { url: "https://prismic.io", }; - expect(asLink(field, linkResolver), "https://prismic.io"); + expect(asLink(field, linkResolver)).toBe("https://prismic.io"); }); it("resolves a link to media field", () => { @@ -124,11 +124,11 @@ it("resolves a link to media field", () => { width: "42", }; - expect(asLink(field, linkResolver), "https://prismic.io"); + expect(asLink(field, linkResolver)).toBe("https://prismic.io"); }); it("resolves a document", () => { const document = { ...documentFixture.empty }; - expect(asLink(document), "/test"); + expect(asLink(document)).toBe("/test"); }); diff --git a/test/helpers-asLinkAttrs.test.ts b/test/helpers-asLinkAttrs.test.ts new file mode 100644 index 00000000..4b334ce5 --- /dev/null +++ b/test/helpers-asLinkAttrs.test.ts @@ -0,0 +1,161 @@ +import { expect, it, vi } from "vitest"; + +import { asLinkAttrs } from "../src"; + +it("returns empty object for nullish inputs", () => { + expect(asLinkAttrs(null)).toEqual({}); + expect(asLinkAttrs(undefined)).toEqual({}); +}); + +it("returns empty object when link field is empty", (ctx) => { + const field = ctx.mock.value.link({ type: "Any", state: "empty" }); + + expect(asLinkAttrs(field)).toEqual({}); +}); + +it("returns empty object when link to document field is empty", (ctx) => { + const field = ctx.mock.value.link({ type: "Document", state: "empty" }); + + expect(asLinkAttrs(field)).toEqual({}); +}); + +it("returns empty object when link to media field is empty", (ctx) => { + const field = ctx.mock.value.link({ type: "Media", state: "empty" }); + + expect(asLinkAttrs(field)).toEqual({}); +}); + +it("returns empty object when link to web field is empty", (ctx) => { + const field = ctx.mock.value.link({ type: "Web", state: "empty" }); + + expect(asLinkAttrs(field)).toEqual({}); +}); + +it("resolves a link to document field with Route Resolver", (ctx) => { + const field = ctx.mock.value.link({ type: "Document" }); + field.url = "/url"; + + expect(asLinkAttrs(field)).toEqual({ + href: field.url, + target: undefined, + rel: undefined, + }); + expect(asLinkAttrs(field, { linkResolver: () => "/linkResolver" })).toEqual({ + href: "/linkResolver", + target: undefined, + rel: undefined, + }); +}); + +it("resolves a link to document field without Route Resolver", (ctx) => { + const field = ctx.mock.value.link({ type: "Document" }); + field.url = undefined; + + expect(asLinkAttrs(field)).toEqual({ + href: undefined, + target: undefined, + rel: undefined, + }); + expect(asLinkAttrs(field, { linkResolver: () => "/linkResolver" })).toEqual({ + href: "/linkResolver", + target: undefined, + rel: undefined, + }); +}); + +it("resolves a link to web field", (ctx) => { + const field = ctx.mock.value.link({ type: "Web" }); + + expect(asLinkAttrs(field)).toEqual({ + href: field.url, + target: undefined, + rel: "noreferrer", + }); + expect(asLinkAttrs(field, { linkResolver: () => "/linkResolver" })).toEqual({ + href: field.url, + target: undefined, + rel: "noreferrer", + }); +}); + +it("returns correct target when field has a target", (ctx) => { + const field = ctx.mock.value.link({ type: "Web", withTargetBlank: true }); + + expect(asLinkAttrs(field)).toEqual({ + href: field.url, + target: field.target, + rel: "noreferrer", + }); + expect(asLinkAttrs(field, { linkResolver: () => "/linkResolver" })).toEqual({ + href: field.url, + target: field.target, + rel: "noreferrer", + }); +}); + +it("resolves a link to media field", (ctx) => { + const field = ctx.mock.value.link({ type: "Media" }); + + expect(asLinkAttrs(field)).toEqual({ + href: field.url, + target: undefined, + rel: "noreferrer", + }); + expect(asLinkAttrs(field, { linkResolver: () => "/linkResolver" })).toEqual({ + href: field.url, + target: undefined, + rel: "noreferrer", + }); +}); + +it("resolves a document", (ctx) => { + const doc = ctx.mock.value.document(); + doc.url = "/foo"; + + expect(asLinkAttrs(doc)).toEqual({ + href: doc.url, + target: undefined, + rel: undefined, + }); + expect(asLinkAttrs(doc, { linkResolver: () => "/linkResolver" })).toEqual({ + href: "/linkResolver", + target: undefined, + rel: undefined, + }); +}); + +it('returns "noreferrer" `rel` value when the field\'s `href` is external', (ctx) => { + const field = ctx.mock.value.link({ type: "Web" }); + + expect(asLinkAttrs(field).rel).toBe("noreferrer"); + expect(asLinkAttrs(field, { linkResolver: () => "/linkResolver" }).rel).toBe( + "noreferrer", + ); +}); + +it("allows the `rel` value to be configured using `config.rel`", (ctx) => { + const internalField = ctx.mock.value.link({ type: "Document" }); + internalField.url = "/foo"; + + const externalField = ctx.mock.value.link({ type: "Web" }); + externalField.url = "https://prismic.io"; + externalField.target = "_blank"; + + const relFn = vi.fn().mockImplementation(() => "bar"); + + const internalRes = asLinkAttrs(internalField, { rel: relFn }); + expect(internalRes.rel).toBe("bar"); + expect(relFn).toHaveBeenNthCalledWith(1, { + href: internalField.url, + target: undefined, + isExternal: false, + }); + + const externalRes = asLinkAttrs(externalField, { rel: relFn }); + expect(externalRes.rel).toBe("bar"); + expect(relFn).toHaveBeenNthCalledWith(2, { + href: externalField.url, + target: externalField.target, + isExternal: true, + }); +});