import { join, posix } from "../deps/path.ts";import { merge } from "./utils/object.ts";import { normalizePath } from "./utils/path.ts";import { env } from "./utils/env.ts";import { log } from "./utils/log.ts";
import FS from "./fs.ts";import ComponentLoader from "./component_loader.ts";import DataLoader from "./data_loader.ts";import Source from "./source.ts";import Scopes from "./scopes.ts";import Processors from "./processors.ts";import Renderer from "./renderer.ts";import Events from "./events.ts";import Formats from "./formats.ts";import Searcher from "./searcher.ts";import Scripts from "./scripts.ts";import FSWatcher from "../core/watcher.ts";import { FSWriter } from "./writer.ts";import { Page } from "./file.ts";import textLoader from "./loaders/text.ts";
import type { Component, Components } from "./component_loader.ts";import type { Data, RawData, StaticFile } from "./file.ts";import type { Engine, Helper, HelperOptions } from "./renderer.ts";import type { Event, EventListener, EventOptions } from "./events.ts";import type { Processor } from "./processors.ts";import type { Extensions } from "./utils/path.ts";import type { Loader } from "./fs.ts";import type { Writer } from "./writer.ts";import type { Middleware } from "./server.ts";import type { ScopeFilter } from "./scopes.ts";import type { ScriptOrFunction } from "./scripts.ts";import type { Watcher } from "./watcher.ts";import type { MergeStrategy } from "./utils/merge_data.ts";
const defaults: SiteOptions = { cwd: Deno.cwd(), src: "./", dest: "./_site", emptyDest: true, includes: "_includes", location: new URL("http://localhost"), prettyUrls: true, server: { port: 3000, open: false, page404: "/404.html", middlewares: [], }, watcher: { ignore: [], debounce: 100, }, components: { variable: "comp", cssFile: "/components.css", jsFile: "/components.js", },};
export default class Site { options: SiteOptions;
_data: Record<string, unknown> = {};
fs: FS;
formats: Formats;
dataLoader: DataLoader;
componentLoader: ComponentLoader;
source: Source;
scopes: Scopes;
processors: Processors;
preprocessors: Processors;
renderer: Renderer;
events: Events<any>;
scripts: Scripts;
search: Searcher;
writer: Writer;
scopedData = new Map<string, RawData>([["/", {}]]);
scopedPages = new Map<string, RawData[]>();
scopedComponents = new Map<string, Components>();
hooks: Record<string, (...args: any[]) => void> = {};
readonly pages: Page[] = [];
readonly onDemandPages: Page[] = [];
readonly files: StaticFile[] = [];
constructor(options: Partial<SiteOptions> = {}) { this.options = merge(defaults, options);
const src = this.src(); const dest = this.dest(); const { includes, cwd, prettyUrls, components, server } = this.options;
const fs = new FS({ root: src }); const formats = new Formats();
const dataLoader = new DataLoader({ formats }); const componentLoader = new ComponentLoader({ formats }); const source = new Source({ fs, dataLoader, componentLoader, formats, components, scopedData: this.scopedData, scopedPages: this.scopedPages, scopedComponents: this.scopedComponents, prettyUrls, });
const scopes = new Scopes(); const processors = new Processors(); const preprocessors = new Processors(); const renderer = new Renderer({ prettyUrls, preprocessors, formats, fs, includes, });
const events = new Events<SiteEvent>(); const scripts = new Scripts({ cwd }); const writer = new FSWriter({ dest });
const url404 = server.page404 ? normalizePath(server.page404) : undefined; const searcher = new Searcher({ pages: this.pages, files: this.files, sourceData: source.data, filters: [ (data: Data) => data.page.outputPath.endsWith(".html") ?? false, (data: Data) => !url404 || data.url !== url404, ], });
this.fs = fs; this.formats = formats; this.componentLoader = componentLoader; this.dataLoader = dataLoader; this.source = source; this.scopes = scopes; this.processors = processors; this.preprocessors = preprocessors; this.renderer = renderer; this.events = events; this.scripts = scripts; this.search = searcher; this.writer = writer;
if (this.dest().startsWith(this.src())) { this.ignore(this.options.dest); }
this.options.watcher.ignore.push(normalizePath(this.options.dest)); this.fs.options.ignore = this.options.watcher.ignore; }
get globalData(): RawData { return this.scopedData.get("/")!; }
root(...path: string[]): string { return normalizePath(join(this.options.cwd, ...path)); }
src(...path: string[]): string { return this.root(this.options.src, ...path); }
dest(...path: string[]): string { return this.root(this.options.dest, ...path); }
addEventListener<K extends SiteEventType>( type: K, listener: EventListener<Event & SiteEvent<K>> | string, options?: EventOptions, ): this { const fn = typeof listener === "string" ? () => this.run(listener) : listener;
this.events.addEventListener(type, fn, options); return this; }
dispatchEvent(event: SiteEvent): Promise<boolean> { return this.events.dispatchEvent(event); }
use(plugin: Plugin): this { plugin(this); return this; }
script(name: string, ...scripts: ScriptOrFunction[]): this { this.scripts.set(name, ...scripts); return this; }
async run(name: string): Promise<boolean> { return await this.scripts.run(name); }
loadData(extensions: string[], dataLoader: Loader = textLoader): this { extensions.forEach((ext) => { this.formats.set({ ext, dataLoader }); });
return this; }
loadPages( extensions: string[], options: LoadPagesOptions | Loader = {}, ): this { if (typeof options === "function") { options = { loader: options }; }
const { engine, pageSubExtension } = options; const loader = options.loader || textLoader; const engines = Array.isArray(engine) ? engine : engine ? [engine] : [];
const pageExtensions = pageSubExtension ? extensions.map((ext) => pageSubExtension + ext) : extensions;
pageExtensions.forEach((ext) => { this.formats.set({ ext, loader, pageType: "page", engines, }); });
if (pageSubExtension) { extensions.forEach((ext) => this.formats.set({ ext, loader, engines })); }
for (const [name, helper] of this.renderer.helpers) { engines.forEach((engine) => engine.addHelper(name, ...helper)); }
return this; }
loadAssets(extensions: string[], assetLoader: Loader = textLoader): this { extensions.forEach((ext) => { this.formats.set({ ext, assetLoader, pageType: "asset", }); });
return this; }
preprocess(extensions: Extensions, preprocessor: Processor): this { this.preprocessors.set(extensions, preprocessor); return this; }
process(extensions: Extensions, processor: Processor): this { this.processors.set(extensions, processor); return this; }
filter(name: string, filter: Helper, async = false): this { return this.helper(name, filter, { type: "filter", async }); }
helper(name: string, fn: Helper, options: HelperOptions): this { this.renderer.addHelper(name, fn, options); return this; }
data(name: string, value: unknown, scope = "/"): this { const data = this.scopedData.get(scope) || {}; data[name] = value; this.scopedData.set(scope, data); return this; }
page(data: Partial<Data>, scope = "/"): this { const pages = this.scopedPages.get(scope) || []; pages.push(data); this.scopedPages.set(scope, pages); return this; }
component(context: string, component: Component, scope = "/"): this { const pieces = context.split("."); const scopedComponents: Components = this.scopedComponents.get(scope) || new Map(); let components: Components = scopedComponents;
while (pieces.length) { const name = pieces.shift()!; if (!components.get(name)) { components.set(name, new Map()); } components = components.get(name) as Components; }
components.set(component.name, component); this.scopedComponents.set(scope, scopedComponents); return this; }
mergeKey(key: string, merge: MergeStrategy, scope = "/"): this { const data = this.scopedData.get(scope) || {}; const mergedKeys = data.mergedKeys || {}; mergedKeys[key] = merge; data.mergedKeys = mergedKeys; this.scopedData.set(scope, data); return this; }
copy(from: string, to?: string | ((path: string) => string)): this; copy(from: string[], to?: (path: string) => string): this; copy( from: string | string[], to?: string | ((path: string) => string), ): this { if (Array.isArray(from)) { if (typeof to === "string") { throw new Error( `copy() files by extension expects a function as second argument but got a string "${to}"`, ); }
from.forEach((ext) => { this.formats.set({ ext, copy: to ? to : true }); }); return this; }
this.source.addStaticPath(from, to); return this; }
copyRemainingFiles( filter: (path: string) => string | boolean = () => true, ): this { this.source.copyRemainingFiles = filter; return this; }
ignore(...paths: (string | ScopeFilter)[]): this { paths.forEach((path) => { if (typeof path === "string") { this.source.addIgnoredPath(path); } else { this.source.addIgnoreFilter(path); } }); return this; }
scopedUpdates(...scopes: ScopeFilter[]): this { scopes.forEach((scope) => this.scopes.scopes.add(scope)); return this; }
remoteFile(filename: string, url: string): this { this.fs.remoteFiles.set(posix.join("/", filename), url); return this; }
async clear(): Promise<void> { await this.writer.clear(); }
async build(): Promise<void> { if (await this.dispatchEvent({ type: "beforeBuild" }) === false) { return; }
if (this.options.emptyDest) { await this.clear(); }
performance.mark("start-loadfiles");
this.fs.init();
const showDrafts = env<boolean>("LUME_DRAFTS"); const [_pages, _staticFiles] = await this.source.build( (_, page) => !page?.data.draft || showDrafts === true, );
performance.mark("end-loadfiles");
log.debug( `Pages loaded in ${ (performance.measure("duration", "start-loadfiles", "end-loadfiles") .duration / 1000).toFixed(2) } seconds`, );
this.files.splice(0, this.files.length, ..._staticFiles);
if (await this.#buildPages(_pages) === false) { return; }
const pages = await this.writer.savePages(this.pages); const staticFiles = await this.writer.copyFiles(this.files);
await this.dispatchEvent({ type: "afterBuild", pages, staticFiles }); }
async update(files: Set<string>): Promise<void> { if (await this.dispatchEvent({ type: "beforeUpdate", files }) === false) { return; }
this.search.deleteCache();
for (const file of files) { this.formats.deleteCache(file); const entry = this.fs.update(file);
if (!entry) { continue; }
const pages = this.pages.filter((page) => page.src.entry === entry).map(( page, ) => page.outputPath); const files = this.files.filter((file) => file.entry === entry).map(( file, ) => file.outputPath); await this.writer.removeFiles([...pages, ...files]); }
const showDrafts = env<boolean>("LUME_DRAFTS"); const [_pages, _staticFiles] = await this.source.build( (_, page) => !page?.data.draft || showDrafts === true, this.scopes.getFilter(files), );
this.files.splice(0, this.files.length, ..._staticFiles);
if (await this.#buildPages(_pages) === false) { return; }
const pages = await this.writer.savePages(this.pages); const staticFiles = await this.writer.copyFiles(this.files);
await this.dispatchEvent({ type: "afterUpdate", files, pages, staticFiles, }); }
async #buildPages(pages: Page[]): Promise<boolean> { if (await this.dispatchEvent({ type: "beforeRender", pages }) === false) { return false; } performance.mark("start-render");
this.pages.splice(0); this.onDemandPages.splice(0); await this.renderer.renderPages(pages, this.pages, this.onDemandPages);
for (const extra of this.source.getComponentsExtraCode()) { const page = await this.getOrCreatePage(extra.data.url);
if (page.content) { page.content += `\n${extra.content}`; } else { page.content = extra.content; } }
this.pages.splice( 0, this.pages.length, ...this.pages.filter((page) => { if (page.data.ondemand) { log.debug( `[Lume] <cyan>Skipped page</cyan> ${page.data.url} (page is build only on demand)`, ); return false; }
if (!page.content) { log.warn( `[Lume] <cyan>Skipped page</cyan> ${page.data.url} (file content is empty)`, ); return false; }
return true; }), );
performance.mark("end-render");
log.debug( `Pages rendered in ${ (performance.measure("duration", "start-render", "end-render") .duration / 1000).toFixed(2) } seconds`, );
performance.mark("start-process"); if ( await this.events.dispatchEvent({ type: "afterRender", pages: this.pages, }) === false ) { return false; }
await this.processors.run(this.pages); performance.mark("end-process");
log.debug( `Pages processed in ${ (performance.measure("duration", "start-process", "end-process") .duration / 1000).toFixed(2) } seconds`, );
return await this.dispatchEvent({ type: "beforeSave" }); }
async renderPage( file: string, extraData?: Record<string, unknown>, ): Promise<Page | undefined> { this.fs.init();
const [pages] = await this.source.build( (entry) => (entry.type === "directory" && file.startsWith(entry.path)) || entry.path === file, );
const page = pages[0];
if (!page) { return; }
if (extraData) { page.data = { ...page.data, ...extraData }; }
await this.dispatchEvent({ type: "beforeRenderOnDemand", page });
await this.renderer.renderPageOnDemand(page);
await this.processors.run([page]); return page; }
url(path: string, absolute = false): string { if ( path.startsWith("./") || path.startsWith("../") || path.startsWith("?") || path.startsWith("#") || path.startsWith("//") ) { return path; }
if (path.startsWith("~/")) { path = decodeURI(path.slice(1));
const match = path.match(/^(.*)\s*\(([^)]+)\)$/); const srcPath = match ? match[1] : path; const pages = match ? this.search.pages(match[2]).map<Page>((data) => data.page!) : this.pages;
const page = pages.find((page) => page.src.path + page.src.ext === srcPath );
if (page) { path = page.data.url; } else { const file = this.files.find((file) => file.entry.path === path);
if (file) { path = file.outputPath; } else { throw new Error(`Source file not found: ${path}`); } } } else { try { return new URL(path).href; } catch { } }
if (!path.startsWith(this.options.location.pathname)) { path = posix.join(this.options.location.pathname, path); }
return absolute ? this.options.location.origin + path : path; }
async getOrCreatePage( url: string, loader: Loader = textLoader, ): Promise<Page> { url = normalizePath(url);
const page = this.pages.find((page) => page.data.url === url);
if (page) { return page; }
const index = this.files.findIndex((f) => f.outputPath === url);
if (index > -1) { const { entry } = this.files.splice(index, 1)[0]; const data = await entry.getContent(loader) as Data; const page = Page.create({ ...data, url }); this.pages.push(page); return page; }
const entry = this.fs.entries.get(url); if (entry) { const data = await entry.getContent(loader) as Data; const page = Page.create({ ...data, url }); this.pages.push(page); return page; }
const newPage = Page.create({ url }); this.pages.push(newPage); return newPage; }
async getContent( file: string, loader: Loader, ): Promise<string | Uint8Array | undefined> { file = normalizePath(file); const basePath = this.src();
if (file.startsWith(basePath)) { file = normalizePath(file.slice(basePath.length)); }
file = decodeURI(file); const url = encodeURI(file);
const page = this.pages.find((page) => page.data.url === url);
if (page) { return page.content; }
const staticFile = this.files.find((f) => f.outputPath === file);
if (staticFile) { return (await staticFile.entry.getContent(loader)).content as | string | Uint8Array; }
try { const entry = this.fs.entries.get(file); if (entry) { return (await entry.getContent(loader)).content as string | Uint8Array; } } catch { } }
getWatcher(): Watcher { return new FSWatcher({ root: this.src(), ignore: this.options.watcher.ignore, debounce: this.options.watcher.debounce, }); }}
export interface ResolveOptions { includes?: boolean;
loader?: Loader;}
export interface SiteOptions { cwd: string;
src: string;
dest: string;
emptyDest?: boolean;
preview?: boolean;
includes: string;
location: URL;
prettyUrls: boolean;
server: ServerOptions;
watcher: WatcherOptions;
components: ComponentsOptions;}
export interface ServerOptions { root?: string;
port: number;
open: boolean;
page404: string;
middlewares: Middleware[];}
export interface WatcherOptions { ignore: (string | ((path: string) => boolean))[];
debounce: number;}
export interface ComponentsOptions { variable: string;
cssFile: string;
jsFile: string;}
export type SiteEventMap = { beforeBuild: { pages: Page[]; }; afterBuild: { pages: Page[]; staticFiles: StaticFile[]; }; beforeUpdate: { files: Set<string>; }; afterUpdate: { files: Set<string>; pages: Page[]; staticFiles: StaticFile[]; }; beforeRender: { pages: Page[]; }; afterRender: { pages: Page[]; }; beforeRenderOnDemand: { page: Page; }; beforeSave: {}; afterStartServer: {};};
export interface LoadPagesOptions { loader?: Loader; engine?: Engine | Engine[]; pageSubExtension?: string;}
export type SiteEvent<T extends SiteEventType = SiteEventType> = & Event & SiteEventMap[T] & { type: T };
export type SiteEventType = keyof SiteEventMap;
export type Plugin = (site: Site) => void;