@seoblog/next 0.1.1 → 0.1.3
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 +234 -0
- package/dist/cli/detect.d.ts +14 -0
- package/dist/cli/detect.js +61 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +29 -0
- package/dist/cli/prompt.d.ts +7 -0
- package/dist/cli/prompt.js +74 -0
- package/dist/cli/scaffold.d.ts +14 -0
- package/dist/cli/scaffold.js +48 -0
- package/dist/cli/setup.d.ts +1 -0
- package/dist/cli/setup.js +127 -0
- package/dist/cli/templates.d.ts +11 -0
- package/dist/cli/templates.js +217 -0
- package/dist/index.js +1 -1
- package/package.json +16 -3
package/README.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# @seoblog/next
|
|
2
|
+
|
|
3
|
+
The official Next.js SDK + CLI for [SEO Blog](https://www.nextseoblog.com) — an AI service that writes, schedules, and publishes SEO-optimized blog posts straight into your Next.js site.
|
|
4
|
+
|
|
5
|
+
- **One-command setup** — `npx @seoblog/next setup` scaffolds an entire blog (index, post page, sitemap, RSS, JSON-LD) wired to your dashboard.
|
|
6
|
+
- **App Router native** — uses Server Components, `generateMetadata`, `generateStaticParams`, and ISR.
|
|
7
|
+
- **Typed API client** — `getPosts()` / `getPost(slug)` with full TypeScript types.
|
|
8
|
+
- **Zero lock-in** — your content is plain Markdown; pull it any time.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
In the root of your Next.js app:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @seoblog/next setup
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The CLI will:
|
|
21
|
+
|
|
22
|
+
1. Detect your project (App Router, TypeScript, `src/` layout, Tailwind).
|
|
23
|
+
2. Ask where the blog should live and a few SEO details.
|
|
24
|
+
3. Scaffold:
|
|
25
|
+
- `app/blog/page.tsx` — listing
|
|
26
|
+
- `app/blog/[slug]/page.tsx` — post page with metadata + JSON-LD
|
|
27
|
+
- `app/blog/feed.xml/route.ts` — RSS feed
|
|
28
|
+
- `app/sitemap.ts` — sitemap that includes your posts
|
|
29
|
+
- `lib/seoblog.ts` — typed client reading `SEOBLOG_API_KEY` from env
|
|
30
|
+
- `.env.local` and `.env.example` entries
|
|
31
|
+
4. Print the exact install command for your package manager.
|
|
32
|
+
|
|
33
|
+
Then install the runtime deps it prints (typically `@seoblog/next marked`), paste your API key from the [dashboard](https://www.nextseoblog.com/dashboard) into `.env.local`, and visit `/blog`.
|
|
34
|
+
|
|
35
|
+
> **Requirements:** Next.js 14+ with the App Router. Pages Router users can still use the API client manually — see below.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Installation (manual)
|
|
40
|
+
|
|
41
|
+
If you'd rather wire things up yourself:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install @seoblog/next
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Set your API key in `.env.local`:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
SEOBLOG_API_KEY=sk_live_...
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Get the key from your [SEO Blog dashboard](https://www.nextseoblog.com/dashboard) → site → API Keys.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## API reference
|
|
58
|
+
|
|
59
|
+
### `getPosts(options)`
|
|
60
|
+
|
|
61
|
+
Returns every published post for the site bound to your API key, ordered by `publishedAt` descending.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { getPosts } from "@seoblog/next";
|
|
65
|
+
|
|
66
|
+
const posts = await getPosts({ apiKey: process.env.SEOBLOG_API_KEY! });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Options**
|
|
70
|
+
|
|
71
|
+
| Option | Type | Default | Description |
|
|
72
|
+
| ------------ | ------------------- | ---------------------------------- | ------------------------------------------------- |
|
|
73
|
+
| `apiKey` | `string` (required) | — | Your site's API key. |
|
|
74
|
+
| `apiUrl` | `string` | `"https://www.nextseoblog.com"` | Override for self-hosting or local development. |
|
|
75
|
+
| `revalidate` | `number` | `3600` | ISR revalidation in seconds. Pass `0` for `no-store`. |
|
|
76
|
+
|
|
77
|
+
### `getPost(slug, options)`
|
|
78
|
+
|
|
79
|
+
Returns a single post by slug, or `null` if not found.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { getPost } from "@seoblog/next";
|
|
83
|
+
|
|
84
|
+
const post = await getPost("my-first-post", {
|
|
85
|
+
apiKey: process.env.SEOBLOG_API_KEY!,
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### `BlogPost`
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
interface BlogPost {
|
|
93
|
+
id: string;
|
|
94
|
+
title: string;
|
|
95
|
+
slug: string;
|
|
96
|
+
metaTitle: string; // ≤60 chars
|
|
97
|
+
metaDescription: string; // ≤160 chars
|
|
98
|
+
excerpt: string;
|
|
99
|
+
tags: string[];
|
|
100
|
+
contentMd: string; // Markdown body
|
|
101
|
+
coverImageUrl: string | null;
|
|
102
|
+
publishedAt: string; // ISO 8601
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Recipes
|
|
109
|
+
|
|
110
|
+
### Listing page
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
// app/blog/page.tsx
|
|
114
|
+
import Link from "next/link";
|
|
115
|
+
import { getPosts } from "@/lib/seoblog";
|
|
116
|
+
|
|
117
|
+
export default async function BlogPage() {
|
|
118
|
+
const posts = await getPosts();
|
|
119
|
+
return (
|
|
120
|
+
<ul>
|
|
121
|
+
{posts.map((p) => (
|
|
122
|
+
<li key={p.id}>
|
|
123
|
+
<Link href={`/blog/${p.slug}`}>{p.title}</Link>
|
|
124
|
+
</li>
|
|
125
|
+
))}
|
|
126
|
+
</ul>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Post page with metadata + JSON-LD
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
// app/blog/[slug]/page.tsx
|
|
135
|
+
import { notFound } from "next/navigation";
|
|
136
|
+
import { marked } from "marked";
|
|
137
|
+
import { getPost, getPosts } from "@/lib/seoblog";
|
|
138
|
+
|
|
139
|
+
export async function generateStaticParams() {
|
|
140
|
+
const posts = await getPosts();
|
|
141
|
+
return posts.map((p) => ({ slug: p.slug }));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function generateMetadata({ params }) {
|
|
145
|
+
const { slug } = await params;
|
|
146
|
+
const post = await getPost(slug);
|
|
147
|
+
if (!post) return {};
|
|
148
|
+
return {
|
|
149
|
+
title: post.metaTitle,
|
|
150
|
+
description: post.metaDescription,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export default async function Page({ params }) {
|
|
155
|
+
const { slug } = await params;
|
|
156
|
+
const post = await getPost(slug);
|
|
157
|
+
if (!post) notFound();
|
|
158
|
+
return <article dangerouslySetInnerHTML={{ __html: marked.parse(post.contentMd) }} />;
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Cache control
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
// Cache for one hour (default)
|
|
166
|
+
await getPosts({ apiKey, revalidate: 3600 });
|
|
167
|
+
|
|
168
|
+
// Always fresh (skip cache)
|
|
169
|
+
await getPosts({ apiKey, revalidate: 0 });
|
|
170
|
+
|
|
171
|
+
// Cache for a day
|
|
172
|
+
await getPosts({ apiKey, revalidate: 86400 });
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The SDK uses Next's built-in `fetch` cache, so revalidation works the same as any `fetch()` call in a Server Component.
|
|
176
|
+
|
|
177
|
+
### Local development against a self-hosted dashboard
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
await getPosts({
|
|
181
|
+
apiKey: process.env.SEOBLOG_API_KEY!,
|
|
182
|
+
apiUrl: "http://localhost:3000",
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Environment variables
|
|
189
|
+
|
|
190
|
+
| Variable | Required | Description |
|
|
191
|
+
| ---------------------- | -------- | ------------------------------------------------------ |
|
|
192
|
+
| `SEOBLOG_API_KEY` | ✅ | Your site API key from the dashboard. |
|
|
193
|
+
| `NEXT_PUBLIC_SITE_URL` | optional | Used by the scaffolded sitemap and RSS feed. |
|
|
194
|
+
|
|
195
|
+
Never expose `SEOBLOG_API_KEY` to the client — keep it server-side only (no `NEXT_PUBLIC_` prefix).
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## CLI commands
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
npx @seoblog/next setup Scaffold a Next.js blog
|
|
203
|
+
npx @seoblog/next help Show usage
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The `setup` command is idempotent in spirit — it will refuse to overwrite existing files. If a conflict is found, it lists the offending paths and exits non-zero so you can resolve before retrying.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Troubleshooting
|
|
211
|
+
|
|
212
|
+
**"Unauthorized" / 401 from `getPosts`**
|
|
213
|
+
The API key is missing, malformed, or revoked. Confirm `SEOBLOG_API_KEY` is set on the server (Vercel project env vars, not just `.env.local` in production) and that the key still exists in the dashboard.
|
|
214
|
+
|
|
215
|
+
**Posts don't show up after publishing**
|
|
216
|
+
Next's ISR cache is held until `revalidate` expires. Either drop `revalidate` lower, or trigger an on-demand revalidation from the dashboard's webhook (coming soon — `revalidatePath('/blog')`).
|
|
217
|
+
|
|
218
|
+
**`marked` not found**
|
|
219
|
+
The setup CLI prints it in the install step. If you skipped that, run `npm install marked`.
|
|
220
|
+
|
|
221
|
+
**Pages Router**
|
|
222
|
+
Not currently scaffolded. You can still call `getPosts` / `getPost` from `getStaticProps` / `getStaticPaths` — only the file structure differs.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Links
|
|
227
|
+
|
|
228
|
+
- Website: <https://www.nextseoblog.com>
|
|
229
|
+
- Dashboard: <https://www.nextseoblog.com/dashboard>
|
|
230
|
+
- Issues: <https://github.com/antonyefanov/nextseoblog/issues>
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ProjectInfo {
|
|
2
|
+
cwd: string;
|
|
3
|
+
isNextProject: boolean;
|
|
4
|
+
nextVersion: string | null;
|
|
5
|
+
router: "app" | "pages" | "unknown";
|
|
6
|
+
appDir: string | null;
|
|
7
|
+
hasSrcDir: boolean;
|
|
8
|
+
isTypescript: boolean;
|
|
9
|
+
hasTailwind: boolean;
|
|
10
|
+
hasTailwindTypography: boolean;
|
|
11
|
+
existingBlog: string | null;
|
|
12
|
+
packageManager: "npm" | "pnpm" | "yarn" | "bun";
|
|
13
|
+
}
|
|
14
|
+
export declare function detectProject(cwd: string): ProjectInfo;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
function readJson(path) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function detectPackageManager(cwd) {
|
|
12
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml")))
|
|
13
|
+
return "pnpm";
|
|
14
|
+
if (existsSync(join(cwd, "yarn.lock")))
|
|
15
|
+
return "yarn";
|
|
16
|
+
if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock")))
|
|
17
|
+
return "bun";
|
|
18
|
+
return "npm";
|
|
19
|
+
}
|
|
20
|
+
export function detectProject(cwd) {
|
|
21
|
+
const pkg = readJson(join(cwd, "package.json"));
|
|
22
|
+
const deps = {
|
|
23
|
+
...(pkg?.dependencies ?? {}),
|
|
24
|
+
...(pkg?.devDependencies ?? {}),
|
|
25
|
+
};
|
|
26
|
+
const nextVersion = deps["next"] ?? null;
|
|
27
|
+
const isNextProject = Boolean(nextVersion);
|
|
28
|
+
const hasSrcDir = existsSync(join(cwd, "src"));
|
|
29
|
+
const root = hasSrcDir ? join(cwd, "src") : cwd;
|
|
30
|
+
const appDirCandidate = join(root, "app");
|
|
31
|
+
const pagesDirCandidate = join(root, "pages");
|
|
32
|
+
const hasApp = existsSync(appDirCandidate);
|
|
33
|
+
const hasPages = existsSync(pagesDirCandidate);
|
|
34
|
+
const router = hasApp ? "app" : hasPages ? "pages" : "unknown";
|
|
35
|
+
const isTypescript = existsSync(join(cwd, "tsconfig.json")) || Boolean(deps["typescript"]);
|
|
36
|
+
const hasTailwind = Boolean(deps["tailwindcss"]);
|
|
37
|
+
const hasTailwindTypography = Boolean(deps["@tailwindcss/typography"]);
|
|
38
|
+
const blogCandidates = [
|
|
39
|
+
join(root, "app", "blog"),
|
|
40
|
+
join(root, "app", "posts"),
|
|
41
|
+
join(root, "pages", "blog"),
|
|
42
|
+
join(root, "pages", "posts"),
|
|
43
|
+
join(cwd, "content", "blog"),
|
|
44
|
+
join(cwd, "content", "posts"),
|
|
45
|
+
join(cwd, "posts"),
|
|
46
|
+
];
|
|
47
|
+
const existingBlog = blogCandidates.find((p) => existsSync(p)) ?? null;
|
|
48
|
+
return {
|
|
49
|
+
cwd,
|
|
50
|
+
isNextProject,
|
|
51
|
+
nextVersion,
|
|
52
|
+
router,
|
|
53
|
+
appDir: hasApp ? appDirCandidate : null,
|
|
54
|
+
hasSrcDir,
|
|
55
|
+
isTypescript,
|
|
56
|
+
hasTailwind,
|
|
57
|
+
hasTailwindTypography,
|
|
58
|
+
existingBlog,
|
|
59
|
+
packageManager: detectPackageManager(cwd),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runSetup } from "./setup.js";
|
|
3
|
+
const HELP = `@seoblog/next — CLI
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
npx @seoblog/next setup Scaffold a Next.js blog wired to your SEO Blog dashboard
|
|
7
|
+
npx @seoblog/next help Show this message
|
|
8
|
+
|
|
9
|
+
Docs: https://www.nextseoblog.com/docs
|
|
10
|
+
`;
|
|
11
|
+
async function main() {
|
|
12
|
+
const [, , cmd = "setup"] = process.argv;
|
|
13
|
+
switch (cmd) {
|
|
14
|
+
case "setup":
|
|
15
|
+
return runSetup();
|
|
16
|
+
case "help":
|
|
17
|
+
case "--help":
|
|
18
|
+
case "-h":
|
|
19
|
+
process.stdout.write(HELP);
|
|
20
|
+
return 0;
|
|
21
|
+
default:
|
|
22
|
+
process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}`);
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
main().then((code) => process.exit(code), (err) => {
|
|
27
|
+
console.error(err instanceof Error ? err.message : err);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function ask(question: string, defaultValue?: string): Promise<string>;
|
|
2
|
+
export declare function confirm(question: string, defaultYes?: boolean): Promise<boolean>;
|
|
3
|
+
export declare function select<T extends string>(question: string, options: {
|
|
4
|
+
value: T;
|
|
5
|
+
label: string;
|
|
6
|
+
}[], defaultIndex?: number): Promise<T>;
|
|
7
|
+
export declare function closePrompt(): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
let rl = null;
|
|
4
|
+
let lineQueue = [];
|
|
5
|
+
let waiters = [];
|
|
6
|
+
let closed = false;
|
|
7
|
+
function ensureReader() {
|
|
8
|
+
if (rl)
|
|
9
|
+
return;
|
|
10
|
+
rl = createInterface({ input: stdin, terminal: false });
|
|
11
|
+
rl.on("line", (line) => {
|
|
12
|
+
if (waiters.length > 0) {
|
|
13
|
+
const resolve = waiters.shift();
|
|
14
|
+
resolve(line);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
lineQueue.push(line);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
rl.on("close", () => {
|
|
21
|
+
closed = true;
|
|
22
|
+
while (waiters.length > 0) {
|
|
23
|
+
const resolve = waiters.shift();
|
|
24
|
+
resolve(null);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function readLine() {
|
|
29
|
+
ensureReader();
|
|
30
|
+
if (lineQueue.length > 0)
|
|
31
|
+
return Promise.resolve(lineQueue.shift());
|
|
32
|
+
if (closed)
|
|
33
|
+
return Promise.resolve(null);
|
|
34
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
35
|
+
}
|
|
36
|
+
export async function ask(question, defaultValue) {
|
|
37
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
38
|
+
stdout.write(`${question}${suffix} `);
|
|
39
|
+
const line = await readLine();
|
|
40
|
+
const answer = (line ?? "").trim();
|
|
41
|
+
return answer || defaultValue || "";
|
|
42
|
+
}
|
|
43
|
+
export async function confirm(question, defaultYes = true) {
|
|
44
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
45
|
+
stdout.write(`${question} [${hint}] `);
|
|
46
|
+
const line = await readLine();
|
|
47
|
+
const answer = (line ?? "").trim().toLowerCase();
|
|
48
|
+
if (!answer)
|
|
49
|
+
return defaultYes;
|
|
50
|
+
return answer === "y" || answer === "yes";
|
|
51
|
+
}
|
|
52
|
+
export async function select(question, options, defaultIndex = 0) {
|
|
53
|
+
console.log(question);
|
|
54
|
+
options.forEach((opt, i) => {
|
|
55
|
+
const marker = i === defaultIndex ? "›" : " ";
|
|
56
|
+
console.log(` ${marker} ${i + 1}. ${opt.label}`);
|
|
57
|
+
});
|
|
58
|
+
stdout.write(`Pick (1-${options.length}, default ${defaultIndex + 1}): `);
|
|
59
|
+
const line = await readLine();
|
|
60
|
+
const answer = (line ?? "").trim();
|
|
61
|
+
if (!answer)
|
|
62
|
+
return options[defaultIndex].value;
|
|
63
|
+
const idx = parseInt(answer, 10) - 1;
|
|
64
|
+
if (Number.isNaN(idx) || idx < 0 || idx >= options.length) {
|
|
65
|
+
return options[defaultIndex].value;
|
|
66
|
+
}
|
|
67
|
+
return options[idx].value;
|
|
68
|
+
}
|
|
69
|
+
export function closePrompt() {
|
|
70
|
+
if (rl) {
|
|
71
|
+
rl.close();
|
|
72
|
+
rl = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ScaffoldFile {
|
|
2
|
+
path: string;
|
|
3
|
+
content: string;
|
|
4
|
+
/** If true and file exists, skip. If false and file exists, error. */
|
|
5
|
+
skipIfExists?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface WriteResult {
|
|
8
|
+
written: string[];
|
|
9
|
+
skipped: string[];
|
|
10
|
+
conflicts: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function writeFiles(cwd: string, files: ScaffoldFile[]): WriteResult;
|
|
13
|
+
export declare function appendEnvLocal(cwd: string, entries: Record<string, string>): "created" | "updated" | "noop";
|
|
14
|
+
export declare function relPath(cwd: string, path: string): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
export function writeFiles(cwd, files) {
|
|
4
|
+
const written = [];
|
|
5
|
+
const skipped = [];
|
|
6
|
+
const conflicts = [];
|
|
7
|
+
for (const file of files) {
|
|
8
|
+
const abs = join(cwd, file.path);
|
|
9
|
+
if (existsSync(abs)) {
|
|
10
|
+
if (file.skipIfExists) {
|
|
11
|
+
skipped.push(file.path);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
conflicts.push(file.path);
|
|
15
|
+
}
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
19
|
+
writeFileSync(abs, file.content, "utf8");
|
|
20
|
+
written.push(file.path);
|
|
21
|
+
}
|
|
22
|
+
return { written, skipped, conflicts };
|
|
23
|
+
}
|
|
24
|
+
export function appendEnvLocal(cwd, entries) {
|
|
25
|
+
const envPath = join(cwd, ".env.local");
|
|
26
|
+
let existing = "";
|
|
27
|
+
let existed = false;
|
|
28
|
+
if (existsSync(envPath)) {
|
|
29
|
+
existed = true;
|
|
30
|
+
existing = readFileSync(envPath, "utf8");
|
|
31
|
+
}
|
|
32
|
+
const toAdd = [];
|
|
33
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
34
|
+
const pattern = new RegExp(`^${key}=`, "m");
|
|
35
|
+
if (!pattern.test(existing)) {
|
|
36
|
+
toAdd.push(`${key}=${value}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (toAdd.length === 0)
|
|
40
|
+
return "noop";
|
|
41
|
+
const prefix = existed && !existing.endsWith("\n") ? "\n" : "";
|
|
42
|
+
const block = `${prefix}# Added by @seoblog/next setup\n${toAdd.join("\n")}\n`;
|
|
43
|
+
writeFileSync(envPath, existing + block, "utf8");
|
|
44
|
+
return existed ? "updated" : "created";
|
|
45
|
+
}
|
|
46
|
+
export function relPath(cwd, path) {
|
|
47
|
+
return relative(cwd, path) || ".";
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runSetup(): Promise<number>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { detectProject } from "./detect.js";
|
|
3
|
+
import { ask, confirm, closePrompt } from "./prompt.js";
|
|
4
|
+
import { appendEnvLocal, writeFiles, } from "./scaffold.js";
|
|
5
|
+
import { blogIndexPage, blogPostPage, envExample, feedRouteTs, libSeoblogTs, sitemapTs, } from "./templates.js";
|
|
6
|
+
const DASHBOARD_URL = "https://www.nextseoblog.com/dashboard";
|
|
7
|
+
function color(code, s) {
|
|
8
|
+
return `\x1b[${code}m${s}\x1b[0m`;
|
|
9
|
+
}
|
|
10
|
+
const bold = (s) => color(1, s);
|
|
11
|
+
const green = (s) => color(32, s);
|
|
12
|
+
const yellow = (s) => color(33, s);
|
|
13
|
+
const red = (s) => color(31, s);
|
|
14
|
+
const dim = (s) => color(2, s);
|
|
15
|
+
export async function runSetup() {
|
|
16
|
+
const cwd = process.cwd();
|
|
17
|
+
console.log(bold("\n@seoblog/next setup\n"));
|
|
18
|
+
const info = detectProject(cwd);
|
|
19
|
+
if (!info.isNextProject) {
|
|
20
|
+
console.log(red("✗ This doesn't look like a Next.js project."), "\n No `next` dependency found in package.json.", `\n Run this command from the root of your Next.js app.\n`);
|
|
21
|
+
closePrompt();
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
if (info.router === "pages") {
|
|
25
|
+
console.log(yellow("! Detected the Pages Router."), "\n This CLI currently only scaffolds for the App Router.", "\n Migrate to /app or import @seoblog/next manually in your pages.\n");
|
|
26
|
+
closePrompt();
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
console.log(`${green("✓")} Next.js ${info.nextVersion ?? "?"} · ${info.router === "app" ? "App Router" : "unknown router"} · ${info.isTypescript ? "TypeScript" : "JavaScript"}${info.hasSrcDir ? " · src/" : ""}${info.hasTailwind ? " · Tailwind" : ""}`);
|
|
30
|
+
if (info.existingBlog) {
|
|
31
|
+
console.log(yellow(`! Existing blog detected at ${info.existingBlog}`), "\n This CLI will not touch it. Pick a different path below or cancel.\n");
|
|
32
|
+
}
|
|
33
|
+
const blogBasePath = await ask("Where should the blog live?", info.existingBlog ? "/blog-ai" : "/blog");
|
|
34
|
+
const normalizedBase = blogBasePath.startsWith("/")
|
|
35
|
+
? blogBasePath
|
|
36
|
+
: `/${blogBasePath}`;
|
|
37
|
+
const siteName = await ask("Site name (for RSS + metadata):", "My Site");
|
|
38
|
+
const siteUrl = await ask("Production site URL (used in sitemap + RSS):", "https://example.com");
|
|
39
|
+
const wantSitemap = await confirm("Generate app/sitemap.ts?", true);
|
|
40
|
+
const wantFeed = await confirm("Generate RSS feed at /blog/feed.xml?", true);
|
|
41
|
+
console.log();
|
|
42
|
+
closePrompt();
|
|
43
|
+
const rootSegment = info.hasSrcDir ? "src" : "";
|
|
44
|
+
const appBase = join(rootSegment, "app");
|
|
45
|
+
const libBase = join(rootSegment, "lib");
|
|
46
|
+
const blogRoute = join(appBase, normalizedBase.replace(/^\//, ""));
|
|
47
|
+
const ctx = {
|
|
48
|
+
blogBasePath: normalizedBase,
|
|
49
|
+
siteName,
|
|
50
|
+
siteUrl,
|
|
51
|
+
};
|
|
52
|
+
const files = [
|
|
53
|
+
{
|
|
54
|
+
path: join(libBase, "seoblog.ts"),
|
|
55
|
+
content: libSeoblogTs(),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
path: join(blogRoute, "page.tsx"),
|
|
59
|
+
content: blogIndexPage(ctx),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
path: join(blogRoute, "[slug]", "page.tsx"),
|
|
63
|
+
content: blogPostPage(ctx),
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
if (wantFeed) {
|
|
67
|
+
files.push({
|
|
68
|
+
path: join(blogRoute, "feed.xml", "route.ts"),
|
|
69
|
+
content: feedRouteTs(ctx),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (wantSitemap) {
|
|
73
|
+
files.push({
|
|
74
|
+
path: join(appBase, "sitemap.ts"),
|
|
75
|
+
content: sitemapTs(ctx),
|
|
76
|
+
skipIfExists: true,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
files.push({
|
|
80
|
+
path: ".env.example",
|
|
81
|
+
content: envExample(),
|
|
82
|
+
skipIfExists: true,
|
|
83
|
+
});
|
|
84
|
+
const result = writeFiles(cwd, files);
|
|
85
|
+
if (result.conflicts.length > 0) {
|
|
86
|
+
console.log(red("✗ Refusing to overwrite existing files:"));
|
|
87
|
+
for (const c of result.conflicts)
|
|
88
|
+
console.log(` ${c}`);
|
|
89
|
+
console.log("\n Move or delete them and re-run, or pass a different blog path.\n");
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
for (const f of result.written)
|
|
93
|
+
console.log(`${green("+")} ${f}`);
|
|
94
|
+
for (const f of result.skipped)
|
|
95
|
+
console.log(`${dim("·")} ${f} ${dim("(exists, skipped)")}`);
|
|
96
|
+
const envResult = appendEnvLocal(cwd, {
|
|
97
|
+
SEOBLOG_API_KEY: "",
|
|
98
|
+
NEXT_PUBLIC_SITE_URL: siteUrl,
|
|
99
|
+
});
|
|
100
|
+
if (envResult === "created")
|
|
101
|
+
console.log(`${green("+")} .env.local`);
|
|
102
|
+
if (envResult === "updated")
|
|
103
|
+
console.log(`${green("~")} .env.local ${dim("(appended)")}`);
|
|
104
|
+
const pmInstall = info.packageManager === "pnpm"
|
|
105
|
+
? "pnpm add"
|
|
106
|
+
: info.packageManager === "yarn"
|
|
107
|
+
? "yarn add"
|
|
108
|
+
: info.packageManager === "bun"
|
|
109
|
+
? "bun add"
|
|
110
|
+
: "npm install";
|
|
111
|
+
const missing = [];
|
|
112
|
+
if (!info.hasTailwindTypography && info.hasTailwind) {
|
|
113
|
+
missing.push("@tailwindcss/typography");
|
|
114
|
+
}
|
|
115
|
+
missing.push("marked");
|
|
116
|
+
console.log(`\n${bold("Next steps:")}`);
|
|
117
|
+
console.log(` 1. Install runtime deps:`);
|
|
118
|
+
console.log(` ${pmInstall} @seoblog/next ${missing.join(" ")}`);
|
|
119
|
+
console.log(` 2. Grab your API key from ${DASHBOARD_URL}`);
|
|
120
|
+
console.log(` and paste it into ${bold("SEOBLOG_API_KEY")} in .env.local`);
|
|
121
|
+
if (info.hasTailwind && !info.hasTailwindTypography) {
|
|
122
|
+
console.log(` 3. Add ${bold("@tailwindcss/typography")} to your tailwind config plugins`);
|
|
123
|
+
}
|
|
124
|
+
console.log(` ${info.hasTailwind && !info.hasTailwindTypography ? "4" : "3"}. Start your dev server and visit ${bold(normalizedBase)}`);
|
|
125
|
+
console.log();
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface TemplateContext {
|
|
2
|
+
blogBasePath: string;
|
|
3
|
+
siteName: string;
|
|
4
|
+
siteUrl: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function libSeoblogTs(): string;
|
|
7
|
+
export declare function blogIndexPage(ctx: TemplateContext): string;
|
|
8
|
+
export declare function blogPostPage(ctx: TemplateContext): string;
|
|
9
|
+
export declare function feedRouteTs(ctx: TemplateContext): string;
|
|
10
|
+
export declare function sitemapTs(ctx: TemplateContext): string;
|
|
11
|
+
export declare function envExample(): string;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
export function libSeoblogTs() {
|
|
2
|
+
return `import { getPosts as sdkGetPosts, getPost as sdkGetPost } from "@seoblog/next";
|
|
3
|
+
|
|
4
|
+
const apiKey = process.env.SEOBLOG_API_KEY;
|
|
5
|
+
|
|
6
|
+
if (!apiKey) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
"SEOBLOG_API_KEY is not set. Add it to .env.local — grab the key from https://www.nextseoblog.com/dashboard",
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getPosts() {
|
|
13
|
+
return sdkGetPosts({ apiKey: apiKey! });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getPost(slug: string) {
|
|
17
|
+
return sdkGetPost(slug, { apiKey: apiKey! });
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
}
|
|
21
|
+
export function blogIndexPage(ctx) {
|
|
22
|
+
return `import Link from "next/link";
|
|
23
|
+
import type { Metadata } from "next";
|
|
24
|
+
|
|
25
|
+
import { getPosts } from "@/lib/seoblog";
|
|
26
|
+
|
|
27
|
+
export const metadata: Metadata = {
|
|
28
|
+
title: "Blog | ${ctx.siteName}",
|
|
29
|
+
description: "Latest articles and updates.",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default async function BlogPage() {
|
|
33
|
+
const posts = await getPosts();
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<main className="mx-auto max-w-3xl px-4 py-12">
|
|
37
|
+
<h1 className="mb-8 text-4xl font-bold tracking-tight">Blog</h1>
|
|
38
|
+
<ul className="space-y-8">
|
|
39
|
+
{posts.map((post) => (
|
|
40
|
+
<li key={post.id} className="border-b pb-6 last:border-b-0">
|
|
41
|
+
<Link href={\`${ctx.blogBasePath}/\${post.slug}\`} className="group">
|
|
42
|
+
<h2 className="text-2xl font-semibold group-hover:underline">
|
|
43
|
+
{post.title}
|
|
44
|
+
</h2>
|
|
45
|
+
{post.excerpt ? (
|
|
46
|
+
<p className="mt-2 text-muted-foreground">{post.excerpt}</p>
|
|
47
|
+
) : null}
|
|
48
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
49
|
+
{post.publishedAt
|
|
50
|
+
? new Date(post.publishedAt).toLocaleDateString()
|
|
51
|
+
: null}
|
|
52
|
+
</p>
|
|
53
|
+
</Link>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
{posts.length === 0 ? (
|
|
57
|
+
<li className="text-muted-foreground">No posts yet — check back soon.</li>
|
|
58
|
+
) : null}
|
|
59
|
+
</ul>
|
|
60
|
+
</main>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
export function blogPostPage(ctx) {
|
|
66
|
+
return `import { notFound } from "next/navigation";
|
|
67
|
+
import { marked } from "marked";
|
|
68
|
+
import type { Metadata } from "next";
|
|
69
|
+
|
|
70
|
+
import { getPost, getPosts } from "@/lib/seoblog";
|
|
71
|
+
|
|
72
|
+
interface Props {
|
|
73
|
+
params: Promise<{ slug: string }>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function generateStaticParams() {
|
|
77
|
+
const posts = await getPosts();
|
|
78
|
+
return posts.map((p) => ({ slug: p.slug }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
82
|
+
const { slug } = await params;
|
|
83
|
+
const post = await getPost(slug);
|
|
84
|
+
if (!post) return {};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
title: post.metaTitle || post.title,
|
|
88
|
+
description: post.metaDescription,
|
|
89
|
+
openGraph: {
|
|
90
|
+
title: post.metaTitle || post.title,
|
|
91
|
+
description: post.metaDescription,
|
|
92
|
+
type: "article",
|
|
93
|
+
images: post.coverImageUrl ? [post.coverImageUrl] : undefined,
|
|
94
|
+
publishedTime: post.publishedAt,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default async function PostPage({ params }: Props) {
|
|
100
|
+
const { slug } = await params;
|
|
101
|
+
const post = await getPost(slug);
|
|
102
|
+
if (!post) notFound();
|
|
103
|
+
|
|
104
|
+
const html = marked.parse(post.contentMd) as string;
|
|
105
|
+
|
|
106
|
+
const jsonLd = {
|
|
107
|
+
"@context": "https://schema.org",
|
|
108
|
+
"@type": "Article",
|
|
109
|
+
headline: post.title,
|
|
110
|
+
description: post.metaDescription,
|
|
111
|
+
image: post.coverImageUrl ?? undefined,
|
|
112
|
+
datePublished: post.publishedAt,
|
|
113
|
+
keywords: post.tags.join(", "),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<main className="mx-auto max-w-3xl px-4 py-12">
|
|
118
|
+
<script
|
|
119
|
+
type="application/ld+json"
|
|
120
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
121
|
+
/>
|
|
122
|
+
<article className="prose prose-neutral dark:prose-invert max-w-none">
|
|
123
|
+
<h1>{post.title}</h1>
|
|
124
|
+
{post.publishedAt ? (
|
|
125
|
+
<p className="text-sm text-muted-foreground">
|
|
126
|
+
{new Date(post.publishedAt).toLocaleDateString()}
|
|
127
|
+
</p>
|
|
128
|
+
) : null}
|
|
129
|
+
{/* eslint-disable-next-line react/no-danger */}
|
|
130
|
+
<div dangerouslySetInnerHTML={{ __html: html }} />
|
|
131
|
+
</article>
|
|
132
|
+
</main>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
export function feedRouteTs(ctx) {
|
|
138
|
+
return `import { getPosts } from "@/lib/seoblog";
|
|
139
|
+
|
|
140
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "${ctx.siteUrl}";
|
|
141
|
+
const SITE_NAME = "${ctx.siteName}";
|
|
142
|
+
|
|
143
|
+
function escapeXml(s: string): string {
|
|
144
|
+
return s
|
|
145
|
+
.replace(/&/g, "&")
|
|
146
|
+
.replace(/</g, "<")
|
|
147
|
+
.replace(/>/g, ">")
|
|
148
|
+
.replace(/"/g, """)
|
|
149
|
+
.replace(/'/g, "'");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function GET() {
|
|
153
|
+
const posts = await getPosts();
|
|
154
|
+
|
|
155
|
+
const items = posts
|
|
156
|
+
.map(
|
|
157
|
+
(p) => \`
|
|
158
|
+
<item>
|
|
159
|
+
<title>\${escapeXml(p.title)}</title>
|
|
160
|
+
<link>\${SITE_URL}${ctx.blogBasePath}/\${p.slug}</link>
|
|
161
|
+
<guid isPermaLink="true">\${SITE_URL}${ctx.blogBasePath}/\${p.slug}</guid>
|
|
162
|
+
<description>\${escapeXml(p.metaDescription || p.excerpt)}</description>
|
|
163
|
+
<pubDate>\${p.publishedAt ? new Date(p.publishedAt).toUTCString() : ""}</pubDate>
|
|
164
|
+
</item>\`,
|
|
165
|
+
)
|
|
166
|
+
.join("");
|
|
167
|
+
|
|
168
|
+
const xml = \`<?xml version="1.0" encoding="UTF-8"?>
|
|
169
|
+
<rss version="2.0">
|
|
170
|
+
<channel>
|
|
171
|
+
<title>\${escapeXml(SITE_NAME)}</title>
|
|
172
|
+
<link>\${SITE_URL}</link>
|
|
173
|
+
<description>Latest posts from \${escapeXml(SITE_NAME)}</description>
|
|
174
|
+
\${items}
|
|
175
|
+
</channel>
|
|
176
|
+
</rss>\`;
|
|
177
|
+
|
|
178
|
+
return new Response(xml, {
|
|
179
|
+
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
`;
|
|
183
|
+
}
|
|
184
|
+
export function sitemapTs(ctx) {
|
|
185
|
+
return `import type { MetadataRoute } from "next";
|
|
186
|
+
|
|
187
|
+
import { getPosts } from "@/lib/seoblog";
|
|
188
|
+
|
|
189
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "${ctx.siteUrl}";
|
|
190
|
+
|
|
191
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
192
|
+
const posts = await getPosts();
|
|
193
|
+
|
|
194
|
+
return [
|
|
195
|
+
{
|
|
196
|
+
url: \`\${SITE_URL}${ctx.blogBasePath}\`,
|
|
197
|
+
changeFrequency: "daily",
|
|
198
|
+
priority: 0.8,
|
|
199
|
+
},
|
|
200
|
+
...posts.map((post) => ({
|
|
201
|
+
url: \`\${SITE_URL}${ctx.blogBasePath}/\${post.slug}\`,
|
|
202
|
+
lastModified: post.publishedAt ? new Date(post.publishedAt) : undefined,
|
|
203
|
+
changeFrequency: "weekly" as const,
|
|
204
|
+
priority: 0.6,
|
|
205
|
+
})),
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
export function envExample() {
|
|
211
|
+
return `# Get your API key from https://www.nextseoblog.com/dashboard
|
|
212
|
+
SEOBLOG_API_KEY=
|
|
213
|
+
|
|
214
|
+
# Used by sitemap.ts and feed.xml — set to your production URL
|
|
215
|
+
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
|
216
|
+
`;
|
|
217
|
+
}
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seoblog/next",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Fetch AI-generated blog posts from your SEO Blog dashboard into your Next.js site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -12,13 +12,26 @@
|
|
|
12
12
|
"types": "./dist/index.d.ts"
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"seoblog": "./dist/cli/index.js"
|
|
17
|
+
},
|
|
15
18
|
"files": [
|
|
16
|
-
"dist"
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
17
21
|
],
|
|
18
22
|
"scripts": {
|
|
19
|
-
"build": "tsc",
|
|
23
|
+
"build": "tsc && chmod +x dist/cli/index.js",
|
|
20
24
|
"dev": "tsc --watch"
|
|
21
25
|
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"nextjs",
|
|
28
|
+
"blog",
|
|
29
|
+
"seo",
|
|
30
|
+
"cms",
|
|
31
|
+
"ai",
|
|
32
|
+
"content"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
22
35
|
"devDependencies": {
|
|
23
36
|
"typescript": "^5.9.3"
|
|
24
37
|
},
|