diff --git a/config/oxlint/overrides.json b/config/oxlint/overrides.json index 3c2dfe0..6a69991 100644 --- a/config/oxlint/overrides.json +++ b/config/oxlint/overrides.json @@ -320,7 +320,11 @@ "plugins": ["typescript"] }, { - "files": ["src/routes/og.review.tsx", "src/routes/og.tag.tsx"], + "files": [ + "src/lib/site-brand-icon.ts", + "src/routes/og.review.tsx", + "src/routes/og.tag.tsx" + ], "rules": { "unicorn/number-literal-case": "off" } diff --git a/lexicons/fyi/atstore/directory/searchListings.json b/lexicons/fyi/atstore/directory/searchListings.json index 7949915..7f1407d 100644 --- a/lexicons/fyi/atstore/directory/searchListings.json +++ b/lexicons/fyi/atstore/directory/searchListings.json @@ -75,7 +75,7 @@ }, "main": { "type": "query", - "description": "Directory listing search and pagination (verified listings with a listing.detail AT URI only).", + "description": "Directory listing search and pagination (verified listings with a listing.detail AT URI only). Filters match the at-store /apps page: values within one filter are OR'd, different filters are AND'd.", "parameters": { "type": "params", "properties": { @@ -87,7 +87,50 @@ "type": "string", "maxLength": 24, "default": "popular", - "enum": ["popular", "newest", "alphabetical"] + "enum": [ + "popular", + "rating", + "reviews", + "favorites", + "newest", + "updated", + "alphabetical" + ], + "description": "popular = trending score; rating = average rating (unrated last); reviews = review count; favorites = favorite count; newest = created; updated = last updated; alphabetical = name." + }, + "tag": { + "type": "array", + "maxLength": 20, + "items": { "type": "string", "maxLength": 256 }, + "description": "App tags to match (any). Accepts the tag text from appTags or its slug, e.g. 'developer tool' or 'developer'." + }, + "category": { + "type": "array", + "maxLength": 20, + "items": { "type": "string", "maxLength": 512 }, + "description": "Category paths to match (any), including descendants, e.g. 'apps/bluesky' or 'apps/bluesky/client'. 'apps' matches top-level apps only." + }, + "data": { + "type": "array", + "maxLength": 20, + "items": { "type": "string", "maxLength": 256 }, + "description": "Lexicon producers whose records the listing reads or writes (any), as the first two NSID segments, e.g. 'app.bsky'." + }, + "minRating": { + "type": "string", + "maxLength": 8, + "knownValues": ["3", "4", "4.5"], + "description": "Only listings with an average rating at or above this value." + }, + "has": { + "type": "array", + "maxLength": 3, + "items": { + "type": "string", + "maxLength": 32, + "knownValues": ["open-source", "donations", "official"] + }, + "description": "Required extras (all): open-source (has a source link), donations (has a donate link), official (has a product account)." }, "limit": { "type": "integer", diff --git a/package.json b/package.json index b9a0eac..d213bfd 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "db:studio": "drizzle-kit studio", "db:backup": "tsx -r dotenv/config scripts/db-backup.ts", "db:seed": "tsx -r dotenv/config scripts/db-seed.ts", - "lex:gen": "bash -c 'mkdir -p src/lexicons/generated && pnpm exec lex gen-ts-obj lexicons/fyi/atstore/directory/searchListings.json lexicons/fyi/atstore/directory/getListing.json lexicons/fyi/atstore/reviews/listForListing.json lexicons/fyi/atstore/server/describe.json lexicons/fyi/atstore/profile.json lexicons/fyi/atstore/listing/detail.json lexicons/fyi/atstore/listing/review.json lexicons/fyi/atstore/listing/reviewReply.json lexicons/fyi/atstore/listing/favorite.json lexicons/fyi/atstore/auth/basic.json lexicons/fyi/atstore/auth/thirdPartyReviews.json > src/lexicons/generated/bundle.ts'", + "lex:gen": "bash -c 'mkdir -p src/lexicons/generated && pnpm exec lex gen-ts-obj lexicons/fyi/atstore/directory/searchListings.json lexicons/fyi/atstore/directory/getListing.json lexicons/fyi/atstore/reviews/listForListing.json lexicons/fyi/atstore/server/describe.json lexicons/fyi/atstore/profile.json lexicons/fyi/atstore/listing/detail.json lexicons/fyi/atstore/listing/review.json lexicons/fyi/atstore/listing/reviewReply.json lexicons/fyi/atstore/listing/favorite.json lexicons/fyi/atstore/auth/basic.json lexicons/fyi/atstore/auth/thirdPartyReviews.json > src/lexicons/generated/bundle.ts && pnpm exec oxfmt src/lexicons/generated/bundle.ts'", "lex:lint": "node scripts/goat-lex.mjs lex lint", "lex:check-dns": "node scripts/goat-lex.mjs lex check-dns", "lex:status": "node scripts/goat-lex.mjs lex status", diff --git a/scripts/generate-listing-copy.ts b/scripts/generate-listing-copy.ts index 09d0841..9a49b95 100644 --- a/scripts/generate-listing-copy.ts +++ b/scripts/generate-listing-copy.ts @@ -1,119 +1,129 @@ #!/usr/bin/env node -import 'dotenv/config' +import "dotenv/config"; +import { Anthropic } from "@anthropic-ai/sdk"; +import { desc, eq } from "drizzle-orm"; +import { chromium } from "playwright"; -import Anthropic from '@anthropic-ai/sdk' -import { desc, eq } from 'drizzle-orm' -import { chromium } from 'playwright' - -import { db, dbClient } from '../src/db/index.server' -import { storeListings } from '../src/db/schema' +import { db, dbClient } from "../src/db/index.server"; +import { storeListings } from "../src/db/schema"; import { isMeaningfulListingCopy, sanitizeListingDescription, sanitizeListingTagline, stripEmbeddedListingMetadata, -} from '../src/lib/listing-copy' +} from "../src/lib/listing-copy"; -const DEFAULT_MODEL = 'claude-sonnet-4-20250514' +const DEFAULT_MODEL = "claude-sonnet-4-20250514"; const GENERIC_TAGLINE_PATTERNS = [ /^a tool for the bluesky and at protocol ecosystem\.?$/i, /^a bluesky client\.?$/i, /^alternative bluesky client\.?$/i, /^find your community\.?$/i, -] +]; type CandidateListing = { - id: string - name: string - sourceUrl: string - externalUrl: string | null - tagline: string | null - fullDescription: string | null - rawCategoryHint: string | null - scope: string | null - productType: string | null - domain: string | null -} + id: string; + name: string; + sourceUrl: string; + externalUrl: string | null; + tagline: string | null; + fullDescription: string | null; + rawCategoryHint: string | null; + scope: string | null; + productType: string | null; + domain: string | null; +}; function taxonomyHintsFromCategorySlugs( slugs: string[] | null | undefined, -): Pick { - const primary = slugs?.[0]?.trim() +): Pick< + CandidateListing, + "rawCategoryHint" | "scope" | "productType" | "domain" +> { + const primary = slugs?.[0]?.trim(); if (!primary) { - return { rawCategoryHint: null, scope: null, productType: null, domain: null } - } - const parts = primary.split('/').map((s) => s.trim()).filter(Boolean) + return { + rawCategoryHint: null, + scope: null, + productType: null, + domain: null, + }; + } + const parts = primary + .split("/") + .map((s) => s.trim()) + .filter(Boolean); return { rawCategoryHint: null, scope: primary, productType: parts[0] ?? null, domain: parts[1] ?? null, - } + }; } type ScriptArgs = { - dryRun: boolean - force: boolean - limit: number | null - id: string | null - help: boolean -} + dryRun: boolean; + force: boolean; + limit: number | null; + id: string | null; + help: boolean; +}; type ExtractedPageCopy = { - finalUrl: string - title: string | null - metaDescription: string | null - ogDescription: string | null - headings: string[] - paragraphs: string[] -} + finalUrl: string; + title: string | null; + metaDescription: string | null; + ogDescription: string | null; + headings: Array; + paragraphs: Array; +}; type GeneratedCopy = { - tagline: string - fullDescription: string -} + tagline: string; + fullDescription: string; +}; -function parseArgs(argv: string[]): ScriptArgs { +function parseArgs(argv: Array): ScriptArgs { const out: ScriptArgs = { dryRun: false, force: false, limit: null, id: null, help: false, - } + }; for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--help' || arg === '-h') { - out.help = true - continue + const arg = argv[i]; + if (arg === "--help" || arg === "-h") { + out.help = true; + continue; } - if (arg === '--dry-run') { - out.dryRun = true - continue + if (arg === "--dry-run") { + out.dryRun = true; + continue; } - if (arg === '--force') { - out.force = true - continue + if (arg === "--force") { + out.force = true; + continue; } - if (arg === '--limit' || arg === '-l') { - const raw = argv[++i] - const value = Number.parseInt(raw ?? '', 10) + if (arg === "--limit" || arg === "-l") { + const raw = argv[++i]; + const value = Number.parseInt(raw ?? "", 10); if (!Number.isFinite(value) || value <= 0) { - throw new Error(`Invalid --limit value "${raw ?? ''}"`) + throw new Error(`Invalid --limit value "${raw ?? ""}"`); } - out.limit = value - continue + out.limit = value; + continue; } - if (arg === '--id') { - out.id = argv[++i] ?? null - continue + if (arg === "--id") { + out.id = argv[++i] ?? null; + continue; } - throw new Error(`Unknown argument: ${arg}`) + throw new Error(`Unknown argument: ${arg}`); } - return out + return out; } function printHelp(): void { @@ -130,103 +140,105 @@ Options: Environment: ANTHROPIC_API_KEY or ANTHROPIC_KEY Required when fallback generation is needed ANTHROPIC_MODEL Optional (default: ${DEFAULT_MODEL}) -`) +`); } function getAnthropicApiKey(): string { - const key = process.env.ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_KEY ?? '' + const key = process.env.ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_KEY ?? ""; if (!key) { throw new Error( - 'Missing API key: set ANTHROPIC_API_KEY (or ANTHROPIC_KEY) in the environment.', - ) + "Missing API key: set ANTHROPIC_API_KEY (or ANTHROPIC_KEY) in the environment.", + ); } - return key + return key; } function normalizeText(value: string | null | undefined): string | null { if (!value) { - return null + return null; } - const cleaned = stripEmbeddedListingMetadata(value) + const cleaned = stripEmbeddedListingMetadata(value); if (!cleaned) { - return null + return null; } return cleaned - .replace(/\u00a0/g, ' ') - .replace(/[ \t]{2,}/g, ' ') - .replace(/\n{3,}/g, '\n\n') - .trim() + .replaceAll("\u00A0", " ") + .replaceAll(/[ \t]{2,}/g, " ") + .replaceAll(/\n{3,}/g, "\n\n") + .trim(); } -function dedupeStrings(values: Array): string[] { - const seen = new Set() - const out: string[] = [] +function dedupeStrings( + values: Array, +): Array { + const seen = new Set(); + const out: Array = []; for (const value of values) { - const cleaned = normalizeText(value) - if (!cleaned) continue - const key = cleaned.toLowerCase() - if (seen.has(key)) continue - seen.add(key) - out.push(cleaned) + const cleaned = normalizeText(value); + if (!cleaned) continue; + const key = cleaned.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(cleaned); } - return out + return out; } function shortenToSentence(value: string, maxLength = 160): string { - const cleaned = normalizeText(value) ?? value.trim() + const cleaned = normalizeText(value) ?? value.trim(); if (cleaned.length <= maxLength) { - return cleaned + return cleaned; } - const sentenceMatch = cleaned.match(/^(.{30,200}?[.!?])(?:\s|$)/) + const sentenceMatch = cleaned.match(/^(.{30,200}?[.!?])(?:\s|$)/); if (sentenceMatch?.[1] && sentenceMatch[1].length <= maxLength) { - return sentenceMatch[1].trim() + return sentenceMatch[1].trim(); } - return `${cleaned.slice(0, maxLength - 1).trimEnd()}…` + return `${cleaned.slice(0, maxLength - 1).trimEnd()}…`; } function isWeakTagline(value: string | null | undefined): boolean { - const cleaned = sanitizeListingTagline(value) + const cleaned = sanitizeListingTagline(value); if (!cleaned) { - return true + return true; } if (cleaned.length < 24) { - return true + return true; } - return GENERIC_TAGLINE_PATTERNS.some((pattern) => pattern.test(cleaned)) + return GENERIC_TAGLINE_PATTERNS.some((pattern) => pattern.test(cleaned)); } function hasEmbeddedMetadata(value: string | null | undefined): boolean { - const raw = typeof value === 'string' ? value.trim() : '' + const raw = typeof value === "string" ? value.trim() : ""; if (!raw) { - return false + return false; } - return sanitizeListingDescription(raw) !== raw + return sanitizeListingDescription(raw) !== raw; } function isWeakDescription(value: string | null | undefined): boolean { - const cleaned = sanitizeListingDescription(value) + const cleaned = sanitizeListingDescription(value); if (!cleaned) { - return true + return true; } if (cleaned.length < 80) { - return true + return true; } if (hasEmbeddedMetadata(value)) { - return true + return true; } - return !isMeaningfulListingCopy(cleaned, { minLength: 80 }) + return !isMeaningfulListingCopy(cleaned, { minLength: 80 }); } function needsCopyRefresh(listing: CandidateListing, force: boolean): boolean { if (force) { - return true + return true; } return ( @@ -234,34 +246,34 @@ function needsCopyRefresh(listing: CandidateListing, force: boolean): boolean { isWeakDescription(listing.fullDescription) || hasEmbeddedMetadata(listing.tagline) || hasEmbeddedMetadata(listing.fullDescription) - ) + ); } function getListingUrl(listing: CandidateListing): string { - return listing.externalUrl || listing.sourceUrl + return listing.externalUrl || listing.sourceUrl; } function isLikelyBoilerplateText(value: string): boolean { - const normalized = value.trim().toLowerCase() + const normalized = value.trim().toLowerCase(); if (normalized.length < 30) { - return true + return true; } return ( - normalized.includes('cookie') || - normalized.includes('privacy policy') || - normalized.includes('terms of service') || - normalized.includes('all rights reserved') || - normalized.includes('sign in') || - normalized.includes('log in') || - normalized.includes('create account') - ) + normalized.includes("cookie") || + normalized.includes("privacy policy") || + normalized.includes("terms of service") || + normalized.includes("all rights reserved") || + normalized.includes("sign in") || + normalized.includes("log in") || + normalized.includes("create account") + ); } async function extractPageCopy(url: string): Promise { const browser = await chromium.launch({ headless: true, - }) + }); try { const page = await browser.newPage({ @@ -269,47 +281,57 @@ async function extractPageCopy(url: string): Promise { width: 1440, height: 960, }, - }) + }); await page.goto(url, { - waitUntil: 'domcontentloaded', + waitUntil: "domcontentloaded", timeout: 30_000, - }) - await page.waitForTimeout(2_500) - - const [title, finalUrl, metaDescription, ogDescription, headings, paragraphs] = - await Promise.all([ - page.title(), - page.url(), - page - .locator('meta[name="description"]') - .first() - .getAttribute('content') - .catch(() => null), - page - .locator('meta[property="og:description"]') - .first() - .getAttribute('content') - .catch(() => null), - page - .locator('main h1, main h2, main h3, article h1, article h2, article h3, [role="main"] h1, [role="main"] h2, [role="main"] h3, body h1, body h2, body h3') - .evaluateAll((nodes) => - nodes - .map((node) => node.textContent ?? '') - .map((value) => value.replace(/\s+/g, ' ').trim()) - .filter((value) => value.length > 0) - .slice(0, 10), - ), - page - .locator('main p, main li, article p, article li, [role="main"] p, [role="main"] li, body p, body li') - .evaluateAll((nodes) => - nodes - .map((node) => node.textContent ?? '') - .map((value) => value.replace(/\s+/g, ' ').trim()) - .filter((value) => value.length >= 30) - .slice(0, 24), - ), - ]) + }); + await page.waitForTimeout(2500); + + const [ + title, + finalUrl, + metaDescription, + ogDescription, + headings, + paragraphs, + ] = await Promise.all([ + page.title(), + page.url(), + page + .locator('meta[name="description"]') + .first() + .getAttribute("content") + .catch(() => null), + page + .locator('meta[property="og:description"]') + .first() + .getAttribute("content") + .catch(() => null), + page + .locator( + 'main h1, main h2, main h3, article h1, article h2, article h3, [role="main"] h1, [role="main"] h2, [role="main"] h3, body h1, body h2, body h3', + ) + .evaluateAll((nodes) => + nodes + .map((node) => node.textContent ?? "") + .map((value) => value.replaceAll(/\s+/g, " ").trim()) + .filter((value) => value.length > 0) + .slice(0, 10), + ), + page + .locator( + 'main p, main li, article p, article li, [role="main"] p, [role="main"] li, body p, body li', + ) + .evaluateAll((nodes) => + nodes + .map((node) => node.textContent ?? "") + .map((value) => value.replaceAll(/\s+/g, " ").trim()) + .filter((value) => value.length >= 30) + .slice(0, 24), + ), + ]); return { finalUrl, @@ -318,9 +340,9 @@ async function extractPageCopy(url: string): Promise { ogDescription, headings: dedupeStrings(headings), paragraphs: dedupeStrings(paragraphs), - } + }; } finally { - await browser.close() + await browser.close(); } } @@ -331,53 +353,57 @@ function chooseSiteTagline(extracted: ExtractedPageCopy): string | null { extracted.headings[1], extracted.paragraphs[0], extracted.title, - ]) + ]); for (const candidate of candidates) { - if (isLikelyBoilerplateText(candidate)) continue - if (candidate.length < 24) continue - return shortenToSentence(candidate, 140) + if (isLikelyBoilerplateText(candidate)) continue; + if (candidate.length < 24) continue; + return shortenToSentence(candidate, 140); } - return null + return null; } function chooseSiteDescription(extracted: ExtractedPageCopy): string | null { - const parts: string[] = [] + const parts: Array = []; for (const paragraph of extracted.paragraphs) { - if (isLikelyBoilerplateText(paragraph)) continue - if (parts.some((existing) => existing.toLowerCase() === paragraph.toLowerCase())) { - continue + if (isLikelyBoilerplateText(paragraph)) continue; + if ( + parts.some( + (existing) => existing.toLowerCase() === paragraph.toLowerCase(), + ) + ) { + continue; } - parts.push(paragraph) - if (parts.join('\n\n').length >= 420) { - break + parts.push(paragraph); + if (parts.join("\n\n").length >= 420) { + break; } } - const joined = normalizeText(parts.join('\n\n')) + const joined = normalizeText(parts.join("\n\n")); if (joined && joined.length >= 80) { - return joined + return joined; } const fallback = dedupeStrings([ extracted.metaDescription, extracted.ogDescription, extracted.paragraphs[0], - ])[0] + ])[0]; - return fallback ?? null + return fallback ?? null; } async function generateCopyWithAnthropic(input: { - listing: CandidateListing - extracted: ExtractedPageCopy - preferredTagline: string | null - preferredDescription: string | null + listing: CandidateListing; + extracted: ExtractedPageCopy; + preferredTagline: string | null; + preferredDescription: string | null; }): Promise { - const client = new Anthropic({ apiKey: getAnthropicApiKey() }) - const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL + const client = new Anthropic({ apiKey: getAnthropicApiKey() }); + const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL; const payload = { name: input.listing.name, @@ -388,7 +414,9 @@ async function generateCopyWithAnthropic(input: { productType: input.listing.productType, domain: input.listing.domain, currentTagline: sanitizeListingTagline(input.listing.tagline), - currentDescription: sanitizeListingDescription(input.listing.fullDescription), + currentDescription: sanitizeListingDescription( + input.listing.fullDescription, + ), preferredTagline: input.preferredTagline, preferredDescription: input.preferredDescription, pageTitle: input.extracted.title, @@ -396,7 +424,7 @@ async function generateCopyWithAnthropic(input: { ogDescription: input.extracted.ogDescription, headings: input.extracted.headings.slice(0, 8), paragraphs: input.extracted.paragraphs.slice(0, 10), - } + }; const message = await client.messages.create({ model, @@ -414,94 +442,96 @@ Rules: - Return JSON only with keys "tagline" and "fullDescription".`, messages: [ { - role: 'user', + role: "user", content: [ { - type: 'text', + type: "text", text: JSON.stringify(payload), }, ], }, ], - }) + }); const text = message.content - .filter((block) => block.type === 'text') - .map((block) => ('text' in block ? block.text : '')) - .join('') - .trim() + .filter((block) => block.type === "text") + .map((block) => ("text" in block ? block.text : "")) + .join("") + .trim(); - const parsed = parseJsonObject(text) - const tagline = sanitizeListingTagline(String(parsed.tagline ?? '')) + const parsed = parseJsonObject(text); + const tagline = sanitizeListingTagline(String(parsed.tagline ?? "")); const fullDescription = sanitizeListingDescription( - String(parsed.fullDescription ?? ''), - ) + String(parsed.fullDescription ?? ""), + ); if (!tagline || !fullDescription) { - throw new Error('Model returned incomplete listing copy') + throw new Error("Model returned incomplete listing copy"); } return { tagline, fullDescription, - } + }; } function parseJsonObject(text: string): Record { const cleaned = text - .replace(/^```(?:json)?\s*/i, '') - .replace(/\s*```$/i, '') - .trim() - const start = cleaned.indexOf('{') - const end = cleaned.lastIndexOf('}') - const slice = start >= 0 && end > start ? cleaned.slice(start, end + 1) : cleaned - const parsed: unknown = JSON.parse(slice) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Expected JSON object from model') - } - return parsed as Record + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + const start = cleaned.indexOf("{"); + const end = cleaned.lastIndexOf("}"); + const slice = + start !== -1 && end > start ? cleaned.slice(start, end + 1) : cleaned; + const parsed: unknown = JSON.parse(slice); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Expected JSON object from model"); + } + return parsed as Record; } -async function buildImprovedCopy( - listing: CandidateListing, -): Promise<{ - tagline: string - fullDescription: string - usedModel: boolean +async function buildImprovedCopy(listing: CandidateListing): Promise<{ + tagline: string; + fullDescription: string; + usedModel: boolean; }> { - const extracted = await extractPageCopy(getListingUrl(listing)) + const extracted = await extractPageCopy(getListingUrl(listing)); - const siteTagline = chooseSiteTagline(extracted) - const siteDescription = chooseSiteDescription(extracted) + const siteTagline = chooseSiteTagline(extracted); + const siteDescription = chooseSiteDescription(extracted); - const hasGoodSiteTagline = !isWeakTagline(siteTagline) - const hasGoodSiteDescription = !isWeakDescription(siteDescription) + // Not weak implies non-empty, so these are null exactly when the site copy is unusable. + const goodSiteTagline = isWeakTagline(siteTagline) ? null : siteTagline; + const goodSiteDescription = isWeakDescription(siteDescription) + ? null + : siteDescription; - if (hasGoodSiteTagline && hasGoodSiteDescription) { + if (goodSiteTagline && goodSiteDescription) { return { - tagline: siteTagline!, - fullDescription: siteDescription!, + tagline: goodSiteTagline, + fullDescription: goodSiteDescription, usedModel: false, - } + }; } const generated = await generateCopyWithAnthropic({ listing, extracted, - preferredTagline: hasGoodSiteTagline ? siteTagline : null, - preferredDescription: hasGoodSiteDescription ? siteDescription : null, - }) + preferredTagline: goodSiteTagline, + preferredDescription: goodSiteDescription, + }); return { - tagline: hasGoodSiteTagline ? siteTagline! : generated.tagline, - fullDescription: hasGoodSiteDescription - ? siteDescription! - : generated.fullDescription, + tagline: goodSiteTagline ?? generated.tagline, + fullDescription: goodSiteDescription ?? generated.fullDescription, usedModel: true, - } + }; } -async function getCandidateListings(args: ScriptArgs): Promise { +async function getCandidateListings( + args: ScriptArgs, +): Promise> { const rows = await db .select({ id: storeListings.id, @@ -513,7 +543,7 @@ async function getCandidateListings(args: ScriptArgs): Promise ({ @@ -527,24 +557,24 @@ async function getCandidateListings(args: ScriptArgs): Promise (args.id ? row.id === args.id : true)) .filter((row) => needsCopyRefresh(row, args.force)) - .slice(0, args.limit ?? Number.POSITIVE_INFINITY) + .slice(0, args.limit ?? Number.POSITIVE_INFINITY); } async function processListing( listing: CandidateListing, args: ScriptArgs, ): Promise { - console.log(`Processing ${listing.name} (${listing.id})`) + console.log(`Processing ${listing.name} (${listing.id})`); - const nextCopy = await buildImprovedCopy(listing) + const nextCopy = await buildImprovedCopy(listing); console.log( - `Prepared copy for ${listing.name} using ${nextCopy.usedModel ? 'site + model' : 'site copy'}.`, - ) + `Prepared copy for ${listing.name} using ${nextCopy.usedModel ? "site + model" : "site copy"}.`, + ); if (args.dryRun) { - console.log(`Tagline: ${nextCopy.tagline}`) - console.log(`Description: ${nextCopy.fullDescription}`) - return + console.log(`Tagline: ${nextCopy.tagline}`); + console.log(`Description: ${nextCopy.fullDescription}`); + return; } await db @@ -554,41 +584,41 @@ async function processListing( fullDescription: nextCopy.fullDescription, updatedAt: new Date(), }) - .where(eq(storeListings.id, listing.id)) + .where(eq(storeListings.id, listing.id)); - console.log(`Updated store_listings copy for ${listing.name}.`) + console.log(`Updated store_listings copy for ${listing.name}.`); } async function main(): Promise { - const args = parseArgs(process.argv.slice(2)) + const args = parseArgs(process.argv.slice(2)); if (args.help) { - printHelp() - process.exit(0) + printHelp(); + process.exit(0); } - const candidates = await getCandidateListings(args) + const candidates = await getCandidateListings(args); if (candidates.length === 0) { - console.log('No listings need copy refresh.') - return + console.log("No listings need copy refresh."); + return; } - console.log(`Found ${candidates.length} listing(s) to process.`) + console.log(`Found ${candidates.length} listing(s) to process.`); for (const listing of candidates) { try { - await processListing(listing, args) + await processListing(listing, args); } catch (error) { - console.error(`Failed for ${listing.name} (${listing.id}).`) - console.error(error) + console.error(`Failed for ${listing.name} (${listing.id}).`); + console.error(error); } } } await main() .catch((error) => { - console.error(error) - process.exitCode = 1 + console.error(error); + process.exitCode = 1; }) .finally(async () => { - await dbClient.end({ timeout: 5 }) - }) + await dbClient.end({ timeout: 5 }); + }); diff --git a/scripts/generate-listing-images.ts b/scripts/generate-listing-images.ts index bd7d61d..1b56c3e 100644 --- a/scripts/generate-listing-images.ts +++ b/scripts/generate-listing-images.ts @@ -1,11 +1,10 @@ #!/usr/bin/env node import "dotenv/config"; - +import { desc, eq } from "drizzle-orm"; +import { createHash } from "node:crypto"; import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; -import { createHash } from "node:crypto"; -import { desc, eq } from "drizzle-orm"; import { db, dbClient } from "../src/db/index.server"; import { storeListings } from "../src/db/schema"; import { geminiFlashGenerateImageFromPromptAndImage } from "../src/lib/gemini-flash-image-gen"; @@ -39,7 +38,7 @@ type CandidateListing = { sourceUrl: string; externalUrl: string | null; iconUrl: string | null; - screenshotUrls: string[]; + screenshotUrls: Array; tagline: string | null; fullDescription: string | null; productType: string | null; @@ -89,7 +88,7 @@ type ManualListingRecord = { sourceUrl: string; externalUrl: string | null; iconUrl: string | null; - screenshotUrls: string[]; + screenshotUrls: Array; tagline: string | null; fullDescription: string | null; rawCategoryHint: string | null; @@ -101,7 +100,7 @@ type ManualListingRecord = { categorySlug?: string | null; }; -function parseArgs(argv: string[]): ScriptArgs { +function parseArgs(argv: Array): ScriptArgs { const out: ScriptArgs = { concurrency: 4, dryRun: false, @@ -293,7 +292,7 @@ function parseManualListingRecord( const screenshotUrls = Array.isArray(row.screenshotUrls) ? row.screenshotUrls.map((entry, screenshotIndex) => { if (typeof entry !== "string") { - throw new Error( + throw new TypeError( `Row ${rowIndex}: expected screenshotUrls[${screenshotIndex}] to be a string`, ); } @@ -331,13 +330,13 @@ function parseManualListingRecord( async function readManualListingRecords( inputPath: string, -): Promise { +): Promise> { const absoluteInputPath = path.resolve(process.cwd(), inputPath); const raw = await readFile(absoluteInputPath, "utf8"); const data: unknown = JSON.parse(raw); if (!Array.isArray(data)) { - throw new Error(`Expected ${inputPath} to contain a JSON array`); + throw new TypeError(`Expected ${inputPath} to contain a JSON array`); } return data.map((row, index) => parseManualListingRecord(row, index)); @@ -380,7 +379,7 @@ function buildMarketingPrompt( listing.scope ? `Scope: ${listing.scope}` : null, listing.fullDescription ? `Description: ${listing.fullDescription}` : null, ] - .filter((value): value is string => Boolean(value)) + .filter((value) => value !== null) .join("\n"); return `Create a polished product-marketing image for this software listing using the provided website screenshot as reference. @@ -422,7 +421,7 @@ function buildIconPrompt(listing: CandidateListing, pageUrl: string): string { listing.scope ? `Scope: ${listing.scope}` : null, listing.fullDescription ? `Description: ${listing.fullDescription}` : null, ] - .filter((value): value is string => Boolean(value)) + .filter((value) => value !== null) .join("\n"); return `Create a polished product icon for this software listing using the provided website screenshot as reference. @@ -676,10 +675,10 @@ async function saveGeneratedImage( async function getCandidateListings( args: ScriptArgs, -): Promise { +): Promise> { if (args.input) { const manualRows = await readManualListingRecords(args.input); - const candidates: CandidateListing[] = []; + const candidates: Array = []; for (const row of manualRows) { if (args.id && row.sourceUrl !== args.id) { @@ -773,7 +772,7 @@ async function getCandidateListings( .from(storeListings) .orderBy(desc(storeListings.updatedAt), desc(storeListings.createdAt)); - const candidates: CandidateListing[] = []; + const candidates: Array = []; for (const row of rows) { if (args.id && row.id !== args.id) { diff --git a/src/components/AppTagCard.tsx b/src/components/AppTagCard.tsx index f93d236..dcb5dfc 100644 --- a/src/components/AppTagCard.tsx +++ b/src/components/AppTagCard.tsx @@ -317,9 +317,8 @@ export function AppTagCard({ tag, isFeatured }: AppTagCardProps) { return ( void; +} + +export function BrowseFilterPanel({ + facets, + filters, + onToggle, +}: BrowseFilterPanelProps) { + const isSelected = (facet: BrowseFacet, value: string) => { + switch (facet) { + case "minRating": { + return filters.minRating === Number(value); + } + case "extras": { + return (filters.extras as Array).includes(value); + } + default: { + return filters[facet].includes(value); + } + } + }; + + return ( + + ( + + {getAppTagEmoji(option.label.toLowerCase())} + + )} + /> + + {facets.data.length > 0 ? ( + + ) : null} + + + {facets.minRating.map((option) => ( + onToggle("minRating", option.id)} + /> + ))} + + + + {facets.extras.map((option) => ( + onToggle("extras", option.id)} + /> + ))} + + + isSelected("categories", value)} + onToggle={(value) => onToggle("categories", value)} + /> + + ); +} + +/** + * Category tree: standalone apps, app ecosystems (largest first, the rest behind "show all"), + * then protocol tooling. Ecosystems list their tool categories underneath. + */ +function CategoryFacetSection({ + options, + selectedCount, + isSelected, + onToggle, +}: { + options: Array; + selectedCount: number; + isSelected: (value: string) => boolean; + onToggle: (value: string) => void; +}) { + const [showAll, setShowAll] = useState(false); + const isOptionActive = (option: BrowseFacetOption) => + isSelected(option.id) || + Boolean(option.children?.some((child) => isSelected(child.id))); + + const ecosystems = options.filter( + (option) => option.iconUrl !== undefined && option.children, + ); + const visibleEcosystems = new Set( + (showAll + ? ecosystems + : ecosystems.filter( + (option, index) => + index < COLLAPSED_ECOSYSTEM_COUNT || isOptionActive(option), + ) + ).map((option) => option.id), + ); + const visible = options.filter( + (option) => + !ecosystems.includes(option) || visibleEcosystems.has(option.id), + ); + + return ( + + {visible.map((option) => ( + + onToggle(option.id)} + icon={ + option.iconUrl === undefined ? undefined : ( + + ) + } + /> + {option.children + ?.filter((child) => child.count > 0 || isSelected(child.id)) + .map((child) => ( + onToggle(child.id)} + style={styles.childOption} + /> + ))} + + ))} + {ecosystems.length > COLLAPSED_ECOSYSTEM_COUNT ? ( +
+ +
+ ) : null} +
+ ); +} + +/** Collapsed by default unless it has a selection; pass `defaultExpanded` to override. */ +function FacetSection({ + title, + selectedCount, + defaultExpanded = selectedCount > 0, + hint, + children, +}: { + title: string; + selectedCount: number; + defaultExpanded?: boolean; + hint?: string; + children: React.ReactNode; +}) { + return ( + + + {selectedCount > 0 ? `${title} (${selectedCount})` : title} + + + + {hint ? {hint} : null} + {children} + + + + ); +} + +/** Long facets (tags, data) get an inline filter box and a "show all" toggle. */ +function SearchableFacetSection({ + title, + hint, + facet, + options, + selectedCount, + defaultExpanded, + isSelected, + onToggle, + renderIcon, +}: { + title: string; + hint?: string; + defaultExpanded?: boolean; + facet: BrowseFacet; + options: Array; + selectedCount: number; + isSelected: (facet: BrowseFacet, value: string) => boolean; + onToggle: (facet: BrowseFacet, value: string) => void; + renderIcon?: (option: BrowseFacetOption) => React.ReactNode; +}) { + const [query, setQuery] = useState(""); + const [showAll, setShowAll] = useState(false); + const normalizedQuery = query.trim().toLowerCase(); + + const available = options.filter( + (option) => option.count > 0 || isSelected(facet, option.id), + ); + const matching = normalizedQuery + ? available.filter((option) => + option.label.toLowerCase().includes(normalizedQuery), + ) + : available; + const selectedFirst = matching.toSorted( + (a, b) => Number(isSelected(facet, b.id)) - Number(isSelected(facet, a.id)), + ); + const visible = + showAll || normalizedQuery + ? selectedFirst + : selectedFirst.slice(0, COLLAPSED_OPTION_COUNT); + + if (available.length === 0) { + return null; + } + + return ( + + {available.length > COLLAPSED_OPTION_COUNT ? ( + + ) : null} + {visible.map((option) => ( + onToggle(facet, option.id)} + icon={renderIcon?.(option)} + /> + ))} + {!normalizedQuery && available.length > COLLAPSED_OPTION_COUNT ? ( +
+ +
+ ) : null} +
+ ); +} + +function FacetOption({ + option, + isSelected, + onToggle, + icon, + style, +}: { + option: BrowseFacetOption; + isSelected: boolean; + onToggle: () => void; + icon?: React.ReactNode; + style?: stylex.StyleXStyles; +}) { + return ( + + + {icon} + {option.label} + {option.count} + + + ); +} diff --git a/src/components/EcosystemCategoryCard.tsx b/src/components/EcosystemCategoryCard.tsx index 6d15652..c44e42d 100644 --- a/src/components/EcosystemCategoryCard.tsx +++ b/src/components/EcosystemCategoryCard.tsx @@ -186,9 +186,8 @@ export function EcosystemCategoryCard({ return (