Compare commits
13 Commits
f604af5d4c
...
renovate/e
Author | SHA1 | Date | |
---|---|---|---|
|
261831c851 | ||
5acb6a4f81 | |||
|
7b7a3cf2a0 | ||
ee25889139 | |||
7cf1b4d16e | |||
e1b7beadbd | |||
e5a797718f | |||
b23c48246d | |||
573f13568d | |||
48973560d1 | |||
d022209305 | |||
e10d447873 | |||
|
e38d6a42de |
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"siteName": "Pulse App",
|
"siteName": "Pulse App",
|
||||||
|
"ogApiUrl": "https://docs.pulseapp.cc/api/og?title={title}",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"title": {
|
"title": {
|
||||||
"default": "Pulse Docs",
|
"default": "Pulse Docs",
|
||||||
|
@ -20,11 +20,12 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
|
"@vercel/og": "^0.6.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "1.0.0",
|
"cmdk": "1.0.0",
|
||||||
"framer-motion": "^11.11.1",
|
"framer-motion": "^11.11.1",
|
||||||
"lucide-react": "^0.451.0",
|
"lucide-react": "^0.452.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "^15.0.0-canary.179",
|
"next": "^15.0.0-canary.179",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
@ -42,7 +43,7 @@
|
|||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.15",
|
"eslint-config-next": "15.1.3",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
@ -14,6 +14,7 @@ import { Metadata } from "next";
|
|||||||
import Embed from "@/components/embed";
|
import Embed from "@/components/embed";
|
||||||
import DocsFooter from "@/components/docs-footer";
|
import DocsFooter from "@/components/docs-footer";
|
||||||
import OnThisPage from "@/components/on-this-page";
|
import OnThisPage from "@/components/on-this-page";
|
||||||
|
import config from "@/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page to render the documentation markdown content.
|
* The page to render the documentation markdown content.
|
||||||
@ -34,7 +35,7 @@ const DocsPage = async ({
|
|||||||
const decodedSlug: string = decodeURIComponent(slug || "");
|
const decodedSlug: string = decodeURIComponent(slug || "");
|
||||||
const page: DocsContentMetadata | undefined = pages.find(
|
const page: DocsContentMetadata | undefined = pages.find(
|
||||||
(metadata: DocsContentMetadata): boolean =>
|
(metadata: DocsContentMetadata): boolean =>
|
||||||
metadata.slug === (decodedSlug || "intro")
|
metadata.slug === (decodedSlug || pages[0].slug)
|
||||||
);
|
);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
notFound();
|
notFound();
|
||||||
@ -99,17 +100,19 @@ export const generateMetadata = async ({
|
|||||||
}): Promise<Metadata | undefined> => {
|
}): Promise<Metadata | undefined> => {
|
||||||
const slug: string = (((await params).slug as string[]) || undefined)?.join(
|
const slug: string = (((await params).slug as string[]) || undefined)?.join(
|
||||||
"/"
|
"/"
|
||||||
); // The slug of the content
|
);
|
||||||
if (slug) {
|
if (slug) {
|
||||||
const content: DocsContentMetadata | undefined = (
|
const pages: DocsContentMetadata[] = await getDocsContent();
|
||||||
await getDocsContent()
|
const decodedSlug: string = decodeURIComponent(slug || "");
|
||||||
).find(
|
const page: DocsContentMetadata | undefined = pages.find(
|
||||||
(metadata: DocsContentMetadata): boolean => metadata.slug === slug
|
(metadata: DocsContentMetadata): boolean =>
|
||||||
); // Get the content based on the provided slug
|
metadata.slug === (decodedSlug || pages[0].slug)
|
||||||
if (content) {
|
);
|
||||||
|
if (page) {
|
||||||
return Embed({
|
return Embed({
|
||||||
title: content.title,
|
title: page.title,
|
||||||
description: content.summary,
|
description: page.summary,
|
||||||
|
thumbnail: config.ogApiUrl.replace("{title}", page.title),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
30
src/app/api/og/route.tsx
Normal file
30
src/app/api/og/route.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import config from "@/config";
|
||||||
|
|
||||||
|
export const GET = async (request: Request) => {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const title: string | undefined = searchParams.has("title")
|
||||||
|
? searchParams.get("title")?.slice(0, 100)
|
||||||
|
: "Hello World (:";
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div tw="w-full h-full flex flex-col justify-center items-center bg-black/95 text-white">
|
||||||
|
{/* Logo */}
|
||||||
|
<img
|
||||||
|
src={(config.metadata.openGraph?.images as any)[0].url}
|
||||||
|
alt={`${config.siteName} Logo`}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 tw="text-5xl font-bold">{title}</h1>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
@ -6,6 +6,11 @@ export type Config = {
|
|||||||
*/
|
*/
|
||||||
siteName: string;
|
siteName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL to use for generating OG images.
|
||||||
|
*/
|
||||||
|
ogApiUrl: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The metadata for this app.
|
* The metadata for this app.
|
||||||
*/
|
*/
|
||||||
|
@ -17,7 +17,8 @@ const DocsFooter = ({
|
|||||||
|
|
||||||
const current: number = pages.findIndex(
|
const current: number = pages.findIndex(
|
||||||
(page: DocsContentMetadata) =>
|
(page: DocsContentMetadata) =>
|
||||||
(path === "/" && page.slug === "intro") || path === `/${page.slug}`
|
(path === "/" && page.slug === pages[0].slug) ||
|
||||||
|
path === `/${page.slug}`
|
||||||
);
|
);
|
||||||
const previous: DocsContentMetadata | undefined =
|
const previous: DocsContentMetadata | undefined =
|
||||||
current > 0 ? pages[current - 1] : undefined;
|
current > 0 ? pages[current - 1] : undefined;
|
||||||
|
@ -13,6 +13,11 @@ type EmbedProps = {
|
|||||||
* The description of the embed.
|
* The description of the embed.
|
||||||
*/
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The optional thumbnail image of the embed.
|
||||||
|
*/
|
||||||
|
thumbnail?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -21,13 +26,25 @@ type EmbedProps = {
|
|||||||
* @param props the embed props
|
* @param props the embed props
|
||||||
* @returns the embed jsx
|
* @returns the embed jsx
|
||||||
*/
|
*/
|
||||||
const Embed = ({ title, description }: EmbedProps): Metadata => {
|
const Embed = ({ title, description, thumbnail }: EmbedProps): Metadata => {
|
||||||
return {
|
return {
|
||||||
title: title,
|
title: title,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${title}`,
|
title: `${title}`,
|
||||||
description: description,
|
description: description,
|
||||||
|
...(thumbnail && {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: thumbnail,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
...(thumbnail && {
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export default Embed;
|
export default Embed;
|
||||||
|
@ -18,6 +18,7 @@ type Header = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OnThisPage = ({ page }: { page: DocsContentMetadata }): ReactElement => {
|
const OnThisPage = ({ page }: { page: DocsContentMetadata }): ReactElement => {
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [headers, setHeaders] = useState<Header[]>([]);
|
const [headers, setHeaders] = useState<Header[]>([]);
|
||||||
const [activeHeader, setActiveHeader] = useState<string | undefined>(
|
const [activeHeader, setActiveHeader] = useState<string | undefined>(
|
||||||
undefined
|
undefined
|
||||||
@ -45,6 +46,7 @@ const OnThisPage = ({ page }: { page: DocsContentMetadata }): ReactElement => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setHeaders(extractedHeaders);
|
setHeaders(extractedHeaders);
|
||||||
|
setLoading(false);
|
||||||
}, [page.content]);
|
}, [page.content]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -94,8 +96,10 @@ const OnThisPage = ({ page }: { page: DocsContentMetadata }): ReactElement => {
|
|||||||
|
|
||||||
{/* Headers */}
|
{/* Headers */}
|
||||||
<ul className="relative">
|
<ul className="relative">
|
||||||
{headers.length === 0 ? (
|
{loading ? (
|
||||||
<Skeleton className="w-full h-5 bg-accent rounded-lg" />
|
<Skeleton className="w-full h-5 bg-accent rounded-lg" />
|
||||||
|
) : headers.length === 0 ? (
|
||||||
|
<span className="opacity-75">Nothing ):</span>
|
||||||
) : (
|
) : (
|
||||||
headers.map((header: Header) => (
|
headers.map((header: Header) => (
|
||||||
<li
|
<li
|
||||||
|
@ -22,7 +22,7 @@ const SidebarLinks = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{Object.values(tree).map((node: TreeNode) => (
|
{Object.values(tree).map((node: TreeNode) => (
|
||||||
<CategoryItem key={node.slug} node={node} />
|
<CategoryItem key={node.slug} pages={pages} node={node} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -36,17 +36,20 @@ type TreeNode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CategoryItem = ({
|
const CategoryItem = ({
|
||||||
|
pages,
|
||||||
node,
|
node,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
isLast = true,
|
isLast = true,
|
||||||
}: {
|
}: {
|
||||||
|
pages: DocsContentMetadata[];
|
||||||
node: TreeNode;
|
node: TreeNode;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const path = decodeURIComponent(usePathname());
|
const path = decodeURIComponent(usePathname());
|
||||||
const active =
|
const active =
|
||||||
(path === "/" && node.slug === "intro") || path === `/${node.slug}`;
|
(path === "/" && node.slug === pages[0].slug) ||
|
||||||
|
path === `/${node.slug}`;
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
const hasChildren = Object.keys(node.children).length > 0;
|
const hasChildren = Object.keys(node.children).length > 0;
|
||||||
|
|
||||||
@ -121,6 +124,7 @@ const CategoryItem = ({
|
|||||||
(child, index, array) => (
|
(child, index, array) => (
|
||||||
<CategoryItem
|
<CategoryItem
|
||||||
key={child.slug}
|
key={child.slug}
|
||||||
|
pages={pages}
|
||||||
node={child}
|
node={child}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
isLast={index === array.length - 1}
|
isLast={index === array.length - 1}
|
||||||
|
@ -4,6 +4,8 @@ import path from "node:path";
|
|||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import simpleGit from "simple-git";
|
import simpleGit from "simple-git";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import { cache } from "react";
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regex to match for metadata.
|
* The regex to match for metadata.
|
||||||
@ -24,26 +26,64 @@ const DOCS_DIR: string = isGitUrl(config.contentSource)
|
|||||||
? config.contentSource
|
? config.contentSource
|
||||||
: path.join(config.contentSource.replace("{process}", process.cwd()));
|
: path.join(config.contentSource.replace("{process}", process.cwd()));
|
||||||
|
|
||||||
|
const LAST_UPDATE_FILE = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
"docs_cache",
|
||||||
|
"last_update.json"
|
||||||
|
);
|
||||||
|
const CACHE_DURATION_MS: number = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone the Git repository if DOCS_DIR is a URL, else use the local directory.
|
* Clone the Git repository if DOCS_DIR is a URL, else use the local directory.
|
||||||
* If it's a Git URL, clone it to a cache directory and reuse it.
|
* If it's a Git URL, clone it to a cache directory and reuse it.
|
||||||
*/
|
*/
|
||||||
const getDocsDirectory = async (): Promise<string> => {
|
const getDocsDirectory = cache(async (): Promise<string> => {
|
||||||
if (isGitUrl(DOCS_DIR)) {
|
if (isGitUrl(DOCS_DIR)) {
|
||||||
const repoHash: string = Buffer.from(DOCS_DIR).toString("base64"); // Create a unique identifier based on the repo URL
|
const repoHash: string = Buffer.from(DOCS_DIR).toString("base64"); // Create a unique identifier based on the repo URL
|
||||||
const cacheDir: string = path.join(os.tmpdir(), "docs_cache", repoHash);
|
const cacheDir: string = path.join(os.tmpdir(), "docs_cache", repoHash);
|
||||||
|
|
||||||
// Pull the latest changes from the repo if we don't have it
|
// Pull the latest changes from the repo if we don't have it
|
||||||
if (!fs.existsSync(cacheDir) || fs.readdirSync(cacheDir).length < 1) {
|
if (!fs.existsSync(cacheDir) || fs.readdirSync(cacheDir).length < 1) {
|
||||||
|
console.log("Cloning initial docs content from Git...");
|
||||||
try {
|
try {
|
||||||
await simpleGit().clone(DOCS_DIR, cacheDir, { "--depth": 1 });
|
await simpleGit().clone(DOCS_DIR, cacheDir, { "--depth": 1 });
|
||||||
} catch (error) {}
|
storeUpdatedRepoTime();
|
||||||
|
} catch (error) {
|
||||||
|
// Simply ignore this error. When cloning the repo for
|
||||||
|
// the first time, it'll sometimes error saying the dir
|
||||||
|
// is already created.
|
||||||
|
}
|
||||||
|
} else if (shouldUpdateRepo()) {
|
||||||
|
// Pull the latest changes from Git
|
||||||
|
console.log("Pulling docs content from Git...");
|
||||||
|
await simpleGit(cacheDir)
|
||||||
|
.reset(["--hard"]) // Reset any local changes
|
||||||
|
.pull(); // Pull latest changes
|
||||||
|
storeUpdatedRepoTime();
|
||||||
}
|
}
|
||||||
return cacheDir;
|
return cacheDir;
|
||||||
}
|
}
|
||||||
return DOCS_DIR;
|
return DOCS_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldUpdateRepo = (): boolean => {
|
||||||
|
if (!fs.existsSync(LAST_UPDATE_FILE)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
Date.now() -
|
||||||
|
JSON.parse(fs.readFileSync(LAST_UPDATE_FILE, "utf-8")).lastUpdate >
|
||||||
|
CACHE_DURATION_MS
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const storeUpdatedRepoTime = () =>
|
||||||
|
fs.writeFileSync(
|
||||||
|
LAST_UPDATE_FILE,
|
||||||
|
JSON.stringify({ lastUpdate: Date.now() }),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the content to display in the docs.
|
* Get the content to display in the docs.
|
||||||
*/
|
*/
|
||||||
|
Reference in New Issue
Block a user