Skip to main content
Module

x/lume/core/source.ts

πŸ”₯ Static site generator for Deno πŸ¦•
Very Popular
Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
import { posix } from "../deps/path.ts";import { normalizePath } from "./utils/path.ts";import { mergeData } from "./utils/merge_data.ts";import { parseDateFromFilename } from "./utils/date.ts";import { getPageUrl } from "./utils/page_url.ts";import { getPageDate } from "./utils/page_date.ts";import { Page, StaticFile } from "./file.ts";
import type { Data, RawData } from "./file.ts";import type { default as FS, Entry } from "./fs.ts";import type Formats from "./formats.ts";import type DataLoader from "./data_loader.ts";import type { ScopeFilter } from "./scopes.ts";import type { Components, default as ComponentLoader,} from "./component_loader.ts";
export interface Options { formats: Formats; dataLoader: DataLoader; componentLoader: ComponentLoader; scopedData: Map<string, RawData>; scopedPages: Map<string, RawData[]>; scopedComponents: Map<string, Components>; fs: FS; prettyUrls: boolean; components: { variable: string; cssFile: string; jsFile: string; };}
/** * Scan and load files from the source folder * with the data, pages, assets and static files */export default class Source { /** Filesystem reader to scan folders */ fs: FS;
/** To load all _data files */ dataLoader: DataLoader;
/** To load all components */ componentLoader: ComponentLoader;
/** Info about how to handle different file formats */ formats: Formats;
/** The list of paths to ignore */ ignored = new Set<string>();
/** The path filters to ignore */ filters: ScopeFilter[] = [];
/** The data assigned per path */ scopedData: Map<string, RawData>;
/** The pages assigned per path */ scopedPages: Map<string, RawData[]>;
/** The components assigned per path */ scopedComponents: Map<string, Components>;
/** Use pretty URLs */ prettyUrls: boolean;
/** List of static files and folders to copy */ staticPaths = new Map< string, { dest: string | ((path: string) => string) | undefined; dirOnly: boolean } >();
/** List of static files and folders to copy */ copyRemainingFiles?: (path: string) => string | boolean;
/** Extra code generated by the components */ extraCode = new Map<string, Map<string, string>>();
components: { /** File name used to output the extra CSS code generated by the components */ cssFile: string;
/** File name used to output the extra JavaScript code generated by the components */ jsFile: string;
/** Variable name used to access to the components */ variable: string; };
/** The data assigned per path */ data = new Map<string, Partial<Data>>();
constructor(options: Options) { this.dataLoader = options.dataLoader; this.componentLoader = options.componentLoader; this.fs = options.fs; this.formats = options.formats; this.components = options.components; this.scopedData = options.scopedData; this.scopedPages = options.scopedPages; this.scopedComponents = options.scopedComponents; this.prettyUrls = options.prettyUrls; }
addIgnoredPath(path: string) { this.ignored.add(normalizePath(path)); }
addIgnoreFilter(filter: ScopeFilter) { this.filters.push(filter); }
addStaticPath(from: string, to?: string | ((path: string) => string)) { this.staticPaths.set( normalizePath(from.replace(/\/$/, "")), { dest: typeof to === "string" ? normalizePath(to) : to, dirOnly: from.endsWith("/"), }, ); }
async build(...buildFilters: BuildFilter[]): Promise<[Page[], StaticFile[]]> { const pages: Page[] = []; const staticFiles: StaticFile[] = [];
await this.#build( buildFilters, this.fs.entries.get("/")!, "/", new Map(), {}, pages, staticFiles, );
return [ pages, staticFiles, ]; }
async #build( buildFilters: BuildFilter[], dir: Entry, path: string, parentComponents: Components, parentData: RawData, pages: Page[], staticFiles: StaticFile[], ) { if (buildFilters.some((filter) => !filter(dir))) { return; }
// Parse the date/time in the folder name const [basename, date] = parseDateFromFilename(dir.name);
// Load the _data files const dirDatas: RawData[] = [];
for (const entry of dir.children.values()) { if ( (entry.type === "file" && entry.name.startsWith("_data.")) || (entry.type === "directory" && entry.name === "_data") ) { const loaded = await this.dataLoader.load(entry); if (loaded) { dirDatas.push(loaded); } } }
if (date) { dirDatas.push({ date }); }
// Merge directory data const dirData = mergeData( parentData, { basename }, this.scopedData.get(dir.path) || {}, ...dirDatas, ) as Partial<Data>;
path = posix.join(path, dirData.basename!);
// Directory components const scopedComponents = this.scopedComponents.get(dir.path); let loadedComponents: Components | undefined;
// Load _components files for (const entry of dir.children.values()) { if (entry.type === "directory" && entry.name === "_components") { loadedComponents = await this.componentLoader.load(entry, dirData); break; } }
// Merge the components if (scopedComponents || loadedComponents) { parentComponents = mergeComponents( parentComponents, scopedComponents || new Map(), loadedComponents || new Map(), );
dirData[this.components.variable] = toProxy( parentComponents, this.extraCode, ); }
// Store the root data to be used by other plugins this.data.set(path, dirData);
// Load the pages assigned to the current path if (this.scopedPages.has(dir.path)) { for (const data of this.scopedPages.get(dir.path)!) { const basename = posix.basename(data.url as string).replace( /\.[\w.]+$/, "", ); const page = new Page(); page.data = mergeData( dirData, { basename, date: new Date() }, data, ) as Data;
const url = getPageUrl(page, this.prettyUrls, path); if (!url) { continue; } page.data.url = url; page.data.date = getPageDate(page); page.data.page = page; pages.push(page); } }
// Load the pages and static files for (const entry of dir.children.values()) { if (buildFilters.some((filter) => !filter(entry))) { continue; }
// Static files if (this.staticPaths.has(entry.path)) { const { dest, dirOnly } = this.staticPaths.get(entry.path)!;
staticFiles.push(...this.#getStaticFiles(path, entry, dest, dirOnly)); continue; }
// Check if the entry should be ignored if ( (entry.name.startsWith(".") && !isWellKnownDir(entry)) || entry.name.startsWith("_") || this.ignored.has(entry.path) ) { for (const [staticSrc, { dest, dirOnly }] of this.staticPaths) { if (staticSrc.startsWith(entry.path)) { const staticEntry = this.fs.entries.get(staticSrc)!; const staticPath = posix.dirname( posix.join(path, staticEntry.path.slice(entry.path.length)), ); staticFiles.push( ...this.#getStaticFiles(staticPath, staticEntry, dest, dirOnly), ); } } continue; }
if (this.filters.some((filter) => filter(entry.path))) { continue; }
if (entry.type === "file") { const format = this.formats.search(entry.path);
// Unknown file format if (!format) { // Remaining files if (this.copyRemainingFiles) { const dest = this.copyRemainingFiles(entry.path);
if (dest) { staticFiles.push( ...this.#getStaticFiles( path, entry, typeof dest === "string" ? dest : undefined, ), ); } } continue; }
// The file is a static file if (format.copy) { staticFiles.push( ...this.#getStaticFiles( path, entry, typeof format.copy === "function" ? format.copy : undefined, ), ); continue; }
// The file is a page if (format.pageType) { const loader = format.pageType === "asset" ? format.assetLoader : format.loader;
if (!loader) { throw new Error( `Missing loader for ${format.pageType} page type (${entry.path}))`, ); }
const { ext } = format; const [basename, date] = parseDateFromFilename(entry.name);
// Create the page const page = new Page({ path: entry.path.slice(0, -ext.length), ext, asset: format.pageType === "asset", entry, });
// Load and merge the page data const pageData = await entry.getContent(loader); page.data = mergeData( dirData, { basename: basename.slice(0, -ext.length) }, date ? { date } : {}, this.scopedData.get(entry.path) || {}, pageData, ) as Data;
const url = getPageUrl(page, this.prettyUrls, path); if (!url) { continue; } page.data.url = url; page.data.date = getPageDate(page); page.data.page = page; page._data.layout = pageData.layout;
if (buildFilters.some((filter) => !filter(entry, page))) { continue; }
pages.push(page); continue; } }
// Load recursively the directory if (entry.type === "directory") { await this.#build( buildFilters, entry, path, parentComponents, dirData, pages, staticFiles, ); } }
return [pages, staticFiles]; }
/** Returns the pages with extra code generated by the components */ getComponentsExtraCode(): Page[] { const files = { css: this.components.cssFile, js: this.components.jsFile, }; const pages: Page[] = [];
for (const [type, path] of Object.entries(files)) { const code = this.extraCode.get(type);
if (code && code.size) { pages.push( Page.create({ url: path, content: Array.from(code.values()).join("\n"), }), ); } }
return pages; }
/** Scan the static files in a directory */ *#scanStaticFiles( dirEntry: Entry, destPath: string, destFn?: (file: string) => string, ): Generator<StaticFile> { for (const entry of dirEntry.children.values()) { if (entry.type === "file") { if (entry.name.startsWith(".") || entry.name.startsWith("_")) { continue; }
// Check if the file should be ignored if (this.ignored.has(entry.path)) { continue; }
if (this.filters.some((filter) => filter(entry.path))) { continue; }
const outputPath = getOutputPath(entry, destPath, destFn); yield { entry, outputPath }; }
if (entry.type === "directory") { yield* this.#scanStaticFiles( entry, posix.join(destPath, entry.name), destFn, ); } } }
#getStaticFiles( path: string, entry: Entry, dest: string | ((path: string) => string) | undefined, dirOnly = false, ): StaticFile[] { if (entry.type === "file") { if (!dirOnly) { return [{ entry, outputPath: getOutputPath(entry, path, dest), }]; } return []; }
return Array.from(this.#scanStaticFiles( entry, typeof dest === "string" ? dest : posix.join(path, entry.name), typeof dest === "function" ? dest : undefined, )); }}
/** * Create and returns a proxy to use the components * as comp.name() instead of components.get("name").render() */function toProxy( components: Components, extraCode?: Map<string, Map<string, string>>,): ProxyComponents { const node = { _components: components, _proxies: new Map(), }; return new Proxy(node, { get: (target, name) => { if (typeof name !== "string" || name in target) { return; }
const key = name.toLowerCase();
if (target._proxies.has(key)) { return target._proxies.get(key); }
const component = target._components.get(key);
if (!component) { throw new Error(`Component "${name}" not found`); }
if (component instanceof Map) { const proxy = toProxy(component, extraCode); target._proxies.set(key, proxy); return proxy; }
// Save CSS & JS code for the component if (extraCode) { if (component.css) { const code = extraCode.get("css") ?? new Map(); code.set(key, component.css); extraCode.set("css", code); }
if (component.js) { const code = extraCode.get("js") ?? new Map(); code.set(key, component.js); extraCode.set("js", code); } }
// Return the function to render the component return (props: Record<string, unknown>) => component.render(props); }, }) as unknown as ProxyComponents;}
export type BuildFilter = (entry: Entry, page?: Page) => boolean;
export interface ProxyComponents { // deno-lint-ignore no-explicit-any (props?: Record<string, unknown>): any; [key: string]: ProxyComponents;}
/** Merge the cascade components */function mergeComponents(...components: Components[]): Components { return components.reduce((previous, current) => { const components = new Map(previous);
for (const [key, value] of current) { if (components.has(key)) { const previousValue = components.get(key);
if (previousValue instanceof Map && value instanceof Map) { components.set(key, mergeComponents(value, previousValue)); } else { components.set(key, value); } } else { components.set(key, value); } } return components; });}
function getOutputPath( entry: Entry, path: string, dest?: string | ((path: string) => string),): string { if (typeof dest === "function") { return dest(posix.join(path, entry.name)); }
if (typeof dest === "string") { return dest; }
return posix.join(path, entry.name);}
function isWellKnownDir(entry: Entry) { return entry.type === "directory" && entry.path === "/.well-known";}