@fullstackdatasolutions/articles 0.4.3 → 0.5.0
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 +119 -2
- package/dist/index.cjs +182 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -1
- package/dist/index.d.ts +50 -1
- package/dist/index.js +241 -110
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +221 -188
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +36 -1
- package/dist/server.d.ts +36 -1
- package/dist/server.js +219 -188
- package/dist/server.js.map +1 -1
- package/package.json +5 -1
- package/src/ArticleContent.tsx +17 -0
- package/src/ArticleSchemas.tsx +97 -1
- package/src/ArticleSocialShare.tsx +1 -1
- package/src/ArticleTOC.tsx +29 -0
- package/src/ScrollToTop.tsx +25 -0
- package/src/__tests__/ArticleContent.test.tsx +91 -0
- package/src/__tests__/ArticleSchemas.test.tsx +212 -1
- package/src/__tests__/ArticleSocialShare.test.tsx +7 -7
- package/src/__tests__/ArticleTOC.test.tsx +75 -0
- package/src/__tests__/ScrollToTop.test.tsx +87 -0
- package/src/__tests__/seoUtils.test.ts +256 -0
- package/src/__tests__/server-articles.test.ts +98 -0
- package/src/articleTypes.ts +26 -0
- package/src/articlesConfig.ts +4 -0
- package/src/index.ts +10 -2
- package/src/markdown.ts +143 -130
- package/src/seoUtils.ts +20 -14
- package/src/server-articles.ts +81 -78
- package/src/server.ts +3 -2
package/README.md
CHANGED
|
@@ -134,6 +134,8 @@ Article body in Markdown...
|
|
|
134
134
|
| `categoryDescriptions` | `Record<string, string \| CategoryDescription>` | — | Short/long text per category slug. |
|
|
135
135
|
| `hero` | `HeroConfig` | — | Hero section title and description. Omit to use built-in defaults. |
|
|
136
136
|
| `comments` | `CommentsConfig` | — | Comments feature config. Omit to disable entirely. |
|
|
137
|
+
| `showToc` | `boolean` | `true` | Show the table of contents on article detail pages. Set to `false` to hide on all articles. |
|
|
138
|
+
| `description` | `string` | — | Short description used as the RSS feed channel description. Falls back to `siteName` if omitted. |
|
|
137
139
|
|
|
138
140
|
**Default layout order:** `['hero', 'search', 'featured', 'latest', 'categories']`
|
|
139
141
|
|
|
@@ -248,6 +250,44 @@ const { previous, next } = await getAdjacentArticles(slug)
|
|
|
248
250
|
|
|
249
251
|
---
|
|
250
252
|
|
|
253
|
+
## ArticleTOC
|
|
254
|
+
|
|
255
|
+
`ArticleTOC` renders the article's table of contents as an inline nav block above article content. Server component (no `'use client'`). Returns `null` when the `toc` array is empty, so it is safe to render unconditionally.
|
|
256
|
+
|
|
257
|
+
```tsx
|
|
258
|
+
import { ArticleTOC } from '@fullstackdatasolutions/articles'
|
|
259
|
+
|
|
260
|
+
<ArticleTOC toc={article.toc} />
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
| Prop | Type | Required | Description |
|
|
264
|
+
|---|---|---|---|
|
|
265
|
+
| `toc` | `TocItem[]` | yes | Array of table of contents items from the article |
|
|
266
|
+
| `className` | `string` | no | Optional CSS class to add to the nav wrapper |
|
|
267
|
+
|
|
268
|
+
- Indentation: h2=0rem, h3=1rem, h4=2rem (based on `item.depth - 2`)
|
|
269
|
+
- Returns `null` when `toc` is empty
|
|
270
|
+
- Gate used in article detail pages: `siteConfig.showToc !== false && article.toc && article.toc.length > 0`
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## ScrollToTop
|
|
275
|
+
|
|
276
|
+
`ScrollToTop` is a fixed-position floating button in the bottom-right corner that appears when the user scrolls past 300px, and smooth-scrolls back to the top on click. Client component (`'use client'`). No props required - fully self-contained, safe to place anywhere in your layout.
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
import { ScrollToTop } from '@fullstackdatasolutions/articles'
|
|
280
|
+
|
|
281
|
+
<ScrollToTop />
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
- No props
|
|
285
|
+
- Appears only after 300px of vertical scroll
|
|
286
|
+
- Smooth scroll animation
|
|
287
|
+
- Fixed position, bottom-right corner
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
251
291
|
## Comments setup
|
|
252
292
|
|
|
253
293
|
### 1. Enable in config
|
|
@@ -289,8 +329,9 @@ import { generateArticlesIndexMetadata } from '@fullstackdatasolutions/articles/
|
|
|
289
329
|
import { siteConfig } from '@/config/articles'
|
|
290
330
|
export const metadata: Metadata = generateArticlesIndexMetadata(siteConfig)
|
|
291
331
|
// Produces: title, description, full OpenGraph (type: 'website'), Twitter Card,
|
|
292
|
-
// canonical URL (/articles), and
|
|
332
|
+
// canonical URL (/articles), robots directives, and RSS feed alternate link.
|
|
293
333
|
// Description falls back to config.hero?.description when set.
|
|
334
|
+
// Automatically emits <link rel="alternate" type="application/rss+xml"> pointing to /articles/feed.xml
|
|
294
335
|
```
|
|
295
336
|
|
|
296
337
|
### Article and category metadata
|
|
@@ -308,6 +349,75 @@ export const generateMetadata = ({ params }) =>
|
|
|
308
349
|
generateCategoryMetadata(params.category, siteConfig)
|
|
309
350
|
```
|
|
310
351
|
|
|
352
|
+
### RSS feed
|
|
353
|
+
|
|
354
|
+
To enable RSS 2.0 feed generation at `/articles/feed.xml`, copy this route file into your consuming app:
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
// app/articles/feed.xml/route.ts
|
|
358
|
+
import { getAllArticles } from '@fullstackdatasolutions/articles/server'
|
|
359
|
+
import { siteConfig } from '@/config/articles'
|
|
360
|
+
|
|
361
|
+
export const dynamic = 'force-static'
|
|
362
|
+
|
|
363
|
+
function escapeXml(str: string): string {
|
|
364
|
+
return str
|
|
365
|
+
.replaceAll('&', '&')
|
|
366
|
+
.replaceAll('<', '<')
|
|
367
|
+
.replaceAll('>', '>')
|
|
368
|
+
.replaceAll('"', '"')
|
|
369
|
+
.replaceAll("'", ''')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function GET() {
|
|
373
|
+
const siteUrl = siteConfig.siteUrl.replace(/\/$/, '')
|
|
374
|
+
const articles = await getAllArticles()
|
|
375
|
+
|
|
376
|
+
const items = articles
|
|
377
|
+
.map((article) => {
|
|
378
|
+
const url = `${siteUrl}/articles/${article.slug}`
|
|
379
|
+
const pubDate = article.date ? new Date(article.date).toUTCString() : ''
|
|
380
|
+
return [
|
|
381
|
+
' <item>',
|
|
382
|
+
` <title><![CDATA[${article.title}]]></title>`,
|
|
383
|
+
` <link>${url}</link>`,
|
|
384
|
+
` <guid isPermaLink="true">${url}</guid>`,
|
|
385
|
+
pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
|
|
386
|
+
article.excerpt ? ` <description><![CDATA[${article.excerpt}]]></description>` : '',
|
|
387
|
+
article.author ? ` <author>${escapeXml(article.author)}</author>` : '',
|
|
388
|
+
' </item>',
|
|
389
|
+
]
|
|
390
|
+
.filter(Boolean)
|
|
391
|
+
.join('\n')
|
|
392
|
+
})
|
|
393
|
+
.join('\n')
|
|
394
|
+
|
|
395
|
+
const description = siteConfig.description ?? `${siteConfig.siteName} articles`
|
|
396
|
+
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
397
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
398
|
+
<channel>
|
|
399
|
+
<title><![CDATA[${siteConfig.siteName}]]></title>
|
|
400
|
+
<link>${siteUrl}/articles</link>
|
|
401
|
+
<description><![CDATA[${description}]]></description>
|
|
402
|
+
<language>en</language>
|
|
403
|
+
<atom:link href="${siteUrl}/articles/feed.xml" rel="self" type="application/rss+xml" />
|
|
404
|
+
${items}
|
|
405
|
+
</channel>
|
|
406
|
+
</rss>`
|
|
407
|
+
|
|
408
|
+
return new Response(xml, {
|
|
409
|
+
headers: {
|
|
410
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
411
|
+
'Cache-Control': 'public, max-age=3600',
|
|
412
|
+
},
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- Uses `force-static` for build-time generation
|
|
418
|
+
- Uses `siteConfig.description` for the channel description field (falls back to `siteName` if omitted)
|
|
419
|
+
- The `<link rel="alternate" type="application/rss+xml">` tag is added automatically to the articles index `<head>` via `generateArticlesIndexMetadata`
|
|
420
|
+
|
|
311
421
|
### Sitemaps
|
|
312
422
|
|
|
313
423
|
Use `getArticleSitemapEntries` to generate article and category sitemap entries. Pass your `ArticlesConfig` object (preferred) or a plain URL string.
|
|
@@ -408,8 +518,9 @@ Paste this block into your app's `AGENTS.md` so AI assistants know how the libra
|
|
|
408
518
|
This app uses `@fullstackdatasolutions/articles` for the articles section.
|
|
409
519
|
|
|
410
520
|
### Key files
|
|
411
|
-
- `config/articles.ts` — all library behaviour (layout, theme, page size, comments) is controlled here
|
|
521
|
+
- `config/articles.ts` — all library behaviour (layout, theme, page size, comments, TOC, RSS) is controlled here
|
|
412
522
|
- `app/api/articles/` — thin API route wrappers; do not add business logic here
|
|
523
|
+
- `app/articles/feed.xml/route.ts` — RSS 2.0 feed (copy from reference implementation)
|
|
413
524
|
- `public/articles/[slug]/article.md` — one directory per article
|
|
414
525
|
|
|
415
526
|
### CSS (Tailwind v4)
|
|
@@ -418,6 +529,10 @@ The package ships its source TypeScript files so Tailwind can scan them.
|
|
|
418
529
|
@source "./node_modules/@fullstackdatasolutions/articles/src";
|
|
419
530
|
If this line is missing, most component classes will not render correctly. Note: `ArticleCategoryGrid` images, `CategoryArticlesPage` hero, and `ArticleDetailHero` image use inline styles for critical layout so they render correctly without this line. All other styling still requires it.
|
|
420
531
|
|
|
532
|
+
### Components
|
|
533
|
+
- `ArticleTOC` — server component rendering `article.toc[]` as a nav block. Controlled by `siteConfig.showToc` (defaults `true`). Returns `null` when empty.
|
|
534
|
+
- `ScrollToTop` — client component, no props. Fixed-position button appears after 300px scroll.
|
|
535
|
+
|
|
421
536
|
### Server utilities
|
|
422
537
|
For sitemaps and metadata: `import { getArticleSitemapEntries, generateArticlesIndexMetadata, generateArticleMetadata, generateCategoryMetadata } from '@fullstackdatasolutions/articles/server'`. See README for usage examples.
|
|
423
538
|
|
|
@@ -426,6 +541,8 @@ For sitemaps and metadata: `import { getArticleSitemapEntries, generateArticlesI
|
|
|
426
541
|
- When adding a new article: create `public/articles/[slug]/article.md` with title, excerpt, author, tags.
|
|
427
542
|
- API routes are copied from the library — update the package version and re-copy if the library API changes.
|
|
428
543
|
- `hero.title` and `hero.description` in `ArticlesConfig` control the hero heading — pass them to customize per app.
|
|
544
|
+
- `showToc?: boolean` (default `true`) controls TOC visibility on all article detail pages.
|
|
545
|
+
- `description?: string` sets the RSS feed channel description (falls back to `siteName`).
|
|
429
546
|
- Full library docs: `node_modules/@fullstackdatasolutions/articles/README.md`
|
|
430
547
|
```
|
|
431
548
|
|
package/dist/index.cjs
CHANGED
|
@@ -73,9 +73,11 @@ __export(src_exports, {
|
|
|
73
73
|
ArticleCategoryGrid: () => ArticleCategoryGrid,
|
|
74
74
|
ArticleDetailHero: () => ArticleDetailHero,
|
|
75
75
|
ArticleNavigation: () => ArticleNavigation,
|
|
76
|
+
ArticleSEO: () => ArticleSEO,
|
|
76
77
|
ArticleSchema: () => ArticleSchema,
|
|
77
78
|
ArticleSearchBar: () => ArticleSearchBar,
|
|
78
79
|
ArticleSocialShare: () => ArticleSocialShare,
|
|
80
|
+
ArticleTOC: () => ArticleTOC,
|
|
79
81
|
ArticlesHero: () => ArticlesHero,
|
|
80
82
|
ArticlesPage: () => ArticlesPage,
|
|
81
83
|
BreadcrumbSchema: () => BreadcrumbSchema,
|
|
@@ -88,9 +90,11 @@ __export(src_exports, {
|
|
|
88
90
|
DEFAULT_CATEGORIES_PAGE_SIZE: () => DEFAULT_CATEGORIES_PAGE_SIZE,
|
|
89
91
|
DEFAULT_LAYOUT: () => DEFAULT_LAYOUT,
|
|
90
92
|
DEFAULT_PAGE_SIZE: () => DEFAULT_PAGE_SIZE,
|
|
93
|
+
FAQPageSchema: () => FAQPageSchema,
|
|
91
94
|
FeaturedArticle: () => FeaturedArticle,
|
|
92
95
|
LatestArticles: () => LatestArticles,
|
|
93
96
|
LatestArticlesSection: () => LatestArticlesSection,
|
|
97
|
+
ScrollToTop: () => ScrollToTop,
|
|
94
98
|
useArticles: () => useArticles
|
|
95
99
|
});
|
|
96
100
|
module.exports = __toCommonJS(src_exports);
|
|
@@ -186,6 +190,86 @@ function BreadcrumbSchema({ items }) {
|
|
|
186
190
|
}
|
|
187
191
|
);
|
|
188
192
|
}
|
|
193
|
+
function ArticleSEO({ article, articleUrl, siteName, siteLogo }) {
|
|
194
|
+
const publishedAt = article.date ? new Date(article.date).toISOString() : void 0;
|
|
195
|
+
const modifiedAt = article.lastmod ? new Date(article.lastmod).toISOString() : publishedAt;
|
|
196
|
+
const structuredData = __spreadValues(__spreadProps(__spreadValues(__spreadValues({
|
|
197
|
+
"@context": "https://schema.org",
|
|
198
|
+
"@type": article.articleType || "Article",
|
|
199
|
+
mainEntityOfPage: { "@type": "WebPage", "@id": articleUrl },
|
|
200
|
+
headline: article.title,
|
|
201
|
+
image: article.featuredImage ? { "@type": "ImageObject", url: article.featuredImage } : void 0
|
|
202
|
+
}, publishedAt && { datePublished: publishedAt }), modifiedAt && { dateModified: modifiedAt }), {
|
|
203
|
+
author: { "@type": "Person", name: article.author },
|
|
204
|
+
publisher: __spreadValues({
|
|
205
|
+
"@type": "Organization",
|
|
206
|
+
name: siteName
|
|
207
|
+
}, siteLogo && { logo: { "@type": "ImageObject", url: siteLogo } }),
|
|
208
|
+
description: article.excerpt
|
|
209
|
+
}), article.series && { isPartOf: { "@type": "Blog", name: article.series } });
|
|
210
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
|
|
211
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
212
|
+
"script",
|
|
213
|
+
{
|
|
214
|
+
type: "application/ld+json",
|
|
215
|
+
dangerouslySetInnerHTML: { __html: JSON.stringify(structuredData) }
|
|
216
|
+
}
|
|
217
|
+
),
|
|
218
|
+
article.faq && article.faq.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
219
|
+
"script",
|
|
220
|
+
{
|
|
221
|
+
type: "application/ld+json",
|
|
222
|
+
dangerouslySetInnerHTML: {
|
|
223
|
+
__html: JSON.stringify({
|
|
224
|
+
"@context": "https://schema.org",
|
|
225
|
+
"@type": "FAQPage",
|
|
226
|
+
mainEntity: article.faq.map(({ question, answer }) => ({
|
|
227
|
+
"@type": "Question",
|
|
228
|
+
name: question,
|
|
229
|
+
acceptedAnswer: { "@type": "Answer", text: answer }
|
|
230
|
+
}))
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
),
|
|
235
|
+
article.howTo && article.howTo.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
236
|
+
"script",
|
|
237
|
+
{
|
|
238
|
+
type: "application/ld+json",
|
|
239
|
+
dangerouslySetInnerHTML: {
|
|
240
|
+
__html: JSON.stringify({
|
|
241
|
+
"@context": "https://schema.org",
|
|
242
|
+
"@type": "HowTo",
|
|
243
|
+
name: article.title,
|
|
244
|
+
step: article.howTo.map(({ name, text }) => ({
|
|
245
|
+
"@type": "HowToStep",
|
|
246
|
+
name,
|
|
247
|
+
text
|
|
248
|
+
}))
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
] });
|
|
254
|
+
}
|
|
255
|
+
function FAQPageSchema({ items }) {
|
|
256
|
+
const schema = {
|
|
257
|
+
"@context": "https://schema.org",
|
|
258
|
+
"@type": "FAQPage",
|
|
259
|
+
mainEntity: items.map(({ question, answer }) => ({
|
|
260
|
+
"@type": "Question",
|
|
261
|
+
name: question,
|
|
262
|
+
acceptedAnswer: { "@type": "Answer", text: answer }
|
|
263
|
+
}))
|
|
264
|
+
};
|
|
265
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
266
|
+
"script",
|
|
267
|
+
{
|
|
268
|
+
type: "application/ld+json",
|
|
269
|
+
dangerouslySetInnerHTML: { __html: JSON.stringify(schema) }
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
}
|
|
189
273
|
function CollectionPageSchema({
|
|
190
274
|
title,
|
|
191
275
|
description,
|
|
@@ -908,7 +992,7 @@ function ArticleSocialShare({ title, url, excerpt, shareMessage }) {
|
|
|
908
992
|
}
|
|
909
993
|
});
|
|
910
994
|
const openShareWindow = (shareUrl) => {
|
|
911
|
-
|
|
995
|
+
globalThis.open(shareUrl, "_blank", "width=600,height=400,scrollbars=yes,resizable=yes");
|
|
912
996
|
};
|
|
913
997
|
const btnClass = "inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground hover:bg-primary/10 hover:border-primary hover:text-primary transition-colors cursor-pointer";
|
|
914
998
|
return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "border-t border-border pt-8 mt-8", children: [
|
|
@@ -1004,13 +1088,60 @@ function ArticleNavigation({ previous, next, basePath }) {
|
|
|
1004
1088
|
] }) });
|
|
1005
1089
|
}
|
|
1006
1090
|
|
|
1091
|
+
// src/ArticleTOC.tsx
|
|
1092
|
+
var import_jsx_runtime14 = require("react/jsx-runtime");
|
|
1093
|
+
function ArticleTOC({ toc, className }) {
|
|
1094
|
+
if (!toc.length) return null;
|
|
1095
|
+
return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
|
|
1096
|
+
"nav",
|
|
1097
|
+
{
|
|
1098
|
+
"aria-label": "Table of contents",
|
|
1099
|
+
className: `mb-8 rounded-lg border border-border bg-muted/40 px-6 py-4 ${className != null ? className : ""}`,
|
|
1100
|
+
children: [
|
|
1101
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)("p", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "On this page" }),
|
|
1102
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)("ul", { className: "space-y-1 text-sm", children: toc.map((item) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("li", { style: { paddingLeft: `${Math.max(0, item.depth - 2) * 1}rem` }, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
|
|
1103
|
+
"a",
|
|
1104
|
+
{
|
|
1105
|
+
href: `#${item.id}`,
|
|
1106
|
+
className: "text-muted-foreground hover:text-foreground transition-colors",
|
|
1107
|
+
children: item.text
|
|
1108
|
+
}
|
|
1109
|
+
) }, item.id)) })
|
|
1110
|
+
]
|
|
1111
|
+
}
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// src/ScrollToTop.tsx
|
|
1116
|
+
var import_react5 = require("react");
|
|
1117
|
+
var import_lucide_react8 = require("lucide-react");
|
|
1118
|
+
var import_jsx_runtime15 = require("react/jsx-runtime");
|
|
1119
|
+
function ScrollToTop() {
|
|
1120
|
+
const [visible, setVisible] = (0, import_react5.useState)(false);
|
|
1121
|
+
(0, import_react5.useEffect)(() => {
|
|
1122
|
+
const onScroll = () => setVisible(globalThis.scrollY > 300);
|
|
1123
|
+
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
|
1124
|
+
return () => globalThis.removeEventListener("scroll", onScroll);
|
|
1125
|
+
}, []);
|
|
1126
|
+
if (!visible) return null;
|
|
1127
|
+
return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
1128
|
+
"button",
|
|
1129
|
+
{
|
|
1130
|
+
"aria-label": "Scroll to top",
|
|
1131
|
+
onClick: () => globalThis.scrollTo({ top: 0, behavior: "smooth" }),
|
|
1132
|
+
className: "fixed bottom-8 right-8 z-50 rounded-full bg-primary p-2 shadow-md text-primary-foreground hover:bg-primary/90 transition-colors",
|
|
1133
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react8.ArrowUp, { className: "h-5 w-5" })
|
|
1134
|
+
}
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1007
1138
|
// src/CommentsSection.tsx
|
|
1008
|
-
var
|
|
1009
|
-
var
|
|
1139
|
+
var import_react8 = require("next-auth/react");
|
|
1140
|
+
var import_react9 = require("react");
|
|
1010
1141
|
|
|
1011
1142
|
// src/CommentForm.tsx
|
|
1012
|
-
var
|
|
1013
|
-
var
|
|
1143
|
+
var import_react6 = require("react");
|
|
1144
|
+
var import_jsx_runtime16 = require("react/jsx-runtime");
|
|
1014
1145
|
var MAX_LENGTH = 2e3;
|
|
1015
1146
|
var WARN_THRESHOLD = 1800;
|
|
1016
1147
|
function submitLabel(submitting, parentId) {
|
|
@@ -1018,9 +1149,9 @@ function submitLabel(submitting, parentId) {
|
|
|
1018
1149
|
return parentId ? "Reply" : "Post comment";
|
|
1019
1150
|
}
|
|
1020
1151
|
function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
|
|
1021
|
-
const [body, setBody] = (0,
|
|
1022
|
-
const [submitting, setSubmitting] = (0,
|
|
1023
|
-
const [error, setError] = (0,
|
|
1152
|
+
const [body, setBody] = (0, import_react6.useState)("");
|
|
1153
|
+
const [submitting, setSubmitting] = (0, import_react6.useState)(false);
|
|
1154
|
+
const [error, setError] = (0, import_react6.useState)(null);
|
|
1024
1155
|
const remaining = MAX_LENGTH - body.length;
|
|
1025
1156
|
function handleSubmit(e) {
|
|
1026
1157
|
return __async(this, null, function* () {
|
|
@@ -1049,8 +1180,8 @@ function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
|
|
|
1049
1180
|
}
|
|
1050
1181
|
});
|
|
1051
1182
|
}
|
|
1052
|
-
return /* @__PURE__ */ (0,
|
|
1053
|
-
/* @__PURE__ */ (0,
|
|
1183
|
+
return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("form", { onSubmit: handleSubmit, className: "space-y-2", children: [
|
|
1184
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
1054
1185
|
"textarea",
|
|
1055
1186
|
{
|
|
1056
1187
|
value: body,
|
|
@@ -1063,13 +1194,13 @@ function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
|
|
|
1063
1194
|
"aria-label": parentId ? "Reply text" : "Comment text"
|
|
1064
1195
|
}
|
|
1065
1196
|
),
|
|
1066
|
-
remaining <= MAX_LENGTH - WARN_THRESHOLD && /* @__PURE__ */ (0,
|
|
1197
|
+
remaining <= MAX_LENGTH - WARN_THRESHOLD && /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("p", { className: `text-xs ${remaining < 0 ? "text-destructive" : "text-muted-foreground"}`, children: [
|
|
1067
1198
|
remaining,
|
|
1068
1199
|
" characters remaining"
|
|
1069
1200
|
] }),
|
|
1070
|
-
error && /* @__PURE__ */ (0,
|
|
1071
|
-
/* @__PURE__ */ (0,
|
|
1072
|
-
/* @__PURE__ */ (0,
|
|
1201
|
+
error && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "text-xs text-destructive", children: error }),
|
|
1202
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "flex gap-2", children: [
|
|
1203
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
1073
1204
|
"button",
|
|
1074
1205
|
{
|
|
1075
1206
|
type: "submit",
|
|
@@ -1078,7 +1209,7 @@ function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
|
|
|
1078
1209
|
children: submitLabel(submitting, parentId)
|
|
1079
1210
|
}
|
|
1080
1211
|
),
|
|
1081
|
-
onCancel && /* @__PURE__ */ (0,
|
|
1212
|
+
onCancel && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
1082
1213
|
"button",
|
|
1083
1214
|
{
|
|
1084
1215
|
type: "button",
|
|
@@ -1092,8 +1223,8 @@ function CommentForm({ articleSlug, parentId, onSuccess, onCancel }) {
|
|
|
1092
1223
|
}
|
|
1093
1224
|
|
|
1094
1225
|
// src/CommentItem.tsx
|
|
1095
|
-
var
|
|
1096
|
-
var
|
|
1226
|
+
var import_react7 = require("react");
|
|
1227
|
+
var import_jsx_runtime17 = require("react/jsx-runtime");
|
|
1097
1228
|
function relativeTime(iso) {
|
|
1098
1229
|
const diff = Date.now() - new Date(iso).getTime();
|
|
1099
1230
|
const minutes = Math.floor(diff / 6e4);
|
|
@@ -1114,8 +1245,8 @@ function CommentItem({
|
|
|
1114
1245
|
isReply = false,
|
|
1115
1246
|
onRefresh
|
|
1116
1247
|
}) {
|
|
1117
|
-
const [showReplyForm, setShowReplyForm] = (0,
|
|
1118
|
-
const [deleting, setDeleting] = (0,
|
|
1248
|
+
const [showReplyForm, setShowReplyForm] = (0, import_react7.useState)(false);
|
|
1249
|
+
const [deleting, setDeleting] = (0, import_react7.useState)(false);
|
|
1119
1250
|
const replies = "replies" in comment ? comment.replies : [];
|
|
1120
1251
|
const canReply = !isReply && !!currentUserId && !comment.isDeleted;
|
|
1121
1252
|
const canDelete = !!currentUserId && currentUserId === comment.authorId && !comment.isDeleted;
|
|
@@ -1131,15 +1262,15 @@ function CommentItem({
|
|
|
1131
1262
|
}
|
|
1132
1263
|
});
|
|
1133
1264
|
}
|
|
1134
|
-
return /* @__PURE__ */ (0,
|
|
1135
|
-
/* @__PURE__ */ (0,
|
|
1136
|
-
/* @__PURE__ */ (0,
|
|
1137
|
-
/* @__PURE__ */ (0,
|
|
1138
|
-
/* @__PURE__ */ (0,
|
|
1265
|
+
return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: `${isReply ? "ml-8 border-l-2 border-border pl-4" : ""}`, children: [
|
|
1266
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "rounded-lg bg-muted/40 p-3 space-y-1", children: comment.isDeleted ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { className: "text-sm italic text-muted-foreground", children: "Comment removed" }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(import_jsx_runtime17.Fragment, { children: [
|
|
1267
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex items-center justify-between gap-2", children: [
|
|
1268
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-sm font-medium text-foreground", children: comment.authorName }),
|
|
1269
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-xs text-muted-foreground", children: relativeTime(comment.createdAt) })
|
|
1139
1270
|
] }),
|
|
1140
|
-
/* @__PURE__ */ (0,
|
|
1141
|
-
/* @__PURE__ */ (0,
|
|
1142
|
-
canReply && /* @__PURE__ */ (0,
|
|
1271
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { className: "text-sm text-foreground whitespace-pre-wrap break-words", children: comment.body }),
|
|
1272
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex gap-3 pt-1", children: [
|
|
1273
|
+
canReply && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1143
1274
|
"button",
|
|
1144
1275
|
{
|
|
1145
1276
|
onClick: () => setShowReplyForm((v) => !v),
|
|
@@ -1147,7 +1278,7 @@ function CommentItem({
|
|
|
1147
1278
|
children: showReplyForm ? "Cancel" : "Reply"
|
|
1148
1279
|
}
|
|
1149
1280
|
),
|
|
1150
|
-
canDelete && /* @__PURE__ */ (0,
|
|
1281
|
+
canDelete && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1151
1282
|
"button",
|
|
1152
1283
|
{
|
|
1153
1284
|
onClick: handleDelete,
|
|
@@ -1158,7 +1289,7 @@ function CommentItem({
|
|
|
1158
1289
|
)
|
|
1159
1290
|
] })
|
|
1160
1291
|
] }) }),
|
|
1161
|
-
showReplyForm && /* @__PURE__ */ (0,
|
|
1292
|
+
showReplyForm && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "mt-2 ml-2", children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1162
1293
|
CommentForm,
|
|
1163
1294
|
{
|
|
1164
1295
|
articleSlug,
|
|
@@ -1170,7 +1301,7 @@ function CommentItem({
|
|
|
1170
1301
|
onCancel: () => setShowReplyForm(false)
|
|
1171
1302
|
}
|
|
1172
1303
|
) }),
|
|
1173
|
-
replies.length > 0 && /* @__PURE__ */ (0,
|
|
1304
|
+
replies.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "mt-2 space-y-2", children: replies.map((reply) => /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
1174
1305
|
CommentItem,
|
|
1175
1306
|
{
|
|
1176
1307
|
comment: __spreadProps(__spreadValues({}, reply), { replies: [] }),
|
|
@@ -1185,7 +1316,7 @@ function CommentItem({
|
|
|
1185
1316
|
}
|
|
1186
1317
|
|
|
1187
1318
|
// src/CommentThread.tsx
|
|
1188
|
-
var
|
|
1319
|
+
var import_jsx_runtime18 = require("react/jsx-runtime");
|
|
1189
1320
|
function CommentThread({
|
|
1190
1321
|
comments,
|
|
1191
1322
|
articleSlug,
|
|
@@ -1193,9 +1324,9 @@ function CommentThread({
|
|
|
1193
1324
|
onRefresh
|
|
1194
1325
|
}) {
|
|
1195
1326
|
if (comments.length === 0) {
|
|
1196
|
-
return /* @__PURE__ */ (0,
|
|
1327
|
+
return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { className: "text-sm text-muted-foreground text-center py-6", children: "No comments yet. Be the first to share your thoughts." });
|
|
1197
1328
|
}
|
|
1198
|
-
return /* @__PURE__ */ (0,
|
|
1329
|
+
return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "space-y-4", children: comments.map((comment) => /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
1199
1330
|
CommentItem,
|
|
1200
1331
|
{
|
|
1201
1332
|
comment,
|
|
@@ -1208,17 +1339,17 @@ function CommentThread({
|
|
|
1208
1339
|
}
|
|
1209
1340
|
|
|
1210
1341
|
// src/CommentsSection.tsx
|
|
1211
|
-
var
|
|
1342
|
+
var import_jsx_runtime19 = require("react/jsx-runtime");
|
|
1212
1343
|
function CommentsSection({ articleSlug, config }) {
|
|
1213
1344
|
var _a, _b, _c, _d, _e, _f;
|
|
1214
|
-
const { data: session, status } = (0,
|
|
1215
|
-
const [comments, setComments] = (0,
|
|
1216
|
-
const [loading, setLoading] = (0,
|
|
1217
|
-
const [fetchError, setFetchError] = (0,
|
|
1345
|
+
const { data: session, status } = (0, import_react8.useSession)();
|
|
1346
|
+
const [comments, setComments] = (0, import_react9.useState)([]);
|
|
1347
|
+
const [loading, setLoading] = (0, import_react9.useState)(true);
|
|
1348
|
+
const [fetchError, setFetchError] = (0, import_react9.useState)(null);
|
|
1218
1349
|
const perArticleEnabled = (_b = (_a = config.perArticleOverride) == null ? void 0 : _a[articleSlug]) != null ? _b : true;
|
|
1219
1350
|
const enabled = config.enabled && perArticleEnabled;
|
|
1220
1351
|
const currentUserId = (_f = (_e = (_c = session == null ? void 0 : session.user) == null ? void 0 : _c.email) != null ? _e : (_d = session == null ? void 0 : session.user) == null ? void 0 : _d.name) != null ? _f : void 0;
|
|
1221
|
-
const fetchComments = (0,
|
|
1352
|
+
const fetchComments = (0, import_react9.useCallback)(() => __async(this, null, function* () {
|
|
1222
1353
|
setLoading(true);
|
|
1223
1354
|
setFetchError(null);
|
|
1224
1355
|
try {
|
|
@@ -1232,7 +1363,7 @@ function CommentsSection({ articleSlug, config }) {
|
|
|
1232
1363
|
setLoading(false);
|
|
1233
1364
|
}
|
|
1234
1365
|
}), [articleSlug]);
|
|
1235
|
-
(0,
|
|
1366
|
+
(0, import_react9.useEffect)(() => {
|
|
1236
1367
|
if (enabled) {
|
|
1237
1368
|
fetchComments().catch(() => void 0);
|
|
1238
1369
|
}
|
|
@@ -1240,13 +1371,13 @@ function CommentsSection({ articleSlug, config }) {
|
|
|
1240
1371
|
if (!enabled) return null;
|
|
1241
1372
|
const count = comments.length;
|
|
1242
1373
|
const label = count === 1 ? "1 comment" : `${count} comments`;
|
|
1243
|
-
return /* @__PURE__ */ (0,
|
|
1244
|
-
/* @__PURE__ */ (0,
|
|
1245
|
-
status === "authenticated" ? /* @__PURE__ */ (0,
|
|
1246
|
-
/* @__PURE__ */ (0,
|
|
1374
|
+
return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("section", { "aria-label": "Comments", className: "mt-12 pt-8 border-t border-border space-y-6", children: [
|
|
1375
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("h2", { className: "text-xl font-semibold text-foreground", children: loading ? "Comments" : label }),
|
|
1376
|
+
status === "authenticated" ? /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(CommentForm, { articleSlug, onSuccess: fetchComments }) : /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("p", { className: "text-sm text-muted-foreground", children: [
|
|
1377
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
|
|
1247
1378
|
"button",
|
|
1248
1379
|
{
|
|
1249
|
-
onClick: () => (0,
|
|
1380
|
+
onClick: () => (0, import_react8.signIn)(),
|
|
1250
1381
|
className: "text-primary underline hover:no-underline focus:outline-none",
|
|
1251
1382
|
children: "Sign in"
|
|
1252
1383
|
}
|
|
@@ -1254,8 +1385,8 @@ function CommentsSection({ articleSlug, config }) {
|
|
|
1254
1385
|
" ",
|
|
1255
1386
|
"to join the discussion."
|
|
1256
1387
|
] }),
|
|
1257
|
-
fetchError && /* @__PURE__ */ (0,
|
|
1258
|
-
loading ? /* @__PURE__ */ (0,
|
|
1388
|
+
fetchError && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("p", { className: "text-sm text-destructive", children: fetchError }),
|
|
1389
|
+
loading ? /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("p", { className: "text-sm text-muted-foreground", children: "Loading comments\u2026" }) : /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
|
|
1259
1390
|
CommentThread,
|
|
1260
1391
|
{
|
|
1261
1392
|
comments,
|
|
@@ -1272,9 +1403,11 @@ function CommentsSection({ articleSlug, config }) {
|
|
|
1272
1403
|
ArticleCategoryGrid,
|
|
1273
1404
|
ArticleDetailHero,
|
|
1274
1405
|
ArticleNavigation,
|
|
1406
|
+
ArticleSEO,
|
|
1275
1407
|
ArticleSchema,
|
|
1276
1408
|
ArticleSearchBar,
|
|
1277
1409
|
ArticleSocialShare,
|
|
1410
|
+
ArticleTOC,
|
|
1278
1411
|
ArticlesHero,
|
|
1279
1412
|
ArticlesPage,
|
|
1280
1413
|
BreadcrumbSchema,
|
|
@@ -1287,9 +1420,11 @@ function CommentsSection({ articleSlug, config }) {
|
|
|
1287
1420
|
DEFAULT_CATEGORIES_PAGE_SIZE,
|
|
1288
1421
|
DEFAULT_LAYOUT,
|
|
1289
1422
|
DEFAULT_PAGE_SIZE,
|
|
1423
|
+
FAQPageSchema,
|
|
1290
1424
|
FeaturedArticle,
|
|
1291
1425
|
LatestArticles,
|
|
1292
1426
|
LatestArticlesSection,
|
|
1427
|
+
ScrollToTop,
|
|
1293
1428
|
useArticles
|
|
1294
1429
|
});
|
|
1295
1430
|
//# sourceMappingURL=index.cjs.map
|