git-paid 2.1.7 → 2.1.9
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 +1 -1
- package/bin/git-paid.js +12 -1
- package/lib/auth.js +78 -23
- package/lib/directories.js +91 -90
- package/lib/engine.js +77 -24
- package/lib/fixer.js +207 -45
- package/lib/inbox.js +8 -2
- package/lib/mcp.js +1 -1
- package/lib/pseo.js +33 -33
- package/package.json +1 -1
package/lib/engine.js
CHANGED
|
@@ -58,6 +58,30 @@ function inferCategories(text) {
|
|
|
58
58
|
return cats.slice(0, 3);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export function isPlaceholderTitle(title = "") {
|
|
62
|
+
const clean = String(title || "").trim().toLowerCase();
|
|
63
|
+
if (!clean) return true;
|
|
64
|
+
return /^(lovable app|vite app|react app|vite \+ react(\s*\+\s*ts)?|vite \+ vue(\s*\+\s*ts)?|create next app|next\.js app|bolt app|v0 app|replit|untitled|document|home|welcome|my app|new app|app|website|index)(\s*[-|—–].*)?$/i.test(
|
|
65
|
+
clean
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function humanizeDomainName(domain = "") {
|
|
70
|
+
const base =
|
|
71
|
+
String(domain || "app")
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/^https?:\/\//, "")
|
|
74
|
+
.replace(/^www\./, "")
|
|
75
|
+
.split(".")[0] || "app";
|
|
76
|
+
return (
|
|
77
|
+
base
|
|
78
|
+
.split(/[-_]+/)
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
81
|
+
.join(" ") || "App"
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
61
85
|
export async function scanTargetUrl(rawInput, options = {}) {
|
|
62
86
|
let targetUrl = (rawInput || "https://www.postbuild.dev").trim();
|
|
63
87
|
if (!/^https?:\/\//i.test(targetUrl)) {
|
|
@@ -127,9 +151,12 @@ export async function scanTargetUrl(rawInput, options = {}) {
|
|
|
127
151
|
}
|
|
128
152
|
|
|
129
153
|
const extractedTitle = extractTag(html, /<title[^>]*>([^<]+)<\/title>/i);
|
|
154
|
+
const hasPlaceholderTitle = isPlaceholderTitle(extractedTitle);
|
|
155
|
+
const fallbackBrand = humanizeDomainName(domain);
|
|
130
156
|
const rawTitle =
|
|
131
|
-
extractedTitle
|
|
132
|
-
|
|
157
|
+
extractedTitle && !hasPlaceholderTitle
|
|
158
|
+
? extractedTitle
|
|
159
|
+
: `${fallbackBrand} — Web Application`;
|
|
133
160
|
|
|
134
161
|
const description =
|
|
135
162
|
extractTag(html, /<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i) ||
|
|
@@ -145,7 +172,9 @@ export async function scanTargetUrl(rawInput, options = {}) {
|
|
|
145
172
|
const ogTitle =
|
|
146
173
|
extractTag(html, /<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) || "";
|
|
147
174
|
|
|
148
|
-
const h1List = extractAllTags(html, /<h1[^>]*>([\s\S]*?)<\/h1>/gi, 3)
|
|
175
|
+
const h1List = extractAllTags(html, /<h1[^>]*>([\s\S]*?)<\/h1>/gi, 3).filter(
|
|
176
|
+
(h) => !isPlaceholderTitle(h)
|
|
177
|
+
);
|
|
149
178
|
const h2List = extractAllTags(html, /<h2[^>]*>([\s\S]*?)<\/h2>/gi, 6);
|
|
150
179
|
|
|
151
180
|
const hasJsonLd = /application\/ld\+json/i.test(html);
|
|
@@ -156,15 +185,16 @@ export async function scanTargetUrl(rawInput, options = {}) {
|
|
|
156
185
|
|
|
157
186
|
const hasAiCrawlerRules = hasRobots && /OAI-SearchBot|PerplexityBot|ClaudeBot|GPTBot/i.test(robotsText);
|
|
158
187
|
const sitemapUrlCount = hasSitemap ? (sitemapText.match(/<loc>/gi) || []).length : 0;
|
|
159
|
-
const
|
|
188
|
+
const hasValidSitemap = hasSitemap && sitemapUrlCount >= 1;
|
|
160
189
|
|
|
161
190
|
const checks = [
|
|
162
191
|
{
|
|
163
192
|
id: "title",
|
|
164
193
|
label: "Title Tag & Server-Rendered H1",
|
|
165
|
-
passed: Boolean(extractedTitle && extractedTitle.length > 10 && h1List.length > 0),
|
|
166
|
-
detail:
|
|
167
|
-
extractedTitle
|
|
194
|
+
passed: Boolean(extractedTitle && !hasPlaceholderTitle && extractedTitle.length > 10 && h1List.length > 0),
|
|
195
|
+
detail: hasPlaceholderTitle && extractedTitle
|
|
196
|
+
? `Default builder title detected ("${extractedTitle}") — set your real product title`
|
|
197
|
+
: extractedTitle && h1List.length > 0
|
|
168
198
|
? `${extractedTitle} (H1: ${h1List[0].slice(0, 48)})`
|
|
169
199
|
: !extractedTitle
|
|
170
200
|
? "Missing <title> tag in HTML"
|
|
@@ -183,7 +213,7 @@ export async function scanTargetUrl(rawInput, options = {}) {
|
|
|
183
213
|
{
|
|
184
214
|
id: "og",
|
|
185
215
|
label: "OpenGraph Social Preview Card",
|
|
186
|
-
passed: Boolean(ogImage || ogTitle),
|
|
216
|
+
passed: Boolean((ogImage && !/\.svg(\?|$)/i.test(ogImage)) || ogTitle),
|
|
187
217
|
detail: ogImage
|
|
188
218
|
? `Detected og:image (${ogImage.slice(0, 48)})`
|
|
189
219
|
: ogTitle
|
|
@@ -208,11 +238,11 @@ export async function scanTargetUrl(rawInput, options = {}) {
|
|
|
208
238
|
},
|
|
209
239
|
{
|
|
210
240
|
id: "sitemap",
|
|
211
|
-
label: "
|
|
212
|
-
passed:
|
|
213
|
-
detail:
|
|
214
|
-
? `sitemap.xml active (${sitemapUrlCount} indexed
|
|
215
|
-
: "No sitemap.xml found —
|
|
241
|
+
label: "XML Sitemap",
|
|
242
|
+
passed: hasValidSitemap,
|
|
243
|
+
detail: hasValidSitemap
|
|
244
|
+
? `sitemap.xml active (${sitemapUrlCount} indexed ${sitemapUrlCount === 1 ? "URL" : "URLs"})`
|
|
245
|
+
: "No valid sitemap.xml found — ready to generate"
|
|
216
246
|
},
|
|
217
247
|
{
|
|
218
248
|
id: "paywall",
|
|
@@ -234,7 +264,8 @@ export async function scanTargetUrl(rawInput, options = {}) {
|
|
|
234
264
|
const readinessScore = reachable ? Math.round((passedCount / checks.length) * 100) : 0;
|
|
235
265
|
|
|
236
266
|
const titleParts = rawTitle.split(/\s*[|—–-]\s*/);
|
|
237
|
-
const
|
|
267
|
+
const rawFirstPart = (titleParts[0] || "").trim();
|
|
268
|
+
const cleanName = !rawFirstPart || isPlaceholderTitle(rawFirstPart) ? fallbackBrand : rawFirstPart;
|
|
238
269
|
const h1Candidate =
|
|
239
270
|
h1List[0] && h1List[0].length <= 75 && h1List[0].toLowerCase() !== cleanName.toLowerCase()
|
|
240
271
|
? h1List[0]
|
|
@@ -244,7 +275,9 @@ export async function scanTargetUrl(rawInput, options = {}) {
|
|
|
244
275
|
? titleParts[1].trim()
|
|
245
276
|
: null) ||
|
|
246
277
|
h1Candidate ||
|
|
247
|
-
(description ? description
|
|
278
|
+
(description ? truncateWordBoundary(description, 60) : `${cleanName} on ${domain}`);
|
|
279
|
+
|
|
280
|
+
const needsCopyReview = Boolean(hasPlaceholderTitle || !description || description.length < 25);
|
|
248
281
|
|
|
249
282
|
const launchKit = generateLaunchKit({
|
|
250
283
|
origin: parsedUrl.origin,
|
|
@@ -255,7 +288,9 @@ export async function scanTargetUrl(rawInput, options = {}) {
|
|
|
255
288
|
description,
|
|
256
289
|
h1List,
|
|
257
290
|
h2List,
|
|
258
|
-
botEmail
|
|
291
|
+
botEmail,
|
|
292
|
+
needsCopyReview,
|
|
293
|
+
detectedPlaceholderTitle: hasPlaceholderTitle ? extractedTitle || "" : ""
|
|
259
294
|
});
|
|
260
295
|
|
|
261
296
|
const pseoPages = generatePseoPages(parsedUrl.origin, slug, cleanName);
|
|
@@ -292,19 +327,31 @@ export async function scanTargetUrl(rawInput, options = {}) {
|
|
|
292
327
|
};
|
|
293
328
|
}
|
|
294
329
|
|
|
295
|
-
function generateLaunchKit({
|
|
330
|
+
function generateLaunchKit({
|
|
331
|
+
origin,
|
|
332
|
+
domain,
|
|
333
|
+
slug,
|
|
334
|
+
cleanName,
|
|
335
|
+
tagline,
|
|
336
|
+
description,
|
|
337
|
+
h1List,
|
|
338
|
+
h2List,
|
|
339
|
+
botEmail,
|
|
340
|
+
needsCopyReview = false,
|
|
341
|
+
detectedPlaceholderTitle = ""
|
|
342
|
+
}) {
|
|
296
343
|
const cleanTagline = tagline.length > 64 ? truncateWordBoundary(tagline, 64) : tagline;
|
|
297
344
|
const shortPitch =
|
|
298
345
|
description && description.length >= 25
|
|
299
346
|
? truncateWordBoundary(description, 160)
|
|
300
|
-
: `${cleanName} (${domain}) —
|
|
347
|
+
: `${cleanName} (${domain}) — available at ${origin}.`;
|
|
301
348
|
|
|
302
349
|
const featureBullets =
|
|
303
350
|
h2List.length >= 2
|
|
304
351
|
? h2List.slice(0, 4).map((h) => `- **${h}**`).join("\n")
|
|
305
352
|
: [
|
|
306
|
-
`- **Web
|
|
307
|
-
`- **
|
|
353
|
+
`- **Direct Web Access**: Explore ${cleanName} at ${origin}.`,
|
|
354
|
+
`- **Streamlined Setup**: Built for ${domain} users.`
|
|
308
355
|
].join("\n");
|
|
309
356
|
|
|
310
357
|
const utmUrl = `${origin}/?utm_source=postbuild&utm_medium=directory&utm_campaign=${slug}_launch`;
|
|
@@ -329,7 +376,9 @@ Try it live: ${utmUrl}`;
|
|
|
329
376
|
shortPitch,
|
|
330
377
|
showHnTitle: `Show HN: ${cleanName} – ${cleanTagline}`,
|
|
331
378
|
categories,
|
|
332
|
-
longDescription
|
|
379
|
+
longDescription,
|
|
380
|
+
needsCopyReview,
|
|
381
|
+
detectedPlaceholderTitle
|
|
333
382
|
};
|
|
334
383
|
}
|
|
335
384
|
|
|
@@ -379,7 +428,11 @@ export function generatePseoPages(origin, slug, cleanName) {
|
|
|
379
428
|
|
|
380
429
|
function generateArtifacts(origin, slug, cleanName, description, pseoPages) {
|
|
381
430
|
const today = new Date().toISOString().split("T")[0];
|
|
382
|
-
|
|
431
|
+
let parsedHostname = "";
|
|
432
|
+
try {
|
|
433
|
+
parsedHostname = new URL(origin).hostname.toLowerCase();
|
|
434
|
+
} catch {}
|
|
435
|
+
const isPostbuildSelf = parsedHostname === "postbuild.dev" || parsedHostname === "www.postbuild.dev";
|
|
383
436
|
|
|
384
437
|
const routeEntries = pseoPages
|
|
385
438
|
.map(
|
|
@@ -445,7 +498,7 @@ Sitemap: ${origin}/sitemap.xml`;
|
|
|
445
498
|
</script>`;
|
|
446
499
|
|
|
447
500
|
const paywallCode = `// 1. Client Component: Drop-In Stripe Checkout Gate for ${cleanName}
|
|
448
|
-
export async function startCheckout({ priceId = "price_YOUR_STRIPE_PRICE_ID", plan = "
|
|
501
|
+
export async function startCheckout({ priceId = "price_YOUR_STRIPE_PRICE_ID", plan = "standard", email = "" } = {}) {
|
|
449
502
|
const res = await fetch("/api/checkout", {
|
|
450
503
|
method: "POST",
|
|
451
504
|
headers: { "Content-Type": "application/json" },
|
|
@@ -456,7 +509,7 @@ export async function startCheckout({ priceId = "price_YOUR_STRIPE_PRICE_ID", pl
|
|
|
456
509
|
return data;
|
|
457
510
|
}
|
|
458
511
|
|
|
459
|
-
export function CheckoutGate({ priceId = "price_YOUR_STRIPE_PRICE_ID", label = "
|
|
512
|
+
export function CheckoutGate({ priceId = "price_YOUR_STRIPE_PRICE_ID", label = "Continue to ${cleanName} Checkout" }) {
|
|
460
513
|
return (
|
|
461
514
|
<button
|
|
462
515
|
type="button"
|
package/lib/fixer.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import zlib from "node:zlib";
|
|
3
4
|
import { writePseoHtmlFiles } from "./pseo.js";
|
|
5
|
+
import { DIRECTORIES } from "./directories.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Detects the static public folder of a local project (Next.js, Vite, Astro, SvelteKit, or generic).
|
|
@@ -13,11 +15,19 @@ export function detectPublicDir(cwd = process.cwd()) {
|
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
let framework = "Static / Node";
|
|
16
|
-
if (
|
|
18
|
+
if (
|
|
19
|
+
fs.existsSync(path.join(cwd, "next.config.js")) ||
|
|
20
|
+
fs.existsSync(path.join(cwd, "next.config.mjs")) ||
|
|
21
|
+
fs.existsSync(path.join(cwd, "next.config.ts"))
|
|
22
|
+
) {
|
|
17
23
|
framework = "Next.js";
|
|
18
24
|
} else if (fs.existsSync(path.join(cwd, "astro.config.mjs")) || fs.existsSync(path.join(cwd, "astro.config.ts"))) {
|
|
19
25
|
framework = "Astro";
|
|
20
|
-
} else if (
|
|
26
|
+
} else if (
|
|
27
|
+
fs.existsSync(path.join(cwd, "vite.config.js")) ||
|
|
28
|
+
fs.existsSync(path.join(cwd, "vite.config.ts")) ||
|
|
29
|
+
fs.existsSync(path.join(cwd, "vite.config.mjs"))
|
|
30
|
+
) {
|
|
21
31
|
framework = "Vite";
|
|
22
32
|
}
|
|
23
33
|
|
|
@@ -43,31 +53,114 @@ export function buildOgCardSvg(brandName = "Web Application", domain = "example.
|
|
|
43
53
|
`;
|
|
44
54
|
}
|
|
45
55
|
|
|
46
|
-
|
|
56
|
+
function crc32(buf) {
|
|
57
|
+
let crc = 0xffffffff;
|
|
58
|
+
for (let i = 0; i < buf.length; i++) {
|
|
59
|
+
crc ^= buf[i];
|
|
60
|
+
for (let j = 0; j < 8; j++) {
|
|
61
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makePngChunk(type, data) {
|
|
68
|
+
const typeBuf = Buffer.from(type, "ascii");
|
|
69
|
+
const lenBuf = Buffer.alloc(4);
|
|
70
|
+
lenBuf.writeUInt32BE(data.length, 0);
|
|
71
|
+
const crcInput = Buffer.concat([typeBuf, data]);
|
|
72
|
+
const crcBuf = Buffer.alloc(4);
|
|
73
|
+
crcBuf.writeUInt32BE(crc32(crcInput), 0);
|
|
74
|
+
return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Generates a valid 1200x630 RGB PNG social card using built-in node:zlib (zero external dependencies).
|
|
79
|
+
*/
|
|
80
|
+
export function buildOgCardPngBuffer() {
|
|
81
|
+
const width = 1200;
|
|
82
|
+
const height = 630;
|
|
83
|
+
const rowLen = 1 + width * 3;
|
|
84
|
+
const raw = Buffer.alloc(rowLen * height);
|
|
85
|
+
|
|
86
|
+
for (let y = 0; y < height; y++) {
|
|
87
|
+
const rowStart = y * rowLen;
|
|
88
|
+
raw[rowStart] = 0; // filter type 0 (None)
|
|
89
|
+
const inCardY = y >= 48 && y < 582;
|
|
90
|
+
const inAccentBarY = y >= 48 && y < 60;
|
|
91
|
+
|
|
92
|
+
for (let x = 0; x < width; x++) {
|
|
93
|
+
const px = rowStart + 1 + x * 3;
|
|
94
|
+
const inCardX = x >= 48 && x < 1152;
|
|
95
|
+
if (inCardX && inAccentBarY) {
|
|
96
|
+
// Emerald top accent bar (#047857)
|
|
97
|
+
raw[px] = 4;
|
|
98
|
+
raw[px + 1] = 120;
|
|
99
|
+
raw[px + 2] = 87;
|
|
100
|
+
} else if (inCardX && inCardY) {
|
|
101
|
+
// White card surface (#FFFFFF)
|
|
102
|
+
raw[px] = 255;
|
|
103
|
+
raw[px + 1] = 255;
|
|
104
|
+
raw[px + 2] = 255;
|
|
105
|
+
} else {
|
|
106
|
+
// Light slate outer frame (#F8FAFC)
|
|
107
|
+
raw[px] = 248;
|
|
108
|
+
raw[px + 1] = 250;
|
|
109
|
+
raw[px + 2] = 252;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
115
|
+
const ihdr = Buffer.alloc(13);
|
|
116
|
+
ihdr.writeUInt32BE(width, 0);
|
|
117
|
+
ihdr.writeUInt32BE(height, 4);
|
|
118
|
+
ihdr[8] = 8; // 8-bit depth
|
|
119
|
+
ihdr[9] = 2; // RGB color type
|
|
120
|
+
ihdr[10] = 0;
|
|
121
|
+
ihdr[11] = 0;
|
|
122
|
+
ihdr[12] = 0;
|
|
123
|
+
|
|
124
|
+
const compressed = zlib.deflateSync(raw, { level: 6 });
|
|
125
|
+
return Buffer.concat([
|
|
126
|
+
signature,
|
|
127
|
+
makePngChunk("IHDR", ihdr),
|
|
128
|
+
makePngChunk("IDAT", compressed),
|
|
129
|
+
makePngChunk("IEND", Buffer.alloc(0))
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function buildLlmsTxt(report, options = {}) {
|
|
47
134
|
const kit = report.launchKit || {};
|
|
48
135
|
const brand = kit.brandName || kit.productName || report.domain;
|
|
49
136
|
const tagline =
|
|
50
137
|
kit.tagline && kit.tagline.toLowerCase() !== brand.toLowerCase()
|
|
51
138
|
? kit.tagline
|
|
52
139
|
: report.meta?.description || `${brand} (${report.domain})`;
|
|
140
|
+
const includePublishedRoutes = Boolean(options.includePublishedRoutes || report.domain === "postbuild.dev");
|
|
53
141
|
const lines = [
|
|
54
142
|
`# ${brand}`,
|
|
55
143
|
``,
|
|
56
144
|
`> ${tagline}`,
|
|
57
145
|
``,
|
|
58
146
|
`- Canonical URL: ${report.targetUrl}`,
|
|
59
|
-
`- Contact: ${report.botEmail}`,
|
|
60
147
|
`- Category: ${kit.categories && kit.categories.length ? kit.categories.join(", ") : "Web Application"}`,
|
|
61
148
|
``,
|
|
62
149
|
`## Overview`,
|
|
63
|
-
`${kit.shortPitch || kit.longDescription || `${brand} at ${report.targetUrl}`}
|
|
64
|
-
``,
|
|
65
|
-
`## Guides & Resources (Add published routes here)`,
|
|
66
|
-
...(report.pseoPages || []).slice(0, 8).map(
|
|
67
|
-
(p) => `- [${p.keyword || p.path}](${report.targetUrl.replace(/\/$/, "")}${p.path || p.slug}): ${p.category || p.volume || "Guide"}`
|
|
68
|
-
),
|
|
69
|
-
``
|
|
150
|
+
`${kit.shortPitch || kit.longDescription || `${brand} at ${report.targetUrl}`}`
|
|
70
151
|
];
|
|
152
|
+
|
|
153
|
+
if (includePublishedRoutes && Array.isArray(report.pseoPages) && report.pseoPages.length > 0) {
|
|
154
|
+
lines.push(
|
|
155
|
+
``,
|
|
156
|
+
`## Guides & Resources`,
|
|
157
|
+
...report.pseoPages.slice(0, 8).map(
|
|
158
|
+
(p) => `- [${p.keyword || p.path}](${report.targetUrl.replace(/\/$/, "")}${p.path || p.slug}): ${p.category || p.volume || "Guide"}`
|
|
159
|
+
)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
lines.push(``);
|
|
71
164
|
return lines.join("\n");
|
|
72
165
|
}
|
|
73
166
|
|
|
@@ -80,7 +173,9 @@ export function buildHeadSeoSnippet(report, verificationToken = "") {
|
|
|
80
173
|
: report.meta?.title || `${brand} (${report.domain})`;
|
|
81
174
|
const desc = report.launchKit?.shortPitch || report.meta?.description || `${brand} (${report.domain})`;
|
|
82
175
|
const cleanOrigin = String(report.targetUrl || `https://${report.domain}`).replace(/\/$/, "");
|
|
83
|
-
const
|
|
176
|
+
const existingOgImg =
|
|
177
|
+
report.ogImage && !/\.svg(\?|$)/i.test(report.ogImage) ? report.ogImage : "";
|
|
178
|
+
const ogImg = existingOgImg || `${cleanOrigin}/og-card.png`;
|
|
84
179
|
const verifyTag =
|
|
85
180
|
verificationToken || report.verificationToken
|
|
86
181
|
? `\n<!-- Domain Ownership Verification -->\n<meta name="postbuild-verification" content="${verificationToken || report.verificationToken}" />\n`
|
|
@@ -111,9 +206,15 @@ export function buildStartHereMd(report, verificationToken = "") {
|
|
|
111
206
|
const brand = report.launchKit?.brandName || report.launchKit?.productName || domain;
|
|
112
207
|
const botEmail = report.botEmail || `${domain.replace(/\./g, "-")}-bot@postbuild.dev`;
|
|
113
208
|
const token = verificationToken || report.verificationToken || "";
|
|
209
|
+
const dirs = Array.isArray(report.directories) && report.directories.length > 0 ? report.directories : DIRECTORIES;
|
|
210
|
+
const linkCount = dirs.filter((d) => d.prefillMode === "url_params").length;
|
|
211
|
+
const copyCount = dirs.length - linkCount;
|
|
212
|
+
const dirOnlyCount = dirs.filter((d) => d.listingType !== "community").length;
|
|
213
|
+
const commCount = dirs.length - dirOnlyCount;
|
|
214
|
+
|
|
114
215
|
return `# START HERE — 3-Step Launch Guide for ${brand} (${domain})
|
|
115
216
|
|
|
116
|
-
Built with **Lovable, Bolt, v0, Replit, Cursor, or Claude**? You do **not** need to
|
|
217
|
+
Built with **Lovable, Bolt, v0, Replit, Cursor, or Claude**? You do **not** need to configure SEO manually. Follow these 3 steps in order:
|
|
117
218
|
|
|
118
219
|
---
|
|
119
220
|
|
|
@@ -124,12 +225,12 @@ Upload or drag this folder into your AI builder (Lovable, Bolt, Replit, Cursor,
|
|
|
124
225
|
> **Copy & Paste Prompt for Your AI Builder:**
|
|
125
226
|
> \`\`\`text
|
|
126
227
|
> Please add the SEO & Launch files to my project:
|
|
127
|
-
> 1. Place public/robots.txt, public/sitemap.xml, public/llms.txt, public/og-card.
|
|
228
|
+
> 1. Place public/robots.txt, public/sitemap.xml, public/llms.txt, public/og-card.png, and public/.well-known/postbuild-verify.txt into my static public/ folder so they are served at /robots.txt, /sitemap.xml, /llms.txt, /og-card.png, and /.well-known/postbuild-verify.txt.
|
|
128
229
|
> 2. Copy the meta tags (including <meta name="postbuild-verification" ...>), canonical link, OpenGraph tags, and JSON-LD structured data from head-seo-snippet.html directly into the static HTML <head> of my root index.html (do not inject via client-only JavaScript so crawlers and domain verification can read them on initial HTTP fetch).
|
|
129
|
-
> 3. Review the starter HTML
|
|
230
|
+
> 3. Review the starter HTML templates in .postbuild/pseo/ (or starter-pages/). Copy only the 5–8 routes that match our product into public/, customize their copy with our real features/pricing, remove <meta name="robots" content="noindex" />, and add only those published routes from sitemap-starter-routes.xml into public/sitemap.xml and public/llms.txt.
|
|
130
231
|
> \`\`\`
|
|
131
232
|
|
|
132
|
-
> **Editorial Note on Starter HTML
|
|
233
|
+
> **Editorial Note on Starter HTML Templates:** The static HTML pages in \`.postbuild/pseo/\` include \`<meta name="robots" content="noindex" />\` by default so unedited drafts are never indexed by search engines. \`public/sitemap.xml\` lists only your root URL (\`https://${domain}/\`) by default so you never publish broken 404 URLs. Customize only the 5–8 pages you want to publish, remove \`noindex\`, and add those URLs into \`public/sitemap.xml\`.
|
|
133
234
|
|
|
134
235
|
---
|
|
135
236
|
|
|
@@ -143,14 +244,16 @@ Before submitting to directories with \`${botEmail}\`, confirm your inbox is unl
|
|
|
143
244
|
|
|
144
245
|
---
|
|
145
246
|
|
|
146
|
-
## Step 3: Submit
|
|
247
|
+
## Step 3: Submit Across ${dirOnlyCount} Curated Directories & ${commCount} Launch Communities
|
|
147
248
|
|
|
148
249
|
1. Open \`directories-85.csv\` (or the **Dispatch (85)** tab on \`https://www.postbuild.dev\`).
|
|
149
|
-
2.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
250
|
+
2. **How the 85 links work (${linkCount} fill by link, ${copyCount} fill by copy):**
|
|
251
|
+
- **${linkCount} URL Auto-Prefill links (\`Fill method: Link\`):** Clicking the link opens the submission page with your title, URL, and pitch pre-populated via URL query parameters.
|
|
252
|
+
- **${copyCount} Standalone Web Forms (\`Fill method: Copy\`):** Browsers block cross-site scripts from injecting text into third-party forms, so use the 1-click field copy buttons (or the Auto-Fill Bookmarklet on desktop) on the **Dispatch (85)** tab to paste your Name, URL, Tagline, Pitch, and verification email.
|
|
253
|
+
3. **Pace your community posts:** For community channels (Hacker News, Reddit subreddits, Indie Hackers), post to only 1–2 relevant communities per week and follow each community's self-promotion rules.
|
|
254
|
+
4. **Use your verified inbox address** when a directory asks for a verification email:
|
|
255
|
+
\`${botEmail}\` *(Note: For B2B vendor portals like G2, Capterra, and Crunchbase that require a work email on your own domain, use your \`@${domain}\` address).*
|
|
256
|
+
5. Confirmation emails and 6-digit OTP codes sent to \`${botEmail}\` arrive in your **Account & Verification Inbox** on \`https://www.postbuild.dev\`.
|
|
154
257
|
|
|
155
258
|
---
|
|
156
259
|
|
|
@@ -159,16 +262,45 @@ Before submitting to directories with \`${botEmail}\`, confirm your inbox is unl
|
|
|
159
262
|
- \`public/.well-known/postbuild-verify.txt\` — Domain ownership verification file for \`${botEmail}\`
|
|
160
263
|
- \`public/robots.txt\` — Search & AI crawler rules (OAI-SearchBot, ClaudeBot, PerplexityBot)
|
|
161
264
|
- \`public/sitemap.xml\` — Clean XML Sitemap indexing your root URL (\`https://${domain}/\`)
|
|
162
|
-
- \`sitemap-starter-routes.xml\` —
|
|
265
|
+
- \`sitemap-starter-routes.xml\` — XML Sitemap entries for the optional starter HTML routes
|
|
163
266
|
- \`public/llms.txt\` — Structured markdown manifest for ChatGPT Search, Claude, and Perplexity
|
|
164
|
-
- \`public/og-card.
|
|
267
|
+
- \`public/og-card.png\` — 1200×630 PNG social preview card fallback image (plus \`og-card.svg\` vector source)
|
|
165
268
|
- \`head-seo-snippet.html\` — Title, meta description, domain verification tag, OpenGraph tags, and Schema.org JSON-LD
|
|
166
|
-
- \`launch-kit.md\` — Ready-to-post copy for Show HN, Product Hunt, and
|
|
167
|
-
- \`directories-85.csv\` — All
|
|
168
|
-
- \`.postbuild/pseo/{use-cases,compare,guides,checklists,templates,calculators}/**/index.html\` — Starter static HTML SEO
|
|
269
|
+
- \`launch-kit.md\` — Ready-to-post copy for Show HN, Product Hunt, and software directories
|
|
270
|
+
- \`directories-85.csv\` — All ${dirs.length} directories & communities (${linkCount} fill by link, ${copyCount} fill by copy)
|
|
271
|
+
- \`.postbuild/pseo/{use-cases,compare,guides,checklists,templates,calculators}/**/index.html\` — Starter static HTML SEO templates (\`noindex\` until customized)
|
|
169
272
|
`;
|
|
170
273
|
}
|
|
171
274
|
|
|
275
|
+
function mergeRobotsTxtWithExisting(existingRobots = "", newRobots = "") {
|
|
276
|
+
if (!existingRobots || !existingRobots.trim()) return newRobots;
|
|
277
|
+
const customDisallows = existingRobots
|
|
278
|
+
.split(/\r?\n/)
|
|
279
|
+
.map((l) => l.trim())
|
|
280
|
+
.filter((l) => /^Disallow:\s*\S+/i.test(l) && !/^Disallow:\s*$/i.test(l));
|
|
281
|
+
if (customDisallows.length === 0) return newRobots;
|
|
282
|
+
const uniqueDisallows = [...new Set(customDisallows)];
|
|
283
|
+
return newRobots.replace(
|
|
284
|
+
/User-agent:\s*\*\r?\nAllow:\s*\//i,
|
|
285
|
+
`User-agent: *\nAllow: /\n${uniqueDisallows.join("\n")}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function mergeSitemapWithExisting(existingXml = "", newXml = "") {
|
|
290
|
+
if (!existingXml || !/<urlset/i.test(existingXml)) return newXml;
|
|
291
|
+
const existingLocs = [...existingXml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((m) => m[1].trim());
|
|
292
|
+
if (existingLocs.length === 0) return newXml;
|
|
293
|
+
const newLocs = new Set([...newXml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((m) => m[1].trim()));
|
|
294
|
+
const today = new Date().toISOString().split("T")[0];
|
|
295
|
+
const preservedEntries = existingLocs
|
|
296
|
+
.filter((loc) => !newLocs.has(loc))
|
|
297
|
+
.map(
|
|
298
|
+
(loc) => ` <url>\n <loc>${loc}</loc>\n <lastmod>${today}</lastmod>\n <changefreq>weekly</changefreq>\n <priority>0.8</priority>\n </url>`
|
|
299
|
+
);
|
|
300
|
+
if (preservedEntries.length === 0) return newXml;
|
|
301
|
+
return newXml.replace(/<\/urlset>/i, `${preservedEntries.join("\n")}\n</urlset>`);
|
|
302
|
+
}
|
|
303
|
+
|
|
172
304
|
export function applyLocalSeoFixes(report, options = {}) {
|
|
173
305
|
const cwd = options.cwd || process.cwd();
|
|
174
306
|
const overwritePublic = Boolean(options.overwritePublic);
|
|
@@ -181,6 +313,7 @@ export function applyLocalSeoFixes(report, options = {}) {
|
|
|
181
313
|
fs.mkdirSync(outDir, { recursive: true });
|
|
182
314
|
|
|
183
315
|
const writtenFiles = [];
|
|
316
|
+
const overwrittenPublicFiles = [];
|
|
184
317
|
|
|
185
318
|
// 0. START_HERE.md in .postbuild/
|
|
186
319
|
const startHerePath = path.join(outDir, "START_HERE.md");
|
|
@@ -200,7 +333,7 @@ export function applyLocalSeoFixes(report, options = {}) {
|
|
|
200
333
|
fs.writeFileSync(path.join(outWellKnownDir, "postbuild-verify.txt"), `${verificationToken}\n`, "utf8");
|
|
201
334
|
}
|
|
202
335
|
|
|
203
|
-
// 0c. Default og-card.
|
|
336
|
+
// 0c. Default og-card.png (1200x630 PNG) in public/ (if no og-card.png exists yet) + og-card.svg source in .postbuild/
|
|
204
337
|
const ogSvgPath = path.join(publicDir, "og-card.svg");
|
|
205
338
|
const ogPngPath = path.join(publicDir, "og-card.png");
|
|
206
339
|
const ogSvgContent = buildOgCardSvg(
|
|
@@ -208,34 +341,49 @@ export function applyLocalSeoFixes(report, options = {}) {
|
|
|
208
341
|
report.domain,
|
|
209
342
|
report.launchKit?.tagline || ""
|
|
210
343
|
);
|
|
211
|
-
|
|
344
|
+
const ogPngBuffer = buildOgCardPngBuffer();
|
|
345
|
+
if (!fs.existsSync(ogPngPath)) {
|
|
346
|
+
fs.writeFileSync(ogPngPath, ogPngBuffer);
|
|
347
|
+
writtenFiles.push({ path: path.relative(cwd, ogPngPath), role: "OpenGraph Social Preview Card (og-card.png)" });
|
|
348
|
+
}
|
|
349
|
+
if (!fs.existsSync(ogSvgPath)) {
|
|
212
350
|
fs.writeFileSync(ogSvgPath, ogSvgContent, "utf8");
|
|
213
|
-
writtenFiles.push({ path: path.relative(cwd, ogSvgPath), role: "OpenGraph Social Preview Card (og-card.svg)" });
|
|
214
351
|
}
|
|
352
|
+
fs.writeFileSync(path.join(outDir, "og-card.png"), ogPngBuffer);
|
|
215
353
|
fs.writeFileSync(path.join(outDir, "og-card.svg"), ogSvgContent, "utf8");
|
|
216
354
|
|
|
217
|
-
// 1. robots.txt in public/ (if missing or overwritePublic)
|
|
355
|
+
// 1. robots.txt in public/ (if missing or overwritePublic — merges existing Disallow rules)
|
|
218
356
|
const robotsPath = path.join(publicDir, "robots.txt");
|
|
219
|
-
|
|
220
|
-
|
|
357
|
+
const hadRobots = fs.existsSync(robotsPath);
|
|
358
|
+
if (!hadRobots || overwritePublic) {
|
|
359
|
+
const existingRobots = hadRobots ? fs.readFileSync(robotsPath, "utf8") : "";
|
|
360
|
+
const mergedRobots = mergeRobotsTxtWithExisting(existingRobots, report.artifacts.robotsTxt);
|
|
361
|
+
fs.writeFileSync(robotsPath, mergedRobots, "utf8");
|
|
362
|
+
if (hadRobots) overwrittenPublicFiles.push(path.relative(cwd, robotsPath));
|
|
221
363
|
writtenFiles.push({ path: path.relative(cwd, robotsPath), role: "AI + Search Crawler Rules (robots.txt)" });
|
|
222
364
|
}
|
|
223
365
|
|
|
224
|
-
// 2. sitemap.xml in public/ (root URL only by default unless publishPseoToPublic or postbuild.dev)
|
|
366
|
+
// 2. sitemap.xml in public/ (root URL only by default unless publishPseoToPublic or postbuild.dev; merges existing URLs)
|
|
225
367
|
const sitemapPath = path.join(publicDir, "sitemap.xml");
|
|
368
|
+
const hadSitemap = fs.existsSync(sitemapPath);
|
|
226
369
|
const rootSitemapXml = report.artifacts.rootSitemapXml || report.artifacts.sitemapXml;
|
|
227
370
|
const fullSitemapXml = report.artifacts.sitemapAllRoutesXml || report.artifacts.sitemapXml;
|
|
228
|
-
if (!
|
|
229
|
-
const
|
|
230
|
-
|
|
371
|
+
if (!hadSitemap || overwritePublic) {
|
|
372
|
+
const existingSitemap = hadSitemap ? fs.readFileSync(sitemapPath, "utf8") : "";
|
|
373
|
+
const baseSitemapContent = publishPseoToPublic || report.domain === "postbuild.dev" ? fullSitemapXml : rootSitemapXml;
|
|
374
|
+
const mergedSitemap = mergeSitemapWithExisting(existingSitemap, baseSitemapContent);
|
|
375
|
+
fs.writeFileSync(sitemapPath, mergedSitemap, "utf8");
|
|
376
|
+
if (hadSitemap) overwrittenPublicFiles.push(path.relative(cwd, sitemapPath));
|
|
231
377
|
writtenFiles.push({ path: path.relative(cwd, sitemapPath), role: "XML Sitemap (sitemap.xml)" });
|
|
232
378
|
}
|
|
233
379
|
|
|
234
380
|
// 3. llms.txt in public/ for AI Answer Engines (ChatGPT, Claude, Perplexity)
|
|
235
381
|
const llmsPath = path.join(publicDir, "llms.txt");
|
|
236
|
-
const
|
|
237
|
-
|
|
382
|
+
const hadLlms = fs.existsSync(llmsPath);
|
|
383
|
+
const llmsContent = buildLlmsTxt(report, { includePublishedRoutes: publishPseoToPublic });
|
|
384
|
+
if (!hadLlms || overwritePublic) {
|
|
238
385
|
fs.writeFileSync(llmsPath, llmsContent, "utf8");
|
|
386
|
+
if (hadLlms) overwrittenPublicFiles.push(path.relative(cwd, llmsPath));
|
|
239
387
|
writtenFiles.push({ path: path.relative(cwd, llmsPath), role: "AI Answer Engine Manifest (llms.txt)" });
|
|
240
388
|
}
|
|
241
389
|
|
|
@@ -275,7 +423,7 @@ export function applyLocalSeoFixes(report, options = {}) {
|
|
|
275
423
|
const renderedPseo = writePseoHtmlFiles(report.pseoPages || [], pseoOutDir, pseoRenderOpts);
|
|
276
424
|
writtenFiles.push({
|
|
277
425
|
path: path.relative(cwd, pseoOutDir),
|
|
278
|
-
role: `${renderedPseo.length} Starter Static SEO HTML
|
|
426
|
+
role: `${renderedPseo.length} Starter Static SEO HTML Templates (noindex active until customized)`
|
|
279
427
|
});
|
|
280
428
|
|
|
281
429
|
if (publishPseoToPublic) {
|
|
@@ -286,20 +434,34 @@ export function applyLocalSeoFixes(report, options = {}) {
|
|
|
286
434
|
fs.writeFileSync(launchKitOut, report.launchKit?.longDescription || report.launchKit?.shortPitch || "", "utf8");
|
|
287
435
|
writtenFiles.push({ path: path.relative(cwd, launchKitOut), role: "Synthesized Launch Copy (HN, Product Hunt, Directories)" });
|
|
288
436
|
|
|
289
|
-
// 5. CSV export of all 85 directories with
|
|
290
|
-
const csvHeader = "ID,Directory Name,Category,Domain Rating,Submission
|
|
437
|
+
// 5. CSV export of all 85 directories & communities with honest Fill method (Link vs Copy)
|
|
438
|
+
const csvHeader = "ID,Directory Name,Type,Category,Domain Rating,Pricing,Submission link,Fill method,Verification Route\n";
|
|
291
439
|
const csvRows = (report.directories || []).map((d) => {
|
|
292
440
|
const q = (val) => `"${String(val || "").replace(/"/g, '""')}"`;
|
|
293
|
-
|
|
441
|
+
const typeLabel = d.listingType === "community" ? "Community" : "Directory";
|
|
442
|
+
const fillMethod = d.prefillMode === "url_params" ? "Link" : "Copy";
|
|
443
|
+
const subLink = d.prefilledUrl || d.submitUrl || d.url;
|
|
444
|
+
return [
|
|
445
|
+
d.id,
|
|
446
|
+
q(d.name),
|
|
447
|
+
q(typeLabel),
|
|
448
|
+
q(d.category),
|
|
449
|
+
d.dr,
|
|
450
|
+
q(d.pricing || "Free"),
|
|
451
|
+
q(subLink),
|
|
452
|
+
q(fillMethod),
|
|
453
|
+
q(d.authType || "Direct Form")
|
|
454
|
+
].join(",");
|
|
294
455
|
});
|
|
295
456
|
const csvOut = path.join(outDir, "directories-85.csv");
|
|
296
457
|
fs.writeFileSync(csvOut, csvHeader + csvRows.join("\n") + "\n", "utf8");
|
|
297
|
-
writtenFiles.push({ path: path.relative(cwd, csvOut), role: "85
|
|
458
|
+
writtenFiles.push({ path: path.relative(cwd, csvOut), role: "85 Directories & Communities CSV (23 Link / 62 Copy)" });
|
|
298
459
|
|
|
299
460
|
return {
|
|
300
461
|
framework,
|
|
301
462
|
publicDir: path.relative(cwd, publicDir) || "public",
|
|
302
463
|
outDir: path.relative(cwd, outDir) || ".postbuild",
|
|
303
|
-
writtenFiles
|
|
464
|
+
writtenFiles,
|
|
465
|
+
overwrittenPublicFiles
|
|
304
466
|
};
|
|
305
467
|
}
|
package/lib/inbox.js
CHANGED
|
@@ -201,7 +201,7 @@ export async function ingestInboundEmail(rawPayload = {}, options = {}) {
|
|
|
201
201
|
const forwardTarget = (process.env.ADMIN_FORWARD_EMAIL || process.env.ADMIN_EMAIL || "").trim();
|
|
202
202
|
if (forwardTarget && forwardTarget.includes("@")) {
|
|
203
203
|
try {
|
|
204
|
-
await fetch("https://api.resend.com/emails", {
|
|
204
|
+
const fwdResp = await fetch("https://api.resend.com/emails", {
|
|
205
205
|
method: "POST",
|
|
206
206
|
headers: {
|
|
207
207
|
Authorization: `Bearer ${resendApiKey}`,
|
|
@@ -216,7 +216,13 @@ export async function ingestInboundEmail(rawPayload = {}, options = {}) {
|
|
|
216
216
|
}),
|
|
217
217
|
signal: AbortSignal.timeout(4500)
|
|
218
218
|
});
|
|
219
|
-
|
|
219
|
+
if (!fwdResp.ok) {
|
|
220
|
+
const errText = await fwdResp.text().catch(() => "");
|
|
221
|
+
console.error("[inbox/support-forward] Resend forward failed:", fwdResp.status, errText);
|
|
222
|
+
}
|
|
223
|
+
} catch (fwdErr) {
|
|
224
|
+
console.error("[inbox/support-forward] Error forwarding support email:", fwdErr?.message || fwdErr);
|
|
225
|
+
}
|
|
220
226
|
}
|
|
221
227
|
}
|
|
222
228
|
return {
|
package/lib/mcp.js
CHANGED
|
@@ -936,7 +936,7 @@ export async function executeMcpToolCall(name, args = {}, sessionId = "mcp-stdio
|
|
|
936
936
|
|
|
937
937
|
const fixRes = applyLocalSeoFixes(report, {
|
|
938
938
|
cwd: args.cwd || process.cwd(),
|
|
939
|
-
overwritePublic:
|
|
939
|
+
overwritePublic: Boolean(args.force || args.overwritePublic),
|
|
940
940
|
verificationToken: license.verificationToken || ""
|
|
941
941
|
});
|
|
942
942
|
return fixRes;
|