git-paid 2.1.6 → 2.1.8
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/bin/git-paid.js +16 -6
- package/lib/auth.js +241 -24
- package/lib/directories.js +179 -123
- package/lib/engine.js +92 -48
- package/lib/fixer.js +128 -52
- package/lib/guard.js +203 -1
- package/lib/inbox.js +49 -4
- package/lib/mcp.js +15 -1
- package/lib/pseo.js +202 -8
- package/lib/safe-fetch.js +14 -5
- package/package.json +1 -1
package/lib/guard.js
CHANGED
|
@@ -3,10 +3,24 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import crypto from "node:crypto";
|
|
6
|
-
import
|
|
6
|
+
import dns from "node:dns/promises";
|
|
7
|
+
import {
|
|
8
|
+
verifyLicenseToken,
|
|
9
|
+
checkStripeCustomerStatusCached,
|
|
10
|
+
getFounderEmailsSet,
|
|
11
|
+
getDomainVerificationToken,
|
|
12
|
+
getCandidateVerificationTokens,
|
|
13
|
+
isSameDomainVerifiedEmail,
|
|
14
|
+
isFreeEmailDomain
|
|
15
|
+
} from "./auth.js";
|
|
7
16
|
import { getBotInboxForDomain, normalizeInboxInput } from "./inbox.js";
|
|
17
|
+
import { safeFetch } from "./safe-fetch.js";
|
|
18
|
+
|
|
19
|
+
export { getDomainVerificationToken };
|
|
8
20
|
|
|
9
21
|
const rateLimitBuckets = new Map(); // `${bucket}:${ip}` -> { count: number, resetAt: number }
|
|
22
|
+
const verifiedDomainCache = new Map(); // `${domain}:${email}` -> { verifiedAt: number, method: string }
|
|
23
|
+
const failedDomainCache = new Map(); // `${domain}:${email}` -> { failedAt: number, payload: object }
|
|
10
24
|
|
|
11
25
|
const ALLOWED_ORIGINS = new Set([
|
|
12
26
|
"https://www.postbuild.dev",
|
|
@@ -195,6 +209,194 @@ export function checkRateLimit(req, bucket = "default", limitPerMinute = 20) {
|
|
|
195
209
|
};
|
|
196
210
|
}
|
|
197
211
|
|
|
212
|
+
export function hasVerificationMetaTagInHead(rawHtml = "", expectedTokens = []) {
|
|
213
|
+
const tokenSet = new Set(Array.isArray(expectedTokens) ? expectedTokens : [expectedTokens]);
|
|
214
|
+
if (!rawHtml || tokenSet.size === 0) return false;
|
|
215
|
+
const headMatch = String(rawHtml).match(/<head\b[^>]*>([\s\S]*?)<\/head>/i);
|
|
216
|
+
const rawHeadHtml = headMatch ? headMatch[1] : String(rawHtml).split(/<body\b/i)[0] || "";
|
|
217
|
+
const sanitizedHeadHtml = rawHeadHtml
|
|
218
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
219
|
+
.replace(/<(title|script|style|noscript|template|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
|
|
220
|
+
const metaTags = sanitizedHeadHtml.match(/<meta\b[^>]*>/gi) || [];
|
|
221
|
+
for (const tag of metaTags) {
|
|
222
|
+
const nameMatch = tag.match(/\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/i);
|
|
223
|
+
const contentMatch = tag.match(/\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/i);
|
|
224
|
+
const metaName = (nameMatch?.[1] ?? nameMatch?.[2] ?? nameMatch?.[3] ?? "").trim().toLowerCase();
|
|
225
|
+
const metaContent = (contentMatch?.[1] ?? contentMatch?.[2] ?? contentMatch?.[3] ?? "").trim();
|
|
226
|
+
if (metaName === "postbuild-verification" && tokenSet.has(metaContent)) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function verifyDomainOwnership(verified, requestedDomain = "", options = {}) {
|
|
234
|
+
if (!verified || !verified.valid) {
|
|
235
|
+
return { verified: false, method: "none", error: "Authentication required." };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const cleanDomain = String(requestedDomain || verified.domain || "")
|
|
239
|
+
.toLowerCase()
|
|
240
|
+
.trim()
|
|
241
|
+
.replace(/^https?:\/\//, "")
|
|
242
|
+
.replace(/^www\./, "")
|
|
243
|
+
.split("/")[0]
|
|
244
|
+
.split(":")[0];
|
|
245
|
+
const cleanEmail = String(verified.email || "").toLowerCase().trim();
|
|
246
|
+
if (!cleanDomain || !cleanDomain.includes(".")) {
|
|
247
|
+
return { verified: false, method: "none", error: "Valid project domain required." };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const expectedToken = getDomainVerificationToken(cleanDomain, cleanEmail);
|
|
251
|
+
const candidateTokens = getCandidateVerificationTokens(cleanDomain, cleanEmail);
|
|
252
|
+
const emailDomain = cleanEmail.includes("@") ? cleanEmail.split("@")[1] : "";
|
|
253
|
+
|
|
254
|
+
// 1. Operator for postbuild.dev or OTP/email-verified non-freemail address on the same domain
|
|
255
|
+
if (isSameDomainVerifiedEmail(cleanEmail, cleanDomain, Boolean(verified.emailVerified))) {
|
|
256
|
+
return {
|
|
257
|
+
verified: true,
|
|
258
|
+
method: "email_domain",
|
|
259
|
+
domain: cleanDomain,
|
|
260
|
+
verificationToken: expectedToken
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const cacheKey = `${cleanDomain}:${cleanEmail}`;
|
|
265
|
+
const cached = verifiedDomainCache.get(cacheKey);
|
|
266
|
+
if (cached && Date.now() - cached.verifiedAt < 3600 * 1000) {
|
|
267
|
+
return {
|
|
268
|
+
verified: true,
|
|
269
|
+
method: cached.method,
|
|
270
|
+
domain: cleanDomain,
|
|
271
|
+
verificationToken: expectedToken
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!options.forceFresh && !options.resolveTxtFn && !options.fetchFn) {
|
|
276
|
+
const negCached = failedDomainCache.get(cacheKey);
|
|
277
|
+
if (negCached && Date.now() - negCached.failedAt < 60 * 1000) {
|
|
278
|
+
return negCached.payload;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const resolveTxt = options.resolveTxtFn || ((host) => dns.resolveTxt(host));
|
|
283
|
+
const fetchUrl =
|
|
284
|
+
options.fetchFn ||
|
|
285
|
+
((url) => safeFetch(url, { timeoutMs: 4500, maxBytes: 256 * 1024, truncateOnMaxBytes: true }));
|
|
286
|
+
|
|
287
|
+
let dnsCheckDetail = `No matching TXT record on _postbuild.${cleanDomain}`;
|
|
288
|
+
let wellKnownCheckDetail = "Not found";
|
|
289
|
+
let htmlMetaCheckDetail = "No matching <meta> tag in homepage <head>";
|
|
290
|
+
|
|
291
|
+
// 2. Check DNS TXT on _postbuild.<domain> or <domain>
|
|
292
|
+
for (const host of [`_postbuild.${cleanDomain}`, cleanDomain]) {
|
|
293
|
+
try {
|
|
294
|
+
const records = await resolveTxt(host);
|
|
295
|
+
if (Array.isArray(records)) {
|
|
296
|
+
const flat = records.map((chunks) => (Array.isArray(chunks) ? chunks.join("") : String(chunks)).trim());
|
|
297
|
+
if (flat.some((r) => candidateTokens.some((tok) => r === tok || r.includes(tok)))) {
|
|
298
|
+
failedDomainCache.delete(cacheKey);
|
|
299
|
+
verifiedDomainCache.set(cacheKey, { verifiedAt: Date.now(), method: "dns_txt" });
|
|
300
|
+
return {
|
|
301
|
+
verified: true,
|
|
302
|
+
method: "dns_txt",
|
|
303
|
+
domain: cleanDomain,
|
|
304
|
+
verificationToken: expectedToken
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 3a. Check https://<domain>/.well-known/postbuild-verify.txt (strict line match)
|
|
312
|
+
try {
|
|
313
|
+
const resp = await fetchUrl(`https://${cleanDomain}/.well-known/postbuild-verify.txt`);
|
|
314
|
+
if (resp && resp.ok) {
|
|
315
|
+
const text = typeof resp.text === "function" ? await resp.text() : String(resp.body || "");
|
|
316
|
+
const lines = String(text || "").split(/\r?\n/).map((l) => l.trim());
|
|
317
|
+
if (candidateTokens.some((tok) => lines.includes(tok))) {
|
|
318
|
+
failedDomainCache.delete(cacheKey);
|
|
319
|
+
verifiedDomainCache.set(cacheKey, { verifiedAt: Date.now(), method: "well_known_file" });
|
|
320
|
+
return {
|
|
321
|
+
verified: true,
|
|
322
|
+
method: "well_known_file",
|
|
323
|
+
domain: cleanDomain,
|
|
324
|
+
verificationToken: expectedToken
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
wellKnownCheckDetail = "File returned 200 OK without matching token";
|
|
328
|
+
} else if (resp && resp.status) {
|
|
329
|
+
wellKnownCheckDetail = `HTTP ${resp.status}`;
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
wellKnownCheckDetail = "Unreachable or timed out";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 3b. Check https://<domain>/ for <meta name="postbuild-verification" content="..."> inside <head>
|
|
336
|
+
try {
|
|
337
|
+
const resp = await fetchUrl(`https://${cleanDomain}/`);
|
|
338
|
+
if (resp && resp.ok) {
|
|
339
|
+
const text = typeof resp.text === "function" ? await resp.text() : String(resp.body || "");
|
|
340
|
+
if (hasVerificationMetaTagInHead(text, candidateTokens)) {
|
|
341
|
+
failedDomainCache.delete(cacheKey);
|
|
342
|
+
verifiedDomainCache.set(cacheKey, { verifiedAt: Date.now(), method: "html_meta" });
|
|
343
|
+
return {
|
|
344
|
+
verified: true,
|
|
345
|
+
method: "html_meta",
|
|
346
|
+
domain: cleanDomain,
|
|
347
|
+
verificationToken: expectedToken
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
htmlMetaCheckDetail = "Homepage 200 OK — <meta name=\"postbuild-verification\"> not found in <head>";
|
|
351
|
+
} else if (resp && resp.status) {
|
|
352
|
+
htmlMetaCheckDetail = `HTTP ${resp.status}`;
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
htmlMetaCheckDetail = "Homepage unreachable or timed out";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const isFree = isFreeEmailDomain(emailDomain);
|
|
359
|
+
const sameDomainUnconfirmed = Boolean(
|
|
360
|
+
emailDomain && !isFree && (cleanDomain === emailDomain || cleanDomain.endsWith(`.${emailDomain}`)) && !verified.emailVerified
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const emailDomainStatus = !emailDomain
|
|
364
|
+
? "No account email"
|
|
365
|
+
: isFree
|
|
366
|
+
? `Personal email (${emailDomain}) requires domain proof`
|
|
367
|
+
: sameDomainUnconfirmed
|
|
368
|
+
? "Confirm your email via 6-digit code to unlock automatically"
|
|
369
|
+
: `Account email (@${emailDomain}) differs from ${cleanDomain}`;
|
|
370
|
+
|
|
371
|
+
const failurePayload = {
|
|
372
|
+
verified: false,
|
|
373
|
+
method: "none",
|
|
374
|
+
domain: cleanDomain,
|
|
375
|
+
verificationToken: expectedToken,
|
|
376
|
+
sameDomainUnconfirmed,
|
|
377
|
+
checkedAt: new Date().toISOString(),
|
|
378
|
+
checks: {
|
|
379
|
+
emailDomain: emailDomainStatus,
|
|
380
|
+
dnsTxt: dnsCheckDetail,
|
|
381
|
+
wellKnownFile: wellKnownCheckDetail,
|
|
382
|
+
htmlMeta: htmlMetaCheckDetail
|
|
383
|
+
},
|
|
384
|
+
dnsHost: `_postbuild.${cleanDomain}`,
|
|
385
|
+
wellKnownPath: "/.well-known/postbuild-verify.txt",
|
|
386
|
+
metaTag: `<meta name="postbuild-verification" content="${expectedToken}" />`,
|
|
387
|
+
error: `Domain ownership proof required for ${cleanDomain} before reading ${getBotInboxForDomain(cleanDomain, cleanEmail)}. Deploy /.well-known/postbuild-verify.txt (included in your Launch Bundle .zip), add <meta name="postbuild-verification" content="${expectedToken}" /> to your homepage <head>, add DNS TXT record _postbuild.${cleanDomain} = ${expectedToken}, or sign in via 6-digit code with an @${cleanDomain} email.`
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
if (!options.resolveTxtFn && !options.fetchFn) {
|
|
391
|
+
failedDomainCache.set(cacheKey, { failedAt: Date.now(), payload: failurePayload });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return failurePayload;
|
|
395
|
+
}
|
|
396
|
+
|
|
198
397
|
export function resetRateLimitsForTests() {
|
|
199
398
|
rateLimitBuckets.clear();
|
|
399
|
+
verifiedDomainCache.clear();
|
|
400
|
+
failedDomainCache.clear();
|
|
200
401
|
}
|
|
402
|
+
|
package/lib/inbox.js
CHANGED
|
@@ -155,7 +155,8 @@ export function sanitizeSubjectForDisplay(subject = "", otpCode = "") {
|
|
|
155
155
|
* Ingests a verified inbound email webhook payload.
|
|
156
156
|
* Rejects mail addressed to unknown or non-@postbuild.dev recipients.
|
|
157
157
|
*/
|
|
158
|
-
export async function ingestInboundEmail(rawPayload = {}) {
|
|
158
|
+
export async function ingestInboundEmail(rawPayload = {}, options = {}) {
|
|
159
|
+
const { fromWebhook = true } = options || {};
|
|
159
160
|
const payload = rawPayload?.data && typeof rawPayload.data === "object" ? rawPayload.data : rawPayload;
|
|
160
161
|
|
|
161
162
|
const toRaw = payload.to || payload.recipient || payload.envelope?.to || "";
|
|
@@ -194,6 +195,40 @@ export async function ingestInboundEmail(rawPayload = {}) {
|
|
|
194
195
|
const fromRaw = payload.from || payload.sender || payload.envelope?.from || "no-reply@directory-verifier.com";
|
|
195
196
|
const fromAddress = String(fromRaw).trim();
|
|
196
197
|
|
|
198
|
+
// Forward inbound customer support replies (support@postbuild.dev / login@postbuild.dev) to founder email only on webhook path
|
|
199
|
+
if (toAddress === "support@postbuild.dev" || toAddress === "login@postbuild.dev") {
|
|
200
|
+
if (fromWebhook && resendApiKey) {
|
|
201
|
+
const forwardTarget = (process.env.ADMIN_FORWARD_EMAIL || process.env.ADMIN_EMAIL || "").trim();
|
|
202
|
+
if (forwardTarget && forwardTarget.includes("@")) {
|
|
203
|
+
try {
|
|
204
|
+
await fetch("https://api.resend.com/emails", {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
Authorization: `Bearer ${resendApiKey}`,
|
|
208
|
+
"Content-Type": "application/json"
|
|
209
|
+
},
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
from: "postbuild Support <login@postbuild.dev>",
|
|
212
|
+
reply_to: fromAddress,
|
|
213
|
+
to: [forwardTarget],
|
|
214
|
+
subject: `[Support ${toAddress}] ${subject}`,
|
|
215
|
+
text: `From: ${fromAddress}\nTo: ${toAddress}\nSubject: ${subject}\n\n${textBody || htmlBody}`
|
|
216
|
+
}),
|
|
217
|
+
signal: AbortSignal.timeout(4500)
|
|
218
|
+
});
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
id: resendEmailId || `support_${Date.now()}`,
|
|
224
|
+
to: toAddress,
|
|
225
|
+
from: fromAddress,
|
|
226
|
+
subject,
|
|
227
|
+
status: "forwarded",
|
|
228
|
+
receivedAt: new Date().toISOString()
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
197
232
|
const signals = extractVerificationSignals(subject, textBody, htmlBody);
|
|
198
233
|
const displaySubject = sanitizeSubjectForDisplay(subject, signals.otpCode);
|
|
199
234
|
const receivedDate = payload.created_at ? new Date(payload.created_at) : new Date();
|
|
@@ -265,7 +300,7 @@ async function syncFromResendReceivingApi(targetInbox = "") {
|
|
|
265
300
|
} catch {}
|
|
266
301
|
}
|
|
267
302
|
|
|
268
|
-
await ingestInboundEmail(fullItem);
|
|
303
|
+
await ingestInboundEmail(fullItem, { fromWebhook: false });
|
|
269
304
|
}
|
|
270
305
|
} catch {}
|
|
271
306
|
}
|
|
@@ -295,11 +330,11 @@ export async function listInboxMessages(inboxAddress = "", options = {}) {
|
|
|
295
330
|
headers: {
|
|
296
331
|
Authorization: `Bearer ${options.bearerToken}`
|
|
297
332
|
},
|
|
298
|
-
signal: AbortSignal.timeout(
|
|
333
|
+
signal: AbortSignal.timeout(10000)
|
|
299
334
|
}
|
|
300
335
|
);
|
|
336
|
+
const remoteData = await remoteResp.json().catch(() => ({}));
|
|
301
337
|
if (remoteResp.ok) {
|
|
302
|
-
const remoteData = await remoteResp.json();
|
|
303
338
|
if (Array.isArray(remoteData.messages)) {
|
|
304
339
|
for (const rm of remoteData.messages) {
|
|
305
340
|
if (rm && rm.id && rm.to === normalized) {
|
|
@@ -308,6 +343,16 @@ export async function listInboxMessages(inboxAddress = "", options = {}) {
|
|
|
308
343
|
}
|
|
309
344
|
persistMessages();
|
|
310
345
|
}
|
|
346
|
+
} else if (remoteResp.status === 403 && remoteData && remoteData.requiresDomainVerification) {
|
|
347
|
+
return {
|
|
348
|
+
inboxAddress: normalized,
|
|
349
|
+
retentionDays: 30,
|
|
350
|
+
count: 0,
|
|
351
|
+
messages: [],
|
|
352
|
+
requiresDomainVerification: true,
|
|
353
|
+
verificationToken: remoteData.verificationToken || "",
|
|
354
|
+
error: remoteData.error || "Domain ownership verification required before reading this inbox."
|
|
355
|
+
};
|
|
311
356
|
}
|
|
312
357
|
} catch {}
|
|
313
358
|
}
|
package/lib/mcp.js
CHANGED
|
@@ -182,6 +182,9 @@ export async function autoVerifyInboxLinks(inboxAddress = "", options = {}) {
|
|
|
182
182
|
|
|
183
183
|
return {
|
|
184
184
|
inboxAddress: res.inboxAddress,
|
|
185
|
+
requiresDomainVerification: Boolean(res.requiresDomainVerification),
|
|
186
|
+
verificationToken: res.verificationToken || "",
|
|
187
|
+
error: res.error || "",
|
|
185
188
|
totalMessages: verificationResults.length,
|
|
186
189
|
verifiedCount: verificationResults.filter((x) => x.autoVerified).length,
|
|
187
190
|
extractedOtps: verificationResults
|
|
@@ -893,6 +896,16 @@ export async function executeMcpToolCall(name, args = {}, sessionId = "mcp-stdio
|
|
|
893
896
|
bearerToken: license.token
|
|
894
897
|
});
|
|
895
898
|
|
|
899
|
+
if (result.requiresDomainVerification) {
|
|
900
|
+
return {
|
|
901
|
+
isError: true,
|
|
902
|
+
status: "DOMAIN_VERIFICATION_REQUIRED",
|
|
903
|
+
inboxAddress: result.inboxAddress,
|
|
904
|
+
verificationToken: result.verificationToken,
|
|
905
|
+
message: result.error || "Verify ownership of your domain on https://www.postbuild.dev/?tab=account before reading this inbox."
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
896
909
|
const compacted = mcpInbox.compactList({
|
|
897
910
|
sessionId,
|
|
898
911
|
records: result.messages || []
|
|
@@ -923,7 +936,8 @@ export async function executeMcpToolCall(name, args = {}, sessionId = "mcp-stdio
|
|
|
923
936
|
|
|
924
937
|
const fixRes = applyLocalSeoFixes(report, {
|
|
925
938
|
cwd: args.cwd || process.cwd(),
|
|
926
|
-
overwritePublic: true
|
|
939
|
+
overwritePublic: true,
|
|
940
|
+
verificationToken: license.verificationToken || ""
|
|
927
941
|
});
|
|
928
942
|
return fixRes;
|
|
929
943
|
}
|
package/lib/pseo.js
CHANGED
|
@@ -263,7 +263,7 @@ export function renderSinglePseoPage(page, allPages = [], options = {}) {
|
|
|
263
263
|
<meta name="twitter:title" content="${escapeHtml(fullTitle)}" />
|
|
264
264
|
<meta name="twitter:description" content="${escapeHtml(metaDescription)}" />
|
|
265
265
|
<link rel="icon" type="image/svg+xml" href="/brand/postbuild-icon.svg" />
|
|
266
|
-
<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>
|
|
266
|
+
<script type="application/ld+json">${JSON.stringify(jsonLd).replace(/</g, "\\u003c")}</script>
|
|
267
267
|
<script>
|
|
268
268
|
window.si = window.si || function () { (window.siq = window.siq || []).push(arguments); };
|
|
269
269
|
</script>
|
|
@@ -638,7 +638,7 @@ export function renderSinglePseoPage(page, allPages = [], options = {}) {
|
|
|
638
638
|
|
|
639
639
|
<footer class="site-footer">
|
|
640
640
|
<div>
|
|
641
|
-
<strong>postbuild.dev</strong> (<code>git-paid</code> v2.1.
|
|
641
|
+
<strong>postbuild.dev</strong> (<code>git-paid</code> v2.1.6) — Post-build SEO, directory launch kit & verification inbox.
|
|
642
642
|
</div>
|
|
643
643
|
<div style="display:flex;gap:14px;flex-wrap:wrap;">
|
|
644
644
|
<a href="/">Home & Audit</a>
|
|
@@ -663,19 +663,213 @@ export function renderSinglePseoPage(page, allPages = [], options = {}) {
|
|
|
663
663
|
</html>`;
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
+
export function renderBuyerStarterPseoPage(page, allPages = [], options = {}) {
|
|
667
|
+
const cleanDomain = String(options.domain || "example.com")
|
|
668
|
+
.toLowerCase()
|
|
669
|
+
.trim()
|
|
670
|
+
.replace(/^https?:\/\//, "")
|
|
671
|
+
.replace(/^www\./, "")
|
|
672
|
+
.split("/")[0];
|
|
673
|
+
const cleanOrig = String(options.origin || `https://${cleanDomain}`).replace(/\/$/, "");
|
|
674
|
+
const prodName = String(options.cleanName || cleanDomain).trim();
|
|
675
|
+
const canonicalUrl = `${cleanOrig}${page.path}`;
|
|
676
|
+
const cat = page.category || page.volume || "Guide";
|
|
677
|
+
const cleanHeading = String(page.keyword || humanizeSlug(page.path, prodName).title);
|
|
678
|
+
const pageTitle = cleanHeading.toLowerCase().includes(prodName.toLowerCase())
|
|
679
|
+
? cleanHeading
|
|
680
|
+
: `${cleanHeading} | ${prodName}`;
|
|
681
|
+
|
|
682
|
+
const rawTagline = String(options.tagline || "").trim();
|
|
683
|
+
const tagline =
|
|
684
|
+
rawTagline &&
|
|
685
|
+
rawTagline.toLowerCase() !== prodName.toLowerCase() &&
|
|
686
|
+
!rawTagline.toLowerCase().includes(`(${cleanDomain})`)
|
|
687
|
+
? rawTagline
|
|
688
|
+
: "";
|
|
689
|
+
|
|
690
|
+
const shortPitch = String(options.shortPitch || options.description || "").trim();
|
|
691
|
+
const summaryText =
|
|
692
|
+
shortPitch ||
|
|
693
|
+
`${prodName} is available at ${cleanOrig}. Customize this starter draft with your specific product workflows, screenshots, and customer examples before publishing.`;
|
|
694
|
+
|
|
695
|
+
const cats =
|
|
696
|
+
Array.isArray(options.categories) && options.categories.length > 0
|
|
697
|
+
? options.categories.join(", ")
|
|
698
|
+
: "Web Application";
|
|
699
|
+
|
|
700
|
+
const metaDesc = `${cleanHeading}. ${summaryText}`.slice(0, 158).trim();
|
|
701
|
+
const ogImgUrl = options.ogImage || `${cleanOrig}/og-card.svg`;
|
|
702
|
+
const related = (allPages || []).filter((p) => p.path !== page.path).slice(0, 5);
|
|
703
|
+
const headingTopic = cleanHeading
|
|
704
|
+
.replace(new RegExp(`^${prodName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*[—–:-]?\\s*`, "i"), "")
|
|
705
|
+
.trim() || cleanHeading;
|
|
706
|
+
|
|
707
|
+
const hasFaqSchema = String(page.schemaType || page.kd || "").includes("FAQPage");
|
|
708
|
+
const faqItems = [
|
|
709
|
+
{
|
|
710
|
+
q: `What is covered in this ${headingTopic} guide?`,
|
|
711
|
+
a: `${summaryText}`
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
q: `Where can I access ${prodName}?`,
|
|
715
|
+
a: `Visit ${cleanOrig} to explore ${prodName} directly and review current product documentation.`
|
|
716
|
+
}
|
|
717
|
+
];
|
|
718
|
+
|
|
719
|
+
const graph = [
|
|
720
|
+
{
|
|
721
|
+
"@type": "SoftwareApplication",
|
|
722
|
+
name: prodName,
|
|
723
|
+
url: cleanOrig,
|
|
724
|
+
applicationCategory: "BusinessApplication",
|
|
725
|
+
operatingSystem: "Web",
|
|
726
|
+
description: summaryText
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
"@type": "BreadcrumbList",
|
|
730
|
+
itemListElement: [
|
|
731
|
+
{ "@type": "ListItem", position: 1, name: prodName, item: cleanOrig },
|
|
732
|
+
{ "@type": "ListItem", position: 2, name: cat, item: canonicalUrl }
|
|
733
|
+
]
|
|
734
|
+
}
|
|
735
|
+
];
|
|
736
|
+
if (hasFaqSchema) {
|
|
737
|
+
graph.push({
|
|
738
|
+
"@type": "FAQPage",
|
|
739
|
+
mainEntity: faqItems.map((f) => ({
|
|
740
|
+
"@type": "Question",
|
|
741
|
+
name: f.q,
|
|
742
|
+
acceptedAnswer: { "@type": "Answer", text: f.a }
|
|
743
|
+
}))
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
const ldJson = JSON.stringify({ "@context": "https://schema.org", "@graph": graph }).replace(/</g, "\\u003c");
|
|
747
|
+
|
|
748
|
+
let bodySectionHtml = "";
|
|
749
|
+
if (cat === "Comparison" || cat === "Walkthrough") {
|
|
750
|
+
bodySectionHtml = ` <section class="card">
|
|
751
|
+
<h2>Evaluation Criteria: ${escapeHtml(headingTopic)}</h2>
|
|
752
|
+
<p>Use the worksheet below when comparing <strong>${escapeHtml(prodName)}</strong> against existing internal workflows:</p>
|
|
753
|
+
<table class="cmp-table">
|
|
754
|
+
<thead><tr><th>Criterion</th><th>${escapeHtml(prodName)} (${escapeHtml(cleanDomain)})</th><th>Current / Baseline Process</th></tr></thead>
|
|
755
|
+
<tbody>
|
|
756
|
+
<tr><td><strong>Primary Scope</strong></td><td>${escapeHtml(tagline || prodName)}</td><td>Document current team baseline</td></tr>
|
|
757
|
+
<tr><td><strong>Overview</strong></td><td>${escapeHtml(summaryText)}</td><td>Record current steps and handoffs</td></tr>
|
|
758
|
+
<tr><td><strong>Direct Access</strong></td><td><a href="${escapeHtml(cleanOrig)}">${escapeHtml(cleanOrig)}</a></td><td>Internal docs / spreadsheets</td></tr>
|
|
759
|
+
</tbody>
|
|
760
|
+
</table>
|
|
761
|
+
</section>`;
|
|
762
|
+
} else if (cat === "Calculator") {
|
|
763
|
+
bodySectionHtml = ` <section class="card">
|
|
764
|
+
<h2>Workflow Time Worksheet — ${escapeHtml(headingTopic)}</h2>
|
|
765
|
+
<p>Enter your team's monthly frequency and average minutes per task to estimate total monthly time spent on this workflow:</p>
|
|
766
|
+
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:18px;margin-top:12px;">
|
|
767
|
+
<label style="display:block;font-weight:600;margin-bottom:6px;">Monthly runs: <span id="runsVal">10 runs/mo</span></label>
|
|
768
|
+
<input id="runsInput" type="range" min="1" max="60" value="10" style="width:100%;margin-bottom:12px;" oninput="var r=parseInt(this.value,10)||1;var m=parseInt(document.getElementById('minsInput').value,10)||15;document.getElementById('runsVal').textContent=r+' runs/mo';document.getElementById('hrsTotal').textContent=((r*m)/60).toFixed(1)+' hrs/mo';" />
|
|
769
|
+
<label style="display:block;font-weight:600;margin-bottom:6px;">Minutes per run: <span id="minsVal">30 mins/run</span></label>
|
|
770
|
+
<input id="minsInput" type="range" min="5" max="120" step="5" value="30" style="width:100%;" oninput="var m=parseInt(this.value,10)||15;var r=parseInt(document.getElementById('runsInput').value,10)||1;document.getElementById('minsVal').textContent=m+' mins/run';document.getElementById('hrsTotal').textContent=((r*m)/60).toFixed(1)+' hrs/mo';" />
|
|
771
|
+
<div style="margin-top:14px;background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:12px;">
|
|
772
|
+
<div style="font-size:12px;color:#64748b;">Estimated Monthly Workflow Time (Runs × Minutes)</div>
|
|
773
|
+
<div id="hrsTotal" style="font-size:22px;font-weight:800;color:#047857;">5.0 hrs/mo</div>
|
|
774
|
+
</div>
|
|
775
|
+
</div>
|
|
776
|
+
</section>`;
|
|
777
|
+
} else {
|
|
778
|
+
bodySectionHtml = ` <section class="card">
|
|
779
|
+
<h2>Key Notes: ${escapeHtml(headingTopic)}</h2>
|
|
780
|
+
<ul style="padding-left:20px;margin:12px 0;">
|
|
781
|
+
<li style="margin-bottom:10px;"><strong>Objective:</strong> Define how ${escapeHtml(prodName)} fits into your ${escapeHtml(headingTopic.toLowerCase())} workflow.</li>
|
|
782
|
+
<li style="margin-bottom:10px;"><strong>Live Environment:</strong> Visit <a href="${escapeHtml(cleanOrig)}">${escapeHtml(cleanOrig)}</a> to inspect current features and setup options.</li>
|
|
783
|
+
<li style="margin-bottom:10px;"><strong>Customization Note:</strong> Replace this starter section with your product's specific steps, screenshots, or code examples before publishing.</li>
|
|
784
|
+
</ul>
|
|
785
|
+
</section>`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return `<!DOCTYPE html>
|
|
789
|
+
<!-- Starter SEO draft for ${escapeHtml(cleanDomain)} (${escapeHtml(page.path)}). Review and customize with your product details before publishing to production. -->
|
|
790
|
+
<html lang="en">
|
|
791
|
+
<head>
|
|
792
|
+
<meta charset="UTF-8" />
|
|
793
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
794
|
+
<title>${escapeHtml(pageTitle)}</title>
|
|
795
|
+
<meta name="description" content="${escapeHtml(metaDesc)}" />
|
|
796
|
+
<link rel="canonical" href="${escapeHtml(canonicalUrl)}" />
|
|
797
|
+
<meta property="og:type" content="article" />
|
|
798
|
+
<meta property="og:title" content="${escapeHtml(pageTitle)}" />
|
|
799
|
+
<meta property="og:description" content="${escapeHtml(metaDesc)}" />
|
|
800
|
+
<meta property="og:url" content="${escapeHtml(canonicalUrl)}" />
|
|
801
|
+
<meta property="og:image" content="${escapeHtml(ogImgUrl)}" />
|
|
802
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
803
|
+
<meta name="twitter:title" content="${escapeHtml(pageTitle)}" />
|
|
804
|
+
<meta name="twitter:description" content="${escapeHtml(metaDesc)}" />
|
|
805
|
+
<meta name="twitter:image" content="${escapeHtml(ogImgUrl)}" />
|
|
806
|
+
<script type="application/ld+json">${ldJson}</script>
|
|
807
|
+
<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:880px;margin:0 auto;padding:36px 20px 64px;line-height:1.65;color:#0f172a;background:#f8fafc}.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:26px;margin-bottom:20px}.cta{display:inline-block;background:#047857;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600}.cmp-table{width:100%;border-collapse:collapse;margin-top:14px;font-size:14px}.cmp-table th,.cmp-table td{border:1px solid #e2e8f0;padding:10px 12px;text-align:left}.cmp-table th{background:#f1f5f9;font-weight:700}.faq-item{border-top:1px solid #e2e8f0;padding-top:14px;margin-top:14px}</style>
|
|
808
|
+
</head>
|
|
809
|
+
<body>
|
|
810
|
+
<header style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;padding-bottom:14px;border-bottom:1px solid #e2e8f0;">
|
|
811
|
+
<nav aria-label="Breadcrumb" style="font-size:13px;color:#64748b;"><a href="${escapeHtml(cleanOrig)}" style="font-weight:700;color:#0f172a;text-decoration:none;">${escapeHtml(prodName)}</a> / <span>${escapeHtml(cat)}</span></nav>
|
|
812
|
+
<a class="cta" href="${escapeHtml(cleanOrig)}" style="font-size:13px;padding:7px 14px;">Open ${escapeHtml(prodName)} →</a>
|
|
813
|
+
</header>
|
|
814
|
+
<main>
|
|
815
|
+
<article class="card">
|
|
816
|
+
<div style="font-size:12px;font-weight:700;color:#047857;text-transform:uppercase;letter-spacing:0.04em;">${escapeHtml(cat)} · ${escapeHtml(cats)}</div>
|
|
817
|
+
<h1 style="margin:8px 0 12px;font-size:28px;line-height:1.25;">${escapeHtml(cleanHeading)}</h1>
|
|
818
|
+
${tagline ? `<p style="font-size:16px;color:#334155;margin-bottom:14px;"><strong>${escapeHtml(prodName)}</strong> — ${escapeHtml(tagline)}</p>` : ""}
|
|
819
|
+
<p style="color:#475569;">${escapeHtml(summaryText)}</p>
|
|
820
|
+
<p style="margin-top:18px;"><a class="cta" href="${escapeHtml(cleanOrig)}">Explore ${escapeHtml(prodName)} on ${escapeHtml(cleanDomain)} →</a></p>
|
|
821
|
+
</article>
|
|
822
|
+
${bodySectionHtml}
|
|
823
|
+
${
|
|
824
|
+
hasFaqSchema
|
|
825
|
+
? ` <section class="card">
|
|
826
|
+
<h2>Frequently Asked Questions</h2>
|
|
827
|
+
${faqItems.map((f) => ` <div class="faq-item"><h3 style="margin:0 0 6px;font-size:16px;">${escapeHtml(f.q)}</h3><p style="margin:0;color:#475569;font-size:14px;">${escapeHtml(f.a)}</p></div>`).join("\n")}
|
|
828
|
+
</section>`
|
|
829
|
+
: ""
|
|
830
|
+
}
|
|
831
|
+
<section class="card">
|
|
832
|
+
<h2>Related ${escapeHtml(prodName)} Guides</h2>
|
|
833
|
+
<ul style="padding-left:20px;margin:10px 0;">
|
|
834
|
+
${related.map((r) => ` <li style="margin-bottom:6px;"><a href="${escapeHtml(r.path)}" style="color:#0284c7;text-decoration:none;font-weight:500;">${escapeHtml(r.keyword || r.path)}</a> <span style="color:#64748b;font-size:12px;">(${escapeHtml(r.category || r.volume || "Guide")})</span></li>`).join("\n")}
|
|
835
|
+
</ul>
|
|
836
|
+
</section>
|
|
837
|
+
</main>
|
|
838
|
+
</body>
|
|
839
|
+
</html>`;
|
|
840
|
+
}
|
|
841
|
+
|
|
666
842
|
/**
|
|
667
|
-
* Writes
|
|
843
|
+
* Writes Programmatic SEO HTML files into targetBaseDir so cleanUrls (/use-cases/...) resolve with 200 OK.
|
|
844
|
+
* Uses the buyer-safe starter template for all customer domains, and only uses postbuild.dev's own marketing
|
|
845
|
+
* template when building postbuild.dev itself.
|
|
668
846
|
*/
|
|
669
847
|
export function writePseoHtmlFiles(pseoPages = [], targetBaseDir, options = {}) {
|
|
848
|
+
const cleanDomain = String(options.domain || "postbuild.dev")
|
|
849
|
+
.toLowerCase()
|
|
850
|
+
.trim()
|
|
851
|
+
.replace(/^https?:\/\//, "")
|
|
852
|
+
.replace(/^www\./, "")
|
|
853
|
+
.split("/")[0];
|
|
854
|
+
const isPostbuildSelf = cleanDomain === "postbuild.dev";
|
|
670
855
|
const written = [];
|
|
671
856
|
for (const page of pseoPages) {
|
|
672
857
|
const cleanRel = String(page.path || "").replace(/^\/+/, "");
|
|
673
858
|
if (!cleanRel) continue;
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
859
|
+
const html = isPostbuildSelf
|
|
860
|
+
? renderSinglePseoPage(page, pseoPages, options)
|
|
861
|
+
: renderBuyerStarterPseoPage(page, pseoPages, options);
|
|
862
|
+
const indexOutFile = path.join(targetBaseDir, cleanRel, "index.html");
|
|
863
|
+
fs.mkdirSync(path.dirname(indexOutFile), { recursive: true });
|
|
864
|
+
fs.writeFileSync(indexOutFile, html, "utf8");
|
|
865
|
+
written.push(indexOutFile);
|
|
866
|
+
|
|
867
|
+
if (isPostbuildSelf) {
|
|
868
|
+
const flatOutFile = path.join(targetBaseDir, `${cleanRel}.html`);
|
|
869
|
+
fs.mkdirSync(path.dirname(flatOutFile), { recursive: true });
|
|
870
|
+
fs.writeFileSync(flatOutFile, html, "utf8");
|
|
871
|
+
}
|
|
679
872
|
}
|
|
680
873
|
return written;
|
|
681
874
|
}
|
|
875
|
+
|
package/lib/safe-fetch.js
CHANGED
|
@@ -145,7 +145,7 @@ export async function assertSafeUrl(rawUrl, options = {}) {
|
|
|
145
145
|
return parsed;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
async function readResponseWithCap(response, maxBytes = MAX_RESPONSE_BYTES) {
|
|
148
|
+
async function readResponseWithCap(response, maxBytes = MAX_RESPONSE_BYTES, truncateOnMaxBytes = false) {
|
|
149
149
|
if (!response.body) {
|
|
150
150
|
return Buffer.alloc(0);
|
|
151
151
|
}
|
|
@@ -157,11 +157,19 @@ async function readResponseWithCap(response, maxBytes = MAX_RESPONSE_BYTES) {
|
|
|
157
157
|
const { done, value } = await reader.read();
|
|
158
158
|
if (done) break;
|
|
159
159
|
const buf = Buffer.from(value);
|
|
160
|
-
total
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
if (total + buf.length > maxBytes) {
|
|
161
|
+
if (truncateOnMaxBytes) {
|
|
162
|
+
const remaining = Math.max(0, maxBytes - total);
|
|
163
|
+
if (remaining > 0) {
|
|
164
|
+
chunks.push(buf.subarray(0, remaining));
|
|
165
|
+
}
|
|
166
|
+
await reader.cancel().catch(() => {});
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
await reader.cancel().catch(() => {});
|
|
163
170
|
throw new Error(`Response exceeded maximum size limit of ${maxBytes} bytes`);
|
|
164
171
|
}
|
|
172
|
+
total += buf.length;
|
|
165
173
|
chunks.push(buf);
|
|
166
174
|
}
|
|
167
175
|
|
|
@@ -176,6 +184,7 @@ export async function safeFetch(rawUrl, options = {}) {
|
|
|
176
184
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
177
185
|
maxRedirects = MAX_REDIRECTS,
|
|
178
186
|
maxBytes = MAX_RESPONSE_BYTES,
|
|
187
|
+
truncateOnMaxBytes = false,
|
|
179
188
|
allowedDomains = null,
|
|
180
189
|
lookupFn
|
|
181
190
|
} = options;
|
|
@@ -220,7 +229,7 @@ export async function safeFetch(rawUrl, options = {}) {
|
|
|
220
229
|
continue;
|
|
221
230
|
}
|
|
222
231
|
|
|
223
|
-
const bodyBuffer = await readResponseWithCap(resp, maxBytes);
|
|
232
|
+
const bodyBuffer = await readResponseWithCap(resp, maxBytes, truncateOnMaxBytes);
|
|
224
233
|
return {
|
|
225
234
|
ok: resp.ok,
|
|
226
235
|
status: resp.status,
|
package/package.json
CHANGED