@opinly/next 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-lint.log +14 -0
- package/CHANGELOG.md +21 -0
- package/dist/index.cjs +876 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +838 -0
- package/dist/index.mjs +838 -0
- package/package.json +41 -0
- package/src/components/author-page/_helpers/json-ld.ts +0 -0
- package/src/components/author-page/index.tsx +62 -0
- package/src/components/authors-page/index.tsx +90 -0
- package/src/components/blog-post/_components/content.tsx +141 -0
- package/src/components/blog-post/_components/faq.tsx +18 -0
- package/src/components/blog-post/_components/share.tsx +48 -0
- package/src/components/blog-post/_components/table-of-contents.tsx +63 -0
- package/src/components/blog-post/_helpers/calc-reading-time.ts +0 -0
- package/src/components/blog-post/_helpers/extract-headings.ts +25 -0
- package/src/components/blog-post/_helpers/json-ld.ts +176 -0
- package/src/components/blog-post/_helpers/reading-time.ts +28 -0
- package/src/components/blog-post/_types/blog-post.ts +6 -0
- package/src/components/blog-post/_types/class-names.ts +10 -0
- package/src/components/blog-post/index.tsx +126 -0
- package/src/components/bread-crumbs.tsx +48 -0
- package/src/components/folder-page/index.tsx +50 -0
- package/src/components/home-page.tsx +65 -0
- package/src/components/not-found.tsx +36 -0
- package/src/components/opinly-blog.tsx +37 -0
- package/src/components/post-preview.tsx +73 -0
- package/src/index.ts +2 -0
- package/src/sdk-config.ts +37 -0
- package/src/utils/dates.ts +6 -0
- package/src/utils/generate-metadata.ts +54 -0
- package/src/utils/images.ts +7 -0
- package/src/utils/merge.ts +6 -0
- package/sst-env.d.ts +9 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { JSONContent } from "@tiptap/core";
|
|
2
|
+
|
|
3
|
+
export const countWords = (content: JSONContent | undefined): number => {
|
|
4
|
+
if (!content?.content) return 0;
|
|
5
|
+
|
|
6
|
+
let wordCount = 0;
|
|
7
|
+
|
|
8
|
+
const countWords = (nodes: any[]) => {
|
|
9
|
+
for (const node of nodes) {
|
|
10
|
+
if (node.type === "text" && node.text) {
|
|
11
|
+
const words = node.text.trim().split(/\s+/).filter(Boolean);
|
|
12
|
+
wordCount += words.length;
|
|
13
|
+
}
|
|
14
|
+
if (node.content) countWords(node.content);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
countWords(content.content);
|
|
19
|
+
|
|
20
|
+
return wordCount;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function calculateReadingTime(content: JSONContent): number {
|
|
24
|
+
const wordCount = countWords(content.content);
|
|
25
|
+
|
|
26
|
+
const readingTimeMinutes = Math.ceil(wordCount / 200);
|
|
27
|
+
return readingTimeMinutes;
|
|
28
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { JSONContent } from "@tiptap/core";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { cn } from "../../utils/merge";
|
|
5
|
+
import { opinlyConfig } from "../../sdk-config";
|
|
6
|
+
import { BlogPostRes } from "./_types/blog-post";
|
|
7
|
+
import {
|
|
8
|
+
genBlogPostingJSONLD,
|
|
9
|
+
genBreadcrumbJSONLD,
|
|
10
|
+
genFaqJSONLD,
|
|
11
|
+
} from "./_helpers/json-ld";
|
|
12
|
+
import { BlogComponentClassNames } from "./_types/class-names";
|
|
13
|
+
import { FAQS } from "./_components/faq";
|
|
14
|
+
import { calculateReadingTime } from "./_helpers/reading-time";
|
|
15
|
+
import { Content } from "./_components/content";
|
|
16
|
+
import { TableOfContents } from "./_components/table-of-contents";
|
|
17
|
+
import { BreadCrumbs } from "../bread-crumbs";
|
|
18
|
+
import { formatDate } from "../../utils/dates";
|
|
19
|
+
import Head from "next/head";
|
|
20
|
+
import Script from "next/script";
|
|
21
|
+
|
|
22
|
+
export const BlogPost = ({
|
|
23
|
+
post,
|
|
24
|
+
classNames = {},
|
|
25
|
+
}: {
|
|
26
|
+
post: BlogPostRes;
|
|
27
|
+
classNames?: BlogComponentClassNames;
|
|
28
|
+
notFoundComponent?: React.ReactNode;
|
|
29
|
+
}) => {
|
|
30
|
+
const content = post.content as JSONContent;
|
|
31
|
+
|
|
32
|
+
console.log("content", content);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
{/* <Head>
|
|
37
|
+
<> */}
|
|
38
|
+
{post.faqs && (
|
|
39
|
+
<Script
|
|
40
|
+
strategy="beforeInteractive"
|
|
41
|
+
type="application/ld+json"
|
|
42
|
+
dangerouslySetInnerHTML={{
|
|
43
|
+
__html: JSON.stringify(genFaqJSONLD(post.faqs)).replace(
|
|
44
|
+
/</g,
|
|
45
|
+
"\\u003c"
|
|
46
|
+
),
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
)}
|
|
50
|
+
<Script
|
|
51
|
+
strategy="beforeInteractive"
|
|
52
|
+
type="application/ld+json"
|
|
53
|
+
dangerouslySetInnerHTML={{
|
|
54
|
+
__html: JSON.stringify(genBlogPostingJSONLD(post)),
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
<Script
|
|
58
|
+
strategy="beforeInteractive"
|
|
59
|
+
type="application/ld+json"
|
|
60
|
+
dangerouslySetInnerHTML={{
|
|
61
|
+
__html: JSON.stringify(genBreadcrumbJSONLD(post)),
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
{/* </>
|
|
65
|
+
</Head> */}
|
|
66
|
+
|
|
67
|
+
<div className="max-w-7xl mx-auto px-4 py-24 flex items-start space-x-8">
|
|
68
|
+
<div className="flex flex-col gap-12 w-full">
|
|
69
|
+
<div className="bg-orange-600">
|
|
70
|
+
<BreadCrumbs
|
|
71
|
+
breadcrumbs={[
|
|
72
|
+
post.folder && {
|
|
73
|
+
url: `${opinlyConfig.blogPrefix}/${post.folder.slug}`,
|
|
74
|
+
name: post.folder.name,
|
|
75
|
+
},
|
|
76
|
+
]}
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
<h1 className="text-4xl font-bold mb-2 leading-tight">
|
|
80
|
+
{post.title}
|
|
81
|
+
</h1>
|
|
82
|
+
<div className="text-sm mb-4">
|
|
83
|
+
Author:{" "}
|
|
84
|
+
<Link
|
|
85
|
+
href={`${opinlyConfig.blogPrefix}/authors/${post.author?.slug}`}
|
|
86
|
+
className="font-medium"
|
|
87
|
+
>
|
|
88
|
+
{post.author?.name}
|
|
89
|
+
</Link>{" "}
|
|
90
|
+
· {calculateReadingTime(content)} min read ·{" "}
|
|
91
|
+
{formatDate(post.firstPublishedAt as string) || "Today"}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div
|
|
96
|
+
className={cn(
|
|
97
|
+
"flex flex-col-reverse md:flex-row mx-auto mt-10 gap-12 relative w-full ",
|
|
98
|
+
classNames.container
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
<article className="w-full lg:w-2/3 order-2 lg:order-1 flex items-start space-x-4">
|
|
102
|
+
<div className="prose w-full">
|
|
103
|
+
<Content content={content} classNames={classNames} />
|
|
104
|
+
<FAQS faqs={post.faqs} />
|
|
105
|
+
</div>
|
|
106
|
+
</article>
|
|
107
|
+
<div className="block md:min-w-72 md:sticky md:top-20 md:self-start">
|
|
108
|
+
<TableOfContents
|
|
109
|
+
content={content}
|
|
110
|
+
author={post.author}
|
|
111
|
+
post={post}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
{/* <div className="hidden md:block ">
|
|
117
|
+
<TableOfContents
|
|
118
|
+
content={content}
|
|
119
|
+
author={post.post.author}
|
|
120
|
+
post={post}
|
|
121
|
+
/>
|
|
122
|
+
</div> */}
|
|
123
|
+
</div>
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { Fragment } from "react";
|
|
3
|
+
import { opinlyConfig } from "../sdk-config";
|
|
4
|
+
|
|
5
|
+
export const BreadCrumbs = ({
|
|
6
|
+
breadcrumbs,
|
|
7
|
+
}: {
|
|
8
|
+
breadcrumbs?: (
|
|
9
|
+
| {
|
|
10
|
+
name: string;
|
|
11
|
+
url?: string;
|
|
12
|
+
}
|
|
13
|
+
| null
|
|
14
|
+
| undefined
|
|
15
|
+
)[];
|
|
16
|
+
}) => {
|
|
17
|
+
return (
|
|
18
|
+
<nav className="text-sm text-orange-100 text-muted-foreground mb-4">
|
|
19
|
+
<ol className="list-reset flex space-x-2">
|
|
20
|
+
<Link href="/" className="font-bold">
|
|
21
|
+
{opinlyConfig.siteName}
|
|
22
|
+
</Link>
|
|
23
|
+
<span>/</span>
|
|
24
|
+
<Link href={opinlyConfig.blogPrefix} className="font-bold">
|
|
25
|
+
Blog
|
|
26
|
+
</Link>
|
|
27
|
+
{breadcrumbs?.map((breadcrumb, index) => {
|
|
28
|
+
if (!breadcrumb) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Fragment key={index}>
|
|
34
|
+
<span>/</span>
|
|
35
|
+
{breadcrumb.url ? (
|
|
36
|
+
<Link href={breadcrumb.url} className="font-bold">
|
|
37
|
+
{breadcrumb.name}
|
|
38
|
+
</Link>
|
|
39
|
+
) : (
|
|
40
|
+
<span className="font-bold">{breadcrumb.name}</span>
|
|
41
|
+
)}
|
|
42
|
+
</Fragment>
|
|
43
|
+
);
|
|
44
|
+
})}
|
|
45
|
+
</ol>
|
|
46
|
+
</nav>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Opinly } from "@opinly/backend";
|
|
2
|
+
import { PostPreview } from "../post-preview";
|
|
3
|
+
import { BreadCrumbs } from "../bread-crumbs";
|
|
4
|
+
|
|
5
|
+
export const FolderPage = ({
|
|
6
|
+
folder,
|
|
7
|
+
}: {
|
|
8
|
+
folder: Extract<
|
|
9
|
+
Awaited<ReturnType<Opinly["content"]["blog"]>>,
|
|
10
|
+
{ type: "folder" }
|
|
11
|
+
>["data"];
|
|
12
|
+
}) => {
|
|
13
|
+
console.log("folder", JSON.stringify(folder, null, 2));
|
|
14
|
+
return (
|
|
15
|
+
<div className="max-w-6xl mx-auto px-4 py-10">
|
|
16
|
+
<BreadCrumbs />
|
|
17
|
+
|
|
18
|
+
<h1 className="text-4xl font-bold mb-2">{folder.name}</h1>
|
|
19
|
+
<p className="text-muted-foreground max-w-2xl mb-8">
|
|
20
|
+
{folder.description || `Explore articles about ${folder.name}.`}
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
24
|
+
{folder.posts.length === 0 && (
|
|
25
|
+
<div className="text-muted-foreground">No posts added yet</div>
|
|
26
|
+
)}
|
|
27
|
+
{folder.posts.map((post) => {
|
|
28
|
+
console.log("post.image", post.image);
|
|
29
|
+
return <PostPreview key={post.slug} post={post} />;
|
|
30
|
+
})}
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function formatDate(dateStr: string) {
|
|
37
|
+
return new Date(dateStr).toLocaleDateString("en-GB", {
|
|
38
|
+
day: "2-digit",
|
|
39
|
+
month: "short",
|
|
40
|
+
year: "numeric",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatMinutes(description: string | null) {
|
|
45
|
+
// Fake estimate based on length of description
|
|
46
|
+
if (!description) return "5 min read";
|
|
47
|
+
const words = description.split(" ").length;
|
|
48
|
+
const mins = Math.ceil(words / 200);
|
|
49
|
+
return `${mins} min read`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
import { Opinly } from "@opinly/backend";
|
|
4
|
+
import { PostPreview } from "./post-preview";
|
|
5
|
+
|
|
6
|
+
type HomePage = Extract<
|
|
7
|
+
Awaited<ReturnType<Opinly["content"]["blog"]>>,
|
|
8
|
+
{ type: "home" }
|
|
9
|
+
>["data"];
|
|
10
|
+
|
|
11
|
+
export const HomePage = ({
|
|
12
|
+
homePage,
|
|
13
|
+
page,
|
|
14
|
+
}: {
|
|
15
|
+
homePage: HomePage;
|
|
16
|
+
page: number | null | undefined;
|
|
17
|
+
}) => {
|
|
18
|
+
const { posts, folders, count } = homePage;
|
|
19
|
+
|
|
20
|
+
console.log("posts", JSON.stringify(posts, null, 2));
|
|
21
|
+
|
|
22
|
+
const currentPage = page;
|
|
23
|
+
const totalPages = Math.ceil(count / 10);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="max-w-7xl mx-auto px-4 py-10">
|
|
27
|
+
{/* Posts Grid */}
|
|
28
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-10">
|
|
29
|
+
{posts.map((post) => (
|
|
30
|
+
<PostPreview key={post.slug} post={post} />
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
{/* Pagination */}
|
|
35
|
+
<div className="flex justify-center items-center mt-10 gap-2">
|
|
36
|
+
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
|
37
|
+
<Link
|
|
38
|
+
key={p}
|
|
39
|
+
href={`?page=${p}`}
|
|
40
|
+
className={`px-3 py-1 border rounded ${
|
|
41
|
+
p === currentPage
|
|
42
|
+
? "bg-black text-white"
|
|
43
|
+
: "hover:bg-gray-100 text-gray-700"
|
|
44
|
+
}`}
|
|
45
|
+
>
|
|
46
|
+
{p}
|
|
47
|
+
</Link>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{/* Folder tags */}
|
|
52
|
+
<div className="flex flex-wrap gap-2 mb-10">
|
|
53
|
+
{folders.map((folder) => (
|
|
54
|
+
<Link
|
|
55
|
+
key={folder.slug}
|
|
56
|
+
href={`/blog/${folder.slug}`}
|
|
57
|
+
className="px-4 py-1 bg-gray-100 text-sm rounded-full hover:bg-gray-200"
|
|
58
|
+
>
|
|
59
|
+
{folder.title}
|
|
60
|
+
</Link>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
export const OpinlyNotFound = () => {
|
|
4
|
+
return (
|
|
5
|
+
<>
|
|
6
|
+
<div className="bg-orange-600 py-10">
|
|
7
|
+
<div className="max-w-7xl mx-auto px-4">
|
|
8
|
+
<h1 className="text-4xl font-bold mb-2 leading-tight text-white">
|
|
9
|
+
Post Not Found
|
|
10
|
+
</h1>
|
|
11
|
+
<div className="text-sm mb-4 text-orange-100">
|
|
12
|
+
The post you're looking for doesn't exist or has been removed.
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div className="flex max-w-7xl mx-auto mt-10 gap-12">
|
|
18
|
+
<article className="w-2/3">
|
|
19
|
+
<div className="prose max-w-none">
|
|
20
|
+
<p className="text-gray-600">You can try:</p>
|
|
21
|
+
<ul className="list-disc pl-6 my-4">
|
|
22
|
+
<li>Checking the URL for typos</li>
|
|
23
|
+
<li>
|
|
24
|
+
Returning to the{" "}
|
|
25
|
+
<Link href="/blog" className="text-blue-600 underline">
|
|
26
|
+
blog homepage
|
|
27
|
+
</Link>
|
|
28
|
+
</li>
|
|
29
|
+
<li>Using the search function to find what you're looking for</li>
|
|
30
|
+
</ul>
|
|
31
|
+
</div>
|
|
32
|
+
</article>
|
|
33
|
+
</div>
|
|
34
|
+
</>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { BlogPost } from "./blog-post";
|
|
2
|
+
import { FolderPage } from "./folder-page";
|
|
3
|
+
import { Opinly } from "@opinly/backend";
|
|
4
|
+
import { HomePage } from "./home-page";
|
|
5
|
+
import { AuthorPage } from "./author-page";
|
|
6
|
+
import { OpinlyNotFound } from "./not-found";
|
|
7
|
+
import { AuthorsPage } from "./authors-page";
|
|
8
|
+
|
|
9
|
+
export const OpinlyBlog = ({
|
|
10
|
+
blog,
|
|
11
|
+
page,
|
|
12
|
+
}: {
|
|
13
|
+
blog: Awaited<ReturnType<Opinly["content"]["blog"]>>;
|
|
14
|
+
page: number | null | undefined;
|
|
15
|
+
}) => {
|
|
16
|
+
if (blog.type === "not-found") {
|
|
17
|
+
return <OpinlyNotFound />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (blog.type === "home") {
|
|
21
|
+
return <HomePage homePage={blog.data} page={page} />;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (blog.type === "post") {
|
|
25
|
+
return <BlogPost post={blog.data} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (blog.type === "author") {
|
|
29
|
+
return <AuthorPage author={blog.data} />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (blog.type === "authors") {
|
|
33
|
+
return <AuthorsPage authors={blog.data} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return <FolderPage folder={blog.data} />;
|
|
37
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Opinly } from "@opinly/backend";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import Image from "next/image";
|
|
4
|
+
import { opinlyConfig } from "../sdk-config";
|
|
5
|
+
import { replaceImageNameSpace } from "../utils/images";
|
|
6
|
+
import { formatDate } from "../utils/dates";
|
|
7
|
+
|
|
8
|
+
export const PostPreview = ({
|
|
9
|
+
post,
|
|
10
|
+
}: {
|
|
11
|
+
post: Extract<
|
|
12
|
+
Awaited<ReturnType<Opinly["content"]["blog"]>>,
|
|
13
|
+
{ type: "home" }
|
|
14
|
+
>["data"]["posts"][number];
|
|
15
|
+
}) => {
|
|
16
|
+
const postSlug = `${opinlyConfig.blogPrefix}/${post.folder?.slug ? `${post.folder.slug}/` : ""}${post.slug}`;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div>
|
|
20
|
+
{post.image?.fileKey && (
|
|
21
|
+
<Link key={post.slug} href={postSlug} className="group">
|
|
22
|
+
<div className="relative w-full h-48 mb-4">
|
|
23
|
+
<Image
|
|
24
|
+
src={`${opinlyConfig.imagesPrefix}/${replaceImageNameSpace(post.image.fileKey)}`}
|
|
25
|
+
alt={post.title}
|
|
26
|
+
fill
|
|
27
|
+
className="object-cover rounded-lg"
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
</Link>
|
|
31
|
+
)}
|
|
32
|
+
<div className="text-sm text-muted-foreground mb-1">
|
|
33
|
+
{post.folder?.name && (
|
|
34
|
+
<>
|
|
35
|
+
<Link
|
|
36
|
+
href={`${opinlyConfig.blogPrefix}/${post.folder.slug}`}
|
|
37
|
+
className="hover:underline"
|
|
38
|
+
>
|
|
39
|
+
{post.folder.name}
|
|
40
|
+
</Link>
|
|
41
|
+
<span className="mx-1">·</span>
|
|
42
|
+
</>
|
|
43
|
+
)}{" "}
|
|
44
|
+
{formatDate(post.firstPublishedAt as string)}
|
|
45
|
+
</div>
|
|
46
|
+
<Link key={post.slug} href={postSlug} className="group">
|
|
47
|
+
<h2 className="text-lg font-semibold leading-snug group-hover:underline">
|
|
48
|
+
{post.title}
|
|
49
|
+
</h2>
|
|
50
|
+
</Link>
|
|
51
|
+
|
|
52
|
+
{post.description && (
|
|
53
|
+
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
|
|
54
|
+
{post.description}
|
|
55
|
+
</p>
|
|
56
|
+
)}
|
|
57
|
+
<div className="text-xs text-gray-500 mt-2">
|
|
58
|
+
{post.author && (
|
|
59
|
+
<>
|
|
60
|
+
<Link
|
|
61
|
+
href={`${opinlyConfig.blogPrefix}/authors/${post.author.slug}`}
|
|
62
|
+
className="font-medium hover:underline"
|
|
63
|
+
>
|
|
64
|
+
{post.author?.name}
|
|
65
|
+
</Link>{" "}
|
|
66
|
+
<span className="mx-1">·</span>
|
|
67
|
+
</>
|
|
68
|
+
)}{" "}
|
|
69
|
+
{formatDate(post.firstPublishedAt as string)}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const siteUrl = process.env.OPINLY_SITE_URL;
|
|
2
|
+
|
|
3
|
+
const opinlyEnvVars = {
|
|
4
|
+
OPINLY_IMAGES_PREFIX: process.env.OPINLY_IMAGES_PREFIX as string,
|
|
5
|
+
OPINLY_SITE_URL: process.env.OPINLY_SITE_URL as string,
|
|
6
|
+
OPINLY_BLOG_PREFIX: process.env.OPINLY_BLOG_PREFIX as string,
|
|
7
|
+
OPINLY_CDN_URL: process.env.OPINLY_CDN_URL as string,
|
|
8
|
+
OPINLY_SITE_NAME: process.env.OPINLY_SITE_NAME as string,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
Object.entries(opinlyEnvVars).forEach(([key, value]) => {
|
|
12
|
+
if (!value) {
|
|
13
|
+
throw new Error(`OPINLY ERROR: env var ${key} is not set`);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export let opinlyConfig: {
|
|
18
|
+
cdnUrl: string;
|
|
19
|
+
imagesPrefix: string;
|
|
20
|
+
imagesUrl: string;
|
|
21
|
+
siteUrl: string;
|
|
22
|
+
blogPrefix: string;
|
|
23
|
+
blogUrl: string;
|
|
24
|
+
siteName: string;
|
|
25
|
+
} = {
|
|
26
|
+
cdnUrl: process.env.OPINLY_CDN_URL || "https://cdn.opinly.ai",
|
|
27
|
+
siteUrl: process.env.OPINLY_SITE_URL as string,
|
|
28
|
+
imagesPrefix: process.env.OPINLY_IMAGES_PREFIX as string,
|
|
29
|
+
imagesUrl: `${process.env.OPINLY_SITE_URL as string}${process.env.OPINLY_IMAGES_PREFIX as string}`,
|
|
30
|
+
blogPrefix: process.env.OPINLY_BLOG_PREFIX as string,
|
|
31
|
+
blogUrl: `${process.env.OPINLY_SITE_URL as string}${process.env.OPINLY_BLOG_PREFIX as string}`,
|
|
32
|
+
siteName: process.env.OPINLY_SITE_NAME as string,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function setOpinlyConfig(newConfig: typeof opinlyConfig) {
|
|
36
|
+
opinlyConfig = { ...opinlyConfig, ...newConfig };
|
|
37
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Opinly, OpinlyApiOutput, OpinlyPageProps } from "@opinly/backend";
|
|
2
|
+
import type { Metadata, ResolvingMetadata } from "next";
|
|
3
|
+
import { opinlyConfig } from "../sdk-config";
|
|
4
|
+
|
|
5
|
+
type BlogPost = Extract<
|
|
6
|
+
OpinlyApiOutput["content"]["postOrFolder"],
|
|
7
|
+
{ type: "post" }
|
|
8
|
+
>["data"];
|
|
9
|
+
|
|
10
|
+
export const generateMetadata = async (
|
|
11
|
+
client: Opinly,
|
|
12
|
+
params: OpinlyPageProps,
|
|
13
|
+
parent: ResolvingMetadata
|
|
14
|
+
): Promise<Metadata> => {
|
|
15
|
+
const blog = await client.content.blog(params);
|
|
16
|
+
|
|
17
|
+
if (blog.type === "not-found") {
|
|
18
|
+
return parent as Metadata;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const previousImages = (await parent).openGraph?.images || [];
|
|
22
|
+
|
|
23
|
+
if (blog.type === "post") {
|
|
24
|
+
const openGraphImageKey = blog.data?.titleFile?.fileKey;
|
|
25
|
+
|
|
26
|
+
const images = openGraphImageKey
|
|
27
|
+
? [`${opinlyConfig.imagesUrl}/${openGraphImageKey}`, ...previousImages]
|
|
28
|
+
: previousImages;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...(parent as Metadata),
|
|
32
|
+
title: blog.data.title,
|
|
33
|
+
|
|
34
|
+
openGraph: {
|
|
35
|
+
images: images,
|
|
36
|
+
type: "article",
|
|
37
|
+
authors: blog.data.author && [
|
|
38
|
+
`${opinlyConfig.blogUrl}/authors/${blog.data.author.slug}`,
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// keywords: [blog.data.keywords],
|
|
43
|
+
authors: blog.data.author && [
|
|
44
|
+
{
|
|
45
|
+
name: blog.data.author.name,
|
|
46
|
+
url: `${opinlyConfig.blogUrl}/authors/${blog.data.author.slug}`,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
other: {},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return parent as Metadata;
|
|
54
|
+
};
|
package/sst-env.d.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": ["../../tsconfig.base.json", "@tsconfig/next/tsconfig.json"],
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"moduleResolution": "bundler",
|
|
5
|
+
"jsx": "react-jsx",
|
|
6
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
7
|
+
"allowJs": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"incremental": false
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules"]
|
|
19
|
+
}
|