alinea
Version:
Headless git-based CMS
570 lines (568 loc) • 20.2 kB
JavaScript
import {
any,
array,
object
} from "../../chunks/chunk-WD7H5L2L.js";
import "../../chunks/chunk-NZLE2WMY.js";
// src/core/db/EntryResolver.ts
import { EntryFields } from "alinea/core/EntryFields";
import { Field } from "alinea/core/Field";
import {
querySource as queryEdge
} from "alinea/core/Graph";
import {
getExpr,
hasExpr,
hasField,
hasRoot,
hasWorkspace
} from "alinea/core/Internal";
import { getScope } from "alinea/core/Scope";
import { hasExact } from "alinea/core/util/Checks";
import { entries, fromEntries } from "alinea/core/util/Objects";
import { unreachable } from "alinea/core/util/Types";
import { createRecord } from "../EntryRecord.js";
import { compareStrings } from "../source/Utils.js";
import { assert } from "../util/Assert.js";
import {
combineConditions
} from "./EntryIndex.js";
import { LinkResolver } from "./LinkResolver.js";
var orFilter = object({ or: array(any) }).and(hasExact(["or"]));
var andFilter = object({ and: array(any) }).and(hasExact(["and"]));
var EntryResolver = class {
index;
#scope;
constructor(config, index) {
this.#scope = getScope(config);
this.index = index;
}
call(ctx, entry, internal) {
switch (internal.method) {
case "snippet": {
if (!ctx.searchTerms)
throw new Error("Snippet method requires search terms to be provided");
const start = this.expr(ctx, entry, internal.args[0]);
const end = this.expr(ctx, entry, internal.args[1]);
const cutOff = this.expr(ctx, entry, internal.args[2]);
const limit = this.expr(ctx, entry, internal.args[3]);
return snippet(
entry.searchableText,
ctx.searchTerms,
start,
end,
cutOff,
limit
);
}
default:
throw new Error(`Unknown method: "${internal.method}"`);
}
}
field(entry, field) {
const name = this.#scope.nameOf(field);
if (!name) throw new Error(`Expression has no name ${field}`);
return entry.data[name];
}
expr(ctx, entry, expr) {
const internal = getExpr(expr);
switch (internal.type) {
case "field": {
const result = this.field(entry, expr);
if (result && typeof result === "object") {
return structuredClone(result);
}
return result;
}
case "entryField":
return entry[internal.name];
case "call":
return this.call(ctx, entry, internal);
case "value":
return internal.value;
default:
unreachable(internal);
}
}
projectTypes(types) {
return entries(EntryFields).concat(types.flatMap(entries));
}
projection(query) {
return query.select ?? fromEntries(
(query.type ? this.projectTypes(
Array.isArray(query.type) ? query.type : [query.type]
) : []).concat(entries(EntryFields)).concat(query.include ? entries(query.include) : [])
);
}
sourceFilter(ctx, entry, query) {
switch (query.edge) {
case "parent": {
return {
nodes: entry.parentId ? [ctx.graph.byId(entry.parentId)] : [],
language: (lang) => lang.locale === entry.locale
};
}
case "next": {
const parent = entry.parentId ? ctx.graph.byId(entry.parentId) : void 0;
const [next] = Array.from(
ctx.graph.filter({
nodes: parent?.children() ?? [],
node: (node) => node.index > entry.index,
language: (lang) => lang.locale === entry.locale
})
).sort((a, b) => compareStrings(a.index, b.index));
const nodes = next ? [ctx.graph.byId(next.id)] : [];
return { nodes };
}
case "previous": {
const parent = entry.parentId ? ctx.graph.byId(entry.parentId) : void 0;
const [previous] = Array.from(
ctx.graph.filter({
nodes: parent?.children() ?? [],
node: (node) => node.index < entry.index,
language: (lang) => lang.locale === entry.locale
})
).sort((a, b) => compareStrings(b.index, a.index));
const nodes = previous ? [ctx.graph.byId(previous.id)] : [];
return { nodes };
}
case "siblings": {
const parent = entry.parentId ? ctx.graph.byId(entry.parentId) : void 0;
return {
nodes: parent?.children() ?? [],
node: (node) => query.includeSelf || node.id !== entry.id,
language: (lang) => lang.locale === entry.locale
};
}
case "translations": {
const self = ctx.graph.byId(entry.id);
assert(self);
return {
nodes: [self],
language: (lang) => query.includeSelf || lang.locale !== entry.locale
};
}
case "children": {
const depth = query?.depth ?? 1;
return {
entry: (e) => e.filePath.startsWith(entry.childrenDir),
node: (node) => node.level > entry.level && node.level <= entry.level + depth
};
}
case "parents": {
const depth = query?.depth ?? Number.POSITIVE_INFINITY;
const ids = entry.parents.slice(-depth);
const nodes = ids.map((id) => ctx.graph.byId(id));
return { nodes, language: (lang) => lang.locale === entry.locale };
}
case "entryMultiple": {
const fieldValue = this.field(entry, query.field);
const ids = Array.isArray(fieldValue) ? fieldValue.map((item) => item._entry).filter(Boolean) : [];
const nodes = ids.map((id) => ctx.graph.byId(id)).filter(Boolean);
return { nodes };
}
case "entrySingle": {
const fieldValue = this.field(entry, query.field);
const entryId = fieldValue?._entry;
const node = ctx.graph.byId(entryId);
return { nodes: node ? [node] : [] };
}
default:
return {};
}
}
selectProjection(ctx, entry, value) {
if (value && hasExpr(value)) return this.expr(ctx, entry, value);
const source = queryEdge(value);
if (!source)
return fromEntries(
entries(value).map(([key, value2]) => {
return [key, this.selectProjection(ctx, entry, value2)];
})
);
const related = value;
return this.query(
{ ...ctx, locale: entry.locale },
related,
this.sourceFilter(ctx, entry, related)
).getUnprocessed();
}
select(ctx, entry, query) {
if (!entry) return null;
if (query.select && hasExpr(query.select))
return this.expr(ctx, entry, query.select);
const fields = this.projection(query);
return this.selectProjection(ctx, entry, fields);
}
condition(ctx, query) {
const location = Array.isArray(query.location) ? query.location : query.location && this.#scope.locationOf(query.location);
const checkStatus = statusChecker(ctx.status);
const checkLocation = location && locationChecker(location);
const locale = query.locale ?? ctx.locale;
let checkLocale = locale !== void 0 && query.edge !== "translations" && localeChecker(
typeof locale === "string" ? locale.toLowerCase() : null,
false
);
if (!checkLocale && query.preferredLocale)
checkLocale = localeChecker(query.preferredLocale.toLowerCase(), true);
const checkType = Boolean(query.type) && typeChecker(
Array.isArray(query.type) ? query.type.map((type) => this.#scope.nameOf(type)) : this.#scope.nameOf(query.type)
);
const source = queryEdge(query);
const checkEntry = entryChecker(this.#scope, query);
const checkFilter = query.filter && filterChecker(query.filter, (entry, name) => {
if (name.startsWith("_")) return entry[name.slice(1)];
return entry.data[name];
});
const multipleIds = typeof query.id === "object" && query.id !== null && query.id.in;
const ids = Array.isArray(multipleIds) ? multipleIds : typeof query.id === "string" ? [query.id] : void 0;
return {
ids,
condition(entry) {
if (!checkStatus(entry)) return false;
if (checkLocation && !checkLocation(entry)) return false;
if (checkType && !checkType(entry)) return false;
const matchesLocale = checkLocale ? checkLocale(entry) : true;
if (source !== "translations" && !matchesLocale) return false;
if (checkEntry && !checkEntry(entry)) return false;
if (checkFilter && !checkFilter(entry)) return false;
return true;
}
};
}
isSingleResult(query) {
return Boolean(
query.first || query.get || query.count || query.edge === "parent" || query.edge === "next" || query.edge === "previous"
);
}
query(ctx, query, preFilter) {
const edge = query;
const { skip, take, orderBy, groupBy, search, count } = query;
const { ids, condition } = this.condition(ctx, edge);
const filter = {
search: Array.isArray(search) ? search.join(" ") : search,
node: (node) => ids ? ids.includes(node.id) : true,
entry: condition
};
let entries2 = Array.from(
ctx.graph.filter(
preFilter ? combineConditions(filter, preFilter) : filter
)
);
if (groupBy) {
assert(!Array.isArray(groupBy), "groupBy must be a single field");
const groups = /* @__PURE__ */ new Map();
for (const entry of entries2) {
const value = this.expr(ctx, entry, groupBy);
if (!groups.has(value)) groups.set(value, entry);
}
entries2 = Array.from(groups.values());
}
if (orderBy) {
const orders = Array.isArray(orderBy) ? orderBy : [orderBy];
entries2.sort((a, b) => {
for (const order of orders) {
const expr = order.asc ?? order.desc;
const valueA = this.expr(ctx, a, expr);
const valueB = this.expr(ctx, b, expr);
const strings = typeof valueA === "string" && typeof valueB === "string";
const numbers = typeof valueA === "number" && typeof valueB === "number";
if (strings) {
const compare = order.caseSensitive ? compareStrings(valueA, valueB) : valueA.localeCompare(valueB, void 0, { numeric: true });
if (compare !== 0) return order.asc ? compare : -compare;
} else if (numbers) {
if (valueA !== valueB)
return order.asc ? valueA - valueB : valueB - valueA;
}
}
return 0;
});
} else if (edge.edge === "parents") {
entries2.sort((a, b) => a.level - b.level);
}
if (skip) entries2.splice(0, skip);
if (take) entries2.splice(take);
const isSingle = this.isSingleResult(query);
const asEdge = query;
const getSelected = () => entries2.map((entry) => this.select(ctx, entry, query));
const getUnprocessed = () => {
if (count) return entries2.length;
const results = getSelected();
if (isSingle) return results[0];
return results;
};
const getProcessed = async () => {
if (count) return entries2.length;
const results = getSelected();
if (isSingle) {
const entry = entries2[0];
if (results[0]) {
const linkResolver = new LinkResolver(this, ctx, entry.locale);
await this.postRow({ linkResolver }, results[0], asEdge);
}
return results[0];
}
if (results.length > 0) {
await Promise.all(
results.map((result, index) => {
if (!result) return;
const linkResolver = new LinkResolver(
this,
ctx,
entries2[index].locale
);
return this.postRow({ linkResolver }, result, asEdge);
}).filter(Boolean)
);
}
return results;
};
return {
entries: entries2,
getUnprocessed,
getProcessed
};
}
async postField(ctx, interim, field) {
const shape = Field.shape(field);
await shape.applyLinks(interim, ctx.linkResolver);
}
async postExpr(ctx, interim, expr) {
if (hasField(expr)) await this.postField(ctx, interim, expr);
}
async postRow(ctx, interim, query) {
if (!interim) return;
const selected = this.projection(query);
if (hasExpr(selected)) return this.postExpr(ctx, interim, selected);
if (queryEdge(selected))
return this.post(ctx, interim, selected);
await Promise.all(
entries(selected).map(([key, value]) => {
const source = queryEdge(value);
if (source)
return this.post(ctx, interim[key], value);
return this.postExpr(ctx, interim[key], value);
})
);
}
async post(ctx, interim, input) {
if (input.count === true) return;
const isSingle = this.isSingleResult(input);
if (isSingle) return this.postRow(ctx, interim, input);
await Promise.all(interim.map((row) => this.postRow(ctx, row, input)));
}
async resolve(query) {
const { preview } = query;
const previewEntry = preview && "entry" in preview ? preview.entry : void 0;
let graph = this.index.graph;
if (previewEntry)
graph = graph.withChanges({
fromSha: this.index.tree.sha,
changes: [
{
op: "add",
path: previewEntry.filePath,
sha: previewEntry.fileHash,
contents: new TextEncoder().encode(
JSON.stringify(
createRecord(previewEntry, previewEntry.status),
null,
2
)
)
}
]
});
const ctx = {
status: query.status ?? "published",
locale: query.locale,
graph,
searchTerms: Array.isArray(query.search) ? query.search.join(" ") : query.search
};
return this.query(ctx, query).getProcessed();
}
};
function statusChecker(status) {
switch (status) {
case "published":
return (entry) => entry.status === "published";
case "draft":
return (entry) => entry.status === "draft";
case "archived":
return (entry) => entry.status === "archived";
case "preferDraft":
return (entry) => entry.active;
case "preferPublished":
return (entry) => entry.main;
case "all":
return (entry) => true;
}
}
function isObject(input) {
return input && typeof input === "object";
}
function entryChecker(scope, query) {
const root = isObject(query.root) && hasRoot(query.root) ? scope.nameOf(query.root) : query.root;
const workspace = isObject(query.workspace) && hasWorkspace(query.workspace) ? scope.nameOf(query.workspace) : query.workspace;
return filterChecker({
id: query.id,
parentId: query.parentId,
path: query.path,
url: query.url,
level: query.level,
workspace,
root
});
}
function typeChecker(type) {
if (Array.isArray(type)) {
return (entry) => type.includes(entry.type);
}
return (entry) => entry.type === type;
}
function locationChecker(location) {
switch (location.length) {
case 1:
return (entry) => entry.workspace === location[0];
case 2:
return (entry) => entry.workspace === location[0] && entry.root === location[1];
case 3: {
return (entry) => {
if (entry.workspace !== location[0]) return false;
if (entry.root !== location[1]) return false;
if (entry.level === 0) return false;
if (entry.level === 1)
return entry.parentDir.endsWith(`/${location[2]}`);
const segment = entry.parentDir.split("/").at(-entry.level);
return segment === location[2];
};
}
default:
return (entry) => true;
}
}
function localeChecker(locale, preferred) {
return (entry) => {
if (preferred && entry.locale === null) return true;
if (typeof entry.locale === "string")
return entry.locale.toLowerCase() === locale;
return entry.locale === locale;
};
}
function filterChecker(filter, getField = (input, name) => input[name]) {
const isOrFilter = orFilter.check(filter);
if (isOrFilter) {
const or = filter.or.filter(Boolean).map((op) => filterChecker(op, getField));
return (input) => {
for (const fn of or) if (fn(input)) return true;
return false;
};
}
const isAndFilter = andFilter.check(filter);
if (isAndFilter) {
const and = filter.and.filter(Boolean).map((op) => filterChecker(op, getField));
return (input) => {
for (const fn of and) if (!fn(input)) return false;
return true;
};
}
if (typeof filter !== "object" || filter === null) {
return (input) => input === filter;
}
const conditions = createConditions(filter, getField);
return (input) => {
for (const condition of conditions) if (!condition(input)) return false;
return true;
};
}
function createConditions(ops, getField) {
const conditions = Array();
for (const [name, op] of entries(ops)) {
if (op === void 0) continue;
if (typeof op !== "object" || op === null) {
conditions.push((input) => getField(input, name) === op);
continue;
}
const inner = op;
if (inner.is !== void 0)
conditions.push((input) => getField(input, name) === inner.is);
if (inner.isNot !== void 0)
conditions.push((input) => getField(input, name) !== inner.isNot);
const inOp = inner.in;
if (Array.isArray(inOp))
conditions.push((input) => input && inOp.includes(getField(input, name)));
const notInOp = inner.notIn;
if (Array.isArray(notInOp))
conditions.push(
(input) => input && !notInOp.includes(getField(input, name))
);
if (inner.gt !== void 0)
conditions.push((input) => getField(input, name) > inner.gt);
if (inner.gte !== void 0)
conditions.push((input) => getField(input, name) >= inner.gte);
if (inner.lt !== void 0)
conditions.push((input) => getField(input, name) < inner.lt);
if (inner.lte !== void 0)
conditions.push((input) => getField(input, name) <= inner.lte);
if (inner.startsWith)
conditions.push((input) => {
const field = getField(input, name);
return typeof field === "string" && field.startsWith(inner.startsWith);
});
const orOp = inner.or;
if (orOp) {
const inner2 = Array.isArray(orOp) ? orOp.flatMap((op2) => createConditions(op2, getField)) : createConditions(orOp, getField);
conditions.push((input) => {
for (const condition of inner2) if (condition(input)) return true;
return false;
});
}
if (inner.has) {
const has = filterChecker(inner.has);
conditions.push((input) => has(getField(input, name)));
}
if (inner.includes) {
const includes = filterChecker(inner.includes);
conditions.push((input) => {
const field = getField(input, name);
if (!Array.isArray(field)) return false;
for (const item of field) if (includes(item)) return true;
return false;
});
}
}
return conditions;
}
function snippet(body, searchTerms, start, end, cutOff, limit) {
if (limit <= 0 || limit > 64)
throw new Error(
"The 'limit' parameter must be greater than zero and less than or equal to 64."
);
const terms = searchTerms.toLowerCase().split(/\s+/).filter((term) => term !== "");
const words = body.split(/\s+/);
const highlightedWords = Array();
let firstMatchIndex = -1;
for (let i = 0; i < words.length; i++) {
const lowerCaseWord = words[i].toLowerCase();
const isMatch = terms.some((term) => lowerCaseWord.includes(term));
if (isMatch && firstMatchIndex === -1) firstMatchIndex = i;
if (isMatch) highlightedWords.push(`${start}${words[i]}${end}`);
else highlightedWords.push(words[i]);
}
if (firstMatchIndex === -1)
return words.slice(0, Math.min(limit, words.length)).join(" ");
const idealStartIndex = Math.max(0, firstMatchIndex - Math.floor(limit / 2));
const snippetResultWords = highlightedWords.slice(
idealStartIndex,
idealStartIndex + limit
);
let snippetResult = snippetResultWords.join(" ");
if (idealStartIndex > 0) snippetResult = `${cutOff}${snippetResult}`;
if (idealStartIndex + limit < highlightedWords.length)
snippetResult = `${snippetResult}${cutOff}`;
return snippetResult;
}
export {
EntryResolver,
statusChecker
};