Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add asLinkAttrs() helper #282

Merged
merged 13 commits into from
Apr 10, 2023
15 changes: 8 additions & 7 deletions src/helpers/asLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type LinkResolverFunction<ReturnType = string | null | undefined> = (
/**
* The return type of `asLink()`.
*/
type AsLinkReturnType<
export type AsLinkReturnType<
LinkResolverFunctionReturnType = string | null | undefined,
Field extends LinkField | PrismicDocument | null | undefined =
| LinkField
Expand All @@ -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 = <
Expand Down
127 changes: 127 additions & 0 deletions src/helpers/asLinkAttrs.ts
Original file line number Diff line number Diff line change
@@ -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<LinkResolverFunction>,
Field extends LinkField | PrismicDocument | null | undefined =
| LinkField
| PrismicDocument
| null
| undefined,
> = {
href:
| NonNullable<AsLinkReturnType<LinkResolverFunctionReturnType, Field>>
| undefined;
isExternal: boolean;
target?: string;
};

type AsLinkAttrsConfig<
LinkResolverFunctionReturnType = ReturnType<LinkResolverFunction>,
Field extends LinkField | PrismicDocument | null | undefined =
| LinkField
| PrismicDocument
| null
| undefined,
> = {
linkResolver?: LinkResolverFunction<LinkResolverFunctionReturnType>;
rel?: (
args: AsLinkAttrsConfigRelArgs<LinkResolverFunctionReturnType, Field>,
) => string | undefined | void;
};

/**
* The return type of `asLinkAttrs()`.
*/
type AsLinkAttrsReturnType<
LinkResolverFunctionReturnType = ReturnType<LinkResolverFunction>,
Field extends LinkField | PrismicDocument | null | undefined =
| LinkField
| PrismicDocument
| null
| undefined,
> = Field extends
| FilledLinkToWebField
| FilledLinkToMediaField
| FilledContentRelationshipField
| PrismicDocument
? {
href:
| NonNullable<AsLinkReturnType<LinkResolverFunctionReturnType, Field>>
| 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 `<a>`.
*
* 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<LinkResolverFunction>,
Field extends LinkField | PrismicDocument | null | undefined =
| LinkField
| PrismicDocument
| null
| undefined,
>(
linkFieldOrDocument: Field,
config: AsLinkAttrsConfig<LinkResolverFunctionReturnType> = {},
): AsLinkAttrsReturnType<LinkResolverFunctionReturnType> => {
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<typeof rawHref>);

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 {};
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
14 changes: 14 additions & 0 deletions src/lib/isInternalURL.ts
Original file line number Diff line number Diff line change
@@ -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;
};
6 changes: 3 additions & 3 deletions test/helpers-asLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
angeloashmore marked this conversation as resolved.
Show resolved Hide resolved
});

it("resolves a link to media field", () => {
Expand All @@ -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");
});
161 changes: 161 additions & 0 deletions test/helpers-asLinkAttrs.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});