From d652e66b3c765929e81136401db9b2f45bd4174c Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Tue, 10 Nov 2026 13:08:01 +0530 Subject: [PATCH 1/4] Add static app category directory pages and development draft workflow --- .../[category]/content/ai/intro.mdx | 1 + .../content/communication/intro.mdx | 1 + .../[category]/content/databases/intro.mdx | 1 + .../[category]/content/development/intro.mdx | 1 + .../[category]/content/devops/intro.mdx | 1 + .../[category]/content/files/intro.mdx | 1 + .../[category]/content/games-fun/intro.mdx | 1 + .../categories/[category]/content/index.ts | 32 +++ .../content/media-graphics/intro.mdx | 1 + .../[category]/content/monitoring/intro.mdx | 1 + .../[category]/content/networking/intro.mdx | 1 + .../[category]/content/productivity/intro.mdx | 1 + .../[category]/content/security/intro.mdx | 1 + .../[category]/content/terminal-ui/intro.mdx | 1 + .../[category]/content/text/intro.mdx | 1 + .../cli-apps/categories/[category]/layout.tsx | 7 + .../cli-apps/categories/[category]/page.tsx | 29 +++ app/(site)/(directories)/cli-apps/layout.tsx | 2 +- app/(site)/(directories)/cli-apps/page.tsx | 7 + .../[category]/content/ai-ml/intro.mdx | 1 + .../[category]/content/automation/intro.mdx | 1 + .../content/bookmarks-read-it-later/intro.mdx | 1 + .../content/business-commerce/intro.mdx | 1 + .../content/calendars-contacts/intro.mdx | 1 + .../content/communication/intro.mdx | 1 + .../content/communities-social/intro.mdx | 1 + .../content/content-publishing/intro.mdx | 1 + .../[category]/content/dashboards/intro.mdx | 1 + .../[category]/content/databases/intro.mdx | 1 + .../content/design-creative/intro.mdx | 1 + .../content/developer-tools/intro.mdx | 1 + .../content/documents-pdfs/intro.mdx | 1 + .../content/ebooks-libraries/intro.mdx | 1 + .../content/email-newsletters/intro.mdx | 1 + .../content/file-sharing-sync/intro.mdx | 1 + .../content/finance-budgeting/intro.mdx | 1 + .../content/forms-polls-events/intro.mdx | 1 + .../[category]/content/games/intro.mdx | 1 + .../content/health-fitness/intro.mdx | 1 + .../content/home-automation-iot/intro.mdx | 1 + .../content/identity-access/intro.mdx | 1 + .../categories/[category]/content/index.ts | 88 +++++++++ .../content/knowledge-notes/intro.mdx | 1 + .../content/learning-courses/intro.mdx | 1 + .../[category]/content/maps-travel/intro.mdx | 1 + .../content/media-device-control/intro.mdx | 1 + .../content/media-servers/intro.mdx | 1 + .../content/monitoring-status/intro.mdx | 1 + .../[category]/content/music-audio/intro.mdx | 1 + .../content/networking-dns/intro.mdx | 1 + .../content/password-managers/intro.mdx | 1 + .../content/photos-galleries/intro.mdx | 1 + .../content/project-task-management/intro.mdx | 1 + .../[category]/content/recipes-food/intro.mdx | 1 + .../content/remote-access/intro.mdx | 1 + .../[category]/content/search/intro.mdx | 1 + .../content/security-privacy/intro.mdx | 1 + .../content/storage-backup/intro.mdx | 1 + .../content/system-admin-hosting/intro.mdx | 1 + .../content/time-tracking/intro.mdx | 1 + .../content/video-conferencing/intro.mdx | 1 + .../[category]/content/wikis/intro.mdx | 1 + .../categories/[category]/layout.tsx | 7 + .../categories/[category]/page.tsx | 29 +++ .../(directories)/self-host-apps/layout.tsx | 2 +- .../(directories)/self-host-apps/page.tsx | 7 + app/ui/directories/app-category-links.tsx | 13 ++ app/ui/directories/app-category-page.tsx | 35 ++++ app/ui/directories/app-category.module.css | 10 + lib/app-categories.ts | 45 +++++ lib/app-category-html.ts | 18 ++ lib/app-category-model.ts | 26 +++ lib/registered-site-pages.json | 2 + proxy.ts | 4 +- .../stack127/app-categories/categories.mjs | 11 ++ .../app-categories/prepare-manifest.mjs | 33 ++++ .../stack127/app-categories/records-ready.sql | 8 + .../stack127/app-categories/sync-pages.mjs | 27 +++ scripts/stack127/cache/targets.mjs | 8 + scripts/stack127/publishing/database.mjs | 2 + stack127_docs/caching.md | 7 +- stack127_docs/directory-pages.md | 13 +- stack127_docs/page-publishing.md | 45 +++++ stack127_docs/seo.md | 4 +- .../20261110120000_app_category_pages.sql | 185 ++++++++++++++++++ .../stack127/app-categories-database.test.mjs | 63 ++++++ tests/stack127/app-categories.test.mjs | 53 +++++ tests/stack127/breadcrumbs.test.mjs | 16 ++ .../page-publishing-database.test.mjs | 11 ++ 89 files changed, 896 insertions(+), 9 deletions(-) create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/ai/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/communication/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/databases/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/development/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/devops/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/files/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/games-fun/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/index.ts create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/media-graphics/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/monitoring/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/networking/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/productivity/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/security/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/terminal-ui/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/content/text/intro.mdx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/layout.tsx create mode 100644 app/(site)/(directories)/cli-apps/categories/[category]/page.tsx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/ai-ml/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/automation/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/bookmarks-read-it-later/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/business-commerce/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/calendars-contacts/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/communication/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/communities-social/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/content-publishing/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/dashboards/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/databases/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/design-creative/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/developer-tools/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/documents-pdfs/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/ebooks-libraries/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/email-newsletters/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/file-sharing-sync/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/finance-budgeting/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/forms-polls-events/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/games/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/health-fitness/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/home-automation-iot/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/identity-access/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/index.ts create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/knowledge-notes/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/learning-courses/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/maps-travel/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/media-device-control/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/media-servers/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/monitoring-status/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/music-audio/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/networking-dns/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/password-managers/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/photos-galleries/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/project-task-management/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/recipes-food/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/remote-access/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/search/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/security-privacy/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/storage-backup/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/system-admin-hosting/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/time-tracking/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/video-conferencing/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/content/wikis/intro.mdx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/layout.tsx create mode 100644 app/(site)/(directories)/self-host-apps/categories/[category]/page.tsx create mode 100644 app/ui/directories/app-category-links.tsx create mode 100644 app/ui/directories/app-category-page.tsx create mode 100644 app/ui/directories/app-category.module.css create mode 100644 lib/app-categories.ts create mode 100644 lib/app-category-html.ts create mode 100644 lib/app-category-model.ts create mode 100644 scripts/stack127/app-categories/categories.mjs create mode 100644 scripts/stack127/app-categories/prepare-manifest.mjs create mode 100644 scripts/stack127/app-categories/records-ready.sql create mode 100644 scripts/stack127/app-categories/sync-pages.mjs create mode 100644 supabase/migrations/20261110120000_app_category_pages.sql create mode 100644 tests/stack127/app-categories-database.test.mjs create mode 100644 tests/stack127/app-categories.test.mjs 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 000000000..bebe7df96 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/ai/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the AI and agents category. 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 000000000..4622f57b6 --- /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 000000000..c2c389681 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/databases/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Databases category. 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 000000000..20d7bb06c --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/development/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Development category. 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 000000000..3e5a51dde --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/devops/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the DevOps and cloud category. 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 000000000..cf4dcf925 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/files/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Files and archives category. 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 000000000..7a60e8f51 --- /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 000000000..05b38951b --- /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 000000000..20456b290 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/media-graphics/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Media and graphics category. 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 000000000..b8210963a --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/monitoring/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Monitoring and system category. 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 000000000..3cd832b11 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/networking/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Networking category. 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 000000000..f6dcc0179 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/productivity/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Productivity category. 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 000000000..0cf66daa9 --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/security/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Security category. 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 000000000..a0e576e0d --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/terminal-ui/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Terminals and shells category. 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 000000000..c36393a9c --- /dev/null +++ b/app/(site)/(directories)/cli-apps/categories/[category]/content/text/intro.mdx @@ -0,0 +1 @@ +This list contains CLI apps in the Text and documents category. 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 000000000..6f955e1e7 --- /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 000000000..d85f65786 --- /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 700bee98a..ef1c5760b 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 402a1eace..f3ff51fa6 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 000000000..f62ddae25 --- /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 000000000..a51c28acb --- /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 000000000..95cb1d815 --- /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 000000000..376885c85 --- /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 000000000..9a4798b3c --- /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 000000000..1c64bd564 --- /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 000000000..e2e2cc699 --- /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 000000000..64c11767b --- /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 000000000..006d3d71e --- /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 000000000..ac0d39e00 --- /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 000000000..f07ab4f9c --- /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 000000000..0cd581793 --- /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 000000000..88fa36563 --- /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 000000000..ff2de4c1f --- /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 000000000..ce9ddaa8c --- /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 000000000..6e4f44300 --- /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 000000000..a36ea58d9 --- /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 000000000..a2abfa474 --- /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 000000000..b079c21f7 --- /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 000000000..2b4e12762 --- /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 000000000..01a342c4f --- /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 000000000..aaea13bf3 --- /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 000000000..3c84e6cc0 --- /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 000000000..35631f9bf --- /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 000000000..fd6a62d18 --- /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 000000000..c232aab00 --- /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 000000000..2d543c832 --- /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 000000000..ea8a40f08 --- /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 000000000..72955c106 --- /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 000000000..be5226375 --- /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 000000000..ececb8180 --- /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 000000000..ece5c0013 --- /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 000000000..58205d945 --- /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 000000000..1d9472eb6 --- /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 000000000..c6963aa00 --- /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 000000000..8be78a319 --- /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 000000000..dfca7a6ae --- /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 000000000..f4de349b4 --- /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 000000000..bb79d554e --- /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 000000000..a2e1f19a4 --- /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 000000000..dba7d34fb --- /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 000000000..e2585e6fc --- /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 000000000..f97f5b7c9 --- /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 000000000..52ab5efb1 --- /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 000000000..a6c1473e6 --- /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 cc2b90f89..4e1e02ba8 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 cf78856c6..2661d48d6 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 000000000..28ffe7205 --- /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 000000000..57e9fc00e --- /dev/null +++ b/app/ui/directories/app-category-page.tsx @@ -0,0 +1,35 @@ +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

