diff --git a/app/(app)/projects/[id]/members/members-client.tsx b/app/(app)/projects/[id]/members/members-client.tsx
new file mode 100644
index 00000000..53bac28d
--- /dev/null
+++ b/app/(app)/projects/[id]/members/members-client.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import {
+ inviteProjectMember,
+ revokeProjectInvitation,
+ removeProjectMember,
+ type TeamMember,
+ type PendingInvitation,
+} from "@/app/actions/project-members";
+
+export function MembersClient({
+ projectId,
+ isOwner,
+ members,
+ invitations,
+}: {
+ projectId: string;
+ isOwner: boolean;
+ members: TeamMember[];
+ invitations: PendingInvitation[];
+}) {
+ return (
+
+ {isOwner &&
}
+
+
+ Team members
+ {members.length === 0 ? (
+
+ No team members yet. Invite someone to get started.
+
+ ) : (
+
+ {members.map((m) => (
+
+ ))}
+
+ )}
+
+
+ {isOwner && invitations.length > 0 && (
+
+ Pending invitations
+
+ {invitations.map((inv) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function InviteForm({ projectId }: { projectId: string }) {
+ const [email, setEmail] = useState("");
+ const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
+ const [pending, start] = useTransition();
+
+ function onSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setMessage(null);
+ start(async () => {
+ const res = await inviteProjectMember(projectId, email);
+ if (res.ok) {
+ setEmail("");
+ setMessage({ ok: true, text: "Invitation sent." });
+ } else {
+ setMessage({ ok: false, text: res.error ?? "Something went wrong." });
+ }
+ });
+ }
+
+ return (
+
+ Invite a team member
+
+ They'll get an email with a link to join this project.
+
+
+ {message && (
+
+ {message.text}
+
+ )}
+
+ );
+}
+
+function MemberRow({
+ projectId,
+ member,
+ isOwner,
+}: {
+ projectId: string;
+ member: TeamMember;
+ isOwner: boolean;
+}) {
+ const [pending, start] = useTransition();
+ const label =
+ member.profile?.display_name || member.profile?.email || "Unknown user";
+ const sub = member.profile?.display_name
+ ? member.profile.email
+ : new Date(member.created_at).toLocaleDateString();
+
+ function onRemove() {
+ start(async () => {
+ await removeProjectMember(projectId, member.user_id);
+ });
+ }
+
+ return (
+
+
+
{label}
+ {sub && (
+
{sub}
+ )}
+
+ {isOwner && (
+
+ )}
+
+ );
+}
+
+function InvitationRow({
+ projectId,
+ invitation,
+}: {
+ projectId: string;
+ invitation: PendingInvitation;
+}) {
+ const [pending, start] = useTransition();
+ const expired = new Date(invitation.expires_at) < new Date();
+
+ function onRevoke() {
+ start(async () => {
+ await revokeProjectInvitation(projectId, invitation.id);
+ });
+ }
+
+ return (
+
+
+
{invitation.email}
+
+ {expired
+ ? "Expired"
+ : `Expires ${new Date(invitation.expires_at).toLocaleDateString()}`}
+
+
+
+
+ );
+}
diff --git a/app/(app)/projects/[id]/members/page.tsx b/app/(app)/projects/[id]/members/page.tsx
new file mode 100644
index 00000000..2e2c7878
--- /dev/null
+++ b/app/(app)/projects/[id]/members/page.tsx
@@ -0,0 +1,22 @@
+import { notFound } from "next/navigation";
+import { listProjectTeam } from "@/app/actions/project-members";
+import { MembersClient } from "./members-client";
+
+export default async function ProjectMembersPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const result = await listProjectTeam(id);
+ if (!result.ok) notFound();
+
+ return (
+
+ );
+}
diff --git a/app/actions/project-members.ts b/app/actions/project-members.ts
new file mode 100644
index 00000000..bde51b73
--- /dev/null
+++ b/app/actions/project-members.ts
@@ -0,0 +1,208 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { createClient } from "@/lib/supabase/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { sendProjectInviteEmail } from "@/lib/email";
+import { env } from "@/lib/env";
+
+async function requireOwner(projectId: string) {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return { ok: false as const, error: "Not authenticated." };
+
+ const { data: project } = await supabase
+ .from("projects")
+ .select("id, name, owner_id")
+ .eq("id", projectId)
+ .eq("owner_id", user.id)
+ .maybeSingle();
+ if (!project) return { ok: false as const, error: "Project not found." };
+
+ return { ok: true as const, user, project, supabase };
+}
+
+export async function inviteProjectMember(
+ projectId: string,
+ email: string,
+): Promise<{ ok: boolean; error?: string }> {
+ const normalized = email.trim().toLowerCase();
+ if (!normalized.includes("@")) {
+ return { ok: false, error: "Invalid email address." };
+ }
+
+ const ctx = await requireOwner(projectId);
+ if (!ctx.ok) return { ok: false, error: ctx.error };
+ const { user, project } = ctx;
+
+ const { data: ownerProfile } = await ctx.supabase
+ .from("profiles")
+ .select("email")
+ .eq("id", user.id)
+ .maybeSingle();
+ if (ownerProfile?.email?.toLowerCase() === normalized) {
+ return { ok: false, error: "You can't invite yourself." };
+ }
+
+ const svc = serviceClient();
+
+ // Delete any existing invitation for this email (re-invite resets the timer)
+ await svc
+ .from("project_invitations")
+ .delete()
+ .eq("project_id", projectId)
+ .eq("email", normalized);
+
+ const { data: invitation, error: insertErr } = await svc
+ .from("project_invitations")
+ .insert({ project_id: projectId, email: normalized, invited_by: user.id })
+ .select("token")
+ .single();
+
+ if (insertErr || !invitation) {
+ return { ok: false, error: insertErr?.message ?? "Failed to create invitation." };
+ }
+
+ const { data: inviterProfile } = await svc
+ .from("profiles")
+ .select("display_name, email")
+ .eq("id", user.id)
+ .maybeSingle();
+
+ const inviterName =
+ inviterProfile?.display_name || inviterProfile?.email || "Someone";
+ const acceptUrl = `${env.siteUrl}/invite/${invitation.token}`;
+
+ const emailResult = await sendProjectInviteEmail({
+ to: normalized,
+ invitedBy: inviterName,
+ projectName: project.name,
+ acceptUrl,
+ });
+
+ if (!emailResult.sent) {
+ await svc
+ .from("project_invitations")
+ .delete()
+ .eq("project_id", projectId)
+ .eq("email", normalized);
+ return { ok: false, error: `Email failed: ${emailResult.error}` };
+ }
+
+ revalidatePath(`/projects/${projectId}/members`);
+ return { ok: true };
+}
+
+export async function revokeProjectInvitation(
+ projectId: string,
+ invitationId: string,
+): Promise<{ ok: boolean; error?: string }> {
+ const ctx = await requireOwner(projectId);
+ if (!ctx.ok) return { ok: false, error: ctx.error };
+
+ const { error } = await ctx.supabase
+ .from("project_invitations")
+ .delete()
+ .eq("id", invitationId)
+ .eq("project_id", projectId);
+
+ if (error) return { ok: false, error: error.message };
+ revalidatePath(`/projects/${projectId}/members`);
+ return { ok: true };
+}
+
+export async function removeProjectMember(
+ projectId: string,
+ userId: string,
+): Promise<{ ok: boolean; error?: string }> {
+ const ctx = await requireOwner(projectId);
+ if (!ctx.ok) return { ok: false, error: ctx.error };
+
+ const { error } = await ctx.supabase
+ .from("project_members")
+ .delete()
+ .eq("project_id", projectId)
+ .eq("user_id", userId);
+
+ if (error) return { ok: false, error: error.message };
+ revalidatePath(`/projects/${projectId}/members`);
+ return { ok: true };
+}
+
+export type TeamMember = {
+ id: string;
+ user_id: string;
+ created_at: string;
+ profile: { id: string; email: string; display_name: string } | null;
+};
+
+export type PendingInvitation = {
+ id: string;
+ email: string;
+ expires_at: string;
+ created_at: string;
+};
+
+export async function listProjectTeam(projectId: string): Promise<
+ | { ok: false; error: string }
+ | {
+ ok: true;
+ isOwner: boolean;
+ members: TeamMember[];
+ invitations: PendingInvitation[];
+ }
+> {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Not authenticated." };
+
+ const { data: project } = await supabase
+ .from("projects")
+ .select("id, owner_id")
+ .eq("id", projectId)
+ .maybeSingle();
+ if (!project) return { ok: false, error: "Project not found." };
+
+ const svc = serviceClient();
+
+ const { data: membersRaw } = await svc
+ .from("project_members")
+ .select("id, user_id, created_at")
+ .eq("project_id", projectId)
+ .order("created_at", { ascending: true });
+
+ const userIds = (membersRaw ?? []).map((m: any) => m.user_id);
+ const { data: profilesRaw } =
+ userIds.length > 0
+ ? await svc
+ .from("profiles")
+ .select("id, email, display_name")
+ .in("id", userIds)
+ : { data: [] };
+
+ const members: TeamMember[] = (membersRaw ?? []).map((m: any) => ({
+ id: m.id,
+ user_id: m.user_id,
+ created_at: m.created_at,
+ profile:
+ (profilesRaw ?? []).find((p: any) => p.id === m.user_id) ?? null,
+ }));
+
+ const { data: invitationsRaw } = await svc
+ .from("project_invitations")
+ .select("id, email, expires_at, created_at")
+ .eq("project_id", projectId)
+ .is("accepted_at", null)
+ .order("created_at", { ascending: false });
+
+ return {
+ ok: true,
+ isOwner: project.owner_id === user.id,
+ members,
+ invitations: invitationsRaw ?? [],
+ };
+}
diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx
new file mode 100644
index 00000000..6eccb927
--- /dev/null
+++ b/app/invite/[token]/page.tsx
@@ -0,0 +1,132 @@
+import { redirect } from "next/navigation";
+import Link from "next/link";
+import { createClient } from "@/lib/supabase/server";
+import { serviceClient } from "@/lib/supabase/service";
+
+export const metadata = { robots: { index: false } };
+
+export default async function InvitePage({
+ params,
+}: {
+ params: Promise<{ token: string }>;
+}) {
+ const { token } = await params;
+ const svc = serviceClient();
+
+ const { data: inv } = await svc
+ .from("project_invitations")
+ .select("id, project_id, email, expires_at, accepted_at")
+ .eq("token", token)
+ .maybeSingle();
+
+ if (!inv) {
+ return (
+
+ Invalid invitation
+
+ This link is invalid or has already been revoked.
+
+
+ );
+ }
+
+ if (inv.accepted_at) {
+ return (
+
+ Already accepted
+
+ This invitation has already been used.
+
+
+ Go to project
+
+
+ );
+ }
+
+ if (new Date(inv.expires_at) < new Date()) {
+ return (
+
+ Invitation expired
+
+ This invitation link has expired. Ask the project owner to send a new one.
+
+
+ );
+ }
+
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ redirect(`/login?redirect=/invite/${token}`);
+ }
+
+ const { data: userProfile } = await svc
+ .from("profiles")
+ .select("email")
+ .eq("id", user.id)
+ .maybeSingle();
+
+ if (userProfile?.email?.toLowerCase() !== inv.email.toLowerCase()) {
+ return (
+
+ Wrong account
+
+ This invitation was sent to{" "}
+ {inv.email}.
+ Please sign in with that email address to accept.
+
+
+ Sign in with correct account
+
+
+ );
+ }
+
+ // Check if already a member
+ const { data: existing } = await svc
+ .from("project_members")
+ .select("id")
+ .eq("project_id", inv.project_id)
+ .eq("user_id", user.id)
+ .maybeSingle();
+
+ if (!existing) {
+ // Fetch invited_by to populate the foreign key
+ const { data: invitationFull } = await svc
+ .from("project_invitations")
+ .select("invited_by")
+ .eq("id", inv.id)
+ .single();
+
+ await svc.from("project_members").insert({
+ project_id: inv.project_id,
+ user_id: user.id,
+ invited_by: invitationFull?.invited_by ?? user.id,
+ });
+ }
+
+ await svc
+ .from("project_invitations")
+ .update({ accepted_at: new Date().toISOString() })
+ .eq("id", inv.id);
+
+ redirect(`/projects/${inv.project_id}`);
+}
+
+function InviteShell({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ ← CrawlProof
+
+ {children}
+
+ );
+}
diff --git a/components/project-tabs-nav.tsx b/components/project-tabs-nav.tsx
index e4b554ee..f057e81c 100644
--- a/components/project-tabs-nav.tsx
+++ b/components/project-tabs-nav.tsx
@@ -66,6 +66,12 @@ const TABS: ProjectTab[] = [
href: (id) => `/projects/${id}/repos`,
matches: (p, id) => p.startsWith(`/projects/${id}/repos`),
},
+ {
+ id: "members",
+ label: "Members",
+ href: (id) => `/projects/${id}/members`,
+ matches: (p, id) => p.startsWith(`/projects/${id}/members`),
+ },
];
export function ProjectTabsNav({ projectId }: { projectId: string }) {
diff --git a/lib/email.ts b/lib/email.ts
index d236fdef..1d4dca6a 100644
--- a/lib/email.ts
+++ b/lib/email.ts
@@ -547,3 +547,60 @@ export async function sendHireInquiryEmail(input: {
if (res.error) return { sent: false, error: String(res.error) };
return { sent: true };
}
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+export async function sendProjectInviteEmail(input: {
+ to: string;
+ invitedBy: string;
+ projectName: string;
+ acceptUrl: string;
+}): Promise<{ sent: boolean; error?: string }> {
+ const c = client();
+ if (!c) return { sent: false, error: "RESEND_API_KEY not set" };
+
+ const innerHtml = `
+
+
+ You've been invited
+
+ ${escapeHtml(input.invitedBy)} invited you to join the
+ ${escapeHtml(input.projectName)} project on CrawlProof.
+
+ |
+
+
+ |
+
+ Accept Invitation
+
+
+ This link expires in 7 days. If you don't have a CrawlProof account you'll be prompted to create one.
+
+ |
+
+ `;
+
+ const html = emailShell({
+ title: `Invitation to join ${input.projectName} on CrawlProof`,
+ innerHtml,
+ footerNote: "If you weren't expecting this invitation, you can safely ignore this email.",
+ });
+
+ const res = await c.emails.send({
+ from: env.resendFrom,
+ to: input.to,
+ subject: `${escapeHtml(input.invitedBy)} invited you to ${escapeHtml(input.projectName)} on CrawlProof`,
+ html,
+ });
+ if (res.error) return { sent: false, error: String(res.error) };
+ return { sent: true };
+}
diff --git a/lib/sp/renderPost.ts b/lib/sp/renderPost.ts
index 2446a44b..1b990f3f 100644
--- a/lib/sp/renderPost.ts
+++ b/lib/sp/renderPost.ts
@@ -22,7 +22,9 @@ const RenderSchema = z.object({
.describe("Reddit post title; empty string for every other platform."),
hashtags: z
.array(z.string())
- .describe("Platform-appropriate hashtags (no leading #). Empty if the platform doesn't use them."),
+ .describe(
+ "Platform-appropriate hashtags (no leading #). Empty if the platform doesn't use them.",
+ ),
});
export type RenderedPost = {
@@ -65,7 +67,8 @@ const PLATFORM_PROFILES: Record = {
threads: {
maxChars: 480,
usesHashtags: "few",
- voice: "Conversational. One short hook, then one supporting line if needed.",
+ voice:
+ "Conversational. One short hook, then one supporting line if needed.",
},
mastodon: {
maxChars: 480,
@@ -135,7 +138,9 @@ export async function renderPostForPlatform(args: {
throw new Error(`No platform profile configured for "${platform}".`);
}
if (!anthropic && !openai) {
- throw new Error("No AI provider configured (ANTHROPIC_API_KEY / OPENAI_API_KEY).");
+ throw new Error(
+ "No AI provider configured (ANTHROPIC_API_KEY / OPENAI_API_KEY).",
+ );
}
const tone = config.tone || "casual";
@@ -184,7 +189,9 @@ export async function renderPostForPlatform(args: {
throw new Error("Render produced empty text.");
}
const title =
- platform === "reddit" && output.title?.trim() ? output.title.trim() : undefined;
+ platform === "reddit" && output.title?.trim()
+ ? output.title.trim()
+ : undefined;
const hashtags = (output.hashtags ?? [])
.filter((h): h is string => typeof h === "string")
.map((h) => (h.startsWith("#") ? h : `#${h}`))
@@ -193,35 +200,94 @@ export async function renderPostForPlatform(args: {
return { text, title, hashtags };
}
+// Real per-platform hard limits — the length each platform actually rejects
+// above. These are the SAME values enforced in lib/sp/platforms/* and at the
+// postViaAccount guard; hardcoded here (they don't change) so the renderer can
+// guarantee a fitting post without importing every platform module. NOTE these
+// differ from PLATFORM_PROFILES[*].maxChars, which is the *soft* cap fed to the
+// LLM (deliberately lower to leave room for the URL + hashtags).
+const PLATFORM_HARD_LIMIT: Record = {
+ bluesky: 300,
+ x: 280,
+ threads: 500,
+ mastodon: 500,
+ facebook: 5000,
+ facebook_page: 5000,
+ linkedin: 3000,
+ discord: 2000,
+ telegram: 4096,
+ reddit: 40_000,
+};
+
+const ELLIPSIS = "…";
+
+// Join body + url + hashtags and guarantee the result fits within `limit`.
+// The body is the only truncatable piece — the URL must stay intact (it's the
+// point of the post), and hashtags are dropped before the URL is sacrificed.
+function clampAssembled(
+ body: string,
+ url: string,
+ tags: string,
+ joiner: string,
+ limit: number,
+): string {
+ const assemble = (b: string, includeTags: boolean) =>
+ [b, url, includeTags ? tags : ""].filter(Boolean).join(joiner);
+
+ // Fits as-is.
+ if (assemble(body, true).length <= limit) return assemble(body, true);
+
+ // Decide whether we can keep hashtags: only if doing so still leaves a
+ // reasonable amount of room for the body.
+ const suffixWithTags = assemble("", true); // = url (+ joiner + tags)
+ const suffixNoTags = assemble("", false); // = url
+ const keepTags = limit - suffixWithTags.length >= 24;
+ const suffix = keepTags ? suffixWithTags : suffixNoTags;
+
+ // Budget left for the body once the suffix and one joiner are reserved.
+ const bodyBudget = limit - suffix.length - joiner.length;
+ if (bodyBudget <= ELLIPSIS.length) {
+ // URL (and maybe tags) alone already fill the limit — return the suffix,
+ // trimmed as a last resort. This effectively never triggers for the short
+ // platforms because their URLs are far shorter than the limit.
+ return suffix.slice(0, limit);
+ }
+
+ // Trim the body to budget, preferring a word boundary so we don't cut mid-word.
+ let cut = body.slice(0, bodyBudget - ELLIPSIS.length);
+ const lastSpace = cut.lastIndexOf(" ");
+ if (lastSpace > bodyBudget * 0.6) cut = cut.slice(0, lastSpace);
+ const trimmedBody = cut.trimEnd() + ELLIPSIS;
+
+ return [trimmedBody, suffix].join(joiner);
+}
+
// Assemble final post text for the platform from the rendered pieces +
// the article URL. Different platforms want different orderings; some
-// embed the URL inline, others trail it after a blank line.
+// embed the URL inline, others trail it after a blank line. The result is
+// always clamped to the platform's hard character limit.
export function assemblePostText(args: {
rendered: RenderedPost;
url: string;
platform: string;
}): string {
const { rendered, url, platform } = args;
- const profile = PLATFORM_PROFILES[platform];
- const tagsBlock = rendered.hashtags.length > 0 ? rendered.hashtags.join(" ") : "";
+ const tagsBlock =
+ rendered.hashtags.length > 0 ? rendered.hashtags.join(" ") : "";
+ const limit = PLATFORM_HARD_LIMIT[platform] ?? Infinity;
if (platform === "reddit") {
// Reddit's "text" body is its self-post; URL goes in the body. Title
- // is handled at the postViaAccount layer.
- return [rendered.text, url].filter(Boolean).join("\n\n");
+ // is handled at the postViaAccount layer. No hashtags.
+ return clampAssembled(rendered.text, url, "", "\n\n", limit);
}
if (platform === "linkedin") {
- return [rendered.text, url, tagsBlock].filter(Boolean).join("\n\n");
+ return clampAssembled(rendered.text, url, tagsBlock, "\n\n", limit);
}
if (platform === "discord" || platform === "telegram") {
// No hashtags for these.
- return [rendered.text, url].filter(Boolean).join("\n\n");
+ return clampAssembled(rendered.text, url, "", "\n\n", limit);
}
// Default short-form (bluesky/x/threads/mastodon/facebook): inline.
- const head =
- profile && rendered.text.length + url.length + 2 > profile.maxChars
- ? rendered.text.slice(0, Math.max(0, profile.maxChars - url.length - 8)) + "…"
- : rendered.text;
- return [head, url, tagsBlock].filter(Boolean).join("\n");
+ return clampAssembled(rendered.text, url, tagsBlock, "\n", limit);
}
-
diff --git a/supabase/migrations/20260601120000_project_members.sql b/supabase/migrations/20260601120000_project_members.sql
new file mode 100644
index 00000000..0bfc0ea6
--- /dev/null
+++ b/supabase/migrations/20260601120000_project_members.sql
@@ -0,0 +1,47 @@
+-- project_members: accepted team memberships
+create table public.project_members (
+ id uuid primary key default gen_random_uuid(),
+ project_id uuid not null references public.projects(id) on delete cascade,
+ user_id uuid not null references public.profiles(id) on delete cascade,
+ invited_by uuid not null references public.profiles(id),
+ created_at timestamptz not null default now(),
+ unique(project_id, user_id)
+);
+
+alter table public.project_members enable row level security;
+
+create policy "project_members owner all"
+ on public.project_members for all
+ using (exists(select 1 from public.projects where id = project_id and owner_id = auth.uid()))
+ with check (exists(select 1 from public.projects where id = project_id and owner_id = auth.uid()));
+
+create policy "project_members self read"
+ on public.project_members for select
+ using (user_id = auth.uid());
+
+-- project_invitations: pending email invitations
+create table public.project_invitations (
+ id uuid primary key default gen_random_uuid(),
+ project_id uuid not null references public.projects(id) on delete cascade,
+ email text not null,
+ token text not null unique default encode(gen_random_bytes(32), 'hex'),
+ invited_by uuid not null references public.profiles(id),
+ expires_at timestamptz not null default (now() + interval '7 days'),
+ accepted_at timestamptz,
+ created_at timestamptz not null default now(),
+ unique(project_id, email)
+);
+
+alter table public.project_invitations enable row level security;
+
+create policy "project_invitations owner all"
+ on public.project_invitations for all
+ using (exists(select 1 from public.projects where id = project_id and owner_id = auth.uid()))
+ with check (exists(select 1 from public.projects where id = project_id and owner_id = auth.uid()));
+
+-- team members can also read projects they belong to
+create policy "projects member read"
+ on public.projects for select
+ using (
+ exists(select 1 from public.project_members where project_id = id and user_id = auth.uid())
+ );
diff --git a/tests/sp/render-post.test.ts b/tests/sp/render-post.test.ts
new file mode 100644
index 00000000..1c4da457
--- /dev/null
+++ b/tests/sp/render-post.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect } from "vitest";
+import { assemblePostText, type RenderedPost } from "@/lib/sp/renderPost";
+
+const BLUESKY_LIMIT = 300;
+const X_LIMIT = 280;
+
+function rendered(over: Partial = {}): RenderedPost {
+ return { text: "Body.", hashtags: [], ...over };
+}
+
+describe("assemblePostText", () => {
+ it("leaves a short post untouched", () => {
+ // Hashtags arrive already "#"-prefixed from renderPostForPlatform.
+ const out = assemblePostText({
+ rendered: rendered({
+ text: "Quick take on bot traffic.",
+ hashtags: ["#seo"],
+ }),
+ url: "https://crawlproof.com/blog/bots",
+ platform: "bluesky",
+ });
+ expect(out).toBe(
+ "Quick take on bot traffic.\nhttps://crawlproof.com/blog/bots\n#seo",
+ );
+ expect(out.length).toBeLessThanOrEqual(BLUESKY_LIMIT);
+ });
+
+ it("clamps an over-limit Bluesky post (body + url + hashtags) to <= 300", () => {
+ // Body alone is near the limit; url + hashtags would have pushed the old
+ // assembler well past 300 (the reported 323-char failure).
+ const out = assemblePostText({
+ rendered: rendered({
+ text: "A".repeat(290),
+ hashtags: ["crawlproof", "seo", "bots"],
+ }),
+ url: "https://crawlproof.com/blog/some-long-article-slug",
+ platform: "bluesky",
+ });
+ expect(out.length).toBeLessThanOrEqual(BLUESKY_LIMIT);
+ // The URL is never sacrificed — it's the point of the post.
+ expect(out).toContain("https://crawlproof.com/blog/some-long-article-slug");
+ // Body was truncated with an ellipsis.
+ expect(out).toContain("…");
+ });
+
+ it("drops hashtags before sacrificing the URL when room is tight", () => {
+ const out = assemblePostText({
+ rendered: rendered({
+ text: "B".repeat(295),
+ hashtags: ["one", "two", "three", "four", "five"],
+ }),
+ url: "https://crawlproof.com/x",
+ platform: "bluesky",
+ });
+ expect(out.length).toBeLessThanOrEqual(BLUESKY_LIMIT);
+ expect(out).toContain("https://crawlproof.com/x");
+ });
+
+ it("respects the tighter X limit of 280", () => {
+ const out = assemblePostText({
+ rendered: rendered({ text: "C".repeat(300), hashtags: ["growth"] }),
+ url: "https://crawlproof.com/blog/post",
+ platform: "x",
+ });
+ expect(out.length).toBeLessThanOrEqual(X_LIMIT);
+ expect(out).toContain("https://crawlproof.com/blog/post");
+ });
+
+ it("does not truncate long-form platforms with generous limits", () => {
+ const body = "D".repeat(1200);
+ const out = assemblePostText({
+ rendered: rendered({ text: body, hashtags: ["a", "b", "c"] }),
+ url: "https://crawlproof.com/blog/post",
+ platform: "linkedin",
+ });
+ expect(out).toContain(body);
+ expect(out).not.toContain("…");
+ });
+});