diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/ai/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/ai/intro.mdx new file mode 100644 index 00000000..56b3174f --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/ai/intro.mdx @@ -0,0 +1 @@ +Want an AI model or a coding agent in your terminal? This list has coding agents such as Claude Code, Codex CLI, and Aider, tools that run models on your own machine such as Ollama and llama.cpp, and small helpers that send text to a model. Each row shows the type, language, license, and platforms, with the most starred tools first. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/communication/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/communication/intro.mdx new file mode 100644 index 00000000..4622f57b --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/communication/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Communication category. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/databases/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/databases/intro.mdx new file mode 100644 index 00000000..3a2d58d8 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/databases/intro.mdx @@ -0,0 +1 @@ +Need to query a database or run a migration without leaving the terminal? This list has clients with autocomplete such as pgcli, mycli, and litecli, engines you can run from a prompt such as DuckDB and SQLite, and migration tools such as dbmate, Atlas, and Goose. Each row shows the type, language, license, and platforms. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/development/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/development/intro.mdx new file mode 100644 index 00000000..f5a11ae9 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/development/intro.mdx @@ -0,0 +1 @@ +Building software and want better tools in your terminal? This is the largest category in the directory. It covers runtimes and toolchains such as Go, Deno, and Bun, editors such as Neovim and Emacs, package managers such as uv, and coding agents. Rows are sorted by GitHub stars, so the most starred tools come first. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/devops/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/devops/intro.mdx new file mode 100644 index 00000000..2654c134 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/devops/intro.mdx @@ -0,0 +1 @@ +Deploying an app or looking after a server from the terminal? This list covers infrastructure tools such as Terraform and Ansible, container and Kubernetes tools such as lazydocker, dive, and K9s, and the official CLIs for GitHub, GitLab, Fly.io, Railway, Supabase, and Stripe. Each row shows the type, language, license, and platforms. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/files/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/files/intro.mdx new file mode 100644 index 00000000..7f546c6e --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/files/intro.mdx @@ -0,0 +1 @@ +Need to find, move, or back up files faster than a file manager allows? This list has search tools such as ripgrep and fd, sync and transfer tools such as rclone and croc, backup tools such as restic, and terminal file managers such as Yazi and Midnight Commander. It also covers duplicate finders and disk usage tools such as fclones and ncdu. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/games-fun/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/games-fun/intro.mdx new file mode 100644 index 00000000..7a60e8f5 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/games-fun/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Games and fun category. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/index.ts b/app/(site)/(directories)/cli-apps/categories/[category]/content/index.ts new file mode 100644 index 00000000..05b38951 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/index.ts @@ -0,0 +1,32 @@ +import type { ComponentType } from "react"; +import Intro0 from "./development/intro.mdx"; +import Intro1 from "./devops/intro.mdx"; +import Intro2 from "./files/intro.mdx"; +import Intro3 from "./text/intro.mdx"; +import Intro4 from "./networking/intro.mdx"; +import Intro5 from "./security/intro.mdx"; +import Intro6 from "./monitoring/intro.mdx"; +import Intro7 from "./databases/intro.mdx"; +import Intro8 from "./media-graphics/intro.mdx"; +import Intro9 from "./productivity/intro.mdx"; +import Intro10 from "./communication/intro.mdx"; +import Intro11 from "./games-fun/intro.mdx"; +import Intro12 from "./terminal-ui/intro.mdx"; +import Intro13 from "./ai/intro.mdx"; + +export const categoryIntros: Record = { + "development": Intro0, + "devops": Intro1, + "files": Intro2, + "text": Intro3, + "networking": Intro4, + "security": Intro5, + "monitoring": Intro6, + "databases": Intro7, + "media-graphics": Intro8, + "productivity": Intro9, + "communication": Intro10, + "games-fun": Intro11, + "terminal-ui": Intro12, + "ai": Intro13, +}; diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/media-graphics/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/media-graphics/intro.mdx new file mode 100644 index 00000000..b931f2bf --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/media-graphics/intro.mdx @@ -0,0 +1 @@ +Want to convert, download, or play media without opening a desktop app? This list has converters such as ffmpeg and ImageMagick, downloaders such as yt-dlp and gallery-dl, and players such as mpv, cmus, and ncspot. A few tools show images right in the terminal, such as Chafa and viu. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/monitoring/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/monitoring/intro.mdx new file mode 100644 index 00000000..4075e7f0 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/monitoring/intro.mdx @@ -0,0 +1 @@ +Trying to see what is using your CPU, disk, or network? This list has process monitors such as btop, Glances, and bottom, disk usage tools such as duf, dust, and ncdu, and network tools such as gping and bandwhich. It also has benchmarking and log tools such as hyperfine and lnav. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/networking/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/networking/intro.mdx new file mode 100644 index 00000000..31b456f4 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/networking/intro.mdx @@ -0,0 +1 @@ +Debugging a connection or testing an API from the terminal? This list has HTTP clients such as curl, HTTPie, and Hurl, DNS tools such as doggo and dnsx, and tunnel and VPN tools such as frp and Tailscale. For tracing and packet capture there are MTR and tcpdump. To check DNS records in the browser, use our [DNS lookup](/dns-lookup). diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/productivity/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/productivity/intro.mdx new file mode 100644 index 00000000..b1413470 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/productivity/intro.mdx @@ -0,0 +1 @@ +Spend most of your day in a terminal and want it to go faster? This list has finders and jumpers such as fzf and zoxide, session managers such as tmux and Zellij, task runners such as just, and dotfile managers such as chezmoi. It also has calendar, time tracking, and password tools such as calcurse, Timewarrior, and password-store. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/security/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/security/intro.mdx new file mode 100644 index 00000000..6842a552 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/security/intro.mdx @@ -0,0 +1 @@ +Want to check your code and servers for problems before someone else does? This list has secret scanners such as Gitleaks and TruffleHog, vulnerability scanners such as Trivy and Nuclei, and encryption tools such as age and GnuPG. For certificates there are mkcert, acme.sh, and step CLI, and for passwords there are KeePassXC CLI and password-store. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/terminal-ui/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/terminal-ui/intro.mdx new file mode 100644 index 00000000..9210ca44 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/terminal-ui/intro.mdx @@ -0,0 +1 @@ +Setting up a terminal you will enjoy using? This list has terminal emulators such as Ghostty, Alacritty, and Warp, shells such as Zsh, Bash, and PowerShell, and prompts and themes such as Starship and Powerlevel10k. It also has full-screen terminal apps such as lazygit, lazydocker, and Yazi. For Ghostty, Warp, and other apps, see our [keyboard shortcuts](/keyboard-shortcuts). diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/content/text/intro.mdx b/app/(site)/(directories)/cli-apps/categories/[category]/content/text/intro.mdx new file mode 100644 index 00000000..71069d06 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/text/intro.mdx @@ -0,0 +1 @@ +Editing, searching, or converting text in the terminal? This list has editors such as Neovim, Helix, Vim, and micro, JSON tools such as jq and fx, and diff viewers such as delta and Difftastic. For documents there are Pandoc to convert formats and Glow and mdcat to read Markdown. diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/layout.tsx b/app/(site)/(directories)/cli-apps/categories/[category]/layout.tsx new file mode 100644 index 00000000..6f955e1e --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/layout.tsx @@ -0,0 +1,7 @@ +import { registryLayout } from "@/lib/registry-layout"; + +const registry = registryLayout("/cli-apps/categories/[category]"); +export const generateMetadata = registry.generateMetadata; +export function generateStaticParams() { return []; } +export const revalidate = false; +export default registry.Layout; diff --git a/app/(site)/(directories)/cli-apps/categories/[category]/page.tsx b/app/(site)/(directories)/cli-apps/categories/[category]/page.tsx new file mode 100644 index 00000000..d85f6578 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getAppCategoryLinks } from "@/lib/app-categories"; +import { appCategoryStaticParams, selectCategoryApps, MIN_CATEGORY_APPS } from "@/lib/app-category-model"; +import { getTerminalApps, terminalAppTypeLabels } from "@/lib/terminal-apps"; +import { AppCategoryPage } from "@/app/ui/directories/app-category-page"; +import { categoryIntros } from "./content"; + +export const revalidate = false; +export async function generateStaticParams() { + return appCategoryStaticParams(await getAppCategoryLinks("cli-apps")); +} + +export default async function CategoryPage({ params }: { params: Promise<{ category: string }> }) { + const { category } = await params; + const directoryPage = await getRequiredDirectoryPage(`cli-apps/categories/${category}`); + const categories = await getAppCategoryLinks("cli-apps"); + if (!categories.some(entry => entry.slug === category)) notFound(); + const Intro = categoryIntros[category]; + if (!Intro) throw new Error(`Missing CLI category intro: ${category}`); + const apps = selectCategoryApps(await getTerminalApps(), category, app => app.categories, app => app.stars ?? 0); + if (apps.length < MIN_CATEGORY_APPS) notFound(); + return } categories={categories} apps={apps.map(app => ({ + name: app.name, summary: app.summary, href: app.repository_url || app.website_url, + facts: [terminalAppTypeLabels.get(app.primary_type) ?? app.primary_type, app.language || "Language unknown", + app.license || "License unknown", app.platforms.length ? app.platforms.join(", ") : "Platforms unknown"].join(" · "), + }))} />; +} diff --git a/app/(site)/(directories)/cli-apps/layout.tsx b/app/(site)/(directories)/cli-apps/layout.tsx index 700bee98..ef1c5760 100644 --- a/app/(site)/(directories)/cli-apps/layout.tsx +++ b/app/(site)/(directories)/cli-apps/layout.tsx @@ -1,7 +1,7 @@ import "../../../ui/directories/directory.css"; import { registryLayout } from "@/lib/registry-layout"; -const registry = registryLayout("/cli-apps"); +const registry = registryLayout("/cli-apps", { structuredData: "page" }); export const generateMetadata = registry.generateMetadata; export const revalidate = false; export default registry.Layout; diff --git a/app/(site)/(directories)/cli-apps/page.tsx b/app/(site)/(directories)/cli-apps/page.tsx index 402a1eac..f3ff51fa 100644 --- a/app/(site)/(directories)/cli-apps/page.tsx +++ b/app/(site)/(directories)/cli-apps/page.tsx @@ -1,3 +1,6 @@ +import { AppCategoryLinks } from "@/app/ui/directories/app-category-links"; +import { getAppCategoryLinks } from "@/lib/app-categories"; +import { RegistryStructuredData } from "@/lib/registry-layout"; import { getRequiredDirectoryPage } from "@/lib/directory-pages"; import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; @@ -131,6 +134,8 @@ export default async function CliAppsPage() { /> ); + const categoryLinks = await getAppCategoryLinks("cli-apps"); + return ( +

{" "}

@@ -149,6 +155,7 @@ export default async function CliAppsPage() { } /> ({ slug: app.slug, name: app.name, body: }))} /> +
diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/ai-ml/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/ai-ml/intro.mdx new file mode 100644 index 00000000..f62ddae2 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/ai-ml/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the ai & ml category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/automation/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/automation/intro.mdx new file mode 100644 index 00000000..a51c28ac --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/automation/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the automation category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/bookmarks-read-it-later/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/bookmarks-read-it-later/intro.mdx new file mode 100644 index 00000000..95cb1d81 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/bookmarks-read-it-later/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the bookmarks & read-it-later category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/business-commerce/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/business-commerce/intro.mdx new file mode 100644 index 00000000..376885c8 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/business-commerce/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the business & commerce category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/calendars-contacts/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/calendars-contacts/intro.mdx new file mode 100644 index 00000000..9a4798b3 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/calendars-contacts/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the calendars & contacts category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/communication/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/communication/intro.mdx new file mode 100644 index 00000000..1c64bd56 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/communication/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the communication category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/communities-social/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/communities-social/intro.mdx new file mode 100644 index 00000000..e2e2cc69 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/communities-social/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the communities & social category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/content-publishing/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/content-publishing/intro.mdx new file mode 100644 index 00000000..64c11767 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/content-publishing/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the content & publishing category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/dashboards/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/dashboards/intro.mdx new file mode 100644 index 00000000..006d3d71 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/dashboards/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the dashboards category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/databases/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/databases/intro.mdx new file mode 100644 index 00000000..ac0d39e0 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/databases/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the databases category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/design-creative/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/design-creative/intro.mdx new file mode 100644 index 00000000..f07ab4f9 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/design-creative/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the design & creative category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/developer-tools/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/developer-tools/intro.mdx new file mode 100644 index 00000000..0cd58179 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/developer-tools/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the developer tools category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/documents-pdfs/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/documents-pdfs/intro.mdx new file mode 100644 index 00000000..88fa3656 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/documents-pdfs/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the documents & pdfs category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/ebooks-libraries/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/ebooks-libraries/intro.mdx new file mode 100644 index 00000000..ff2de4c1 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/ebooks-libraries/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the e-books & libraries category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/email-newsletters/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/email-newsletters/intro.mdx new file mode 100644 index 00000000..ce9ddaa8 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/email-newsletters/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the email & newsletters category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/file-sharing-sync/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/file-sharing-sync/intro.mdx new file mode 100644 index 00000000..6e4f4430 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/file-sharing-sync/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the file sharing & sync category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/finance-budgeting/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/finance-budgeting/intro.mdx new file mode 100644 index 00000000..a36ea58d --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/finance-budgeting/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the finance & budgeting category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/forms-polls-events/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/forms-polls-events/intro.mdx new file mode 100644 index 00000000..a2abfa47 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/forms-polls-events/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the forms, polls & events category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/games/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/games/intro.mdx new file mode 100644 index 00000000..b079c21f --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/games/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the games category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/health-fitness/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/health-fitness/intro.mdx new file mode 100644 index 00000000..2b4e1276 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/health-fitness/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the health & fitness category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/home-automation-iot/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/home-automation-iot/intro.mdx new file mode 100644 index 00000000..01a342c4 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/home-automation-iot/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the home automation & iot category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/identity-access/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/identity-access/intro.mdx new file mode 100644 index 00000000..aaea13bf --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/identity-access/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the identity & access category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/index.ts b/app/(site)/(directories)/self-host-apps/categories/[category]/content/index.ts new file mode 100644 index 00000000..3c84e6cc --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/index.ts @@ -0,0 +1,88 @@ +import type { ComponentType } from "react"; +import Intro0 from "./ai-ml/intro.mdx"; +import Intro1 from "./automation/intro.mdx"; +import Intro2 from "./bookmarks-read-it-later/intro.mdx"; +import Intro3 from "./business-commerce/intro.mdx"; +import Intro4 from "./calendars-contacts/intro.mdx"; +import Intro5 from "./communication/intro.mdx"; +import Intro6 from "./communities-social/intro.mdx"; +import Intro7 from "./content-publishing/intro.mdx"; +import Intro8 from "./dashboards/intro.mdx"; +import Intro9 from "./databases/intro.mdx"; +import Intro10 from "./design-creative/intro.mdx"; +import Intro11 from "./developer-tools/intro.mdx"; +import Intro12 from "./documents-pdfs/intro.mdx"; +import Intro13 from "./ebooks-libraries/intro.mdx"; +import Intro14 from "./email-newsletters/intro.mdx"; +import Intro15 from "./file-sharing-sync/intro.mdx"; +import Intro16 from "./finance-budgeting/intro.mdx"; +import Intro17 from "./forms-polls-events/intro.mdx"; +import Intro18 from "./games/intro.mdx"; +import Intro19 from "./health-fitness/intro.mdx"; +import Intro20 from "./home-automation-iot/intro.mdx"; +import Intro21 from "./identity-access/intro.mdx"; +import Intro22 from "./knowledge-notes/intro.mdx"; +import Intro23 from "./learning-courses/intro.mdx"; +import Intro24 from "./maps-travel/intro.mdx"; +import Intro25 from "./media-device-control/intro.mdx"; +import Intro26 from "./media-servers/intro.mdx"; +import Intro27 from "./monitoring-status/intro.mdx"; +import Intro28 from "./music-audio/intro.mdx"; +import Intro29 from "./networking-dns/intro.mdx"; +import Intro30 from "./password-managers/intro.mdx"; +import Intro31 from "./photos-galleries/intro.mdx"; +import Intro32 from "./project-task-management/intro.mdx"; +import Intro33 from "./recipes-food/intro.mdx"; +import Intro34 from "./remote-access/intro.mdx"; +import Intro35 from "./search/intro.mdx"; +import Intro36 from "./security-privacy/intro.mdx"; +import Intro37 from "./storage-backup/intro.mdx"; +import Intro38 from "./system-admin-hosting/intro.mdx"; +import Intro39 from "./time-tracking/intro.mdx"; +import Intro40 from "./video-conferencing/intro.mdx"; +import Intro41 from "./wikis/intro.mdx"; + +export const categoryIntros: Record = { + "ai-ml": Intro0, + "automation": Intro1, + "bookmarks-read-it-later": Intro2, + "business-commerce": Intro3, + "calendars-contacts": Intro4, + "communication": Intro5, + "communities-social": Intro6, + "content-publishing": Intro7, + "dashboards": Intro8, + "databases": Intro9, + "design-creative": Intro10, + "developer-tools": Intro11, + "documents-pdfs": Intro12, + "ebooks-libraries": Intro13, + "email-newsletters": Intro14, + "file-sharing-sync": Intro15, + "finance-budgeting": Intro16, + "forms-polls-events": Intro17, + "games": Intro18, + "health-fitness": Intro19, + "home-automation-iot": Intro20, + "identity-access": Intro21, + "knowledge-notes": Intro22, + "learning-courses": Intro23, + "maps-travel": Intro24, + "media-device-control": Intro25, + "media-servers": Intro26, + "monitoring-status": Intro27, + "music-audio": Intro28, + "networking-dns": Intro29, + "password-managers": Intro30, + "photos-galleries": Intro31, + "project-task-management": Intro32, + "recipes-food": Intro33, + "remote-access": Intro34, + "search": Intro35, + "security-privacy": Intro36, + "storage-backup": Intro37, + "system-admin-hosting": Intro38, + "time-tracking": Intro39, + "video-conferencing": Intro40, + "wikis": Intro41, +}; diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/knowledge-notes/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/knowledge-notes/intro.mdx new file mode 100644 index 00000000..35631f9b --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/knowledge-notes/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the knowledge & notes category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/learning-courses/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/learning-courses/intro.mdx new file mode 100644 index 00000000..fd6a62d1 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/learning-courses/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the learning & courses category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/maps-travel/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/maps-travel/intro.mdx new file mode 100644 index 00000000..c232aab0 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/maps-travel/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the maps & travel category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/media-device-control/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/media-device-control/intro.mdx new file mode 100644 index 00000000..2d543c83 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/media-device-control/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the media device control category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/media-servers/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/media-servers/intro.mdx new file mode 100644 index 00000000..ea8a40f0 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/media-servers/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the media servers category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/monitoring-status/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/monitoring-status/intro.mdx new file mode 100644 index 00000000..72955c10 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/monitoring-status/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the monitoring & status category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/music-audio/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/music-audio/intro.mdx new file mode 100644 index 00000000..be522637 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/music-audio/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the music & audio category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/networking-dns/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/networking-dns/intro.mdx new file mode 100644 index 00000000..ececb818 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/networking-dns/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the networking & dns category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/password-managers/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/password-managers/intro.mdx new file mode 100644 index 00000000..ece5c001 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/password-managers/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the password managers category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/photos-galleries/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/photos-galleries/intro.mdx new file mode 100644 index 00000000..58205d94 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/photos-galleries/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the photos & galleries category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/project-task-management/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/project-task-management/intro.mdx new file mode 100644 index 00000000..1d9472eb --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/project-task-management/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the project & task management category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/recipes-food/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/recipes-food/intro.mdx new file mode 100644 index 00000000..c6963aa0 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/recipes-food/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the recipes & food category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/remote-access/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/remote-access/intro.mdx new file mode 100644 index 00000000..8be78a31 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/remote-access/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the remote access category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/search/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/search/intro.mdx new file mode 100644 index 00000000..dfca7a6a --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/search/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the search category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/security-privacy/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/security-privacy/intro.mdx new file mode 100644 index 00000000..f4de349b --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/security-privacy/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the security & privacy category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/storage-backup/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/storage-backup/intro.mdx new file mode 100644 index 00000000..bb79d554 --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/storage-backup/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the storage & backup category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/system-admin-hosting/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/system-admin-hosting/intro.mdx new file mode 100644 index 00000000..a2e1f19a --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/system-admin-hosting/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the system admin & hosting category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/time-tracking/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/time-tracking/intro.mdx new file mode 100644 index 00000000..dba7d34f --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/time-tracking/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the time tracking category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/video-conferencing/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/video-conferencing/intro.mdx new file mode 100644 index 00000000..e2585e6f --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/video-conferencing/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the video & conferencing category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/content/wikis/intro.mdx b/app/(site)/(directories)/self-host-apps/categories/[category]/content/wikis/intro.mdx new file mode 100644 index 00000000..f97f5b7c --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/content/wikis/intro.mdx @@ -0,0 +1 @@ +This list contains self-hosted apps in the wikis category. diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/layout.tsx b/app/(site)/(directories)/self-host-apps/categories/[category]/layout.tsx new file mode 100644 index 00000000..52ab5efb --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/layout.tsx @@ -0,0 +1,7 @@ +import { registryLayout } from "@/lib/registry-layout"; + +const registry = registryLayout("/self-host-apps/categories/[category]"); +export const generateMetadata = registry.generateMetadata; +export function generateStaticParams() { return []; } +export const revalidate = false; +export default registry.Layout; diff --git a/app/(site)/(directories)/self-host-apps/categories/[category]/page.tsx b/app/(site)/(directories)/self-host-apps/categories/[category]/page.tsx new file mode 100644 index 00000000..a6c1473e --- /dev/null +++ b/app/(site)/(directories)/self-host-apps/categories/[category]/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getAppCategoryLinks, getSelfHostCategoryApps } from "@/lib/app-categories"; +import { appCategoryStaticParams, MIN_CATEGORY_APPS } from "@/lib/app-category-model"; +import { AppCategoryPage } from "@/app/ui/directories/app-category-page"; +import { categoryIntros } from "./content"; + +export const revalidate = false; +export async function generateStaticParams() { + return appCategoryStaticParams(await getAppCategoryLinks("self-host-apps")); +} + +export default async function CategoryPage({ params }: { params: Promise<{ category: string }> }) { + const { category } = await params; + const directoryPage = await getRequiredDirectoryPage(`self-host-apps/categories/${category}`); + const categories = await getAppCategoryLinks("self-host-apps"); + if (!categories.some(entry => entry.slug === category)) notFound(); + const Intro = categoryIntros[category]; + if (!Intro) throw new Error(`Missing self-host category intro: ${category}`); + const apps = await getSelfHostCategoryApps(category); + if (apps.length < MIN_CATEGORY_APPS) notFound(); + return } categories={categories} apps={apps.map(app => ({ + name: app.name, summary: app.summary, href: app.href, + facts: [app.license || app.source_model.replaceAll("_", " "), app.maintenance_status, + `Docker: ${app.docker_support.replaceAll("_", " ")}`, + ...(app.ram_min_mb !== null ? [`Minimum RAM: ${app.ram_min_mb} MB`] : [])].join(" · "), + }))} />; +} diff --git a/app/(site)/(directories)/self-host-apps/layout.tsx b/app/(site)/(directories)/self-host-apps/layout.tsx index cc2b90f8..4e1e02ba 100644 --- a/app/(site)/(directories)/self-host-apps/layout.tsx +++ b/app/(site)/(directories)/self-host-apps/layout.tsx @@ -1,7 +1,7 @@ import "../../../ui/directories/directory.css"; import { registryLayout } from "@/lib/registry-layout"; -const registry = registryLayout("/self-host-apps"); +const registry = registryLayout("/self-host-apps", { structuredData: "page" }); export const generateMetadata = registry.generateMetadata; export const revalidate = false; export default registry.Layout; diff --git a/app/(site)/(directories)/self-host-apps/page.tsx b/app/(site)/(directories)/self-host-apps/page.tsx index cf78856c..2661d48d 100644 --- a/app/(site)/(directories)/self-host-apps/page.tsx +++ b/app/(site)/(directories)/self-host-apps/page.tsx @@ -1,3 +1,6 @@ +import { AppCategoryLinks } from "@/app/ui/directories/app-category-links"; +import { getAppCategoryLinks } from "@/lib/app-categories"; +import { RegistryStructuredData } from "@/lib/registry-layout"; import { getRequiredDirectoryPage } from "@/lib/directory-pages"; import { getBreadcrumbs } from "@/lib/breadcrumbs"; import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; @@ -56,6 +59,8 @@ export default async function SelfHostAppsPage() { ); + const categoryLinks = await getAppCategoryLinks("self-host-apps"); + return ( @@ -65,6 +70,7 @@ export default async function SelfHostAppsPage() { sidebar={} sidebarLabel="Self-hosted app filters" > +

{" "}

@@ -75,6 +81,7 @@ export default async function SelfHostAppsPage() { } /> ({ slug: app.slug, name: app.name, body: }))} /> +
diff --git a/app/ui/directories/app-category-links.tsx b/app/ui/directories/app-category-links.tsx new file mode 100644 index 00000000..28ffe720 --- /dev/null +++ b/app/ui/directories/app-category-links.tsx @@ -0,0 +1,13 @@ +import type { AppCategoryLink } from "@/lib/app-category-model"; +import styles from "./app-category.module.css"; + +export function AppCategoryLinks({ categories, current }: { categories: readonly AppCategoryLink[]; current?: string }) { + const links = categories.filter(category => category.slug !== current); + if (!links.length) return null; + return ; +} diff --git a/app/ui/directories/app-category-page.tsx b/app/ui/directories/app-category-page.tsx new file mode 100644 index 00000000..3953a77e --- /dev/null +++ b/app/ui/directories/app-category-page.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; +import { AppShell } from "@/app/ui/site/app-shell"; +import { PageCanvas } from "@/app/ui/site/page-canvas"; +import { PageHeader } from "@/app/ui/site/page-header"; +import { PageMeta } from "@/app/ui/site/page-meta"; +import { DirectoryPageTitle } from "./directory-page-content"; +import type { Stack127DirectoryPage } from "@/lib/directory-pages"; +import type { AppCategoryLink, AppDirectory } from "@/lib/app-category-model"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; +import { AppCategoryLinks } from "./app-category-links"; +import { categoryListHtml, type CategoryAppRow } from "@/lib/app-category-html"; +import styles from "./app-category.module.css"; + +export async function AppCategoryPage({ directory, category, directoryPage, intro, apps, categories }: { + directory: AppDirectory; category: string; directoryPage: Stack127DirectoryPage; + intro: ReactNode; apps: readonly CategoryAppRow[]; categories: readonly AppCategoryLink[]; +}) { + return + + +

+ + {intro} +
+
+

{apps.length.toLocaleString("en-US")} apps

+ {/* Compact HTML avoids a separate React element tree for every row. React + still includes this HTML string in its navigation payload, so the list + travels twice in the initial document. The serializer escapes dataset + text and validates outbound protocols. */} +
    +
+ +

Back to {directory === "cli-apps" ? "CLI apps" : "self-host apps"}

+
+
; +} diff --git a/app/ui/directories/app-category.module.css b/app/ui/directories/app-category.module.css new file mode 100644 index 00000000..5bcc19d1 --- /dev/null +++ b/app/ui/directories/app-category.module.css @@ -0,0 +1,10 @@ +.categories h2 { margin-bottom: var(--space-4); } +.categories ul { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: var(--space-3) var(--space-6); padding: 0; list-style: none; } +.categories span, .count { font-family: var(--font-mono); font-size: var(--text-small); color: var(--color-text-muted); } +.listing { max-width: 1000px; } +.listing ul { padding: 0; list-style: none; } +.listing li { padding-block: var(--space-4); border-bottom: 1px solid var(--color-border); overflow-wrap: anywhere; } +.listing li:first-child { border-top: 1px solid var(--color-border); } +.listing li > a, .listing li > strong { font-family: var(--font-display); font-weight: var(--weight-bold); } +.listing p { margin-block: var(--space-2); } +.listing small { display: block; font-family: var(--font-mono); font-size: var(--text-small); color: var(--color-text-muted); } diff --git a/lib/app-categories.ts b/lib/app-categories.ts new file mode 100644 index 00000000..8dd65363 --- /dev/null +++ b/lib/app-categories.ts @@ -0,0 +1,45 @@ +import { unstable_cache } from "next/cache"; +import { cache } from "react"; +import { getDirectoryAppIndex } from "./apps"; +import { getTerminalApps, terminalAppCategories } from "./terminal-apps"; +import { getPool } from "./db"; +import { HOME_PAGES_TAG, SELF_HOST_INDEX_TAG } from "./cache-tags"; +import { countAppCategories, publishedAppCategories, type AppDirectory } from "./app-category-model"; + +// Every page event invalidates HOME_PAGES_TAG, including a child's publication change. +const getCategoryRegistry = unstable_cache(async () => (await getPool().query<{ + slug: string; is_published: boolean; page_family: string; +}>(`select slug,is_published,page_family from public.stack127_pages where page_family='directory' + and is_published and (slug like 'self-host-apps/categories/%' or slug like 'cli-apps/categories/%')`)).rows, +["app-category-registry"], { revalidate: false, tags: [HOME_PAGES_TAG] }); + +export const getAppCategoryLinks = cache(async (directory: AppDirectory) => { + const pages = await getCategoryRegistry(); + if (directory === "cli-apps") { + const apps = await getTerminalApps(); + return publishedAppCategories(directory, countAppCategories(apps, terminalAppCategories, app => app.categories), pages); + } + const apps = await getDirectoryAppIndex(); + const categories = [...new Map(apps.flatMap(app => app.categories).map(category => [category.slug, category])).values()] + .sort((a, b) => a.name.localeCompare(b.name)); + return publishedAppCategories(directory, countAppCategories(apps, categories, app => app.categories.map(category => category.slug)), pages); +}); + +export type SelfHostCategoryApp = { + name: string; summary: string; href: string | null; license: string | null; + source_model: string; maintenance_status: string; docker_support: string; ram_min_mb: number | null; +}; + +// No details, images, hidden panels, or client props. One lean cached query per category. +export const getSelfHostCategoryApps = unstable_cache(async (slug: string): Promise => { + const result = await getPool().query(`select app.name,app.summary,app.popularity_score::float8 as popularity_score, + coalesce(app.source_code_url,app.website_url) as href,coalesce(app.license_spdx_id,app.license) as license, + app.source_model,app.maintenance_status,app.docker_support,app.ram_min_mb + from public.self_host_apps app + where app.published and app.self_hosting_status='self_hostable' and exists ( + select 1 from public.self_host_app_category_links link join public.self_host_app_categories category on category.id=link.category_id + where link.app_id=app.id and category.slug=$1) + order by app.popularity_score desc, app.name asc`, [slug]); + // Match the hub's locale-aware name tie-break, rather than database collation. + return result.rows.sort((a, b) => b.popularity_score - a.popularity_score || a.name.localeCompare(b.name)); +}, ["self-host-category-apps"], { revalidate: false, tags: [SELF_HOST_INDEX_TAG] }); diff --git a/lib/app-category-html.ts b/lib/app-category-html.ts new file mode 100644 index 00000000..3243feac --- /dev/null +++ b/lib/app-category-html.ts @@ -0,0 +1,18 @@ +export type CategoryAppRow = { name: string; summary: string; href: string | null; facts: string }; + +const escapeHtml = (value: string) => value.replace(/[&<>"']/g, character => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', +})[character]!); + +/** All fields are untrusted dataset text. Accept only absolute HTTP(S) outbound URLs. */ +export function categoryListHtml(apps: readonly CategoryAppRow[]) { + return apps.map(app => { + let href = null; + try { + const url = new URL(app.href ?? ''); + if (url.protocol === 'https:' || url.protocol === 'http:') href = url.href; + } catch { /* Missing or malformed links keep the app name as text. */ } + const name = escapeHtml(app.name); + return `
  • ${href ? `${name}` : `${name}`}

    ${escapeHtml(app.summary)}

    ${escapeHtml(app.facts)}
  • `; + }).join(''); +} diff --git a/lib/app-category-model.ts b/lib/app-category-model.ts new file mode 100644 index 00000000..2363a37b --- /dev/null +++ b/lib/app-category-model.ts @@ -0,0 +1,26 @@ +/** Category pages list the complete default view; query filters stay on the hubs. */ +export const MIN_CATEGORY_APPS = 15; +export type AppDirectory = "self-host-apps" | "cli-apps"; +export type AppCategoryCount = { slug: string; name: string; count: number }; +export type AppCategoryLink = AppCategoryCount & { href: string }; + +export function countAppCategories(apps: readonly T[], categories: readonly { slug: string; name: string }[], slugs: (app: T) => readonly string[]): AppCategoryCount[] { + const counts = new Map(); + for (const app of apps) for (const slug of new Set(slugs(app))) counts.set(slug, (counts.get(slug) ?? 0) + 1); + return categories.map(category => ({ slug: category.slug, name: category.name, count: counts.get(category.slug) ?? 0 })); +} + +/** Only exact, published registry children with enough records can have links or build params. */ +export function publishedAppCategories(directory: AppDirectory, categories: readonly AppCategoryCount[], pages: readonly { slug: string; is_published: boolean; page_family: string }[]): AppCategoryLink[] { + const published = new Set(pages.filter(page => page.is_published && page.page_family === "directory").map(page => page.slug)); + return categories.filter(category => category.count >= MIN_CATEGORY_APPS && published.has(`${directory}/categories/${category.slug}`)) + .map(category => ({ ...category, href: `/${directory}/categories/${category.slug}` })); +} + +export function appCategoryStaticParams(categories: readonly AppCategoryLink[]) { + return categories.map(category => ({ category: category.slug })); +} + +export function selectCategoryApps(apps: readonly T[], category: string, slugs: (app: T) => readonly string[], score: (app: T) => number): T[] { + return apps.filter(app => slugs(app).includes(category)).sort((a, b) => score(b) - score(a) || a.name.localeCompare(b.name)); +} diff --git a/lib/registered-site-pages.json b/lib/registered-site-pages.json index 4349fda5..41436474 100644 --- a/lib/registered-site-pages.json +++ b/lib/registered-site-pages.json @@ -22,6 +22,7 @@ "/chmod-calculator", "/churn-rate-converter", "/cli-apps", + "/cli-apps/categories/[category]", "/components-ui", "/components-ui/[section]", "/components-ui/charts", @@ -312,6 +313,7 @@ "/saas-gross-margin-calculator", "/saas-metrics-calculator", "/self-host-apps", + "/self-host-apps/categories/[category]", "/self-hosted-app-resource-planner", "/self-hosting-for-beginners", "/self-hosting-for-beginners/access-apps-remotely-with-tailscale", diff --git a/proxy.ts b/proxy.ts index 235658aa..7ef2b508 100644 --- a/proxy.ts +++ b/proxy.ts @@ -58,9 +58,9 @@ export async function proxy(request: NextRequest) { const tags = ["stack127-public", `page:${slug}`]; if (pathname === "/") tags.push("home"); if (pathname === "/sitemap.xml") tags.push("sitemap"); - if (pathname === "/self-host-apps" || pathname === "/self-hosted-app-resource-planner" || pathname === "/api/resource-planner-index" || pathname === "/api/self-host-apps/search") tags.push("self-host-catalog"); + if (pathname.startsWith("/self-host-apps/categories/") || pathname === "/self-host-apps" || pathname === "/self-hosted-app-resource-planner" || pathname === "/api/resource-planner-index" || pathname === "/api/self-host-apps/search") tags.push("self-host-catalog"); if ((pathname.startsWith("/api/self-host-apps/") && pathname !== "/api/self-host-apps/search") || pathname.startsWith("/api/resource-planner-app/")) tags.push("self-host-detail"); - if (pathname === "/cli-apps" || pathname === "/api/cli-apps/index") tags.push("cli-apps-catalog"); + if (pathname.startsWith("/cli-apps/categories/") || pathname === "/cli-apps" || pathname === "/api/cli-apps/index") tags.push("cli-apps-catalog"); if (pathname === "/platform-fees" || pathname.startsWith("/platform-fees/")) tags.push("platform-fees-catalog"); if (pathname === "/ad-networks" || pathname.startsWith("/ad-networks/") || pathname.startsWith("/api/ad-networks/") || pathname === "/ad-network-checker") tags.push("ad-networks-catalog"); if (pathname === "/keyboard-shortcuts" || pathname.startsWith("/keyboard-shortcuts/")) tags.push("keyboard-shortcuts-catalog"); diff --git a/scripts/stack127/app-categories/categories.mjs b/scripts/stack127/app-categories/categories.mjs new file mode 100644 index 00000000..ac0d106e --- /dev/null +++ b/scripts/stack127/app-categories/categories.mjs @@ -0,0 +1,20 @@ +import { directoryTitleParts } from "../../../lib/directory-page-content.ts"; +import { terminalAppCategories } from '../../../lib/terminal-app-taxonomy.ts'; +export async function readCategories(client, directory) { + if (!['self-host-apps', 'cli-apps'].includes(directory)) throw new Error('Use --directory self-host-apps|cli-apps'); + if (directory === 'self-host-apps') return (await client.query(`select category.slug,category.name,count(app.id)::int as count + from public.self_host_app_categories category left join public.self_host_app_category_links link on link.category_id=category.id + left join public.self_host_apps app on app.id=link.app_id and app.published and app.self_hosting_status='self_hostable' + group by category.id order by category.slug`)).rows; + const counts = new Map((await client.query(`select category,count(*)::int as count from public.terminal_apps app + cross join lateral unnest(app.categories) category where app.published group by category`)).rows.map(row => [row.category, row.count])); + return terminalAppCategories.map(category => ({ slug: category.slug, name: category.name, count: counts.get(category.slug) ?? 0 })); +} + +/** The H1 exists even when there are no published siblings to navigate to. */ +export function categorySmokeCheck(directory, category, title) { + // The publisher's HTML text reader inserts spaces at element boundaries. + const text = directoryTitleParts(title).map(part => part.text).join(' ').replace(/\s+/g, ' ').trim(); + if (!text.trim()) throw new Error(`Missing category heading: ${directory}/categories/${category}`); + return { path: `/${directory}/categories/${category}`, text }; +} diff --git a/scripts/stack127/app-categories/prepare-manifest.mjs b/scripts/stack127/app-categories/prepare-manifest.mjs new file mode 100644 index 00000000..8ac5d5af --- /dev/null +++ b/scripts/stack127/app-categories/prepare-manifest.mjs @@ -0,0 +1,37 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import pg from 'pg'; +import { parseArgs, requireArg, resolveDatabaseUrl } from '../tool-pages/lib.mjs'; +import { readCategories, categorySmokeCheck } from './categories.mjs'; +import { MIN_CATEGORY_APPS } from '../../../lib/app-category-model.ts'; +const args = parseArgs(); +for (const key of Object.keys(args)) if (!['directory','out','indexable','category'].includes(key)) throw new Error(`Unsupported --${key}`); +if (!['true','false'].includes(args.indexable)) throw new Error('Explicit --indexable true|false is required; true needs reviewed copy.'); +const directory = requireArg(args, 'directory'); +const client = new pg.Client({ connectionString: resolveDatabaseUrl({ environment: 'development' }), options: '-c default_transaction_read_only=on' }); +await client.connect(); +try { + const pages = []; + for (const category of await readCategories(client, directory)) { + if (category.count < MIN_CATEGORY_APPS || (args.category && category.slug !== args.category)) continue; + const route = `app/(site)/(directories)/${directory}/categories/[category]`; + const intro = `${route}/content/${category.slug}/intro.mdx`; + if (!existsSync(intro)) throw new Error(`Missing intro: ${intro}`); + const slug = `${directory}/categories/${category.slug}`; + const page = (await client.query(`select content.title from public.stack127_directory_pages content + join public.stack127_pages page on page.id=content.page_id where page.slug=$1 and page.page_family='directory'`, [slug])).rows[0]; + if (!page) throw new Error(`Missing development category page: ${slug}`); + pages.push({ slug: `${directory}/categories/${category.slug}`, family: 'directory', source: 'development', + registry: { is_published: true, is_indexable: args.indexable === 'true' }, + touch_reason: 'Category introduction and complete app list', + requirements: { route: `${route}/page.tsx`, assets: [intro], records: { kind: 'database', checks: [{ + name: 'Category has at least 15 published apps', sql_file: 'scripts/stack127/app-categories/records-ready.sql', params: [directory, category.slug], + }] }, smoke: [categorySmokeCheck(directory, category.slug, page.title)] }, + }); + } + if (!pages.length) throw new Error('No eligible categories selected'); + const out = requireArg(args, 'out'); await mkdir(path.dirname(out), { recursive: true }); + await writeFile(out, JSON.stringify({ schema_version: 1, kind: 'directories', pages }, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); + console.log(JSON.stringify({ manifest: out, pages: pages.length, indexable: args.indexable })); +} finally { await client.end(); } diff --git a/scripts/stack127/app-categories/records-ready.sql b/scripts/stack127/app-categories/records-ready.sql new file mode 100644 index 00000000..f4c77f36 --- /dev/null +++ b/scripts/stack127/app-categories/records-ready.sql @@ -0,0 +1,8 @@ +select case when $1::text = 'self-host-apps' then ( + select count(*) >= 15 from public.self_host_apps app + where app.published and app.self_hosting_status='self_hostable' and exists ( + select 1 from public.self_host_app_category_links link join public.self_host_app_categories category on category.id=link.category_id + where link.app_id=app.id and category.slug=$2::text) +) when $1::text = 'cli-apps' then ( + select count(*) >= 15 from public.terminal_apps app where app.published and $2::text=any(app.categories) +) else false end as ready; diff --git a/scripts/stack127/app-categories/sync-pages.mjs b/scripts/stack127/app-categories/sync-pages.mjs new file mode 100644 index 00000000..ec2c0737 --- /dev/null +++ b/scripts/stack127/app-categories/sync-pages.mjs @@ -0,0 +1,27 @@ +// Insert missing drafts only. Copy and publication belong to the directory publisher. +import { existsSync } from 'node:fs'; +import { parseArgs, requireArg, runMutation, printResult } from '../tool-pages/lib.mjs'; +import { readCategories } from './categories.mjs'; +import { MIN_CATEGORY_APPS } from '../../../lib/app-category-model.ts'; +const args = parseArgs(); +for (const key of Object.keys(args)) if (!['directory','environment','apply'].includes(key)) throw new Error(`Unsupported --${key}`); +if (args.environment && args.environment !== 'development') throw new Error('Draft sync writes development only; use the publisher for production.'); +const directory = requireArg(args, 'directory'); +printResult(await runMutation(args, async client => { + const categories = await readCategories(client, directory); + const rows = []; + for (const category of categories) { + if (category.count < MIN_CATEGORY_APPS) continue; + const slug = `${directory}/categories/${category.slug}`; + const title = `${category.name} ${directory === 'cli-apps' ? 'CLI apps' : 'self-host apps'}`; + await client.query(`insert into public.stack127_pages(slug,page_family,label,meta_title,meta_description,is_published,is_indexable,page_type) + values($1,'directory',$2,$2,$3,false,false,'CollectionPage') on conflict(slug) do nothing`, + [slug, title, `Apps in the ${category.name} category of the ${directory} directory.`]); + const page = (await client.query('select id,page_family,is_published,is_indexable from public.stack127_pages where slug=$1', [slug])).rows[0]; + if (page.page_family !== 'directory') throw new Error(`Wrong page family: ${slug}`); + await client.query(`insert into public.stack127_directory_pages(page_id,title,card_name,card_description) + values($1,$2,$3,$4) on conflict(page_id) do nothing`, [page.id, `${category.name} ${directory === 'cli-apps' ? 'CLI apps' : 'self-host apps'}`, category.name, `Apps in the ${category.name} category.`]); + rows.push({ slug, published: page.is_published, indexable: page.is_indexable, count: category.count }); + } + return { directory, categories, rows, missing_intros: categories.filter(category => !existsSync(`app/(site)/(directories)/${directory}/categories/[category]/content/${category.slug}/intro.mdx`)).map(category => category.slug) }; +})); diff --git a/scripts/stack127/app-categories/verify-app-role.sql b/scripts/stack127/app-categories/verify-app-role.sql new file mode 100644 index 00000000..3beb5bd2 --- /dev/null +++ b/scripts/stack127/app-categories/verify-app-role.sql @@ -0,0 +1,53 @@ +-- Run after release through an administrator connection on the self-hosted DB. +-- Every write is rolled back. ON_ERROR_STOP is supplied by the psql command. +BEGIN; +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '60s'; +SET LOCAL ROLE stack127_app; + +DO $$ +DECLARE signature text; +BEGIN + IF current_user <> 'stack127_app' THEN RAISE EXCEPTION 'Wrong application role'; END IF; + FOREACH signature IN ARRAY ARRAY[ + 'public.stack127_app_category_count(text)', + 'public.stack127_check_app_category_publication()', + 'public.stack127_remove_published_app_category_links()', + 'public.stack127_touch_directory_records()' + ] LOOP + IF NOT has_function_privilege(current_user,signature,'EXECUTE') THEN + RAISE EXCEPTION 'Missing EXECUTE on %',signature; + END IF; + END LOOP; +END; +$$; + +SELECT current_user, + public.stack127_app_category_count('cli-apps/categories/development') AS cli_apps, + public.stack127_app_category_count('self-host-apps/categories/ai-ml') AS self_host_apps; + +-- Exercise actual sync-trigger paths with a real visible change, not a no-op. +-- A check that touched no row proves nothing, so the app updates must each hit one. +DO $$ +DECLARE touched integer; +BEGIN + UPDATE public.self_host_apps SET summary=summary || ' [role verification]' + WHERE id=(SELECT id FROM public.self_host_apps WHERE published AND self_hosting_status='self_hostable' LIMIT 1); + GET DIAGNOSTICS touched = ROW_COUNT; + IF touched <> 1 THEN RAISE EXCEPTION 'Self-host sync trigger was not exercised: % rows',touched; END IF; + + UPDATE public.terminal_apps SET summary=summary || ' [role verification]' + WHERE slug=(SELECT slug FROM public.terminal_apps WHERE published LIMIT 1); + GET DIAGNOSTICS touched = ROW_COUNT; + IF touched <> 1 THEN RAISE EXCEPTION 'CLI sync trigger was not exercised: % rows',touched; END IF; + + -- Fires the publication guard and its count call. Before the CLI categories + -- are published this row does not exist, so report the count instead of failing. + UPDATE public.stack127_pages SET is_published=is_published + WHERE slug='cli-apps/categories/development'; + GET DIAGNOSTICS touched = ROW_COUNT; + RAISE NOTICE 'Publication guard rows exercised: %',touched; +END; +$$; + +ROLLBACK; diff --git a/scripts/stack127/cache/targets.mjs b/scripts/stack127/cache/targets.mjs index e736858e..73e15052 100644 --- a/scripts/stack127/cache/targets.mjs +++ b/scripts/stack127/cache/targets.mjs @@ -52,6 +52,14 @@ export function targetsForEvents(events, catalogSlugs = [], toolSlugs = []) { addPage("/api/ad-networks/sites", false); addPage("/api/ad-networks/site", false); } + if (slug.startsWith("self-host-apps/categories/")) { + addPage("/self-host-apps"); + edgeTags.add("self-host-catalog"); + } + if (slug.startsWith("cli-apps/categories/")) { + addPage("/cli-apps"); + edgeTags.add("cli-apps-catalog"); + } if (slug === "cli-apps") { addPage("/api/cli-apps/index", false); edgeTags.add("cli-apps-catalog"); diff --git a/scripts/stack127/publishing/database.mjs b/scripts/stack127/publishing/database.mjs index d577b9ec..8e4142ba 100644 --- a/scripts/stack127/publishing/database.mjs +++ b/scripts/stack127/publishing/database.mjs @@ -113,6 +113,8 @@ function batchEntries(entries, restore) { } function parentSlug(slug) { + // categories is a path segment, not a separately published landing page. + if (/^(self-host-apps|cli-apps)\/categories\/[^/]+$/.test(slug)) return slug.split("/")[0]; return slug.includes("/") ? slug.slice(0, slug.lastIndexOf("/")) : null; } diff --git a/stack127_docs/caching.md b/stack127_docs/caching.md index cd98ef16..54ff71b3 100644 --- a/stack127_docs/caching.md +++ b/stack127_docs/caching.md @@ -20,7 +20,7 @@ Shared links prefetch a page only when a reader shows intent, such as hovering o ## Indexing - A URL with a query string (`?…`) is never indexed. This includes search, filters, sort, tool state, and `?page=`. The proxy sends `X-Robots-Tag: noindex, follow` for these, and the canonical link points to the clean URL. On the static frontend, a Cloudflare response header rule sends the same header. The one exception is a query made only of tracking parameters, such as `utm_*`, `gclid`, or `ref`. It gets no noindex and relies on the canonical. `lib/seo/query-indexing.mjs` holds the list. -- A page we want indexed gets its own path, such as `/platform-fees/creators-and-communities` or later `/self-host-apps/categories/`. Its registry row must have both `is_published` and `is_indexable` set to `true`. +- A page we want indexed gets its own path, such as `/platform-fees/creators-and-communities`, `/self-host-apps/categories/`, or `/cli-apps/categories/`. Its registry row must have both `is_published` and `is_indexable` set to `true`. - A published row with `is_indexable = false` stays public, sends noindex metadata, omits JSON-LD, and stays out of the sitemap. Use this for thin child pages and pages still being improved. - See [seo.md](seo.md) for the registry and sitemap rules. @@ -49,6 +49,11 @@ Filters, search, sort, and pagination are a client component: Without JavaScript, visitors and crawlers see the default static page. +Self-host and CLI category pages have their own exact registry paths at `/self-host-apps/categories/` and `/cli-apps/categories/`. They list every category app in the server HTML, in the hub's default popularity order. They have no query-dependent server view or pagination. Only categories with at least 15 published apps can publish. `generateStaticParams` uses exact published registry children and current counts, including public noindex previews, so the static export contains the same pages as hub links. + +Category data uses the existing self-host and CLI source tags. The directory record trigger touches affected category children, including old memberships, and their hub. A category that drops below 15 apps is unpublished; growth does not republish it. Parent and child responses share `self-host-catalog` or `cli-apps-catalog` edge tags. A category page event also refreshes its hub links and purges the family, because sibling links show counts and publication. MDX intros ship with the release. + + ## Cache layers 1. **Cloudflare edge.** Caches successful HTML and RSC responses. The proxy adds a `Cache-Tag` header: `stack127-public`, `page:`, and family tags such as `home`, `sitemap`, `self-host-catalog`, and `self-host-detail`. API indexes share their directory's purge tags. Errors are never cached. diff --git a/stack127_docs/directory-pages.md b/stack127_docs/directory-pages.md index 53a1e8f9..4c97f0c6 100644 --- a/stack127_docs/directory-pages.md +++ b/stack127_docs/directory-pages.md @@ -26,13 +26,14 @@ A published directory page with no row returns 404. The directory publisher crea Every published child page gets its own row, for example `/ad-networks/` or `/keyboard-shortcuts/`. The row holds its title, search metadata, and dates, the same as a top-level directory. -Every child page also gets its own words, written for that page: an intro at least, and sections when the page needs them. They live in MDX, as [page-content.md](page-content.md) describes. A child page with its own route folder, such as `/keyboard-shortcuts/figma`, keeps them in `content/` beside its `page.tsx`. Platform fee categories share `platform-fees/[section]/`, so each category has a folder under that route's `content/`, named for its slug. +Every child page also gets its own words, written for that page: an intro at least, and sections when the page needs them. They live in MDX, as [page-content.md](page-content.md) describes. A child page with its own route folder, such as `/keyboard-shortcuts/figma`, keeps them in `content/` beside its `page.tsx`. Platform fee categories share `platform-fees/[section]/`. Self-host and CLI categories share `/categories/[category]/`. Each has a folder under that route's `content/`, named for its category slug, and an entry in `content/index.ts`. Record sync scripts own the records and the generated route files. They leave existing page words, titles, search metadata, and cards alone. They may insert a starting database row when one is missing. The keyboard shortcuts sync requires every app's `content/intro.mdx` before writing any files, including in `--check` mode. It passes that intro to the template; the template renders the print link. Write the title and search metadata with the directory writing skill, then publish them with `"source": "development"` in the manifest. See [page-publishing.md](page-publishing.md). | Directory | Record script | Page fields | | --- | --- | --- | | Keyboard shortcuts | `scripts/keyboard-shortcuts/sync-pages.mjs` | `--apply` inserts missing child rows, then syncs app records and the hub snapshot. Existing page fields stay as written. | +| App categories | `node --experimental-strip-types scripts/stack127/app-categories/sync-pages.mjs --directory self-host-apps` (or `cli-apps`) | `--environment development --apply` inserts missing unpublished, noindex rows only for categories with at least 15 apps. Existing page fields stay unchanged. Reports all category counts and missing intros. | | Platform fees | `scripts/platform-fees/sync-development.mjs`, `sync-records.mjs`, then `prepare-category-manifest.mjs` | Syncs records and inserts missing page rows. The manifest takes reviewed page fields from development. For records only, use [`sync-dataset.mjs`](../scripts/platform-fees/README.md): preview first, then apply with production confirmation; deletions need `--allow-delete`. | The managed-development hardware preview scripts, `scripts/stack127/preview-hardware-directories.mjs` and `scripts/zigbee-devices/preview-local-page.mjs`, insert starting rows only when missing. Existing titles, cards, and search fields stay as written. MDX owns the page words. Normal runs publish drafts; published pages stay unchanged. `--remove` unpublishes without deleting rows. Zigbee removal still requires the original preview metadata. Add `--dry-run` to preview either operation and roll back. The old hardware `--refresh` flag overwrote search fields; both scripts now reject it. Use the single-page editor below for reviewed changes. @@ -50,8 +51,8 @@ An MDX content change doesn't move the date by itself. When it matters to reader | Platform fees | Changed sections or entries touch the affected category and `/platform-fees`. Moving a section or entry touches both categories. | | Launch directories | Changed published listings touch `/launch-directories`. | | Ad networks | Changed network facts or confirmed publishers touch the network child and `/ad-networks`. Crawler leases and other bookkeeping do not. | -| Self-host apps | Changed published apps, category links, resource estimates, install options, or linked GitHub facts touch `/self-host-apps`. Registered app children also get touched. | -| CLI apps | Changed published terminal apps or their linked GitHub facts touch `/cli-apps`. Candidate discovery and provenance do not. | +| Self-host apps | Changed published apps, category links, resource estimates, install options, or linked GitHub facts touch `/self-host-apps`. Registered app children and affected category children also get touched. Category membership removals and app deletions refresh the former categories too. | +| CLI apps | Changed published terminal apps or their linked GitHub facts touch `/cli-apps`. Affected category children also get touched, including old and new memberships. Candidate discovery and provenance do not. | | Software licenses | Changes to published app license counts move the date. License facts in code need an explicit touch. | | Keyboard shortcuts | Copy syncs touch changed children and the hub. Full app records catch shortcut edits with the same count. A separate hub snapshot catches removed apps and changed catalog counts. The first run establishes a baseline. | @@ -121,3 +122,9 @@ node scripts/stack127/directory-pages/update-fields.mjs \ Its flags are `--title`, `--meta-title`, `--meta-description`, `--card-name`, `--card-description`, `--card-icon-key`, and `--card-art-key`. It rejects keys that aren't in the registries. A saved edit queues revalidation for that page's `stack127-directory-page:` tag and its path. For launch directories, `evidence.public_submission` holds checked free-plan requirements, backlink status, approval time, launch queue text, and dated observations. The server projects only validated public fields from that object. Signed-in and email observations describe our submission attempt, and stay separate from public rules. The free-without-required-backlink filter accepts a free record only when its backlink check says optional or not required. A record with no check, or a check that says required or unknown, stays out. Existing research notes remain private. Update these records through `scripts/stack127/database/update-launch-directories.mjs`, which checks the reviewed row version. + +## Self-host and CLI category pages + +The public paths are `/self-host-apps/categories/` and `/cli-apps/categories/`. Each child uses its own registry and directory row. The publisher treats the hub as its parent; `categories` is only a path segment. Only categories with at least 15 published apps can publish. If a published category drops below that number, the record trigger unpublishes it. A later increase does not publish it again. + +Starting intros live at `app/(site)/(directories)//categories/[category]/content//intro.mdx`. Replace them with reviewed copy. Every intro is imported by that route's `content/index.ts`; add an import there when adding a new category. Use the fields editor above for the reviewed title, search title, description, and card fields. The draft sync never changes those fields or publishes a row. See [page-publishing.md](page-publishing.md#app-category-publication) for preview and release commands. diff --git a/stack127_docs/page-publishing.md b/stack127_docs/page-publishing.md index f868fc2b..9888316c 100644 --- a/stack127_docs/page-publishing.md +++ b/stack127_docs/page-publishing.md @@ -322,3 +322,48 @@ These commands each write one kind of database field: | `node scripts/stack127/directory-pages/update-fields.mjs` | A directory's title, search metadata, and card fields | Use them in development and for a small fix to one live page. Use the publisher for a new page, a page going public, and any change that touches several tables at once. Page content changes go through a code release instead. + +## App category publication + +For `self-host-apps` and `cli-apps`, create missing development drafts with the shared category sync. It only inserts eligible categories with at least 15 apps and reports missing MDX intros. It never overwrites copy or publication. Run once per directory: + +```bash +node --experimental-strip-types scripts/stack127/app-categories/sync-pages.mjs --directory self-host-apps --environment development --apply +``` + +Replace the starting intro in `app/(site)/(directories)/self-host-apps/categories/[category]/content//intro.mdx` (or the equivalent `cli-apps` path). Save reviewed fields with: + +```bash +node scripts/stack127/directory-pages/update-fields.mjs --environment development \ + --slug self-host-apps/categories/ai-ml \ + --title 'Reviewed title' --meta-title 'Reviewed search title' \ + --meta-description 'Reviewed search description' \ + --card-name 'Reviewed category name' --card-description 'Reviewed card description' --apply +``` + +Prepare a manifest from development. `--indexable false` is required for unreviewed preview copy; use `true` only after copy review. The command includes all eligible categories, or one with `--category `. It takes titles and search fields from development, includes each intro as a required asset, and checks the target database has at least 15 apps per page. + +```bash +node --experimental-strip-types scripts/stack127/app-categories/prepare-manifest.mjs \ + --directory self-host-apps --indexable false --out tmp/page-publishing/app-categories/self-preview.json +npm run stack127:directories:publish -- prepare --environment development \ + --manifest tmp/page-publishing/app-categories/self-preview.json --out tmp/page-publishing/app-categories/self-plan-dev.json +npm run stack127:directories:publish -- preview --environment development --plan tmp/page-publishing/app-categories/self-plan-dev.json +npm run stack127:directories:publish -- apply --environment development --plan tmp/page-publishing/app-categories/self-plan-dev.json --site http://127.0.0.1:3333 +``` + +Repeat with `--directory cli-apps` and different manifest and plan filenames. The development server and cache watcher must use the same port as `--site`. To publish reviewed copy as indexable in development, generate a new manifest with `--indexable true`, then prepare, preview, and apply a new plan. + +After the code and migration are released, the coordinator prepares the production batch from the exact released checkout. Use a reviewed indexable manifest and new output filenames: + +```bash +node --experimental-strip-types scripts/stack127/app-categories/prepare-manifest.mjs \ + --directory self-host-apps --indexable true --out tmp/page-publishing/app-categories/self-reviewed.json +npm run stack127:directories:publish -- prepare --environment production \ + --manifest tmp/page-publishing/app-categories/self-reviewed.json --out tmp/page-publishing/app-categories/self-plan-prod.json +npm run stack127:directories:publish -- preview --environment production --plan tmp/page-publishing/app-categories/self-plan-prod.json +npm run stack127:directories:publish -- dispatch --environment production \ + --plan tmp/page-publishing/app-categories/self-plan-prod.json --confirm stack127-production --release-sha +``` + +Repeat for CLI categories. Check canonical, sitemap inclusion, breadcrumbs, complete server rows, and both hubs after publication. The production readiness check stops if a category lacks 15 published apps. An unpublished category must return 404. Never create a synthetic `/categories` registry page. diff --git a/stack127_docs/seo.md b/stack127_docs/seo.md index c3f0d5d4..dac2b429 100644 --- a/stack127_docs/seo.md +++ b/stack127_docs/seo.md @@ -55,7 +55,9 @@ Follow these route rules: | Exact published child slug in `stack127_pages` | Render; its `is_indexable` value controls indexing and sitemap inclusion | | Route outside a published root, for example `/random-test-page` | `404`, even if code exists | -Tracking-only URLs rely on their clean canonical, following [Google’s duplicate URL guidance](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls). They get no query-based `noindex` header. Search, filters, sort, pagination, tool state, unknown parameters, and any mix of those with tracking still get `noindex, follow`. Only clean published URLs enter the sitemap. To index a category or other view, give it its own path, such as `/self-host-apps/categories/`. Publish that exact slug. See [caching.md](caching.md) for why pages never read the query on the server. +Tracking-only URLs rely on their clean canonical, following [Google’s duplicate URL guidance](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls). They get no query-based `noindex` header. Search, filters, sort, pagination, tool state, unknown parameters, and any mix of those with tracking still get `noindex, follow`. Only clean published URLs enter the sitemap. To index a category or other view, give it its own path, such as `/self-host-apps/categories/` or `/cli-apps/categories/`. Publish that exact slug. See [caching.md](caching.md) for why pages never read the query on the server. + +Self-host and CLI category pages require at least 15 published apps. Each eligible child has its own directory row, reviewed search fields, and MDX intro. The hub and sibling links include only exact published categories. The `categories` segment has no landing page or registry row: breadcrumbs skip it and show home, the directory, and the category. Draft copy previews stay `is_indexable = false`; reviewed pages can become indexable through the directory publisher. Their canonical, sitemap entry, and breadcrumb JSON-LD all come from the shared registry helpers. ### Query parameter policy diff --git a/supabase/migrations/20261110120000_app_category_pages.sql b/supabase/migrations/20261110120000_app_category_pages.sql new file mode 100644 index 00000000..2b4df9d9 --- /dev/null +++ b/supabase/migrations/20261110120000_app_category_pages.sql @@ -0,0 +1,204 @@ +begin; +set local lock_timeout = '10s'; + +create function public.stack127_app_category_count(p_slug text) +returns bigint language sql stable security invoker set search_path=pg_catalog as $$ + select case when p_slug like 'self-host-apps/categories/%' then ( + select count(*) from public.self_host_apps app where app.published and app.self_hosting_status='self_hostable' and exists( + select 1 from public.self_host_app_category_links link join public.self_host_app_categories category on category.id=link.category_id + where link.app_id=app.id and category.slug=split_part(p_slug,'/',3)) + ) when p_slug like 'cli-apps/categories/%' then ( + select count(*) from public.terminal_apps app where app.published and split_part(p_slug,'/',3)=any(app.categories) + ) else 0 end +$$; +revoke all on function public.stack127_app_category_count(text) from public,anon,authenticated; +grant execute on function public.stack127_app_category_count(text) to service_role; + +create function public.stack127_check_app_category_publication() +returns trigger language plpgsql security invoker set search_path=pg_catalog as $$ +begin + if new.is_published and (new.slug like 'self-host-apps/categories/%' or new.slug like 'cli-apps/categories/%') + and public.stack127_app_category_count(new.slug) < 15 then + raise exception 'Category needs at least 15 published apps: %',new.slug; + end if; + return new; +end; +$$; +revoke all on function public.stack127_check_app_category_publication() from public,anon,authenticated; +create trigger stack127_check_app_category_publication before insert or update of is_published,slug +on public.stack127_pages for each row execute function public.stack127_check_app_category_publication(); + +create or replace function public.stack127_touch_directory_records() +returns trigger language plpgsql security invoker set search_path = pg_catalog as $$ +declare + changed jsonb[]; + value jsonb; + owner_slug text; + section_id text; + slugs text[] := '{}'; + entities text[] := '{}'; + entity_slugs text[] := '{}'; + idx integer; +begin + if tg_op = 'INSERT' then + execute format('select array_agg(value) from (select public.stack127_directory_record_value(%L,to_jsonb(r)) value from new_rows r) records where value is not null',tg_table_name) into changed; + elsif tg_op = 'DELETE' then + execute format('select array_agg(value) from (select public.stack127_directory_record_value(%L,to_jsonb(r)) value from old_rows r) records where value is not null',tg_table_name) into changed; + else + execute format('with before as (select public.stack127_directory_record_value(%L,to_jsonb(r)) value from old_rows r), + after as (select public.stack127_directory_record_value(%L,to_jsonb(r)) value from new_rows r), + changes as ((select value from before except select value from after) union (select value from after except select value from before)) + select array_agg(value) from changes where value is not null',tg_table_name,tg_table_name) into changed; + end if; + if changed is null then return null; end if; + foreach value in array changed loop + case tg_table_name + when 'platform_fee_sections','platform_fee_entries' then + section_id := case when tg_table_name='platform_fee_sections' then value->>'id' else value->>'section_id' end; + if tg_table_name='platform_fee_sections' then + owner_slug := public.stack127_platform_fee_group_slug(value->>'group_name'); + else + select public.stack127_platform_fee_group_slug(group_name) into owner_slug from public.platform_fee_sections where id=section_id; + end if; + slugs := slugs || array['platform-fees','platform-fees/' || owner_slug]; + entities := array_append(entities,'platform_fees'); entity_slugs := array_append(entity_slugs,section_id); + when 'ad_networks','ad_network_publisher_relationships','ad_network_publishers' then + if tg_table_name <> 'ad_network_publishers' or exists ( + select 1 from public.ad_network_publisher_relationships rel where rel.publisher_id=(value->>'id')::bigint and rel.status='confirmed' + ) then slugs := array_append(slugs,'ad-networks'); end if; + if tg_table_name='ad_networks' then + owner_slug := value->>'slug'; + slugs := array_append(slugs,'ad-networks/' || owner_slug); + entities := array_append(entities,'ad_networks'); entity_slugs := array_append(entity_slugs,owner_slug); + else + for owner_slug in + select network.slug from public.ad_networks network where + (tg_table_name='ad_network_publisher_relationships' and network.id=(value->>'ad_network_id')::bigint) or + (tg_table_name='ad_network_publishers' and exists(select 1 from public.ad_network_publisher_relationships rel + where rel.ad_network_id=network.id and rel.publisher_id=(value->>'id')::bigint and rel.status='confirmed')) + loop + slugs := array_append(slugs,'ad-networks/' || owner_slug); + entities := array_append(entities,'ad_networks'); entity_slugs := array_append(entity_slugs,owner_slug); + end loop; + end if; + when 'launch_directories' then + slugs := array_append(slugs,'launch-directories'); + entities := array_append(entities,'launch_directories'); entity_slugs := array_append(entity_slugs,'launch-directories'); + when 'terminal_apps' then + slugs := array_append(slugs,'cli-apps'); + entities := array_append(entities,'cli_apps'); entity_slugs := array_append(entity_slugs,'cli-apps'); + when 'github_projects' then + if exists(select 1 from public.self_host_apps app where app.github_project_id=(value->>'id')::uuid and app.published and app.self_hosting_status='self_hostable') then + slugs := array_append(slugs,'self-host-apps'); + slugs := slugs || coalesce((select array_agg('self-host-apps/' || app.slug) from public.self_host_apps app + where app.published and app.self_hosting_status='self_hostable' and ( + (tg_table_name='github_projects' and app.github_project_id=(value->>'id')::uuid) or + (tg_table_name='self_host_app_categories' and exists(select 1 from public.self_host_app_category_links link + where link.app_id=app.id and link.category_id=(value->>'id')::uuid)) + )), '{}'::text[]); + entities := array_append(entities,'self_host_catalog'); entity_slugs := array_append(entity_slugs,'all'); + end if; + if exists(select 1 from public.terminal_apps app where app.github_project_id=(value->>'id')::uuid and app.published) then + slugs := array_append(slugs,'cli-apps'); + entities := array_append(entities,'cli_apps'); entity_slugs := array_append(entity_slugs,'cli-apps'); + end if; + when 'self_host_app_categories' then + if exists(select 1 from public.self_host_app_category_links link join public.self_host_apps app on app.id=link.app_id + where link.category_id=(value->>'id')::uuid and app.published and app.self_hosting_status='self_hostable') then + slugs := array_append(slugs,'self-host-apps'); + slugs := slugs || coalesce((select array_agg('self-host-apps/' || app.slug) from public.self_host_apps app + where app.published and app.self_hosting_status='self_hostable' and ( + (tg_table_name='github_projects' and app.github_project_id=(value->>'id')::uuid) or + (tg_table_name='self_host_app_categories' and exists(select 1 from public.self_host_app_category_links link + where link.app_id=app.id and link.category_id=(value->>'id')::uuid)) + )), '{}'::text[]); + entities := array_append(entities,'self_host_catalog'); entity_slugs := array_append(entity_slugs,'all'); + end if; + else + if tg_table_name='self_host_apps' then owner_slug := value->>'slug'; + else + select app.slug into owner_slug from public.self_host_apps app where app.id=(value->>'app_id')::uuid + and app.published and app.self_hosting_status='self_hostable'; + end if; + if owner_slug is not null then + slugs := slugs || array['self-host-apps','self-host-apps/' || owner_slug]; + entities := array_append(entities,'self_host_app'); entity_slugs := array_append(entity_slugs,owner_slug); + end if; + end case; + + -- Both transition snapshots preserve removed memberships and old category slugs. + if tg_table_name = 'terminal_apps' then + slugs := slugs || coalesce((select array_agg('cli-apps/categories/' || category) + from jsonb_array_elements_text(value->'categories') category), '{}'::text[]); + elsif tg_table_name = 'github_projects' then + slugs := slugs || coalesce((select array_agg(distinct 'cli-apps/categories/' || category) + from public.terminal_apps app cross join lateral unnest(app.categories) category + where app.published and app.github_project_id=(value->>'id')::uuid), '{}'::text[]); + end if; + if tg_table_name = 'self_host_app_categories' then + slugs := array_append(slugs, 'self-host-apps/categories/' || (value->>'slug')); + elsif tg_table_name = 'self_host_app_category_links' then + slugs := slugs || coalesce((select array_agg('self-host-apps/categories/' || category.slug) + from public.self_host_app_categories category where category.id=(value->>'category_id')::uuid + and exists(select 1 from public.self_host_apps app where app.id=(value->>'app_id')::uuid + and app.published and app.self_hosting_status='self_hostable')), '{}'::text[]); + elsif tg_table_name in ('self_host_apps','self_host_app_resource_estimates','self_host_app_install_options','github_projects') then + slugs := slugs || coalesce((select array_agg(distinct 'self-host-apps/categories/' || category.slug) + from public.self_host_app_categories category join public.self_host_app_category_links link on link.category_id=category.id + where (tg_table_name='self_host_apps' and link.app_id=(value->>'id')::uuid) + or (tg_table_name in ('self_host_app_resource_estimates','self_host_app_install_options') and link.app_id=(value->>'app_id')::uuid + and exists(select 1 from public.self_host_apps app where app.id=link.app_id and app.published and app.self_hosting_status='self_hostable')) + or (tg_table_name='github_projects' and exists(select 1 from public.self_host_apps app where app.id=link.app_id + and app.published and app.self_hosting_status='self_hostable' and app.github_project_id=(value->>'id')::uuid))), '{}'::text[]); + end if; + end loop; + for owner_slug in select distinct unnest(slugs) order by 1 loop + if owner_slug like 'self-host-apps/categories/%' or owner_slug like 'cli-apps/categories/%' then + -- Drafts and absent rows need neither retirement counts nor page touches. + if not exists(select 1 from public.stack127_pages where slug=owner_slug and is_published) then + continue; + end if; + if public.stack127_app_category_count(owner_slug) < 15 then + -- Falling below the threshold retires a page; growth never auto-publishes it. + update public.stack127_pages set is_published=false,published_at=null,updated_at=null where slug=owner_slug and is_published; + end if; + end if; + perform public.stack127_touch_page(owner_slug,tg_table_name); + end loop; + for idx in select distinct on (entities[n],entity_slugs[n]) n from generate_subscripts(entities,1) n loop + perform public.stack127_enqueue_revalidation(entities[idx],entity_slugs[idx],tg_table_name); + end loop; + return null; +end; +$$; + +-- Remove links while a deleted published app is still visible to the statement trigger. +-- FK cascades alone run after the owner is gone and lose its publication state. +create function public.stack127_remove_published_app_category_links() +returns trigger language plpgsql security invoker set search_path=pg_catalog as $$ +begin + if old.published and old.self_hosting_status='self_hostable' then + delete from public.self_host_app_category_links where app_id=old.id; + end if; + return old; +end; +$$; +revoke all on function public.stack127_remove_published_app_category_links() from public,anon,authenticated; +create trigger stack127_remove_published_app_category_links before delete on public.self_host_apps +for each row execute function public.stack127_remove_published_app_category_links(); +-- app-role.sql grants existing functions during self-host bootstrap, not future ones. +-- Trigger execution itself does not check EXECUTE, but grant all new functions +-- explicitly so both application roles also have the same callable surface. +grant execute on function public.stack127_check_app_category_publication(), + public.stack127_remove_published_app_category_links() to service_role; +do $$ +begin + if exists(select 1 from pg_catalog.pg_roles where rolname='stack127_app') then + grant execute on function public.stack127_app_category_count(text), + public.stack127_check_app_category_publication(), + public.stack127_remove_published_app_category_links(), + public.stack127_touch_directory_records() to stack127_app; + end if; +end; +$$; +commit; diff --git a/tests/stack127/app-categories-database.test.mjs b/tests/stack127/app-categories-database.test.mjs new file mode 100644 index 00000000..357f5073 --- /dev/null +++ b/tests/stack127/app-categories-database.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import pg from 'pg'; +import { resolveDatabaseUrl } from '../../scripts/stack127/tool-pages/lib.mjs'; +const enabled = process.env.APP_CATEGORY_DATABASE_TESTS === '1'; +async function transaction(work) { + const db = new pg.Client({ connectionString: resolveDatabaseUrl({ environment: 'development' }), query_timeout: 60000 }); + await db.connect(); + try { await db.query('begin'); await work(db); } finally { await db.query('rollback'); await db.end(); } +} +const readPage = async (db, slug) => (await db.query('select is_published,updated_at::text from public.stack127_pages where slug=$1', [slug])).rows[0]; +const reset = async (db, slug) => db.query("update public.stack127_pages set published_at='2000-01-01',updated_at='2000-01-01' where slug=$1", [slug]); + +test('published self-host facts, category moves, and deletion touch their category pages; no-op leaves dates unchanged', { skip: !enabled }, async () => transaction(async db => { + const category = 'self-host-apps/categories/learning-courses'; + const app = (await db.query(`select app.id from public.self_host_apps app join public.self_host_app_category_links link on link.app_id=app.id + join public.self_host_app_categories category on category.id=link.category_id where app.published and app.self_hosting_status='self_hostable' + and category.slug='learning-courses' limit 1`)).rows[0]; + assert.ok(app); + await reset(db, category); + const before = await readPage(db, category); + await db.query('update public.self_host_apps set summary=summary where id=$1', [app.id]); + assert.equal((await readPage(db, category)).updated_at, before.updated_at); + await db.query("update public.self_host_apps set summary=summary || ' Test.' where id=$1", [app.id]); + assert.notEqual((await readPage(db, category)).updated_at, before.updated_at); + await reset(db, category); + await db.query('delete from public.self_host_apps where id=$1', [app.id]); + assert.notEqual((await readPage(db, category)).updated_at, before.updated_at); +})); + +test('old and new CLI memberships refresh, and dropping below 15 retires the page', { skip: !enabled }, async () => transaction(async db => { + const oldSlug = 'cli-apps/categories/media-graphics'; + const nextSlug = 'cli-apps/categories/development'; + const apps = (await db.query("select slug from public.terminal_apps where published and 'media-graphics'=any(categories) limit 2")).rows; + assert.equal(apps.length, 2); + await reset(db, oldSlug); await reset(db, nextSlug); + const before = await readPage(db, oldSlug); + const nextBefore = await readPage(db, nextSlug); + await db.query("update public.terminal_apps set categories=array_remove(categories,'media-graphics') || array['development'] where slug=$1", [apps[0].slug]); + assert.notEqual((await readPage(db, oldSlug)).updated_at, before.updated_at); + assert.notEqual((await readPage(db, nextSlug)).updated_at, nextBefore.updated_at); + assert.equal((await readPage(db, oldSlug)).is_published, true); + await db.query('update public.terminal_apps set published=false where slug=$1', [apps[1].slug]); + assert.equal((await readPage(db, oldSlug)).is_published, false); + await assert.rejects(db.query('update public.stack127_pages set is_published=true where slug=$1', [oldSlug]), /at least 15/); +})); + +test('self-host link moves, RAM facts, and linked GitHub facts use the existing category refresh pipeline', { skip: !enabled }, async () => transaction(async db => { + const slug = 'self-host-apps/categories/ai-ml'; + const app = (await db.query(`select a.id,a.github_project_id,l.category_id from public.self_host_apps a + join public.self_host_app_category_links l on l.app_id=a.id join public.self_host_app_categories c on c.id=l.category_id + where a.published and a.self_hosting_status='self_hostable' and c.slug='ai-ml' and a.github_project_id is not null limit 1`)).rows[0]; + assert.ok(app); + await reset(db, slug); const before = await readPage(db, slug); + await db.query('update public.self_host_apps set ram_min_mb=coalesce(ram_min_mb,0)+1 where id=$1', [app.id]); + assert.notEqual((await readPage(db, slug)).updated_at, before.updated_at); + await reset(db, slug); + await db.query('update public.github_projects set stars=coalesce(stars,0)+1 where id=$1', [app.github_project_id]); + assert.notEqual((await readPage(db, slug)).updated_at, before.updated_at); + await reset(db, slug); + await db.query('delete from public.self_host_app_category_links where app_id=$1 and category_id=$2', [app.id,app.category_id]); + assert.notEqual((await readPage(db, slug)).updated_at, before.updated_at); +})); + +for (const state of ['absent', 'unpublished']) { + test(`self-host sync skips retirement counts with ${state} category rows`, { skip: !enabled }, async () => transaction(async db => { + if (state === 'absent') { + await db.query("delete from public.stack127_pages where slug like 'self-host-apps/categories/%'"); + } else { + await db.query("update public.stack127_pages set is_published=false,published_at=null,updated_at=null where slug like 'self-host-apps/categories/%'"); + } + // Fail if any retirement count runs. Everything, including this replacement, + // rolls back; this models production with no published self-host categories. + await db.query(`create or replace function public.stack127_app_category_count(p_slug text) + returns bigint language plpgsql stable security invoker set search_path=pg_catalog as $$ + begin raise exception 'Unexpected category count: %',p_slug; end; $$`); + await db.query(`update public.self_host_apps set summary=summary || ' Test.' where id=( + select id from public.self_host_apps where published and self_hosting_status='self_hostable' limit 1)`); + })); +} + +test('application function grants include all newly created category functions', { skip: !enabled }, async () => transaction(async db => { + const functions = ['stack127_app_category_count(text)', 'stack127_check_app_category_publication()', + 'stack127_remove_published_app_category_links()', 'stack127_touch_directory_records()']; + const roles = ['service_role']; + if ((await db.query("select 1 from pg_catalog.pg_roles where rolname='stack127_app'")).rowCount) roles.push('stack127_app'); + for (const role of roles) for (const fn of functions) { + const result = await db.query('select has_function_privilege($1,$2,\'EXECUTE\') as allowed', [role, `public.${fn}`]); + assert.equal(result.rows[0].allowed, true, `${role}: ${fn}`); + } +})); diff --git a/tests/stack127/app-categories.test.mjs b/tests/stack127/app-categories.test.mjs new file mode 100644 index 00000000..08b3a3d4 --- /dev/null +++ b/tests/stack127/app-categories.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { countAppCategories, publishedAppCategories, appCategoryStaticParams, selectCategoryApps } from '../../lib/app-category-model.ts'; +import { targetsForEvents } from '../../scripts/stack127/cache/targets.mjs'; + +test('multi-category counts count each app once, preserve zeroes, and include all records', () => { + const apps = Array.from({ length: 16 }, (_, i) => ({ categories: i ? ['large'] : ['large', 'small', 'small'] })); + assert.deepEqual(countAppCategories(apps, [{ slug: 'large', name: 'Large' }, { slug: 'small', name: 'Small' }, { slug: 'empty', name: 'Empty' }], app => app.categories), [ + { slug: 'large', name: 'Large', count: 16 }, { slug: 'small', name: 'Small', count: 1 }, { slug: 'empty', name: 'Empty', count: 0 }, + ]); +}); + +test('only exact published directory children at the 15-app threshold get links and static params, including noindex previews', () => { + const categories = ['draft','small','ready','noindex','wrong-family','inherited'].map(slug => ({ slug, name: slug, count: slug === 'small' ? 14 : 15 })); + const pages = ['draft','small','ready','noindex','wrong-family'].map(slug => ({ slug: `cli-apps/categories/${slug}`, is_published: slug !== 'draft', is_indexable: slug !== 'noindex', page_family: slug === 'wrong-family' ? 'tool' : 'directory' })); + pages.push({ slug: 'cli-apps', is_published: true, page_family: 'directory' }); + const links = publishedAppCategories('cli-apps', categories, pages); + assert.deepEqual(links.map(entry => entry.href), ['/cli-apps/categories/ready','/cli-apps/categories/noindex']); + assert.deepEqual(appCategoryStaticParams(links), [{ category: 'ready' }, { category: 'noindex' }]); +}); + +test('a complete category keeps the hub default popularity order, name ties, null scores, and multiple memberships', () => { + const apps = [{ name: 'Z', categories: ['other','wanted'], score: 8 }, { name: 'A', categories: ['wanted'], score: 8 }, + { name: 'B', categories: ['wanted'], score: null }, { name: 'C', categories: ['other'], score: 100 }]; + assert.deepEqual(selectCategoryApps(apps, 'wanted', app => app.categories, app => app.score ?? 0).map(app => app.name), ['A','Z','B']); + assert.equal(apps[0].name, 'Z'); + assert.equal(selectCategoryApps(Array.from({ length: 1201 }, (_, i) => ({ name: String(i), categories: ['wanted'], score: i })), 'wanted', app => app.categories, app => app.score).length, 1201); +}); + +test('category publication changes refresh hub links, registry, sitemap, and family purge tags', () => { + for (const directory of ['self-host-apps','cli-apps']) { + const result = targetsForEvents([{ entity_type: 'page', slug: `${directory}/categories/example` }]); + assert.ok(result.paths.includes(`/${directory}`)); + assert.ok(result.paths.includes(`/${directory}/categories/example`)); + assert.ok(result.paths.includes('/sitemap.xml')); + assert.ok(result.edgeTags.includes(directory === 'cli-apps' ? 'cli-apps-catalog' : 'self-host-catalog')); + assert.ok(result.nextTags.includes('stack127-home-pages')); + } +}); + +test('compact server rows escape dataset text and reject executable outbound URLs', async () => { + const { categoryListHtml } = await import('../../lib/app-category-html.ts'); + const html = categoryListHtml([{ name: '', summary: '', facts: 'A&B', href: 'javascript:alert(1)' }, + { name: 'Safe', summary: 'Summary', facts: 'License', href: 'https://example.com/?a="&b=' }]); + assert.ok(!html.includes('