+ {/* One server-owned HTML block avoids a React element payload for every row. + The serializer escapes every dataset value 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 000000000..5bcc19d16 --- /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 000000000..8dd65363c --- /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 000000000..3243feac5 --- /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 000000000..2363a37b2 --- /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 4349fda57..41436474b 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 235658aaf..7ef2b5080 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 000000000..f386f6966 --- /dev/null +++ b/scripts/stack127/app-categories/categories.mjs @@ -0,0 +1,11 @@ +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 })); +} diff --git a/scripts/stack127/app-categories/prepare-manifest.mjs b/scripts/stack127/app-categories/prepare-manifest.mjs new file mode 100644 index 000000000..e9a57b3e6 --- /dev/null +++ b/scripts/stack127/app-categories/prepare-manifest.mjs @@ -0,0 +1,33 @@ +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 } 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}`); + 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: [{ path: `/${directory}/categories/${category.slug}`, text: 'Browse by category' }] }, + }); + } + 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 000000000..f4c77f36c --- /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 000000000..ec2c07378 --- /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/cache/targets.mjs b/scripts/stack127/cache/targets.mjs index e736858e8..73e15052e 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 d577b9ecf..8e4142ba0 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 cd98ef16e..54ff71b3e 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 53a1e8f90..4c97f0c6e 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 f868fc2b3..9888316c7 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 c3f0d5d47..dac2b429c 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 000000000..d6e9af974 --- /dev/null +++ b/supabase/migrations/20261110120000_app_category_pages.sql @@ -0,0 +1,185 @@ +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 + 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(); +commit; diff --git a/tests/stack127/app-categories-database.test.mjs b/tests/stack127/app-categories-database.test.mjs new file mode 100644 index 000000000..fc95bea4f --- /dev/null +++ b/tests/stack127/app-categories-database.test.mjs @@ -0,0 +1,63 @@ +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); +})); diff --git a/tests/stack127/app-categories.test.mjs b/tests/stack127/app-categories.test.mjs new file mode 100644 index 000000000..c46203d32 --- /dev/null +++ b/tests/stack127/app-categories.test.mjs @@ -0,0 +1,53 @@ +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('