UNPKG

staticql

Version:

Type-safe query engine for static content including Markdown, YAML, JSON, and more.

213 lines (212 loc) 8.25 kB
#!/usr/bin/env node import path from "path"; import { promises as fs } from "fs"; import { ConsoleLogger } from "../src/logger/ConsoleLogger.js"; const logger = new ConsoleLogger("info"); /** * Main CLI entry point. */ async function run() { const { config, outPath } = await getArgs(); const typeDefs = generateTypeDefs(config); await writeTypeDefs(outPath, typeDefs); } run(); /** * Parses CLI arguments and loads the config file. */ async function getArgs() { let [configPath, outputDir] = process.argv.slice(2); if (!configPath || !outputDir) { logger.warn("Error: Expected at least 2 arguments: <config_file> <output_dir>"); process.exit(1); } const outPath = path.join(outputDir, "staticql-types.d.ts"); configPath = path.resolve(process.cwd(), configPath); let config; try { const configRaw = await fs.readFile(configPath, "utf-8"); config = JSON.parse(configRaw); } catch (err) { logger.warn("Failed to read or parse config file."); logger.warn(err); process.exit(1); } return { config, outPath }; } /** * Writes the generated TypeScript type definitions to a file. */ async function writeTypeDefs(outPath, typeDefs) { try { await fs.mkdir(path.dirname(outPath), { recursive: true }); await fs.writeFile(outPath, typeDefs); logger.info(`Types generated to ${outPath}`); } catch (err) { logger.warn("Failed to write type definition file."); logger.warn(err); process.exit(1); } } /** * Generates TypeScript type definitions from StaticQL config schema. */ function generateTypeDefs(config) { const sources = config.sources || {}; let typeDefs = `// Auto-generated by generate-types.ts\n\n`; typeDefs += `type Index<T> = T & { __brand: "index" };\n\n`; typeDefs += `type SourceRecord = { slug: Index<string>; raw: string };\n\n`; const sourceTypeMapEntries = []; for (const [sourceName, sourceDef] of Object.entries(sources)) { const schema = sourceDef.schema; const typeName = `${capitalize(sourceName)}Record`; sourceTypeMapEntries.push(` ${sourceName}: ${typeName};`); // define fields const indexFieldNames = new Set(); const customIndexFieldNames = new Set(); if (sourceDef.relations) { for (const rel of Object.values(sourceDef.relations)) { if (rel.type === "hasOne" || rel.type === "hasMany" || rel.type === "belongsTo" || rel.type === "belongsToMany") { if (rel.localKey !== "slug") { indexFieldNames.add(rel.localKey); } } else if (rel.type === "hasOneThrough" || rel.type === "hasManyThrough") { if (rel.sourceLocalKey !== "slug") { indexFieldNames.add(rel.sourceLocalKey); } } } } if (sourceDef.index) { for (const [fieldName] of Object.entries(sourceDef.index)) { indexFieldNames.add(fieldName); } } if (sourceDef.customIndex) { for (const [fieldName] of Object.entries(sourceDef.customIndex)) { customIndexFieldNames.add(fieldName); } } for (const [otherSourceName, otherSourceDef] of Object.entries(sources)) { if (otherSourceName === sourceName || !otherSourceDef.relations) continue; for (const relDef of Object.values(otherSourceDef.relations)) { if (relDef.to !== sourceName) continue; let field = null; if (relDef.type === "hasOne" || relDef.type === "hasMany" || relDef.type === "belongsTo" || relDef.type === "belongsToMany") { field = relDef.foreignKey === "slug" ? null : relDef.foreignKey; } else if (relDef.type === "hasOneThrough" || relDef.type === "hasManyThrough") { field = relDef.targetForeignKey === "slug" ? null : `${relDef.targetForeignKey}.slug`; } if (field) { indexFieldNames.add(field); } } } let finalTypeString = jsonSchemaToTypeString(schema, indexFieldNames); if (sourceDef.relations) { const relationFields = Object.entries(sourceDef.relations) .map(([key, rel]) => { const targetType = `${capitalize(rel.to || "unknown")}Record`; const relType = rel.type; let valueType = targetType; if (["hasMany", "hasManyThrough", "belongsToMany"].includes(relType)) { valueType += "[]"; } else if (relType === "belongsTo") { valueType += " | null"; } return `${key}?: ${valueType}`; }) .join("; "); finalTypeString = finalTypeString.replace(/^\{/, `{ ${relationFields};`); } typeDefs += `export type ${typeName} = SourceRecord & ${finalTypeString};\n\n`; if (sourceDef.relations) { for (const [key, relDef] of Object.entries(sourceDef.relations)) { const valueType = [ "hasMany", "hasManyThrough", "belongsToMany", ].includes(relDef.type) ? "string[]" : relDef.type === "belongsTo" ? "string | null" : "string"; typeDefs += `export type ${capitalize(sourceName)}Relation_${key} = Record<string, ${valueType}>;\n\n`; } } if (customIndexFieldNames.size > 0) { const union = [...customIndexFieldNames].map((f) => `'${f}'`).join(" | "); typeDefs += `export type ${capitalize(sourceName)}CustomIndexKeys = ${union};\n\n`; } } if (sourceTypeMapEntries.length > 0) { typeDefs += `export type SourceTypeMap = {\n${sourceTypeMapEntries.join("\n")}\n};\n`; } return typeDefs; } /** * Converts a simplified JSON Schema to a TypeScript type string. */ function jsonSchemaToTypeString(schema, indexFields = new Set(), parentPath = "") { if (!schema || typeof schema !== "object") return "any"; switch (schema.type) { case "string": return "string"; case "number": case "integer": return "number"; case "boolean": return "boolean"; case "array": return `${jsonSchemaToTypeString(schema.items, indexFields, parentPath)}[]`; case "object": if (schema.properties) { const required = Array.isArray(schema.required) ? schema.required : ["slug"]; const fields = Object.entries(schema.properties) .map(([key, value]) => { const isRequired = required.includes(key); const fullPath = parentPath ? `${parentPath}.${key}` : key; const typeStr = jsonSchemaToTypeString(value, indexFields, fullPath); const finalTypeStr = indexFields.has(fullPath) ? `Index<${typeStr}>` : typeStr; return `${key}${isRequired ? "" : "?"}: ${finalTypeStr}`; }) .join("; "); return `{ ${fields} }`; } return "{ [key: string]: any }"; default: if (Array.isArray(schema.type)) { return schema.type.join(" | "); } return "any"; } } /** * Capitalizes the first letter of a string. */ function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); }