@m13v/seo-components 0.7.2 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/seo-components",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "39 animated React components for programmatic SEO pages. Remotion video, Magic UI style animations, trust signals, JSON-LD helpers. Teal/cyan brand, light-theme only.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { usePathname } from "next/navigation";
5
+
6
+ function slugify(text: string): string {
7
+ return text
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9]+/g, "-")
10
+ .replace(/^-+|-+$/g, "")
11
+ .slice(0, 80);
12
+ }
13
+
14
+ /**
15
+ * Walks every H2 inside the nearest <article> on mount and sets an `id`
16
+ * attribute derived from the heading text. On initial load, if the URL
17
+ * has a hash, scrolls to the matching heading once IDs are attached.
18
+ *
19
+ * This keeps the sidebar ToC clickable without requiring every page
20
+ * author to add id attributes manually.
21
+ */
22
+ export function HeadingAnchors() {
23
+ const pathname = usePathname();
24
+
25
+ useEffect(() => {
26
+ const article = document.querySelector("article");
27
+ if (!article) return;
28
+
29
+ const headings = article.querySelectorAll<HTMLHeadingElement>("h2");
30
+ const seen = new Set<string>();
31
+ headings.forEach((h) => {
32
+ const text = (h.textContent || "").trim();
33
+ if (!text) return;
34
+ const base = slugify(text);
35
+ if (!base) return;
36
+ let id = base;
37
+ let i = 2;
38
+ while (seen.has(id)) {
39
+ id = `${base}-${i++}`;
40
+ }
41
+ seen.add(id);
42
+ h.id = id;
43
+ h.style.scrollMarginTop = "16px";
44
+ });
45
+
46
+ if (window.location.hash) {
47
+ const target = document.getElementById(
48
+ decodeURIComponent(window.location.hash.slice(1)),
49
+ );
50
+ if (target) {
51
+ requestAnimationFrame(() => {
52
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
53
+ });
54
+ }
55
+ }
56
+ }, [pathname]);
57
+
58
+ return null;
59
+ }
@@ -0,0 +1,353 @@
1
+ "use client";
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import { usePathname } from "next/navigation";
5
+
6
+ interface PageSection {
7
+ id: string;
8
+ title: string;
9
+ }
10
+
11
+ interface PageEntry {
12
+ href: string;
13
+ title: string;
14
+ description: string;
15
+ datePublished?: string;
16
+ sections: PageSection[];
17
+ category: string;
18
+ }
19
+
20
+ export interface SitemapSidebarProps {
21
+ /** All pages to show in the sidebar (from walkPages on the server). */
22
+ pages: PageEntry[];
23
+ /** Brand name shown in the header and footer. */
24
+ brandName: string;
25
+ /** Optional custom logo element. Falls back to brandName text + accent dot. */
26
+ brandLogo?: React.ReactNode;
27
+ /** Link for the brand logo / "Back to ..." link. Default: "/" */
28
+ homeHref?: string;
29
+ /** Custom category labels. Keys are path segments, values are display names.
30
+ * Built-in defaults: t -> "Guides", blog -> "Blog", compare -> "Comparisons", etc. */
31
+ categoryLabels?: Record<string, string>;
32
+ }
33
+
34
+ const DEFAULT_LABELS: Record<string, string> = {
35
+ t: "Guides",
36
+ compare: "Comparisons",
37
+ blog: "Blog",
38
+ "use-case": "Use Cases",
39
+ automate: "Automations",
40
+ alternative: "Alternatives",
41
+ };
42
+
43
+ function getCategoryLabel(
44
+ category: string,
45
+ custom?: Record<string, string>,
46
+ ): string {
47
+ if (custom?.[category]) return custom[category];
48
+ if (DEFAULT_LABELS[category]) return DEFAULT_LABELS[category];
49
+ return category
50
+ .split("-")
51
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
52
+ .join(" ");
53
+ }
54
+
55
+ function groupPages(pages: PageEntry[]): Map<string, PageEntry[]> {
56
+ const groups = new Map<string, PageEntry[]>();
57
+ for (const page of pages) {
58
+ const cat = page.category || "pages";
59
+ if (!groups.has(cat)) groups.set(cat, []);
60
+ groups.get(cat)!.push(page);
61
+ }
62
+ return groups;
63
+ }
64
+
65
+ export function SitemapSidebar({
66
+ pages,
67
+ brandName,
68
+ brandLogo,
69
+ homeHref = "/",
70
+ categoryLabels,
71
+ }: SitemapSidebarProps) {
72
+ const pathname = usePathname();
73
+ const [query, setQuery] = useState("");
74
+ const [mobileOpen, setMobileOpen] = useState(false);
75
+ const [activeSection, setActiveSection] = useState<string | null>(null);
76
+ const activeRef = useRef<HTMLAnchorElement | null>(null);
77
+ const navRef = useRef<HTMLElement | null>(null);
78
+
79
+ const filtered = query
80
+ ? pages.filter(
81
+ (p) =>
82
+ p.title.toLowerCase().includes(query.toLowerCase()) ||
83
+ p.description.toLowerCase().includes(query.toLowerCase()),
84
+ )
85
+ : pages;
86
+
87
+ const groups = groupPages(filtered);
88
+
89
+ const scrollActiveIntoView = () => {
90
+ const active = activeRef.current;
91
+ const nav = navRef.current;
92
+ if (!active || !nav) return;
93
+ const activeRect = active.getBoundingClientRect();
94
+ const navRect = nav.getBoundingClientRect();
95
+ const relativeTop = activeRect.top - navRect.top + nav.scrollTop;
96
+ nav.scrollTop = Math.max(0, relativeTop - 24);
97
+ };
98
+
99
+ useLayoutEffect(() => {
100
+ scrollActiveIntoView();
101
+ const raf = requestAnimationFrame(scrollActiveIntoView);
102
+ return () => cancelAnimationFrame(raf);
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
104
+ }, [pathname]);
105
+
106
+ // Track which H2 section is currently in view.
107
+ useEffect(() => {
108
+ const article = document.querySelector("article");
109
+ if (!article) return;
110
+
111
+ const headings = Array.from(
112
+ article.querySelectorAll<HTMLHeadingElement>("h2[id]"),
113
+ );
114
+ if (headings.length === 0) return;
115
+
116
+ const observer = new IntersectionObserver(
117
+ (entries) => {
118
+ const visible = entries
119
+ .filter((e) => e.isIntersecting)
120
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
121
+ if (visible.length > 0) {
122
+ setActiveSection(visible[0].target.id);
123
+ }
124
+ },
125
+ { rootMargin: "-20% 0px -70% 0px", threshold: 0 },
126
+ );
127
+
128
+ headings.forEach((h) => observer.observe(h));
129
+ return () => observer.disconnect();
130
+ }, [pathname]);
131
+
132
+ const handleSectionClick = (
133
+ e: React.MouseEvent<HTMLAnchorElement>,
134
+ sectionId: string,
135
+ ) => {
136
+ e.preventDefault();
137
+ const el = document.getElementById(sectionId);
138
+ if (el) {
139
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
140
+ if (typeof window !== "undefined") {
141
+ history.replaceState(null, "", `#${sectionId}`);
142
+ }
143
+ setActiveSection(sectionId);
144
+ setMobileOpen(false);
145
+ }
146
+ };
147
+
148
+ return (
149
+ <>
150
+ {/* Mobile toggle */}
151
+ <button
152
+ onClick={() => setMobileOpen(!mobileOpen)}
153
+ className="lg:hidden fixed bottom-4 left-4 z-50 text-white p-3 rounded-full shadow-lg transition-colors"
154
+ style={{
155
+ backgroundColor: "var(--seo-accent, #14b8a6)",
156
+ }}
157
+ aria-label="Toggle site navigation"
158
+ >
159
+ <svg
160
+ width="20"
161
+ height="20"
162
+ viewBox="0 0 24 24"
163
+ fill="none"
164
+ stroke="currentColor"
165
+ strokeWidth="2"
166
+ strokeLinecap="round"
167
+ strokeLinejoin="round"
168
+ >
169
+ {mobileOpen ? (
170
+ <>
171
+ <line x1="18" y1="6" x2="6" y2="18" />
172
+ <line x1="6" y1="6" x2="18" y2="18" />
173
+ </>
174
+ ) : (
175
+ <>
176
+ <line x1="3" y1="12" x2="21" y2="12" />
177
+ <line x1="3" y1="6" x2="21" y2="6" />
178
+ <line x1="3" y1="18" x2="21" y2="18" />
179
+ </>
180
+ )}
181
+ </svg>
182
+ </button>
183
+
184
+ {/* Mobile backdrop */}
185
+ {mobileOpen && (
186
+ <div
187
+ className="lg:hidden fixed inset-0 z-40 bg-black/30 backdrop-blur-sm"
188
+ onClick={() => setMobileOpen(false)}
189
+ />
190
+ )}
191
+
192
+ {/* Sidebar */}
193
+ <aside
194
+ className={`
195
+ fixed lg:sticky top-0 left-0 z-40 h-screen
196
+ w-72 bg-white border-r border-zinc-200
197
+ flex flex-col
198
+ transition-transform duration-200 ease-out
199
+ lg:translate-x-0
200
+ ${mobileOpen ? "translate-x-0" : "-translate-x-full"}
201
+ `}
202
+ >
203
+ {/* Header */}
204
+ <div className="p-4 border-b border-zinc-100">
205
+ <a href={homeHref} className="flex items-baseline gap-0 mb-4">
206
+ {brandLogo ?? (
207
+ <>
208
+ <span className="font-mono font-bold text-lg tracking-tight text-zinc-900">
209
+ {brandName}
210
+ </span>
211
+ <span
212
+ className="w-1.5 h-1.5 rounded-full ml-0.5 mb-0.5 inline-block"
213
+ style={{ backgroundColor: "var(--seo-accent, #14b8a6)" }}
214
+ />
215
+ </>
216
+ )}
217
+ </a>
218
+ <div className="relative">
219
+ <svg
220
+ className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400"
221
+ width="14"
222
+ height="14"
223
+ viewBox="0 0 24 24"
224
+ fill="none"
225
+ stroke="currentColor"
226
+ strokeWidth="2"
227
+ strokeLinecap="round"
228
+ strokeLinejoin="round"
229
+ >
230
+ <circle cx="11" cy="11" r="8" />
231
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
232
+ </svg>
233
+ <input
234
+ type="text"
235
+ placeholder="Search pages..."
236
+ value={query}
237
+ onChange={(e) => setQuery(e.target.value)}
238
+ className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 rounded-lg bg-zinc-50 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:border-transparent transition"
239
+ style={
240
+ {
241
+ "--tw-ring-color":
242
+ "color-mix(in srgb, var(--seo-accent, #14b8a6) 20%, transparent)",
243
+ } as React.CSSProperties
244
+ }
245
+ />
246
+ </div>
247
+ </div>
248
+
249
+ {/* Page list */}
250
+ <nav ref={navRef} className="flex-1 overflow-y-auto p-3">
251
+ {filtered.length === 0 && (
252
+ <p className="text-sm text-zinc-400 px-3 py-4">No pages found</p>
253
+ )}
254
+
255
+ {Array.from(groups.entries()).map(([category, categoryPages]) => (
256
+ <div key={category} className="mb-4">
257
+ {/* Category header (only show if more than one category) */}
258
+ {groups.size > 1 && (
259
+ <div className="px-3 py-1.5 text-xs font-semibold text-zinc-400 uppercase tracking-wider">
260
+ {getCategoryLabel(category, categoryLabels)}
261
+ </div>
262
+ )}
263
+
264
+ {categoryPages.map((page) => {
265
+ const isActive = pathname === page.href;
266
+ return (
267
+ <div key={page.href} className="mb-0.5">
268
+ <a
269
+ ref={isActive ? activeRef : null}
270
+ href={page.href}
271
+ onClick={() => setMobileOpen(false)}
272
+ className={`block px-3 py-2.5 rounded-lg transition-colors ${
273
+ isActive
274
+ ? "text-zinc-900 font-medium"
275
+ : "text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900"
276
+ }`}
277
+ style={
278
+ isActive
279
+ ? {
280
+ backgroundColor:
281
+ "color-mix(in srgb, var(--seo-accent, #14b8a6) 10%, transparent)",
282
+ color: "var(--seo-accent-dark, #0d9488)",
283
+ }
284
+ : undefined
285
+ }
286
+ >
287
+ <span className="text-sm leading-snug line-clamp-2">
288
+ {page.title}
289
+ </span>
290
+ {page.datePublished && (
291
+ <span className="text-[11px] text-zinc-400 mt-0.5 block">
292
+ {page.datePublished}
293
+ </span>
294
+ )}
295
+ </a>
296
+
297
+ {/* Subsections for the active page */}
298
+ {isActive && page.sections.length > 0 && (
299
+ <ul className="mt-1 mb-2 ml-3 border-l border-zinc-200 space-y-0.5">
300
+ {page.sections.map((section) => {
301
+ const isSectionActive =
302
+ activeSection === section.id;
303
+ return (
304
+ <li key={section.id}>
305
+ <a
306
+ href={`#${section.id}`}
307
+ onClick={(e) =>
308
+ handleSectionClick(e, section.id)
309
+ }
310
+ className={`block pl-3 pr-2 py-1.5 -ml-px border-l-2 text-[13px] leading-snug transition-colors ${
311
+ isSectionActive
312
+ ? "font-medium"
313
+ : "border-transparent text-zinc-500 hover:text-zinc-900 hover:border-zinc-300"
314
+ }`}
315
+ style={
316
+ isSectionActive
317
+ ? {
318
+ borderColor:
319
+ "var(--seo-accent, #14b8a6)",
320
+ color:
321
+ "var(--seo-accent-dark, #0d9488)",
322
+ }
323
+ : undefined
324
+ }
325
+ >
326
+ {section.title}
327
+ </a>
328
+ </li>
329
+ );
330
+ })}
331
+ </ul>
332
+ )}
333
+ </div>
334
+ );
335
+ })}
336
+ </div>
337
+ ))}
338
+ </nav>
339
+
340
+ {/* Footer */}
341
+ <div className="p-4 border-t border-zinc-100">
342
+ <a
343
+ href={homeHref}
344
+ className="text-sm font-medium transition-colors"
345
+ style={{ color: "var(--seo-accent, #14b8a6)" }}
346
+ >
347
+ &larr; Back to {brandName}
348
+ </a>
349
+ </div>
350
+ </aside>
351
+ </>
352
+ );
353
+ }
package/src/index.ts CHANGED
@@ -54,12 +54,17 @@ export { MotionSequence } from "./components/MotionSequence";
54
54
  export { RemotionClip, ConceptReveal } from "./components/RemotionClip";
