create-lacspace-seo 1.0.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/LICENSE +51 -0
- package/README.md +101 -0
- package/dist/index.js +344 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Lacspace Free Licence
|
|
2
|
+
Version 1.0, August 2026
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Lacspace
|
|
5
|
+
|
|
6
|
+
PREAMBLE
|
|
7
|
+
|
|
8
|
+
This software is published by Lacspace under the Lacspace Free Licence — a free,
|
|
9
|
+
permissive licence that lets you use this software for any purpose, including in
|
|
10
|
+
commercial products and services, at no cost. It grants the same freedoms as
|
|
11
|
+
common permissive open-source licences; the only condition is that this notice
|
|
12
|
+
travels with the software. The canonical, always-current text of this licence is
|
|
13
|
+
maintained at https://lacspace.com/licenses/lacspace-free-1.0
|
|
14
|
+
|
|
15
|
+
GRANT OF RIGHTS
|
|
16
|
+
|
|
17
|
+
Permission is hereby granted, free of charge, to any person or organisation
|
|
18
|
+
obtaining a copy of this software and its associated documentation and data files
|
|
19
|
+
(the "Software"), to deal in the Software without restriction, including without
|
|
20
|
+
limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
21
|
+
sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
|
22
|
+
Software is furnished to do so, subject to the conditions below. These rights are
|
|
23
|
+
granted for any purpose, personal or commercial, and are perpetual, worldwide,
|
|
24
|
+
non-exclusive, and royalty-free.
|
|
25
|
+
|
|
26
|
+
CONDITIONS
|
|
27
|
+
|
|
28
|
+
The above copyright notice, this permission notice, and the name of this licence
|
|
29
|
+
("Lacspace Free Licence") shall be included in all copies or substantial portions
|
|
30
|
+
of the Software.
|
|
31
|
+
|
|
32
|
+
TRADEMARKS
|
|
33
|
+
|
|
34
|
+
This licence does not grant permission to use the trade names, trademarks, service
|
|
35
|
+
marks, logos, or product names of Lacspace, except as required to reproduce the
|
|
36
|
+
notice above or to describe the origin of the Software in a truthful manner.
|
|
37
|
+
|
|
38
|
+
DISCLAIMER OF WARRANTY AND LIMITATION OF LIABILITY
|
|
39
|
+
|
|
40
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
41
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
42
|
+
FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
43
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN
|
|
44
|
+
AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
|
|
45
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
The Lacspace Free Licence is a source-available, permissive licence and is not (as
|
|
50
|
+
of this version) an OSI-approved licence. In substance it grants the same freedoms
|
|
51
|
+
as the MIT Licence. Learn more at https://lacspace.com/licenses
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# create-lacspace-seo
|
|
4
|
+
|
|
5
|
+
**Scaffold a complete, production-ready SEO setup into a Next.js app in one command.**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/create-lacspace-seo)
|
|
8
|
+
[](https://github.com/lacspace/npm-packages/blob/main/LICENSE)
|
|
9
|
+
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
> One command drops in a `defineSite()` config plus **ready-made `robots.txt`, `sitemap.xml`, `feed.xml`, `llms.txt` and a dynamic OG-image route** — all wired to a single source of truth. No copywriting, no boilerplate, no fragile hand-written structured data.
|
|
13
|
+
|
|
14
|
+
## Use it
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm create lacspace-seo@latest
|
|
18
|
+
# or
|
|
19
|
+
npx create-lacspace-seo
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Run it inside a Next.js **App Router** project. It auto-detects `app/` or `src/app/`, asks a few questions (or take flags), and writes:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
lib/site.ts ← one defineSite() config, used everywhere
|
|
26
|
+
app/robots.txt/route.ts ← robots.txt (auto sitemap ref, optional AI blocking)
|
|
27
|
+
app/sitemap.xml/route.ts ← sitemap.xml from bare paths
|
|
28
|
+
app/feed.xml/route.ts ← RSS feed, prefilled from the site config
|
|
29
|
+
app/llms.txt/route.ts ← llms.txt map for LLMs (llmstxt.org)
|
|
30
|
+
app/og/route.tsx ← dynamic Open Graph images (/og?title=…)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then install the packages it uses and you're done:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm i @lacspace/seo @lacspace/robots @lacspace/sitemap @lacspace/rss @lacspace/llms-txt
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Non-interactive (CI / scripts)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx create-lacspace-seo --yes \
|
|
43
|
+
--name "Acme" \
|
|
44
|
+
--url "https://acme.com" \
|
|
45
|
+
--description "We build things" \
|
|
46
|
+
--twitter acmehq \
|
|
47
|
+
--logo /logo.png
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Flag | Meaning |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `--name <name>` | Site / brand name |
|
|
53
|
+
| `--url <url>` | Absolute site URL |
|
|
54
|
+
| `--description <text>` | Default meta description |
|
|
55
|
+
| `--twitter <handle>` | Twitter/X handle |
|
|
56
|
+
| `--logo <path>` | Logo path (e.g. `/logo.png`) |
|
|
57
|
+
| `--no-og` | Skip the dynamic OG-image route |
|
|
58
|
+
| `-y, --yes` | No prompts (needs `--name` & `--url`) |
|
|
59
|
+
| `--force` | Overwrite existing files |
|
|
60
|
+
|
|
61
|
+
Existing files are **never overwritten** unless you pass `--force` — safe to re-run.
|
|
62
|
+
|
|
63
|
+
## What you get
|
|
64
|
+
|
|
65
|
+
Every page becomes a one-liner, powered by the file it generated:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { site } from "@/lib/site";
|
|
69
|
+
|
|
70
|
+
export const metadata = site.meta({ title: "Pricing", path: "/pricing" });
|
|
71
|
+
// → title template, canonical, Open Graph, Twitter card, dynamic OG image — all filled in
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## The Lacspace SEO Kit
|
|
75
|
+
|
|
76
|
+
| Package | For |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| [`@lacspace/seo`](https://www.npmjs.com/package/@lacspace/seo) | Metadata, JSON-LD & the `defineSite()` engine |
|
|
79
|
+
| [`@lacspace/sitemap`](https://www.npmjs.com/package/@lacspace/sitemap) | sitemap.xml |
|
|
80
|
+
| [`@lacspace/robots`](https://www.npmjs.com/package/@lacspace/robots) | robots.txt |
|
|
81
|
+
| [`@lacspace/llms-txt`](https://www.npmjs.com/package/@lacspace/llms-txt) | llms.txt / llms-full.txt |
|
|
82
|
+
| [`@lacspace/rss`](https://www.npmjs.com/package/@lacspace/rss) | RSS / Atom / JSON feeds |
|
|
83
|
+
| **`create-lacspace-seo`** | Scaffolder (this package) |
|
|
84
|
+
|
|
85
|
+
## Licensing
|
|
86
|
+
|
|
87
|
+
This package is **free** under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — MIT-equivalent freedoms. Use it in personal and commercial projects at no cost; just keep the notice.
|
|
88
|
+
|
|
89
|
+
Not every Lacspace package is free. We also offer **Commercial** (paid), **Client-specific**, and **Private** (proprietary) packages under separate terms. See the full **[Lacspace Licence Centre](https://lacspace.com/licenses)**.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
<div align="center">
|
|
94
|
+
|
|
95
|
+
**Part of the Lacspace ecosystem — zero-dependency, isomorphic TypeScript packages.**
|
|
96
|
+
|
|
97
|
+
[All packages ↗](https://lacspace.com/packages) · [npm org ↗](https://www.npmjs.com/org/lacspace) · [Licence Centre ↗](https://lacspace.com/licenses) · [GitHub ↗](https://github.com/lacspace/npm-packages)
|
|
98
|
+
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div align="center"><sub>Built with care by <a href="https://lacspace.com">Lacspace</a> · Lacspace Free Licence · <a href="https://github.com/lacspace/npm-packages">source</a></sub></div>
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join, resolve, dirname } from 'path';
|
|
4
|
+
import { createInterface } from 'readline/promises';
|
|
5
|
+
import { stdout, exit, argv, cwd, stdin } from 'process';
|
|
6
|
+
|
|
7
|
+
var C = {
|
|
8
|
+
reset: "\x1B[0m",
|
|
9
|
+
bold: "\x1B[1m",
|
|
10
|
+
dim: "\x1B[2m",
|
|
11
|
+
green: "\x1B[32m",
|
|
12
|
+
cyan: "\x1B[36m",
|
|
13
|
+
yellow: "\x1B[33m",
|
|
14
|
+
red: "\x1B[31m",
|
|
15
|
+
blue: "\x1B[34m"
|
|
16
|
+
};
|
|
17
|
+
var c = (color, s) => `${C[color]}${s}${C.reset}`;
|
|
18
|
+
function parseArgs(list) {
|
|
19
|
+
const a = { yes: false, force: false, og: true, help: false };
|
|
20
|
+
for (let i = 0; i < list.length; i++) {
|
|
21
|
+
const arg = list[i];
|
|
22
|
+
const next = () => list[++i] ?? "";
|
|
23
|
+
switch (arg) {
|
|
24
|
+
case "--name":
|
|
25
|
+
a.name = next();
|
|
26
|
+
break;
|
|
27
|
+
case "--url":
|
|
28
|
+
a.url = next();
|
|
29
|
+
break;
|
|
30
|
+
case "--description":
|
|
31
|
+
case "--desc":
|
|
32
|
+
a.description = next();
|
|
33
|
+
break;
|
|
34
|
+
case "--twitter":
|
|
35
|
+
a.twitter = next();
|
|
36
|
+
break;
|
|
37
|
+
case "--logo":
|
|
38
|
+
a.logo = next();
|
|
39
|
+
break;
|
|
40
|
+
case "-y":
|
|
41
|
+
case "--yes":
|
|
42
|
+
a.yes = true;
|
|
43
|
+
break;
|
|
44
|
+
case "--force":
|
|
45
|
+
a.force = true;
|
|
46
|
+
break;
|
|
47
|
+
case "--no-og":
|
|
48
|
+
a.og = false;
|
|
49
|
+
break;
|
|
50
|
+
case "-h":
|
|
51
|
+
case "--help":
|
|
52
|
+
a.help = true;
|
|
53
|
+
break;
|
|
54
|
+
default:
|
|
55
|
+
if (arg.startsWith("--")) {
|
|
56
|
+
const eq = arg.indexOf("=");
|
|
57
|
+
if (eq !== -1) {
|
|
58
|
+
const key = arg.slice(2, eq);
|
|
59
|
+
const val = arg.slice(eq + 1);
|
|
60
|
+
if (key === "name") a.name = val;
|
|
61
|
+
else if (key === "url") a.url = val;
|
|
62
|
+
else if (key === "description" || key === "desc") a.description = val;
|
|
63
|
+
else if (key === "twitter") a.twitter = val;
|
|
64
|
+
else if (key === "logo") a.logo = val;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return a;
|
|
70
|
+
}
|
|
71
|
+
var HELP = `
|
|
72
|
+
${c("bold", "create-lacspace-seo")} \u2014 scaffold a full SEO setup into a Next.js app
|
|
73
|
+
|
|
74
|
+
${c("bold", "Usage")}
|
|
75
|
+
npm create lacspace-seo@latest
|
|
76
|
+
npx create-lacspace-seo [options]
|
|
77
|
+
|
|
78
|
+
${c("bold", "Options")}
|
|
79
|
+
--name <name> Site / brand name
|
|
80
|
+
--url <url> Absolute site URL (https://\u2026)
|
|
81
|
+
--description <text> Default meta description
|
|
82
|
+
--twitter <handle> Twitter/X handle (with or without @)
|
|
83
|
+
--logo <path> Logo path (e.g. /logo.png)
|
|
84
|
+
--no-og Skip the dynamic OG-image route
|
|
85
|
+
-y, --yes Accept defaults, no prompts (needs --name & --url)
|
|
86
|
+
--force Overwrite existing files
|
|
87
|
+
-h, --help Show this help
|
|
88
|
+
`;
|
|
89
|
+
function detectLayout(root) {
|
|
90
|
+
if (existsSync(join(root, "src", "app"))) {
|
|
91
|
+
return { root, appDir: join(root, "src", "app"), libDir: join(root, "src", "lib"), usesSrc: true };
|
|
92
|
+
}
|
|
93
|
+
if (existsSync(join(root, "app"))) {
|
|
94
|
+
return { root, appDir: join(root, "app"), libDir: join(root, "lib"), usesSrc: false };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function isNextProject(root) {
|
|
99
|
+
const pkgPath = join(root, "package.json");
|
|
100
|
+
if (!existsSync(pkgPath)) return false;
|
|
101
|
+
try {
|
|
102
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
103
|
+
return Boolean(pkg.dependencies?.["next"] ?? pkg.devDependencies?.["next"]);
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
var q = (s) => JSON.stringify(s);
|
|
109
|
+
function siteTemplate(cfg) {
|
|
110
|
+
const lines = [
|
|
111
|
+
`import { defineSite } from "@lacspace/seo";`,
|
|
112
|
+
``,
|
|
113
|
+
`/**`,
|
|
114
|
+
` * Your site's SEO configuration \u2014 set ONCE, used everywhere.`,
|
|
115
|
+
` * Generated by create-lacspace-seo.`,
|
|
116
|
+
` */`,
|
|
117
|
+
`export const site = defineSite({`,
|
|
118
|
+
` name: ${q(cfg.name)},`,
|
|
119
|
+
` url: ${q(cfg.url)},`
|
|
120
|
+
];
|
|
121
|
+
if (cfg.description) lines.push(` description: ${q(cfg.description)},`);
|
|
122
|
+
if (cfg.logo) lines.push(` logo: ${q(cfg.logo)},`);
|
|
123
|
+
if (cfg.twitter) lines.push(` twitter: ${q(cfg.twitter)},`);
|
|
124
|
+
if (cfg.og) lines.push(` ogImage: "/og", // dynamic social cards \u2192 /og?title=<page>`);
|
|
125
|
+
lines.push(` // searchUrl: ${q(cfg.url.replace(/\/$/, "") + "/search?q={search_term_string}")},`);
|
|
126
|
+
lines.push(`});`, ``);
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
var robotsTemplate = () => `import { robotsForSite } from "@lacspace/robots";
|
|
130
|
+
import { site } from "@/lib/site";
|
|
131
|
+
|
|
132
|
+
// Served at /robots.txt \u2014 flip blockAi to true to fend off AI scrapers.
|
|
133
|
+
export function GET() {
|
|
134
|
+
return new Response(robotsForSite(site.config, { blockAi: false }), {
|
|
135
|
+
headers: { "content-type": "text/plain" },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
`;
|
|
139
|
+
var sitemapTemplate = () => `import { sitemapForSite } from "@lacspace/sitemap";
|
|
140
|
+
import { site } from "@/lib/site";
|
|
141
|
+
|
|
142
|
+
// Served at /sitemap.xml \u2014 add your static + dynamic routes here.
|
|
143
|
+
export function GET() {
|
|
144
|
+
const xml = sitemapForSite(site.config, [
|
|
145
|
+
"/",
|
|
146
|
+
"/about",
|
|
147
|
+
// { path: "/blog", changefreq: "daily", priority: 0.8 },
|
|
148
|
+
]);
|
|
149
|
+
return new Response(xml, { headers: { "content-type": "application/xml" } });
|
|
150
|
+
}
|
|
151
|
+
`;
|
|
152
|
+
var feedTemplate = () => `import { rss, feedForSite } from "@lacspace/rss";
|
|
153
|
+
import { site } from "@/lib/site";
|
|
154
|
+
|
|
155
|
+
// Served at /feed.xml \u2014 map your posts into feed items.
|
|
156
|
+
export function GET() {
|
|
157
|
+
const posts: { title: string; link: string; date: string; description?: string }[] = [
|
|
158
|
+
// { title: "Hello world", link: site.url("/blog/hello"), date: "2026-01-01" },
|
|
159
|
+
];
|
|
160
|
+
return new Response(rss(feedForSite(site.config, "/feed.xml"), posts), {
|
|
161
|
+
headers: { "content-type": "application/rss+xml" },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
`;
|
|
165
|
+
var llmsTemplate = () => `import { llmsTxt } from "@lacspace/llms-txt";
|
|
166
|
+
import { site } from "@/lib/site";
|
|
167
|
+
|
|
168
|
+
// Served at /llms.txt \u2014 a curated map for LLMs (llmstxt.org).
|
|
169
|
+
export function GET() {
|
|
170
|
+
const txt = llmsTxt({
|
|
171
|
+
title: site.config.name,
|
|
172
|
+
summary: site.config.description ?? "",
|
|
173
|
+
sections: [
|
|
174
|
+
{
|
|
175
|
+
title: "Pages",
|
|
176
|
+
links: [
|
|
177
|
+
{ title: "Home", url: site.url("/") },
|
|
178
|
+
{ title: "About", url: site.url("/about") },
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
return new Response(txt, { headers: { "content-type": "text/plain" } });
|
|
184
|
+
}
|
|
185
|
+
`;
|
|
186
|
+
var ogTemplate = () => `import { ImageResponse } from "next/og";
|
|
187
|
+
import { site } from "@/lib/site";
|
|
188
|
+
|
|
189
|
+
export const runtime = "edge";
|
|
190
|
+
|
|
191
|
+
// Served at /og?title=... \u2014 dynamic Open Graph card, no design tool needed.
|
|
192
|
+
export function GET(req: Request) {
|
|
193
|
+
const title = new URL(req.url).searchParams.get("title") ?? site.config.name;
|
|
194
|
+
return new ImageResponse(
|
|
195
|
+
(
|
|
196
|
+
<div
|
|
197
|
+
style={{
|
|
198
|
+
width: "100%",
|
|
199
|
+
height: "100%",
|
|
200
|
+
display: "flex",
|
|
201
|
+
flexDirection: "column",
|
|
202
|
+
justifyContent: "center",
|
|
203
|
+
padding: "80px",
|
|
204
|
+
background: "linear-gradient(135deg, #0b1220, #1e293b)",
|
|
205
|
+
color: "white",
|
|
206
|
+
fontFamily: "sans-serif",
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
<div style={{ fontSize: 68, fontWeight: 800, lineHeight: 1.1 }}>{title}</div>
|
|
210
|
+
<div style={{ marginTop: 24, fontSize: 30, opacity: 0.7 }}>{site.config.name}</div>
|
|
211
|
+
</div>
|
|
212
|
+
),
|
|
213
|
+
{ width: 1200, height: 630 },
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
`;
|
|
217
|
+
function writePlan(plans, force) {
|
|
218
|
+
const written = [];
|
|
219
|
+
const skipped = [];
|
|
220
|
+
for (const p of plans) {
|
|
221
|
+
if (existsSync(p.path) && !force) {
|
|
222
|
+
skipped.push(p.rel);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
mkdirSync(dirname(p.path), { recursive: true });
|
|
226
|
+
writeFileSync(p.path, p.content);
|
|
227
|
+
written.push(p.rel);
|
|
228
|
+
}
|
|
229
|
+
return { written, skipped };
|
|
230
|
+
}
|
|
231
|
+
async function main() {
|
|
232
|
+
const args = parseArgs(argv.slice(2));
|
|
233
|
+
if (args.help) {
|
|
234
|
+
stdout.write(HELP + "\n");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
stdout.write(`
|
|
238
|
+
${c("bold", c("cyan", "\u25C6 create-lacspace-seo"))} ${c("dim", "\u2014 full SEO setup for Next.js")}
|
|
239
|
+
|
|
240
|
+
`);
|
|
241
|
+
const root = cwd();
|
|
242
|
+
if (!isNextProject(root)) {
|
|
243
|
+
stdout.write(c("yellow", '! No "next" dependency found in package.json.\n'));
|
|
244
|
+
stdout.write(c("dim", " Run this inside a Next.js App Router project.\n\n"));
|
|
245
|
+
if (args.yes) exit(1);
|
|
246
|
+
}
|
|
247
|
+
const layout = detectLayout(root);
|
|
248
|
+
if (!layout) {
|
|
249
|
+
stdout.write(c("red", "\u2717 Could not find an app/ or src/app/ directory.\n"));
|
|
250
|
+
stdout.write(c("dim", " This scaffolder targets the Next.js App Router.\n\n"));
|
|
251
|
+
exit(1);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
let name = args.name;
|
|
255
|
+
let url = args.url;
|
|
256
|
+
let description = args.description;
|
|
257
|
+
let twitter = args.twitter;
|
|
258
|
+
let logo = args.logo;
|
|
259
|
+
let og = args.og;
|
|
260
|
+
if (!args.yes) {
|
|
261
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
262
|
+
try {
|
|
263
|
+
const ask = async (label, fallback = "") => {
|
|
264
|
+
const suffix = fallback ? c("dim", ` (${fallback})`) : "";
|
|
265
|
+
const ans = (await rl.question(`${c("green", "?")} ${label}${suffix}: `)).trim();
|
|
266
|
+
return ans || fallback;
|
|
267
|
+
};
|
|
268
|
+
if (!name) name = await ask("Site / brand name", "My Site");
|
|
269
|
+
if (!url) url = await ask("Site URL", "https://example.com");
|
|
270
|
+
if (description === void 0) description = await ask("Default description") || void 0;
|
|
271
|
+
if (twitter === void 0) twitter = await ask("Twitter/X handle (optional)") || void 0;
|
|
272
|
+
if (logo === void 0) logo = await ask("Logo path (optional, e.g. /logo.png)") || void 0;
|
|
273
|
+
const ogAns = (await ask("Add dynamic OG-image route? (Y/n)", "Y")).toLowerCase();
|
|
274
|
+
og = ogAns !== "n" && ogAns !== "no";
|
|
275
|
+
} finally {
|
|
276
|
+
rl.close();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (!name || !url) {
|
|
280
|
+
stdout.write(c("red", "\n\u2717 --name and --url are required (with --yes).\n\n"));
|
|
281
|
+
exit(1);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const cfg = { name, url, description, twitter, logo, og };
|
|
285
|
+
const { appDir, libDir } = layout;
|
|
286
|
+
const plans = [
|
|
287
|
+
{ path: join(libDir, "site.ts"), rel: relTo(root, join(libDir, "site.ts")), content: siteTemplate(cfg) },
|
|
288
|
+
{ path: join(appDir, "robots.txt", "route.ts"), rel: relTo(root, join(appDir, "robots.txt", "route.ts")), content: robotsTemplate() },
|
|
289
|
+
{ path: join(appDir, "sitemap.xml", "route.ts"), rel: relTo(root, join(appDir, "sitemap.xml", "route.ts")), content: sitemapTemplate() },
|
|
290
|
+
{ path: join(appDir, "feed.xml", "route.ts"), rel: relTo(root, join(appDir, "feed.xml", "route.ts")), content: feedTemplate() },
|
|
291
|
+
{ path: join(appDir, "llms.txt", "route.ts"), rel: relTo(root, join(appDir, "llms.txt", "route.ts")), content: llmsTemplate() }
|
|
292
|
+
];
|
|
293
|
+
if (og) {
|
|
294
|
+
plans.push({ path: join(appDir, "og", "route.tsx"), rel: relTo(root, join(appDir, "og", "route.tsx")), content: ogTemplate() });
|
|
295
|
+
}
|
|
296
|
+
const { written, skipped } = writePlan(plans, args.force);
|
|
297
|
+
stdout.write("\n");
|
|
298
|
+
for (const w of written) stdout.write(` ${c("green", "+")} ${w}
|
|
299
|
+
`);
|
|
300
|
+
for (const s of skipped) stdout.write(` ${c("yellow", "\u2022")} ${s} ${c("dim", "(exists, skipped \u2014 use --force)")}
|
|
301
|
+
`);
|
|
302
|
+
const deps = ["@lacspace/seo", "@lacspace/robots", "@lacspace/sitemap", "@lacspace/rss", "@lacspace/llms-txt"];
|
|
303
|
+
stdout.write(`
|
|
304
|
+
${c("bold", "Next steps")}
|
|
305
|
+
`);
|
|
306
|
+
stdout.write(` 1. Install the SEO packages:
|
|
307
|
+
`);
|
|
308
|
+
stdout.write(` ${c("cyan", `npm i ${deps.join(" ")}`)}
|
|
309
|
+
`);
|
|
310
|
+
stdout.write(` 2. Edit ${c("cyan", relTo(root, join(libDir, "site.ts")))} \u2014 that's your one source of truth.
|
|
311
|
+
`);
|
|
312
|
+
stdout.write(` 3. Use it in any page:
|
|
313
|
+
`);
|
|
314
|
+
stdout.write(` ${c("dim", `import { site } from "@/lib/site";`)}
|
|
315
|
+
`);
|
|
316
|
+
stdout.write(` ${c("dim", `export const metadata = site.meta({ title: "Pricing", path: "/pricing" });`)}
|
|
317
|
+
`);
|
|
318
|
+
stdout.write(` 4. Drop the sitewide JSON-LD into app/layout.tsx once:
|
|
319
|
+
`);
|
|
320
|
+
stdout.write(` ${c("dim", `import { jsonLdScript } from "@lacspace/seo";`)}
|
|
321
|
+
`);
|
|
322
|
+
stdout.write(` ${c("dim", `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(site.rootJsonLd()) }} />`)}
|
|
323
|
+
`);
|
|
324
|
+
if (skipped.length) {
|
|
325
|
+
stdout.write(`
|
|
326
|
+
${c("yellow", `${skipped.length} file(s) already existed`)} \u2014 re-run with ${c("cyan", "--force")} to overwrite.
|
|
327
|
+
`);
|
|
328
|
+
}
|
|
329
|
+
stdout.write(`
|
|
330
|
+
${c("green", "\u2713 SEO on autopilot.")} ${c("dim", "Docs: https://lacspace.com/packages")}
|
|
331
|
+
|
|
332
|
+
`);
|
|
333
|
+
}
|
|
334
|
+
function relTo(root, p) {
|
|
335
|
+
const r = resolve(p).slice(resolve(root).length).replace(/^[/\\]/, "");
|
|
336
|
+
return r || p;
|
|
337
|
+
}
|
|
338
|
+
main().catch((err) => {
|
|
339
|
+
stdout.write(c("red", `
|
|
340
|
+
\u2717 ${err instanceof Error ? err.message : String(err)}
|
|
341
|
+
|
|
342
|
+
`));
|
|
343
|
+
exit(1);
|
|
344
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-lacspace-seo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Scaffold a complete, production-ready SEO setup into a Next.js App Router app in seconds — defineSite() config plus ready-made robots.txt, sitemap.xml, feed.xml, llms.txt and a dynamic OG-image route. Zero config, zero copywriting.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-lacspace-seo": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"create-lacspace-seo",
|
|
18
|
+
"seo",
|
|
19
|
+
"scaffold",
|
|
20
|
+
"generator",
|
|
21
|
+
"nextjs",
|
|
22
|
+
"next-seo",
|
|
23
|
+
"robots-txt",
|
|
24
|
+
"sitemap",
|
|
25
|
+
"llms-txt",
|
|
26
|
+
"rss-feed",
|
|
27
|
+
"og-image",
|
|
28
|
+
"structured-data",
|
|
29
|
+
"boilerplate",
|
|
30
|
+
"cli",
|
|
31
|
+
"typescript"
|
|
32
|
+
],
|
|
33
|
+
"author": "Lacspace <contact@lacspace.com>",
|
|
34
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
35
|
+
"homepage": "https://lacspace.com/packages",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/lacspace/npm-packages.git",
|
|
39
|
+
"directory": "create-lacspace-seo"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/lacspace/npm-packages/issues"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|