Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 70 additions & 11 deletions mod.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,6 +10,7 @@ export interface ImagePluginOptions {
widths?: number[];
formats?: Array<"webp" | "jpeg" | "png">;
quality?: number;
injectLazyLoad?: boolean; // New Option!
}

export interface SiteConfig {
Expand All @@ -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"];

Expand All @@ -28,8 +30,9 @@ export default function imagePlugin(options: ImagePluginOptions = {}): StenoPlug

async afterBuild(config: SiteConfig): Promise<void> {
const outputDir = config.output ?? "dist";
const imageMetadataMap = new Map<string, { width: number; height: number; lqip?: string }>();

// 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)) {
Expand All @@ -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") {
Expand All @@ -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 <img> tags
const imgRegex = /<img\s+([^>]*?)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 `<img ${before}src="${src}"${after}${extraAttrs}>`;
});

if (modified) {
await Deno.writeTextFile(entry.path, htmlContent);
}
}
}
},
};
}
88 changes: 74 additions & 14 deletions mod_test.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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
Expand All @@ -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 = `
<!DOCTYPE html>
<html>
<body>
<h1>Welcome to Steno</h1>
<img src="/hero.png" alt="Hero Image">
</body>
</html>
`;
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 });
}
}
});
Loading