55
55
  export { LottiePlayer } from "./components/LottiePlayer";
56
56
 
57
+ // Sitemap sidebar + heading anchors
58
+ export { SitemapSidebar } from "./components/SitemapSidebar";
59
+ export type { SitemapSidebarProps } from "./components/SitemapSidebar";
60
+ export { HeadingAnchors } from "./components/HeadingAnchors";
61
+
57
62
  // Guide chat (AI page assistant) — client-safe
58
63
  export { GuideChatPanel } from "./components/GuideChatPanel";
59
64
  export type { GuideChatPanelProps } from "./components/GuideChatPanel";
60
65
 
61
66
  // Server utilities: import from "@seo/components/server" instead
62
- // (createGuideChatHandler, logAiUsage, discoverGuides, etc.)
67
+ // (walkPages, createGuideChatHandler, logAiUsage, discoverGuides, etc.)
63
68
 
64
69
  // Magic UI style components
65
70
  export { Marquee } from "./components/Marquee";
@@ -1,16 +1,12 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
-
4
- const TITLE_RE = /const\s+TITLE\s*=\s*"([^"]+)"/;
5
- const DESC_RE = /const\s+DESCRIPTION\s*=\s*"([^"]+)"/;
6
- const DATE_RE = /const\s+DATE_PUBLISHED\s*=\s*"([^"]+)"/;
7
- const H2_RE = /<h2\b[^>]*>([\s\S]*?)<\/h2>/g;
2
+ import { walkPages, type PageEntry } from "./walk-pages";
8
3
 
