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; };}
export default class Source { fs: FS;
dataLoader: DataLoader;
componentLoader: ComponentLoader;
formats: Formats;
ignored = new Set<string>();
filters: ScopeFilter[] = [];
scopedData: Map<string, RawData>;
scopedPages: Map<string, RawData[]>;
scopedComponents: Map<string, Components>;
prettyUrls: boolean;
staticPaths = new Map< string, { dest: string | ((path: string) => string) | undefined; dirOnly: boolean } >();
copyRemainingFiles?: (path: string) => string | boolean;
extraCode = new Map<string, Map<string, string>>();
components: { cssFile: string;
jsFile: string;
variable: string; };
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; }
const [basename, date] = parseDateFromFilename(dir.name);
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 }); }
const dirData = mergeData( parentData, { basename }, this.scopedData.get(dir.path) || {}, ...dirDatas, ) as Partial<Data>;
path = posix.join(path, dirData.basename!);
const scopedComponents = this.scopedComponents.get(dir.path); let loadedComponents: Components | undefined;
for (const entry of dir.children.values()) { if (entry.type === "directory" && entry.name === "_components") { loadedComponents = await this.componentLoader.load(entry, dirData); break; } }
if (scopedComponents || loadedComponents) { parentComponents = mergeComponents( parentComponents, scopedComponents || new Map(), loadedComponents || new Map(), );
dirData[this.components.variable] = toProxy( parentComponents, this.extraCode, ); }
this.data.set(path, dirData);
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); } }
for (const entry of dir.children.values()) { if (buildFilters.some((filter) => !filter(entry))) { continue; }
if (this.staticPaths.has(entry.path)) { const { dest, dirOnly } = this.staticPaths.get(entry.path)!;
staticFiles.push(...this.#getStaticFiles(path, entry, dest, dirOnly)); continue; }
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);
if (!format) { if (this.copyRemainingFiles) { const dest = this.copyRemainingFiles(entry.path);
if (dest) { staticFiles.push( ...this.#getStaticFiles( path, entry, typeof dest === "string" ? dest : undefined, ), ); } } continue; }
if (format.copy) { staticFiles.push( ...this.#getStaticFiles( path, entry, typeof format.copy === "function" ? format.copy : undefined, ), ); continue; }
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);
const page = new Page({ path: entry.path.slice(0, -ext.length), ext, asset: format.pageType === "asset", entry, });
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; } }
if (entry.type === "directory") { await this.#build( buildFilters, entry, path, parentComponents, dirData, pages, staticFiles, ); } }
return [pages, staticFiles]; }
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; }
*#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; }
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, )); }}
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; }
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 (props: Record<string, unknown>) => component.render(props); }, }) as unknown as ProxyComponents;}
export type BuildFilter = (entry: Entry, page?: Page) => boolean;
export interface ProxyComponents { (props?: Record<string, unknown>): any; [key: string]: ProxyComponents;}
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";}