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/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
- `${slug.charAt(0).toUpperCase() + slug.slice(1)} — Web Application`;
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 hasPseoSitemap = sitemapUrlCount >= 10;
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 && h1List.length > 0
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: "Programmatic SEO Sitemap (10+ Routes)",
212
- passed: hasPseoSitemap,
213
- detail: hasSitemap
214
- ? `sitemap.xml active (${sitemapUrlCount} indexed routes — ${hasPseoSitemap ? "pSEO active" : "25 pSEO routes ready to add"})`
215
- : "No sitemap.xml found — 25 structured pSEO routes ready to deploy"
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 cleanName = (titleParts[0] || slug).trim();
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.slice(0, 60).trim() : domain);
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({ origin, domain, slug, cleanName, tagline, description, h1List, h2List, botEmail }) {
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}) — web application and online workspace available at ${origin}.`;
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 Workspace**: Access ${cleanName} directly at ${origin}.`,
307
- `- **Focused Workflow**: Built for teams and individuals using ${domain}.`
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
- const isPostbuildSelf = origin.includes("postbuild.dev");
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 = "pro", email = "" } = {}) {
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 = "Get ${cleanName} Pro — $29" }) {
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 (fs.existsSync(path.join(cwd, "next.config.js")) || fs.existsSync(path.join(cwd, "next.config.mjs")) || fs.existsSync(path.join(cwd, "next.config.ts"))) {
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 (fs.existsSync(path.join(cwd, "vite.config.js")) || fs.existsSync(path.join(cwd, "vite.config.ts")) || fs.existsSync(path.join(cwd, "vite.config.mjs"))) {
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
- export function buildLlmsTxt(report) {
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 ogImg = report.ogImage || `${cleanOrigin}/og-card.svg`;
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 know how to code or configure SEO manually. Follow these 3 steps in order:
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.svg, 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.svg, and /.well-known/postbuild-verify.txt.
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 guides 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, and add only those published routes from sitemap-starter-routes.xml into public/sitemap.xml and public/llms.txt (remove any unpublished route links so nothing 404s).
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 Pages:** The static HTML pages in \`.postbuild/pseo/\` are **starter drafts** synthesized from your homepage scan. \`public/sitemap.xml\` lists only your root URL (\`https://${domain}/\`) by default so you never publish broken 404 URLs. Copy and customize only the pages you want to publish, then add those URLs from \`sitemap-starter-routes.xml\` into \`public/sitemap.xml\`.
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 to 85 Curated Startup & AI Directories (No Personal Inbox Spam)
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. Click any **Prefilled Launch URL** — your product name, URL, UTM tags, and description are already pre-populated in the link parameters.
150
- 3. For directories with custom form fields, drag the **1-Click Auto-Fill Bookmarklet** on the Postbuild **Dispatch (85)** tab to your browser bookmarks bar and click it on any directory page to fill the form automatically.
151
- 4. **Use your verified inbox address** when a directory asks for an email address:
152
- \`${botEmail}\`
153
- 5. Every confirmation email or 6-digit OTP code sent to \`${botEmail}\` arrives in your **Account & Verification Inbox** on \`https://www.postbuild.dev\`.
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\` — Full XML Sitemap entries for the optional starter HTML routes
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.svg\` — Social preview card fallback image
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 startup directories
167
- - \`directories-85.csv\` — All 85 curated directories ranked by Domain Rating (DR) with prefilled URLs
168
- - \`.postbuild/pseo/{use-cases,compare,guides,checklists,templates,calculators}/**/index.html\` — Starter static HTML SEO drafts to review before publishing
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.svg in public/ (if no og-card.svg or og-card.png exists yet)
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
- if (!fs.existsSync(ogSvgPath) && !fs.existsSync(ogPngPath)) {
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
- if (!fs.existsSync(robotsPath) || overwritePublic) {
220
- fs.writeFileSync(robotsPath, report.artifacts.robotsTxt, "utf8");
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 (!fs.existsSync(sitemapPath) || overwritePublic) {
229
- const publicSitemapContent = publishPseoToPublic || report.domain === "postbuild.dev" ? fullSitemapXml : rootSitemapXml;
230
- fs.writeFileSync(sitemapPath, publicSitemapContent, "utf8");
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 llmsContent = buildLlmsTxt(report);
237
- if (!fs.existsSync(llmsPath) || overwritePublic) {
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 Drafts (review & copy selected routes to public/)`
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 prefilled submission links
290
- const csvHeader = "ID,Directory Name,Category,Domain Rating,Submission URL,Prefilled Launch URL,Auto-Fill\n";
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
- return [d.id, q(d.name), q(d.category), d.dr, q(d.submitUrl || d.url), q(d.prefilledUrl), d.auto ? "Yes" : "Manual"].join(",");
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 Curated Directories CSV with Prefilled URLs" });
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
- } catch {}
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: true,
939
+ overwritePublic: Boolean(args.force || args.overwritePublic),
940
940
  verificationToken: license.verificationToken || ""
941
941
  });
942
942
  return fixRes;