Browse / github.com/storytold/artcraft-services / patch
Show each Crafting App's latest release on the /apps pages
Show each Crafting App's latest release on the /apps pages The `/apps` pages pinned every app's version by hand, so they fell behind every release: getartcraft.com/apps/photocraft still offers 0.7.0, while 0.10.0 is out. Every other app was behind as well. ## What changes The pages now get versions from GitHub at render time instead of from hardcoded constants: - **`fetchCraftRelease(repo, assets)`** calls GitHub's `releases/latest` for each repo, which only returns published, non-pre-release builds. Next caches the result for an hour. - **`/apps` and `/apps/[slug]` regenerate hourly** (`revalidate = 3600`). A new release appears on the live deploy
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- tanish2k09 (imported from pull request #169, open)
- Base
e57ee3d63ac478a24149384bf1473ef97aa067f8- Size
- +136 −71 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
6a3da93f62409d772ae608a7
How it went
No other operator has reported using it yet.
Diff
raw · AGENTS.md frontend/apps/artcraft-website-next/src/app/apps/[slug]/page.tsx frontend/apps/artcraft-website-next/src/app/apps/page.tsx frontend/apps/artcraft-website-next/src/components/apps/app-card.tsx frontend/apps/artcraft-website-next/src/components/apps/app-downloads.tsx frontend/apps/artcraft-website-next/src/components/apps/app-sections.tsx frontend/apps/artcraft-website-next/src/components/apps/launcher-callout.tsx frontend/apps/artcraft-website-next/src/lib/craft-launcher.ts frontend/apps/artcraft-website-next/src/lib/crafting-app-releases.ts frontend/apps/artcraft-website-next/src/lib/crafting-apps.ts
@@ -67,6 +67,12 @@ filenames for Windows (`ArtCraft_<version>_x64-setup.exe`) and macOS require deployment of the affected apps before the public pages change; do not report a local update as a deployed update. +The Crafting App pages (`/apps`, `/apps/<slug>`) are different: they read each+app's and the ArtCraft Launcher's latest GitHub release when they render and+regenerate hourly, so their versions are never edited by hand. To hide an app's+downloads or change its file list, see the header of+`frontend/apps/artcraft-website-next/src/lib/crafting-app-releases.ts`.+ ## Code Style - Rust with no minimum supported version@@ -47,6 +47,10 @@ type Params = Promise<{ slug: string }>; export const dynamicParams = false; +// Downloads follow each app's latest GitHub release; regenerate hourly to pick+// up new ones (CRAFT_RELEASE_REVALIDATE_SECONDS; route config must be a literal).+export const revalidate = 3600;+ export function generateStaticParams() { return CRAFTING_APPS.map((app) => ({ slug: app.slug })); }@@ -82,12 +86,12 @@ export default async function CraftAppPage({ params }: { params: Params }) { if (!app) notFound(); const name = craftAppName(app);- const release = craftAppRelease(app);+ const release = await craftAppRelease(app); // The hero's download is ArtCraft Launcher, which installs this app and keeps // it updated; the app's own installers are in #get-it, under the launcher. // An app with no release yet has nothing for the launcher to install.- const launcherDownloads = release ? craftLauncherRecommended() : [];- const allLauncherDownloads = craftLauncherDownloads();+ const launcherDownloads = release ? await craftLauncherRecommended() : [];+ const allLauncherDownloads = await craftLauncherDownloads(); const hasDownload = release !== null; const [hero] = app.shots; const [before, accent, after] = app.headline;@@ -72,9 +72,13 @@ const PRINCIPLE_ICONS = [ <GlobeIcon key="web" aria-hidden className="h-5 w-5" />, ]; -export default function CraftingAppsPage() {- const launcherDownloads = craftLauncherRecommended();- const allLauncherDownloads = craftLauncherDownloads();+// Downloads follow each app's latest GitHub release; regenerate hourly to pick+// up new ones (CRAFT_RELEASE_REVALIDATE_SECONDS; route config must be a literal).+export const revalidate = 3600;++export default async function CraftingAppsPage() {+ const launcherDownloads = await craftLauncherRecommended();+ const allLauncherDownloads = await craftLauncherDownloads(); return ( <> <RevealManager />@@ -126,7 +130,7 @@ export default function CraftingAppsPage() { <LauncherDownloadButton recommended={launcherDownloads} allDownloads={allLauncherDownloads}- fallbackHref={craftLauncherReleasePageUrl()}+ fallbackHref={await craftLauncherReleasePageUrl()} /> )} <DiscordButton@@ -16,7 +16,7 @@ import { AppIcon, AppTab, AppWordmark, appThemeClass } from "./app-wordmark"; // straddling the screenshot's bottom edge, wordmark, pitch, and (unless // compact) status/platform chips. The whole cell is the link; a hairline in // the app's color draws across the top on hover.-export default function AppCard({+export default async function AppCard({ app, index, compact = false,@@ -26,7 +26,7 @@ export default function AppCard({ compact?: boolean; }) { const [hero] = app.shots;- const release = craftAppRelease(app);+ const release = await craftAppRelease(app); return ( <Link href={craftAppPath(app)}@@ -11,7 +11,6 @@ import { import { twMerge } from "tailwind-merge"; import { WindowsIcon } from "@/components/icons"; import { Button, type ButtonProps } from "@/components/ui";-import { craftLauncherDownloads } from "@/lib/craft-launcher"; import { CRAFT_DESKTOP_PLATFORMS, type CraftDesktopPlatform,@@ -178,7 +177,7 @@ export function LauncherDownloadButton({ className, }: { recommended: CraftDownload[];- allDownloads?: CraftDownload[];+ allDownloads: CraftDownload[]; fallbackHref: string; size?: ButtonProps["size"]; variant?: ButtonProps["variant"];@@ -213,7 +212,7 @@ export function LauncherDownloadButton({ }, [menuOpen]); const isLinux = detected?.platform === "Linux";- const downloads = allDownloads ?? (isLinux ? craftLauncherDownloads() : []);+ const downloads = isLinux ? allDownloads : []; const linuxDownloads = isLinux ? downloads.filter( (download) =>@@ -9,7 +9,7 @@ import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell import LazyVideo from "@/components/lazy-video"; import { Badge, Button, CopyButton } from "@/components/ui"; import { trackAttrs } from "@/lib/analytics";-import { CRAFT_LAUNCHER_RELEASE } from "@/lib/crafting-app-releases";+import { craftLauncherRelease } from "@/lib/craft-launcher"; import { CRAFTING_APPS, craftAppBuildCommand,@@ -157,11 +157,12 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) { // away) above release details and build-from-source. Versions and files come // from crafting-app-releases.ts; a null release there drops the cards and // shows the Discord waitlist instead.-export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {+export async function AppGetIt({ app, index }: { app: CraftApp; index: string }) { const name = craftAppName(app); const command = craftAppBuildCommand(app); const desktop = app.platforms.filter((platform) => platform !== "Web");- const release = craftAppRelease(app);+ const release = await craftAppRelease(app);+ const launcherRelease = await craftLauncherRelease(); const downloads = release ? craftReleaseDownloads(app, release) : []; const extras = downloads.filter((download) => download.group === "Other"); @@ -174,7 +175,7 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) { /> {release && ( <>- {CRAFT_LAUNCHER_RELEASE && (+ {launcherRelease && ( <> <LauncherCallout app={app} /> <div data-reveal className="border-y border-line bg-bg p-6 md:p-10">@@ -3,9 +3,9 @@ import { CRAFT_LAUNCHER_NAME, CRAFT_LAUNCHER_REPO, craftLauncherDownloads,+ craftLauncherRelease, craftLauncherReleasePageUrl, } from "@/lib/craft-launcher";-import { CRAFT_LAUNCHER_RELEASE } from "@/lib/crafting-app-releases"; import { craftAppName, type CraftApp, type CraftDownload } from "@/lib/crafting-apps"; import { LauncherDownloadButton } from "./app-downloads"; import { ColorAccent } from "./app-wordmark";@@ -18,16 +18,17 @@ import { ColorAccent } from "./app-wordmark"; const CALLOUT_POINTS = ["Every Crafting App", "One-click updates", "Verified downloads"]; /** The launcher's main download per platform, for the OS-aware buttons. */-export function craftLauncherRecommended(): CraftDownload[] {- return craftLauncherDownloads().filter((download) => download.recommended);+export async function craftLauncherRecommended(): Promise<CraftDownload[]> {+ return (await craftLauncherDownloads()).filter((download) => download.recommended); } // Leads an app's Get-it section, above that app's own installers: the pitch // beside its download panel, matching the release and build-from-source // cells below it. Renders nothing while the launcher has no release.-export function LauncherCallout({ app }: { app: CraftApp }) {- const recommended = craftLauncherRecommended();- if (!CRAFT_LAUNCHER_RELEASE || recommended.length === 0) return null;+export async function LauncherCallout({ app }: { app: CraftApp }) {+ const release = await craftLauncherRelease();+ const recommended = await craftLauncherRecommended();+ if (!release || recommended.length === 0) return null; const name = craftAppName(app); return ( <div@@ -61,12 +62,12 @@ export function LauncherCallout({ app }: { app: CraftApp }) { </div> <div className="flex flex-col justify-center bg-bg p-6 md:p-10"> <p className="hud-label text-faint">- {CRAFT_LAUNCHER_NAME} · v{CRAFT_LAUNCHER_RELEASE.version}+ {CRAFT_LAUNCHER_NAME} · v{release.version} </p> <LauncherDownloadButton recommended={recommended}- allDownloads={craftLauncherDownloads()}- fallbackHref={craftLauncherReleasePageUrl()}+ allDownloads={await craftLauncherDownloads()}+ fallbackHref={await craftLauncherReleasePageUrl()} fullWidth className="mt-4" />@@ -1,4 +1,8 @@-import { CRAFT_LAUNCHER_RELEASE } from "./crafting-app-releases";+import {+ CRAFT_LAUNCHER_ASSETS,+ fetchCraftRelease,+ type CraftRelease,+} from "./crafting-app-releases"; import { CRAFTING_APPS_GITHUB_ORG, craftReleaseTag,@@ -10,22 +14,27 @@ import { // every Crafting App. The /apps hub's main button downloads it and every // /apps/<slug> Get-it section recommends it above that app's own installers. //-// Facts come from the craft-launcher README — keep them in sync. Version and-// files live in crafting-app-releases.ts (CRAFT_LAUNCHER_RELEASE); null there-// hides every launcher download.+// Facts come from the craft-launcher README — keep them in sync. The version+// is the repo's latest GitHub release; its files are CRAFT_LAUNCHER_ASSETS in+// crafting-app-releases.ts, and null there hides every launcher download. export const CRAFT_LAUNCHER_NAME = "ArtCraft Launcher"; export const CRAFT_LAUNCHER_REPO = `${CRAFTING_APPS_GITHUB_ORG}/craft-launcher`; -/** Every launcher release file, or none while the release is null. */-export function craftLauncherDownloads(): CraftDownload[] {- return CRAFT_LAUNCHER_RELEASE- ? releaseDownloads(CRAFT_LAUNCHER_REPO, "artcraft-launcher", CRAFT_LAUNCHER_RELEASE)- : [];+/** The launcher's latest GitHub release, or null while its downloads are hidden. */+export async function craftLauncherRelease(): Promise<CraftRelease | null> {+ return CRAFT_LAUNCHER_ASSETS ? fetchCraftRelease("craft-launcher", CRAFT_LAUNCHER_ASSETS) : null;+}++/** Every launcher release file, or none while the release is hidden. */+export async function craftLauncherDownloads(): Promise<CraftDownload[]> {+ const release = await craftLauncherRelease();+ return release ? releaseDownloads(CRAFT_LAUNCHER_REPO, "artcraft-launcher", release) : []; } -export function craftLauncherReleasePageUrl(): string {- return CRAFT_LAUNCHER_RELEASE- ? `${CRAFT_LAUNCHER_REPO}/releases/tag/${craftReleaseTag(CRAFT_LAUNCHER_RELEASE)}`+export async function craftLauncherReleasePageUrl(): Promise<string> {+ const release = await craftLauncherRelease();+ return release+ ? `${CRAFT_LAUNCHER_REPO}/releases/tag/${craftReleaseTag(release)}` : `${CRAFT_LAUNCHER_REPO}/releases`; }@@ -3,13 +3,18 @@ import type { CraftAppSlug } from "./crafting-apps"; // GitHub releases for the Crafting Apps, which drive every download button on // the /apps pages. //-// To bump an app: change its version in CRAFT_APP_RELEASES (each app has its-// own, e.g. `craftRelease("0.4.0")`).-// To hide an app's downloads (Discord waitlist instead): set it to null.-// If a release renames, adds or drops files, edit CRAFT_RELEASE_ASSETS, or-// pass a custom list as craftRelease's second argument for a single app.+// Versions are never edited here: each page asks GitHub for the repo's latest+// published release when it renders (fetchCraftRelease) and caches the answer+// for CRAFT_RELEASE_REVALIDATE_SECONDS, so a new release shows up on the live+// deploy within the hour, without a commit or a redeploy. //-// Release tags are `v<version>` and live at github.com/storytold/<slug>.+// To hide an app's downloads (Discord waitlist instead): set it to null in+// CRAFT_APP_ASSETS.+// If a release renames or adds files, edit CRAFT_RELEASE_ASSETS, or give a+// single app its own list in CRAFT_APP_ASSETS. Catalog files missing from a+// release are left out, so a dropped file never becomes a dead link.+//+// Releases live at github.com/storytold/<slug>. export type CraftDesktopPlatform = "macOS" | "Windows" | "Linux"; @@ -43,6 +48,8 @@ export type CraftRelease = { /** Fills `{slug}` in filenames. Defaults to the app's slug; a release from before a rename keeps the old one. */ fileSlug?: string; assets: readonly CraftReleaseAsset[];+ /** The release's actual file names; catalog assets not in it are left out. */+ files?: readonly string[]; }; // The file set every Crafting App publishes, in display order within each group.@@ -197,34 +204,64 @@ export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [ }, ]; -export function craftRelease(- version: string,- assets: readonly CraftReleaseAsset[] = CRAFT_RELEASE_ASSETS,-): CraftRelease {- return { version, assets };+/** How long a fetched release is cached before the next request rechecks GitHub. */+export const CRAFT_RELEASE_REVALIDATE_SECONDS = 3600;++/**+ * The latest published release of github.com/storytold/<repo> (never a draft+ * or pre-release). Throws when GitHub can't answer: a build then fails and the+ * previous deploy stays live, and a background revalidation keeps serving the+ * last good page. Set GITHUB_TOKEN in Netlify to lift the anonymous rate limit.+ */+export async function fetchCraftRelease(+ repo: string,+ assets: readonly CraftReleaseAsset[],+): Promise<CraftRelease> {+ const headers: Record<string, string> = {+ Accept: "application/vnd.github+json",+ "User-Agent": "getartcraft.com",+ "X-GitHub-Api-Version": "2022-11-28",+ };+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;+ const res = await fetch(`https://api.github.com/repos/storytold/${repo}/releases/latest`, {+ headers,+ next: { revalidate: CRAFT_RELEASE_REVALIDATE_SECONDS },+ });+ if (!res.ok) throw new Error(`GitHub latest release of storytold/${repo}: HTTP ${res.status}`);+ const body = (await res.json()) as { tag_name?: unknown; assets?: { name?: unknown }[] };+ const tag = typeof body.tag_name === "string" ? body.tag_name : "";+ if (!/^v\d+\.\d+\.\d+/.test(tag)) {+ throw new Error(`GitHub latest release of storytold/${repo}: unexpected tag "${tag}"`);+ }+ return {+ version: tag.slice(1),+ tag,+ assets,+ files: (body.assets ?? []).flatMap((asset) =>+ typeof asset.name === "string" ? [asset.name] : [],+ ),+ }; } // ArtCraft Launcher (github.com/storytold/craft-launcher) installs and updates // every app above, and the /apps pages recommend it first. Its releases ship // the same file set minus the macOS CLI, named artcraft-launcher-*.-export const CRAFT_LAUNCHER_RELEASE: CraftRelease | null = craftRelease(- "0.5.1",- CRAFT_RELEASE_ASSETS.filter((asset) => !asset.file.startsWith("{slug}-cli-")),-);+export const CRAFT_LAUNCHER_ASSETS: readonly CraftReleaseAsset[] | null =+ CRAFT_RELEASE_ASSETS.filter((asset) => !asset.file.startsWith("{slug}-cli-")); // Every app must be listed, so a new slug fails the type check until it is.-export const CRAFT_APP_RELEASES: Record<CraftAppSlug, CraftRelease | null> = {- photocraft: craftRelease("0.7.0"),- vectorcraft: craftRelease("0.9.0"),- filmcraft: craftRelease("0.6.0"),- lightcraft: craftRelease("0.6.0"),- pdfcraft: craftRelease("0.6.0"),- effectcraft: craftRelease("0.8.0"),- designcraft: craftRelease("0.6.0"),- figcraft: craftRelease("0.5.1"),- wordcraft: craftRelease("0.5.0"),- soundcraft: craftRelease("0.5.0"),- deckcraft: craftRelease("0.5.0"),- cadcraft: craftRelease("0.5.0"),- gridcraft: craftRelease("0.5.0"),+export const CRAFT_APP_ASSETS: Record<CraftAppSlug, readonly CraftReleaseAsset[] | null> = {+ photocraft: CRAFT_RELEASE_ASSETS,+ vectorcraft: CRAFT_RELEASE_ASSETS,+ filmcraft: CRAFT_RELEASE_ASSETS,+ lightcraft: CRAFT_RELEASE_ASSETS,+ pdfcraft: CRAFT_RELEASE_ASSETS,+ effectcraft: CRAFT_RELEASE_ASSETS,+ designcraft: CRAFT_RELEASE_ASSETS,+ figcraft: CRAFT_RELEASE_ASSETS,+ wordcraft: CRAFT_RELEASE_ASSETS,+ soundcraft: CRAFT_RELEASE_ASSETS,+ deckcraft: CRAFT_RELEASE_ASSETS,+ cadcraft: CRAFT_RELEASE_ASSETS,+ gridcraft: CRAFT_RELEASE_ASSETS, };@@ -1,6 +1,7 @@ import type { Tip } from "./campaign-data"; import {- CRAFT_APP_RELEASES,+ CRAFT_APP_ASSETS,+ fetchCraftRelease, type CraftRelease, type CraftReleaseAsset, } from "./crafting-app-releases";@@ -1128,8 +1129,10 @@ export function craftAppIconSourceUrl(app: CraftApp): string { return `https://raw.githubusercontent.com/storytold/${app.slug}/main/assets/app-icon/${app.slug}-1024.png`; } -export function craftAppRelease(app: CraftApp): CraftRelease | null {- return CRAFT_APP_RELEASES[app.slug];+/** The app's latest GitHub release, or null while its downloads are hidden. */+export async function craftAppRelease(app: CraftApp): Promise<CraftRelease | null> {+ const assets = CRAFT_APP_ASSETS[app.slug];+ return assets ? fetchCraftRelease(app.slug, assets) : null; } export function craftReleaseTag(release: CraftRelease): string {@@ -1159,11 +1162,12 @@ export function releaseDownloads( release: CraftRelease, ): CraftDownload[] { const base = `${repo}/releases/download/${craftReleaseTag(release)}`;- return release.assets.map(({ file, ...asset }) => {+ return release.assets.flatMap(({ file, ...asset }) => { const fileName = file .replaceAll("{slug}", release.fileSlug ?? fileSlug) .replaceAll("{version}", release.version);- return { ...asset, fileName, href: `${base}/${fileName}` };+ if (release.files && !release.files.includes(fileName)) return [];+ return [{ ...asset, fileName, href: `${base}/${fileName}` }]; }); }