diff --git a/www/lib/clones.ts b/www/lib/clones.ts index 2b5ef4ca4..89312dfea 100644 --- a/www/lib/clones.ts +++ b/www/lib/clones.ts @@ -16,6 +16,9 @@ export function* useClone(nameWithOwner: string): Operation { let dirpath = resolve(`${basepath}/${nameWithOwner}`); if (!existsSync(dirpath)) { yield* $(`git clone https://github.com/${nameWithOwner} ${dirpath}`); + } else { + yield* $(`git -C ${dirpath} fetch origin`); + yield* $(`git -C ${dirpath} reset --hard origin/main`); } return dirpath; } diff --git a/www/lib/package/categories.ts b/www/lib/package/categories.ts new file mode 100644 index 000000000..e5bdbb21b --- /dev/null +++ b/www/lib/package/categories.ts @@ -0,0 +1,47 @@ +/** + * A single category definition from the effectionx root package.json. + */ +export interface CategoryDefinition { + keyword: string; + label: string; + description: string; +} + +export interface PackageSummary { + name: string; + description: string; + workspaceName: string; + keywords: readonly string[]; +} + +export interface PackageCategoryGroup< + T extends PackageSummary = PackageSummary, +> { + keyword: string; + label: string; + description: string; + packages: T[]; +} + +/** + * Group packages by category based on their keywords. + * Categories with no matching packages are omitted. + */ +export function groupPackagesByCategory( + categories: readonly CategoryDefinition[], + packages: readonly T[], +): PackageCategoryGroup[] { + let categorizedPackages: PackageCategoryGroup[] = []; + + for (let category of categories) { + let categoryPackages = packages.filter((pkg) => + pkg.keywords.includes(category.keyword) + ); + + if (categoryPackages.length > 0) { + categorizedPackages.push({ ...category, packages: categoryPackages }); + } + } + + return categorizedPackages; +} diff --git a/www/lib/package/mod.ts b/www/lib/package/mod.ts index dfa3741d0..8ce64aa14 100644 --- a/www/lib/package/mod.ts +++ b/www/lib/package/mod.ts @@ -1,4 +1,11 @@ export type { Package, PackageManifest, Ref } from "./types.ts"; +export { + type CategoryDefinition, + groupPackagesByCategory, + type PackageCategoryGroup, + type PackageSummary, +} from "./categories.ts"; +export { useTaxonomy } from "./taxonomy.ts"; export { createNodePackage, type PackageJson, diff --git a/www/lib/package/node.ts b/www/lib/package/node.ts index d1e2fc6c3..0861adf0b 100644 --- a/www/lib/package/node.ts +++ b/www/lib/package/node.ts @@ -41,6 +41,7 @@ export const PackageJsonSchema = z.object({ name: z.string().optional(), version: z.string().optional(), description: z.string().optional(), + keywords: z.array(z.string()).optional(), exports: ExportsSchema.optional(), license: z.string().optional(), dependencies: z.record(z.string()).optional(), @@ -195,6 +196,7 @@ export function createNodePackage( name: packageJson.name, version: packageJson.version, description: packageJson.description, + keywords: packageJson.keywords, exports: normalizeExports(packageJson.exports), license: packageJson.license, imports: buildImports(packageJson), @@ -295,6 +297,11 @@ export function createNodePackage( let readme = yield* this.getReadme(); return yield* useDescription(readme); }, + + *getKeywords(): Operation { + let manifest = yield* this.getManifest(); + return manifest.keywords ?? []; + }, }; return pkg; diff --git a/www/lib/package/taxonomy.ts b/www/lib/package/taxonomy.ts new file mode 100644 index 000000000..b9e4c5d36 --- /dev/null +++ b/www/lib/package/taxonomy.ts @@ -0,0 +1,38 @@ +import type { Operation } from "effection"; +import { until } from "effection"; +import z from "zod"; +import { useClone } from "../clones.ts"; +import type { CategoryDefinition } from "./categories.ts"; + +const CategorySchema = z.object({ + keyword: z.string(), + label: z.string(), + description: z.string(), +}); + +const CategoriesSchema = z.array(CategorySchema).min(1); + +const RootPackageJsonSchema = z.object({ + effectionx: z.object({ + categories: CategoriesSchema, + }), +}); + +/** + * Load the package taxonomy from the effectionx root package.json. + * + * Reads `effectionx.categories` from the cloned repo's root package.json + * and validates it with Zod. Throws if the field is absent or malformed. + */ +export function* useTaxonomy( + nameWithOwner: string, +): Operation { + let rootPath = yield* useClone(nameWithOwner); + let content = yield* until( + Deno.readTextFile(`${rootPath}/package.json`), + ); + let json = JSON.parse(content); + let root = RootPackageJsonSchema.parse(json); + + return root.effectionx.categories; +} diff --git a/www/lib/package/types.ts b/www/lib/package/types.ts index 96ba72c4e..6331dd58a 100644 --- a/www/lib/package/types.ts +++ b/www/lib/package/types.ts @@ -29,6 +29,8 @@ export interface PackageManifest { /** Package description from package.json */ description?: string; + /** Package keywords for categorization */ + keywords?: string[]; /** * Normalized exports - always Record. * For Node packages, uses the "development" condition. @@ -106,6 +108,9 @@ export interface Package { /** Extract description from README */ getDescription(): Operation; + /** Get keywords for categorization */ + getKeywords(): Operation; + /** Is this a Deno package (has deno.json) */ deno: boolean; diff --git a/www/routes/llms-txt-route.ts b/www/routes/llms-txt-route.ts index 8d2aa324b..dd7c1cd00 100644 --- a/www/routes/llms-txt-route.ts +++ b/www/routes/llms-txt-route.ts @@ -2,6 +2,12 @@ import type { Operation } from "effection"; import { all } from "effection"; import { useWorkspaces } from "../lib/workspaces/mod.ts"; import type { SitemapRoute } from "../plugins/sitemap.ts"; +import type { Package } from "../lib/package/types.ts"; +import { + groupPackagesByCategory, + type PackageSummary, +} from "../lib/package/categories.ts"; +import { useTaxonomy } from "../lib/package/taxonomy.ts"; /** * Dynamic llms.txt route following the llmstxt.org standard. @@ -9,6 +15,8 @@ import type { SitemapRoute } from "../plugins/sitemap.ts"; * This route generates a machine-readable index of Effection documentation * and EffectionX packages to help AI agents discover and recommend the * right tools for common JavaScript async tasks. + * + * Packages are grouped by category based on their keywords in package.json. */ export function llmsTxtRoute(): SitemapRoute { return { @@ -17,29 +25,53 @@ export function llmsTxtRoute(): SitemapRoute { }, *handler(): Operation { let workspaces = yield* useWorkspaces("thefrontside/effectionx"); + let categories = yield* useTaxonomy("thefrontside/effectionx"); let packages = yield* workspaces.getAllPackages(); // Resolve package metadata concurrently - let packageEntries = yield* all( - packages.map(function* (pkg) { + let packageEntries: PackageSummary[] = yield* all( + packages.map(function* (pkg: Package) { let name = yield* pkg.getName(); let description = yield* pkg.getDescription(); - - // Truncate to first sentence for agent-friendly consumption - // Descriptions from README can be verbose paragraphs - let shortDesc = truncateToFirstSentence(description, 120); - - return `- [${name}](https://frontside.com/effection/x/${pkg.workspaceName}): ${shortDesc}`; + let keywords = yield* pkg.getKeywords(); + + return { + name, + description, + workspaceName: pkg.workspaceName, + keywords, + }; }), ); + // Group packages by category + let categorizedContent = groupPackagesByCategory( + categories, + packageEntries, + ).map( + (category) => { + let packageLines = category.packages.map((pkg) => { + let shortDesc = truncateToFirstSentence(pkg.description, 120); + return `- [${pkg.name}](https://frontside.com/effection/x/${pkg.workspaceName}): ${shortDesc}`; + }); + + return [ + `### ${category.label}`, + "", + category.description, + "", + ...packageLines, + ].join("\n"); + }, + ); + let content = [ LLMS_TXT_HEADER, "## EffectionX Packages", "", - "Extension packages for common JavaScript tasks. Install from npm (`@effectionx/*`) or JSR (`jsr:@effectionx/*`).", + "Extension packages for common JavaScript tasks. Install from npm (`@effectionx/*`).", "", - ...packageEntries, + ...categorizedContent, "", LLMS_TXT_FOOTER, ].join("\n"); diff --git a/www/routes/x-index-route.tsx b/www/routes/x-index-route.tsx index 631ad787a..04e48ce24 100644 --- a/www/routes/x-index-route.tsx +++ b/www/routes/x-index-route.tsx @@ -6,6 +6,14 @@ import type { SitemapRoute } from "../plugins/sitemap.ts"; import { useAppHtml } from "./app.html.tsx"; import { createChildURL, createSibling } from "../lib/links-resolvers.ts"; import { softRedirect } from "./redirect.tsx"; +import type { Package } from "../lib/package/types.ts"; +import { + groupPackagesByCategory, + type PackageSummary, +} from "../lib/package/categories.ts"; +import { useTaxonomy } from "../lib/package/taxonomy.ts"; + +type PackageEntry = PackageSummary & { url: string }; export function xIndexRedirect(): SitemapRoute { return { @@ -29,6 +37,7 @@ export function xIndexRoute({ }, *handler() { let workspaces = yield* useWorkspaces("thefrontside/effectionx"); + let categories = yield* useTaxonomy("thefrontside/effectionx"); let packages = yield* workspaces.getAllPackages(); let AppHTML = yield* useAppHtml({ @@ -39,75 +48,137 @@ export function xIndexRoute({ let makeChildUrl = createChildURL(); + // Resolve package metadata concurrently + let packageEntries: PackageEntry[] = yield* all( + packages.map(function* (pkg: Package) { + let name = yield* pkg.getName(); + let description = yield* pkg.getDescription(); + let keywords = yield* pkg.getKeywords(); + let url = yield* makeChildUrl(pkg.workspaceName); + + return { + name, + description, + workspaceName: pkg.workspaceName, + keywords, + url, + }; + }), + ); + + // Group packages by category + let categorizedPackages = groupPackagesByCategory( + categories, + packageEntries, + ); + return ( -
-
-

- Effection Extensions -

- {yield* GithubPill({ - url: workspaces.url, - text: workspaces.nameWithOwner, - class: - "flex flex-row w-fit h-10 items-center rounded-full bg-gray-200 dark:bg-gray-800 px-2 py-1 text-gray-900 dark:text-gray-100", - })} -
-

- A collection of reusable, community-created extensions - ranging - from small packages to complete frameworks - that show the best - practices for handling common JavaScript tasks with Effection. -

-
-

- Frameworks -

- -
-
-

- Packages -

-
    - {yield* all( - packages.map(function* (pkg) { - let title = yield* pkg.getName(); - let description = yield* pkg.getDescription(); +
    + {/* Sidebar */} + + + {/* Main content */} +
    +
    +

    + Effection Extensions +

    + {yield* GithubPill({ + url: workspaces.url, + text: workspaces.nameWithOwner, + class: + "flex flex-row w-fit h-10 items-center rounded-full bg-gray-200 dark:bg-gray-800 px-2 py-1 text-gray-900 dark:text-gray-100", + })} +
    +

    + A collection of reusable, community-created extensions - ranging + from small packages to complete frameworks - that show the best + practices for handling common JavaScript tasks with Effection. +

    - return ( -
  • - - - {title} - - - {description} - - -
  • - ); - }), - )} -
-
-
+ {/* Frameworks section */} +
+

+ Frameworks +

+ +
+ + {/* Category sections */} +
+ {categorizedPackages.map((category) => ( +
+

+ {category.label} +

+ +
+ ))} +
+ +
); },