UNPKG

staticql

Version:

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

230 lines (229 loc) 8.18 kB
import { Indexer } from "./Indexer.js"; import { joinPath } from "./utils/path.js"; /** * Resolves user-defined source configurations into a normalized internal format. */ export class SourceConfigResolver { constructor(sources) { this.sources = sources; this.cache = {}; } /** * Resolves all sources and returns the enriched configurations. * * @returns */ resolveAll() { if (Object.values(this.cache).length !== 0) { return Object.values(this.cache); } for (const [name] of Object.entries(this.sources)) { this.cache[name] = this.resolveOne(name); } return Object.values(this.cache); } /** * Resolves a single source by name. * * @param sourceName - The name of the source. * @returns Resolved configuration. * @throws If the source does not exist. */ resolveOne(sourceName) { if (this.cache[sourceName]) { return this.cache[sourceName]; } const source = this.sources[sourceName]; if (!source) throw new Error(`Source not found: ${sourceName}`); const indexes = { slug: { dir: Indexer.getIndexDir(sourceName, "slug"), depth: Indexer.indexDepth, }, }; if (source.index) { for (const [fieldName, definition] of Object.entries(source.index)) { const depth = definition["indexDepth"] ?? Indexer.indexDepth; if (!this.isDepthInRange(depth)) throw new Error(""); indexes[fieldName] = { dir: Indexer.getIndexDir(sourceName, fieldName), depth, }; } } const relationalSources = [ ...Object.entries(this.sources) .filter(([name]) => name !== sourceName) .map(([_, source]) => Object.entries(source.relations ?? {}).find(([_, rel]) => this.isThroughRelation(rel) ? rel.to === sourceName || rel.through === sourceName : rel.to === sourceName)) .filter(Boolean) .filter((e) => !!e), ...Object.entries(source.relations ?? {}), ]; if (relationalSources) { for (const [_, rel] of relationalSources) { const fieldNames = []; if (rel.type === "belongsTo" || rel.type === "belongsToMany" || rel.type === "hasOne" || rel.type === "hasMany") { if (rel.to === sourceName) { fieldNames.push(rel.foreignKey === "slug" ? null : rel.foreignKey); } else { fieldNames.push(rel.localKey === "slug" ? null : rel.localKey); } } else if (rel.type === "hasOneThrough" || rel.type === "hasManyThrough") { if (rel.to === sourceName) { fieldNames.push(rel.throughForeignKey === "slug" ? null : rel.throughForeignKey); } else { fieldNames.push(rel.targetForeignKey === "slug" ? null : rel.targetForeignKey); } } if (!fieldNames.length) continue; for (const fieldName of fieldNames) { if (!fieldName) continue; indexes[fieldName] = { dir: Indexer.getIndexDir(sourceName, fieldName), depth: Indexer.indexDepth, }; } } } // resolve customIndexes if (source.customIndex) { for (const [fieldName, definition] of Object.entries(source.customIndex)) { const depth = definition["indexDepth"] ?? Indexer.indexDepth; if (!this.isDepthInRange(depth)) throw new Error(""); indexes[fieldName] = { dir: Indexer.getIndexDir(sourceName, fieldName), depth, }; } } const result = { name: sourceName, pattern: source.pattern, type: source.type, schema: source.schema, relations: source.relations, indexes, }; this.cache[sourceName] = result; return result; } /** * Determines whether a relation is a through (indirect) relation. * * @param rel * @returns */ isThroughRelation(rel) { return (typeof rel === "object" && "through" in rel && (rel.type === "hasOneThrough" || rel.type === "hasManyThrough")); } /** * Check depth in range. */ isDepthInRange(n) { return n >= 1 && n <= 10; } /** * Converts a list of slugs into full paths based on a glob pattern. */ static getSourcePathsBySlugs(pattern, slugs) { const extMatch = pattern.match(/\.(\w+)$/); const ext = extMatch ? "." + extMatch[1] : ""; let filteredSlugs = slugs; if (pattern.includes("*")) { const wcIdx = pattern.indexOf("*"); let slugPattern = pattern.slice(wcIdx); slugPattern = this.pathToSlug(slugPattern).replace(/\.[^\.]+$/, ""); slugPattern = slugPattern .replace(/\*\*/g, "([\\w-]+(--)?)*") .replace(/\*/g, "[\\w-]+"); const regex = new RegExp("^" + slugPattern + "$"); filteredSlugs = slugs.filter((slug) => regex.test(slug)); } return filteredSlugs.map((slug) => this.resolveFilePath(pattern, this.slugToPath(slug) + ext)); } /** * Converts a slug (with `--`) to a file path (`/`). */ static slugToPath(slug) { return slug.replace(/--/g, "/"); } /** * Converts a path (`/`) to a slug (with `--`). */ static pathToSlug(path) { return path.replace(/\//g, "--"); } /** * Extracts the base directory from a glob pattern (up to the first wildcard). */ static extractBaseDir(globPath) { const parts = globPath.split("/"); const index = parts.findIndex((part) => part.includes("*")); return index === -1 ? globPath : joinPath(...parts.slice(0, index)) + "/"; } /** * Resolves a logical file path from a glob source and a relative path. */ static resolveFilePath(sourceGlob, relativePath) { const baseDir = this.extractBaseDir(sourceGlob); return baseDir + relativePath; } /** * Extracts the slug from a full file path using the source glob. */ static getSlugFromPath(sourcePath, filePath) { const ext = filePath.slice(filePath.lastIndexOf(".")) || ""; const baseDir = this.extractBaseDir(sourcePath); let rel = filePath.startsWith(baseDir) ? filePath.slice(baseDir.length) : filePath; if (rel.startsWith("/")) rel = rel.slice(1); return this.pathToSlug(rel.replace(ext, "")); } static patternTest(pattern, filePath) { return this.globToRegExp(pattern).test(filePath); } static globToRegExp(glob) { const p = glob.replace(/\\/g, "/"); let re = "^"; let i = 0; while (i < p.length) { const c = p[i]; if (c === "*") { if (p[i + 1] === "*") { i++; const isSlash = p[i + 1] === "/"; if (isSlash) i++; re += isSlash ? "(?:[^/]+/)*" : "(?:[^/]+/)*[^/]*"; } else { re += "[^/]*"; } } else { re += c.replace(/[$^+.()|{}]/g, "\\$&"); } i++; } re += "$"; return new RegExp(re); } }