@m13v/seo-components 0.20.1 → 0.22.1

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
@@ -110,6 +110,8 @@ import { walkPages, createGuideChatHandler, logAiUsage } from "@seo/components/s
110
110
  | Export | What it does |
111
111
  |--------|-------------|
112
112
  | `walkPages()` | Discovers all pages in `src/app/`, extracts titles, descriptions, H2 sections |
113
+ | `generateSitemap()` | Returns a `MetadataRoute.Sitemap` by walking `src/app` with priority tiers |
114
+ | `generateRobots()` | Returns a `MetadataRoute.Robots` with default + AI-crawler allowlist + sitemap URL |
113
115
  | `createGuideChatHandler()` | Gemini streaming chat route handler |
114
116
  | `discoverGuides()` | Legacy guide discovery (delegates to `walkPages`) |
115
117
  | `getGuideContext()` | Builds page context for AI chat |
@@ -117,6 +119,48 @@ import { walkPages, createGuideChatHandler, logAiUsage } from "@seo/components/s
117
119
  | `getSupabaseAdmin()` | Supabase admin client |
118
120
  | `slugify()` | URL-safe slug utility |
119
121
 
122
+ ### Sitemap + robots (canonical setup)
123
+
124
+ Every site should ship a **dynamic** sitemap and a robots.txt that references it. The library provides both as one-liners so you never hand-roll either again.
125
+
126
+ ```ts
127
+ // src/app/sitemap.ts
128
+ import type { MetadataRoute } from "next";
129
+ import { generateSitemap } from "@seo/components/server";
130
+
131
+ export default function sitemap(): MetadataRoute.Sitemap {
132
+ return generateSitemap({ baseUrl: "https://yourdomain.com" });
133
+ }
134
+ ```
135
+
136
+ ```ts
137
+ // src/app/robots.ts
138
+ import type { MetadataRoute } from "next";
139
+ import { generateRobots } from "@seo/components/server";
140
+
141
+ export default function robots(): MetadataRoute.Robots {
142
+ return generateRobots({ baseUrl: "https://yourdomain.com" });
143
+ }
144
+ ```
145
+
146
+ `generateSitemap()` walks `src/app/` for `page.tsx` files (skipping route groups, dynamic segments, `api/`, underscore-prefixed dirs) and applies priority tiers. For dynamic routes like `/blog/[slug]`, pass `extraEntries`:
147
+
148
+ ```ts
149
+ return generateSitemap({
150
+ baseUrl: "https://yourdomain.com",
151
+ extraEntries: posts.map((p) => ({ url: `https://yourdomain.com/blog/${p.slug}` })),
152
+ });
153
+ ```
154
+
155
+ `generateRobots()` emits the default `User-agent: *` rule, a 13-bot AI allowlist (GPTBot, ChatGPT-User, ClaudeBot, Claude-Web, anthropic-ai, PerplexityBot, CCBot, Google-Extended, Bytespider, cohere-ai, Applebot, Applebot-Extended), and a sitemap URL derived from `baseUrl`. Override `aiAllowlist`, `disallow`, `extraRules`, or `sitemap` as needed.
156
+
157
+ Verify live after deploy:
158
+
159
+ ```bash
160
+ curl -s -o /dev/null -w "%{http_code}\n" https://yourdomain.com/sitemap.xml # 200
161
+ curl -s https://yourdomain.com/robots.txt | grep sitemap.xml # non-empty
162
+ ```
163
+
120
164
  ## JSON-LD Helpers
121
165
 
122
166
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/seo-components",
3
- "version": "0.20.1",
3
+ "version": "0.22.1",
4
4
  "scripts": {
5
5
  "build:css": "tailwind -i src/_build.css -o dist/styles.css --minify",
6
6
  "bump:consumers": "bash scripts/bump-consumers.sh",
@@ -2,7 +2,21 @@
2
2
 
3
3
  import { useEffect, useState } from "react";
4
4
  import { motion, AnimatePresence } from "framer-motion";
5
- import { trackScheduleClick } from "../lib/track";
5
+ import { trackScheduleClick, withBookingAttribution } from "../lib/track";
6
+
7
+ /**
8
+ * Render the bare destination for SSR, then swap in the UTM-attributed URL
9
+ * after hydration so Cmd+Click / middle-click preserves the attribution.
10
+ * PostHog's `schedule_click` event still receives the bare destination so
11
+ * aggregation isn't fragmented per page.
12
+ */
13
+ function useBookingHref(destination: string): string {
14
+ const [href, setHref] = useState(destination);
15
+ useEffect(() => {
16
+ setHref(withBookingAttribution(destination));
17
+ }, [destination]);
18
+ return href;
19
+ }
6
20
 
7
21
  export type BookCallAppearance = "inline" | "sticky" | "hero" | "footer";
8
22
 
@@ -111,6 +125,7 @@ function InlineBookCall({
111
125
  section: string;
112
126
  site?: string;
113
127
  }) {
128
+ const href = useBookingHref(destination);
114
129
  return (
115
130
  <motion.div
116
131
  className="my-12 mx-auto max-w-2xl p-6 rounded-2xl border border-teal-100 dark:border-teal-800/60 bg-teal-50/40 dark:bg-teal-950/60"
@@ -126,7 +141,7 @@ function InlineBookCall({
126
141
  {description}
127
142
  </p>
128
143
  <a
129
- href={destination}
144
+ href={href}
130
145
  target="_blank"
131
146
  rel="noopener noreferrer"
132
147
  className="inline-flex items-center gap-2 text-sm font-medium px-4 py-2 rounded-lg bg-teal-500 text-accent-contrast hover:bg-accent-dim transition-colors"
@@ -151,9 +166,10 @@ function HeroBookCall({
151
166
  section: string;
152
167
  site?: string;
153
168
  }) {
169
+ const href = useBookingHref(destination);
154
170
  return (
155
171
  <a
156
- href={destination}
172
+ href={href}
157
173
  target="_blank"
158
174
  rel="noopener noreferrer"
159
175
  className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg border border-teal-200 dark:border-teal-800/60 text-sm font-medium text-teal-700 dark:text-teal-200 hover:bg-teal-50 dark:hover:bg-teal-950/60 transition-colors"
@@ -181,6 +197,7 @@ function FooterBookCall({
181
197
  section: string;
182
198
  site?: string;
183
199
  }) {
200
+ const href = useBookingHref(destination);
184
201
  return (
185
202
  <motion.div
186
203
  className="my-16 mx-auto max-w-3xl p-8 rounded-2xl border border-teal-200 dark:border-teal-800/60 bg-gradient-to-br from-teal-50 to-white dark:from-teal-950/70 dark:to-zinc-950 text-center"
@@ -196,7 +213,7 @@ function FooterBookCall({
196
213
  {description}
197
214
  </p>
198
215
  <a
199
- href={destination}
216
+ href={href}
200
217
  target="_blank"
201
218
  rel="noopener noreferrer"
202
219
  className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-teal-500 text-accent-contrast hover:bg-accent-dim transition-colors text-sm font-medium"
@@ -226,6 +243,7 @@ function StickyBookCall({
226
243
  scrollThreshold: number;
227
244
  }) {
228
245
  const [visible, setVisible] = useState(false);
246
+ const href = useBookingHref(destination);
229
247
 
230
248
  useEffect(() => {
231
249
  const handler = () => setVisible(window.scrollY > scrollThreshold);
@@ -249,7 +267,7 @@ function StickyBookCall({
249
267
  {description}
250
268
  </p>
251
269
  <a
252
- href={destination}
270
+ href={href}
253
271
  target="_blank"
254
272
  rel="noopener noreferrer"
255
273
  className="text-sm font-medium px-4 py-2 rounded-lg bg-teal-500 text-accent-contrast hover:bg-accent-dim transition-colors"
@@ -1,7 +1,12 @@
1
1
  "use client";
2
2
 
3
+ import { useEffect, useState } from "react";
3
4
  import { motion } from "framer-motion";
4
- import { trackGetStartedClick, trackScheduleClick } from "../lib/track";
5
+ import {
6
+ trackGetStartedClick,
7
+ trackScheduleClick,
8
+ withBookingAttribution,
9
+ } from "../lib/track";
5
10
 
6
11
  interface InlineCtaProps {
7
12
  heading: string;
@@ -30,6 +35,13 @@ export function InlineCta({
30
35
  site,
31
36
  section,
32
37
  }: InlineCtaProps) {
38
+ // For schedule CTAs, rewrite the href after hydration so the Cal.com webhook
39
+ // sees `metadata[utm_*]` params and can attribute the booking to this page.
40
+ // PostHog events still carry the bare `href` so aggregation stays clean.
41
+ const [renderHref, setRenderHref] = useState(href);
42
+ useEffect(() => {
43
+ setRenderHref(trackAs === "schedule" ? withBookingAttribution(href) : href);
44
+ }, [href, trackAs]);
33
45
  return (
34
46
  <motion.div
35
47
  className="my-12 mx-auto max-w-2xl p-6 rounded-2xl border border-teal-100 dark:border-teal-800/60 bg-teal-50/30 dark:bg-teal-950/60"
@@ -45,7 +57,7 @@ export function InlineCta({
45
57
  {body}
46
58
  </p>
47
59
  <a
48
- href={href}
60
+ href={renderHref}
49
61
  className="inline-flex items-center gap-2 text-sm font-medium text-teal-600 dark:text-teal-300 hover:text-accent-dim transition-colors"
50
62
  onClick={() => {
51
63
  if (trackAs === "get_started") {
@@ -2,7 +2,11 @@
2
2
 
3
3
  import { useState, useEffect } from "react";
4
4
  import { motion, AnimatePresence } from "framer-motion";
5
- import { trackGetStartedClick, trackScheduleClick } from "../lib/track";
5
+ import {
6
+ trackGetStartedClick,
7
+ trackScheduleClick,
8
+ withBookingAttribution,
9
+ } from "../lib/track";
6
10
 
7
11
  interface StickyBottomCtaProps {
8
12
  description: string;
@@ -33,6 +37,13 @@ export function StickyBottomCta({
33
37
  }: StickyBottomCtaProps) {
34
38
  const [visible, setVisible] = useState(false);
35
39
 
40
+ // Rewrite schedule CTAs' href after hydration so the Cal.com webhook sees
41
+ // `metadata[utm_*]` params and attributes the booking to this page.
42
+ const [renderHref, setRenderHref] = useState(href);
43
+ useEffect(() => {
44
+ setRenderHref(trackAs === "schedule" ? withBookingAttribution(href) : href);
45
+ }, [href, trackAs]);
46
+
36
47
  useEffect(() => {
37
48
  const handler = () => setVisible(window.scrollY > scrollThreshold);
38
49
  window.addEventListener("scroll", handler);
@@ -75,7 +86,7 @@ export function StickyBottomCta({
75
86
  {description}
76
87
  </p>
77
88
  <a
78
- href={href}
89
+ href={renderHref}
79
90
  className="text-sm font-medium px-4 py-2 rounded-lg bg-teal-500 text-accent-contrast hover:bg-accent-dim transition-colors"
80
91
  onClick={onClick}
81
92
  >
package/src/index.ts CHANGED
@@ -64,6 +64,7 @@ export {
64
64
  trackScheduleClick,
65
65
  trackGetStartedClick,
66
66
  trackDownloadClick,
67
+ withBookingAttribution,
67
68
  } from "./lib/track";
68
69
  export type {
69
70
  ScheduleClickProps,
@@ -0,0 +1,90 @@
1
+ export type RobotsRule = {
2
+ userAgent: string | string[];
3
+ allow?: string | string[];
4
+ disallow?: string | string[];
5
+ crawlDelay?: number;
6
+ };
7
+
8
+ export type GenerateRobotsOptions = {
9
+ /** Absolute base URL, no trailing slash (e.g. `https://fde10x.com`). Used to build the sitemap URL. */
10
+ baseUrl: string;
11
+ /**
12
+ * Paths disallowed for the default `*` rule. Defaults to `["/api/"]`.
13
+ * Pass an empty array to allow everything.
14
+ */
15
+ disallow?: string[];
16
+ /**
17
+ * Explicit AI crawler allowlist. Each user agent gets its own rule with
18
+ * `allow: "/"` so operators (Google, Perplexity, etc.) can distinguish
19
+ * this site as AI-opted-in. Defaults to the canonical 13-bot allowlist
20
+ * used across every m13v property. Pass `[]` to disable.
21
+ */
22
+ aiAllowlist?: string[];
23
+ /** Extra rules appended after the default + AI allowlist. */
24
+ extraRules?: RobotsRule[];
25
+ /**
26
+ * Override the sitemap URL. Defaults to `${baseUrl}/sitemap.xml`. Pass an
27
+ * array when you ship multiple sitemaps.
28
+ */
29
+ sitemap?: string | string[];
30
+ };
31
+
32
+ export type GeneratedRobots = {
33
+ rules: RobotsRule[];
34
+ sitemap: string | string[];
35
+ };
36
+
37
+ const DEFAULT_AI_ALLOWLIST = [
38
+ "GPTBot",
39
+ "ChatGPT-User",
40
+ "ClaudeBot",
41
+ "Claude-Web",
42
+ "anthropic-ai",
43
+ "PerplexityBot",
44
+ "CCBot",
45
+ "Google-Extended",
46
+ "Bytespider",
47
+ "cohere-ai",
48
+ "Applebot",
49
+ "Applebot-Extended",
50
+ ];
51
+
52
+ const DEFAULT_DISALLOW = ["/api/"];
53
+
54
+ /**
55
+ * Generate a Next.js `MetadataRoute.Robots` value with a default rule, an
56
+ * AI-crawler allowlist, and a sitemap reference derived from `baseUrl`.
57
+ *
58
+ * ```ts
59
+ * // src/app/robots.ts
60
+ * import { generateRobots } from "@seo/components/server";
61
+ *
62
+ * export default function robots() {
63
+ * return generateRobots({ baseUrl: "https://fde10x.com" });
64
+ * }
65
+ * ```
66
+ *
67
+ * Pair with `generateSitemap()` in `src/app/sitemap.ts` — the two together
68
+ * are the canonical SEO-infrastructure setup for a client site.
69
+ */
70
+ export function generateRobots(opts: GenerateRobotsOptions): GeneratedRobots {
71
+ const baseUrl = opts.baseUrl.replace(/\/+$/, "");
72
+ const disallow = opts.disallow ?? DEFAULT_DISALLOW;
73
+ const aiAllowlist = opts.aiAllowlist ?? DEFAULT_AI_ALLOWLIST;
74
+
75
+ const defaultRule: RobotsRule = {
76
+ userAgent: "*",
77
+ allow: "/",
78
+ ...(disallow.length > 0 ? { disallow } : {}),
79
+ };
80
+
81
+ const aiRules: RobotsRule[] = aiAllowlist.map((userAgent) => ({
82
+ userAgent,
83
+ allow: "/",
84
+ }));
85
+
86
+ const rules = [defaultRule, ...aiRules, ...(opts.extraRules ?? [])];
87
+ const sitemap = opts.sitemap ?? `${baseUrl}/sitemap.xml`;
88
+
89
+ return { rules, sitemap };
90
+ }
@@ -24,7 +24,11 @@ export function getGuideContext(
24
24
  );