9
4
  export interface GuideEntry {
10
5
  slug: string;
11
6
  title: string;
12
7
  description: string;
13
8
  datePublished: string;
9
+ href: string;
14
10
  sections: { id: string; title: string }[];
15
11
  hasFaq: boolean;
16
12
  }
@@ -18,55 +14,34 @@ export interface GuideEntry {
18
14
  let cachedDir: string | null = null;
19
15
  let cachedGuides: GuideEntry[] | null = null;
20
16
 
17
+ /**
18
+ * Legacy helper that discovers guides under a specific content directory.
19
+ * Internally delegates to walkPages() then filters to the relevant path prefix.
20
+ *
21
+ * For new integrations, prefer walkPages() directly.
22
+ */
21
23
  export function discoverGuides(contentDir?: string): GuideEntry[] {
22
24
  const dir = contentDir ?? path.join(process.cwd(), "src/app/(content)/t");
23
25
 
24
26
  if (cachedDir === dir && cachedGuides) return cachedGuides;
25
27
 
26
- if (!fs.existsSync(dir)) return [];
27
-
28
- const slugs = fs
29
- .readdirSync(dir, { withFileTypes: true })
30
- .filter((d) => d.isDirectory() && !d.name.startsWith("["))
31
- .map((d) => d.name);
32
-
33
- const guides: GuideEntry[] = [];
34
- for (const slug of slugs) {
35
- const pagePath = path.join(dir, slug, "page.tsx");
36
- let src: string;
37
- try {
38
- src = fs.readFileSync(pagePath, "utf-8");
39
- } catch {
40
- continue;
41
- }
28
+ // Determine which URL prefix these pages live under by
29
+ // finding the path segments after src/app (skipping route groups).
30
+ const appDir = path.join(process.cwd(), "src/app");
31
+ const allPages = walkPages({ appDir });
42
32
 
43
- const title = src.match(TITLE_RE)?.[1] ?? "";
44
- const description = src.match(DESC_RE)?.[1] ?? "";
45
- const datePublished = src.match(DATE_RE)?.[1] ?? "";
46
- if (!title) continue;
33
+ // Figure out the href prefix from contentDir.
34
+ // e.g. "src/app/(content)/t" -> "/t", "src/app/t" -> "/t"
35
+ const relative = path.relative(appDir, dir);
36
+ const segments = relative
37
+ .split(path.sep)
38
+ .filter((s) => !s.startsWith("(") || !s.endsWith(")"));
39
+ const hrefPrefix = "/" + segments.join("/");
47
40
 
48
- const sections: { id: string; title: string }[] = [];
49
- let m: RegExpExecArray | null;
50
- while ((m = H2_RE.exec(src)) !== null) {
51
- const raw = m[1].replace(/<[^>]+>/g, "").trim();
52
- if (raw) {
53
- const id = raw
54
- .toLowerCase()
55
- .replace(/[^a-z0-9]+/g, "-")
56
- .replace(/^-|-$/g, "");
57
- sections.push({ id, title: raw });
58
- }
59
- }
60
-
61
- guides.push({
62
- slug,
63
- title,
64
- description,
65
- datePublished,
66
- sections,
67
- hasFaq: /<FaqSection\b/.test(src),
68
- });
69
- }
41
+ const guides: GuideEntry[] = allPages
42
+ .filter((p) => p.href.startsWith(hrefPrefix + "/") || p.href === hrefPrefix)
43
+ .filter((p) => p.href !== hrefPrefix) // exclude the index page
44
+ .map(pageToGuide);
70
45
 
71
46
  guides.sort(
72
47
  (a, b) =>
@@ -78,3 +53,19 @@ export function discoverGuides(contentDir?: string): GuideEntry[] {
78
53
  cachedGuides = guides;
79
54
  return guides;
80
55
  }
56
+
57
+ function pageToGuide(p: PageEntry): GuideEntry {
58
+ const parts = p.href.split("/");
59
+ const slug = parts[parts.length - 1] || "";
60
+ return {
61
+ slug,
62
+ title: p.title,
63
+ description: p.description,
64
+ datePublished: p.datePublished ?? "",
65
+ href: p.href,
66
+ sections: p.sections,
67
+ hasFaq: p.sections.some(
68
+ (s) => s.id === "frequently-asked-questions",
69
+ ),
70
+ };
71
+ }
@@ -0,0 +1,7 @@
1
+ export function slugify(text: string): string {
2
+ return text
3
+ .toLowerCase()
4
+ .replace(/[^a-z0-9]+/g, "-")
5
+ .replace(/^-+|-+$/g, "")
6
+ .slice(0, 80);
7
+ }
@@ -0,0 +1,244 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { slugify } from "./slugify";
4
+
5
+ export interface PageSection {
6
+ id: string;
7
+ title: string;
8
+ }
9
+
10
+ export interface PageEntry {
11
+ href: string;
12
+ title: string;
13
+ description: string;
14
+ datePublished?: string;
15
+ sections: PageSection[];
16
+ category: string;
17
+ }
18
+
19
+ export interface WalkPagesOptions {
20
+ /** Absolute path to src/app directory. Defaults to `process.cwd()/src/app`. */
21
+ appDir?: string;
22
+ /** Filter out specific path segments (e.g. ["checkout", "trial-required"]). */
23
+ excludePaths?: string[];
24
+ /** If true, include the home page (href "/"). Default: false. */
25
+ includeHome?: boolean;
26
+ }
27
+
28
+ // Metadata patterns: `const TITLE = "..."` style
29
+ const CONST_TITLE_RE = /const\s+TITLE\s*=\s*["'`](.+?)["'`]/;
30
+ const CONST_DESC_RE = /const\s+DESCRIPTION\s*=\s*\n?\s*["'`](.+?)["'`]/;
31
+ const CONST_DATE_RE = /const\s+DATE_PUBLISHED\s*=\s*["'`](.+?)["'`]/;
32
+
33
+ // Metadata patterns: `export const metadata = { title: "..." }` style
34
+ const META_TITLE_RE = /title:\s*["'`](.+?)["'`]/;
35
+ const META_DESC_RE = /description:\s*["'`](.+?)["'`]/;
36
+
37
+ const H2_RE = /<h2\b[^>]*>([\s\S]*?)<\/h2>/g;
38
+ const FAQ_SECTION_RE = /<FaqSection\b/;
39
+
40
+ const ENTITY_MAP: Record<string, string> = {
41
+ "&ldquo;": "\u201c",
42
+ "&rdquo;": "\u201d",
43
+ "&lsquo;": "\u2018",
44
+ "&rsquo;": "\u2019",
45
+ "&apos;": "'",
46
+ "&quot;": '"',
47
+ "&amp;": "&",
48
+ "&lt;": "<",
49
+ "&gt;": ">",
50
+ "&nbsp;": " ",
51
+ "&mdash;": ",",
52
+ "&ndash;": ",",
53
+ "&hellip;": "...",
54
+ };
55
+
56
+ function decodeEntities(s: string): string {
57
+ return s
58
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
59
+ .replace(
60
+ /&#x([0-9a-f]+);/gi,
61
+ (_, n) => String.fromCharCode(parseInt(n, 16)),
62
+ )
63
+ .replace(/&[a-z]+;/gi, (m) => ENTITY_MAP[m.toLowerCase()] ?? m);
64
+ }
65
+
66
+ function slugToTitle(slug: string): string {
67
+ return slug
68
+ .split("-")
69
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
70
+ .join(" ");
71
+ }
72
+
73
+ function extractSections(src: string): PageSection[] {
74
+ const seen = new Set<string>();
75
+ const out: PageSection[] = [];
76
+ let m: RegExpExecArray | null;
77
+ H2_RE.lastIndex = 0;
78
+ while ((m = H2_RE.exec(src)) !== null) {
79
+ const raw = m[1];
80
+ const text = decodeEntities(
81
+ raw
82
+ .replace(/\{[^}]*\}/g, " ")
83
+ .replace(/<[^>]+>/g, " ")
84
+ .replace(/\s+/g, " "),
85
+ ).trim();
86
+ if (!text) continue;
87
+ const base = slugify(text);
88
+ if (!base) continue;
89
+ let id = base;
90
+ let i = 2;
91
+ while (seen.has(id)) {
92
+ id = `${base}-${i++}`;
93
+ }
94
+ seen.add(id);
95
+ out.push({ id, title: text });
96
+ }
97
+ if (FAQ_SECTION_RE.test(src)) {
98
+ const faqTitle = "Frequently asked questions";
99
+ const faqId = slugify(faqTitle);
100
+ if (!seen.has(faqId)) {
101
+ out.push({ id: faqId, title: faqTitle });
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function extractMetadata(src: string, fallbackSlug: string) {
108
+ // Try const TITLE first, fall back to metadata.title
109
+ const title =
110
+ src.match(CONST_TITLE_RE)?.[1] ??
111
+ src.match(META_TITLE_RE)?.[1] ??
112
+ slugToTitle(fallbackSlug);
113
+ const description =
114
+ src.match(CONST_DESC_RE)?.[1] ?? src.match(META_DESC_RE)?.[1] ?? "";
115
+ const datePublished = src.match(CONST_DATE_RE)?.[1];
116
+
117
+ return {
118
+ title: decodeEntities(title),
119
+ description: decodeEntities(description),
120
+ datePublished,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Recursively discovers all page.tsx files under a Next.js app directory.
126
+ * Same logic as the sitemap.ts walkPages() that already exists on every site,
127
+ * but enriched with title, description, sections, and category.
128
+ *
129
+ * Import from "@seo/components/server".
130
+ */
131
+ export function walkPages(opts?: WalkPagesOptions): PageEntry[] {
132
+ const appDir =
133
+ opts?.appDir ?? path.join(process.cwd(), "src/app");
134
+ const excludeSet = new Set(opts?.excludePaths ?? []);
135
+
136
+ const pages: PageEntry[] = [];
137
+
138
+ function walk(dir: string, urlSegments: string[]) {
139
+ let entries: fs.Dirent[];
140
+ try {
141
+ entries = fs.readdirSync(dir, { withFileTypes: true });
142
+ } catch {
143
+ return;
144
+ }
145
+
146
+ for (const entry of entries) {
147
+ if (entry.isFile() && entry.name === "page.tsx") {
148
+ const href =
149
+ urlSegments.length === 0 ? "/" : "/" + urlSegments.join("/");
150
+
151
+ // Skip home page unless requested
152
+ if (href === "/" && !opts?.includeHome) continue;
153
+
154
+ const filePath = path.join(dir, entry.name);
155
+ let src: string;
156
+ try {
157
+ src = fs.readFileSync(filePath, "utf-8");
158
+ } catch {
159
+ continue;
160
+ }
161
+
162
+ const lastSegment = urlSegments[urlSegments.length - 1] ?? "";
163
+ const meta = extractMetadata(src, lastSegment);
164
+ const sections = extractSections(src);
165
+ const category = urlSegments[0] ?? "";
166
+
167
+ pages.push({
168
+ href,
169
+ title: meta.title,
170
+ description: meta.description,
171
+ datePublished: meta.datePublished,
172
+ sections,
173
+ category,
174
+ });
175
+ continue;
176
+ }
177
+
178
+ if (!entry.isDirectory()) continue;
179
+
180
+ const name = entry.name;
181
+ if (name.startsWith("_")) continue;
182
+ if (name === "api") continue;
183
+ if (name.startsWith("[") && name.endsWith("]")) continue;
184
+ if (excludeSet.has(name)) continue;
185
+
186
+ const isRouteGroup = name.startsWith("(") && name.endsWith(")");
187
+ const nextSegments = isRouteGroup
188
+ ? urlSegments
189
+ : [...urlSegments, name];
190
+
191
+ walk(path.join(dir, name), nextSegments);
192
+ }
193
+ }
194
+
195
+ walk(appDir, []);
196
+
197
+ // Sort: pages with dates descending, then alphabetically by title
198
+ pages.sort(
199
+ (a, b) =>
200
+ (b.datePublished ?? "").localeCompare(a.datePublished ?? "") ||
201
+ a.title.localeCompare(b.title),
202
+ );
203
+
204
+ return pages;
205
+ }
206
+
207
+ /**
208
+ * Groups pages by their category (first URL segment).
209
+ * Returns a Map where keys are category names and values are page arrays.
210
+ * Categories are auto-labeled from the path segment (e.g. "use-case" becomes "Use Case").
211
+ */
212
+ export function groupByCategory(
213
+ pages: PageEntry[],
214
+ ): Map<string, PageEntry[]> {
215
+ const groups = new Map<string, PageEntry[]>();
216
+ for (const page of pages) {
217
+ const cat = page.category || "pages";
218
+ if (!groups.has(cat)) groups.set(cat, []);
219
+ groups.get(cat)!.push(page);
220
+ }
221
+ return groups;
222
+ }
223
+
224
+ /**
225
+ * Converts a category slug to a display label.
226
+ * "t" -> "Guides", "use-case" -> "Use Cases", "compare" -> "Comparisons", etc.
227
+ */
228
+ export function categoryLabel(category: string): string {
229
+ const labels: Record<string, string> = {
230
+ t: "Guides",
231
+ compare: "Comparisons",
232
+ blog: "Blog",
233
+ "use-case": "Use Cases",
234
+ automate: "Automations",
235
+ alternative: "Alternatives",
236
+ };
237
+ return (
238
+ labels[category] ??
239
+ category
240
+ .split("-")
241
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
242
+ .join(" ")
243
+ );
244
+ }
package/src/server.ts CHANGED
@@ -1,6 +1,11 @@
1
- // Server-side utilities for guide chat, token accounting, and content discovery.
1
+ // Server-side utilities for page discovery, guide chat, token accounting.
2
2
  // Import from "@seo/components/server" in API routes and server components only.
3
3
 
4
+ export { walkPages, groupByCategory, categoryLabel } from "./lib/walk-pages";
5
+ export type { PageEntry, PageSection, WalkPagesOptions } from "./lib/walk-pages";
6
+
7
+ export { slugify } from "./lib/slugify";
8
+
4
9
  export { createGuideChatHandler } from "./lib/guide-chat-route";
5
10
  export type { GuideChatConfig } from "./lib/guide-chat-route";
6
11