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