// Builds README.md and data/destinations.json from the public Submitlist catalog.
// Run: bun scripts/build-readme.js (or: node scripts/build-readme.js)
import { writeFile, readFile } from "node:fs/promises";
const CATALOG_URL = "https://api.submitlist.io/catalog/destinations";
const SITE_URL = "https://submitlist.io";
const CATALOG_PAGE = `${SITE_URL}/catalog`;
const SECTIONS = [
{
type: "directory",
anchor: "directories",
title: "Directories",
icon: "type-directory.svg",
intro: "The classic move. Fill a form, wait for an editor, get a listing and usually a link. The top of this list is Trustpilot and the BBB territory, where a listing is a trust signal more than a traffic source. Further down are the maker directories that still send real visitors.",
},
{
type: "product-launch-site",
anchor: "product-launch-sites",
title: "Product launch sites",
icon: "type-launch.svg",
intro: "One day, one shot. Product Hunt is the obvious one and still the biggest. The rest are smaller but easier to win, and a top spot on a small launch site beats page four on a big one.",
},
{
type: "newsletter",
anchor: "newsletters",
title: "Newsletters",
icon: "type-newsletter.svg",
intro: "No form, no queue. You write to a human who curates an inbox that people actually open. Reader counts and open rates are the newsletter's own published numbers. Pitch short, pitch specific, and read three back issues first.",
},
{
type: "community",
anchor: "communities",
title: "Communities",
icon: "type-community.svg",
intro: "Forums and groups where showing your work is welcome as long as you are a member first and a marketer second. The good ones have long memories for drive-by promotion.",
},
{
type: "subreddit",
anchor: "subreddits",
title: "Subreddits",
icon: "type-subreddit.svg",
intro: "Reddit will bury a bad post and its author in about four minutes. Every entry says which route the moderators allow: a direct post, a weekly promotion thread, or comments only. When it says unknown, ask the mods before you post.",
},
{
type: "marketplace",
anchor: "marketplaces",
title: "Marketplaces",
icon: "type-marketplace.svg",
intro: "App, extension, and plugin stores. Listing here is distribution, not PR. Reviews take longer, developer accounts sometimes cost money, and once you are in, the store keeps sending users for years.",
},
{
type: "citation",
anchor: "citations",
title: "Citations",
icon: "type-citation.svg",
intro: "Places that do not run a directory but mention products inside articles, rankings, and profiles. Nobody accepts a submission here. You earn the mention, and the second line of each entry says what that takes.",
},
];
const PRICING_LABEL = { free: "Free", paid: "Paid", unknown: "" };
const LINK_LABEL = { dofollow: "Dofollow", nofollow: "Nofollow", unknown: "" };
const PROMOTION_LABEL = {
direct_post: "Direct posts OK",
promotion_thread: "Promotion thread only",
promo_thread: "Promotion thread only",
recurring_thread: "Promotion thread only",
"direct_post+promotion_thread": "Post or promotion thread",
comments_only: "Comments only",
restricted: "Restricted",
prohibited: "No promotion",
unknown: "Rules unclear",
};
const PLATFORM_LABEL = {
beehiiv: "beehiiv",
substack: "Substack",
kit: "Kit",
ghost: "Ghost",
mailchimp: "Mailchimp",
buttondown: "Buttondown",
other: "",
};
function compactNumber(value) {
if (value === null || value === undefined) return "";
if (value >= 1e9) return `${trimDecimal(value / 1e9)}B`;
if (value >= 1e6) return `${trimDecimal(value / 1e6)}M`;
if (value >= 1e3) return `${trimDecimal(value / 1e3)}K`;
return String(value);
}
function trimDecimal(value) {
return value >= 10 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, "");
}
function clean(text) {
return String(text ?? "").replace(/\s+/g, " ").trim();
}
function shorten(text, maxLength = 230) {
const value = clean(text);
if (value.length <= maxLength) return value;
const cut = value.slice(0, maxLength);
const sentenceEnd = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("! "), cut.lastIndexOf("? "));
if (sentenceEnd > 80) return cut.slice(0, sentenceEnd + 1);
const wordEnd = cut.lastIndexOf(" ");
return `${cut.slice(0, wordEnd > 80 ? wordEnd : maxLength).replace(/[,;:]$/, "")}…`;
}
function destinationLink(destination) {
return destination.submission_url || destination.website_url;
}
function hostname(url) {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return "";
}
}
function logo(destination) {
const src = destination.logo_url || `https://www.google.com/s2/favicons?domain=${hostname(destination.website_url)}&sz=64`;
return ``;
}
function chips(values) {
return values.filter(Boolean).map((value) => `\`${value}\``).join(" ");
}
function entry(destination, chipValues, text, secondLine = "") {
const lines = [
`- ${logo(destination)} **[${clean(destination.name)}](${destinationLink(destination)})** ${chips(chipValues)}
`,
` ${text}${secondLine ? "
" : ""}`,
];
if (secondLine) lines.push(` ${secondLine}`);
return lines.join("\n");
}
function byRatingThenName(a, b) {
const ratingA = a.domain_rating ?? -1;
const ratingB = b.domain_rating ?? -1;
if (ratingB !== ratingA) return ratingB - ratingA;
return a.name.localeCompare(b.name);
}
function byMembersThenName(a, b) {
const membersA = a.subreddit?.members_count ?? -1;
const membersB = b.subreddit?.members_count ?? -1;
if (membersB !== membersA) return membersB - membersA;
return a.name.localeCompare(b.name);
}
function bySubscribersThenName(a, b) {
const subsA = a.newsletter_subscribers ?? -1;
const subsB = b.newsletter_subscribers ?? -1;
if (subsB !== subsA) return subsB - subsA;
return a.name.localeCompare(b.name);
}
function trafficChip(destination) {
return destination.monthly_traffic ? `${compactNumber(destination.monthly_traffic)} visits/mo` : "";
}
function ratingChip(destination) {
return destination.domain_rating !== null && destination.domain_rating !== undefined ? `DR ${destination.domain_rating}` : "";
}
function directoryEntries(items) {
return items.sort(byRatingThenName).map((d) =>
entry(d, [ratingChip(d), trafficChip(d), PRICING_LABEL[d.pricing], LINK_LABEL[d.link_type]], shorten(d.description)),
);
}
function newsletterEntries(items) {
return items.sort(bySubscribersThenName).map((d) =>
entry(
d,
[
d.newsletter_subscribers ? `${compactNumber(d.newsletter_subscribers)} readers` : "",
d.newsletter_open_rate ? `${d.newsletter_open_rate}% open rate` : "",
PLATFORM_LABEL[d.newsletter_platform] ?? clean(d.newsletter_platform),
],
shorten(d.description),
),
);
}
function growthChip(subreddit) {
const growth = subreddit?.growth_30d_absolute;
const members = subreddit?.members_count;
if (!growth || !members) return "";
const previous = members - growth;
if (previous <= 0) return `+${compactNumber(growth)} in 30d`;
const percent = (growth / previous) * 100;
return `+${percent >= 10 ? Math.round(percent) : percent.toFixed(1)}% in 30d`;
}
function subredditEntries(items) {
return items.sort(byMembersThenName).map((d) =>
entry(
d,
[
d.subreddit?.members_count ? `${compactNumber(d.subreddit.members_count)} members` : "",
growthChip(d.subreddit),
PROMOTION_LABEL[d.subreddit?.promotion_mode] ?? clean((d.subreddit?.promotion_mode ?? "").replace(/_/g, " ")),
],
shorten(d.description),
),
);
}
function citationEntries(items) {
return items.sort(byRatingThenName).map((d) =>
entry(
d,
[ratingChip(d), trafficChip(d), PRICING_LABEL[d.pricing]],
shorten(d.description, 200),
d.eligibility ? `How to get in: ${shorten(d.eligibility, 260)}` : "",
),
);
}
const ENTRIES_BY_TYPE = {
newsletter: newsletterEntries,
subreddit: subredditEntries,
citation: citationEntries,
};
function badge(label, value, color) {
const encode = (text) => encodeURIComponent(text).replace(/-/g, "--").replace(/_/g, "__");
return `https://img.shields.io/badge/${encode(label)}-${encode(value)}-${color}?style=flat-square`;
}
function renderReadme(destinations, syncedOn) {
const groups = Object.fromEntries(SECTIONS.map((section) => [section.type, destinations.filter((d) => d.type === section.type)]));
const total = destinations.length;
const free = destinations.filter((d) => d.pricing === "free").length;
const dofollow = destinations.filter((d) => d.link_type === "dofollow").length;
const highAuthority = destinations.filter((d) => (d.domain_rating ?? 0) >= 80).length;
const purple = "6864e5";
const contents = SECTIONS.map((section) => `- [${section.title}](#${section.anchor}) ${groups[section.type].length}`).join("\n");
const sections = SECTIONS.map((section) => {
const items = groups[section.type];
const render = ENTRIES_BY_TYPE[section.type] ?? directoryEntries;
return [
`## ${section.title}`,
"",
`${items.length} ${items.length === 1 ? "entry" : "entries"} · [filter and sort these on submitlist.io](${CATALOG_PAGE}?type=${section.type})`,
"",
section.intro,
"",
render(items).join("\n"),
"",
`
${total} places to submit your startup.
Directories, launch sites, newsletters, communities, subreddits, marketplaces, and the press pages that mention products. With the numbers attached.
submitlist.io · Browse the catalog · Guides · Suggest a site
You built the thing. Now where do you post it? Every list that answers this question is either five years stale, padded with dead domains, or a lead magnet for an agency. This one is none of those. It is generated from the live [Submitlist catalog](${CATALOG_PAGE}), where each site was opened by a person, checked for whether it still takes submissions, and tagged with the four numbers that decide if it is worth your afternoon: Ahrefs Domain Rating, monthly organic traffic, what it costs, and whether the backlink is dofollow. Dead sites get archived and vanish from here on the next weekly sync. Right now ${highAuthority} of the ${total} destinations have a Domain Rating of 80 or higher, ${free} are free, and ${dofollow} give you a dofollow link. The pigeon keeps count. ## ContentsMaintained by Alvin · Built from submitlist.io/catalog · Last synced ${syncedOn}
`; } function publicSnapshot(destination) { return { slug: destination.slug, name: destination.name, type: destination.type, website_url: destination.website_url, submission_url: destination.submission_url || null, logo_url: destination.logo_url || null, description: destination.description, audience: destination.audience || null, eligibility: destination.eligibility || null, pricing: destination.pricing, pricing_note: destination.pricing_note || null, link_type: destination.link_type, domain_rating: destination.domain_rating, monthly_traffic: destination.monthly_traffic, categories: (destination.categories ?? []).map((category) => category.slug), ...(destination.type === "newsletter" ? { newsletter: { category: destination.newsletter_category, topic: destination.newsletter_topic, platform: destination.newsletter_platform, subscribers: destination.newsletter_subscribers, open_rate: destination.newsletter_open_rate, }, } : {}), ...(destination.subreddit ? { subreddit: { members_count: destination.subreddit.members_count, growth_30d_absolute: destination.subreddit.growth_30d_absolute, promotion_mode: destination.subreddit.promotion_mode, rules_url: destination.subreddit.rules_url, }, } : {}), }; } async function loadDestinations() { const response = await fetch(CATALOG_URL, { headers: { accept: "application/json" } }); if (!response.ok) throw new Error(`Catalog request failed: ${response.status} ${response.statusText}`); const payload = await response.json(); if (!Array.isArray(payload.destinations) || payload.destinations.length === 0) throw new Error("Catalog returned no destinations"); return payload.destinations.filter((d) => d.availability !== "paused" && !d.archived_at); } const destinations = await loadDestinations(); const syncedOn = new Date().toISOString().slice(0, 10); const readme = renderReadme(destinations, syncedOn); const snapshot = { source: CATALOG_URL, synced_on: syncedOn, count: destinations.length, destinations: destinations.map(publicSnapshot).sort((a, b) => a.slug.localeCompare(b.slug)), }; const previousReadme = await readFile("README.md", "utf8").catch(() => ""); const previousSnapshot = await readFile("data/destinations.json", "utf8").catch(() => ""); const stripDate = (text) => text.replace(/\d{4}-\d{2}-\d{2}/g, "DATE"); const unchanged = stripDate(previousReadme) === stripDate(readme) && stripDate(previousSnapshot) === stripDate(`${JSON.stringify(snapshot, null, 2)}\n`); if (unchanged && !process.argv.includes("--force")) { console.log(`Catalog unchanged (${destinations.length} destinations). README.md left as is.`); } else { await writeFile("README.md", readme); await writeFile("data/destinations.json", `${JSON.stringify(snapshot, null, 2)}\n`); console.log(`Wrote README.md and data/destinations.json with ${destinations.length} destinations (synced ${syncedOn}).`); }