@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 +1 -1
- package/src/components/HeadingAnchors.tsx +59 -0
- package/src/components/SitemapSidebar.tsx +353 -0
- package/src/index.ts +6 -1
- package/src/lib/discover-guides.ts +39 -48
- package/src/lib/slugify.ts +7 -0
- package/src/lib/walk-pages.ts +244 -0
- package/src/server.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/seo-components",
|
|
3
|
-
"version": "0.
|
|
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
|
+
← 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
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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,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
|
+
"“": "\u201c",
|
|
42
|
+
"”": "\u201d",
|
|
43
|
+
"‘": "\u2018",
|
|
44
|
+
"’": "\u2019",
|
|
45
|
+
"'": "'",
|
|
46
|
+
""": '"',
|
|
47
|
+
"&": "&",
|
|
48
|
+
"<": "<",
|
|
49
|
+
">": ">",
|
|
50
|
+
" ": " ",
|
|
51
|
+
"—": ",",
|
|
52
|
+
"–": ",",
|
|
53
|
+
"…": "...",
|
|
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
|
|
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
|
|