spb/tools-spboucher-ai
Public
1import { z } from "zod";23/** Controlled vocabularies (keep in sync with docs/data-model.md). */4export const PRODUCT_TYPES = ["application", "library", "framework", "api-service", "platform", "cli-tool", "language", "database", "model", "data-service", "extension", "specification", "service"] as const;5export const PRODUCT_STATUSES = ["active", "maintenance", "deprecated", "discontinued", "renamed", "unknown"] as const;6export const SOURCE_MODELS = ["open-source", "proprietary", "source-available", "mixed"] as const;7export const PRICING_MODELS = ["free", "freemium", "paid", "enterprise", "usage-based", "open-source", "academic", "unknown"] as const;8export const OPERATING_SYSTEMS = ["macos", "windows", "linux", "ios", "android", "web", "any"] as const;9export const EXECUTION_MODES = ["cloud", "self-hosted", "local"] as const;10export const INTERFACES = ["web", "desktop", "mobile", "cli", "api", "sdk", "library", "ide-extension", "browser-extension", "mcp-server"] as const;11export const DEPLOYMENT_MODELS = ["saas", "on-premises", "hybrid", "desktop", "embedded", "managed-cloud"] as const;12export const SOURCE_TYPES = ["official_site", "official_docs", "repository", "release_notes", "pricing_page", "policy_page", "license_page", "package_registry", "academic_paper", "third_party_directory", "news", "other"] as const;13export const CONFIDENCE = ["high", "medium", "low"] as const;14export const DEPTHS = ["deep", "standard"] as const;15export const EDITORIAL_STATUSES = ["draft", "reviewed", "published", "deprecated"] as const;16export const VISIBILITIES = ["public", "private"] as const;17export const USE_STATES = ["active", "testing", "candidate", "archived", "rejected"] as const;1819const slug = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be kebab-case");20const url = z.string().url().refine((u) => /^https?:\/\//.test(u), "http(s) only");21const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD");22const shortList = (max = 12) => z.array(z.string().min(1).max(160)).max(max);2324export const sourceSchema = z.object({25 url,26 type: z.enum(SOURCE_TYPES),27 title: z.string().max(200).optional(),28 /** Which record fields this source supports (e.g. ["license_spdx","pricing_model"]). */29 supports: z.array(z.string()).default([]),30 checked_at: date,31 note: z.string().max(400).optional(),32});3334export const toolRecordSchema = z.object({35 slug,36 name: z.string().min(1).max(120),37 aliases: z.array(z.string().max(120)).default([]),38 vendor: z.object({ name: z.string().min(1).max(120), slug: slug.optional(), website: url.optional() }),39 homepage_url: url,40 docs_url: url.optional(),41 repo_url: url.optional(),42 pricing_url: url.optional(),43 license_url: url.optional(),44 privacy_url: url.optional(),45 security_url: url.optional(),46 logo_url: url.optional(),47 logo_attribution: z.string().max(200).optional(),48 product_type: z.enum(PRODUCT_TYPES),49 product_status: z.enum(PRODUCT_STATUSES),50 summary: z.string().min(40).max(220),51 /** Original editorial description in Markdown. deep: 100–300 words; standard: ≥ 40 words. */52 description: z.string().min(150),53 capabilities: shortList(10).min(2),54 workflows: shortList(8).default([]),55 advantages: shortList(8).default([]),56 limitations: shortList(8).default([]),57 prerequisites: shortList(8).default([]),58 audiences: shortList(8).default([]),59 alternatives: z.array(slug).max(10).default([]),60 comparison_notes: z.string().max(1200).optional(),61 categories: z.array(z.object({ slug, primary: z.boolean().default(false), label: z.string().max(80).optional() })).min(1).max(5),62 tags: z.array(slug).max(20).default([]),63 use_cases: z.array(slug).max(12).default([]),64 operating_systems: z.array(z.enum(OPERATING_SYSTEMS)).default([]),65 execution_modes: z.array(z.enum(EXECUTION_MODES)).min(1),66 interfaces: z.array(z.enum(INTERFACES)).min(1),67 deployment_models: z.array(z.enum(DEPLOYMENT_MODELS)).default([]),68 integrations: shortList(20).default([]),69 data_formats: shortList(20).default([]),70 languages: shortList(20).default([]),71 source_model: z.enum(SOURCE_MODELS),72 license_spdx: z.string().max(60).optional(),73 pricing_model: z.array(z.enum(PRICING_MODELS)).min(1),74 pricing_checked_at: date.optional(),75 privacy_posture: z.string().max(600).optional(),76 data_residency: z.string().max(300).optional(),77 self_hostable: z.boolean().nullable().default(null),78 sources: z.array(sourceSchema).min(1),79 confidence: z.enum(CONFIDENCE),80 uncertainty_note: z.string().max(600).optional(),81 discovery_method: z.string().max(80).default("editorial-research"),82 depth: z.enum(DEPTHS).default("standard"),83 editorial_status: z.enum(EDITORIAL_STATUSES).default("reviewed"),84 visibility: z.enum(VISIBILITIES).default("public"),85 featured: z.boolean().default(false),86 author: z.string().max(80).default("editorial"),87 reviewed_at: date.optional(),88 reviewed_by: z.string().max(80).optional(),89});90export type ToolRecord = z.infer<typeof toolRecordSchema>;91export type ToolRecordInput = z.input<typeof toolRecordSchema>;9293export const categorySchema: z.ZodType<{ slug: string; name: string; description: string; children?: any[] }> = z.object({94 slug, name: z.string(), description: z.string(), children: z.array(z.lazy(() => categorySchema)).optional(),95});9697export const collectionSchema = z.object({98 slug, title: z.string().min(3).max(120), description: z.string().min(40).max(1200), published: z.boolean().default(true),99 items: z.array(z.object({ tool: slug, blurb: z.string().max(400).optional() })).min(2),100});101102export const ownerRelevanceSchema = z.object({103 tool: slug,104 use_state: z.enum(USE_STATES),105 rating: z.number().int().min(1).max(5).optional(),106 notes: z.string().max(4000).optional(),107 cluster_compat: z.enum(["yes", "partial", "no", "unknown"]).default("unknown"),108 priority: z.enum(["high", "medium", "low"]).default("medium"),109 projects: z.array(slug).default([]),110 subscription_status: z.string().max(120).optional(),111 custom_tags: z.array(slug).default([]),112 review_at: date.optional(),113});114115export const WORD = (s: string) => s.trim().split(/\s+/).filter(Boolean).length;116