♻️ Refactor code for generating absolute url (#904)

This commit is contained in:
Luke Vella 2023-10-19 18:01:00 +01:00 committed by GitHub
parent eaa8f5813d
commit 703d551aac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 108 additions and 61 deletions

View file

@ -2,7 +2,6 @@ import "tailwindcss/tailwind.css";
import "../style.css"; import "../style.css";
import { trpc, UserSession } from "@rallly/backend/next/trpc/client"; import { trpc, UserSession } from "@rallly/backend/next/trpc/client";
import { absoluteUrl } from "@rallly/utils";
import { inject } from "@vercel/analytics"; import { inject } from "@vercel/analytics";
import dayjs from "dayjs"; import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat"; import localizedFormat from "dayjs/plugin/localizedFormat";
@ -16,6 +15,8 @@ import { appWithTranslation } from "next-i18next";
import { DefaultSeo, SoftwareAppJsonLd } from "next-seo"; import { DefaultSeo, SoftwareAppJsonLd } from "next-seo";
import React from "react"; import React from "react";
import { absoluteUrl } from "@/utils/absolute-url";
import * as nextI18nNextConfig from "../../next-i18next.config.js"; import * as nextI18nNextConfig from "../../next-i18next.config.js";
import { NextPageWithLayout } from "../types"; import { NextPageWithLayout } from "../types";

View file

@ -1,5 +1,4 @@
import { ArrowLeftIcon } from "@rallly/icons"; import { ArrowLeftIcon } from "@rallly/icons";
import { absoluteUrl } from "@rallly/utils";
import { GetStaticPropsContext } from "next"; import { GetStaticPropsContext } from "next";
import ErrorPage from "next/error"; import ErrorPage from "next/error";
import Head from "next/head"; import Head from "next/head";
@ -14,6 +13,7 @@ import { getBlogLayout } from "@/components/layouts/blog-layout";
import { getAllPosts, getPostBySlug } from "@/lib/api"; import { getAllPosts, getPostBySlug } from "@/lib/api";
import markdownToHtml from "@/lib/markdownToHtml"; import markdownToHtml from "@/lib/markdownToHtml";
import { NextPageWithLayout, Post } from "@/types"; import { NextPageWithLayout, Post } from "@/types";
import { absoluteUrl } from "@/utils/absolute-url";
import { getStaticTranslations } from "@/utils/page-translations"; import { getStaticTranslations } from "@/utils/page-translations";
type Props = { type Props = {

View file

@ -28,8 +28,3 @@ export function absoluteUrl(subpath = "", query?: Record<string, string>) {
return joinPath(baseUrl, subpath) + queryString; return joinPath(baseUrl, subpath) + queryString;
} }
export function shortUrl(subpath = "") {
const baseUrl = process.env.NEXT_PUBLIC_SHORT_BASE_URL ?? absoluteUrl();
return joinPath(baseUrl, subpath);
}

View file

@ -9,10 +9,6 @@ declare global {
* "development" or "production" * "development" or "production"
*/ */
NODE_ENV: "development" | "production"; NODE_ENV: "development" | "production";
/**
* Set to "true" to take users straight to app instead of landing page
*/
DISABLE_LANDING_PAGE?: string;
/** /**
* Must be 32 characters long * Must be 32 characters long
*/ */
@ -84,10 +80,6 @@ declare global {
* The app version just for reference * The app version just for reference
*/ */
NEXT_PUBLIC_APP_VERSION?: string; NEXT_PUBLIC_APP_VERSION?: string;
/**
* "true" to enable finalization of polls
*/
NEXT_PUBLIC_ENABLE_FINALIZATION?: string;
} }
} }
} }

View file

