Refactor CMS to handle ContentLayer and WordPress platforms

This commit refactors the CMS to handle two platforms: ContentLayer and WordPress. The CMS layer is abstracted into a core package, and separate implementations for each platform are created. This change allows the app to switch the CMS type based on environment variable, which can improve the flexibility of content management. It also updates several functions in the `server-sitemap.xml` route to accommodate these changes and generate sitemaps based on the CMS client. Further, documentation content and posts have been relocated to align with the new structure. Notably, this refactor is a comprehensive update to the way the CMS is structured and managed.
This commit is contained in:
giancarlo
2024-04-01 19:47:51 +08:00
parent d6004f2f7e
commit 6b72206b00
62 changed files with 1313 additions and 690 deletions

View File

@@ -7,6 +7,9 @@ NEXT_PUBLIC_DEFAULT_THEME_MODE=light
NEXT_PUBLIC_THEME_COLOR="#ffffff"
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
# CMS
CMS_CLIENT=contentlayer
# AUTH
NEXT_PUBLIC_AUTH_PASSWORD=true
NEXT_PUBLIC_AUTH_MAGIC_LINK=false

View File

@@ -1,12 +1,10 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Script from 'next/script';
import { allPosts } from 'contentlayer/generated';
import { createCmsClient } from '@kit/cms';
import Post from '~/(marketing)/blog/_components/post';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata({
@@ -14,14 +12,14 @@ export async function generateMetadata({
}: {
params: { slug: string };
}): Promise<Metadata | undefined> {
const post = allPosts.find((post) => post.slug === params.slug);
const cms = await createCmsClient();
const post = await cms.getContentItemById(params.slug);
if (!post) {
notFound();
}
const { title, date, description, image, slug } = post;
const url = [appConfig.url, 'blog', slug].join('/');
const { title, publishedAt, description, image } = post;
return Promise.resolve({
title,
@@ -30,8 +28,8 @@ export async function generateMetadata({
title,
description,
type: 'article',
publishedTime: date,
url,
publishedTime: publishedAt.toDateString(),
url: post.url,
images: image
? [
{
@@ -49,8 +47,9 @@ export async function generateMetadata({
});
}
function BlogPost({ params }: { params: { slug: string } }) {
const post = allPosts.find((post) => post.slug === params.slug);
async function BlogPost({ params }: { params: { slug: string } }) {
const cms = await createCmsClient();
const post = await cms.getContentItemById(params.slug);
if (!post) {
notFound();
@@ -58,11 +57,7 @@ function BlogPost({ params }: { params: { slug: string } }) {
return (
<div className={'container mx-auto'}>
<Script id={'ld-json'} type="application/ld+json">
{JSON.stringify(post.structuredData)}
</Script>
<Post post={post} content={post.body.code} />
<Post post={post} content={post.content} />
</div>
);
}

View File

@@ -1,5 +1,4 @@
import type { Post } from 'contentlayer/generated';
import { Cms } from '@kit/cms';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
@@ -7,9 +6,9 @@ import { CoverImage } from '~/(marketing)/blog/_components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/_components/date-formatter';
export const PostHeader: React.FC<{
post: Post;
post: Cms.ContentItem;
}> = ({ post }) => {
const { title, date, readingTime, description, image } = post;
const { title, publishedAt, description, image } = post;
// NB: change this to display the post's image
const displayImage = true;
@@ -30,11 +29,8 @@ export const PostHeader: React.FC<{
<div className="flex">
<div className="flex flex-row items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
<div>
<DateFormatter dateString={date} />
<DateFormatter dateString={publishedAt.toISOString()} />
</div>
<span>·</span>
<span>{readingTime} minutes reading</span>
</div>
</div>

View File

@@ -1,14 +1,13 @@
import Link from 'next/link';
import type { Post } from 'contentlayer/generated';
import { Cms } from '@kit/cms';
import { If } from '@kit/ui/if';
import { CoverImage } from '~/(marketing)/blog/_components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/_components/date-formatter';
type Props = {
post: Post;
post: Cms.ContentItem;
preloadImage?: boolean;
imageHeight?: string | number;
};
@@ -20,15 +19,16 @@ export function PostPreview({
preloadImage,
imageHeight,
}: React.PropsWithChildren<Props>) {
const { title, image, date, readingTime, description } = post;
const { title, image, publishedAt, description } = post;
const height = imageHeight ?? DEFAULT_IMAGE_HEIGHT;
const url = post.url;
return (
<div className="rounded-xl transition-shadow duration-500 dark:text-gray-800">
<div className="rounded-xl transition-shadow duration-500">
<If condition={image}>
{(imageUrl) => (
<div className="relative mb-2 w-full" style={{ height }}>
<Link href={post.url}>
<Link href={url}>
<CoverImage
preloadImage={preloadImage}
title={title}
@@ -40,27 +40,21 @@ export function PostPreview({
</If>
<div className={'px-1'}>
<div className="flex flex-col space-y-1 px-1 py-2">
<h3 className="px-1 text-2xl font-bold leading-snug dark:text-white">
<Link href={post.url} className="hover:underline">
<div className="flex flex-col space-y-1 py-2">
<h3 className="text-2xl font-bold leading-snug dark:text-white">
<Link href={url} className="hover:underline">
{title}
</Link>
</h3>
</div>
<div className="mb-2 flex flex-row items-center space-x-2 px-1 text-sm">
<div className="text-gray-600 dark:text-gray-300">
<DateFormatter dateString={date} />
<div className="mb-2 flex flex-row items-center space-x-2 text-sm">
<div className="text-muted-foreground">
<DateFormatter dateString={publishedAt.toISOString()} />
</div>
<span className="text-gray-600 dark:text-gray-300">·</span>
<span className="text-gray-600 dark:text-gray-300">
{readingTime} mins reading
</span>
</div>
<p className="mb-4 px-1 text-sm leading-relaxed dark:text-gray-300">
<p className="mb-4 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
</div>

View File

@@ -1,15 +1,10 @@
import dynamic from 'next/dynamic';
import type { Post as PostType } from 'contentlayer/generated';
import type { Cms } from '@kit/cms';
import { ContentRenderer } from '@kit/cms';
import { PostHeader } from './post-header';
const Mdx = dynamic(() =>
import('@kit/ui/mdx').then((mod) => ({ default: mod.Mdx })),
);
export const Post: React.FC<{
post: PostType;
post: Cms.ContentItem;
content: string;
}> = ({ post, content }) => {
return (
@@ -17,7 +12,7 @@ export const Post: React.FC<{
<PostHeader post={post} />
<article className={'mx-auto flex justify-center'}>
<Mdx code={content} />
<ContentRenderer content={content} />
</article>
</div>
);

View File

@@ -1,6 +1,6 @@
import type { Metadata } from 'next';
import { allPosts } from 'contentlayer/generated';
import { createCmsClient } from '@kit/cms';
import { GridList } from '~/(marketing)/_components/grid-list';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
@@ -13,11 +13,11 @@ export const metadata: Metadata = {
description: `Tutorials, Guides and Updates from our team`,
};
function BlogPage() {
const livePosts = allPosts.filter((post) => {
const isProduction = appConfig.production;
async function BlogPage() {
const cms = await createCmsClient();
return isProduction ? post.live : true;
const posts = await cms.getContentItems({
type: 'post',
});
return (
@@ -29,7 +29,7 @@ function BlogPage() {
/>
<GridList>
{livePosts.map((post, idx) => {
{posts.map((post, idx) => {
return <PostPreview key={idx} post={post} />;
})}
</GridList>

View File

@@ -2,20 +2,20 @@ import { cache } from 'react';
import { notFound } from 'next/navigation';
import { allDocumentationPages } from 'contentlayer/generated';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { ContentRenderer, createCmsClient } from '@kit/cms';
import { If } from '@kit/ui/if';
import { Mdx } from '@kit/ui/mdx';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { DocsCards } from '~/(marketing)/docs/_components/docs-cards';
import { DocumentationPageLink } from '~/(marketing)/docs/_components/documentation-page-link';
import { getDocumentationPageTree } from '~/(marketing)/docs/_lib/get-documentation-page-tree';
import { withI18n } from '~/lib/i18n/with-i18n';
const getPageBySlug = cache((slug: string) => {
return allDocumentationPages.find((post) => post.resolvedPath === slug);
const getPageBySlug = cache(async (slug: string) => {
const client = await createCmsClient();
return client.getContentItemById(slug);
});
interface PageParams {
@@ -24,8 +24,8 @@ interface PageParams {
};
}
export const generateMetadata = ({ params }: PageParams) => {
const page = getPageBySlug(params.slug.join('/'));
export const generateMetadata = async ({ params }: PageParams) => {
const page = await getPageBySlug(params.slug.join('/'));
if (!page) {
notFound();
@@ -39,16 +39,13 @@ export const generateMetadata = ({ params }: PageParams) => {
};
};
function DocumentationPage({ params }: PageParams) {
const page = getPageBySlug(params.slug.join('/'));
async function DocumentationPage({ params }: PageParams) {
const page = await getPageBySlug(params.slug.join('/'));
if (!page) {
notFound();
}
const { nextPage, previousPage, children } =
getDocumentationPageTree(page.resolvedPath) ?? {};
const description = page?.description ?? '';
return (
@@ -60,40 +57,11 @@ function DocumentationPage({ params }: PageParams) {
className={'items-start'}
/>
<Mdx code={page.body.code} />
<ContentRenderer content={page.content} />
<If condition={children}>
<DocsCards pages={children ?? []} />
<If condition={page.children}>
<DocsCards pages={page.children ?? []} />
</If>
<div
className={
'flex flex-col justify-between space-y-4 md:flex-row md:space-x-8' +
' md:space-y-0'
}
>
<div className={'w-full'}>
<If condition={previousPage}>
{(page) => (
<DocumentationPageLink
page={page}
before={<ChevronLeft className={'w-4'} />}
/>
)}
</If>
</div>
<div className={'w-full'}>
<If condition={nextPage}>
{(page) => (
<DocumentationPageLink
page={page}
after={<ChevronRight className={'w-4'} />}
/>
)}
</If>
</div>
</div>
</div>
</div>
);

View File

@@ -4,18 +4,18 @@ import { ChevronRight } from 'lucide-react';
export const DocsCard: React.FC<
React.PropsWithChildren<{
label: string;
title: string;
subtitle?: string | null;
link?: { url: string; label: string };
}>
> = ({ label, subtitle, children, link }) => {
> = ({ title, subtitle, children, link }) => {
return (
<div className="flex flex-col">
<div
className={`flex grow flex-col space-y-2.5 border bg-background p-6
${link ? 'rounded-t-2xl border-b-0' : 'rounded-2xl'}`}
>
<h3 className="mt-0 text-lg font-semibold dark:text-white">{label}</h3>
<h3 className="mt-0 text-lg font-semibold dark:text-white">{title}</h3>
{subtitle && (
<div className="text-sm text-gray-500 dark:text-gray-400">
@@ -31,7 +31,7 @@ export const DocsCard: React.FC<
<span className={'flex items-center space-x-2'}>
<Link
className={'text-sm font-medium hover:underline'}
href={`/docs/${link.url}`}
href={link.url}
>
{link.label}
</Link>

View File

@@ -1,19 +1,19 @@
import type { DocumentationPage } from 'contentlayer/generated';
import { Cms } from '@kit/cms';
import { DocsCard } from './docs-card';
export function DocsCards({ pages }: { pages: DocumentationPage[] }) {
export function DocsCards({ pages }: { pages: Cms.ContentItem[] }) {
return (
<div className={'grid grid-cols-1 gap-8 lg:grid-cols-2'}>
{pages.map((item) => {
return (
<DocsCard
key={item.label}
label={item.label}
key={item.title}
title={item.title}
subtitle={item.description}
link={{
url: item.resolvedPath,
label: item.cardCTA ?? 'Read more',
url: item.url,
label: 'Read more',
}}
/>
);

View File

@@ -5,33 +5,21 @@ import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronDown, Menu } from 'lucide-react';
import { Menu } from 'lucide-react';
import { Cms } from '@kit/cms';
import { isBrowser } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/utils';
import type { ProcessedDocumentationPage } from '~/(marketing)/docs/_lib/build-documentation-tree';
const DocsNavLink: React.FC<{
label: string;
url: string;
level: number;
activePath: string;
collapsible: boolean;
collapsed: boolean;
toggleCollapsed: () => void;
}> = ({
label,
url,
level,
activePath,
collapsible,
collapsed,
toggleCollapsed,
}) => {
}> = ({ label, url, level, activePath }) => {
const isCurrent = url == activePath;
const isFirstLevel = level === 0;
@@ -39,76 +27,51 @@ const DocsNavLink: React.FC<{
<div className={getNavLinkClassName(isCurrent, isFirstLevel)}>
<Link
className="flex h-full max-w-full grow items-center space-x-2"
href={`/docs/${url}`}
href={url}
>
<span className="block max-w-full truncate">{label}</span>
</Link>
{collapsible && (
<button
aria-label="Toggle children"
onClick={toggleCollapsed}
className="mr-2 shrink-0 px-2 py-1"
>
<span
className={`block w-2.5 ${collapsed ? '-rotate-90 transform' : ''}`}
>
<ChevronDown className="h-4 w-4" />
</span>
</button>
)}
</div>
);
};
const Node: React.FC<{
node: ProcessedDocumentationPage;
node: Cms.ContentItem;
level: number;
activePath: string;
}> = ({ node, level, activePath }) => {
const [collapsed, setCollapsed] = useState<boolean>(node.collapsed ?? false);
const toggleCollapsed = () => setCollapsed(!collapsed);
useEffect(() => {
if (
activePath == node.resolvedPath ||
node.children.map((_) => _.resolvedPath).includes(activePath)
) {
setCollapsed(false);
}
}, [activePath, node.children, node.resolvedPath]);
return (
<>
<DocsNavLink
label={node.label}
url={node.resolvedPath}
label={node.title}
url={node.url}
level={level}
activePath={activePath}
collapsible={node.collapsible}
collapsed={collapsed}
toggleCollapsed={toggleCollapsed}
/>
{node.children.length > 0 && !collapsed && (
<Tree tree={node.children} level={level + 1} activePath={activePath} />
{(node.children ?? []).length > 0 && (
<Tree
pages={node.children ?? []}
level={level + 1}
activePath={activePath}
/>
)}
</>
);
};
function Tree({
tree,
pages,
level,
activePath,
}: {
tree: ProcessedDocumentationPage[];
pages: Cms.ContentItem[];
level: number;
activePath: string;
}) {
return (
<div className={cn('w-full space-y-2.5 pl-3', level > 0 ? 'border-l' : '')}>
{tree.map((treeNode, index) => (
{pages.map((treeNode, index) => (
<Node
key={index}
node={treeNode}
@@ -120,11 +83,7 @@ function Tree({
);
}
export default function DocsNavigation({
tree,
}: {
tree: ProcessedDocumentationPage[];
}) {
export function DocsNavigation({ pages }: { pages: Cms.ContentItem[] }) {
const activePath = usePathname().replace('/docs/', '');
return (
@@ -135,11 +94,14 @@ export default function DocsNavigation({
}}
className="sticky top-2 hidden w-80 shrink-0 border-r p-4 lg:flex"
>
<Tree tree={tree} level={0} activePath={activePath} />
<Tree pages={pages} level={0} activePath={activePath} />
</aside>
<div className={'lg:hidden'}>
<FloatingDocumentationNavigation tree={tree} activePath={activePath} />
<FloatingDocumentationNavigation
pages={pages}
activePath={activePath}
/>
</div>
</>
);
@@ -159,10 +121,10 @@ function getNavLinkClassName(isCurrent: boolean, isFirstLevel: boolean) {
}
function FloatingDocumentationNavigation({
tree,
pages,
activePath,
}: React.PropsWithChildren<{
tree: ProcessedDocumentationPage[];
pages: Cms.ContentItem[];
activePath: string;
}>) {
const body = useMemo(() => {
@@ -210,7 +172,7 @@ function FloatingDocumentationNavigation({
>
<Heading level={1}>Table of Contents</Heading>
<Tree tree={tree} level={0} activePath={activePath} />
<Tree pages={pages} level={0} activePath={activePath} />
</div>
</If>

View File

@@ -1,7 +1,5 @@
import Link from 'next/link';
import type { DocumentationPage } from 'contentlayer/generated';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/utils';
@@ -10,7 +8,10 @@ export function DocumentationPageLink({
before,
after,
}: React.PropsWithChildren<{
page: DocumentationPage;
page: {
url: string;
title: string;
};
before?: React.ReactNode;
after?: React.ReactNode;
}>) {
@@ -23,7 +24,7 @@ export function DocumentationPageLink({
'justify-end self-end': after,
},
)}
href={`/docs/${page.resolvedPath}`}
href={page.url}
>
<If condition={before}>{(node) => <>{node}</>}</If>

View File

@@ -1,55 +0,0 @@
import { cache } from 'react';
import type { DocumentationPage } from 'contentlayer/generated';
export interface ProcessedDocumentationPage extends DocumentationPage {
collapsible: boolean;
pathSegments: string[];
nextPage: ProcessedDocumentationPage | DocumentationPage | undefined;
previousPage: ProcessedDocumentationPage | DocumentationPage | undefined;
children: DocsTree;
}
export type DocsTree = ProcessedDocumentationPage[];
/**
* Build a tree of documentation pages from a flat list of pages with path segments
* @param docs
* @param parentPathNames
*/
export const buildDocumentationTree = cache(
(docs: DocumentationPage[], parentPathNames: string[] = []): DocsTree => {
const level = parentPathNames.length;
const pages = docs
.filter(
(_) =>
_.pathSegments.length === level + 1 &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
_.pathSegments
.map(({ pathName }: { pathName: string }) => pathName)
.join('/')
.startsWith(parentPathNames.join('/')),
)
.sort(
(a, b) => a.pathSegments[level].order - b.pathSegments[level].order,
);
return pages.map((doc, index) => {
const children = buildDocumentationTree(
docs,
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
doc.pathSegments.map(({ pathName }: { pathName: string }) => pathName),
);
return {
...doc,
pathSegments: doc.pathSegments || ([] as string[]),
collapsible: children.length > 0,
nextPage: children[0] ?? pages[index + 1],
previousPage: pages[index - 1],
children,
};
});
},
);

View File

@@ -1,45 +0,0 @@
import { cache } from 'react';
import type { DocumentationPage } from 'contentlayer/generated';
import { allDocumentationPages } from 'contentlayer/generated';
import { buildDocumentationTree } from './build-documentation-tree';
/**
* Retrieves a specific documentation page from the page tree by its path.
*
* @param {string} pagePath - The path of the documentation page to retrieve.
* @returns {DocumentationPageWithChildren | undefined} The documentation page found in the tree, if any.
*/
export const getDocumentationPageTree = cache((pagePath: string) => {
const tree = buildDocumentationTree(allDocumentationPages);
type DocumentationPageWithChildren = DocumentationPage & {
previousPage?: DocumentationPage | null;
nextPage?: DocumentationPage | null;
children?: DocumentationPage[];
};
const findPageInTree = (
pages: DocumentationPageWithChildren[],
path: string,
): DocumentationPageWithChildren | undefined => {
for (const page of pages) {
if (page.resolvedPath === path) {
return page;
}
const hasChildren = page.children && page.children.length > 0;
if (hasChildren) {
const foundPage = findPageInTree(page.children ?? [], path);
if (foundPage) {
return foundPage;
}
}
}
};
return findPageInTree(tree, pagePath);
});

View File

@@ -1,15 +1,22 @@
import { allDocumentationPages } from 'contentlayer/generated';
import { createCmsClient } from '@kit/cms';
import DocsNavigation from '~/(marketing)/docs/_components/docs-navigation';
import { buildDocumentationTree } from '~/(marketing)/docs/_lib/build-documentation-tree';
import { DocsNavigation } from '~/(marketing)/docs/_components/docs-navigation';
function DocsLayout({ children }: React.PropsWithChildren) {
const tree = buildDocumentationTree(allDocumentationPages);
async function DocsLayout({ children }: React.PropsWithChildren) {
const cms = await createCmsClient();
const pages = await cms.getContentItems({
type: 'page',
categories: ['documentation'],
depth: 1,
});
console.log(pages);
return (
<div className={'container mx-auto'}>
<div className={'flex'}>
<DocsNavigation tree={tree} />
<DocsNavigation pages={pages} />
<div className={'flex w-full flex-col items-center'}>{children}</div>
</div>

View File

@@ -1,8 +1,7 @@
import { allDocumentationPages } from 'contentlayer/generated';
import { createCmsClient } from '@kit/cms';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { DocsCards } from '~/(marketing)/docs/_components/docs-cards';
import { buildDocumentationTree } from '~/(marketing)/docs/_lib/build-documentation-tree';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';
@@ -10,8 +9,16 @@ export const metadata = {
title: `Documentation - ${appConfig.name}`,
};
function DocsPage() {
const tree = buildDocumentationTree(allDocumentationPages);
async function DocsPage() {
const client = await createCmsClient();
const docs = await client.getContentItems({
type: 'page',
categories: ['documentation'],
depth: 1,
});
console.log(docs);
return (
<div className={'my-8 flex flex-col space-y-16'}>
@@ -21,7 +28,7 @@ function DocsPage() {
/>
<div>
<DocsCards pages={tree ?? []} />
<DocsCards pages={docs} />
</div>
</div>
);

View File

@@ -1,17 +1,26 @@
import { invariant } from '@epic-web/invariant';
import { allDocumentationPages, allPosts } from 'contentlayer/generated';
import { getServerSideSitemap } from 'next-sitemap';
import { createCmsClient } from '@kit/cms';
import appConfig from '~/config/app.config';
invariant(appConfig.url, 'No NEXT_PUBLIC_SITE_URL environment variable found');
export async function GET() {
const urls = getSiteUrls();
const posts = getPostsSitemap();
const docs = getDocsSitemap();
const client = await createCmsClient();
const contentItems = await client.getContentItems();
return getServerSideSitemap([...urls, ...posts, ...docs]);
return getServerSideSitemap([
...urls,
...contentItems.map((item) => {
return {
loc: new URL(item.url, appConfig.url).href,
lastmod: new Date().toISOString(),
};
}),
]);
}
function getSiteUrls() {
@@ -24,21 +33,3 @@ function getSiteUrls() {
};
});
}
function getPostsSitemap() {
return allPosts.map((post) => {
return {
loc: new URL(post.url, appConfig.url).href,
lastmod: new Date().toISOString(),
};
});
}
function getDocsSitemap() {
return allDocumentationPages.map((page) => {
return {
loc: new URL(page.url, appConfig.url).href,
lastmod: new Date().toISOString(),
};
});
}

View File

@@ -1,85 +0,0 @@
---
title: Installing Makerkit
label: Installing Makerkit
description: Learn how to install Makerkit on your local machine
---
If you have bought a license for MakerKit, you have access to all the
repositories built by the MakerKit team. In this document, we will learn how
to fetch and install the codebase.
### Requirements
To get started with the Next.js and Supabase SaaS template, we need to ensure
you install the required software.
- Node.js
- Git
- Docker
### Getting Started with MakerKit
You have two choices for cloning the repository:
1. forking the original repository and cloning it from your fork
2. cloning it manually from the original repository
#### Clone the repository
To get the codebase on your local machine using the original repository, clone the repository with the
following command:
```
git clone --depth=1 git@github.com:makerkit/next-supabase-saas-kit-lite.git my-saas
```
The command above clones the repository in the folder `my-saas` which
you can rename it with the name of your project.
If you forked the repository, point it to your fork instead of the original.
#### Initializing Git
Now, run the following commands for:
1. Moving into the folder
2. Reinitialize your git repository
Personally I re-initialize the Git repository, but it's not required.
```
cd my-saas
rm -rf .git
git init
```
### Setting the Upstream repository, and fetching updates
Now, we can add the original Makerkit repository as "upstream" so we can fetch updates from the main repository:
```
git remote add upstream git@github.com:makerkit/next-supabase-saas-kit-lite.git
git add .
git commit -a -m "Initial Commit"
```
In this way, to fetch updates (after committing your files), simply run:
```
git pull upstream main --allow-unrelated-histories
```
You'll likely run into conflicts when running this command, so carefully choose the changes (sorry!).
### Installing the Node dependencies
Finally, we can install the NodeJS dependencies with `npm`:
```
npm i
```
While the application code is fully working, we now need to set up your Supabase
project.
So let's jump on to the next step!

View File

@@ -1,85 +0,0 @@
---
title: Clone the MakerKit SaaS boilerplate repository
label: Clone the repository
description: Learn how to clone the MakerKit repository and install the NodeJS dependencies.
---
If you have bought a license for MakerKit, you have access to all the
repositories built by the MakerKit team. In this document, we will learn how
to fetch and install the codebase.
### Requirements
To get started with the Next.js and Supabase SaaS template, we need to ensure
you install the required software.
- Node.js
- Git
- Docker
### Getting Started with MakerKit
You have two choices for cloning the repository:
1. forking the original repository and cloning it from your fork
2. cloning it manually from the original repository
#### Clone the repository
To get the codebase on your local machine using the original repository, clone the repository with the
following command:
```
git clone --depth=1 git@github.com:makerkit/next-supabase-saas-kit-lite.git my-saas
```
The command above clones the repository in the folder `my-saas` which
you can rename it with the name of your project.
If you forked the repository, point it to your fork instead of the original.
#### Initializing Git
Now, run the following commands for:
1. Moving into the folder
2. Reinitialize your git repository
Personally I re-initialize the Git repository, but it's not required.
```
cd my-saas
rm -rf .git
git init
```
### Setting the Upstream repository, and fetching updates
Now, we can add the original Makerkit repository as "upstream" so we can fetch updates from the main repository:
```
git remote add upstream git@github.com:makerkit/next-supabase-saas-kit-lite.git
git add .
git commit -a -m "Initial Commit"
```
In this way, to fetch updates (after committing your files), simply run:
```
git pull upstream main --allow-unrelated-histories
```
You'll likely run into conflicts when running this command, so carefully choose the changes (sorry!).
### Installing the Node dependencies
Finally, we can install the NodeJS dependencies with `npm`:
```
npm i
```
While the application code is fully working, we now need to set up your Supabase
project.
So let's jump on to the next step!

View File

@@ -1,17 +0,0 @@
---
title: Running the Next.js Server
label: Next.js
description: Learn how to run the Next.js server on your local machine.
---
First, we can run the Next.js Server by running the following command:
```bash
npm run dev
```
If everything goes well, your server should be running at
[http://localhost:3000](http://localhost:3000).
With the server running, we can now set up our Supabase containers using
Docker. Jump to the next section to learn how to do that.

View File

@@ -1,88 +0,0 @@
---
title: Running the Supabase Containers
label: Supabase
description: Running the Supabase containers locally for development
---
Before we can run the Supabase local environment, we need to run Docker, as Supabase uses it for running its local environment.
You can use Docker Desktop, Colima, OrbStack, or any other Docker-compatible solution.
### Running the Supabase Environment
First, let's run the Supabase environment, which will spin up a local
instance using Docker. We can do this by running the following command:
```bash
npm run supabase:start
```
Additionally, it imports the default seed data. We use it this data to
populate the database with some initial data and execute the E2E tests.
After running the command above, you will be able to access the Supabase
Studio UI at [http://localhost:54323/](http://localhost:54323/).
### Adding the Supabase Keys to the Environment Variables
If this is the first time you run this command, we will need to get the
Supabase keys and add them to our local environment variables configuration
file `.env`.
When running the command, we will see a message like this:
```bash
> supabase start
Applying migration 20221215192558_schema.sql...
Seeding data supabase/seed.sql...
Started supabase local development setup.
API URL: http://localhost:54321
DB URL: postgresql://postgres:postgres@localhost:54322/postgres
Studio URL: http://localhost:54323
Inbucket URL: http://localhost:54324
JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
anon key: ****************************************************
service_role key: ****************************************************
```
Now, we need to copy the `anon key` and `service_role key` values and add
them to the `.env` file:
```
NEXT_PUBLIC_SUPABASE_ANON_KEY=****************************************************
SUPABASE_SERVICE_ROLE_KEY=****************************************************
```
### Running the Stripe CLI
Run the Stripe CLI with the following command:
```bash
npm run stripe:listen
```
#### Add the Stripe Webhooks Key to your environment file
If this is the first time you run this command, you will need to copy the Webhooks key printed on the console and add it to your development environment variables file:
```bash title=".env.development"
STRIPE_WEBHOOKS_KEY=<PASTE_KEY_HERE>
```
#### Signing In for the first time
You should now be able to sign in. To quickly get started, use the following credentials:
```
email = test@makerkit.dev
password = testingpassword
```
#### Email Confirmations
When signing up, Supabase sends an email confirmation to a testing account. You can access the InBucket testing emails [using the following link](http://localhost:54324/monitor), and can follow the links to complete the sign up process.

View File

@@ -1,19 +0,0 @@
---
title: Running the Stripe CLI for Webhooks
label: Stripe
description: How to run the Stripe CLI for Webhooks in a local development environment
---
Run the Stripe CLI with the following command:
```bash
npm run stripe:listen
```
#### Add the Stripe Webhooks Key to your environment file
If this is the first time you run this command, you will need to copy the Webhooks key printed on the console and add it to your development environment variables file:
```bash title=".env.development"
STRIPE_WEBHOOKS_KEY=<PASTE_KEY_HERE>
```

View File

@@ -1,20 +0,0 @@
---
title: Running the Application
label: Running the Application
description: How to run the application in development mode
---
After installing the modules, we can finally run the
application in development mode.
We need to execute two commands (and an optional one for Stripe):
1. **Next.js Server**: the first command is for running the Next.js server
2. **Supabase Environment**: the second command is for running the Supabase
environment with Docker
3. **Stripe CLI**: finally, the Stripe CLI is needed to dispatch webhooks to
our local server (optional, only needed when interacting with Stripe)
## About this Documentation
This documentation complements the Supabase one and is not meant to be a replacement. We recommend reading the Supabase documentation to get a better understanding of the Supabase concepts and how to use it.

View File

@@ -1,12 +0,0 @@
---
title: Getting Started
label: Getting Started
description: Getting started with the Makerkit Kit
---
Makerkit is a Next.js/Remix SaaS Starter that helps you build your own SaaS in minutes. It comes with a fully integrated Stripe billing system, a landing page, and a dashboard.
In this section, we learn how to install and run the SaaS kit on your local
machine.
Buckle up and let's get started!

View File

@@ -1,53 +0,0 @@
---
title: Authentication Overview
label: Overview
description: Learn how authentication works in MakerKit and how to configure it.
---
The way you want your users to authenticate can be driven via configuration.
If you open the global configuration at `src/configuration.ts`, you'll find
the `auth` object:
```tsx title="configuration.ts"
import type { Provider } from '@supabase/gotrue-js/src/lib/types';
auth: {
requireEmailConfirmation: false,
providers: {
emailPassword: true,
phoneNumber: false,
emailLink: false,
oAuth: ['google'] as Provider[],
},
}
```
As you can see, the `providers` object can be configured to only display the
auth methods we want to use.
1. For example, by setting both `phoneNumber` and `emailLink` to `true`, the
authentication pages will display the `Email Link` authentication
and the `Phone Number` authentication forms.
2. Instead, by setting `emailPassword` to `false`, we will remove the
`email/password` form from the authentication and user profile pages.
## Requiring Email Verification
This setting needs to match what you have set up in Supabase. If you require email confirmation before your users can sign in, you will have to flip the following flag in your configuration:
```ts
auth: {
requireEmailConfirmation: false,
}
```
When the flag is set to `true`, the user will not be redirected to the onboarding flow, but will instead see a successful alert asking them to confirm their email. After confirmation, they will be able to sign in.
When the flag is set to `false`, the application will redirect them directly to the onboarding flow.
## Emails sent by Supabase
Supabase spins up an [InBucket](http://localhost:54324/) instance where all the emails are sent: this is where you can find emails related to password reset, sign-in links, and email verifications.
To access the InBucket instance, you can go to the following URL: [http://localhost:54324/](http://localhost:54324/). Save this URL, you will use it very often.

View File

@@ -1,30 +0,0 @@
---
title: Supabase Setup
label: Supabase Setup
description: How to setup authentication in MakerKit using Supabase.
---
Supabase needs a few settings to be configured in their Dashboard to work correctly. This guide will walk you through the steps to get your Supabase authentication setup.
## Authentication URLs
The first thing you need to do is to set the authentication URLs in the Supabase Dashboard. These URLs are used to redirect users to the correct page after they have logged in or signed up.
1. Go to the [Supabase Dashboard](https://app.supabase.io/).
2. Click on the project you want to use.
3. Go to the **Authentication** tab.
4. Click on **URL Configuration**.
5. Add your Site URL to the **Site URL** field. This is the URL of your MakerKit site (e.g. `https://my-site.com`).
6. Add your Redirect URLs to the **Redirect URLs** field. You need to add at least two URLs: This is the URL of your MakerKit site with `/auth/callback` appended to it (e.g. `https://my-site.com/auth/callback`) and another for redirecting users to their password reset page (e.g. `https://my-site.com/settings/profile/password`).
## Custom SMTP (optional)
If you want to send emails from your own domain, you can configure your SMTP settings in the Supabase Dashboard.
This is optional, but recommended if you want to send emails from your own domain.
1. Go to the [Supabase Dashboard](https://app.supabase.io/).
2. Click on the project you want to use.
3. Go to the **Project Settings** tab.
4. Click on **Auth**.
5. Tweak the `SMTP Settings` settings to your liking according to your provider's documentation.

View File

@@ -1,23 +0,0 @@
---
title: Authentication
label: Authentication
description: Learn everything about Authentication in Makerkit
---
MakerKit uses Supabase to manage authentication within your application.
By default, every kit comes with the following built-in authentication methods:
- **Email/Password** - we added, by default, the traditional way of signing in
- **Third Party Providers** - we also added by default Google Auth sign-in
- **Email Links**
- **Phone Number**
You're free to add (or remove) any of the methods supported by Supabase's
Authentication: we will see how.
This documentation will help you with the following:
- **Setup** - setting up your Supabase project
- **SSR** - use SSR to persist your users' authentication, adding new
providers
- **Customization** - an overview of how MakerKit works so that you can adapt
it to your own application's needs

View File

@@ -1,44 +0,0 @@
---
title: Lorem Ipsum
date: 2021-12-24
live: false
description: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
image: /assets/images/posts/lorem-ipsum.webp
---
## Fecerat avis invenio mentis
Lorem markdownum signis vidi quisquis saepe inani inter animam laceras nequiquam
castos cumque dum poste pede, **soliti et** eras. Cornua utendum. Ne dignus
opacae? Moles percussis, redimitus equi quercus haurit perque *aras*; humo!
Digessit Famemque membris vestrum [sua adveniens](http://erit.net/luctus.php)
deserta, et me cum cum dicuntur et ignes.
var xmp_duplex = boolean + alu_unmount_tween - newline;
var matrix_http_plain = facebook;
rom = risc_flops + market + 2 + reimage_c_mca;
## Parilesque duae meritis
Suum spes medio faciunt miserarum artisque. Honor amplectique crescunt saepius
cavis esse saepe laetabile modo. Fontis relinquit titulum est victa mundi!
## Orbem dare
More cingo concipit cumque armenta. Secuta quare profundi damni erigimur effugit
facta ipsa, videt videt. Conantem et **campos animos usquam** ut munus erat
audito, e!
> Talia ponit corpora Philemon. Volant pone dicta, fugerent hanc; trahunt plus
> **cinxit agendo**, Sedit, animum, molli spargere. Perterrita et bella Tiresia
> tanta auctor, colatur **nigro** externa. Stamina nunc bis *longeque* cornua.
## Cantat sequentes et illi opertos Cycno
Per inque Pallade cuspide errabat. Est dolor excitus ultorem avertere, numero et
Minos pater flamine, ictu tune Phylius.
Ditem est qui Scythicis erubuit suae, qui nunc tacito aequare auras. Suas erat
cubitoque esse, volventem quae, fera tinxi villo ita. Addit hic sine at orbe ut
sanesque intravit pudore nullisque Canens, ait aut. Parabat Pittheia Pan cibus
et perque inquit grave suae coniunx cura matris undaeque et stagni et.

View File

@@ -1,221 +0,0 @@
import { defineDocumentType, makeSource } from 'contentlayer/source-files';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
export const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `posts/*.mdx`,
contentType: 'mdx',
fields: {
title: {
type: 'string',
description: 'The title of the post',
required: true,
},
date: {
type: 'date',
description: 'The date of the post',
required: true,
},
live: {
type: 'boolean',
description: 'Whether the post is live or not',
required: true,
default: false,
},
image: {
type: 'string',
description: 'The path to the cover image',
},
description: {
type: 'string',
description: 'The description of the post',
},
},
computedFields: {
url: {
type: 'string',
resolve: (post) => `/blog/${getSlug(post._raw.sourceFileName)}`,
},
readingTime: {
type: 'number',
resolve: (post) => calculateReadingTime(post.body.raw),
},
slug: {
type: 'string',
resolve: (post) => getSlug(post._raw.sourceFileName),
},
structuredData: {
type: 'object',
resolve: (doc) => ({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: doc.title,
datePublished: doc.date,
dateModified: doc.date,
description: doc.description,
image: [siteUrl, doc.image].join(''),
url: [siteUrl, 'blog', doc._raw.flattenedPath].join('/'),
author: {
'@type': 'Organization',
name: `Makerkit`,
},
}),
},
},
}));
export const DocumentationPage = defineDocumentType(() => ({
name: 'DocumentationPage',
filePathPattern: `docs/**/*.mdx`,
contentType: 'mdx',
fields: {
title: {
type: 'string',
description: 'The title of the post',
required: true,
},
label: {
type: 'string',
description: 'The label of the page in the sidebar',
required: true,
},
cardCTA: {
type: 'string',
description: 'The label of the CTA link on the card',
required: false,
},
description: {
type: 'string',
description: 'The description of the post',
},
show_child_cards: {
type: 'boolean',
default: false,
},
collapsible: {
type: 'boolean',
required: false,
default: false,
},
collapsed: {
type: 'boolean',
required: false,
default: false,
},
},
computedFields: {
url: {
type: 'string',
resolve: (post) => `/blog/${getSlug(post._raw.sourceFileName)}`,
},
readingTime: {
type: 'number',
resolve: (post) => calculateReadingTime(post.body.raw),
},
slug: {
type: 'string',
resolve: (post) => getSlug(post._raw.sourceFileName),
},
structuredData: {
type: 'object',
resolve: (doc) => ({
'@context': 'https://schema.org',
'@type': 'LearningResource',
headline: doc.title,
datePublished: doc.date,
dateModified: doc.date,
description: doc.description,
image: [siteUrl, doc.image].join(''),
url: [siteUrl, 'blog', doc._raw.flattenedPath].join('/'),
author: {
'@type': 'Organization',
name: `Makerkit`,
},
}),
},
path: {
type: 'string',
resolve: (doc) => {
if (doc._id.startsWith('docs/index.md')) {
return '/docs';
}
return urlFromFilePath(doc);
},
},
pathSegments: {
type: 'json',
resolve: (doc) => getPathSegments(doc).map(getMetaFromFolderName),
},
resolvedPath: {
type: 'string',
resolve: (doc) => {
return getPathSegments(doc)
.map(getMetaFromFolderName)
.map(({ pathName }) => pathName)
.join('/');
},
},
},
}));
export default makeSource({
contentDirPath: './content',
documentTypes: [Post, DocumentationPage],
mdx: {
remarkPlugins: [],
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
properties: {
className: ['anchor'],
},
},
],
],
},
});
function calculateReadingTime(content) {
const wordsPerMinute = 235;
const numberOfWords = content.split(/\s/g).length;
const minutes = numberOfWords / wordsPerMinute;
return Math.ceil(minutes);
}
function getSlug(fileName) {
return fileName.replace('.mdx', '');
}
function urlFromFilePath(doc) {
let urlPath = doc._raw.flattenedPath.replace(/^app\/?/, '/');
if (!urlPath.startsWith('/')) {
urlPath = `/${urlPath}`;
}
return urlPath;
}
function getMetaFromFolderName(dirName) {
const re = /^((\d+)-)?(.*)$/;
const [, , orderStr, pathName] = dirName.match(re) ?? [];
const order = orderStr ? parseInt(orderStr) : 0;
return { order, pathName };
}
function getPathSegments(doc) {
return (
urlFromFilePath(doc)
.split('/')
// skip `/docs` prefix
.slice(2)
);
}

View File

@@ -16,7 +16,8 @@ const INTERNAL_PACKAGES = [
'@kit/billing-gateway',
'@kit/stripe',
'@kit/email-templates',
'@kit/database-webhooks'
'@kit/database-webhooks',
'@kit/cms'
];
/** @type {import('next').NextConfig} */

View File

@@ -30,6 +30,7 @@
"@kit/supabase": "workspace:^",
"@kit/team-accounts": "workspace:^",
"@kit/ui": "workspace:^",
"@kit/cms": "workspace:^",
"@next/mdx": "^14.1.4",
"@radix-ui/react-icons": "^1.3.0",
"@supabase/ssr": "^0.1.0",
@@ -37,13 +38,11 @@
"@tanstack/react-query": "5.28.6",
"@tanstack/react-query-next-experimental": "^5.28.9",
"@tanstack/react-table": "^8.15.0",
"contentlayer": "0.3.4",
"date-fns": "^3.6.0",
"edge-csrf": "^1.0.9",
"i18next": "^23.10.1",
"i18next-resources-to-backend": "^1.2.0",
"next": "v14.2.0-canary.49",
"next-contentlayer": "0.3.4",
"next": "v14.2.0-canary.50",
"next-sitemap": "^4.2.3",
"next-themes": "0.3.0",
"react": "18.2.0",
@@ -51,8 +50,6 @@
"react-hook-form": "^7.51.2",
"react-i18next": "^14.1.0",
"recharts": "^2.12.3",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"sonner": "^1.4.41",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4"

View File

@@ -6,8 +6,7 @@
"~/*": ["./app/*"],
"~/config/*": ["./config/*"],
"~/components/*": ["./components/*"],
"~/lib/*": ["./lib/*"],
"contentlayer/generated": ["./.contentlayer/generated"]
"~/lib/*": ["./lib/*"]
},
"plugins": [
{
@@ -22,7 +21,7 @@
"*.ts",
"*.tsx",
"*.mjs",
"config/**/*.ts",
"./config/**/*.ts",
"components/**/*.{tsx|ts}",
"lib/**/*.ts",
"app"