Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Aeditor/app/settings/components/XPostsVisibilityControl.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/settings/components/XSessionSection.tsx | 9+++++++++
Meditor/app/settings/page.tsx | 7++++++-
Meditor/app/settings/xSessionActions.ts | 50+++++++++++++++++++++++++++++++++++++++++++++++---
Meditor/app/sites/actions.ts | 7+++++++
Meditor/app/sites/components/SiteForm.tsx | 21++++++++++++++++++++-
Meditor/app/sites/lib/buildAction.ts | 23+++++++++++++++++++++++
Meditor/app/sites/lib/deployAction.ts | 13++++++++++++-
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 &amp; 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,