@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 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 robots directives.
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('&', '&amp;')
366
+ .replaceAll('<', '&lt;')
367
+ .replaceAll('>', '&gt;')
368
+ .replaceAll('"', '&quot;')
369
+ .replaceAll("'", '&apos;')
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
- window.open(shareUrl, "_blank", "width=600,height=400,scrollbars=yes,resizable=yes");
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 import_react7 = require("next-auth/react");
1009
- var import_react8 = require("react");
1139
+ var import_react8 = require("next-auth/react");
1140
+ var import_react9 = require("react");
1010
1141
 
1011
1142
  // src/CommentForm.tsx
1012
- var import_react5 = require("react");
1013
- var import_jsx_runtime14 = require("react/jsx-runtime");
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, import_react5.useState)("");
1022
- const [submitting, setSubmitting] = (0, import_react5.useState)(false);
1023
- const [error, setError] = (0, import_react5.useState)(null);
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, import_jsx_runtime14.jsxs)("form", { onSubmit: handleSubmit, className: "space-y-2", children: [
1053
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
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, import_jsx_runtime14.jsxs)("p", { className: `text-xs ${remaining < 0 ? "text-destructive" : "text-muted-foreground"}`, children: [
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, import_jsx_runtime14.jsx)("p", { className: "text-xs text-destructive", children: error }),
1071
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex gap-2", children: [
1072
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
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, import_jsx_runtime14.jsx)(
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 import_react6 = require("react");
1096
- var import_jsx_runtime15 = require("react/jsx-runtime");
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, import_react6.useState)(false);
1118
- const [deleting, setDeleting] = (0, import_react6.useState)(false);
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, import_jsx_runtime15.jsxs)("div", { className: `${isReply ? "ml-8 border-l-2 border-border pl-4" : ""}`, children: [
1135
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "rounded-lg bg-muted/40 p-3 space-y-1", children: comment.isDeleted ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("p", { className: "text-sm italic text-muted-foreground", children: "Comment removed" }) : /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(import_jsx_runtime15.Fragment, { children: [
1136
- /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "flex items-center justify-between gap-2", children: [
1137
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "text-sm font-medium text-foreground", children: comment.authorName }),
1138
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "text-xs text-muted-foreground", children: relativeTime(comment.createdAt) })
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, import_jsx_runtime15.jsx)("p", { className: "text-sm text-foreground whitespace-pre-wrap break-words", children: comment.body }),
1141
- /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "flex gap-3 pt-1", children: [
1142
- canReply && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
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, import_jsx_runtime15.jsx)(
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, import_jsx_runtime15.jsx)("div", { className: "mt-2 ml-2", children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
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, import_jsx_runtime15.jsx)("div", { className: "mt-2 space-y-2", children: replies.map((reply) => /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
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 import_jsx_runtime16 = require("react/jsx-runtime");
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, import_jsx_runtime16.jsx)("p", { className: "text-sm text-muted-foreground text-center py-6", children: "No comments yet. Be the first to share your thoughts." });
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, import_jsx_runtime16.jsx)("div", { className: "space-y-4", children: comments.map((comment) => /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
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 import_jsx_runtime17 = require("react/jsx-runtime");
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, import_react7.useSession)();
1215
- const [comments, setComments] = (0, import_react8.useState)([]);
1216
- const [loading, setLoading] = (0, import_react8.useState)(true);
1217
- const [fetchError, setFetchError] = (0, import_react8.useState)(null);
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, import_react8.useCallback)(() => __async(this, null, function* () {
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, import_react8.useEffect)(() => {
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, import_jsx_runtime17.jsxs)("section", { "aria-label": "Comments", className: "mt-12 pt-8 border-t border-border space-y-6", children: [
1244
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("h2", { className: "text-xl font-semibold text-foreground", children: loading ? "Comments" : label }),
1245
- status === "authenticated" ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(CommentForm, { articleSlug, onSuccess: fetchComments }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("p", { className: "text-sm text-muted-foreground", children: [
1246
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
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, import_react7.signIn)(),
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, import_jsx_runtime17.jsx)("p", { className: "text-sm text-destructive", children: fetchError }),
1258
- loading ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { className: "text-sm text-muted-foreground", children: "Loading comments\u2026" }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
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