Skip to main content
Module

x/lume/core/site.ts

πŸ”₯ Static site generator for Deno πŸ¦•
Very Popular
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783
import { join, posix } from "../deps/path.ts";import { merge, normalizePath } from "./utils.ts";import { Exception } from "./errors.ts";
import Reader from "./reader.ts";import PageLoader from "./page_loader.ts";import ComponentLoader from "./component_loader.ts";import DataLoader from "./data_loader.ts";import IncludesLoader from "./includes_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 Logger from "./logger.ts";import Scripts from "./scripts.ts";import Writer from "./writer.ts";import textLoader from "./loaders/text.ts";
import type { Data, Engine, Event, EventListener, EventOptions, Helper, HelperOptions, Loader, Middleware, Page, Plugin, Processor, ScopeFilter, ScriptOptions, ScriptOrFunction, StaticFile,} from "../core.ts";
/** Default options of the site */const defaults: SiteOptions = { cwd: Deno.cwd(), src: "./", dest: "./_site", includes: "_includes", location: new URL("http://localhost"), quiet: false, dev: false, prettyUrls: true, server: { port: 3000, open: false, page404: "/404.html", }, watcher: { ignore: [], debounce: 100, }, components: { variable: "comp", cssFile: "/components.css", jsFile: "/components.js", },};
/** * This is the heart of Lume, * it contains everything needed to build the site */export default class Site { options: SiteOptions;
/** To read the files from the filesystem */ reader: Reader;
/** Info about how to handle different file formats */ formats: Formats;
/** To load all pages */ pageLoader: PageLoader;
/** To load all _data files */ dataLoader: DataLoader;
/** To load all _includes files (layouts, templates, etc) */ includesLoader: IncludesLoader;
/** To load reusable components */ componentLoader: ComponentLoader;
/** To scan the src folder */ source: Source;
/** To update pages of the same scope after any change */ scopes: Scopes;
/** To store and run the processors */ processors: Processors;
/** To store and run the pre-processors */ preprocessors: Processors;
/** To render the pages using any template engine */ renderer: Renderer;
/** To listen and dispatch events */ events: Events<SiteEvent>;
/** To output messages to the console */ logger: Logger;
/** To run scripts */ scripts: Scripts;
/** To write the generated pages in the dest folder */ writer: Writer;
/** Global data shared by all pages */ globalData: Data = {};
/** The generated pages are stored here */ pages: Page[] = [];
/** The static files to be copied are stored here */ files: StaticFile[] = [];
constructor(options: Partial<SiteOptions> = {}) { this.options = merge(defaults, options);
const src = this.src(); const dest = this.dest(); const { quiet, includes, cwd, prettyUrls, components } = this.options;
// To load source files const reader = new Reader({ src }); const formats = new Formats();
const pageLoader = new PageLoader({ reader }); const dataLoader = new DataLoader({ reader, formats }); const includesLoader = new IncludesLoader({ reader, includes }); const componentLoader = new ComponentLoader({ reader, formats }); const source = new Source({ reader, pageLoader, dataLoader, componentLoader, formats, components, globalData: this.globalData, });
// To render pages const scopes = new Scopes(); const processors = new Processors(); const preprocessors = new Processors(); const renderer = new Renderer({ includesLoader, prettyUrls, preprocessors, formats, });
// Other stuff const events = new Events<SiteEvent>(); const logger = new Logger({ quiet }); const scripts = new Scripts({ logger, options: { cwd } }); const writer = new Writer({ src, dest, logger });
// Save everything in the site instance this.reader = reader; this.formats = formats; this.pageLoader = pageLoader; this.componentLoader = componentLoader; this.dataLoader = dataLoader; this.includesLoader = includesLoader; this.source = source; this.scopes = scopes; this.processors = processors; this.preprocessors = preprocessors; this.renderer = renderer; this.events = events; this.logger = logger; this.scripts = scripts; this.writer = writer;
// Ignore the "dest" directory if it's inside src if (this.dest().startsWith(this.src())) { this.ignore(this.options.dest); }
// Ignore the dest folder by the watcher this.options.watcher.ignore.push(this.options.dest); }
/** * Returns the full path to the src directory. * Use the arguments to return a subpath */ src(...path: string[]): string { return normalizePath(join(this.options.cwd, this.options.src, ...path)); }
/** * Returns the full path to the dest directory. * Use the arguments to return a subpath */ dest(...path: string[]): string { return normalizePath(join(this.options.cwd, this.options.dest, ...path)); }
/** Add a listener to an event */ addEventListener( type: SiteEventType, listener: EventListener<SiteEvent> | string, options?: EventOptions, ): this { const fn = typeof listener === "string" ? () => this.run(listener) : listener;
this.events.addEventListener(type, fn, options); return this; }
/** Dispatch an event */ dispatchEvent(event: SiteEvent): Promise<boolean> { return this.events.dispatchEvent(event); }
/** Use a plugin */ use(plugin: Plugin): this { plugin(this); return this; }
/** * Register a script or a function, so it can be executed with * lume run <name> */ script(name: string, ...scripts: ScriptOrFunction[]): this { this.scripts.set(name, ...scripts); return this; }
/** Runs a script or function registered previously */ async run(name: string, options: ScriptOptions = {}): Promise<boolean> { return await this.scripts.run(options, name); }
/** * Register a data loader for some extensions */ loadData(extensions: string[], dataLoader: Loader = textLoader): this { extensions.forEach((ext) => { this.formats.set({ ext, dataLoader }); });
return this; }
/** * Register a page loader for some extensions */ loadPages( extensions: string[], pageLoader: Loader = textLoader, engine?: Engine, ): this { extensions.forEach((ext) => { this.formats.set({ ext, pageLoader }); });
if (engine) { this.engine(extensions, engine); }
return this; }
/** * Register an assets loader for some extensions */ loadAssets(extensions: string[], pageLoader: Loader = textLoader): this { extensions.forEach((ext) => { this.formats.set({ ext, pageLoader, asset: true, }); });
return this; }
/** * Register a component loader for some extensions */ loadComponents( extensions: string[], componentLoader: Loader = textLoader, engine: Engine, ): this { extensions.forEach((ext) => { this.formats.set({ ext, componentLoader }); }); this.engine(extensions, engine); return this; }
/** Register an import path for some extensions */ includes(extensions: string[], path: string): this { extensions.forEach((ext) => { this.formats.set({ ext, includesPath: path }); });
// Ignore any includes folder return this.ignore(path); }
/** Register a engine for some extensions */ engine(extensions: string[], engine: Engine): this { extensions.forEach((ext) => { this.formats.set({ ext, engine }); });
for (const [name, helper] of this.renderer.helpers) { engine.addHelper(name, ...helper); }
return this; }
/** Register a preprocessor for some extensions */ preprocess(extensions: string[] | "*", preprocessor: Processor): this { this.preprocessors.set(extensions, preprocessor); return this; }
/** Register a processor for some extensions */ process(extensions: string[] | "*", processor: Processor): this { this.processors.set(extensions, processor); return this; }
/** Register a template filter */ filter(name: string, filter: Helper, async = false): this { return this.helper(name, filter, { type: "filter", async }); }
/** Register a template helper */ helper(name: string, fn: Helper, options: HelperOptions): this { this.renderer.addHelper(name, fn, options); return this; }
/** Register extra data accessible by layouts */ data(name: string, data: unknown): this { this.globalData[name] = data; return this; }
/** Copy static files or directories without processing */ 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 { // File extensions if (Array.isArray(from)) { if (typeof to === "string") { throw new Exception( "copy() files by extension expects a function as second argument", { to }, ); }
from.forEach((ext) => { this.formats.set({ ext, copy: to ? to : true }); }); return this; }
this.source.addStaticPath(from, to); return this; }
/** Ignore one or several files or directories */ ignore(...paths: (string | ScopeFilter)[]): this { paths.forEach((path) => { if (typeof path === "string") { this.source.addIgnoredPath(path); } else { this.source.addIgnoreFilter(path); } }); return this; }
/** Define independent scopes to optimize the update process */ scopedUpdates(...scopes: ScopeFilter[]): this { scopes.forEach((scope) => this.scopes.scopes.add(scope)); return this; }
/** Define a remote fallback for a missing local file */ remoteFile(filename: string, url: string): this { this.reader.remoteFile(filename, url); return this; }
/** Clear the dest directory and any cache */ async clear(): Promise<void> { this.reader.clearCache(); await this.writer.clear(); }
/** Build the entire site */ async build(): Promise<void> { if (await this.dispatchEvent({ type: "beforeBuild" }) === false) { return; }
await this.clear();
// Load source files await this.source.load();
// Get static files this.files = this.source.getStaticFiles();
// Get all pages to process (ignore drafts) const pagesToBuild = this.source.getPages( (page) => !page.data.draft || this.options.dev, );
// Stop if the build is cancelled if (await this.#buildPages(pagesToBuild) === false) { return; }
// Save the pages and copy static files in the dest folder const pages = await this.writer.savePages(this.pages); const staticFiles = await this.writer.copyFiles(this.files);
await this.dispatchEvent({ type: "afterBuild", pages, staticFiles }); }
/** Reload some files that might be changed */ async update(files: Set<string>): Promise<void> { if (await this.dispatchEvent({ type: "beforeUpdate", files }) === false) { return; }
// Reload the changed files for (const file of files) { // Delete the file from the cache this.reader.deleteCache(file); this.formats.deleteCache(file);
await this.source.update(file); }
// Copy static files this.files = this.source.getStaticFiles();
// Get the selected pages to process (ignore drafts and non scoped pages) const pagesToBuild = this.source.getPages( (page) => !page.data.draft || this.options.dev, this.scopes.getFilter(files), );
if (await this.#buildPages(pagesToBuild) === false) { return; }
// Save the pages and copy static files in the dest folder const pages = await this.writer.savePages(this.pages); const staticFiles = await this.writer.copyFiles(this.files);
await this.dispatchEvent({ type: "afterUpdate", files, pages, staticFiles, }); }
/** * Internal function to render pages * The common operations of build and update */ async #buildPages(pages: Page[]): Promise<boolean> { if (await this.dispatchEvent({ type: "beforeRender" }) === false) { return false; }
// Render the pages into this.pages array this.pages = []; await this.renderer.renderPages(pages, this.pages);
// Add extra code generated by the components for (const extra of this.source.getComponentsExtraCode()) { const exists = this.pages.find((page) => page.data.url === extra.data.url );
// If it's duplicated, merge the content if (exists) { exists.content = `${exists.content}\n${extra.content}`; } else { this.pages.push(extra); } }
if (await this.events.dispatchEvent({ type: "afterRender" }) === false) { return false; }
// Remove empty pages and ondemand pages this.pages = this.pages.filter((page) => { const shouldSkip = !page.content || page.data.ondemand; if (shouldSkip) { this.logger.warn( `Skipped page ${page.data.url} (${ page.data.ondemand ? "page is build only on demand" : "file content is empty" })`, ); } return !shouldSkip; });
// Run the processors to the pages await this.processors.run(this.pages);
return await this.dispatchEvent({ type: "beforeSave" }); }
/** Render a single page (used for on demand rendering) */ async renderPage(file: string): Promise<Page | undefined> { // Load the page await this.source.update(file);
// Returns the page const page = this.source.getFileOrDirectory(file) as Page | undefined;
if (!page) { return; }
await this.dispatchEvent({ type: "beforeRenderOnDemand", page });
// Render the page await this.renderer.renderPageOnDemand(page);
// Run the processors to the page await this.processors.run([page]); return page; }
/** Return the URL of a path */ url(path: string, absolute = false): string { if ( path.startsWith("./") || path.startsWith("../") || path.startsWith("?") || path.startsWith("#") || path.startsWith("//") ) { return path; }
// It's a source file if (path.startsWith("~/")) { path = decodeURI(path.slice(1));
// It's a page const page = this.pages.find((page) => page.src.path + page.src.ext === path );
if (page) { path = page.data.url as string; } else { // It's a static file const file = this.files.find((file) => file.src === path);
if (file) { path = file.dest; } else { throw new Error(`Source file not found: ${path}`); } } } else { // Absolute URLs are returned as is try { return new URL(path).href; } catch { // Ignore error } }
if (!path.startsWith(this.options.location.pathname)) { path = posix.join(this.options.location.pathname, path); }
return absolute ? this.options.location.origin + path : path; }
/** * Get the content of a file. * Resolve the path if it's needed. */ async getContent( file: string, { includes = true, loader = textLoader }: ResolveOptions = {}, ): Promise<string | Uint8Array | undefined> { file = normalizePath(file);
// It's a page const page = this.pages.find((page) => page.data.url === file);
if (page) { return page.content; }
// It's a static file const staticFile = this.files.find((f) => f.dest === file);
if (staticFile) { const content = await this.reader.read(staticFile.src, loader); return content.content as Uint8Array | string; }
// Search in includes if (includes) { const format = this.formats.search(file);
if (format) { try { const source = await this.includesLoader.load(file, format);
if (source) { return source[1].content as string; } } catch { // Ignore error } } }
// Read the source files directly try { const content = await this.reader.read(file, loader); return content.content as Uint8Array | string; } catch { // Ignore error } }}
/** The options for the resolve function */export interface ResolveOptions { /** Whether search in the includes folder or not */ includes?: boolean;
/** Default loader */ loader?: Loader;}
/** The options to configure the site build */export interface SiteOptions { /** The path of the current working directory */ cwd: string;
/** The path of the site source */ src: string;
/** The path of the built destination */ dest: string;
/** The default includes path */ includes: string;
/** Set `true` to enable the `dev` mode */ dev: boolean;
/** The site location (used to generate final urls) */ location: URL;
/** Set true to generate pretty urls (`/about-me/`) */ prettyUrls: boolean | "no-html-extension";
/** Set `true` to skip logs */ quiet: boolean;
/** The local server options */ server: ServerOptions;
/** The local watcher options */ watcher: WatcherOptions;
/** The components options */ components: ComponentsOptions;}
/** The options to configure the local server */export interface ServerOptions { /** The port to listen on */ port: number;
/** To open the server in a browser */ open: boolean;
/** The file to serve on 404 error */ page404: string;
/** Optional for the server */ middlewares?: Middleware[];}
/** The options to configure the local watcher */export interface WatcherOptions { /** Paths to ignore by the watcher */ ignore: (string | ((path: string) => boolean))[];
/** The interval in milliseconds to check for changes */ debounce: number;}
/** The options to configure the components */export interface ComponentsOptions { /** The variable name used to access to the components */ variable: string;
/** The name of the file to save the components css code */ cssFile: string;
/** The name of the file to save the components javascript code */ jsFile: string;}
/** Custom events for site build */export interface SiteEvent extends Event { /** The event type */ type: SiteEventType;
/** * Available only in "beforeUpdate" and "afterUpdate" * contains the files that were changed */ files?: Set<string>;
/** * Available only in "beforeRenderOnDemand" * contains the page that will be rendered */ page?: Page;
/** * Available only in "afterBuild" and "afterUpdate" * contains the list of pages that have been saved */ pages?: Page[];
/** * Available only in "afterBuild" and "afterUpdate" * contains the list of static files that have been copied */ staticFiles?: StaticFile[];}
/** The available event types */export type SiteEventType = | "beforeBuild" | "afterBuild" | "beforeUpdate" | "afterUpdate" | "beforeRender" | "afterRender" | "beforeRenderOnDemand" | "beforeSave" | "afterStartServer";