From 359130544bdb7ee32bf98d8b2032087f43d2f0c8 Mon Sep 17 00:00:00 2001 From: Oscar Otero Date: Thu, 11 May 2023 19:07:49 +0200 Subject: [PATCH] moved the code of search plugin to the core --- CHANGELOG.md | 7 + core.ts | 2 + core/searcher.ts | 398 ++++++++++++++++++++++++ core/site.ts | 18 ++ plugins/feed.ts | 5 +- plugins/nav.ts | 27 +- plugins/search.ts | 396 ++--------------------- plugins/sitemap.ts | 13 +- tests/__snapshots__/search.test.ts.snap | 54 ++-- tests/search.test.ts | 56 ++-- 10 files changed, 531 insertions(+), 445 deletions(-) create mode 100644 core/searcher.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 93dc4cc3..0e755088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project try to adheres to [Semantic Versioning](https://semver.org/), but not always is possible (due the use of unstable features from Deno). Any BREAKING CHANGE between minor versions will be documented here in upper case. +## [Unreleased] +### Added +- New `site.searcher` property with a instance of `Searcher` class. + It's used by plugins like `search`, `nav`, `sitemap` and `feed`. + (Previouly, each plugin had it's own instance). + ## [1.17.3] - 2023-05-10 ### Fixed - The `lume/` import is not correctly generated with `lume init`. @@ -2229,6 +2235,7 @@ The first version. [#418]: https://github.com/lumeland/lume/issues/418 [#419]: https://github.com/lumeland/lume/issues/419 +[Unreleased]: https://github.com/lumeland/lume/compare/v1.17.3...HEAD [1.17.3]: https://github.com/lumeland/lume/compare/v1.17.2...v1.17.3 [1.17.2]: https://github.com/lumeland/lume/compare/v1.17.1...v1.17.2 [1.17.1]: https://github.com/lumeland/lume/compare/v1.17.0...v1.17.1 diff --git a/core.ts b/core.ts index 0ae3a85b..f09e5046 100644 --- a/core.ts +++ b/core.ts @@ -25,6 +25,7 @@ import type { default as Scripts, ScriptOrFunction } from "./core/scripts.ts"; import type { default as FS, Entry, Loader } from "./core/fs.ts"; import type Logger from "./core/logger.ts"; +import type Searcher from "./core/searcher.ts"; import type Writer from "./core/writer.ts"; import type IncludesLoader from "./core/includes_loader.ts"; import type DataLoader from "./core/data_loader.ts"; @@ -133,6 +134,7 @@ export type { Scopes, ScriptOrFunction, Scripts, + Searcher, Server, ServerEvent, ServerEventType, diff --git a/core/searcher.ts b/core/searcher.ts new file mode 100644 index 00000000..0a30a7d9 --- /dev/null +++ b/core/searcher.ts @@ -0,0 +1,398 @@ +import { normalizePath } from "./utils.ts"; +import { Data, Page } from "../core.ts"; + +export interface Options { + /** The pages array */ + pages: Page[]; + + /** Context data */ + sourceData: Map; + + filters?: Filter[]; +} + +type Filter = (data: Data) => boolean; +type Condition = [string, string, unknown]; + +/** Search helper */ +export default class Searcher { + #pages: Page[]; + #sourceData: Map; + #cache = new Map(); + #filters: Filter[]; + + constructor(options: Options) { + this.#pages = options.pages; + this.#sourceData = options.sourceData; + this.#filters = options.filters || []; + } + + /** Clear the cache (used after a change in watch mode) */ + deleteCache() { + this.#cache.clear(); + } + + /** + * Return the data in the scope of a path (file or folder) + */ + data(path = "/"): Data | undefined { + const normalized = normalizePath(path); + const dirData = this.#sourceData.get(normalized); + + if (dirData) { + return dirData; + } + + const result = this.#pages.find((page) => page.data.url === normalized); + + if (result) { + return result.data; + } + } + + /** Search pages */ + pages(query?: string, sort?: string, limit?: number): Data[] { + const result = this.#searchPages(query, sort); + + if (!limit) { + return result; + } + + return (limit < 0) ? result.slice(limit) : result.slice(0, limit); + } + + /** 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 | 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 | 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[] { + const id = JSON.stringify([query, sort]); + + if (this.#cache.has(id)) { + return [...this.#cache.get(id)!]; + } + + 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]; + } +} + +/** + * 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"; + + const pieces = sort.split(/\s+/).filter((arg) => arg); + + pieces.reverse().forEach((arg) => { + const match = arg.match(/([\w.-]+)(?:=(asc|desc))?/); + + if (!match) { + return; + } + + let [, key, direction] = match; + key = key.replaceAll(".", "?."); + const operator = direction === "desc" ? ">" : "<"; + fn = + `(a.${key} == b.${key} ? ${fn} : (a.${key} ${operator} b.${key} ? -1 : 1))`; + }); + + return new Function("a", "b", `return ${fn}`) as (a: Data, b: Data) => number; +} diff --git a/core/site.ts b/core/site.ts index 50216934..af4d69a1 100644 --- a/core/site.ts +++ b/core/site.ts @@ -13,6 +13,7 @@ import Renderer from "./renderer.ts"; import Events from "./events.ts"; import Formats from "./formats.ts"; import Logger from "./logger.ts"; +import Searcher from "./searcher.ts"; import Scripts from "./scripts.ts"; import Writer from "./writer.ts"; import textLoader from "./loaders/text.ts"; @@ -116,6 +117,9 @@ export default class Site { /** To run scripts */ scripts: Scripts; + /** To search pages */ + searcher: Searcher; + /** To write the generated pages in the dest folder */ writer: Writer; @@ -178,6 +182,17 @@ export default class Site { const logger = new Logger({ quiet }); const scripts = new Scripts({ logger, cwd }); const writer = new Writer({ src, dest, logger }); + const searcher = new Searcher({ + pages: this.pages, + sourceData: source.data, + filters: [ + (data: Data) => data.page?.outputPath?.endsWith(".html") ?? false, // only html pages + (data: Data) => + options.server?.page404 + ? data.url !== normalizePath(options.server.page404) + : true, // not the 404 page + ], + }); // Save everything in the site instance this.fs = fs; @@ -193,6 +208,7 @@ export default class Site { this.events = events; this.logger = logger; this.scripts = scripts; + this.searcher = searcher; this.writer = writer; // Ignore the "dest" directory if it's inside src @@ -533,6 +549,8 @@ export default class Site { return; } + this.searcher.deleteCache(); + // Reload the changed files for (const file of files) { // Delete the file from the cache diff --git a/plugins/feed.ts b/plugins/feed.ts index 25cc941a..f3a33148 100644 --- a/plugins/feed.ts +++ b/plugins/feed.ts @@ -7,7 +7,6 @@ import { import { getDataValue } from "./utils.ts"; import { $XML, stringify } from "../deps/xml.ts"; import { Page } from "../core/filesystem.ts"; -import { Search } from "../plugins/search.ts"; import type { Data, Site } from "../core.ts"; @@ -79,14 +78,12 @@ export default (userOptions?: DeepPartial) => { const options = merge(defaults, userOptions); return (site: Site) => { - const search = new Search(site, true); - site.addEventListener("beforeSave", () => { const output = Array.isArray(options.output) ? options.output : [options.output]; - const pages = search.pages( + const pages = site.searcher.pages( options.query, options.sort, options.limit, diff --git a/plugins/nav.ts b/plugins/nav.ts index f8c1e994..70170edd 100644 --- a/plugins/nav.ts +++ b/plugins/nav.ts @@ -1,7 +1,6 @@ import { merge } from "../core/utils.ts"; -import { Search } from "./search.ts"; -import type { Data, Page, Site } from "../core.ts"; +import type { Data, Searcher, Site } from "../core.ts"; export interface Options { /** The helper name */ @@ -20,18 +19,24 @@ export default function (userOptions?: Partial) { const options = merge(defaults, userOptions); return (site: Site) => { - site.data(options.name, new Nav(site)); + const nav = new Nav(site.searcher); + site.data(options.name, nav); + site.addEventListener("beforeUpdate", () => nav.deleteCache()); }; } /** Search helper */ export class Nav { #cache = new Map(); - #search: Search; + #search: Searcher; - constructor(site: Site) { - site.addEventListener("beforeUpdate", () => this.#cache.clear()); - this.#search = new Search(site, false); + constructor(searcher: Searcher) { + this.#search = searcher; + } + + /** Clear the cache (used after a change in watch mode) */ + deleteCache() { + this.#cache.clear(); } menu(url?: "/", query?: string, sort?: string): NavData; @@ -67,10 +72,10 @@ export class Nav { slug: "", }; - const pages = this.#search.pages(query, sort) as Page[]; + const dataPages = this.#search.pages(query, sort); - for (const page of pages) { - const url = page.outputPath; + for (const data of dataPages) { + const url = data.page?.outputPath; if (!url) { continue; @@ -82,7 +87,7 @@ export class Nav { while (part) { if (part === "index.html") { - current.data = page.data; + current.data = data; break; } diff --git a/plugins/search.ts b/plugins/search.ts index f197aef3..3f07ea7b 100644 --- a/plugins/search.ts +++ b/plugins/search.ts @@ -1,6 +1,6 @@ -import { merge, normalizePath } from "../core/utils.ts"; +import { merge } from "../core/utils.ts"; -import { Data, Page, Site } from "../core.ts"; +import { Data, Page, Searcher, Site } from "../core.ts"; export interface Options { /** The helper name */ @@ -15,85 +15,50 @@ export const defaults: Options = { returnPageData: false, }; +type Query = string | string[]; + /** Register the plugin to enable the `search` helpers */ export default function (userOptions?: Partial) { const options = merge(defaults, userOptions); return (site: Site) => { - site.data(options.name, new Search(site, options.returnPageData)); + site.data(options.name, new Search(site.searcher, options.returnPageData)); site.filter("data", data); }; } -type Query = string | string[]; -type Condition = [string, string, unknown]; - /** Search helper */ export class Search { - #site: Site; - #cache = new Map(); + #searcher: Searcher; #returnPageData: boolean; - constructor(site: Site, returnPageData: boolean) { - this.#site = site; + constructor(searcher: Searcher, returnPageData: boolean) { + this.#searcher = searcher; this.#returnPageData = returnPageData; - - site.addEventListener("beforeUpdate", () => this.#cache.clear()); } /** * Return the data in the scope of a path (file or folder) - * @deprecated Use `search.page()` instead */ data(path = "/"): Data | undefined { - const normalized = normalizePath(path); - const dirData = this.#site.source.data.get(normalized); - - if (dirData) { - return dirData; - } - - const result = this.#site.pages.find((page) => - page.data.url === normalized - ); - - if (result) { - return result.data; - } + return this.#searcher.data(path); } /** Search pages */ pages(query?: Query, sort?: Query, limit?: number) { - const pages = this.#searchPages(query, sort); - const result = this.#returnPageData ? data(pages) : pages; - - if (!limit) { - return result; - } + const result = this.#searcher.pages(toString(query), toString(sort), limit); - return (limit < 0) ? result.slice(limit) : result.slice(0, limit); + return this.#returnPageData ? result : result.map((data) => data.page); } - /** Search and return a page */ + /** Search and return one page */ page(query?: Query, sort?: Query) { return this.pages(query, sort)[0]; } /** Returns all values from the same key of a search */ values(key: string, query?: Query) { - const values = new Set(); - - this.#searchPages(query).forEach((page) => { - const value = page.data[key]; - - if (Array.isArray(value)) { - value.forEach((v) => values.add(v)); - } else if (value !== undefined) { - values.add(value); - } - }); - - return Array.from(values); + return this.#searcher.values(key, toString(query)); } /** Returns all tags values of a search */ @@ -103,46 +68,22 @@ export class Search { /** Return the next page of a search */ nextPage(url: string, query?: Query, sort?: Query) { - const pages = this.#searchPages(query, sort); - const index = pages.findIndex((page) => page.data.url === url); - - return (index === -1) - ? undefined - : this.#returnPageData - ? pages[index + 1].data - : pages[index + 1]; + const result = this.#searcher.nextPage( + url, + toString(query), + toString(sort), + ); + return this.#returnPageData ? result : result?.page; } /** Return the previous page of a search */ previousPage(url: string, query?: Query, sort?: Query) { - const pages = this.#searchPages(query, sort); - const index = pages.findIndex((page) => page.data.url === url); - - return (index <= 0) - ? undefined - : this.#returnPageData - ? pages[index - 1].data - : pages[index - 1]; - } - - #searchPages(query?: Query, sort: Query = "date"): Page[] { - if (Array.isArray(query)) { - query = query.join(" "); - } - - const id = JSON.stringify([query, sort]); - - if (this.#cache.has(id)) { - return [...this.#cache.get(id)!]; - } - - const filter = buildFilter(query, this.#site.options.server.page404); - const result = this.#site.pages.filter(filter); - - result.sort(buildSort(sort)); - - this.#cache.set(id, result); - return [...result]; + const result = this.#searcher.previousPage( + url, + toString(query), + toString(sort), + ); + return this.#returnPageData ? result : result?.page; } } @@ -150,289 +91,6 @@ function data(pages: Page[]): Data[] { return pages.map((page) => page.data); } -/** - * 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 = "", page404 = ""): (page: Page) => boolean { - // (?:(not)?(fieldName)(operator))?(value|"value"|'value') - const matches = query - ? query.matchAll( - /(?:(!)?([\w.-]+)([!^$*]?=|[<>]=?))?([^'"\s][^\s=<>]*|"[^"]+"|'[^']+')/g, - ) - : []; - - const conditions: Condition[] = [ - // Always return html pages - ["outputPath", "$=", ".html"], - - // Exclude the 404 page - ["data.url", "!=", page404], - ]; - - 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([`data.${key}`, operator, compileValue(value)]); - } - - return compileFilter(conditions); -} - -/** - * Convert a parsed query to a function - * - * example: [["data.title", "=", "foo"], ["data.level", "<", 3]] - * returns: (page) => page.data.title === "foo" && page.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 (page) => ${filters.join(" && ")};`); - - const factory = new Function(...args); - - return factory(...values); -} - -/** - * Convert a parsed condition to a function - * - * example: key = "title", operator = "=" name = "value0" value = "foo" - * returns: page.data.title === value0 - */ -function compileCondition( - key: string, - operator: string, - name: string, - value: unknown, -) { - key = key.replaceAll(".", "?."); - - if (value instanceof Date) { - switch (operator) { - case "=": - return `page.${key}?.getTime() === ${name}.getTime()`; - - case "!=": - return `page.${key}?.getTime() !== ${name}.getTime()`; - - case "<": - case "<=": - case ">": - case ">=": - return `page.${key}?.getTime() ${operator} ${name}.getTime()`; - - case "!<": - case "!<=": - case "!>": - case "!>=": - return `!(page.${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) => page.${key} === i)`; - - case "!=": - return `${name}.some((i) => page.${key} !== i)`; - - case "^=": - return `${name}.some((i) => page.${key}?.startsWith(i))`; - - case "!^=": - return `!${name}.some((i) => page.${key}?.startsWith(i))`; - - case "$=": - return `${name}.some((i) => page.${key}?.endsWith(i))`; - - case "!$=": - return `!${name}.some((i) => page.${key}?.endsWith(i))`; - - case "*=": - return `${name}.some((i) => page.${key}?.includes(i))`; - - case "!*=": - return `${name}.some((i) => page.${key}?.includes(i))`; - - case "!<": - case "!<=": - case "!>": - case "!>=": - return `!${name}.some((i) => page.${key} ${operator.substring(1)} i)`; - - default: // < <= > >= - return `${name}.some((i) => page.${key} ${operator} i)`; - } - } - - switch (operator) { - case "=": - return `page.${key} === ${name}`; - - case "!=": - return `page.${key} !== ${name}`; - - case "^=": - return `page.${key}?.startsWith(${name})`; - - case "!^=": - return `!page.${key}?.startsWith(${name})`; - - case "$=": - return `page.${key}?.endsWith(${name})`; - - case "!$=": - return `!page.${key}?.endsWith(${name})`; - - case "*=": - return `page.${key}?.includes(${name})`; - - case "!*=": - return `!page.${key}?.includes(${name})`; - - case "!<": - case "!<=": - case "!>": - case "!>=": - return `!(page.${key} ${operator.substring(1)} ${name})`; - - default: // < <= > >= - return `page.${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.data.title > b.data.title - */ -export function buildSort(sort: Query): (a: Page, b: Page) => number { - let fn = "0"; - - if (typeof sort === "string") { - sort = sort.split(/\s+/).filter((arg) => arg); - } - - sort.reverse().forEach((arg) => { - const match = arg.match(/([\w.-]+)(?:=(asc|desc))?/); - - if (!match) { - return; - } - - let [, key, direction] = match; - key = key.replaceAll(".", "?."); - const operator = direction === "desc" ? ">" : "<"; - fn = - `(a.data?.${key} == b.data?.${key} ? ${fn} : (a.data?.${key} ${operator} b.data?.${key} ? -1 : 1))`; - }); - - return new Function("a", "b", `return ${fn}`) as (a: Page, b: Page) => number; +function toString(value?: Query): string | undefined { + return Array.isArray(value) ? value.join(" ") : value; } diff --git a/plugins/sitemap.ts b/plugins/sitemap.ts index 0f3b2162..cd1caeac 100644 --- a/plugins/sitemap.ts +++ b/plugins/sitemap.ts @@ -1,8 +1,7 @@ import { merge } from "../core/utils.ts"; import { Page } from "../core/filesystem.ts"; -import { Search } from "../plugins/search.ts"; -import type { Data, Site, StaticFile } from "../core.ts"; +import type { Data, Searcher, Site, StaticFile } from "../core.ts"; export interface Options { /** The sitemap file name */ @@ -33,7 +32,10 @@ export default function (userOptions?: Partial) { return (site: Site) => { site.addEventListener("afterRender", () => { // Create the sitemap.xml page - const sitemap = Page.create(options.filename, getSitemapContent(site)); + const sitemap = Page.create( + options.filename, + getSitemapContent(site.searcher), + ); // Add to the sitemap page to pages site.pages.push(sitemap); @@ -62,9 +64,8 @@ export default function (userOptions?: Partial) { } }); - function getSitemapContent(site: Site) { - const search = new Search(site, true); - const sitemap = search.pages(options.query, options.sort) + function getSitemapContent(searcher: Searcher) { + const sitemap = searcher.pages(options.query, options.sort) .map((data: Data) => getPageData(data)); // deno-fmt-ignore diff --git a/tests/__snapshots__/search.test.ts.snap b/tests/__snapshots__/search.test.ts.snap index 098d5e1b..8ec6982b 100644 --- a/tests/__snapshots__/search.test.ts.snap +++ b/tests/__snapshots__/search.test.ts.snap @@ -1,58 +1,58 @@ export const snapshot = {}; -snapshot[`Search by Tags 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.tags?.includes(value2) && page.data?.tags?.includes(value3)"`; +snapshot[`Search by Tags 1`] = `"(data) => data.tags?.includes(value0) && data.tags?.includes(value1)"`; -snapshot[`Search by NOT Tags 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.tags?.includes(value2) && !page.data?.tags?.includes(value3)"`; +snapshot[`Search by NOT Tags 1`] = `"(data) => data.tags?.includes(value0) && !data.tags?.includes(value1)"`; -snapshot[`Search by Equal 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo === value2"`; +snapshot[`Search by Equal 1`] = `"(data) => data.foo === value0"`; -snapshot[`Search by Equal undefined 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo === value2"`; +snapshot[`Search by Equal undefined 1`] = `"(data) => data.foo === value0"`; -snapshot[`Search by Equal null 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo === value2"`; +snapshot[`Search by Equal null 1`] = `"(data) => data.foo === value0"`; -snapshot[`Search by Upper than 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo > value2"`; +snapshot[`Search by Upper than 1`] = `"(data) => data.foo > value0"`; -snapshot[`Search by Upper or equals than 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo >= value2"`; +snapshot[`Search by Upper or equals than 1`] = `"(data) => data.foo >= value0"`; -snapshot[`Search by Lower than 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo < value2"`; +snapshot[`Search by Lower than 1`] = `"(data) => data.foo < value0"`; -snapshot[`Search by Lower or equals than 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo <= value2"`; +snapshot[`Search by Lower or equals than 1`] = `"(data) => data.foo <= value0"`; -snapshot[`Search by Not Equal 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo !== value2"`; +snapshot[`Search by Not Equal 1`] = `"(data) => data.foo !== value0"`; -snapshot[`Search by Not Equal alt 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo !== value2"`; +snapshot[`Search by Not Equal alt 1`] = `"(data) => data.foo !== value0"`; -snapshot[`Search by Starts With 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo?.startsWith(value2)"`; +snapshot[`Search by Starts With 1`] = `"(data) => data.foo?.startsWith(value0)"`; -snapshot[`Search by NOT Starts With 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && !page.data?.foo?.startsWith(value2)"`; +snapshot[`Search by NOT Starts With 1`] = `"(data) => !data.foo?.startsWith(value0)"`; -snapshot[`Search by Ends With 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo?.endsWith(value2)"`; +snapshot[`Search by Ends With 1`] = `"(data) => data.foo?.endsWith(value0)"`; -snapshot[`Search by Contains 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo?.includes(value2)"`; +snapshot[`Search by Contains 1`] = `"(data) => data.foo?.includes(value0)"`; -snapshot[`Search by Tags with OR 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && value2.some((i) => page.data?.tags?.includes(i))"`; +snapshot[`Search by Tags with OR 1`] = `"(data) => value0.some((i) => data.tags?.includes(i))"`; -snapshot[`Search by Equal with OR 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && value2.some((i) => page.data?.foo === i)"`; +snapshot[`Search by Equal with OR 1`] = `"(data) => value0.some((i) => data.foo === i)"`; -snapshot[`Search by Not Equal with OR 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && value2.some((i) => page.data?.foo !== i)"`; +snapshot[`Search by Not Equal with OR 1`] = `"(data) => value0.some((i) => data.foo !== i)"`; -snapshot[`Search by Starts With with OR 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && value2.some((i) => page.data?.foo?.startsWith(i))"`; +snapshot[`Search by Starts With with OR 1`] = `"(data) => value0.some((i) => data.foo?.startsWith(i))"`; -snapshot[`Search by Ends With with OR 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && value2.some((i) => page.data?.foo?.endsWith(i))"`; +snapshot[`Search by Ends With with OR 1`] = `"(data) => value0.some((i) => data.foo?.endsWith(i))"`; -snapshot[`Search by Contains with OR 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && value2.some((i) => page.data?.foo?.includes(i))"`; +snapshot[`Search by Contains with OR 1`] = `"(data) => value0.some((i) => data.foo?.includes(i))"`; -snapshot[`Search Date by Equal 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo?.getTime() === value2.getTime()"`; +snapshot[`Search Date by Equal 1`] = `"(data) => data.foo?.getTime() === value0.getTime()"`; -snapshot[`Search Date by Not Equal 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo?.getTime() !== value2.getTime()"`; +snapshot[`Search Date by Not Equal 1`] = `"(data) => data.foo?.getTime() !== value0.getTime()"`; -snapshot[`Search Date by lower than 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo?.getTime() < value2.getTime()"`; +snapshot[`Search Date by lower than 1`] = `"(data) => data.foo?.getTime() < value0.getTime()"`; -snapshot[`Search Date by lower or equals than 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo?.getTime() <= value2.getTime()"`; +snapshot[`Search Date by lower or equals than 1`] = `"(data) => data.foo?.getTime() <= value0.getTime()"`; -snapshot[`Search Date by upper than 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo?.getTime() > value2.getTime()"`; +snapshot[`Search Date by upper than 1`] = `"(data) => data.foo?.getTime() > value0.getTime()"`; -snapshot[`Search Date by upper or equals than 1`] = `"(page) => page.outputPath?.endsWith(value0) && page.data?.url !== value1 && page.data?.foo?.getTime() >= value2.getTime()"`; +snapshot[`Search Date by upper or equals than 1`] = `"(data) => data.foo?.getTime() >= value0.getTime()"`; snapshot[`Sort by one field 1`] = `[Function: anonymous]`; diff --git a/tests/search.test.ts b/tests/search.test.ts index 4b164118..ce0458a9 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -1,166 +1,166 @@ import { assertSnapshot } from "../deps/snapshot.ts"; -import { buildFilter, buildSort } from "../plugins/search.ts"; +import { buildFilter, buildSort } from "../core/searcher.ts"; Deno.test("Search by Tags", async (t) => { const filter = buildFilter("foo bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by NOT Tags", async (t) => { const filter = buildFilter("foo !bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Equal", async (t) => { const filter = buildFilter("foo=bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Equal undefined", async (t) => { const filter = buildFilter("foo=undefined"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Equal null", async (t) => { const filter = buildFilter("foo=null"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Upper than", async (t) => { const filter = buildFilter("foo>bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Upper or equals than", async (t) => { const filter = buildFilter("foo>=bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Lower than", async (t) => { const filter = buildFilter("foo { const filter = buildFilter("foo<=bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Not Equal", async (t) => { const filter = buildFilter("foo!=bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Not Equal alt", async (t) => { const filter = buildFilter("!foo=bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Starts With", async (t) => { const filter = buildFilter("foo^=bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by NOT Starts With", async (t) => { const filter = buildFilter("!foo^=bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Ends With", async (t) => { const filter = buildFilter("foo$=bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Contains", async (t) => { const filter = buildFilter("foo*=bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Tags with OR", async (t) => { const filter = buildFilter("foo|bar"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Equal with OR", async (t) => { const filter = buildFilter("foo=bar|baz"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Not Equal with OR", async (t) => { const filter = buildFilter("foo!=bar|baz"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Starts With with OR", async (t) => { const filter = buildFilter("foo^=bar|baz"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Ends With with OR", async (t) => { const filter = buildFilter("foo$=bar|baz"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search by Contains with OR", async (t) => { const filter = buildFilter("foo*=bar|baz"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search Date by Equal", async (t) => { const filter = buildFilter("foo=2000-01-02"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search Date by Not Equal", async (t) => { const filter = buildFilter("foo!=2000-01-02"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search Date by lower than", async (t) => { const filter = buildFilter("foo<2000-01-02T18:00"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search Date by lower or equals than", async (t) => { const filter = buildFilter("foo<=2000-01-02T18:00"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search Date by upper than", async (t) => { const filter = buildFilter("foo>2000-01-02T18:00"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Search Date by upper or equals than", async (t) => { const filter = buildFilter("foo>=2000-01-02T18:00"); - await assertSnapshot(t, filter.toString()); + await assertSnapshot(t, filter?.toString()); }); Deno.test("Sort by one field", async (t) => {