chalknotes 0.0.29 ā 0.0.31
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/README.md +36 -10
- package/bin/cli.js +187 -21
- package/package.json +1 -1
- package/src/lib/getPostBySlug.js +220 -155
- package/blog.config.js +0 -7
package/README.md
CHANGED
@@ -13,6 +13,8 @@
|
|
13
13
|
- š§ Interactive configuration setup
|
14
14
|
- š Customizable route paths
|
15
15
|
- š§ Minimal setup ā just run `chalknotes`
|
16
|
+
- š¼ļø **Rich content support** - Images, code blocks, lists, quotes, and more
|
17
|
+
- š **Secure rendering** - React-based component instead of raw HTML
|
16
18
|
|
17
19
|
---
|
18
20
|
|
@@ -45,6 +47,7 @@ npm install chalknotes
|
|
45
47
|
- Creates `blog.config.js` with default configuration (if needed)
|
46
48
|
- Generates blog routes with clean, responsive templates
|
47
49
|
- Supports light and dark themes
|
50
|
+
- **Renders rich Notion content** with images, code blocks, and more
|
48
51
|
|
49
52
|
---
|
50
53
|
|
@@ -98,6 +101,7 @@ Creates:
|
|
98
101
|
```js
|
99
102
|
// pages/blog/[slug].js (or custom route)
|
100
103
|
import { getStaticPropsForPost, getStaticPathsForPosts } from 'chalknotes';
|
104
|
+
import NotionRenderer from './NotionRenderer';
|
101
105
|
|
102
106
|
export const getStaticProps = getStaticPropsForPost;
|
103
107
|
export const getStaticPaths = getStaticPathsForPosts;
|
@@ -110,10 +114,7 @@ export default function BlogPost({ post }) {
|
|
110
114
|
<h1 className="text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
111
115
|
{post.title}
|
112
116
|
</h1>
|
113
|
-
<
|
114
|
-
className="prose prose-lg max-w-none text-gray-700 leading-relaxed"
|
115
|
-
dangerouslySetInnerHTML={{ __html: post.content }}
|
116
|
-
/>
|
117
|
+
<NotionRenderer blocks={post.blocks} />
|
117
118
|
</article>
|
118
119
|
</main>
|
119
120
|
</div>
|
@@ -130,6 +131,7 @@ Creates:
|
|
130
131
|
```jsx
|
131
132
|
// app/blog/[slug]/page.jsx (or custom route)
|
132
133
|
import { getPostBySlug } from 'chalknotes';
|
134
|
+
import NotionRenderer from './NotionRenderer';
|
133
135
|
|
134
136
|
export default async function BlogPost({ params }) {
|
135
137
|
const post = await getPostBySlug(params.slug);
|
@@ -141,10 +143,7 @@ export default async function BlogPost({ params }) {
|
|
141
143
|
<h1 className="text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
142
144
|
{post.title}
|
143
145
|
</h1>
|
144
|
-
<
|
145
|
-
className="prose prose-lg max-w-none text-gray-700 leading-relaxed"
|
146
|
-
dangerouslySetInnerHTML={{ __html: post.content }}
|
147
|
-
/>
|
146
|
+
<NotionRenderer blocks={post.blocks} />
|
148
147
|
</article>
|
149
148
|
</main>
|
150
149
|
</div>
|
@@ -157,10 +156,11 @@ export default async function BlogPost({ params }) {
|
|
157
156
|
## š§© API
|
158
157
|
|
159
158
|
### `getPostBySlug(slug: string)`
|
160
|
-
Fetches a post and
|
159
|
+
Fetches a post and returns structured data for rendering.
|
161
160
|
|
162
161
|
```js
|
163
162
|
const post = await getPostBySlug('my-post-title');
|
163
|
+
// Returns: { title, slug, blocks, notionPageId }
|
164
164
|
```
|
165
165
|
|
166
166
|
---
|
@@ -191,6 +191,31 @@ For use with `getStaticPaths` in Page Router.
|
|
191
191
|
|
192
192
|
---
|
193
193
|
|
194
|
+
### `NotionRenderer`
|
195
|
+
React component for rendering Notion blocks (scaffolded in your project):
|
196
|
+
|
197
|
+
```jsx
|
198
|
+
import NotionRenderer from './NotionRenderer';
|
199
|
+
|
200
|
+
<NotionRenderer blocks={post.blocks} />
|
201
|
+
```
|
202
|
+
|
203
|
+
---
|
204
|
+
|
205
|
+
## š¼ļø Supported Content Types
|
206
|
+
|
207
|
+
The `NotionRenderer` component supports all major Notion block types:
|
208
|
+
|
209
|
+
- **Text blocks**: Paragraphs, headings (H1, H2, H3)
|
210
|
+
- **Lists**: Bulleted and numbered lists
|
211
|
+
- **Code blocks**: With syntax highlighting support
|
212
|
+
- **Images**: With captions and Next.js optimization
|
213
|
+
- **Quotes**: Styled blockquotes
|
214
|
+
- **Dividers**: Horizontal rules
|
215
|
+
- **Rich text**: Bold, italic, strikethrough, code, links
|
216
|
+
|
217
|
+
---
|
218
|
+
|
194
219
|
## šØ Styling
|
195
220
|
|
196
221
|
The generated templates use Tailwind CSS with:
|
@@ -199,6 +224,7 @@ The generated templates use Tailwind CSS with:
|
|
199
224
|
- Typography optimized for readability
|
200
225
|
- Proper spacing and hierarchy
|
201
226
|
- Light and dark mode support
|
227
|
+
- **Rich content styling** for all Notion block types
|
202
228
|
|
203
229
|
Make sure you have Tailwind CSS installed in your project:
|
204
230
|
|
@@ -211,7 +237,7 @@ npm install -D tailwindcss @tailwindcss/typography
|
|
211
237
|
## š
Roadmap
|
212
238
|
|
213
239
|
- [ ] Plugin system for custom components
|
214
|
-
- [ ] More Notion block support (
|
240
|
+
- [ ] More Notion block support (callouts, bookmarks, toggles)
|
215
241
|
- [ ] RSS feed support
|
216
242
|
- [ ] MDX or Markdown output option
|
217
243
|
- [ ] Custom theme templates
|
package/bin/cli.js
CHANGED
@@ -28,16 +28,16 @@ const configPath = path.join(process.cwd(), 'blog.config.js');
|
|
28
28
|
if (!fs.existsSync(configPath)) {
|
29
29
|
console.log("\nā blog.config.js not found");
|
30
30
|
console.log("This file is required to configure your blog settings.");
|
31
|
-
|
31
|
+
|
32
32
|
const rl = readline.createInterface({
|
33
33
|
input: process.stdin,
|
34
34
|
output: process.stdout
|
35
35
|
});
|
36
|
-
|
36
|
+
|
37
37
|
rl.question("Would you like to create a default blog.config.js? (y/n): ", (answer) => {
|
38
38
|
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
39
39
|
console.log("š Creating blog.config.js with default configuration...");
|
40
|
-
|
40
|
+
|
41
41
|
const configTemplate = `module.exports = {
|
42
42
|
notionToken: process.env.NOTION_TOKEN,
|
43
43
|
notionDatabaseId: process.env.NOTION_DATABASE_ID,
|
@@ -45,7 +45,7 @@ if (!fs.existsSync(configPath)) {
|
|
45
45
|
theme: 'default',
|
46
46
|
plugins: [],
|
47
47
|
};`.trim();
|
48
|
-
|
48
|
+
|
49
49
|
fs.writeFileSync(configPath, configTemplate);
|
50
50
|
console.log("ā
Created blog.config.js with default configuration");
|
51
51
|
console.log("\nš” Now you can re-run 'npx chalknotes' to scaffold your blog pages!");
|
@@ -86,11 +86,12 @@ const appRouter = path.join(process.cwd(), '/app')
|
|
86
86
|
// Generate templates based on theme
|
87
87
|
function getTemplates(theme, routeBasePath) {
|
88
88
|
const routePath = routeBasePath.replace(/^\//, ''); // Remove leading slash
|
89
|
-
|
89
|
+
|
90
90
|
if (theme === 'dark') {
|
91
91
|
return {
|
92
92
|
pageRouter: `
|
93
93
|
import { getStaticPropsForPost, getStaticPathsForPosts } from 'chalknotes';
|
94
|
+
import NotionRenderer from './NotionRenderer';
|
94
95
|
|
95
96
|
export const getStaticProps = getStaticPropsForPost;
|
96
97
|
export const getStaticPaths = getStaticPathsForPosts;
|
@@ -103,10 +104,7 @@ export default function BlogPost({ post }) {
|
|
103
104
|
<h1 className="text-4xl font-bold text-white mb-6 leading-tight">
|
104
105
|
{post.title}
|
105
106
|
</h1>
|
106
|
-
<
|
107
|
-
className="prose prose-lg prose-invert max-w-none text-gray-300 leading-relaxed"
|
108
|
-
dangerouslySetInnerHTML={{ __html: post.content }}
|
109
|
-
/>
|
107
|
+
<NotionRenderer blocks={post.blocks} />
|
110
108
|
</article>
|
111
109
|
</main>
|
112
110
|
</div>
|
@@ -114,6 +112,7 @@ export default function BlogPost({ post }) {
|
|
114
112
|
}`.trim(),
|
115
113
|
appRouter: `
|
116
114
|
import { getPostBySlug } from 'chalknotes';
|
115
|
+
import NotionRenderer from './NotionRenderer';
|
117
116
|
|
118
117
|
export default async function BlogPost({ params }) {
|
119
118
|
const post = await getPostBySlug(params.slug);
|
@@ -125,10 +124,7 @@ export default async function BlogPost({ params }) {
|
|
125
124
|
<h1 className="text-4xl font-bold text-white mb-6 leading-tight">
|
126
125
|
{post.title}
|
127
126
|
</h1>
|
128
|
-
<
|
129
|
-
className="prose prose-lg prose-invert max-w-none text-gray-300 leading-relaxed"
|
130
|
-
dangerouslySetInnerHTML={{ __html: post.content }}
|
131
|
-
/>
|
127
|
+
<NotionRenderer blocks={post.blocks} />
|
132
128
|
</article>
|
133
129
|
</main>
|
134
130
|
</div>
|
@@ -140,6 +136,7 @@ export default async function BlogPost({ params }) {
|
|
140
136
|
return {
|
141
137
|
pageRouter: `
|
142
138
|
import { getStaticPropsForPost, getStaticPathsForPosts } from 'chalknotes';
|
139
|
+
import NotionRenderer from './NotionRenderer';
|
143
140
|
|
144
141
|
export const getStaticProps = getStaticPropsForPost;
|
145
142
|
export const getStaticPaths = getStaticPathsForPosts;
|
@@ -152,10 +149,7 @@ export default function BlogPost({ post }) {
|
|
152
149
|
<h1 className="text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
153
150
|
{post.title}
|
154
151
|
</h1>
|
155
|
-
<
|
156
|
-
className="prose prose-lg max-w-none text-gray-700 leading-relaxed"
|
157
|
-
dangerouslySetInnerHTML={{ __html: post.content }}
|
158
|
-
/>
|
152
|
+
<NotionRenderer blocks={post.blocks} />
|
159
153
|
</article>
|
160
154
|
</main>
|
161
155
|
</div>
|
@@ -163,6 +157,7 @@ export default function BlogPost({ post }) {
|
|
163
157
|
}`.trim(),
|
164
158
|
appRouter: `
|
165
159
|
import { getPostBySlug } from 'chalknotes';
|
160
|
+
import NotionRenderer from './NotionRenderer';
|
166
161
|
|
167
162
|
export default async function BlogPost({ params }) {
|
168
163
|
const post = await getPostBySlug(params.slug);
|
@@ -174,10 +169,7 @@ export default async function BlogPost({ params }) {
|
|
174
169
|
<h1 className="text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
175
170
|
{post.title}
|
176
171
|
</h1>
|
177
|
-
<
|
178
|
-
className="prose prose-lg max-w-none text-gray-700 leading-relaxed"
|
179
|
-
dangerouslySetInnerHTML={{ __html: post.content }}
|
180
|
-
/>
|
172
|
+
<NotionRenderer blocks={post.blocks} />
|
181
173
|
</article>
|
182
174
|
</main>
|
183
175
|
</div>
|
@@ -196,7 +188,94 @@ if (fs.existsSync(pageRouter)) {
|
|
196
188
|
|
197
189
|
const templates = getTemplates(config.theme, config.routeBasePath);
|
198
190
|
|
191
|
+
// Create NotionRenderer component in the same directory as the blog page
|
192
|
+
const notionRendererContent = `import React from "react";
|
193
|
+
import Image from "next/image";
|
194
|
+
|
195
|
+
export default function NotionRenderer({ blocks }) {
|
196
|
+
if (!blocks || blocks.length === 0) return null;
|
197
|
+
|
198
|
+
return (
|
199
|
+
<div className="prose prose-lg max-w-none text-slate-700 leading-relaxed dark:prose-invert dark:text-slate-300">
|
200
|
+
{blocks.map((block, i) => {
|
201
|
+
switch (block.type) {
|
202
|
+
case "heading_1":
|
203
|
+
return <h1 key={i}>{block.text}</h1>;
|
204
|
+
|
205
|
+
case "heading_2":
|
206
|
+
return <h2 key={i}>{block.text}</h2>;
|
207
|
+
|
208
|
+
case "heading_3":
|
209
|
+
return <h3 key={i}>{block.text}</h3>;
|
210
|
+
|
211
|
+
case "paragraph":
|
212
|
+
return <p key={i}>{block.text}</p>;
|
213
|
+
|
214
|
+
case "bulleted_list_item":
|
215
|
+
return (
|
216
|
+
<ul key={i} className="list-disc ml-6">
|
217
|
+
<li>{block.text}</li>
|
218
|
+
</ul>
|
219
|
+
);
|
220
|
+
|
221
|
+
case "numbered_list_item":
|
222
|
+
return (
|
223
|
+
<ol key={i} className="list-decimal ml-6">
|
224
|
+
<li>{block.text}</li>
|
225
|
+
</ol>
|
226
|
+
);
|
227
|
+
|
228
|
+
case "quote":
|
229
|
+
return (
|
230
|
+
<blockquote key={i} className="border-l-4 pl-4 italic text-slate-600 bg-slate-50 p-4 rounded-r">
|
231
|
+
{block.text}
|
232
|
+
</blockquote>
|
233
|
+
);
|
234
|
+
|
235
|
+
case "code":
|
236
|
+
return (
|
237
|
+
<pre key={i} className="bg-slate-900 text-slate-100 p-4 rounded-xl overflow-x-auto text-sm">
|
238
|
+
<code className={\`language-\${block.language}\`}>{block.code}</code>
|
239
|
+
</pre>
|
240
|
+
);
|
241
|
+
|
242
|
+
case "divider":
|
243
|
+
return <hr key={i} className="my-8 border-slate-300" />;
|
244
|
+
|
245
|
+
case "image":
|
246
|
+
return (
|
247
|
+
<figure key={i} className="max-w-[400px] mx-auto my-6 px-4">
|
248
|
+
<Image
|
249
|
+
src={block.imageUrl}
|
250
|
+
alt={block.alt || "Image"}
|
251
|
+
width={400}
|
252
|
+
height={300}
|
253
|
+
className="rounded-xl object-contain"
|
254
|
+
/>
|
255
|
+
{block.caption && (
|
256
|
+
<figcaption className="text-sm text-center text-slate-500 mt-2 italic">
|
257
|
+
{block.caption}
|
258
|
+
</figcaption>
|
259
|
+
)}
|
260
|
+
</figure>
|
261
|
+
);
|
262
|
+
|
263
|
+
default:
|
264
|
+
return (
|
265
|
+
<p key={i} className="text-sm text-red-500 italic">
|
266
|
+
ā ļø Unsupported block: {block.type}
|
267
|
+
</p>
|
268
|
+
);
|
269
|
+
}
|
270
|
+
})}
|
271
|
+
</div>
|
272
|
+
);
|
273
|
+
}`;
|
274
|
+
|
199
275
|
fs.mkdirSync(dirPath, { recursive: true });
|
276
|
+
fs.writeFileSync(path.join(dirPath, 'NotionRenderer.jsx'), notionRendererContent);
|
277
|
+
console.log(`ā
Created ${routePath}/NotionRenderer.jsx`);
|
278
|
+
|
200
279
|
fs.writeFileSync(slugDir, templates.pageRouter);
|
201
280
|
console.log(`ā
Created pages/${routePath}/[slug].js`);
|
202
281
|
|
@@ -208,7 +287,94 @@ if (fs.existsSync(pageRouter)) {
|
|
208
287
|
|
209
288
|
const templates = getTemplates(config.theme, config.routeBasePath);
|
210
289
|
|
290
|
+
// Create NotionRenderer component in the same directory as the blog page
|
291
|
+
const notionRendererContent = `import React from "react";
|
292
|
+
import Image from "next/image";
|
293
|
+
|
294
|
+
export default function NotionRenderer({ blocks }) {
|
295
|
+
if (!blocks || blocks.length === 0) return null;
|
296
|
+
|
297
|
+
return (
|
298
|
+
<div className="prose prose-lg max-w-none text-slate-700 leading-relaxed dark:prose-invert dark:text-slate-300">
|
299
|
+
{blocks.map((block, i) => {
|
300
|
+
switch (block.type) {
|
301
|
+
case "heading_1":
|
302
|
+
return <h1 key={i}>{block.text}</h1>;
|
303
|
+
|
304
|
+
case "heading_2":
|
305
|
+
return <h2 key={i}>{block.text}</h2>;
|
306
|
+
|
307
|
+
case "heading_3":
|
308
|
+
return <h3 key={i}>{block.text}</h3>;
|
309
|
+
|
310
|
+
case "paragraph":
|
311
|
+
return <p key={i}>{block.text}</p>;
|
312
|
+
|
313
|
+
case "bulleted_list_item":
|
314
|
+
return (
|
315
|
+
<ul key={i} className="list-disc ml-6">
|
316
|
+
<li>{block.text}</li>
|
317
|
+
</ul>
|
318
|
+
);
|
319
|
+
|
320
|
+
case "numbered_list_item":
|
321
|
+
return (
|
322
|
+
<ol key={i} className="list-decimal ml-6">
|
323
|
+
<li>{block.text}</li>
|
324
|
+
</ol>
|
325
|
+
);
|
326
|
+
|
327
|
+
case "quote":
|
328
|
+
return (
|
329
|
+
<blockquote key={i} className="border-l-4 pl-4 italic text-slate-600 bg-slate-50 p-4 rounded-r">
|
330
|
+
{block.text}
|
331
|
+
</blockquote>
|
332
|
+
);
|
333
|
+
|
334
|
+
case "code":
|
335
|
+
return (
|
336
|
+
<pre key={i} className="bg-slate-900 text-slate-100 p-4 rounded-xl overflow-x-auto text-sm">
|
337
|
+
<code className={\`language-\${block.language}\`}>{block.code}</code>
|
338
|
+
</pre>
|
339
|
+
);
|
340
|
+
|
341
|
+
case "divider":
|
342
|
+
return <hr key={i} className="my-8 border-slate-300" />;
|
343
|
+
|
344
|
+
case "image":
|
345
|
+
return (
|
346
|
+
<figure key={i} className="max-w-[400px] mx-auto my-6 px-4">
|
347
|
+
<Image
|
348
|
+
src={block.imageUrl}
|
349
|
+
alt={block.alt || "Image"}
|
350
|
+
width={400}
|
351
|
+
height={300}
|
352
|
+
className="rounded-xl object-contain"
|
353
|
+
/>
|
354
|
+
{block.caption && (
|
355
|
+
<figcaption className="text-sm text-center text-slate-500 mt-2 italic">
|
356
|
+
{block.caption}
|
357
|
+
</figcaption>
|
358
|
+
)}
|
359
|
+
</figure>
|
360
|
+
);
|
361
|
+
|
362
|
+
default:
|
363
|
+
return (
|
364
|
+
<p key={i} className="text-sm text-red-500 italic">
|
365
|
+
ā ļø Unsupported block: {block.type}
|
366
|
+
</p>
|
367
|
+
);
|
368
|
+
}
|
369
|
+
})}
|
370
|
+
</div>
|
371
|
+
);
|
372
|
+
}`;
|
373
|
+
|
211
374
|
fs.mkdirSync(dirPath, { recursive: true });
|
375
|
+
fs.writeFileSync(path.join(dirPath, 'NotionRenderer.jsx'), notionRendererContent);
|
376
|
+
console.log(`ā
Created ${routePath}/[slug]/NotionRenderer.jsx`);
|
377
|
+
|
212
378
|
fs.writeFileSync(slugDir, templates.appRouter);
|
213
379
|
console.log(`ā
Created app/${routePath}/[slug]/page.jsx`);
|
214
380
|
|
package/package.json
CHANGED
package/src/lib/getPostBySlug.js
CHANGED
@@ -1,5 +1,5 @@
|
|
1
|
-
const { notion, dbId } = require(
|
2
|
-
const { slugify } = require(
|
1
|
+
const { notion, dbId } = require("./notion")
|
2
|
+
const { slugify } = require("../utils")
|
3
3
|
|
4
4
|
const getPostBySlug = async (slug) => {
|
5
5
|
try {
|
@@ -8,43 +8,116 @@ const getPostBySlug = async (slug) => {
|
|
8
8
|
filter: {
|
9
9
|
property: "Published",
|
10
10
|
checkbox: {
|
11
|
-
equals: true
|
12
|
-
}
|
13
|
-
}
|
11
|
+
equals: true,
|
12
|
+
},
|
13
|
+
},
|
14
14
|
})
|
15
|
+
|
15
16
|
if (response.results.length === 0) {
|
16
|
-
throw new Error("No posts found")
|
17
|
+
throw new Error("No posts found")
|
17
18
|
}
|
18
19
|
|
19
20
|
for (const page of response.results) {
|
20
|
-
const titleProperty = page.properties["Name"]
|
21
|
-
const title = titleProperty?.title?.[0]?.plain_text
|
22
|
-
const pageSlug = slugify(title)
|
21
|
+
const titleProperty = page.properties["Name"]
|
22
|
+
const title = titleProperty?.title?.[0]?.plain_text
|
23
|
+
const pageSlug = slugify(title)
|
23
24
|
|
24
25
|
if (pageSlug === slug) {
|
25
26
|
const response = await notion.blocks.children.list({
|
26
27
|
block_id: page.id,
|
27
28
|
})
|
28
|
-
|
29
|
+
|
30
|
+
let content = []
|
29
31
|
for (const block of response.results) {
|
30
|
-
content
|
32
|
+
content.push(convertBlockToStructuredJSON(block))
|
31
33
|
}
|
34
|
+
|
32
35
|
return {
|
33
36
|
title,
|
34
37
|
slug: pageSlug,
|
35
|
-
|
38
|
+
blocks: content,
|
36
39
|
notionPageId: page.id,
|
37
|
-
}
|
40
|
+
}
|
38
41
|
}
|
39
42
|
}
|
40
43
|
|
41
|
-
throw new Error(`No post found with slug "${slug}"`)
|
44
|
+
throw new Error(`No post found with slug "${slug}"`)
|
42
45
|
} catch (error) {
|
43
46
|
console.error(error)
|
44
|
-
throw new Error(`Error fetching posts from Notion: ${error.message}`)
|
47
|
+
throw new Error(`Error fetching posts from Notion: ${error.message}`)
|
45
48
|
}
|
46
49
|
}
|
47
50
|
|
51
|
+
function convertBlockToStructuredJSON(block) {
|
52
|
+
const base = { type: block.type };
|
53
|
+
|
54
|
+
switch (block.type) {
|
55
|
+
case "paragraph":
|
56
|
+
return {
|
57
|
+
...base,
|
58
|
+
text: extractPlainText(block.paragraph.rich_text),
|
59
|
+
richText: block.paragraph.rich_text,
|
60
|
+
};
|
61
|
+
|
62
|
+
case "heading_1":
|
63
|
+
case "heading_2":
|
64
|
+
case "heading_3":
|
65
|
+
return {
|
66
|
+
...base,
|
67
|
+
text: extractPlainText(block[block.type].rich_text),
|
68
|
+
richText: block[block.type].rich_text,
|
69
|
+
};
|
70
|
+
|
71
|
+
case "bulleted_list_item":
|
72
|
+
case "numbered_list_item":
|
73
|
+
return {
|
74
|
+
...base,
|
75
|
+
text: extractPlainText(block[block.type].rich_text),
|
76
|
+
richText: block[block.type].rich_text,
|
77
|
+
};
|
78
|
+
|
79
|
+
case "image": {
|
80
|
+
const image = block.image;
|
81
|
+
const url = image.type === "external" ? image.external.url : image.file.url;
|
82
|
+
const caption = extractPlainText(image.caption);
|
83
|
+
return {
|
84
|
+
...base,
|
85
|
+
imageUrl: url,
|
86
|
+
caption,
|
87
|
+
alt: caption || "Blog image from Notion",
|
88
|
+
};
|
89
|
+
}
|
90
|
+
|
91
|
+
case "quote":
|
92
|
+
return {
|
93
|
+
...base,
|
94
|
+
text: extractPlainText(block.quote.rich_text),
|
95
|
+
richText: block.quote.rich_text,
|
96
|
+
};
|
97
|
+
|
98
|
+
case "code":
|
99
|
+
return {
|
100
|
+
...base,
|
101
|
+
code: extractPlainText(block.code.rich_text),
|
102
|
+
language: block.code.language || "text",
|
103
|
+
};
|
104
|
+
|
105
|
+
case "divider":
|
106
|
+
return { ...base };
|
107
|
+
|
108
|
+
default:
|
109
|
+
return {
|
110
|
+
...base,
|
111
|
+
unsupported: true,
|
112
|
+
};
|
113
|
+
}
|
114
|
+
}
|
115
|
+
|
116
|
+
function extractPlainText(richText = []) {
|
117
|
+
return richText.map(t => t.plain_text).join("");
|
118
|
+
}
|
119
|
+
|
120
|
+
|
48
121
|
/**
|
49
122
|
* Process individual Notion blocks and convert to HTML
|
50
123
|
* @param {Object} block - Notion block object
|
@@ -53,58 +126,74 @@ const getPostBySlug = async (slug) => {
|
|
53
126
|
function processBlock(block) {
|
54
127
|
switch (block.type) {
|
55
128
|
case "paragraph":
|
56
|
-
return processRichText(block.paragraph.rich_text, "p", "mb-6 leading-
|
57
|
-
|
129
|
+
return processRichText(block.paragraph.rich_text, "p", "mb-6 leading-relaxed text-slate-700 text-base")
|
130
|
+
|
58
131
|
case "heading_1":
|
59
|
-
return processRichText(
|
60
|
-
|
132
|
+
return processRichText(
|
133
|
+
block.heading_1.rich_text,
|
134
|
+
"h1",
|
135
|
+
"text-4xl font-extrabold mb-8 mt-12 text-slate-900 border-b border-slate-200 pb-6",
|
136
|
+
)
|
137
|
+
|
61
138
|
case "heading_2":
|
62
|
-
return processRichText(block.heading_2.rich_text, "h2", "text-3xl font-
|
63
|
-
|
139
|
+
return processRichText(block.heading_2.rich_text, "h2", "text-3xl font-bold mb-6 mt-10 text-slate-900")
|
140
|
+
|
64
141
|
case "heading_3":
|
65
|
-
return processRichText(block.heading_3.rich_text, "h3", "text-2xl font-
|
66
|
-
|
142
|
+
return processRichText(block.heading_3.rich_text, "h3", "text-2xl font-semibold mb-4 mt-8 text-slate-900")
|
143
|
+
|
67
144
|
case "bulleted_list_item":
|
68
|
-
return processRichText(
|
69
|
-
|
145
|
+
return processRichText(
|
146
|
+
block.bulleted_list_item.rich_text,
|
147
|
+
"li",
|
148
|
+
"mb-2 ml-6 leading-relaxed text-slate-700 list-disc",
|
149
|
+
)
|
150
|
+
|
70
151
|
case "numbered_list_item":
|
71
|
-
return processRichText(
|
72
|
-
|
152
|
+
return processRichText(
|
153
|
+
block.numbered_list_item.rich_text,
|
154
|
+
"li",
|
155
|
+
"mb-2 ml-6 leading-relaxed text-slate-700 list-decimal",
|
156
|
+
)
|
157
|
+
|
73
158
|
case "quote":
|
74
|
-
return processRichText(
|
75
|
-
|
159
|
+
return processRichText(
|
160
|
+
block.quote.rich_text,
|
161
|
+
"blockquote",
|
162
|
+
"border-l-4 border-indigo-400 pl-6 italic text-slate-600 mb-8 bg-slate-50 py-6 rounded-r-xl text-lg leading-relaxed font-medium",
|
163
|
+
)
|
164
|
+
|
76
165
|
case "code":
|
77
|
-
const codeContent = block.code.rich_text.map(text => text.plain_text).join("")
|
78
|
-
const language = block.code.language ||
|
79
|
-
return `<pre class="bg-
|
80
|
-
|
166
|
+
const codeContent = block.code.rich_text.map((text) => text.plain_text).join("")
|
167
|
+
const language = block.code.language || "text"
|
168
|
+
return `<pre class="bg-slate-900 text-slate-100 p-6 rounded-xl overflow-x-auto mb-8 text-sm leading-6 shadow-xl border border-slate-800"><code class="language-${language}">${escapeHtml(codeContent)}</code></pre>`
|
169
|
+
|
81
170
|
case "image":
|
82
|
-
return processImage(block.image)
|
83
|
-
|
171
|
+
return processImage(block.image)
|
172
|
+
|
84
173
|
case "divider":
|
85
|
-
return '<hr class="my-12 border-
|
86
|
-
|
174
|
+
return '<hr class="my-12 border-slate-200" />'
|
175
|
+
|
87
176
|
case "callout":
|
88
|
-
return processCallout(block.callout)
|
89
|
-
|
177
|
+
return processCallout(block.callout)
|
178
|
+
|
90
179
|
case "toggle":
|
91
|
-
return processToggle(block.toggle)
|
92
|
-
|
180
|
+
return processToggle(block.toggle)
|
181
|
+
|
93
182
|
case "table_of_contents":
|
94
|
-
return '<div class="bg-
|
95
|
-
|
183
|
+
return '<div class="bg-indigo-50 border border-indigo-200 rounded-xl p-6 mb-8 shadow-sm"><p class="text-indigo-900 font-semibold text-lg">š Table of Contents</p><p class="text-indigo-600 text-sm mt-1">(Generated automatically)</p></div>'
|
184
|
+
|
96
185
|
case "bookmark":
|
97
|
-
return processBookmark(block.bookmark)
|
98
|
-
|
186
|
+
return processBookmark(block.bookmark)
|
187
|
+
|
99
188
|
case "equation":
|
100
|
-
return `<div class="bg-
|
101
|
-
|
189
|
+
return `<div class="bg-slate-50 p-6 rounded-xl mb-8 text-center border border-slate-200 shadow-sm"><p class="text-slate-600 mb-3 font-semibold">š Mathematical equation</p><p class="font-mono text-sm bg-white p-4 rounded-lg border shadow-sm">${block.equation.expression}</p></div>`
|
190
|
+
|
102
191
|
default:
|
103
192
|
// For unsupported blocks, try to extract plain text
|
104
193
|
if (block[block.type]?.rich_text) {
|
105
|
-
return processRichText(block[block.type].rich_text, "p", "mb-6 text-
|
194
|
+
return processRichText(block[block.type].rich_text, "p", "mb-6 text-slate-500 italic text-base")
|
106
195
|
}
|
107
|
-
return ""
|
196
|
+
return ""
|
108
197
|
}
|
109
198
|
}
|
110
199
|
|
@@ -114,93 +203,65 @@ function processBlock(block) {
|
|
114
203
|
* @returns {string} HTML string
|
115
204
|
*/
|
116
205
|
function processImage(image) {
|
117
|
-
const imageUrl = image.type ===
|
118
|
-
const caption = image.caption?.map(text => text.plain_text).join("") || "";
|
119
|
-
const altText = caption || "
|
120
|
-
|
121
|
-
//
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
//
|
126
|
-
|
127
|
-
|
128
|
-
case 'small':
|
129
|
-
maxWidthClass = "max-w-sm";
|
130
|
-
break;
|
131
|
-
case 'medium':
|
132
|
-
maxWidthClass = "max-w-lg";
|
133
|
-
break;
|
134
|
-
case 'large':
|
135
|
-
maxWidthClass = "max-w-2xl";
|
136
|
-
break;
|
137
|
-
default:
|
138
|
-
maxWidthClass = "max-w-full";
|
139
|
-
}
|
140
|
-
}
|
141
|
-
|
142
|
-
// Check for alignment in block properties
|
143
|
-
if (image.alignment) {
|
144
|
-
switch (image.alignment) {
|
145
|
-
case 'left':
|
146
|
-
alignmentClass = "text-left";
|
147
|
-
break;
|
148
|
-
case 'center':
|
149
|
-
alignmentClass = "text-center";
|
150
|
-
break;
|
151
|
-
case 'right':
|
152
|
-
alignmentClass = "text-right";
|
153
|
-
break;
|
154
|
-
default:
|
155
|
-
alignmentClass = "text-center";
|
156
|
-
}
|
157
|
-
}
|
158
|
-
|
159
|
-
const responsiveClasses = `w-full ${maxWidthClass} h-auto rounded-lg shadow-sm`;
|
160
|
-
|
206
|
+
const imageUrl = image.type === "external" ? image.external.url : image.file.url;
|
207
|
+
const caption = image.caption?.map((text) => text.plain_text).join("") || "";
|
208
|
+
const altText = caption || "Blog image from Notion";
|
209
|
+
|
210
|
+
// Strict size constraints for blog layout
|
211
|
+
const containerClasses = "max-w-[400px] mx-auto px-4 my-4";
|
212
|
+
const figureClasses = "relative w-full max-w-[300px] sm:max-w-[400px] h-[300px]";
|
213
|
+
|
214
|
+
// Log image URL for debugging
|
215
|
+
console.log("Image URL:", imageUrl);
|
216
|
+
|
161
217
|
return `
|
162
|
-
|
163
|
-
|
218
|
+
<div className="${containerClasses}">
|
219
|
+
<figure className="${figureClasses}">
|
220
|
+
<Image
|
164
221
|
src="${imageUrl}"
|
165
222
|
alt="${escapeHtml(altText)}"
|
166
|
-
|
167
|
-
|
223
|
+
fill
|
224
|
+
className="rounded-xl object-contain"
|
225
|
+
sizes="(max-width: 640px) 300px, 400px"
|
226
|
+
priority={false}
|
168
227
|
/>
|
169
|
-
${caption ? `<figcaption
|
228
|
+
${caption ? `<figcaption className="text-slate-600 mt-2 text-sm text-center font-medium italic">${escapeHtml(caption)}</figcaption>` : ""}
|
170
229
|
</figure>
|
230
|
+
</div>
|
171
231
|
`.trim();
|
172
232
|
}
|
173
233
|
|
234
|
+
|
174
235
|
/**
|
175
236
|
* Process callout block
|
176
237
|
* @param {Object} callout - Notion callout block
|
177
238
|
* @returns {string} HTML string
|
178
239
|
*/
|
179
240
|
function processCallout(callout) {
|
180
|
-
const content = processRichText(callout.rich_text, "div", "")
|
181
|
-
const icon = callout.icon?.emoji || "š”"
|
182
|
-
const bgColor = callout.color || "blue"
|
183
|
-
|
241
|
+
const content = processRichText(callout.rich_text, "div", "")
|
242
|
+
const icon = callout.icon?.emoji || "š”"
|
243
|
+
const bgColor = callout.color || "blue"
|
244
|
+
|
184
245
|
const colorClasses = {
|
185
|
-
blue: "bg-blue-50 border-blue-200 text-blue-
|
186
|
-
gray: "bg-
|
187
|
-
yellow: "bg-
|
188
|
-
red: "bg-red-50 border-red-200 text-red-
|
189
|
-
green: "bg-
|
190
|
-
purple: "bg-purple-50 border-purple-200 text-purple-
|
191
|
-
pink: "bg-pink-50 border-pink-200 text-pink-
|
192
|
-
}
|
193
|
-
|
194
|
-
const colorClass = colorClasses[bgColor] || colorClasses.blue
|
195
|
-
|
246
|
+
blue: "bg-blue-50 border-blue-200 text-blue-900",
|
247
|
+
gray: "bg-slate-50 border-slate-200 text-slate-900",
|
248
|
+
yellow: "bg-amber-50 border-amber-200 text-amber-900",
|
249
|
+
red: "bg-red-50 border-red-200 text-red-900",
|
250
|
+
green: "bg-emerald-50 border-emerald-200 text-emerald-900",
|
251
|
+
purple: "bg-purple-50 border-purple-200 text-purple-900",
|
252
|
+
pink: "bg-pink-50 border-pink-200 text-pink-900",
|
253
|
+
}
|
254
|
+
|
255
|
+
const colorClass = colorClasses[bgColor] || colorClasses.blue
|
256
|
+
|
196
257
|
return `
|
197
|
-
<div class="${colorClass} border-l-4 p-6 my-8 rounded-r-
|
258
|
+
<div class="${colorClass} border-l-4 p-6 my-8 rounded-r-xl shadow-sm">
|
198
259
|
<div class="flex items-start">
|
199
260
|
<span class="mr-4 text-2xl flex-shrink-0">${icon}</span>
|
200
|
-
<div class="flex-1 leading-
|
261
|
+
<div class="flex-1 leading-relaxed text-base font-medium">${content}</div>
|
201
262
|
</div>
|
202
263
|
</div>
|
203
|
-
`.trim()
|
264
|
+
`.trim()
|
204
265
|
}
|
205
266
|
|
206
267
|
/**
|
@@ -209,24 +270,24 @@ function processCallout(callout) {
|
|
209
270
|
* @returns {string} HTML string
|
210
271
|
*/
|
211
272
|
function processBookmark(bookmark) {
|
212
|
-
const url = bookmark.url
|
213
|
-
const title = bookmark.caption?.[0]?.plain_text || "Bookmark"
|
214
|
-
|
273
|
+
const url = bookmark.url
|
274
|
+
const title = bookmark.caption?.[0]?.plain_text || "Bookmark"
|
275
|
+
|
215
276
|
return `
|
216
277
|
<div class="my-8">
|
217
|
-
<a href="${url}" target="_blank" rel="noopener noreferrer" class="block border border-
|
278
|
+
<a href="${url}" target="_blank" rel="noopener noreferrer" class="block border border-slate-200 rounded-xl p-6 hover:border-slate-300 hover:shadow-lg transition-all duration-300 bg-white hover:bg-slate-50">
|
218
279
|
<div class="flex items-center">
|
219
280
|
<div class="flex-1 min-w-0">
|
220
|
-
<p class="font-semibold text-
|
221
|
-
<p class="text-sm text-
|
281
|
+
<p class="font-semibold text-slate-900 truncate text-lg">${escapeHtml(title)}</p>
|
282
|
+
<p class="text-sm text-slate-500 truncate mt-2">${url}</p>
|
222
283
|
</div>
|
223
|
-
<svg class="w-6 h-6 text-
|
284
|
+
<svg class="w-6 h-6 text-slate-400 ml-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
224
285
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
225
286
|
</svg>
|
226
287
|
</div>
|
227
288
|
</a>
|
228
289
|
</div>
|
229
|
-
`.trim()
|
290
|
+
`.trim()
|
230
291
|
}
|
231
292
|
|
232
293
|
/**
|
@@ -235,18 +296,18 @@ function processBookmark(bookmark) {
|
|
235
296
|
* @returns {string} HTML string
|
236
297
|
*/
|
237
298
|
function processToggle(toggle) {
|
238
|
-
const content = processRichText(toggle.rich_text, "div", "")
|
299
|
+
const content = processRichText(toggle.rich_text, "div", "")
|
239
300
|
return `
|
240
301
|
<details class="my-6">
|
241
|
-
<summary class="cursor-pointer font-semibold text-
|
302
|
+
<summary class="cursor-pointer font-semibold text-slate-700 hover:text-slate-900 text-lg leading-relaxed transition-colors duration-200">
|
242
303
|
${content}
|
243
304
|
</summary>
|
244
|
-
<div class="mt-4 pl-6 border-l-2 border-
|
305
|
+
<div class="mt-4 pl-6 border-l-2 border-slate-200">
|
245
306
|
<!-- Toggle content would go here if Notion API provided it -->
|
246
|
-
<p class="text-
|
307
|
+
<p class="text-slate-600 text-base italic">Toggle content not available in current API</p>
|
247
308
|
</div>
|
248
309
|
</details>
|
249
|
-
`.trim()
|
310
|
+
`.trim()
|
250
311
|
}
|
251
312
|
|
252
313
|
/**
|
@@ -257,24 +318,28 @@ function processToggle(toggle) {
|
|
257
318
|
* @returns {string} HTML string
|
258
319
|
*/
|
259
320
|
function processRichText(richText, tag, className) {
|
260
|
-
if (!richText || richText.length === 0) return ""
|
261
|
-
|
262
|
-
const content = richText
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
321
|
+
if (!richText || richText.length === 0) return ""
|
322
|
+
|
323
|
+
const content = richText
|
324
|
+
.map((text) => {
|
325
|
+
let result = text.plain_text
|
326
|
+
|
327
|
+
// Apply annotations
|
328
|
+
if (text.annotations.bold) result = `<strong class="font-bold">${result}</strong>`
|
329
|
+
if (text.annotations.italic) result = `<em class="italic">${result}</em>`
|
330
|
+
if (text.annotations.strikethrough) result = `<del class="line-through">${result}</del>`
|
331
|
+
if (text.annotations.code)
|
332
|
+
result = `<code class="bg-slate-100 px-2 py-1 rounded-md text-sm font-mono text-slate-800 border border-slate-200">${result}</code>`
|
333
|
+
|
334
|
+
// Apply links
|
335
|
+
if (text.href)
|
336
|
+
result = `<a href="${text.href}" class="text-indigo-600 hover:text-indigo-800 underline font-medium transition-colors duration-200" target="_blank" rel="noopener noreferrer">${result}</a>`
|
337
|
+
|
338
|
+
return result
|
339
|
+
})
|
340
|
+
.join("")
|
341
|
+
|
342
|
+
return `<${tag} class="${className}">${content}</${tag}>`
|
278
343
|
}
|
279
344
|
|
280
345
|
/**
|
@@ -284,15 +349,15 @@ function processRichText(richText, tag, className) {
|
|
284
349
|
*/
|
285
350
|
function escapeHtml(text) {
|
286
351
|
const map = {
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
'"':
|
291
|
-
"'":
|
292
|
-
}
|
293
|
-
return text.replace(/[&<>"']/g, m => map[m])
|
352
|
+
"&": "&",
|
353
|
+
"<": "<",
|
354
|
+
">": ">",
|
355
|
+
'"': """,
|
356
|
+
"'": "'",
|
357
|
+
}
|
358
|
+
return text.replace(/[&<>"']/g, (m) => map[m])
|
294
359
|
}
|
295
360
|
|
296
361
|
module.exports = {
|
297
|
-
getPostBySlug
|
362
|
+
getPostBySlug,
|
298
363
|
}
|