Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Image Loads #1309

Draft
wants to merge 24 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
85ed2de
attempt: a thumbnail preprocessor for cover images.
ItzNotABug Sep 8, 2024
5be9fb7
ran: formatter.
ItzNotABug Sep 8, 2024
1d78a1a
Merge branch 'main' into improve-image-loads
ItzNotABug Sep 16, 2024
8054302
Merge branch 'main' into improve-image-loads
ItzNotABug Sep 25, 2024
0be58a2
update: make sources non-blocking.
ItzNotABug Sep 25, 2024
8739c57
update: create author thumbnails too.
ItzNotABug Sep 25, 2024
fc2d744
ran: formatter.
ItzNotABug Sep 25, 2024
d2601c3
Merge branch 'main' into improve-image-loads
ItzNotABug Sep 25, 2024
8dc3db5
Merge branch 'main' into improve-image-loads
ItzNotABug Sep 25, 2024
9a4db5e
update: use feature image at full quality.
ItzNotABug Sep 26, 2024
ecfb1e4
fix: missing images causing failed tests.
ItzNotABug Sep 26, 2024
1f123d9
ran: formatter.
ItzNotABug Sep 26, 2024
db08dce
Merge branch 'main' into improve-image-loads
ItzNotABug Sep 27, 2024
fdfad2e
revert: minor change.
ItzNotABug Sep 27, 2024
2e4c460
fix: featured flag on a post.
ItzNotABug Sep 27, 2024
eaa8c2b
update: support for high-res but still compressed featured blog cover…
ItzNotABug Sep 27, 2024
b52bde4
update: lazy load certain image components on mobile, replace `enhanc…
ItzNotABug Sep 27, 2024
4b436e8
add: minifiers to test if it helps.
ItzNotABug Sep 29, 2024
1deec01
attempt: separate vendor chunks, smaller author thumbnails.
ItzNotABug Sep 29, 2024
25625aa
Merge branch 'main' into improve-image-loads
ItzNotABug Sep 29, 2024
8f86170
update: temporarily increase memory for previews, but would this work?
ItzNotABug Sep 29, 2024
fd9971c
update: minification perfs.
ItzNotABug Sep 29, 2024
755cb3e
remove: rollup chunks logic to see if they cause build timeouts due t…
ItzNotABug Sep 29, 2024
8da3da5
Merge branch 'main' into improve-image-loads
ItzNotABug Oct 4, 2024
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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build Website
env:
NODE_OPTIONS: '--max_old_space_size=8192'
NODE_OPTIONS: '--max_old_space_size=12288'
PUBLIC_APPWRITE_ENDPOINT: ${{ secrets.PUBLIC_APPWRITE_ENDPOINT }}
PUBLIC_APPWRITE_DASHBOARD: ${{ secrets.PUBLIC_APPWRITE_DASHBOARD }}
PUBLIC_APPWRITE_PROJECT_ID: ${{ secrets.PUBLIC_APPWRITE_PROJECT_ID }}
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ FROM base as build

COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN NODE_OPTIONS=--max_old_space_size=8192 pnpm run build
RUN NODE_OPTIONS=--max_old_space_size=12288 pnpm run build

FROM base as final

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
"vite": "^5.3.1",
"vite-plugin-dynamic-import": "^1.5.0",
"vite-plugin-image-optimizer": "^1.1.8",
"vitest": "^1.6.0"
"vitest": "^1.6.0",
"vite-plugin-minify": "^2.0.0",
"html-minifier-terser": "^7.2.0"
}
}
207 changes: 175 additions & 32 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions scripts/build.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { build } from 'vite';
import { minifyHtmlPostBuild } from './html-minifier.js';
import { downloadContributors } from './download-contributor-data.js';

async function main() {
await downloadContributors();
await build();
await minifyHtmlPostBuild();
}

main();
88 changes: 88 additions & 0 deletions scripts/html-minifier.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import path from 'path';
import fs from 'fs/promises';
import minifier from 'html-minifier-terser';

const prerenderedPagesDir = 'build/prerendered';
const htmlMinifierOptions = {
minifyJS: true,
minifyCSS: true,
useShortDoctype: true,
collapseWhitespace: true,
removeAttributeQuotes: true
};

async function getHtmlFiles(dir) {
let htmlFiles = [];
const files = await fs.readdir(dir, { withFileTypes: true });

for (const file of files) {
const filePath = path.join(dir, file.name);
if (file.isDirectory()) {
htmlFiles = htmlFiles.concat(await getHtmlFiles(filePath));
} else if (file.isFile() && file.name.endsWith('.html')) {
htmlFiles.push(filePath);
}
}

return htmlFiles;
}

// Function to format sizes in bytes to KB, MB, GB, etc.
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unitIndex = 0;

while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}

return `${value.toFixed(2)} ${units[unitIndex]}`;
}

export async function minifyHtmlPostBuild() {
let minifiedCount = 0;
let totalOriginalSize = 0;
let totalMinifiedSize = 0;

const minifyTasks = [];

console.log('Starting html minification...');

try {
const htmlFiles = await getHtmlFiles(prerenderedPagesDir);

for (const htmlPath of htmlFiles) {
minifyTasks.push(
(async () => {
try {
const html = await fs.readFile(htmlPath, 'utf-8');
const originalSize = Buffer.byteLength(html, 'utf-8');
totalOriginalSize += originalSize;

const minHTML = await minifier.minify(html, htmlMinifierOptions);
const minifiedSize = Buffer.byteLength(minHTML, 'utf-8');
totalMinifiedSize += minifiedSize;

await fs.writeFile(htmlPath, minHTML);

minifiedCount += 1;
} catch (error) {
console.error(`Failed to minify HTML for ${htmlPath}: ${error.message}`);
}
})()
);
}

await Promise.all(minifyTasks);

console.log(`Minification complete: ${minifiedCount} files processed.`);
console.log(
`Original: ${formatSize(totalOriginalSize)}, Minified: ${formatSize(totalMinifiedSize)}`
);
} catch (error) {
console.error(`Error processing files: ${error.message}`);
}
}
179 changes: 179 additions & 0 deletions scripts/thumbnails.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { mkdirSync, readdirSync, readFileSync } from 'fs';
import { dirname, join } from 'path';
import sharp from 'sharp';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
// Define the base path for static images
const srcDir = join(__dirname, '../static/images');

const authorDir = join(srcDir, 'avatars');
const blogCoverDir = join(srcDir, 'blog');
const authorDestDir = join(authorDir, 'thumbnails');
const blogCoverDestDir = join(blogCoverDir, 'thumbnails');

const articlesDir = join(__dirname, '../src/routes/blog/post');
const authorsDir = join(__dirname, '../src/routes/blog/author');

function isPNG(file) {
return file.endsWith('.png');
}

function parseFrontmatter(file, key = 'cover') {
const content = readFileSync(file, 'utf8');
const fmRegex = /---\s*([\s\S]*?)\s*---/;
const searchForFeaturedPost = key === 'cover';

const featuredRegex = new RegExp(`^featured:\\s*['"]?(.+?)['"]?$`, 'm');

const match = content.match(fmRegex);
if (match) {
const fmContent = match[1];
const regex = new RegExp(`^${key}:\\s*(.+)$`, 'm');
const imageMatch = fmContent.match(regex);
if (imageMatch) {
let withFeature = {};
withFeature.image = imageMatch[1].trim();

if (searchForFeaturedPost) {
const featureMatch = fmContent.match(featuredRegex);
if (featureMatch) {
withFeature.featured = featureMatch[1] === 'true';
} else {
withFeature.featured = false;
}
}

return withFeature;
}
}
return null;
}

