staticql
Version:
Type-safe query engine for static content including Markdown, YAML, JSON, and more.
213 lines (212 loc) • 8.25 kB
JavaScript
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);
}