@m13v/seo-components 0.8.9 → 0.8.11

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.8.9",
3
+ "version": "0.8.11",
4
4
  "scripts": {
5
5
  "build:css": "tailwind -i src/_build.css -o dist/styles.css --minify",
6
6
  "prepublishOnly": "npm run build:css"
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { walkPages, type PageEntry } from "./walk-pages";
3
4
 
@@ -25,31 +26,55 @@ export function discoverGuides(contentDir?: string): GuideEntry[] {
25
26
 
26
27
  if (cachedDir === dir && cachedGuides) return cachedGuides;
27
28
 
28
- // Determine the Next.js app directory root from contentDir.
29
- // e.g. "src/app/t" -> "src/app", "app" -> "app", "src/app/(content)/t" -> "src/app"
30
- const relDir = path.relative(process.cwd(), dir);
31
- const relParts = relDir.split(path.sep);
32
- const appIdx = relParts.indexOf("app");
33
- const appDir =
34
- appIdx >= 0
35
- ? path.join(process.cwd(), ...relParts.slice(0, appIdx + 1))
36
- : path.join(process.cwd(), "src/app");
37
- const allPages = walkPages({ appDir });
29
+ // Try to load the build-time manifest first (generated by withSeoContent).
30
+ // This is required on Vercel where fs.readdirSync can't walk source dirs.
31
+ // Check both project root and .next/ for backwards compat.
32
+ const candidates = [
33
+ path.join(process.cwd(), "seo-guides-manifest.json"),
34
+ path.join(process.cwd(), ".next", "seo-guides-manifest.json"),
35
+ ];
36
+ let manifestPages: PageEntry[] | null = null;
37
+ for (const manifestPath of candidates) {
38
+ try {
39
+ const raw = fs.readFileSync(manifestPath, "utf-8");
40
+ const manifest = JSON.parse(raw);
41
+ if (Array.isArray(manifest.pages)) {
42
+ manifestPages = manifest.pages;
43
+ break;
44
+ }
45
+ } catch {
46
+ // Try next candidate
47
+ }
48
+ }
38
49
 
39
- // Figure out the href prefix from contentDir.
40
- // e.g. "src/app/(content)/t" -> "/t", "src/app/t" -> "/t"
41
- const relative = path.relative(appDir, dir);
42
- const segments = relative
43
- ? relative
44
- .split(path.sep)
45
- .filter((s) => !(s.startsWith("(") && s.endsWith(")")))
46
- : [];
47
- const hrefPrefix = "/" + segments.join("/");
50
+ let guides: GuideEntry[];
48
51
 
49
- const guides: GuideEntry[] = allPages
50
- .filter((p) => p.href.startsWith(hrefPrefix + "/") || p.href === hrefPrefix)
51
- .filter((p) => p.href !== hrefPrefix) // exclude the index page
52
- .map(pageToGuide);
52
+ if (manifestPages && manifestPages.length > 0) {
53
+ guides = manifestPages.map(pageToGuide);
54
+ } else {
55
+ // Fallback: walk the filesystem (works locally, may fail on Vercel)
56
+ const relDir = path.relative(process.cwd(), dir);
57
+ const relParts = relDir.split(path.sep);
58
+ const appIdx = relParts.indexOf("app");
59
+ const appDir =
60
+ appIdx >= 0
61
+ ? path.join(process.cwd(), ...relParts.slice(0, appIdx + 1))
62
+ : path.join(process.cwd(), "src/app");
63
+ const allPages = walkPages({ appDir });
64
+
65
+ const relative = path.relative(appDir, dir);
66
+ const segments = relative
67
+ ? relative
68
+ .split(path.sep)
69
+ .filter((s) => !(s.startsWith("(") && s.endsWith(")")))
70
+ : [];
71
+ const hrefPrefix = "/" + segments.join("/");
72
+
73
+ guides = allPages
74
+ .filter((p) => p.href.startsWith(hrefPrefix + "/") || p.href === hrefPrefix)
75
+ .filter((p) => p.href !== hrefPrefix)
76
+ .map(pageToGuide);
77
+ }
53
78
 
54
79
  guides.sort(
55
80
  (a, b) =>
package/src/next.js CHANGED
@@ -1,8 +1,18 @@
1
+ import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { join, sep, relative } from "node:path";
3
+
1
4
  /**
2
5
  * Wrap a Next.js config so the guide-chat API route can read content files
3
- * at runtime on Vercel. Adds `outputFileTracingIncludes` for the guide-chat
4
- * endpoint so Next.js's file tracer includes every page.tsx under `contentDir`
5
- * in the serverless function bundle.
6
+ * at runtime on Vercel. Does two things:
7
+ *
8
+ * 1. Walks `contentDir` at config evaluation time (during `next build`) and
9
+ * writes a JSON manifest of all discovered pages to
10
+ * `.next/seo-guides-manifest.json`. The serverless function reads this
11
+ * manifest instead of walking the filesystem at runtime.
12
+ *
13
+ * 2. Adds `outputFileTracingIncludes` so Next.js bundles every page.tsx
14
+ * under `contentDir` into the guide-chat serverless function (needed for
15
+ * reading article text at runtime).
6
16
  *
7
17
  * Usage (next.config.ts or next.config.mjs):
8
18
  *
@@ -16,14 +26,147 @@
16
26
  */
17
27
  export function withSeoContent(config = {}, opts = {}) {
18
28
  const contentDir = opts.contentDir ?? "src/app/t";
19
- const glob = `./${contentDir}/**/*`;
29
+ const contentGlob = `./${contentDir}/**/*`;
20
30
  const existing = config.outputFileTracingIncludes ?? {};
31
+
32
+ // Build a manifest at config evaluation time (runs during `next build`).
33
+ // Write to project root so outputFileTracingIncludes can find it reliably.
34
+ const manifestFile = "seo-guides-manifest.json";
35
+ try {
36
+ const manifest = buildManifest(contentDir);
37
+ writeFileSync(join(process.cwd(), manifestFile), JSON.stringify(manifest));
38
+ } catch (e) {
39
+ // Non-fatal: the runtime will fall back to filesystem walking
40
+ }
41
+
21
42
  return {
22
43
  ...config,
23
44
  outputFileTracingIncludes: {
24
45
  ...existing,
25
- "/api/guide-chat": [glob],
26
- "/api/guide-chat/route": [glob],
46
+ "/api/guide-chat": [contentGlob, `./${manifestFile}`],
47
+ "/api/guide-chat/route": [contentGlob, `./${manifestFile}`],
27
48
  },
28
49
  };
29
50
  }
51
+
52
+ /* ------------------------------------------------------------------ */
53
+ /* Build-time manifest generation (mirrors walk-pages.ts logic) */
54
+ /* ------------------------------------------------------------------ */
55
+
56
+ const CONST_TITLE_RE = /const\s+TITLE\s*=\s*["'`](.+?)["'`]/;
57
+ const CONST_DESC_RE = /const\s+DESCRIPTION\s*=\s*\n?\s*["'`](.+?)["'`]/;
58
+ const CONST_DATE_RE = /const\s+DATE_PUBLISHED\s*=\s*["'`](.+?)["'`]/;
59
+ const META_TITLE_RE = /title:\s*["'`](.+?)["'`]/;
60
+ const META_DESC_RE = /description:\s*["'`](.+?)["'`]/;
61
+ const H2_RE = /<h2\b[^>]*>([\s\S]*?)<\/h2>/g;
62
+ const FAQ_SECTION_RE = /<FaqSection\b/;
63
+
64
+ function slugify(text) {
65
+ return text
66
+ .toLowerCase()
67
+ .replace(/[^a-z0-9]+/g, "-")
68
+ .replace(/^-+|-+$/g, "")
69
+ .slice(0, 80);
70
+ }
71
+
72
+ function extractMeta(src, fallbackSlug) {
73
+ const title =
74
+ src.match(CONST_TITLE_RE)?.[1] ??
75
+ src.match(META_TITLE_RE)?.[1] ??
76
+ fallbackSlug.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
77
+ const description =
78
+ src.match(CONST_DESC_RE)?.[1] ?? src.match(META_DESC_RE)?.[1] ?? "";
79
+ const datePublished = src.match(CONST_DATE_RE)?.[1] ?? undefined;
80
+ return { title, description, datePublished };
81
+ }
82
+
83
+ function extractSections(src) {
84
+ const seen = new Set();
85
+ const out = [];
86
+ let m;
87
+ H2_RE.lastIndex = 0;
88
+ while ((m = H2_RE.exec(src)) !== null) {
89
+ const text = m[1]
90
+ .replace(/\{[^}]*\}/g, " ")
91
+ .replace(/<[^>]+>/g, " ")
92
+ .replace(/\s+/g, " ")
93
+ .trim();
94
+ if (!text) continue;
95
+ const base = slugify(text);
96
+ if (!base) continue;
97
+ let id = base;
98
+ let i = 2;
99
+ while (seen.has(id)) { id = `${base}-${i++}`; }
100
+ seen.add(id);
101
+ out.push({ id, title: text });
102
+ }
103
+ if (FAQ_SECTION_RE.test(src)) {
104
+ const faqId = "frequently-asked-questions";
105
+ if (!seen.has(faqId)) out.push({ id: faqId, title: "Frequently asked questions" });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function buildManifest(contentDir) {
111
+ const cwd = process.cwd();
112
+ const relParts = contentDir.split(/[/\\]/);
113
+ const appIdx = relParts.indexOf("app");
114
+ const appDirRel = appIdx >= 0 ? relParts.slice(0, appIdx + 1).join(sep) : contentDir;
115
+ const appDir = join(cwd, appDirRel);
116
+ const contentAbs = join(cwd, contentDir);
117
+
118
+ const pages = [];
119
+
120
+ function walk(dir, urlSegments) {
121
+ let entries;
122
+ try { entries = readdirSync(dir, { withFileTypes: true }); }
123
+ catch { return; }
124
+
125
+ for (const entry of entries) {
126
+ if (entry.isFile() && entry.name === "page.tsx") {
127
+ const href = urlSegments.length === 0 ? "/" : "/" + urlSegments.join("/");
128
+ if (href === "/") continue;
129
+ const filePath = join(dir, entry.name);
130
+ let src;
131
+ try { src = readFileSync(filePath, "utf-8"); }
132
+ catch { continue; }
133
+ const lastSeg = urlSegments[urlSegments.length - 1] ?? "";
134
+ const meta = extractMeta(src, lastSeg);
135
+ const sections = extractSections(src);
136
+ pages.push({
137
+ href,
138
+ title: meta.title,
139
+ description: meta.description,
140
+ datePublished: meta.datePublished,
141
+ sections,
142
+ category: urlSegments[0] ?? "",
143
+ });
144
+ continue;
145
+ }
146
+ if (!entry.isDirectory()) continue;
147
+ const name = entry.name;
148
+ if (name.startsWith("_") || name === "api" || (name.startsWith("[") && name.endsWith("]"))) continue;
149
+ const isRouteGroup = name.startsWith("(") && name.endsWith(")");
150
+ walk(join(dir, name), isRouteGroup ? urlSegments : [...urlSegments, name]);
151
+ }
152
+ }
153
+
154
+ walk(appDir, []);
155
+
156
+ // Filter to contentDir prefix
157
+ const relContent = relative(appDir, contentAbs);
158
+ const segments = relContent
159
+ ? relContent.split(sep).filter(s => !(s.startsWith("(") && s.endsWith(")")))
160
+ : [];
161
+ const hrefPrefix = "/" + segments.join("/");
162
+
163
+ const filtered = pages.filter(p =>
164
+ (p.href.startsWith(hrefPrefix + "/") || p.href === hrefPrefix) && p.href !== hrefPrefix
165
+ );
166
+
167
+ filtered.sort((a, b) =>
168
+ (b.datePublished ?? "").localeCompare(a.datePublished ?? "") || a.title.localeCompare(b.title)
169
+ );
170
+
171
+ return { contentDir, appDir: appDirRel, pages: filtered };
172
+ }