@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 +1 -1
- package/src/lib/discover-guides.ts +48 -23
- package/src/next.js +149 -6
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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": [
|
|
26
|
-
"/api/guide-chat/route": [
|
|
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
|
+
}
|