Archilyzer · Source

archilyzer

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

commit f111b66baa23a7d06cfaa8f9c613d0dfb0c3bbcd
parent a38ef94914b2d5ecfc92a5799231fcc96dd28a52
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 13:46:34 -0400

reports: a claim may carry a flag (a quiet accent pill on its card); a one-report site's home tab names the report

- report.json claim `flag`: one line, at most 60 characters (schema + validate); REPORT.md regenerated
- ClaimView carries it; ReportArticle draws it beside the verdict chip in the accent
- (workspace)/page generateMetadata: a cited site with one report titles `/` as the report page does
  (shared reportMetadata + onlyReportView in export/app/lib/reports.ts)
- fixture claim-4 flagged; unit + report-site e2e assertions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
MREPORT.md | 1+
Mcommon/lib/report/report.test.ts | 17+++++++++++++++++
Mcommon/lib/report/schema.ts | 5+++++
Mcommon/lib/report/validate.ts | 4++++
Mcommon/lib/report/views.test.ts | 9+++++++++
Mcommon/lib/report/views.ts | 4++++
Mexport/CHANGELOG.md | 1+
Mexport/app/(workspace)/page.tsx | 15++++++++++++---
Mexport/app/components/reports/ReportArticle.tsx | 10+++++++++-
Mexport/app/lib/reports.test.ts | 8++++++++
Mexport/app/lib/reports.ts | 17+++++++++++++++++
Mexport/app/reports/[reportId]/page.tsx | 6+++---
Mexport/e2e-report/report-site.spec.ts | 6++++++
Mexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 1+
Mexport/fixtures/report-site/source/demo-factcheck/report.json | 1+
Mplans/report-sites.md | 2+-
16 files changed, 99 insertions(+), 8 deletions(-)

