import { globToRegExp } from "../deps/path.ts"; import { normalizePath } from "./utils/path.ts"; import type { Data, Page, StaticFile } from "./file.ts"; export interface Options { /** The pages array */ pages: Page[]; /** The static files array */ files: StaticFile[]; /** Context data */ sourceData: Map>; /** Filters to apply to all page searches */ filters?: Filter[]; } type Filter = (data: Data) => boolean; type Condition = [string, string, unknown]; /** Search helper */ export default class Searcher { #pages: Page[]; #files: StaticFile[]; #sourceData: Map>; #cache = new Map(); #cacheFiles = new Map(); #filters: Filter[]; constructor(options: Options) { this.#pages = options.pages; this.#files = options.files; this.#sourceData = options.sourceData; this.#filters = options.filters || []; } /** Clear the cache (used after a change in watch mode) */ deleteCache() { this.#cache.clear(); this.#cacheFiles.clear(); } /** * Return the data in the scope of a path (file or folder) */ data(path = "/"): T & Partial | undefined { const normalized = normalizePath(path); const dirData = this.#sourceData.get(normalized); if (dirData) { return dirData as T & Partial; } const result = this.#pages.find((page) => page.data.url === normalized || page.data.sourcePath === normalized ); if (result) { return result.data as T & Partial; } } /** Search pages */ pages(query?: string, sort?: string, limit?: number): (Data & T)[] { const result = this.#searchPages(query, sort); if (!limit) { return result; } return (limit < 0) ? result.slice(limit) : result.slice(0, limit); } /** Search and return the first page */ page(query?: string, sort?: string): Data & T | undefined { return this.pages(query, sort)[0]; } /** Search files using a glob */ files(globOrRegexp?: RegExp | string): string[] { return this.#searchFiles(globOrRegexp); } /** Search a single file using a glob */ file(globOrRegexp?: RegExp | string): string | undefined { return this.#searchFiles(globOrRegexp)[0]; } /** Returns all values from the same key of a search */ values(key: string, query?: string): T[] { const values = new Set(); this.#searchPages(query).forEach((data) => { const value = data[key]; if (Array.isArray(value)) { value.forEach((v) => values.add(v)); } else if (value !== undefined) { values.add(value); } }); return Array.from(values) as T[]; } /** Return the next page of a search */ nextPage( url: string, query?: string, sort?: string, ): Data & T | undefined { const pages = this.#searchPages(query, sort); const index = pages.findIndex((data) => data.url === url); return (index === -1) ? undefined : pages[index + 1]; } /** Return the previous page of a search */ previousPage( url: string, query?: string, sort?: string, ): Data & T | undefined { const pages = this.#searchPages(query, sort); const index = pages.findIndex((data) => data.url === url); return (index <= 0) ? undefined : pages[index - 1]; } #searchPages(query?: string, sort = "date"): (Data & T)[] { const id = JSON.stringify([query, sort]); if (this.#cache.has(id)) { return [...this.#cache.get(id)!] as (Data & T)[]; } const compiledFilter = buildFilter(query); const filters = compiledFilter ? this.#filters.concat([compiledFilter]) : this.#filters; const result = filters.reduce( (pages, filter) => pages.filter(filter), this.#pages.map((page) => page.data), ); result.sort(buildSort(sort)); this.#cache.set(id, result); return [...result] as (Data & T)[]; } #searchFiles(globOrRegexp?: RegExp | string): string[] { const id = typeof globOrRegexp === "string" ? globOrRegexp : globOrRegexp?.source || ""; if (this.#cacheFiles.has(id)) { return [...this.#cacheFiles.get(id)!]; } const files = this.#files.map((file) => file.outputPath); const pages = this.#pages.map((page) => page.outputPath); let result: string[] = [...files, ...pages]; if (typeof globOrRegexp === "string") { const regexp = globToRegExp(globOrRegexp); result = result.filter((file) => regexp.test(file)); } else if (globOrRegexp instanceof RegExp) { result = result.filter((file) => globOrRegexp.test(file)); } this.#cacheFiles.set(id, result); return [...result]; } } /** * Parse a query string and return a function to filter a search result * * example: "title=foo level<3" * returns: (page) => page.data.title === "foo" && page.data.level < 3 */ export function buildFilter(query = ""): Filter | undefined { // (?:(not)?(fieldName)(operator))?(value|"value"|'value') const matches = query ? query.matchAll( /(?:(!)?([\w.-]+)([!^$*]?=|[<>]=?))?([^'"\s][^\s=<>]*|"[^"]+"|'[^']+')/g, ) : []; const conditions: Condition[] = []; for (const match of matches) { let [, not, key, operator, value] = match; if (!key) { key = "tags"; operator = "*="; if (value?.startsWith("!")) { not = not ? "" : "!"; value = value.slice(1); } } if (not) { operator = "!" + operator; } conditions.push([key, operator, compileValue(value)]); } if (conditions.length) { return compileFilter(conditions); } } /** * Convert a parsed query to a function * * example: [["title", "=", "foo"], ["level", "<", 3]] * returns: (data) => data.title === "foo" && data.level < 3 */ function compileFilter(conditions: Condition[]) { const filters: string[] = []; const args: string[] = []; const values: unknown[] = []; conditions.forEach((condition, index) => { const [key, operator, value] = condition; const varName = `value${index}`; filters.push(compileCondition(key, operator, varName, value)); args.push(varName); values.push(value); }); args.push(`return (data) => ${filters.join(" && ")};`); const factory = new Function(...args); return factory(...values); } /** * Convert a parsed condition to a function * * example: key = "data.title", operator = "=" name = "value0" value = "foo" * returns: data.title === value0 */ function compileCondition( key: string, operator: string, name: string, value: unknown, ) { key = key.replaceAll(".", "?."); if (value instanceof Date) { switch (operator) { case "=": return `data.${key}?.getTime() === ${name}.getTime()`; case "!=": return `data.${key}?.getTime() !== ${name}.getTime()`; case "<": case "<=": case ">": case ">=": return `data.${key}?.getTime() ${operator} ${name}.getTime()`; case "!<": case "!<=": case "!>": case "!>=": return `!(data.${key}?.getTime() ${ operator.substring(1) } ${name}.getTime())`; default: throw new Error(`Operator ${operator} not valid for Date values`); } } if (Array.isArray(value)) { switch (operator) { case "=": return `${name}.some((i) => data.${key} === i)`; case "!=": return `${name}.some((i) => data.${key} !== i)`; case "^=": return `${name}.some((i) => data.${key}?.startsWith(i))`; case "!^=": return `!${name}.some((i) => data.${key}?.startsWith(i))`; case "$=": return `${name}.some((i) => data.${key}?.endsWith(i))`; case "!$=": return `!${name}.some((i) => data.${key}?.endsWith(i))`; case "*=": return `${name}.some((i) => data.${key}?.includes(i))`; case "!*=": return `${name}.some((i) => data.${key}?.includes(i))`; case "!<": case "!<=": case "!>": case "!>=": return `!${name}.some((i) => data.${key} ${operator.substring(1)} i)`; default: // < <= > >= return `${name}.some((i) => data.${key} ${operator} i)`; } } switch (operator) { case "=": return `data.${key} === ${name}`; case "!=": return `data.${key} !== ${name}`; case "^=": return `data.${key}?.startsWith(${name})`; case "!^=": return `!data.${key}?.startsWith(${name})`; case "$=": return `data.${key}?.endsWith(${name})`; case "!$=": return `!data.${key}?.endsWith(${name})`; case "*=": return `data.${key}?.includes(${name})`; case "!*=": return `!data.${key}?.includes(${name})`; case "!<": case "!<=": case "!>": case "!>=": return `!(data.${key} ${operator.substring(1)} ${name})`; default: // < <= > >= return `data.${key} ${operator} ${name}`; } } /** * Compile a value and return the proper type * * example: "true" => true * example: "foo" => "foo" * example: "2021-06-12" => new Date(2021, 05, 12) */ function compileValue(value: string): unknown { if (!value) { return value; } // Remove quotes const quoted = !!value.match(/^('|")(.*)\1$/); if (quoted) { value = value.slice(1, -1); } if (value.includes("|")) { return value.split("|").map((val) => compileValue(val)); } if (quoted) { return value; } if (value.toLowerCase() === "true") { return true; } if (value.toLowerCase() === "false") { return false; } if (value.toLowerCase() === "undefined") { return undefined; } if (value.toLowerCase() === "null") { return null; } if (value.match(/^\d+$/)) { return Number(value); } if (typeof value === "number" && isFinite(value)) { return Number(value); } // Date or datetime values: // yyyy-mm // yyyy-mm-dd // yyyy-mm-ddThh // yyyy-mm-ddThh:ii // yyyy-mm-ddThh:ii:ss const match = value.match( /^(\d{4})-(\d\d)(?:-(\d\d))?(?:T(\d\d)(?::(\d\d))?(?::(\d\d))?)?$/i, ); if (match) { const [, year, month, day, hour, minute, second] = match; return new Date( parseInt(year), parseInt(month) - 1, day ? parseInt(day) : 1, hour ? parseInt(hour) : 0, minute ? parseInt(minute) : 0, second ? parseInt(second) : 0, ); } return value; } /** * Convert a query to sort to a function * * example: "title=desc" * returns: (a, b) => a.title > b.title */ export function buildSort(sort: string): (a: Data, b: Data) => number { let fn = "0"; let init = ""; const pieces = sort.split(/\s+/).filter((arg) => arg); pieces.reverse().forEach((arg) => { const match = arg.match(/([\w.-]+)(?:=(asc-locale|desc-locale|asc|desc))?/); if (!match) { return; } let [, key, direction] = match; key = key.replaceAll(".", "?."); switch (direction) { case "asc-locale": init = "const collator = new Intl.Collator();"; fn = `(a.${key} == b.${key} ? ${fn} : collator.compare(a.${key} || "", b.${key} || ""))`; break; case "desc-locale": init = "const collator = new Intl.Collator();"; fn = `(a.${key} == b.${key} ? ${fn} : collator.compare(b.${key} || "", a.${key} || ""))`; break; case "desc": fn = `(a.${key} == b.${key} ? ${fn} : (a.${key} ?? "") < (b.${key} ?? "") ? 1 : -1)`; break; default: fn = `(a.${key} == b.${key} ? ${fn} : (a.${key} ?? "") > (b.${key} ?? "") ? 1 : -1)`; break; } }); return new Function(`${init} return function (a, b) { return ${fn}; }`)() as ( a: Data, b: Data, ) => number; }