25
25
  if (!guide) return null;
26
26
 
27
- // Derive appDir from contentDir to locate page files by their full href
27
+ // Resolve the page file on disk. `guide.href` is a URL path, which has
28
+ // Next.js route-group segments like `(content)` stripped out, so we cannot
29
+ // just join `appDir + href`. Instead, compute the URL prefix that `dir`
30
+ // maps to, strip it from `href`, and append the remainder to `dir` (the
31
+ // real filesystem path the caller passed in).
28
32
  const relDir = path.relative(process.cwd(), dir);
29
33
  const relParts = relDir.split(path.sep);
30
34
  const appIdx = relParts.indexOf("app");
@@ -32,7 +36,20 @@ export function getGuideContext(
32
36
  appIdx >= 0
33
37
  ? path.join(process.cwd(), ...relParts.slice(0, appIdx + 1))
34
38
  : dir;
35
- const pagePath = path.join(appDir, guide.href, "page.tsx");
39
+ const relContent = path.relative(appDir, dir);
40
+ const urlSegments = relContent
41
+ ? relContent
42
+ .split(path.sep)
43
+ .filter((s) => !(s.startsWith("(") && s.endsWith(")")))
44
+ : [];
45
+ const urlPrefix = urlSegments.length ? "/" + urlSegments.join("/") : "";
46
+ let relHref = guide.href;
47
+ if (urlPrefix && relHref.startsWith(urlPrefix + "/")) {
48
+ relHref = relHref.slice(urlPrefix.length + 1);
49
+ } else if (relHref.startsWith("/")) {
50
+ relHref = relHref.slice(1);
51
+ }
52
+ const pagePath = path.join(dir, relHref, "page.tsx");
36
53
  let rawSource = "";
37
54
  try {
38
55
  rawSource = fs.readFileSync(pagePath, "utf-8");
package/src/lib/track.ts CHANGED
@@ -8,6 +8,54 @@
8
8
 
9
9
  import { captureFromWindow } from "./analytics-context";
10
10
 
11
+ /**
12
+ * Rewrite a booking URL (Cal.com, Calendly, etc.) so the booking webhook can
13
+ * attribute the booking back to the specific landing page it came from.
14
+ *
15
+ * Cal.com's hosted booking page captures plain `utm_source/utm_medium/utm_campaign`
16
+ * URL query params and stores them on the booking's metadata, which Cal.com
17
+ * then forwards in the webhook payload as `booking.metadata.utm_*`. We also
18
+ * emit Cal.com's bracketed `metadata[key]=value` form as a belt-and-braces
19
+ * fallback (some Cal.com surfaces accept that syntax instead). Our webhook
20
+ * handler (social-autoposter-website/api/webhooks/cal/route.ts) writes
21
+ * `booking.metadata.utm_*` into `cal_bookings.utm_source / utm_medium /
22
+ * utm_campaign`, so every booking carries the originating site + page path.
23
+ *
24
+ * Attribution scheme:
25
+ * utm_source = current hostname (e.g. "fazm.com")
26
+ * utm_medium = "schedule_click"
27
+ * utm_campaign = current pathname (e.g. "/t/how-to-quit-weed")
28
+ *
29
+ * SSR-safe: returns the URL unchanged on the server (no `window`) or when the
30
+ * input cannot be parsed as a URL. Never overwrites a pre-existing query key
31
+ * so manual overrides on specific CTAs still win.
32
+ *
33
+ * Applied automatically by `BookCallCTA`, and by `InlineCta` /
34
+ * `StickyBottomCta` when `trackAs === "schedule"`. Consumers that build a
35
+ * custom Book-a-Call CTA MUST route their href through this helper or
36
+ * page-level booking attribution breaks.
37
+ */
38
+ export function withBookingAttribution(destination: string): string {
39
+ if (typeof window === "undefined") return destination;
40
+ try {
41
+ const url = new URL(destination, window.location.href);
42
+ const source = window.location.hostname;
43
+ const campaign = window.location.pathname || "/";
44
+ const setIfAbsent = (key: string, value: string) => {
45
+ if (!url.searchParams.has(key)) url.searchParams.set(key, value);
46
+ };
47
+ setIfAbsent("utm_source", source);
48
+ setIfAbsent("utm_medium", "schedule_click");
49
+ setIfAbsent("utm_campaign", campaign);
50
+ setIfAbsent("metadata[utm_source]", source);
51
+ setIfAbsent("metadata[utm_medium]", "schedule_click");
52
+ setIfAbsent("metadata[utm_campaign]", campaign);
53
+ return url.toString();
54
+ } catch {
55
+ return destination;
56
+ }
57
+ }
58
+
11
59
  export interface ScheduleClickProps {
12
60
  /** Absolute URL the click sends the user to (e.g. https://cal.com/team/mediar/demo). */
13
61
  destination: string;
package/src/server.ts CHANGED
@@ -11,6 +11,13 @@ export type {
11
11
  GenerateSitemapOptions,
12
12
  } from "./lib/generate-sitemap";
13
13
 
14
+ export { generateRobots } from "./lib/generate-robots";
15
+ export type {
16
+ RobotsRule,
17
+ GenerateRobotsOptions,
18
+ GeneratedRobots,
19
+ } from "./lib/generate-robots";
20
+
14
21
  export { slugify } from "./lib/slugify";
15
22
 
16
23
  export { createGuideChatHandler } from "./lib/guide-chat-route";