function walkDirectory(dir, list = []) {
const files = readdirSync(dir, { withFileTypes: true });
files.forEach((file) => {
const pathToFile = join(dir, file.name);
if (file.isDirectory()) {
list = walkDirectory(pathToFile, list);
} else if (file.name === '+page.markdoc') {
list.push(pathToFile);
}
});

return list;
}

function ensureDir(path) {
try {
mkdirSync(path, { recursive: true });
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}

async function createThumbnails(images, destDir, options) {
const { width, height } = options;

for (const file of images) {
let relativePath = file.substring(srcDir.length).replace(/\/blog\/|\/avatars\//, '');
const thumbBasePath = join(destDir, relativePath);

ensureDir(dirname(thumbBasePath));

const pngThumbPath = thumbBasePath.replace(/\.[^/.]+$/, '.png');
const webpThumbPath = thumbBasePath.replace(/\.[^/.]+$/, '.webp');

const resizedImageBuffer = await sharp(file)
.resize({
width,
height,
fit: sharp.fit.inside,
withoutEnlargement: true
})
.toBuffer();

const sharpInstance = sharp(resizedImageBuffer);

await Promise.all([
sharpInstance.webp({ lossless: true }).toFile(webpThumbPath),
sharpInstance.png({ compressionLevel: 9 }).toFile(pngThumbPath)
]);
}
}

function getBlogCovers() {
const markdocFiles = walkDirectory(articlesDir);
return markdocFiles
.map((filePath) => {
const { featured, image: coverPath } = parseFrontmatter(filePath);

if (coverPath && isPNG(coverPath)) {
return {
featured: featured,
coverPath: join(__dirname, '../static', coverPath)
};
}
})
.filter(Boolean);
}

function getAuthorAvatars() {
const authorFiles = walkDirectory(authorsDir);
return authorFiles
.map((filePath) => {
const { image: avatarPath } = parseFrontmatter(filePath, 'avatar');
if (avatarPath && isPNG(avatarPath)) {
return join(__dirname, '../static', avatarPath);
}
})
.filter(Boolean);
}

/**
* @typedef {{ width: number, height: number }} ThumbnailSize
*
* @typedef {{
* cover: {
* normal: ThumbnailSize,
* featured: ThumbnailSize
* },
* author: ThumbnailSize
* }} ThumbnailOptions
*/

/**
* Preprocess thumbnails for cover and author images.
*
* @param {Object} [options={}] - Options to configure the thumbnail sizes.
* @param {{
* cover?: {
* normal?: ThumbnailSize,
* featured?: ThumbnailSize
* },
* author?: ThumbnailSize
* }} [options] - The optional thumbnail configuration.
*/
export async function thumbnailPreprocess(options = {}) {
const {
cover: {
normal: normalCover = { width: 320, height: 320 },
featured: featuredCover = { width: 640, height: 640 }
} = {},
author: authorOption = { width: 112, height: 112 }
} = options;

const coverImages = getBlogCovers();
const authorAvatars = getAuthorAvatars();

const normalCovers = coverImages.filter((img) => !img.featured).map((img) => img.coverPath);
const featuredCovers = coverImages.filter((img) => img.featured).map((img) => img.coverPath);

await Promise.all([
createThumbnails(authorAvatars, authorDestDir, authorOption),
createThumbnails(normalCovers, blogCoverDestDir, normalCover),
createThumbnails(featuredCovers, blogCoverDestDir, featuredCover)
]);

return { name: 'thumbnail-creator-preprocessor' };
}
Loading
Loading