import { basename, dirname, Document, DOMParser, Element, join, posixPathJoin,} from "./deps.ts";import { decoder, encoder, getLocalDependencyPaths, isLocalUrl, md5, qs,} from "./util.ts";import { bundleByEsbuild } from "./bundle_util.ts";import { logger } from "./logger_util.ts";import { compile as compileSass } from "./sass_util.ts";
type GenerateAssetsOptions = { watchPaths?: boolean; onBuild?: () => void; insertLivereloadScript?: boolean; livereloadPort?: number; mainAs404?: boolean; publicUrl: string;};
export async function generateAssets( path: string, opts: GenerateAssetsOptions,): Promise<[AsyncGenerator<File, void, void>, string[]]> { const buildStarted = Date.now(); const htmlAsset = await HtmlAsset.create(path); const { pageName, base } = htmlAsset; const pathPrefix = opts.publicUrl || ".";
const assets = [...htmlAsset.extractReferencedAssets()];
if (opts.insertLivereloadScript) { htmlAsset.insertScriptTag( `http://localhost:${opts.livereloadPort!}/livereload.js`, ); }
const generator = (async function* () { for (const a of assets) { const files = await a.createFileObject({ pageName, base, pathPrefix }); for (const file of files) yield file; }
const files = await htmlAsset.createFileObject({ pageName, base, pathPrefix, }); for (const file of files) yield file; if (opts.mainAs404) { yield Object.assign( (await htmlAsset.createFileObject({ pageName, base, pathPrefix }))[0], { name: "404", lastModified: 0 }, ); } logger.log(`${path} bundled in ${Date.now() - buildStarted}ms`);
opts.onBuild?.(); logger.debug("onBuild"); })();
const watchPaths = opts.watchPaths ? (await Promise.all(assets.map((a) => a.getWatchPaths(htmlAsset.base)))) .flat() : [];
return [generator, [path, ...watchPaths]];}
export async function* watchAndGenAssets( path: string, opts: GenerateAssetsOptions,): AsyncGenerator<File, void, void> { opts = { ...opts, watchPaths: true, insertLivereloadScript: true, }; let [assets, watchPaths] = await generateAssets(path, opts);
while (true) { for await (const file of assets) { yield file; } const watcher = Deno.watchFs(watchPaths); for await (const e of watcher) { logger.log("Changed: " + e.paths.join("")); break; } logger.log("Rebuilding"); await new Promise<void>((resolve) => setTimeout(() => resolve(), 100)); [assets, watchPaths] = await generateAssets(path, opts); }}
type CreateFileObjectParams = { pageName: string; base: string; pathPrefix: string;};
type Asset = { getWatchPaths(base: string): Promise<string[]>; createFileObject(params: CreateFileObjectParams): Promise<File[]>;};
const docType = encoder.encode("<!DOCTYPE html>");
class HtmlAsset implements Asset { static async create(path: string): Promise<HtmlAsset> { logger.debug("Reading", path); const html = decoder.decode(await Deno.readFile(path)); return new HtmlAsset(html, path); }
#doc: Document; #path: string; base: string; #filename: string; pageName: string; constructor(html: string, path: string) { this.#doc = new DOMParser().parseFromString(html, "text/html")!; this.#path = path; this.base = dirname(path); this.#filename = basename(path); if (!this.#filename.endsWith(".html")) { throw new Error(`Entrypoint needs to be an html file: ${path}`); }
this.pageName = this.#filename.replace(/\.html$/, "");
if (!this.pageName) { throw new Error(`Bad entrypoint name: ${path}`); } }
extractReferencedAssets() { return extractReferencedAssets(this.#doc); }
createFileObject(_params: CreateFileObjectParams) { return Promise.resolve([Object.assign( new Blob([docType, encoder.encode(this.#doc.documentElement!.outerHTML)]), { name: this.#filename, lastModified: 0 }, )]); }
getWatchPaths() { return Promise.resolve([this.#path]); }
insertScriptTag(path: string) { const script = this.#doc.createElement("script"); script.setAttribute("src", path); this.#doc.body.insertBefore(script, null); }}
class CssAsset implements Asset { static create(link: Element): CssAsset | null { const href = link.getAttribute("href"); const rel = link.getAttribute("rel"); if (rel !== "stylesheet") { return null; } if (!href) { logger.warn( "<link> tag has rel=stylesheet attribute, but doesn't have href attribute", ); return null; } if (href.startsWith("https://") || href.startsWith("http://")) { return null; } if (href.endsWith(".scss")) { return new ScssAsset(href, link); } return new CssAsset(href, link); }
_el: Element; _href: string; _dest?: string; constructor(href: string, link: Element) { this._el = link; this._href = href; }
getWatchPaths(base: string): Promise<string[]> { return Promise.resolve([join(base, this._href)]); }
async createFileObject( { pageName, base, pathPrefix }: CreateFileObjectParams, ): Promise<File[]> { const data = await Deno.readFile(join(base, this._href)); this._dest = `${pageName}.${md5(data)}.css`; this._el.setAttribute("href", posixPathJoin(pathPrefix, this._dest)); return [ Object.assign(new Blob([data]), { name: this._dest, lastModified: 0 }), ]; }}
class ScssAsset extends CssAsset { async createFileObject( { pageName, base, pathPrefix }: CreateFileObjectParams, ): Promise<File[]> { const scss = await Deno.readFile(join(base, this._href)); this._dest = `${pageName}.${md5(scss)}.css`; this._el.setAttribute("href", posixPathJoin(pathPrefix, this._dest)); return [Object.assign(new Blob([await compileSass(decoder.decode(scss))]), { name: this._dest, lastModified: 0, })]; }}
class ScriptAsset implements Asset { static create(script: Element): ScriptAsset | null { const src = script.getAttribute("src"); if (!src) { return null; } if (src.startsWith("http://") || src.startsWith("https://")) { return null; } return new ScriptAsset(src, script); }
#src: string; #dest?: string; #el: Element;
constructor(src: string, script: Element) { this.#src = src; this.#el = script; }
async getWatchPaths(base: string): Promise<string[]> { return await getLocalDependencyPaths(join(base, this.#src)); }
async createFileObject({ pageName, base, pathPrefix, }: CreateFileObjectParams): Promise<File[]> { const path = join(base, this.#src); const data = await bundleByEsbuild(path); this.#dest = `${pageName}.${md5(data)}.js`; this.#el.setAttribute("src", posixPathJoin(pathPrefix, this.#dest)); return [ Object.assign(new Blob([data]), { name: this.#dest, lastModified: 0 }), ]; }}
class ImageAsset implements Asset { static create(img: Element): ImageAsset | null { let sources: string[] = [];
const src = img.getAttribute("src"); const srcset = img.getAttribute("srcset");
if (img.tagName === "IMG" && !src) { logger.warn("<img> tag doesn't have src attribute"); return null; }
if (src && isLocalUrl(src)) sources.push(src); if (srcset) { sources.push( ...srcset .split(",") .filter(Boolean) .map((src) => src.trim()) .map((src) => src.split(" ")[0]) .filter(isLocalUrl), ); }
sources = [...new Set(sources)];
if (sources.length === 0) return null;
return new ImageAsset(sources, img); }
#sources: string[]; #el: Element;
constructor(sources: string[], image: Element) { this.#sources = sources; this.#el = image; }
async getWatchPaths(base: string): Promise<string[]> { const localDependencyPaths: Promise<string[]>[] = []; for (const src of this.#sources) { localDependencyPaths.push(getLocalDependencyPaths(join(base, src))); } return (await Promise.all(localDependencyPaths)) .flatMap((path) => path); }
async createFileObject( { pageName, base, pathPrefix }: CreateFileObjectParams, ): Promise<File[]> { const files: File[] = [];
for (const src of this.#sources) { const data = await Deno.readFile(join(base, src)); const [, extension] = src.match(/\.([\w]+)$/) ?? []; const dest = `${pageName}.${md5(data)}.${extension}`;
if (this.#el.getAttribute("src")?.match(src)) { this.#el.setAttribute("src", posixPathJoin(pathPrefix, dest)); }
const srcset = this.#el.getAttribute("srcset"); if (srcset?.includes(src)) { this.#el.setAttribute( "srcset", srcset.replace(src, posixPathJoin(pathPrefix, dest)), ); }
files.push( Object.assign(new Blob([data]), { name: dest, lastModified: 0 }), ); }
return files; }}
export function* extractReferencedAssets( doc: Document,): Generator<Asset, void, void> { yield* extractReferencedScripts(doc); yield* extractReferencedStyleSheets(doc); yield* extractReferencedImages(doc);}
function* extractReferencedScripts( doc: Document,): Generator<Asset, void, void> { for (const s of qs(doc, "script")) { const asset = ScriptAsset.create(s); if (asset) yield asset; }}
function* extractReferencedStyleSheets( doc: Document,): Generator<Asset, void, void> { for (const link of qs(doc, "link")) { const asset = CssAsset.create(link); if (asset) yield asset; }}
function* extractReferencedImages( doc: Document,): Generator<Asset, void, void> { for (const img of [...qs(doc, "img"), ...qs(doc, "source")]) { const asset = ImageAsset.create(img); if (asset) yield asset; }}