@ -8,7 +8,6 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@rallly/ui/dialog"; } from "@rallly/ui/dialog";
import { shortUrl } from "@rallly/utils";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import { useCopyToClipboard } from "react-use"; import { useCopyToClipboard } from "react-use";
@ -16,6 +15,7 @@ import { useCopyToClipboard } from "react-use";
import { useParticipants } from "@/components/participants-provider"; import { useParticipants } from "@/components/participants-provider";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { shortUrl } from "@/utils/absolute-url";
import { isSelfHosted } from "@/utils/constants"; import { isSelfHosted } from "@/utils/constants";
export const InviteDialog = () => { export const InviteDialog = () => {

View file

@ -1,10 +1,11 @@
import { getSession } from "@rallly/backend/next/session"; import { getSession } from "@rallly/backend/next/session";
import { stripe } from "@rallly/backend/stripe"; import { stripe } from "@rallly/backend/stripe";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { absoluteUrl } from "@rallly/utils";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod"; import { z } from "zod";
import { absoluteUrl } from "@/utils/absolute-url";
export const config = { export const config = {
edge: true, edge: true,
}; };

View file

@ -1,10 +1,11 @@
import { getSession } from "@rallly/backend/next/session"; import { getSession } from "@rallly/backend/next/session";
import { stripe } from "@rallly/backend/stripe"; import { stripe } from "@rallly/backend/stripe";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { absoluteUrl } from "@rallly/utils";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod"; import { z } from "zod";
import { absoluteUrl } from "@/utils/absolute-url";
const inputSchema = z.object({ const inputSchema = z.object({
session_id: z.string().optional(), session_id: z.string().optional(),
return_path: z.string().optional(), return_path: z.string().optional(),

View file

@ -1,6 +1,7 @@
import { trpcNextApiHandler } from "@rallly/backend/next/trpc/server"; import { trpcNextApiHandler } from "@rallly/backend/next/trpc/server";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { absoluteUrl, shortUrl } from "@/utils/absolute-url";
import { getServerSession, isEmailBlocked } from "@/utils/auth"; import { getServerSession, isEmailBlocked } from "@/utils/auth";
import { isSelfHosted } from "@/utils/constants"; import { isSelfHosted } from "@/utils/constants";
import { emailClient } from "@/utils/emails"; import { emailClient } from "@/utils/emails";
@ -31,5 +32,7 @@ export default async function handler(
emailClient, emailClient,
isSelfHosted, isSelfHosted,
isEmailBlocked, isEmailBlocked,
absoluteUrl,
shortUrl,
})(req, res); })(req, res);
} }

View file

@ -2,7 +2,6 @@ import { trpc } from "@rallly/backend";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { ArrowUpLeftIcon } from "@rallly/icons"; import { ArrowUpLeftIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { absoluteUrl } from "@rallly/utils";
import { GetStaticProps } from "next"; import { GetStaticProps } from "next";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
@ -19,6 +18,7 @@ import { UserProvider, useUser } from "@/components/user-provider";
import { VisibilityProvider } from "@/components/visibility"; import { VisibilityProvider } from "@/components/visibility";
import { PermissionsContext } from "@/contexts/permissions"; import { PermissionsContext } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { absoluteUrl } from "@/utils/absolute-url";
import { ConnectedDayjsProvider } from "@/utils/dayjs"; import { ConnectedDayjsProvider } from "@/utils/dayjs";
import { getStaticTranslations } from "@/utils/with-page-translations"; import { getStaticTranslations } from "@/utils/with-page-translations";

View file

@ -6,10 +6,30 @@ const getVercelUrl = () => {
: null; : null;
}; };
export function absoluteUrl(path = "") { function joinPath(baseUrl: string, subpath = "") {
if (subpath) {
const url = new URL(subpath, baseUrl);
return url.href;
}
return baseUrl;
}
export function absoluteUrl(subpath = "", query?: Record<string, string>) {
const queryString = query
? `?${Object.entries(query)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&")}`
: "";
const baseUrl = const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXT_PUBLIC_BASE_URL ??
getVercelUrl() ?? getVercelUrl() ??
`http://localhost:${port}`; `http://localhost:${port}`;
return `${baseUrl}${path}`;
return joinPath(baseUrl, subpath) + queryString;
}
export function shortUrl(subpath = "") {
const baseUrl = process.env.NEXT_PUBLIC_SHORT_BASE_URL ?? absoluteUrl();
return joinPath(baseUrl, subpath);
} }

View file

@ -1,5 +1,7 @@
import { EmailClient, SupportedEmailProviders } from "@rallly/emails"; import { EmailClient, SupportedEmailProviders } from "@rallly/emails";
import { absoluteUrl } from "@/utils/absolute-url";
const env = process.env["NODE" + "_ENV"]; const env = process.env["NODE" + "_ENV"];
export const emailClient = new EmailClient({ export const emailClient = new EmailClient({
@ -16,4 +18,8 @@ export const emailClient = new EmailClient({
(process.env.SUPPORT_EMAIL as string), (process.env.SUPPORT_EMAIL as string),
}, },
}, },
context: {
logoUrl: absoluteUrl("/logo.png"),
baseUrl: absoluteUrl(""),
},
}); });