diff --git a/REPORT.md b/REPORT.md @@ -47,6 +47,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | `title` | no | A short headline for the claim (plain text, e.g. a phrase it turns on), shown above its text. Absent = the text alone. | | `text` | yes | The claim, as stated by the document under review (plain text). | | `verdict` | no | The ruling on the claim: `CORROBORATED`, `PARTLY`, `CONTRADICTED`, `NOT_FOUND`, `UNTESTABLE`. A fact-check's claim may leave it out (not yet ruled); a sweep's carries none. | +| `flag` | no | A short mark on the claim (plain text, one line, at most 60 characters), shown as a pill on its card: e.g. that the document gives no source for it. Absent = none. | | `sourceQuote` | no | The document's own sentence making the claim: `{ "citation": "<id>" }`, naming a `source` citation (its still is shown with the claim). | | `findings` | no | What the evidence shows, in markdown, citing inline: `[label](cite:<id>)`. | | `citations` | no | The citations the claim rests on, in the order they are listed under it. Each must exist; none twice. | diff --git a/common/lib/report/report.test.ts b/common/lib/report/report.test.ts @@ -239,3 +239,20 @@ test("the verdict vocabulary: five verdicts, a default for each, overrides laid assert.deepEqual(v.PARTLY, { label: "Half true", color: VERDICT_DEFAULTS.PARTLY.color }); assert.deepEqual(Object.keys(v), [...VERDICTS]); }); + +test("a claim's flag: one line of at most 60 characters", () => { + const withFlag = (flag: unknown) => { + const f = structuredClone(fixture()); + (f.sections[0].claims![0] as Record<string, unknown>).flag = flag; + return f; + }; + const ok = parseReport(withFlag("x".repeat(60))); + assert.equal(ok.ok, true); + assert.deepEqual(ok.problems, []); + assert.deepEqual(validateReport(withFlag("No source given")), []); + const long = parseReport(withFlag("x".repeat(61))); + assert.equal(long.ok, false); + assert.deepEqual(paths(long.problems), ["sections[0].claims[0].flag"]); + assert.deepEqual(paths(validateReport(withFlag(" "))), ["sections[0].claims[0].flag"]); + assert.deepEqual(paths(validateReport(withFlag("two\nlines"))), ["sections[0].claims[0].flag"]); +}); diff --git a/common/lib/report/schema.ts b/common/lib/report/schema.ts @@ -37,11 +37,15 @@ export type ReportKind = (typeof REPORT_KINDS)[number]; const text = z.string(); const verdict = z.enum(VERDICTS as unknown as [Verdict, ...Verdict[]]); +// A claim's flag is a short mark, shown as a pill on the claim's card. +export const CLAIM_FLAG_MAX = 60; + export const claimSchema = z.strictObject({ id: text, title: text.optional(), text, verdict: verdict.optional(), + flag: text.max(CLAIM_FLAG_MAX).optional(), sourceQuote: z.strictObject({ citation: text }).optional(), findings: text.optional(), citations: z.array(text).optional(), @@ -120,6 +124,7 @@ export const CLAIM_FIELD_DOCS: FieldDocs<Claim> = { title: "A short headline for the claim (plain text, e.g. a phrase it turns on), shown above its text. Absent = the text alone.", text: "The claim, as stated by the document under review (plain text).", verdict: `The ruling on the claim: ${VERDICTS.map((v) => `\`${v}\``).join(", ")}. A fact-check's claim may leave it out (not yet ruled); a sweep's carries none.`, + flag: `A short mark on the claim (plain text, one line, at most ${CLAIM_FLAG_MAX} characters), shown as a pill on its card: e.g. that the document gives no source for it. Absent = none.`, sourceQuote: "The document's own sentence making the claim: `{ \"citation\": \"<id>\" }`, naming a `source` citation (its still is shown with the claim).", findings: "What the evidence shows, in markdown, citing inline: `[label](cite:<id>)`.", diff --git a/common/lib/report/validate.ts b/common/lib/report/validate.ts @@ -114,6 +114,10 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[] const cp: PathSegment[] = [...sp, "claims", ci]; anchor(claim.id, cp); if (blank(claim.text)) out.push(problem([...cp, "text"], "must not be blank")); + if (claim.flag !== undefined) { + if (blank(claim.flag)) out.push(problem([...cp, "flag"], "must not be blank")); + else if (/[\r\n]/.test(claim.flag)) out.push(problem([...cp, "flag"], "must be one line")); + } if (report.kind === "sweep" && claim.verdict !== undefined) { out.push(problem([...cp, "verdict"], "a sweep's claims carry no verdict (make the report a factcheck)")); } diff --git a/common/lib/report/views.test.ts b/common/lib/report/views.test.ts @@ -298,3 +298,12 @@ test("a report's series rides its views and leads its one-line name", () => { const key = Object.keys(index)[0]; assert.ok(citedInViews(index[key], [v]).every((e) => e.reportTitle === `On the Record: ${report.title}`)); }); + +test("a claim's flag rides its view; a claim without one carries none", () => { + const flagged = structuredClone(report); + flagged.sections[0].claims![0].flag = "No source given"; + assert.deepEqual(validateReport(flagged), []); + const v = buildReportPageView(flagged, { record: (c) => record(c.channel, c.id) }); + assert.equal(v.sections[0].claims[0].flag, "No source given"); + assert.equal("flag" in view.sections[0].claims[0], false); +}); diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts @@ -205,6 +205,9 @@ export type ClaimView = { // The claim, as the document under review states it. text: string; verdict?: Verdict; + // A short mark on the claim (e.g. that the document gives no source), + // shown as a pill on its card. + flag?: string; // The id of the `source` citation holding the document's own sentence. sourceQuote?: string; // The document's archive links that sit in this claim's sentence (their @@ -487,6 +490,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver) title: c.title, text: c.text, verdict: c.verdict, + flag: c.flag, sourceQuote: c.sourceQuote?.citation, archives: archivesFor(c), findings: c.findings, diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **A report's claim can carry a flag, and a site with one report names it in the browser tab.** `report.json` claim `flag` (one line, at most 60 characters) shows as a small pill in the accent colour beside the claim's verdict, e.g. "No source given". On a report-only site with one report, the home page's tab title is the report's, as on the report's own page, where it was the site's title alone. Needs a rebuild and deploy of the site. - **A report-only site with one report opens on that report.** Its home page is the report itself, its header links nothing, and `/reports/` forwards home: there is no index of one. With more reports the home page is the list, without repeating the site's title under the header; a list entry is the report's name, subtitle and dates (its counts and tally are on its page). Pages a report-only site does not have link home. - **A report can belong to a series.** `report.json` `series` leads the report's title in the accent colour, in place of the kind's label ("Fact-check"); a page title, a cited-in link, `llms.txt` and the MCP name it `<series>: <title>`. - **`pnpm start:export` serves a built site's moment pages.** It used `serve`, which listed a video or audio moment's directory (`3126.00-3151.00`) instead of serving its page; it now runs `export/scripts/serve-out.mjs`, which serves directories as Cloudflare Pages does, on `EXPORT_DEV_PORT` (3000). diff --git a/export/app/(workspace)/page.tsx b/export/app/(workspace)/page.tsx @@ -1,12 +1,22 @@ +import type { Metadata } from "next"; import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; import { currentSite } from "../lib/site"; import { instanceMode } from "../lib/mode"; -import { readReportIndex, readReportView, reportIds } from "../lib/reports"; +import { onlyReportView, readReportIndex, reportMetadata } from "../lib/reports"; import NewSinceLastVisit from "../components/NewSinceLastVisit"; import HubHome from "../components/hub/HubHome"; import ReportIndex from "../components/reports/ReportIndex"; import ReportArticle from "../components/reports/ReportArticle"; +// A cited site with one report: the home page IS the report, so its tab names +// the report as the report's own page does. Every other home keeps the +// layout's default title (the site's). +export function generateMetadata(): Metadata { + if (instanceMode() === "hub" || !isCitedSite(currentSite())) return {}; + const only = onlyReportView(); + return only ? reportMetadata(only) : {}; +} + export default async function Home() { // Hub mode renders the federated shelf + cross-site search and reads no local // summaries manifest (there is none), so branch before countTranscripts(). @@ -26,8 +36,7 @@ export default async function Home() { // links nothing: there is nowhere else to go). With more, the index -- no // heading of its own: the header already names the site. if (isCitedSite(settings)) { - const ids = reportIds(); - const only = ids.length === 1 ? readReportView(ids[0]) : null; + const only = onlyReportView(); if (only) return <ReportArticle view={only} />; return <ReportIndex index={readReportIndex()} heading={null} tagline={settings.homeTagline} />; } diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx @@ -19,7 +19,7 @@ import { ArchiveList, Eyebrow, ReportName, SourceBlock, dateLabel, textLink } fr // ONE REPORT, from its view (common/lib/report/views.ts): the header (title, // subtitle, dates, the document under review with its archive links), a // fact-check's tally, the summary, the sections and their claims — each claim -// its verdict, the document's own sentence (its still), the findings with +// its verdict and flag, the document's own sentence (its still), the findings with // their inline citations, and the evidence cards — then the numbered // reference list the inline markers jump to, and the citations as files. @@ -75,6 +75,14 @@ function Claim({ > <div className="flex flex-wrap items-start gap-x-3 gap-y-2"> {claim.verdict && <VerdictChip verdict={claim.verdict} style={view.verdicts[claim.verdict]} className="mt-0.5" />} + {claim.flag && ( + <span + data-claim-flag="" + className="mt-0.5 inline-flex shrink-0 items-center rounded-full border border-brand/40 bg-brand-soft px-2.5 py-0.5 text-xs font-medium text-brand" + > + {claim.flag} + </span> + )} <h3 className="min-w-0 flex-1 basis-60 font-display text-lg font-semibold leading-snug text-foreground"> <a href={`#${claim.id}`} className="hover:text-brand transition-colors"> {claim.title ?? claim.text} diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts @@ -110,6 +110,9 @@ test("the report page: header, tally, sections and claims, inline cites, referen assert.match(html, /href="\/reports\/demo-factcheck\/citations.csv" download=""/); const meta = await reportPage.generateMetadata({ params }); assert.equal(meta.title, "Checking an example article"); + // a claim's flag: a pill on its card; an unflagged claim carries none + assert.match(html, /<article id="claim-4"[^]*?<span data-claim-flag=""[^>]*>No source given<\/span>/); + assert.equal([...html.matchAll(/data-claim-flag=/g)].length, 1); }); test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => { @@ -162,6 +165,10 @@ test("a cited site with one report: its home IS the report, and never counts tra assert.match(html, /data-claim=/); assert.doesNotMatch(html, /data-report-index/); assert.doesNotMatch(html, /Demo Reports/, "the site title is the header's, not the page's"); + // its tab names the report as the report's own page does + const reportMeta = await reportPage.generateMetadata({ params: Promise.resolve({ reportId: "demo-factcheck" }) }); + assert.deepEqual(home.generateMetadata(), reportMeta); + assert.equal(home.generateMetadata().title, "Checking an example article"); }); test("a cited site with more than one report: its home is the index, with no heading of its own", async () => { @@ -176,6 +183,7 @@ test("a cited site with more than one report: its home is the index, with no hea assert.match(html, /data-report-entry="demo-factcheck"/); assert.match(html, /data-report-entry="demo-second"/); assert.doesNotMatch(html, /<h1/, "no heading: the header already names the site"); + assert.deepEqual(home.generateMetadata(), {}, "the index keeps the site's own title"); // With more than one, /reports/ is the index, tally and all. const idx = render(reportsIndexPage.default()); assert.match(idx, /data-report-entry="demo-factcheck"/); diff --git a/export/app/lib/reports.ts b/export/app/lib/reports.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import type { Metadata } from "next"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { parseMomentKey } from "yt-dlp-transcript-common/lib/citations/moments"; import { isReportId } from "yt-dlp-transcript-common/lib/report/schema"; @@ -11,6 +12,7 @@ import { REPORT_PAGE_FORMAT, REPORTS_INDEX_PATH, momentViewPath, + reportFullTitle, reportViewPath, type MomentIndexView, type MomentPageView, @@ -58,6 +60,21 @@ export function readReportView(reportId: string): ReportPageView | null { return v && v.id === reportId && Array.isArray(v.sections) ? v : null; } +// The build's one report, when it publishes exactly one: a cited site's home +// page IS that report. +export function onlyReportView(): ReportPageView | null { + const ids = reportIds(); + return ids.length === 1 ? readReportView(ids[0]) : null; +} + +// A report's page metadata — its page's, and a one-report cited site's home's, +// so the tab reads the same on both: the title as one line names it +// (`<series>: <title>`, the layout's template adds the site), the subtitle as +// the description. +export function reportMetadata(view: ReportPageView): Metadata { + return { title: reportFullTitle(view), ...(view.subtitle ? { description: view.subtitle } : {}) }; +} + // The moment keys to build pages for (only keys exactly as momentKey spells // them: each segment is a path segment of a page written to disk). export function momentKeys(): string[] { diff --git a/export/app/reports/[reportId]/page.tsx b/export/app/reports/[reportId]/page.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; -import { REPORT_PLACEHOLDER_ID, reportFullTitle } from "yt-dlp-transcript-common/lib/report/views"; +import { REPORT_PLACEHOLDER_ID } from "yt-dlp-transcript-common/lib/report/views"; import ReportArticle from "../../components/reports/ReportArticle"; import { EmptyState } from "../../components/reports/parts"; -import { readReportView, reportIds } from "../../lib/reports"; +import { readReportView, reportIds, reportMetadata } from "../../lib/reports"; // /reports/<reportId>/: one report, from /reports/<reportId>/page.json. // Static: every published report is listed, nothing else resolves. A site with @@ -20,7 +20,7 @@ type Params = { params: Promise<{ reportId: string }> }; export async function generateMetadata({ params }: Params): Promise<Metadata> { const view = readReportView((await params).reportId); if (!view) return { title: "No reports" }; - return { title: reportFullTitle(view), ...(view.subtitle ? { description: view.subtitle } : {}) }; + return reportMetadata(view); } export default async function ReportPage({ params }: Params) { diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts @@ -29,6 +29,8 @@ test("one report: the home page IS the report, and the header links nothing", as await page.goto("/"); await expect(page.locator("h1")).toHaveText("Checking an example article"); await expect(page.locator("article[data-claim]")).toHaveCount(5); + // The tab names the report, as the report's own page does. + await expect(page).toHaveTitle("Checking an example article — Demo Reports"); await expect(page.locator("[data-report-index]")).toHaveCount(0); await expect(page.locator("header nav a")).toHaveCount(0); }); @@ -57,6 +59,10 @@ test("the report: verdict chips, the tally, the source's sentence as a still, nu ]) { await expect(page.locator(`article#${claim} [data-verdict="${verdict}"]`)).toBeVisible(); } + // A claim's flag is a pill on its card; an unflagged claim has none. + await expect(page.locator("article#claim-4 [data-claim-flag]")).toHaveText("No source given"); + await expect(page.locator("[data-claim-flag]")).toHaveCount(1); + await expect(page).toHaveTitle("Checking an example article — Demo Reports"); // The claim's sentence in the article, as the reviewed page showed it. await expectImageLoaded(page.locator('[data-source-sentence="a01"] img[src$="/stills/a01.png"]')); diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json @@ -197,6 +197,7 @@ "id": "claim-4", "text": "He privately regrets it.", "verdict": "UNTESTABLE", + "flag": "No source given", "findings": "Nothing public speaks to a private feeling.", "citations": [] }, diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json @@ -116,6 +116,7 @@ "id": "claim-4", "text": "He privately regrets it.", "verdict": "UNTESTABLE", + "flag": "No source given", "findings": "Nothing public speaks to a private feeling." }, { diff --git a/plans/report-sites.md b/plans/report-sites.md @@ -39,7 +39,7 @@ on existing full sites) need configuration, not code. "p01": { "kind": "post", "channel", "id", "quote" }, "a01": { "kind": "source", "source": "s0", "quote", "image": "stills/a01.png" } }, "sections": [ { "id", "title", "body" /* md */, - "claims": [ { "id", "text", "verdict" /* optional */, + "claims": [ { "id", "text", "verdict" /* optional */, "flag" /* optional, ≤60 chars: a pill on the card */, "sourceQuote": { "citation": "a01" }, "findings": "md with [label](cite:c01) refs", "citations": ["c01", "p01"] } ] } ] }