From 07c31217a7c102e418f942fc7dfdcc2781d05ce9 Mon Sep 17 00:00:00 2001 From: Marc Seitz Date: Thu, 18 Apr 2024 15:20:47 +1000 Subject: [PATCH 1/8] feat: add help center articles --- app/(static)/help/[slug]/page.tsx | 114 ++++++++++++++++++++++++++++++ lib/content/help.ts | 56 +++++++++++++++ lib/content/index.ts | 1 + 3 files changed, 171 insertions(+) create mode 100644 app/(static)/help/[slug]/page.tsx create mode 100644 lib/content/help.ts diff --git a/app/(static)/help/[slug]/page.tsx b/app/(static)/help/[slug]/page.tsx new file mode 100644 index 000000000..5b79efbb4 --- /dev/null +++ b/app/(static)/help/[slug]/page.tsx @@ -0,0 +1,114 @@ +import { getHelpPosts, getHelpPost } 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"; + +export async function generateStaticParams() { + const posts = await getHelpPosts(); + return posts.map((post) => ({ slug: post?.data.slug })); +} + +export const generateMetadata = async ({ + params, +}: { + params: { + slug: string; + }; +}): Promise => { + const post = (await getHelpPosts()).find( + (post) => post?.data.slug === params.slug, + ); + const { title, summary: description, image } = post?.data || {}; + + return constructMetadata({ + title: `${title} - Papermark`, + description, + image, + }); +}; + +export default async function BlogPage({ + params, +}: { + params: { slug: string }; +}) { + const post = await getHelpPost(params.slug); + if (!post) return notFound(); + + return ( + <> +
+
+
+ +
+

+ {post.data.title} +

+

{post.data.summary}

+ +
+ + +
+

Marc Seitz

+

@mfts0

+
+ +
+
+
+ +
+
+
+
+ {post.body} +
+
+ +
+
+

Written by

+ {/* */} + + +
+

Marc Seitz

+

@mfts0

