commit 78074df18a6f7bddf017ea394fcea899c0f62ab4
parent c93635a5aa0bad3820d626deb4d2d8222e9c3209
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 17:19:13 -0400
editor: Where X posts appear on /settings, a site's Audience, and the deploy actions refuse a private site
- Settings → X account session: a "Where X posts appear" select
(XPostsVisibilityControl) writing social.x.visibility through saveSettings.
Both X choices are now written over the X block as it is (saveSettings
replaces a nested block whole), so the login source keeps the visibility and
the visibility keeps the source.
- A site's form: an Audience select (public | private) with one sentence;
the save action keeps only "private".
- Build & deploy and Deploy refuse a private site before any job, naming the
audience; Build & deploy asks again before its upload; the host Build &
deploy all skips a private site's deploy before the upload.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
8 files changed, 198 insertions(+), 6 deletions(-)
diff --git a/editor/app/settings/components/XPostsVisibilityControl.tsx b/editor/app/settings/components/XPostsVisibilityControl.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+// Where X posts appear — `social.x.visibility` (release 17 slice XP), inside the
+// X account session section. "Public" builds an X channel's posts into every
+// site that has the channel; "Private" keeps them out of every public site and
+// builds them only into private sites (site.json `audience`). The rule is
+// common/lib/postsVisibility.ts; nothing on disk changes and fetching does not.
+
+import { useEffect, useState, useTransition } from "react";
+import { setXPostsVisibilityAction } from "../xSessionActions";
+import type { XPostsVisibility } from "yt-dlp-transcript-common/social/xCookieSource";
+
+export function XPostsVisibilityControl({
+ initial,
+}: {
+ initial: XPostsVisibility;
+}) {
+ const [visibility, setVisibility] = useState<XPostsVisibility>(initial);
+ const [error, setError] = useState<string | null>(null);
+ const [note, setNote] = useState<string | null>(null);
+ const [saving, startSaving] = useTransition();
+
+ // Another tab's choice re-renders the page with a new value; take it.
+ useEffect(() => setVisibility(initial), [initial]);
+
+ const choose = (choice: string) =>
+ startSaving(async () => {
+ setError(null);
+ setNote(null);
+ const res = await setXPostsVisibilityAction(choice);
+ if (res.ok) {
+ setVisibility(res.visibility);
+ setNote("Saved. Published sites change on their next build and deploy.");
+ } else {
+ setError(res.error);
+ }
+ });
+
+ return (
+ <div data-x-posts-visibility="" className="flex flex-col gap-1">
+ <div className="flex flex-wrap items-center gap-2">
+ <label htmlFor="x-posts-visibility" className="text-sm">
+ Where X posts appear
+ </label>
+ <select
+ id="x-posts-visibility"
+ value={visibility}
+ onChange={(e) => choose(e.target.value)}
+ disabled={saving}
+ className="rounded-md border border-border bg-background px-2 py-1 text-sm disabled:opacity-50"
+ >
+ <option value="public">Public — every site that has the channel</option>
+ <option value="private">Private — private sites only</option>
+ </select>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Private leaves every X channel and its posts out of every public site
+ and builds them only into sites whose audience is private, which are
+ never deployed; nothing is deleted and fetching goes on. Sites already
+ published change on their next build and deploy.
+ </p>
+ {note && (
+ <p role="status" aria-label="x posts visibility saved" className="text-xs text-success">
+ {note}
+ </p>
+ )}
+ {error && (
+ <p role="alert" className="text-xs text-destructive">
+ {error}
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/settings/components/XSessionSection.tsx b/editor/app/settings/components/XSessionSection.tsx
@@ -26,14 +26,19 @@ import {
resolveXCookieSource,
xCookieSourceLabel,
type XCookieSourceView,
+ type XPostsVisibility,
} from "yt-dlp-transcript-common/social/xCookieSource";
+import { XPostsVisibilityControl } from "./XPostsVisibilityControl";
export function XSessionSection({
initial,
initialSource,
+ initialVisibility,
}: {
initial: XSessionStatus;
initialSource: XCookieSourceView;
+ // `social.x.visibility`, resolved (absent = "public"); release 17 slice XP.
+ initialVisibility: XPostsVisibility;
}) {
const [status, setStatus] = useState<XSessionStatus>(initial);
const [source, setSource] = useState<XCookieSourceView>(initialSource);
@@ -250,6 +255,10 @@ export function XSessionSection({
{error}
</p>
)}
+
+ <div className="border-t border-border pt-3">
+ <XPostsVisibilityControl initial={initialVisibility} />
+ </div>
</section>
);
}
diff --git a/editor/app/settings/page.tsx b/editor/app/settings/page.tsx
@@ -5,6 +5,7 @@ import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { readXSessionStatus } from "yt-dlp-transcript-common/social/xSessionBroker";
import { resolveXCookieSourceFor } from "yt-dlp-transcript-common/social/xBrowserLogin";
import { xCookieSourceView } from "yt-dlp-transcript-common/social/xCookieSource";
+import { xPostsVisibility } from "yt-dlp-transcript-common/lib/postsVisibility";
import { SettingsForm } from "./components/SettingsForm";
import { XSessionSection } from "./components/XSessionSection";
@@ -93,7 +94,11 @@ export default async function SettingsPage() {
</section>
<section className="flex flex-col gap-3 border-t border-border pt-6">
- <XSessionSection initial={xSession} initialSource={xSource} />
+ <XSessionSection
+ initial={xSession}
+ initialSource={xSource}
+ initialVisibility={xPostsVisibility(settings)}
+ />
</section>
<section className="flex flex-col gap-3 border-t border-border pt-6">
diff --git a/editor/app/settings/xSessionActions.ts b/editor/app/settings/xSessionActions.ts
@@ -30,11 +30,25 @@ import {
} from "yt-dlp-transcript-common/social/xBrowserLogin";
import {
isXCookieSource,
+ isXPostsVisibility,
xCookieSourceView,
type XCookieSourceView,
+ type XPostsVisibility,
+ type XSocialSettings,
} from "yt-dlp-transcript-common/social/xCookieSource";
+import { xPostsVisibility } from "yt-dlp-transcript-common/lib/postsVisibility";
import { saveSettings } from "./saveSettings";
+// `social.x` is ONE value to saveSettings, whose merge is one level deep: a
+// patch naming `social: { x }` replaces the whole X block. So each X choice is
+// written over the block as it is now, with only its own key changed — the
+// login source keeps the visibility, and the visibility keeps the source.
+function socialXPatch(
+ change: (x: XSocialSettings) => XSocialSettings,
+): { social: { x: XSocialSettings } } {
+ return { social: { x: change({ ...getSettings().social.x }) } };
+}
+
// Every session action returns the source in use beside the profile's status:
// connecting or forgetting a profile can move the read-time default.
export type XSessionActionResult =
@@ -124,12 +138,42 @@ export async function setXCookieSourceAction(
return { ok: false, error: `Unknown X login source "${choice}".` };
}
try {
- await saveSettings({
- social: { x: choice === "auto" ? {} : { cookieSource: choice } },
- });
+ await saveSettings(
+ socialXPatch(({ cookieSource: _was, ...rest }) =>
+ choice === "auto" ? rest : { ...rest, cookieSource: choice },
+ ),
+ );
} catch (e) {
return { ok: false, error: (e as Error).message };
}
revalidatePath("/settings");
return { ok: true, source: await sourceNow() };
}
+
+// WHERE X POSTS APPEAR — `social.x.visibility` (release 17 slice XP). "public"
+// is the default and is written as no key; "private" keeps every X channel's
+// posts out of every public site build (common/lib/postsVisibility.ts). Nothing
+// on disk changes and fetching does not: a site already published changes on
+// its next build and deploy.
+export type XPostsVisibilityResult =
+ | { ok: true; visibility: XPostsVisibility }
+ | { ok: false; error: string };
+
+export async function setXPostsVisibilityAction(
+ choice: string,
+): Promise<XPostsVisibilityResult> {
+ if (!isXPostsVisibility(choice)) {
+ return { ok: false, error: `Unknown X post visibility "${choice}".` };
+ }
+ try {
+ await saveSettings(
+ socialXPatch(({ visibility: _was, ...rest }) =>
+ choice === "public" ? rest : { ...rest, visibility: choice },
+ ),
+ );
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ revalidatePath("/settings");
+ return { ok: true, visibility: xPostsVisibility(getSettings()) };
+}
diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts
@@ -111,6 +111,12 @@ export async function saveSiteAction(
// Listed on the homepage and hub by default: the same opt-out idiom as
// archives below (an unchecked box sends no key → persisted as false).
const listed = formData.get("listed") === "on";
+ // Who the site is built for (release 17 slice XP): only "private" is kept.
+ const audienceRaw = String(formData.get("audience") ?? "public");
+ if (audienceRaw !== "public" && audienceRaw !== "private") {
+ return { ok: false, error: `Unknown audience "${audienceRaw}".`, values };
+ }
+ const isPrivate = audienceRaw === "private";
// Hub parent (per-site override of the family default) + PWA opt-in.
const hubUrlRaw = String(formData.get("hubUrl") ?? "").trim();
@@ -248,6 +254,7 @@ export async function saveSiteAction(
...(siteUrl ? { siteUrl } : {}),
// The Site is rebuilt from the form: a key missing here is dropped on save.
...(listed ? {} : { listed: false }),
+ ...(isPrivate ? { audience: "private" as const } : {}),
...(hubUrl ? { hubUrl } : {}),
...(pwa ? { pwa: true } : {}),
...(archives ? {} : { archives: false }),
diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx
@@ -16,7 +16,7 @@ import {
toSocialRow,
type SocialRow,
} from "../../components/SocialLinksField";
-import { Field } from "../../components/forms/Field";
+import { Field, SeededSelect } from "../../components/forms/Field";
import {
ControlledCheck,
ControlledSelect,
@@ -341,6 +341,25 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) {
defaultValue={initial.siteUrl ?? ""}
hint="Absolute URL this site is served at (e.g. https://jeralyzer.com). Used so other sites can link to it in their footer. Leave blank to omit this site from cross-site lists."
/>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Audience</span>
+ <SeededSelect
+ state={state}
+ name="audience"
+ aria-label="Audience"
+ initial={initial.audience === "private" ? "private" : "public"}
+ className="w-fit rounded border border-border bg-card px-2 py-1 text-sm"
+ >
+ <option value="public">Public — deployed and listed as configured</option>
+ <option value="private">Private — built for reading on this machine, never deployed</option>
+ </SeededSelect>
+ <span className="text-xs text-muted-foreground">
+ A private site is never deployed (Build & deploy and Deploy refuse
+ it; Build still builds it) and never listed on the homepage or the hub,
+ and publishes no hub URL. It is the one kind of site X posts are built
+ into while Settings keeps X posts private.
+ </span>
+ </label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
diff --git a/editor/app/sites/lib/buildAction.ts b/editor/app/sites/lib/buildAction.ts
@@ -16,6 +16,10 @@ import {
} from "yt-dlp-transcript-common/controller/archiveLiveChat";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
+import {
+ deployAudienceProblem,
+ siteDeployProblem,
+} from "yt-dlp-transcript-common/lib/builtExport";
import { getSite, listSites, type Site } from "yt-dlp-transcript-common/lib/site";
import {
runManagedFunction,
@@ -132,6 +136,10 @@ export async function buildAndDeployAction(
}
const branch = previewBranch?.trim();
const site = getSite(id, paths);
+ // A private site is never deployed (site.json `audience`), so Build & deploy
+ // refuses before the build; its Build button still builds it.
+ const privateProblem = siteDeployProblem(site);
+ if (privateProblem) return { ok: false, error: `${privateProblem}.` };
if (!site.cloudflareProject) {
return {
ok: false,
@@ -155,6 +163,14 @@ export async function buildAndDeployAction(
if (buildCode !== 0) {
throw new Error(`Build failed (exit ${buildCode}) — not deploying.`);
}
+ // Asked again before the upload, over the site as it is now and the
+ // bundle just built: an audience switched to private while this job
+ // waited on the queue is not deployed.
+ const audienceProblem = deployAudienceProblem(
+ getSite(id, paths),
+ resolveOutDir(id, paths),
+ );
+ if (audienceProblem) throw new Error(`${audienceProblem} — not deploying.`);
onLog(branch ? `\n=== Deploy (preview "${branch}") ===\n` : "\n=== Deploy ===\n");
if (branch) onLog(PREVIEW_SHARES_ARCHIVES_NOTICE);
// Push oversize archives to R2 before the Pages deploy (no-op when R2
@@ -401,6 +417,13 @@ async function basicBuildAndDeployAll(
});
continue;
}
+ // A private site is built and never deployed — skipped before the upload.
+ const audienceProblem = deployAudienceProblem(site, basicOut);
+ if (audienceProblem) {
+ onLog(`[${site.siteId}] deploy skipped — ${audienceProblem}`);
+ deploys.push({ siteId: site.siteId, status: "skipped", reason: audienceProblem });
+ continue;
+ }
if (!site.cloudflareProject) {
onLog(`[${site.siteId}] deploy skipped — no Cloudflare project configured`);
deploys.push({
diff --git a/editor/app/sites/lib/deployAction.ts b/editor/app/sites/lib/deployAction.ts
@@ -1,6 +1,10 @@
"use server";
-import { builtSiteProblem } from "yt-dlp-transcript-common/lib/builtExport";
+import {
+ builtAudienceProblem,
+ builtSiteProblem,
+ siteDeployProblem,
+} from "yt-dlp-transcript-common/lib/builtExport";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
import { getSite } from "yt-dlp-transcript-common/lib/site";
@@ -38,6 +42,9 @@ export async function deployExportAction(
}
const branch = previewBranch?.trim();
const site = getSite(siteId.trim(), paths);
+ // A private site is never deployed (site.json `audience`): the first answer.
+ const privateProblem = siteDeployProblem(site);
+ if (privateProblem) return { ok: false, error: `${privateProblem}.` };
if (!site.cloudflareProject) {
return {
ok: false,
@@ -58,6 +65,10 @@ export async function deployExportAction(
const outDir = resolveOutDir(site.siteId, paths);
const builtProblem = builtSiteProblem(outDir, site.siteId);
if (builtProblem) return { ok: false, error: builtProblem };
+ const builtPrivate = builtAudienceProblem(outDir);
+ if (builtPrivate) {
+ return { ok: false, error: `${builtPrivate}. Build ${site.siteId} again, then deploy.` };
+ }
return runManagedFunction({
kind: "deploy-export",
queueKey: DEPLOY_QUEUE,