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}

+ +
+ + +
+

Marc Seitz

+

@mfts0

+
+ +
+
+ +
+
+
+ +
+
+
+
+ {article.body} +
+
+ +
+
+ +
+
+
+
+ + ); +} 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({
- 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() {
- 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 ? (