From 7a2cf0a6c1dbe28dcb362d1a1b8da56568210343 Mon Sep 17 00:00:00 2001 From: Gabriel Cozma Date: Tue, 14 Jul 2026 18:31:39 +0300 Subject: [PATCH] feat: add automatic HTML lazy-loading injection and LQIP generation --- mod.ts | 81 +++++++++++++++++++++++++++++++++++++++++------- mod_test.ts | 88 ++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 144 insertions(+), 25 deletions(-) diff --git a/mod.ts b/mod.ts index a73abfd..70dad70 100644 --- a/mod.ts +++ b/mod.ts @@ -1,5 +1,5 @@ /** - * Steno plugin for bulk image optimization, resizing, and WebP compilation. + * Steno plugin for bulk image optimization, resizing, and automatic HTML lazy-loading injection. */ import type { StenoPlugin } from "steno"; import { join, extname, basename, dirname } from "@std/path"; @@ -10,6 +10,7 @@ export interface ImagePluginOptions { widths?: number[]; formats?: Array<"webp" | "jpeg" | "png">; quality?: number; + injectLazyLoad?: boolean; // New Option! } export interface SiteConfig { @@ -20,6 +21,7 @@ export default function imagePlugin(options: ImagePluginOptions = {}): StenoPlug const widths = options.widths ?? [640, 1024, 1280]; const formats = options.formats ?? ["webp"]; const quality = options.quality ?? 80; + const injectLazyLoad = options.injectLazyLoad ?? true; const SUPPORTED_EXTENSIONS = [".jpg", ".jpeg", ".png"]; @@ -28,8 +30,9 @@ export default function imagePlugin(options: ImagePluginOptions = {}): StenoPlug async afterBuild(config: SiteConfig): Promise { const outputDir = config.output ?? "dist"; + const imageMetadataMap = new Map(); - // Recursively find all source assets in the built output directory + // 1. Process and Optimize Physical Images for await (const entry of walk(outputDir, { includeDirs: false })) { const ext = extname(entry.path).toLowerCase(); if (!SUPPORTED_EXTENSIONS.includes(ext)) { @@ -38,27 +41,37 @@ export default function imagePlugin(options: ImagePluginOptions = {}): StenoPlug const dir = dirname(entry.path); const name = basename(entry.path, ext); - - // Read image into buffer once for processing multiple pipeline variations const imageBuffer = await Deno.readFile(entry.path); const metadata = await sharp(imageBuffer).metadata(); - if (!metadata.width) continue; + if (!metadata.width || !metadata.height) continue; + + // Cache metadata for HTML injection phase + const relativePath = entry.path.replace(outputDir, "").replace(/\\/g, "/"); + + // Generate a tiny Base64 LQIP (16px wide) for instant loading preview + const lqipBuffer = await sharp(imageBuffer) + .resize(16) + .blur(1) + .toBuffer(); + const lqipBase64 = `data:image/${ext.slice(1)};base64,${lqipBuffer.toString("base64")}`; - // Process every configuration combination + imageMetadataMap.set(relativePath, { + width: metadata.width, + height: metadata.height, + lqip: lqipBase64 + }); + + // Write optimized files (widths & formats) for (const format of formats) { for (const width of widths) { - // Skip upscaling smaller images to keep sizes minimal - if (width > metadata.width) { - continue; - } + if (width > metadata.width) continue; const outputFilename = `${name}-${width}.${format}`; const outputPath = join(dir, outputFilename); let transform = sharp(imageBuffer).resize({ width }); - // Apply standard encoder configuration if (format === "webp") { transform = transform.webp({ quality }); } else if (format === "jpeg") { @@ -72,6 +85,52 @@ export default function imagePlugin(options: ImagePluginOptions = {}): StenoPlug } } } + + // 2. Scan and Inject lazy-loading attributes into HTML files + if (injectLazyLoad) { + for await (const entry of walk(outputDir, { includeDirs: false })) { + if (extname(entry.path).toLowerCase() !== ".html") { + continue; + } + + let htmlContent = await Deno.readTextFile(entry.path); + let modified = false; + + // Regex to match tags + const imgRegex = /]*?)src="([^"]+?)"([^>]*?)>/gi; + + htmlContent = htmlContent.replace(imgRegex, (match, before, src, after) => { + // Normalize path to map to our metadata registry + const cleanSrc = src.startsWith("/") ? src : `/${src}`; + const meta = imageMetadataMap.get(cleanSrc); + + if (!meta) return match; // Skip if we don't have matching local metadata + + // Check if attributes are already defined to prevent duplicates + const hasWidth = /width=/i.test(match); + const hasHeight = /height=/i.test(match); + const hasLoading = /loading=/i.test(match); + + let extraAttrs = ""; + if (!hasWidth) extraAttrs += ` width="${meta.width}"`; + if (!hasHeight) extraAttrs += ` height="${meta.height}"`; + if (!hasLoading) extraAttrs += ` loading="lazy"`; + + // Apply low-quality blur-up style if LQIP is available + const hasStyle = /style=/i.test(match); + if (!hasStyle && meta.lqip) { + extraAttrs += ` style="background-image: url('${meta.lqip}'); background-size: cover; filter: blur(4px); transition: filter 0.3s;" onload="this.style.filter='none'"`; + } + + modified = true; + return ``; + }); + + if (modified) { + await Deno.writeTextFile(entry.path, htmlContent); + } + } + } }, }; } \ No newline at end of file diff --git a/mod_test.ts b/mod_test.ts index de51c9f..485b4ef 100644 --- a/mod_test.ts +++ b/mod_test.ts @@ -1,31 +1,36 @@ -import { assert } from "@std/assert"; +import { assert, assertStringIncludes } from "@std/assert"; import { join } from "@std/path"; import imagePlugin from "./mod.ts"; import sharp from "sharp"; +// Helper to generate a valid 1x1 PNG using Sharp +async function createTestPng(): Promise { + return await sharp({ + create: { + width: 1, + height: 1, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 } + } + }) + .png() + .toBuffer(); +} + Deno.test({ name: "image-plugin: successfully resizes and outputs modern webp targets", fn: async () => { const testDir = await Deno.makeTempDir({ prefix: "steno_img_test_" }); const inputFilePath = join(testDir, "test-image.png"); - const validPngBuffer = await sharp({ - create: { - width: 1, - height: 1, - channels: 4, - background: { r: 0, g: 0, b: 0, alpha: 0 } - } - }) - .png() - .toBuffer(); - + const validPngBuffer = await createTestPng(); await Deno.writeFile(inputFilePath, validPngBuffer); const plugin = imagePlugin({ - widths: [1], // Matches dummy width so it doesn't skip + widths: [1], formats: ["webp"], - quality: 70 + quality: 70, + injectLazyLoad: false // Disable HTML scanning for this pure asset generation test }); // deno-lint-ignore no-explicit-any @@ -43,4 +48,59 @@ Deno.test({ await Deno.remove(testDir, { recursive: true }); } } +}); + +Deno.test({ + name: "image-plugin: injects lazy-load, dimension, and LQIP attributes into HTML files", + fn: async () => { + const testDir = await Deno.makeTempDir({ prefix: "steno_html_test_" }); + + // 1. Create a dummy PNG image inside the output directory + const imgPath = join(testDir, "hero.png"); + const validPngBuffer = await createTestPng(); + await Deno.writeFile(imgPath, validPngBuffer); + + // 2. Create an HTML file referencing that PNG image + const htmlPath = join(testDir, "index.html"); + const rawHtml = ` + + + +

Welcome to Steno

+ Hero Image + + + `; + await Deno.writeTextFile(htmlPath, rawHtml); + + const plugin = imagePlugin({ + widths: [1], + formats: ["webp"], + injectLazyLoad: true + }); + + // deno-lint-ignore no-explicit-any + const pluginAny = plugin as any; + + try { + // Run the build hook processing + await pluginAny.afterBuild({ output: testDir }); + + // Read back the processed HTML file + const processedHtml = await Deno.readTextFile(htmlPath); + + // Assert that dimension headers were dynamically read and injected + assertStringIncludes(processedHtml, 'width="1"'); + assertStringIncludes(processedHtml, 'height="1"'); + + // Assert that loading lazy was successfully added + assertStringIncludes(processedHtml, 'loading="lazy"'); + + // Assert that the inline Base64 LQIP transition style is present + assertStringIncludes(processedHtml, 'style="background-image: url(\'data:image/png;base64,'); + assertStringIncludes(processedHtml, "onload=\"this.style.filter='none'\""); + } finally { + await Deno.remove(testDir, { recursive: true }); + } + } }); \ No newline at end of file