diff --git a/app/(static)/help/article/[slug]/page.tsx b/app/(static)/help/article/[slug]/page.tsx
new file mode 100644
index 000000000..8d3829d82
--- /dev/null
+++ b/app/(static)/help/article/[slug]/page.tsx
@@ -0,0 +1,128 @@
+import { getHelpArticles, getHelpArticle } from "@/lib/content/help";
+import { ContentBody } from "@/components/mdx/post-body";
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import BlurImage from "@/components/blur-image";
+import { constructMetadata, formatDate } from "@/lib/utils";
+import { Metadata } from "next";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import TableOfContents from "@/components/mdx/table-of-contents";
+
+export async function generateStaticParams() {
+ const articles = await getHelpArticles();
+ return articles.map((article) => ({ slug: article?.data.slug }));
+}
+
+export const generateMetadata = async ({
+ params,
+}: {
+ params: {
+ slug: string;
+ };
+}): Promise => {
+ const article = (await getHelpArticles()).find(
+ (article) => article?.data.slug === params.slug,
+ );
+ const { title, summary: description, image } = article?.data || {};
+
+ return constructMetadata({
+ title: `${title} - Papermark`,
+ description,
+ image,
+ });
+};
+
+export default async function BlogPage({
+ params,
+}: {
+ params: { slug: string };
+}) {
+ const article = await getHelpArticle(params.slug);
+ if (!article) return notFound();
+
+ // const category = article.data.categories ? article.data.categories[0] : "";
+
+ return (
+ <>
+
+
+
+
+
+
+ Help Center
+
+ {/*
+
+
+ {category}
+
+ */}
+
+
+ {article.data.title}
+
+
+
+
+
+ {article.data.title}
+
+
{article.data.summary}
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/(static)/solutions/[slug]/page.tsx b/app/(static)/solutions/[slug]/page.tsx
index 782537ee8..26ea0ec43 100644
--- a/app/(static)/solutions/[slug]/page.tsx
+++ b/app/(static)/solutions/[slug]/page.tsx
@@ -59,7 +59,7 @@ export default async function PagePage({
-
-
+
{page.button}
@@ -171,7 +171,7 @@ export default async function PagePage({
-
+
{page.button}
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 513924784..4b226040b 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -1,10 +1,16 @@
-import { getPosts, getAlternatives, getPages } from "@/lib/content";
+import {
+ getPosts,
+ getAlternatives,
+ getPages,
+ getHelpArticles,
+} from "@/lib/content";
import { MetadataRoute } from "next";
export default async function sitemap(): Promise {
const posts = await getPosts();
const solutions = await getPages();
const alternatives = await getAlternatives();
+ const helpArticles = await getHelpArticles();
const blogLinks = posts.map((post) => ({
url: `https://www.papermark.io/blog/${post?.data.slug}`,
lastModified: new Date().toISOString().split("T")[0],
@@ -17,6 +23,10 @@ export default async function sitemap(): Promise {
url: `https://www.papermark.io/alternatives/${alternative?.slug}`,
lastModified: new Date().toISOString().split("T")[0],
}));
+ const helpArticleLinks = helpArticles.map((article) => ({
+ url: `https://www.papermark.io/help/article/${article?.data.slug}`,
+ lastModified: new Date().toISOString().split("T")[0],
+ }));
return [
{
@@ -54,5 +64,6 @@ export default async function sitemap(): Promise {
...blogLinks,
...solutionLinks,
...alternativeLinks,
+ ...helpArticleLinks,
];
}
diff --git a/components/mdx/components/index.tsx b/components/mdx/components/index.tsx
index ff08aac6f..fa3e8b5a3 100644
--- a/components/mdx/components/index.tsx
+++ b/components/mdx/components/index.tsx
@@ -63,5 +63,23 @@ export const mdxComponents: MDXComponents = {
);
},
+ h2: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ h3: ({ children, ...props }) => (
+
+ {children}
+
+ ),
// any other components you want to use in your markdown
};
diff --git a/components/mdx/table-of-contents.tsx b/components/mdx/table-of-contents.tsx
new file mode 100644
index 000000000..68a61743f
--- /dev/null
+++ b/components/mdx/table-of-contents.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+// import useCurrentAnchor from "#/lib/hooks/use-current-anchor";
+import { cn } from "@/lib/utils";
+import slugify from "@sindresorhus/slugify";
+import Link from "next/link";
+
+export default function TableOfContents({
+ items,
+}: {
+ items: {
+ text: string;
+ level: number;
+ }[];
+}) {
+ const currentAnchor = useCurrentAnchor();
+
+ return (
+
+ {items &&
+ items.map((item, idx) => {
+ const itemId = slugify(item.text);
+ return (
+
+ {item.text}
+
+ );
+ })}
+
+ );
+}
+
+import { useEffect, useState } from "react";
+
+function useCurrentAnchor() {
+ const [currentAnchor, setCurrentAnchor] = useState(null);
+
+ useEffect(() => {
+ const mdxContainer: HTMLElement | null = document.querySelector(
+ "[data-mdx-container]",
+ );
+
+ if (!mdxContainer) return;
+
+ const offsetTop = 200;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ let currentEntry = entries[0];
+ if (!currentEntry) return;
+
+ const offsetBottom =
+ (currentEntry.rootBounds?.height || 0) * 0.3 + offsetTop;
+
+ for (let i = 1; i < entries.length; i++) {
+ const entry = entries[i];
+ if (!entry) break;
+
+ if (
+ entry.boundingClientRect.top <
+ currentEntry.boundingClientRect.top ||
+ currentEntry.boundingClientRect.bottom < offsetTop
+ ) {
+ currentEntry = entry;
+ }
+ }
+
+ let target: Element | undefined = currentEntry.target;
+
+ // if the target is too high up, we need to find the next sibling
+ while (target && target.getBoundingClientRect().bottom < offsetTop) {
+ target = siblings.get(target)?.next;
+ }
+
+ // if the target is too low, we need to find the previous sibling
+ while (target && target.getBoundingClientRect().top > offsetBottom) {
+ target = siblings.get(target)?.prev;
+ }
+ if (target) setCurrentAnchor(target.id);
+ },
+ {
+ threshold: 1,
+ rootMargin: `-${offsetTop}px 0px 0px 0px`,
+ },
+ );
+
+ const siblings = new Map();
+
+ const anchors = mdxContainer?.querySelectorAll("[data-mdx-heading]");
+ anchors.forEach((anchor) => observer.observe(anchor));
+
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
+ return currentAnchor?.replace("#", "");
+}
diff --git a/lib/content/alternative.ts b/lib/content/alternative.ts
index ebf14a535..369232d36 100644
--- a/lib/content/alternative.ts
+++ b/lib/content/alternative.ts
@@ -28,6 +28,10 @@ type Alternative = {
// this means getPosts() will only be called once per page build, even though we may call it multiple times
// when rendering the page.
export const getAlternatives = async () => {
+ if (!process.env.CONTENT_BASE_URL) {
+ return [];
+ }
+
const response = await fetch(
`${process.env.CONTENT_BASE_URL}/api/alternatives`,
{
diff --git a/lib/content/blog.ts b/lib/content/blog.ts
index da20f4f0a..21f66586d 100644
--- a/lib/content/blog.ts
+++ b/lib/content/blog.ts
@@ -18,6 +18,10 @@ const GITHUB_CONTENT_TOKEN = process.env.GITHUB_CONTENT_TOKEN;
const GITHUB_CONTENT_REPO = process.env.GITHUB_CONTENT_REPO;
export const getPostsRemote = cache(async () => {
+ if (!GITHUB_CONTENT_REPO || !GITHUB_CONTENT_TOKEN) {
+ return [];
+ }
+
const apiUrl = `https://api.github.com/repos/${GITHUB_CONTENT_REPO}/contents/content/blog`;
const headers = {
Authorization: `Bearer ${GITHUB_CONTENT_TOKEN}`,
diff --git a/lib/content/help.ts b/lib/content/help.ts
new file mode 100644
index 000000000..30500ac0d
--- /dev/null
+++ b/lib/content/help.ts
@@ -0,0 +1,87 @@
+import matter from "gray-matter";
+import { cache } from "react";
+
+type Article = {
+ data: {
+ title: string;
+ summary: string;
+ publishedAt: string;
+ author: string;
+ image: string;
+ slug: string;
+ published: boolean;
+ categories?: string[];
+ };
+ body: string;
+ toc: { text: string; level: number }[];
+};
+
+const GITHUB_CONTENT_TOKEN = process.env.GITHUB_CONTENT_TOKEN;
+const GITHUB_CONTENT_REPO = process.env.GITHUB_CONTENT_REPO;
+
+export const getHelpArticles = cache(async () => {
+ if (!GITHUB_CONTENT_REPO || !GITHUB_CONTENT_TOKEN) {
+ return [];
+ }
+
+ const apiUrl = `https://api.github.com/repos/${GITHUB_CONTENT_REPO}/contents/content/help`;
+ const headers = {
+ Authorization: `Bearer ${GITHUB_CONTENT_TOKEN}`,
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ };
+
+ const response = await fetch(apiUrl, { headers });
+ const data = await response.json();
+
+ const articles = await Promise.all(
+ data
+ .filter((file: any) => file.name.endsWith(".mdx"))
+ .map(async (file: any) => {
+ const contentResponse = await fetch(file.url, { headers });
+ const fileDetails = await contentResponse.json();
+ const content = fileDetails.content; // Getting the base64 encoded content
+ const decodedContent = Buffer.from(content, "base64").toString("utf8"); // Decoding the base64 content
+ const { data, content: fileContent } = matter(decodedContent);
+
+ if (data.published === false) {
+ return null;
+ }
+
+ const headingItems = await getHeadings(fileContent);
+
+ return { data, body: fileContent, toc: headingItems } as Article;
+ }),
+ );
+
+ const filteredArticles = articles.filter(
+ (article: Article) => article !== null,
+ ) as Article[];
+ return filteredArticles;
+});
+
+export async function getHelpArticle(slug: string) {
+ const articles = await getHelpArticles();
+ return articles.find((article) => article?.data.slug === slug);
+}
+
+async function getHeadings(source: string) {
+ // Get each line individually, and filter out anything that
+ // isn't a heading.
+ const headingLines = source.split("\n").filter((line) => {
+ return line.match(/^###*\s/);
+ });
+
+ // Transform the string '## Some text' into an object
+ // with the shape '{ text: 'Some text', level: 2 }'
+ return headingLines.map((raw) => {
+ const text = raw.replace(/^###*\s/, "");
+ // I only care about h2.
+ // If I wanted more levels, I'd need to count the
+ // number of #s.
+ // match only h2
+ const level = raw.slice(0, 3) === "###" ? 3 : 2;
+
+ return { text, level };
+ });
+}
diff --git a/lib/content/index.ts b/lib/content/index.ts
index 9d68c6cc8..0dde17fb7 100644
--- a/lib/content/index.ts
+++ b/lib/content/index.ts
@@ -1,3 +1,4 @@
export { getAlternatives } from "./alternative";
export { getPostsRemote as getPosts } from "./blog";
export { getPages } from "./page";
+export { getHelpArticles } from "./help";
diff --git a/lib/content/investor.ts b/lib/content/investor.ts
index 9c5235474..b0731067c 100644
--- a/lib/content/investor.ts
+++ b/lib/content/investor.ts
@@ -16,6 +16,10 @@ type Investor = {
// this means getPosts() will only be called once per page build, even though we may call it multiple times
// when rendering the page.
export const getInvestors = cache(async () => {
+ if (!process.env.CONTENT_BASE_URL) {
+ return [];
+ }
+
const response = await fetch(
`${process.env.CONTENT_BASE_URL}/api/investors`,
{
diff --git a/lib/content/page.ts b/lib/content/page.ts
index ffee93c45..be57b38c3 100644
--- a/lib/content/page.ts
+++ b/lib/content/page.ts
@@ -22,6 +22,10 @@ type Page = {
// this means getPosts() will only be called once per page build, even though we may call it multiple times
// when rendering the page.
export const getPages = async () => {
+ if (!process.env.CONTENT_BASE_URL) {
+ return [];
+ }
+
const response = await fetch(`${process.env.CONTENT_BASE_URL}/api/pages`, {
method: "GET",
headers: {
diff --git a/lib/utils.ts b/lib/utils.ts
index e954cc513..c7c8ed74e 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -53,7 +53,10 @@ export const log = async ({
mention?: boolean;
}) => {
/* If in development or env variable not set, log to the console */
- if (process.env.NODE_ENV === "development" || !process.env.PPMK_SLACK_WEBHOOK_URL) {
+ if (
+ process.env.NODE_ENV === "development" ||
+ !process.env.PPMK_SLACK_WEBHOOK_URL
+ ) {
console.log(message);
return;
}
@@ -232,11 +235,15 @@ export const getFirstAndLastDay = (day: number) => {
}
};
-export const formatDate = (dateString: string) => {
+export const formatDate = (dateString: string, updateDate?: boolean) => {
return new Date(dateString).toLocaleDateString("en-US", {
day: "numeric",
month: "long",
- year: "numeric",
+ year:
+ updateDate &&
+ new Date(dateString).getFullYear() === new Date().getFullYear()
+ ? undefined
+ : "numeric",
timeZone: "UTC",
});
};
@@ -419,12 +426,12 @@ export const generateGravatarHash = (email: string | null): string => {
};
export const sanitizeAllowDenyList = (list: string): string[] => {
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- const domainRegex = /^@[^\s@]+\.[^\s@]+$/;
-
- return list
- .split("\n")
- .map(item => item.trim().replace(/,$/, '')) // Trim whitespace and remove trailing commas
- .filter(item => item !== "") // Remove empty items
- .filter(item => emailRegex.test(item) || domainRegex.test(item)); // Remove items that don't match email or domain regex
- };
\ No newline at end of file
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ const domainRegex = /^@[^\s@]+\.[^\s@]+$/;
+
+ return list
+ .split("\n")
+ .map((item) => item.trim().replace(/,$/, "")) // Trim whitespace and remove trailing commas
+ .filter((item) => item !== "") // Remove empty items
+ .filter((item) => emailRegex.test(item) || domainRegex.test(item)); // Remove items that don't match email or domain regex
+};
diff --git a/pages/data-room.tsx b/pages/data-room.tsx
index 7e9c58e71..555266ff3 100644
--- a/pages/data-room.tsx
+++ b/pages/data-room.tsx
@@ -113,10 +113,7 @@ export default function Home() {
-
+
Create Data Room
diff --git a/pages/view/d/[linkId]/index.tsx b/pages/view/d/[linkId]/index.tsx
index 5f248cfb0..6f161119d 100644
--- a/pages/view/d/[linkId]/index.tsx
+++ b/pages/view/d/[linkId]/index.tsx
@@ -135,7 +135,7 @@ export default function ViewPage({
{meta && meta.enableCustomMetatag ? (
@@ -184,7 +184,7 @@ export default function ViewPage({
{enableCustomMetatag ? (
@@ -213,7 +213,7 @@ export default function ViewPage({
{enableCustomMetatag ? (