View file

@ -8,6 +8,13 @@ export interface TRPCContext {
emailClient: EmailClient; emailClient: EmailClient;
isSelfHosted: boolean; isSelfHosted: boolean;
isEmailBlocked?: (email: string) => boolean; isEmailBlocked?: (email: string) => boolean;
/**
* Takes a relative path and returns an absolute URL to the app
* @param path
* @returns absolute URL
*/
absoluteUrl: (path?: string) => string;
shortUrl: (path?: string) => string;
} }
export const trpcNextApiHandler = (context: TRPCContext) => { export const trpcNextApiHandler = (context: TRPCContext) => {

View file

@ -1,5 +1,4 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { absoluteUrl, shortUrl } from "@rallly/utils";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import dayjs from "dayjs"; import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
@ -121,9 +120,9 @@ export const polls = router({
}, },
}); });
const pollLink = absoluteUrl(`/poll/${pollId}`); const pollLink = ctx.absoluteUrl(`/poll/${pollId}`);
const participantLink = shortUrl(`/invite/${pollId}`); const participantLink = ctx.shortUrl(`/invite/${pollId}`);
if (ctx.user.isGuest === false) { if (ctx.user.isGuest === false) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@ -658,7 +657,7 @@ export const polls = router({
to: poll.user.email, to: poll.user.email,
props: { props: {
name: poll.user.name, name: poll.user.name,
pollUrl: absoluteUrl(`/poll/${poll.id}`), pollUrl: ctx.absoluteUrl(`/poll/${poll.id}`),
location: poll.location, location: poll.location,
title: poll.title, title: poll.title,
attendees: poll.participants attendees: poll.participants
@ -682,7 +681,7 @@ export const polls = router({
to: p.email, to: p.email,
props: { props: {
name: p.name, name: p.name,
pollUrl: absoluteUrl(`/poll/${poll.id}`), pollUrl: ctx.absoluteUrl(`/poll/${poll.id}`),
location: poll.location, location: poll.location,
title: poll.title, title: poll.title,
hostName: poll.user?.name ?? "", hostName: poll.user?.name ?? "",

View file

@ -1,5 +1,4 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { absoluteUrl } from "@rallly/utils";
import { z } from "zod"; import { z } from "zod";
import { createToken } from "../../../session"; import { createToken } from "../../../session";
@ -87,8 +86,8 @@ export const comments = router({
props: { props: {
name: watcher.user.name, name: watcher.user.name,
authorName, authorName,
pollUrl: absoluteUrl(`/poll/${poll.id}`), pollUrl: ctx.absoluteUrl(`/poll/${poll.id}`),
disableNotificationsUrl: absoluteUrl( disableNotificationsUrl: ctx.absoluteUrl(
`/auth/disable-notifications?token=${token}`, `/auth/disable-notifications?token=${token}`,
), ),
title: poll.title, title: poll.title,

View file

@ -1,5 +1,4 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { absoluteUrl } from "@rallly/utils";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
@ -112,7 +111,7 @@ export const participants = router({
props: { props: {
name, name,
title: poll.title, title: poll.title,
editSubmissionUrl: absoluteUrl( editSubmissionUrl: ctx.absoluteUrl(
`/invite/${poll.id}?token=${token}`, `/invite/${poll.id}?token=${token}`,
), ),
}, },
@ -149,8 +148,8 @@ export const participants = router({
props: { props: {
name: watcher.user.name, name: watcher.user.name,
participantName: participant.name, participantName: participant.name,
pollUrl: absoluteUrl(`/poll/${poll.id}`), pollUrl: ctx.absoluteUrl(`/poll/${poll.id}`),
disableNotificationsUrl: absoluteUrl( disableNotificationsUrl: ctx.absoluteUrl(
`/auth/disable-notifications?token=${token}`, `/auth/disable-notifications?token=${token}`,
), ),
title: poll.title, title: poll.title,

View file

@ -7,6 +7,7 @@ import previewEmail from "preview-email";
import React from "react"; import React from "react";
import * as templates from "./templates"; import * as templates from "./templates";
import { EmailContext } from "./templates/components/email-context";
type Templates = typeof templates; type Templates = typeof templates;
@ -15,7 +16,6 @@ type TemplateName = keyof typeof templates;
type TemplateProps<T extends TemplateName> = React.ComponentProps< type TemplateProps<T extends TemplateName> = React.ComponentProps<
TemplateComponent<T> TemplateComponent<T>
>; >;
type TemplateComponent<T extends TemplateName> = Templates[T]; type TemplateComponent<T extends TemplateName> = Templates[T];
type SendEmailOptions<T extends TemplateName> = { type SendEmailOptions<T extends TemplateName> = {
@ -59,6 +59,10 @@ type EmailClientConfig = {
address: string; address: string;
}; };
}; };
/**
* Context to pass to each email
*/
context: EmailContext;
}; };
export class EmailClient { export class EmailClient {
@ -79,8 +83,14 @@ export class EmailClient {
} }
const Template = templates[templateName] as TemplateComponent<T>; const Template = templates[templateName] as TemplateComponent<T>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any const html = render(
const html = render(<Template {...(options.props as any)} />); <EmailContext.Provider value={this.config.context}>
<Template
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(options.props as any)}
/>
</EmailContext.Provider>,
);
try { try {
await this.sendEmail({ await this.sendEmail({

View file

@ -0,0 +1,19 @@
import React from "react";
export type EmailContext = {
logoUrl: string;
baseUrl: string;
};
export const EmailContext = React.createContext<EmailContext>({
logoUrl: "",
baseUrl: "",
});
export const useEmailContext = () => {
const context = React.useContext(EmailContext);
return {
...context,
domain: context.baseUrl.replace(/(^\w+:|^)\/\//, ""),
};
};

View file

@ -1,4 +1,3 @@
import { absoluteUrl } from "@rallly/utils";
import { import {
Body, Body,
Container, Container,
@ -9,6 +8,7 @@ import {
Preview, Preview,
} from "@react-email/components"; } from "@react-email/components";
import { useEmailContext } from "./email-context";
import { fontFamily, Section, Text } from "./styled-components"; import { fontFamily, Section, Text } from "./styled-components";
export interface EmailLayoutProps { export interface EmailLayoutProps {
@ -43,13 +43,14 @@ export const EmailLayout = ({
children, children,
footNote, footNote,
}: React.PropsWithChildren<EmailLayoutProps>) => { }: React.PropsWithChildren<EmailLayoutProps>) => {
const { logoUrl, baseUrl } = useEmailContext();
return ( return (
<Html> <Html>
<Head /> <Head />
<Preview>{preview}</Preview> <Preview>{preview}</Preview>
<Body style={{ backgroundColor: "#F3F4F6", padding: "16px" }}> <Body style={{ backgroundColor: "#F3F4F6", padding: "16px" }}>
<Container style={containerStyles}> <Container style={containerStyles}>
<Img src={absoluteUrl("/logo.png")} alt="Rallly" width={128} /> <Img src={logoUrl} alt="Rallly" width={128} />
<Section style={sectionStyles}> <Section style={sectionStyles}>
<Text>Hi {recipientName},</Text> <Text>Hi {recipientName},</Text>
{children} {children}
@ -68,7 +69,7 @@ export const EmailLayout = ({
) : null} ) : null}
</Section> </Section>
<Section style={{ ...sectionStyles, fontSize: 14, marginBottom: 0 }}> <Section style={{ ...sectionStyles, fontSize: 14, marginBottom: 0 }}>
<Link style={linkStyles} href={absoluteUrl()}> <Link style={linkStyles} href={baseUrl}>
Home Home
</Link> </Link>
<span>&nbsp;&bull;&nbsp;</span> <span>&nbsp;&bull;&nbsp;</span>

View file

@ -1,6 +1,6 @@
import { useEmailContext } from "./email-context";
import { EmailLayout } from "./email-layout"; import { EmailLayout } from "./email-layout";
import { Button, Link, Text } from "./styled-components"; import { Button, Link, Text } from "./styled-components";
import { getDomain } from "./utils";
export interface NotificationBaseProps { export interface NotificationBaseProps {
name: string; name: string;
@ -20,6 +20,7 @@ export const NotificationEmail = ({
preview, preview,
children, children,
}: React.PropsWithChildren<NotificationEmailProps>) => { }: React.PropsWithChildren<NotificationEmailProps>) => {
const { domain } = useEmailContext();
return ( return (
<EmailLayout <EmailLayout
recipientName={name} recipientName={name}
@ -36,7 +37,7 @@ export const NotificationEmail = ({
> >
{children} {children}
<Text> <Text>
<Button href={pollUrl}>View on {getDomain()}</Button> <Button href={pollUrl}>View on {domain}</Button>
</Text> </Text>
</EmailLayout> </EmailLayout>
); );

View file

@ -1,4 +1,3 @@
import { absoluteUrl } from "@rallly/utils";
import { import {
Button as UnstyledButton, Button as UnstyledButton,
ButtonProps, ButtonProps,
@ -11,7 +10,7 @@ import {
TextProps, TextProps,
} from "@react-email/components"; } from "@react-email/components";
import { getDomain } from "./utils"; import { useEmailContext } from "./email-context";
export const borderColor = "#E2E8F0"; export const borderColor = "#E2E8F0";
export const Text = ( export const Text = (
@ -34,7 +33,8 @@ export const Text = (
}; };
export const Domain = () => { export const Domain = () => {
return <Link href={absoluteUrl()}>{getDomain()}</Link>; const { baseUrl, domain } = useEmailContext();
return <Link href={baseUrl}>{domain}</Link>;
}; };
export const Button = (props: ButtonProps) => { export const Button = (props: ButtonProps) => {

View file

@ -1,7 +0,0 @@
import { absoluteUrl } from "@rallly/utils";
export const removeProtocalFromUrl = (url: string) => {
return url.replace(/(^\w+:|^)\/\//, "");
};
export const getDomain = () => removeProtocalFromUrl(absoluteUrl());

View file

@ -1,3 +1,4 @@
import { useEmailContext } from "./components/email-context";
import { EmailLayout } from "./components/email-layout"; import { EmailLayout } from "./components/email-layout";
import { import {
Button, Button,
@ -7,7 +8,6 @@ import {
Text, Text,
trackingWide, trackingWide,
} from "./components/styled-components"; } from "./components/styled-components";
import { getDomain } from "./components/utils";
interface LoginEmailProps { interface LoginEmailProps {
name: string; name: string;
@ -20,6 +20,7 @@ export const LoginEmail = ({
code = "123456", code = "123456",
magicLink = "https://rallly.co", magicLink = "https://rallly.co",
}: LoginEmailProps) => { }: LoginEmailProps) => {
const { domain } = useEmailContext();
return ( return (
<EmailLayout <EmailLayout
footNote={ footNote={
@ -39,7 +40,7 @@ export const LoginEmail = ({
<Heading>Option 1: Magic Link</Heading> <Heading>Option 1: Magic Link</Heading>
<Text>Click this magic link to log in on this device.</Text> <Text>Click this magic link to log in on this device.</Text>
<Button href={magicLink} id="magicLink"> <Button href={magicLink} id="magicLink">
Log in to {getDomain()} Log in to {domain}
</Button> </Button>
<Text light={true}>This link will expire in 15 minutes.</Text> <Text light={true}>This link will expire in 15 minutes.</Text>
</Card> </Card>

View file

@ -1,6 +1,6 @@
import { useEmailContext } from "./components/email-context";
import { EmailLayout } from "./components/email-layout"; import { EmailLayout } from "./components/email-layout";
import { Button, Domain, Section, Text } from "./components/styled-components"; import { Button, Domain, Section, Text } from "./components/styled-components";
import { getDomain } from "./components/utils";
interface NewParticipantConfirmationEmailProps { interface NewParticipantConfirmationEmailProps {
name: string; name: string;
@ -12,6 +12,7 @@ export const NewParticipantConfirmationEmail = ({
name = "John", name = "John",
editSubmissionUrl = "https://rallly.co", editSubmissionUrl = "https://rallly.co",
}: NewParticipantConfirmationEmailProps) => { }: NewParticipantConfirmationEmailProps) => {
const { domain } = useEmailContext();
return ( return (
<EmailLayout <EmailLayout
footNote={ footNote={
@ -32,7 +33,7 @@ export const NewParticipantConfirmationEmail = ({
</Text> </Text>
<Section> <Section>
<Button id="editSubmissionUrl" href={editSubmissionUrl}> <Button id="editSubmissionUrl" href={editSubmissionUrl}>
Review response on {getDomain()} Review response on {domain}
</Button> </Button>
</Section> </Section>
</EmailLayout> </EmailLayout>

View file

@ -1,8 +1,6 @@
import { absoluteUrl } from "@rallly/utils"; import { useEmailContext } from "./components/email-context";
import { EmailLayout } from "./components/email-layout"; import { EmailLayout } from "./components/email-layout";
import { Button, Card, Link, Text } from "./components/styled-components"; import { Button, Card, Link, Text } from "./components/styled-components";
import { getDomain } from "./components/utils";
export interface NewPollEmailProps { export interface NewPollEmailProps {
title: string; title: string;
@ -40,13 +38,14 @@ export const NewPollEmail = ({
adminLink = "https://rallly.co/admin/abcdefg123", adminLink = "https://rallly.co/admin/abcdefg123",
participantLink = "https://rallly.co/invite/wxyz9876", participantLink = "https://rallly.co/invite/wxyz9876",
}: NewPollEmailProps) => { }: NewPollEmailProps) => {
const { baseUrl, domain } = useEmailContext();
return ( return (
<EmailLayout <EmailLayout
footNote={ footNote={
<> <>
You are receiving this email because a new poll was created with this You are receiving this email because a new poll was created with this
email address on <Link href={absoluteUrl()}>{getDomain()}</Link>. If email address on <Link href={baseUrl}>{domain}</Link>. If this
this wasn&apos;t you, please ignore this email. wasn&apos;t you, please ignore this email.
</> </>
} }
recipientName={name} recipientName={name}

View file

@ -1,2 +1 @@
export * from "./src/absolute-url";
export * from "./src/prevent-widows"; export * from "./src/prevent-widows";