diff --git a/apps/publish/CLAUDE.md b/apps/publish/CLAUDE.md index a81e747..3058040 100644 --- a/apps/publish/CLAUDE.md +++ b/apps/publish/CLAUDE.md @@ -205,11 +205,20 @@ CFP row is left out entirely where the destination would never ask. ## An invalid feed stops everything, before any destination -`main.ts` runs `validateFeed` first and shows the errors *instead of* the -console. Destinations are pure mappings, not validators: broadcasting from a -broken feed would carry the same errors to every destination at once. Don't +`main.ts` validates first and shows the errors *instead of* the console. +Destinations are pure mappings, not validators: broadcasting from a broken +feed would carry the same errors to every destination at once. Don't "improve" this into best-effort output with a warning. +It validates with **`validateDocument`, not `validateFeed`** — this feed is +one somebody else published, so it is judged against the version it declares. +`validateFeed` is latest-only by design, and under it a perfectly valid 0.4 +feed came back invalid over its `specVersion` alone, so Broadcast refused to +publish a healthy feed (#69). Being behind inside the support window is a +**notice**, never a defect: `showNotices` puts those above `#main` and the +feed still broadcasts. Never let a version notice take the early return the +error path takes. + ## Online-only events are not eligible for Google rich results — say so Google requires a physical location for event rich results ("Virtual diff --git a/apps/publish/build.mjs b/apps/publish/build.mjs index c3afdaa..9311121 100644 --- a/apps/publish/build.mjs +++ b/apps/publish/build.mjs @@ -10,13 +10,24 @@ const serve = process.argv.includes("--serve"); // it here means the snippet can never drift from the deployed assets. const embedVersion = JSON.parse(readFileSync("../embed/package.json", "utf8")).version; +// `splitting` with an outdir instead of a single outfile, the same shape and +// for the same reason as apps/validator: validating a feed against the +// version it declares (#69) means @opentechevents/validate can reach any +// published version's compiled validators, and each one is a few hundred +// kilobytes. They sit behind a dynamic import there, so esbuild gives each +// its own chunk — without `splitting` they are all inlined and `main.js` +// goes from 464 KB to 1.3 MB for versions this page will usually never meet. const options = { entryPoints: ["src/main.ts"], bundle: true, + splitting: true, format: "esm", platform: "browser", target: "es2022", - outfile: "dist/main.js", + outdir: "dist", + // The page loads ./main.js by name; only the shared chunks get hashes. + entryNames: "[name]", + chunkNames: "chunks/[name]-[hash]", sourcemap: true, minify: !serve, logLevel: "info", diff --git a/apps/publish/src/main.ts b/apps/publish/src/main.ts index 173e190..ae90a8f 100644 --- a/apps/publish/src/main.ts +++ b/apps/publish/src/main.ts @@ -1,5 +1,5 @@ import { type OteFeed } from "@opentechevents/export-jsonld"; -import { validateFeed } from "@opentechevents/validate"; +import { validateDocument, type SpecVersionNotice } from "@opentechevents/validate"; import { feedUrls, parseFeedSource, type FeedSource } from "./lib/feed-source.js"; import { DEFAULT_EMBED_OPTIONS } from "./lib/site-snippets.js"; @@ -11,7 +11,7 @@ import { } from "./lib/store.js"; import { renderAppBar, setFeedLabel, wireBrand, wireEventChip, wireTheme } from "./ui/app-bar.js"; import type { AppContext, State } from "./ui/context.js"; -import { el } from "./ui/dom.js"; +import { el, link, sentence } from "./ui/dom.js"; import { renderDestination, wireDestinationChrome } from "./ui/destination.js"; import { wireEventPicker } from "./ui/event-picker.js"; import { renderHome } from "./ui/home.js"; @@ -27,6 +27,26 @@ function showMessage(heading: string, body: string, isError: boolean): void { message.hidden = false; } +/** + * Version notices, which are news rather than findings. The feed broadcasts + * either way, so this never returns early on the caller's behalf, and it + * renders above `#main` instead of in place of it. + * + * A notice keeps its `links` out of its sentence so a page can render them as + * links; this one does, rather than printing a bare URL mid-paragraph. + */ +function showNotices(notices: SpecVersionNotice[]): void { + if (notices.length === 0) return; + message.className = "panel"; + message.replaceChildren(el("h2", undefined, "Worth knowing about this feed")); + for (const notice of notices) { + const parts: (string | Node)[] = [notice.message]; + for (const target of notice.links) parts.push(" ", link(target.href, target.label)); + message.append(sentence(...parts)); + } + message.hidden = false; +} + /** * The whole navigation model: two views, one function that owns every piece of * chrome either of them needs. No router and no hash — the same shape @@ -104,8 +124,14 @@ async function boot(): Promise { // Every destination maps the feed's own fields onto someone else's format; // none of them validates. An invalid feed would be broadcast, errors and // all, to every destination at once — so it stops here. - const validation = validateFeed(json); - if (!validation.valid) { + // + // `validateDocument`, not `validateFeed`: this is a feed somebody else + // published, so it is judged against the version it declares. Against + // "latest" a valid 0.4 feed came back broken over its `specVersion` alone + // and Broadcast refused to publish it — being behind is a notice, never a + // defect (#69). + const report = await validateDocument(json, { kind: "feed" }); + if (!report.valid) { message.className = "panel error"; message.replaceChildren( el("h2", undefined, "This feed is not valid OTE"), @@ -116,7 +142,7 @@ async function boot(): Promise { ), ); const list = el("ul"); - for (const error of validation.errors) { + for (const error of report.errors) { const item = el("li"); item.append(el("code", undefined, error.path), document.createTextNode(` — ${error.message}`)); list.append(item); @@ -126,6 +152,10 @@ async function boot(): Promise { return; } + // Said, but not in the way of anything: a supported-but-older release is a + // legitimate choice, so this sits above the app instead of replacing it. + showNotices(report.notices); + const feed = json as OteFeed; setFeedLabel( `${feed.title} · ${feed.events.length} event${feed.events.length === 1 ? "" : "s"}`,