Skip to main content
Module

x/lume/site.js

πŸ”₯ Static site generator for Deno πŸ¦•
Very Popular
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
import { basename, dirname, extname, join, SEP, posix } from "./deps/path.js";import { copy, emptyDir, ensureDir, exists } from "./deps/fs.js";import { gray } from "./deps/colors.js";import { createHash } from "./deps/hash.js";import Source from "./source.js";import Scripts from "./scripts.js";import { concurrent, normalizePath, searchByExtension, slugify } from "./utils.js";
const defaults = { cwd: Deno.cwd(), src: "./", dest: "./_site", dev: false, prettyUrls: true, flags: [], server: { port: 3000, page404: "/404.html", },};
export default class Site { engines = new Map(); filters = new Map(); extraData = {}; listeners = new Map(); processors = new Map(); pages = [];
#hashes = new Map();
constructor(options = {}) { this.options = { ...defaults, ...options };
this.options.location = (options.location instanceof URL) ? this.options.location : new URL(this.options.location || "http://localhost");
this.source = new Source(this); this.scripts = new Scripts(this); }
/** * Returns the src path */ src(...path) { return join(this.options.cwd, this.options.src, ...path); }
/** * Returns the dest path */ dest(...path) { return join(this.options.cwd, this.options.dest, ...path); }
/** * Adds an event */ addEventListener(type, listener) { const listeners = this.listeners.get(type) || new Set(); listeners.add(listener); this.listeners.set(type, listeners); return this; }
/** * Dispatch an event */ async dispatchEvent(event) { const type = event.type; const listeners = this.listeners.get(type);
if (!listeners) { return; }
for (let listener of listeners) { if (typeof listener === "string") { listener = [listener]; }
if (Array.isArray(listener)) { const success = await this.run(...listener);
if (!success) { return false; }
continue; }
if (await listener(event) === false) { return false; } } }
/** * Use a plugin */ use(plugin) { plugin(this); return this; }
/** * Register a script */ script(name, ...scripts) { this.scripts.set(name, ...scripts); return this; }
/** * Register a data loader for some extensions */ loadData(extensions, loader) { extensions.forEach((extension) => this.source.data.set(extension, loader)); return this; }
/** * Register a page loader for some extensions */ loadPages(extensions, loader) { extensions.forEach((extension) => this.source.pages.set(extension, loader)); return this; }
/** * Register an assets loader for some extensions */ loadAssets(extensions, loader) { extensions.forEach((extension) => this.source.pages.set(extension, loader)); extensions.forEach((extension) => this.source.assets.add(extension)); return this; }
/** * Register a processor for some extensions */ process(extensions, processor) { extensions.forEach((extension) => { const processors = this.processors.get(extension) || []; processors.push(processor); this.processors.set(extension, processors); }); return this; }
/** * Register template engine used for some extensions */ engine(extensions, engine) { extensions.forEach((extension) => this.engines.set(extension, engine)); this.loadPages(extensions, engine.load.bind(engine));
for (const [name, filter] of this.filters) { engine.addFilter(name, ...filter); }
return this; }
/** * Register a template filter */ filter(name, filter, async) { this.filters.set(name, [filter, async]);
for (const engine of this.engines.values()) { engine.addFilter(name, filter, async); }
return this; }
/** * Register extra data accesible by layouts */ data(name, data) { this.extraData[name] = data; return this; }
/** * Copy static files/folders without processing */ copy(from, to = from) { this.source.staticFiles.set(join("/", from), join("/", to)); return this; }
/** * Ignore one or several files or folders */ ignore(...paths) { paths.forEach((path) => this.source.ignored.add(join("/", path))); return this; }
/** * Clear the dest folder */ async clear() { await emptyDir(this.dest()); }
/** * Build the entire site */ async build() { await this.dispatchEvent({ type: "beforeBuild" });
await this.clear();
for (const [from, to] of this.source.staticFiles) { await this.#copyStatic(from, to); }
await this.source.loadDirectory(); await this.#buildPages();
await this.dispatchEvent({ type: "afterBuild" }); }
/** * Reload some files that might be changed */ async update(files) { await this.dispatchEvent({ type: "beforeUpdate", files });
let rebuildIsNeeded = false;
for (const file of files) { // It's an ignored file if (this.source.isIgnored(file)) { continue; }
const normalized = normalizePath(file);
// It's inside a _data file or folder if (normalized.includes("/_data/") || normalized.match(/\/_data.\w+$/)) { await this.source.loadFile(file); rebuildIsNeeded = true; continue; }
// The path contains /_ or /. if (normalized.includes("/_") || normalized.includes("/.")) { continue; }
// It's a static file const entry = this.source.isStatic(file);
if (entry) { const [from, to] = entry;
await this.#copyStatic(file, join(to, file.slice(from.length))); continue; }
// Default await this.source.loadFile(file); rebuildIsNeeded = true; }
if (rebuildIsNeeded) { await this.#buildPages(); }
await this.dispatchEvent({ type: "afterUpdate", files }); }
/** * Run a script */ async run(name, options = {}) { return await this.scripts.run(options, name); }
/** * Return the site flags */ get flags() { return this.options.flags || []; }
/** * Returns the url of a page */ url(path, absolute) { if ( path.startsWith("./") || path.startsWith("../") || path.startsWith("#") || path.startsWith("?") ) { return path; }
//It's source file if (path.startsWith("~/")) { path = path.slice(1).replaceAll("/", SEP);
//It's a page const page = this.pages.find((page) => page.src.path + page.src.ext === path );
if (page) { path = page.data.url; } else { //It's a static file const entry = this.source.isStatic(path);
if (entry) { const [from, to] = entry; path = normalizePath(join(to, path.slice(from.length))); } else { throw new Error(`Source file "${path}" not found`); } } } else { //Absolute urls are returned as is try { return new URL(path).toString(); } catch {} }
if (!this.options.location) { return posix.join("/", path); }
path = posix.join(this.options.location.pathname, path);
return absolute ? this.options.location.origin + path : path; }
/** * Copy a static file */ async #copyStatic(from, to) { const pathFrom = this.src(from); const pathTo = this.dest(to);
if (await exists(pathFrom)) { await ensureDir(dirname(pathTo)); console.log(`πŸ”₯ ${normalizePath(to)} ${gray(from)}`); return copy(pathFrom, pathTo, { overwrite: true }); } }
/** * Build the pages */ async #buildPages() { this.pages = [];
//Group pages by renderOrder const renderOrder = {};
for (const page of this.source.root.getPages()) { if (page.data.draft && !this.options.dev) { continue; }
const order = page.data.renderOrder || 0; renderOrder[order] = renderOrder[order] || []; renderOrder[order].push(page); }
const orderKeys = Object.keys(renderOrder).sort();
for (const order of orderKeys) { const pages = []; const generators = [];
//Prepare the pages for (const page of renderOrder[order]) { if (isGenerator(page.data.content)) { generators.push(page); continue; }
this.#urlPage(page); pages.push(page); this.pages.push(page); }
//Auto-generate pages for (const page of generators) { const generator = await this.engines.get(".tmpl.js") .render( page.data.content, { ...page.data, ...this.extraData, }, this.src(page.src.path + page.src.ext), );
for await (const data of generator) { if (!data.content) { data.content = null; } const newPage = page.duplicate(data); this.#urlPage(newPage); pages.push(newPage); this.pages.push(newPage); } }
//Render all pages for (const page of pages) { page.content = await this.#renderPage(page); } }
//Process the pages for (const [ext, processors] of this.processors) { await concurrent( this.pages, async (page) => { if (ext === page.dest.ext && page.content) { for (const process of processors) { await process(page, this); } } }, ); }
//Save the pages await concurrent( this.pages, (page) => this.#savePage(page), ); }
/** * Generate the url and dest info of a page */ #urlPage(page) { const { dest } = page;
if (page.data.permalink) { let permalink = typeof page.data.permalink === "function" ? page.data.permalink(page) : page.data.permalink; const ext = extname(permalink); dest.ext = ext || ".html";
//Relative permalink if (permalink.startsWith(".")) { permalink = posix.join(dirname(dest.path), permalink); } dest.path = ext ? permalink.slice(0, -ext.length) : permalink;
if (!ext && this.options.prettyUrls) { dest.path = posix.join(dest.path, "index"); } } else if ( this.options.prettyUrls && dest.ext === ".html" && posix.basename(dest.path) !== "index" ) { dest.path = posix.join(dest.path, "index"); }
if (!dest.path.startsWith("/")) { dest.path = `/${dest.path}`; }
dest.path = slugify(dest.path); page.data.url = (dest.ext === ".html" && posix.basename(dest.path) === "index") ? dest.path.slice(0, -5) : dest.path + dest.ext; }
/** * Render a page */ async #renderPage(page) { let content = page.data.content; let pageData = { ...page.data, ...this.extraData }; let layout = pageData.layout; const path = this.src(page.src.path + page.src.ext); const engine = this.#getEngine(page.src.ext, pageData.templateEngine);
if (Array.isArray(engine)) { for (const eng of engine) { content = await eng.render(content, pageData, path); } } else if (engine) { content = await engine.render(content, pageData, path); }
while (layout) { const engine = this.#getEngine(layout); const layoutPath = this.src(engine.includes, layout); const layoutData = await engine.load(layoutPath); pageData = { ...layoutData, ...pageData, content, ...this.extraData, };
content = await engine.render(layoutData.content, pageData, layoutPath);
layout = layoutData.layout; }
return content; }
/** * Save a page */ async #savePage(page) { //Ignore empty files if (!page.content) { return; }
const sha1 = createHash("sha1"); sha1.update(page.content); const hash = sha1.toString();
const dest = page.dest.path + page.dest.ext; const previousHash = this.#hashes.get(dest);
//The page content didn't change if (previousHash === hash) { return; }
this.#hashes.set(dest, hash);
console.log(`πŸ”₯ ${dest} ${gray(page.src.path + page.src.ext)}`);
const filename = this.dest(dest); await ensureDir(dirname(filename));
if (page.content instanceof Uint8Array) { return Deno.writeFile(filename, page.content); }
return Deno.writeTextFile(filename, page.content); }
/** * Get the engine used by a path or extension */ #getEngine(path, custom) { if (custom) { custom = Array.isArray(custom) ? custom : custom.split(",");
return custom.map((name) => { const engine = this.engines.get(`.${name.trim()}`);
if (engine) { return engine; }
throw new Error(`Invalid template engine: "${name}"`); }); }
const result = searchByExtension(path, this.engines);
if (result) { return result[1]; } }}
function isGenerator(content) { if (typeof content !== "function") { return false; }
const name = content.constructor.name; return (name === "GeneratorFunction" || name === "AsyncGeneratorFunction");}