+
+ +
+
+
+
+ + ); +} diff --git a/lib/content/help.ts b/lib/content/help.ts new file mode 100644 index 000000000..664e95386 --- /dev/null +++ b/lib/content/help.ts @@ -0,0 +1,56 @@ +import matter from "gray-matter"; +import { cache } from "react"; + +type Post = { + data: { + title: string; + summary: string; + publishedAt: string; + author: string; + image: string; + slug: string; + published: boolean; + }; + body: string; +}; + +const GITHUB_CONTENT_TOKEN = process.env.GITHUB_CONTENT_TOKEN; +const GITHUB_CONTENT_REPO = process.env.GITHUB_CONTENT_REPO; + +export const getHelpPosts = cache(async () => { + 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 posts = 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; + } + + return { data, body: fileContent } as Post; + }), + ); + + const filteredPosts = posts.filter((post: Post) => post !== null) as Post[]; + return filteredPosts; +}); + +export async function getHelpPost(slug: string) { + const posts = await getHelpPosts(); + return posts.find((post) => post?.data.slug === slug); +} diff --git a/lib/content/index.ts b/lib/content/index.ts index 9d68c6cc8..1fc29a002 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 { getHelpPosts } from "./help"; From 1510668bf0eb1a1f50a4281067bc89421e2481e8 Mon Sep 17 00:00:00 2001 From: Marc Seitz Date: Thu, 18 Apr 2024 15:21:11 +1000 Subject: [PATCH 2/8] fix: escape if no CONTENT_BASE_URL env present --- lib/content/alternative.ts | 4 ++++ lib/content/blog.ts | 4 ++++ lib/content/investor.ts | 4 ++++ lib/content/page.ts | 4 ++++ 4 files changed, 16 insertions(+) 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/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: { From 602e90028615c47f3c532ce406f5a1ba0d59345e Mon Sep 17 00:00:00 2001 From: Marc Seitz Date: Fri, 19 Apr 2024 11:49:14 +0800 Subject: [PATCH 3/8] fix: duplicate classnames --- app/(static)/solutions/[slug]/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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({

- @@ -99,7 +99,7 @@ export default async function PagePage({
- @@ -171,7 +171,7 @@ export default async function PagePage({
- From 184a2517e5781a3742783c2e5a5fe499b64988fb Mon Sep 17 00:00:00 2001 From: Marc Seitz Date: Wed, 24 Apr 2024 14:19:22 +0800 Subject: [PATCH 4/8] fix: metatag url for datarooms --- pages/view/d/[linkId]/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 ? ( From 1ebeebd9ed64ef6e6ccb27b8b9947185049ed183 Mon Sep 17 00:00:00 2001 From: Marc Seitz Date: Wed, 24 Apr 2024 23:39:28 +0800 Subject: [PATCH 5/8] feat: add help article page and table of contents --- .../help/{ => article}/[slug]/page.tsx | 90 ++++++++------- components/mdx/components/index.tsx | 18 +++ components/mdx/table-of-contents.tsx | 105 ++++++++++++++++++ lib/content/help.ts | 45 ++++++-- lib/utils.ts | 31 ++++-- 5 files changed, 230 insertions(+), 59 deletions(-) rename app/(static)/help/{ => article}/[slug]/page.tsx (51%) create mode 100644 components/mdx/table-of-contents.tsx diff --git a/app/(static)/help/[slug]/page.tsx b/app/(static)/help/article/[slug]/page.tsx similarity index 51% rename from app/(static)/help/[slug]/page.tsx rename to app/(static)/help/article/[slug]/page.tsx index 5b79efbb4..8d3829d82 100644 --- a/app/(static)/help/[slug]/page.tsx +++ b/app/(static)/help/article/[slug]/page.tsx @@ -1,14 +1,23 @@ -import { getHelpPosts, getHelpPost } from "@/lib/content/help"; +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 posts = await getHelpPosts(); - return posts.map((post) => ({ slug: post?.data.slug })); + const articles = await getHelpArticles(); + return articles.map((article) => ({ slug: article?.data.slug })); } export const generateMetadata = async ({ @@ -18,10 +27,10 @@ export const generateMetadata = async ({ slug: string; }; }): Promise => { - const post = (await getHelpPosts()).find( - (post) => post?.data.slug === params.slug, + const article = (await getHelpArticles()).find( + (article) => article?.data.slug === params.slug, ); - const { title, summary: description, image } = post?.data || {}; + const { title, summary: description, image } = article?.data || {}; return constructMetadata({ title: `${title} - Papermark`, @@ -35,24 +44,40 @@ export default async function BlogPage({ }: { params: { slug: string }; }) { - const post = await getHelpPost(params.slug); - if (!post) return notFound(); + 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} + + +
-

- {post.data.title} +

+ {article.data.title}

-

{post.data.summary}

+

{article.data.summary}

-
+
+
+ +
-
-
- {post.body} +
+
+ {article.body}
-

Written by

- {/* */} - - -
-

Marc Seitz

-

@mfts0

-
- +
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/help.ts b/lib/content/help.ts index 664e95386..b5d5424d1 100644 --- a/lib/content/help.ts +++ b/lib/content/help.ts @@ -1,7 +1,7 @@ import matter from "gray-matter"; import { cache } from "react"; -type Post = { +type Article = { data: { title: string; summary: string; @@ -10,14 +10,16 @@ type Post = { 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 getHelpPosts = cache(async () => { +export const getHelpArticles = cache(async () => { const apiUrl = `https://api.github.com/repos/${GITHUB_CONTENT_REPO}/contents/content/help`; const headers = { Authorization: `Bearer ${GITHUB_CONTENT_TOKEN}`, @@ -28,7 +30,7 @@ export const getHelpPosts = cache(async () => { const response = await fetch(apiUrl, { headers }); const data = await response.json(); - const posts = await Promise.all( + const articles = await Promise.all( data .filter((file: any) => file.name.endsWith(".mdx")) .map(async (file: any) => { @@ -42,15 +44,40 @@ export const getHelpPosts = cache(async () => { return null; } - return { data, body: fileContent } as Post; + const headingItems = await getHeadings(fileContent); + + return { data, body: fileContent, toc: headingItems } as Article; }), ); - const filteredPosts = posts.filter((post: Post) => post !== null) as Post[]; - return filteredPosts; + const filteredArticles = articles.filter( + (article: Article) => article !== null, + ) as Article[]; + return filteredArticles; }); -export async function getHelpPost(slug: string) { - const posts = await getHelpPosts(); - return posts.find((post) => post?.data.slug === slug); +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/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 +}; From 96473b5bf7a4fbbf2372222643a86fa029761d5a Mon Sep 17 00:00:00 2001 From: Marc Seitz Date: Wed, 24 Apr 2024 23:45:57 +0800 Subject: [PATCH 6/8] fix: cta button --- pages/data-room.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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() {
- From 39470e4ca8930f3478924370a5b5d6ca6671107b Mon Sep 17 00:00:00 2001 From: Marc Seitz Date: Wed, 24 Apr 2024 23:49:23 +0800 Subject: [PATCH 7/8] chore: handle no env vars --- lib/content/help.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/content/help.ts b/lib/content/help.ts index b5d5424d1..30500ac0d 100644 --- a/lib/content/help.ts +++ b/lib/content/help.ts @@ -20,6 +20,10 @@ 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}`, From 1a5c18e743d46d2fb1380cf131d310baf7d0ae5e Mon Sep 17 00:00:00 2001 From: Marc Seitz Date: Thu, 25 Apr 2024 00:02:44 +0800 Subject: [PATCH 8/8] feat: add help articles to sitemap --- app/sitemap.ts | 13 ++++++++++++- lib/content/index.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) 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/lib/content/index.ts b/lib/content/index.ts index 1fc29a002..0dde17fb7 100644 --- a/lib/content/index.ts +++ b/lib/content/index.ts @@ -1,4 +1,4 @@ export { getAlternatives } from "./alternative"; export { getPostsRemote as getPosts } from "./blog"; export { getPages } from "./page"; -export { getHelpPosts } from "./help"; +export { getHelpArticles } from "./help";