new pipeline and build improvements
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
.git
|
.git
|
||||||
.gitea
|
.gitea
|
||||||
utils/xor-enc.py
|
dist
|
||||||
|
node_modules
|
||||||
|
utils/xor-enc.py
|
||||||
|
|||||||
22
.gitea/workflows/deploy.yaml
Normal file
22
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: Homepage Deploy (Production)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy Homepage
|
||||||
|
runs-on: ubuntu-amd64
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Recreate Container
|
||||||
|
uses: https://git.ivanch.me/ivanch/pipeline-actions/ssh-deploy@main
|
||||||
|
with:
|
||||||
|
ssh_host: ${{ secrets.HOST }}
|
||||||
|
ssh_username: ${{ secrets.USERNAME }}
|
||||||
|
ssh_key: ${{ secrets.KEY }}
|
||||||
|
ssh_port: ${{ secrets.PORT }}
|
||||||
|
remote_dir: ${{ secrets.DIR }}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Homepage Build and Deploy
|
name: Homepage Build and Deploy (Test)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -41,4 +41,4 @@ jobs:
|
|||||||
ssh_username: ${{ secrets.USERNAME }}
|
ssh_username: ${{ secrets.USERNAME }}
|
||||||
ssh_key: ${{ secrets.KEY }}
|
ssh_key: ${{ secrets.KEY }}
|
||||||
ssh_port: ${{ secrets.PORT }}
|
ssh_port: ${{ secrets.PORT }}
|
||||||
remote_dir: ${{ secrets.DIR }}
|
remote_dir: ${{ secrets.TEST_DIR }}
|
||||||
|
|||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
7
404.html
7
404.html
@@ -15,6 +15,11 @@
|
|||||||
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
||||||
|
|
||||||
<title>404 | ivanch</title>
|
<title>404 | ivanch</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/main.css">
|
<link rel="stylesheet" href="css/main.css">
|
||||||
<link rel="stylesheet" href="css/matrix.css">
|
<link rel="stylesheet" href="css/matrix.css">
|
||||||
<link rel="stylesheet" href="css/button.css">
|
<link rel="stylesheet" href="css/button.css">
|
||||||
@@ -48,4 +53,4 @@
|
|||||||
<script src="js/matrix.js"></script>
|
<script src="js/matrix.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
15
Dockerfile
15
Dockerfile
@@ -1,19 +1,18 @@
|
|||||||
FROM node:alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY . /src
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
# Install terser to minify JS files
|
COPY . .
|
||||||
RUN npm install -g terser
|
RUN OUTPUT_DIR=/dist npm run build:production
|
||||||
RUN if [ -d "js" ]; then find js -type f -name "*.js" -exec sh -c 'for f; do terser "$f" -c -m -o "$f"; done' sh {} +; fi
|
|
||||||
|
|
||||||
FROM nginx:alpine-slim AS final
|
FROM nginx:alpine-slim AS final
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY . /usr/share/nginx/html
|
COPY --from=build /dist /usr/share/nginx/html
|
||||||
COPY --from=build /src/js /usr/share/nginx/html/js
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
@import url('variables.css');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@100..900&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400..700&display=swap');
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -60,7 +56,6 @@ img {
|
|||||||
border-top: 1px solid var(--glass-border);
|
border-top: 1px solid var(--glass-border);
|
||||||
border-bottom: 1px solid var(--glass-border);
|
border-bottom: 1px solid var(--glass-border);
|
||||||
box-shadow: var(--shadow-lg), var(--inset-shadow);
|
box-shadow: var(--shadow-lg), var(--inset-shadow);
|
||||||
will-change: box-shadow;
|
|
||||||
animation: glass-shadow 10s ease-in-out infinite;
|
animation: glass-shadow 10s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
||||||
|
|
||||||
<title>ivanch</title>
|
<title>ivanch</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/variables.css">
|
||||||
<link rel="stylesheet" href="css/main.css">
|
<link rel="stylesheet" href="css/main.css">
|
||||||
|
|
||||||
<link rel="stylesheet" href="css/sections/profile.css">
|
<link rel="stylesheet" href="css/sections/profile.css">
|
||||||
@@ -34,7 +38,7 @@
|
|||||||
<div class="glass hero-card">
|
<div class="glass hero-card">
|
||||||
<div class="profile-container">
|
<div class="profile-container">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<img src="/assets/lain.png" alt="Avatar" />
|
<img src="/assets/lain.png" alt="Avatar" width="160" height="160" decoding="async" />
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-info">
|
<div class="profile-info">
|
||||||
<div class="profile-info-item">
|
<div class="profile-info-item">
|
||||||
@@ -152,4 +156,4 @@
|
|||||||
<script src="js/matrix.js"></script>
|
<script src="js/matrix.js"></script>
|
||||||
<script src="js/social.js"></script>
|
<script src="js/social.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
310
js/matrix.js
310
js/matrix.js
@@ -1,13 +1,21 @@
|
|||||||
class MatrixBackground {
|
class MatrixBackground {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.canvas = document.getElementById('matrixCanvas');
|
this.canvas = document.getElementById('matrixCanvas');
|
||||||
|
if (!this.canvas) return;
|
||||||
|
|
||||||
this.ctx = this.canvas.getContext('2d');
|
this.ctx = this.canvas.getContext('2d');
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
this.dots = [];
|
this.dots = [];
|
||||||
this.connections = [];
|
this.connections = [];
|
||||||
this.mouse = { x: -9999, y: -9999, active: false };
|
this.mouse = { x: -9999, y: -9999, active: false };
|
||||||
this.rafId = null;
|
this.rafId = null;
|
||||||
this.isRunning = true;
|
this.isRunning = false;
|
||||||
this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
this.lastTimestamp = 0;
|
||||||
|
this.motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
this.prefersReducedMotion = this.motionQuery.matches;
|
||||||
|
this.dotSpriteCache = new Map();
|
||||||
|
this.onAnimationFrame = (timestamp) => this.animate(timestamp);
|
||||||
|
|
||||||
this.config = {
|
this.config = {
|
||||||
dotCount: this.getMobileDotCount(),
|
dotCount: this.getMobileDotCount(),
|
||||||
@@ -18,7 +26,6 @@ class MatrixBackground {
|
|||||||
dotSize: 3,
|
dotSize: 3,
|
||||||
mouseRadius: 110,
|
mouseRadius: 110,
|
||||||
mouseForce: 0.6,
|
mouseForce: 0.6,
|
||||||
// Palette synced to the UI accent tokens
|
|
||||||
colors: {
|
colors: {
|
||||||
dotDefault: 'rgba(230, 240, 255, 0.78)',
|
dotDefault: 'rgba(230, 240, 255, 0.78)',
|
||||||
dotDefaultGlow: 'rgba(180, 220, 255, 0.40)',
|
dotDefaultGlow: 'rgba(180, 220, 255, 0.40)',
|
||||||
@@ -51,15 +58,18 @@ class MatrixBackground {
|
|||||||
init() {
|
init() {
|
||||||
this.handleResize();
|
this.handleResize();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.animate();
|
|
||||||
|
if (this.prefersReducedMotion) {
|
||||||
|
this.renderFrame(0);
|
||||||
|
} else {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bindEvents() {
|
bindEvents() {
|
||||||
// Pointer reactivity — push dots away from cursor for a "matrix repel" feel.
|
window.addEventListener('pointermove', (event) => {
|
||||||
// Touch devices are included; the dot pushes happen only while touched.
|
this.mouse.x = event.clientX;
|
||||||
window.addEventListener('pointermove', (e) => {
|
this.mouse.y = event.clientY;
|
||||||
this.mouse.x = e.clientX;
|
|
||||||
this.mouse.y = e.clientY;
|
|
||||||
this.mouse.active = true;
|
this.mouse.active = true;
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
@@ -69,35 +79,59 @@ class MatrixBackground {
|
|||||||
this.mouse.y = -9999;
|
this.mouse.y = -9999;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pause the rAF loop when the tab is hidden to save CPU / battery.
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
} else if (this.prefersReducedMotion) {
|
||||||
|
this.renderFrame(this.lastTimestamp);
|
||||||
} else {
|
} else {
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resize is debounced via rAF; disabled on mobile for perf parity with original.
|
const updateMotionPreference = (event) => {
|
||||||
|
this.prefersReducedMotion = event.matches;
|
||||||
|
|
||||||
|
if (this.prefersReducedMotion) {
|
||||||
|
this.stop();
|
||||||
|
this.renderFrame(this.lastTimestamp);
|
||||||
|
} else if (!document.hidden) {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.motionQuery.addEventListener) {
|
||||||
|
this.motionQuery.addEventListener('change', updateMotionPreference);
|
||||||
|
} else {
|
||||||
|
this.motionQuery.addListener(updateMotionPreference);
|
||||||
|
}
|
||||||
|
|
||||||
if (window.innerWidth > 768) {
|
if (window.innerWidth > 768) {
|
||||||
let resizeRaf = null;
|
let resizeRaf = null;
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
if (resizeRaf) cancelAnimationFrame(resizeRaf);
|
if (resizeRaf !== null) cancelAnimationFrame(resizeRaf);
|
||||||
resizeRaf = requestAnimationFrame(() => this.handleResize());
|
resizeRaf = requestAnimationFrame(() => {
|
||||||
|
resizeRaf = null;
|
||||||
|
this.handleResize();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
if (this.rafId || !this.isRunning) return;
|
if (this.rafId !== null || this.prefersReducedMotion || document.hidden) return;
|
||||||
this.animate();
|
|
||||||
|
this.isRunning = true;
|
||||||
|
this.rafId = requestAnimationFrame(this.onAnimationFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if (this.rafId) {
|
if (this.rafId !== null) {
|
||||||
cancelAnimationFrame(this.rafId);
|
cancelAnimationFrame(this.rafId);
|
||||||
this.rafId = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.rafId = null;
|
||||||
|
this.isRunning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleResize() {
|
handleResize() {
|
||||||
@@ -105,12 +139,17 @@ class MatrixBackground {
|
|||||||
this.canvas.height = window.innerHeight;
|
this.canvas.height = window.innerHeight;
|
||||||
this.config.dotCount = this.getMobileDotCount();
|
this.config.dotCount = this.getMobileDotCount();
|
||||||
this.config.maxConnections = this.getMobileMaxConnections();
|
this.config.maxConnections = this.getMobileMaxConnections();
|
||||||
|
this.connections = [];
|
||||||
this.createDots();
|
this.createDots();
|
||||||
|
|
||||||
|
if (this.prefersReducedMotion) {
|
||||||
|
this.renderFrame(this.lastTimestamp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createDots() {
|
createDots() {
|
||||||
this.dots = [];
|
this.dots = [];
|
||||||
for (let i = 0; i < this.config.dotCount; i++) {
|
for (let index = 0; index < this.config.dotCount; index++) {
|
||||||
this.createDot();
|
this.createDot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +157,6 @@ class MatrixBackground {
|
|||||||
createDot() {
|
createDot() {
|
||||||
const startX = Math.random() * (this.canvas.width - 20) + 10;
|
const startX = Math.random() * (this.canvas.width - 20) + 10;
|
||||||
const startY = Math.random() * (this.canvas.height - 20) + 10;
|
const startY = Math.random() * (this.canvas.height - 20) + 10;
|
||||||
|
|
||||||
const angle = Math.random() * Math.PI * 2;
|
const angle = Math.random() * Math.PI * 2;
|
||||||
const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4);
|
const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4);
|
||||||
const baseVelocityX = Math.cos(angle) * speed;
|
const baseVelocityX = Math.cos(angle) * speed;
|
||||||
@@ -141,36 +179,32 @@ class MatrixBackground {
|
|||||||
|
|
||||||
updateDots() {
|
updateDots() {
|
||||||
const radius = this.config.mouseRadius;
|
const radius = this.config.mouseRadius;
|
||||||
const radiusSq = radius * radius;
|
const radiusSquared = radius * radius;
|
||||||
const force = this.config.mouseForce;
|
const force = this.config.mouseForce;
|
||||||
|
|
||||||
this.dots.forEach(dot => {
|
for (const dot of this.dots) {
|
||||||
// Mouse repulsion
|
|
||||||
if (this.mouse.active) {
|
if (this.mouse.active) {
|
||||||
const dx = dot.x - this.mouse.x;
|
const dx = dot.x - this.mouse.x;
|
||||||
const dy = dot.y - this.mouse.y;
|
const dy = dot.y - this.mouse.y;
|
||||||
const distSq = dx * dx + dy * dy;
|
const distanceSquared = dx * dx + dy * dy;
|
||||||
if (distSq < radiusSq && distSq > 0.5) {
|
|
||||||
const dist = Math.sqrt(distSq);
|
if (distanceSquared < radiusSquared && distanceSquared > 0.5) {
|
||||||
const strength = (1 - dist / radius) * force;
|
const distance = Math.sqrt(distanceSquared);
|
||||||
dot.vx += (dx / dist) * strength;
|
const strength = (1 - distance / radius) * force;
|
||||||
dot.vy += (dy / dist) * strength;
|
dot.vx += (dx / distance) * strength;
|
||||||
|
dot.vy += (dy / distance) * strength;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dot.x += dot.vx;
|
dot.x += dot.vx;
|
||||||
dot.y += dot.vy;
|
dot.y += dot.vy;
|
||||||
|
|
||||||
// Ease back to a gentle autonomous drift after pointer interaction.
|
|
||||||
dot.wanderPhase += dot.wanderSpeed;
|
dot.wanderPhase += dot.wanderSpeed;
|
||||||
|
|
||||||
const wanderX = Math.cos(dot.wanderPhase) * 0.03;
|
const wanderX = Math.cos(dot.wanderPhase) * 0.03;
|
||||||
const wanderY = Math.sin(dot.wanderPhase * 0.8) * 0.03;
|
const wanderY = Math.sin(dot.wanderPhase * 0.8) * 0.03;
|
||||||
dot.vx += (dot.baseVx + wanderX - dot.vx) * 0.02;
|
dot.vx += (dot.baseVx + wanderX - dot.vx) * 0.02;
|
||||||
dot.vy += (dot.baseVy + wanderY - dot.vy) * 0.02;
|
dot.vy += (dot.baseVy + wanderY - dot.vy) * 0.02;
|
||||||
|
|
||||||
// Reflect at the canvas edge. Keep the base velocity in sync with
|
|
||||||
// the bounce so the drift easing does not push the dot back into
|
|
||||||
// the same wall on the next frames.
|
|
||||||
const dotRadius = dot.size / 2;
|
const dotRadius = dot.size / 2;
|
||||||
const minX = dotRadius;
|
const minX = dotRadius;
|
||||||
const minY = dotRadius;
|
const minY = dotRadius;
|
||||||
@@ -197,69 +231,160 @@ class MatrixBackground {
|
|||||||
dot.baseVy = -Math.abs(dot.baseVy);
|
dot.baseVy = -Math.abs(dot.baseVy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A large pointer impulse can overshoot both edges by more than
|
|
||||||
// one frame, so clamp after reflecting the position.
|
|
||||||
dot.x = Math.max(minX, Math.min(maxX, dot.x));
|
dot.x = Math.max(minX, Math.min(maxX, dot.x));
|
||||||
dot.y = Math.max(minY, Math.min(maxY, dot.y));
|
dot.y = Math.max(minY, Math.min(maxY, dot.y));
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDotSprite(size, isConnected) {
|
||||||
|
const roundedSize = Math.round(size * 2) / 2;
|
||||||
|
const state = isConnected ? 'connected' : 'default';
|
||||||
|
const cacheKey = state + ':' + roundedSize;
|
||||||
|
const cachedSprite = this.dotSpriteCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedSprite) {
|
||||||
|
return cachedSprite;
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = 10;
|
||||||
|
const dimension = Math.ceil(roundedSize + padding * 2);
|
||||||
|
const sprite = document.createElement('canvas');
|
||||||
|
sprite.width = dimension;
|
||||||
|
sprite.height = dimension;
|
||||||
|
|
||||||
|
const spriteContext = sprite.getContext('2d');
|
||||||
|
spriteContext.shadowBlur = 6;
|
||||||
|
spriteContext.shadowColor = isConnected
|
||||||
|
? this.config.colors.dotConnectedGlow
|
||||||
|
: this.config.colors.dotDefaultGlow;
|
||||||
|
spriteContext.fillStyle = isConnected
|
||||||
|
? this.config.colors.dotConnected
|
||||||
|
: this.config.colors.dotDefault;
|
||||||
|
spriteContext.beginPath();
|
||||||
|
spriteContext.arc(dimension / 2, dimension / 2, roundedSize / 2, 0, Math.PI * 2);
|
||||||
|
spriteContext.fill();
|
||||||
|
|
||||||
|
this.dotSpriteCache.set(cacheKey, sprite);
|
||||||
|
return sprite;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawDots() {
|
drawDots() {
|
||||||
this.dots.forEach(dot => {
|
for (const dot of this.dots) {
|
||||||
const isConnected = dot.connectionCount > 0;
|
const sprite = this.getDotSprite(dot.size, dot.connectionCount > 0);
|
||||||
this.ctx.beginPath();
|
const halfDimension = sprite.width / 2;
|
||||||
this.ctx.arc(dot.x, dot.y, dot.size / 2, 0, Math.PI * 2);
|
|
||||||
this.ctx.fillStyle = isConnected ? this.config.colors.dotConnected : this.config.colors.dotDefault;
|
|
||||||
this.ctx.globalAlpha = dot.opacity;
|
this.ctx.globalAlpha = dot.opacity;
|
||||||
this.ctx.shadowBlur = 6;
|
this.ctx.drawImage(sprite, dot.x - halfDimension, dot.y - halfDimension);
|
||||||
this.ctx.shadowColor = isConnected ? this.config.colors.dotConnectedGlow : this.config.colors.dotDefaultGlow;
|
}
|
||||||
this.ctx.fill();
|
|
||||||
});
|
|
||||||
this.ctx.globalAlpha = 1;
|
this.ctx.globalAlpha = 1;
|
||||||
this.ctx.shadowBlur = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createConnection(dot1, dot2) {
|
createConnection(dot1, dot2, timestamp) {
|
||||||
if (this.connections.length >= this.config.maxConnections) return;
|
if (this.connections.length >= this.config.maxConnections) return;
|
||||||
|
|
||||||
dot1.connectionCount++;
|
dot1.connectionCount++;
|
||||||
dot2.connectionCount++;
|
dot2.connectionCount++;
|
||||||
|
|
||||||
this.connections.push({
|
this.connections.push({
|
||||||
dot1: dot1,
|
dot1,
|
||||||
dot2: dot2,
|
dot2,
|
||||||
startTime: Date.now(),
|
startTime: timestamp,
|
||||||
duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min) + this.config.connectionDuration.min
|
duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min)
|
||||||
|
+ this.config.connectionDuration.min
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateConnections() {
|
updateConnections(timestamp) {
|
||||||
this.connections = this.connections.filter(conn => {
|
let writeIndex = 0;
|
||||||
const elapsed = Date.now() - conn.startTime;
|
|
||||||
if (elapsed > conn.duration) {
|
for (let readIndex = 0; readIndex < this.connections.length; readIndex++) {
|
||||||
conn.dot1.connectionCount--;
|
const connection = this.connections[readIndex];
|
||||||
conn.dot2.connectionCount--;
|
|
||||||
return false;
|
if (timestamp - connection.startTime > connection.duration) {
|
||||||
|
connection.dot1.connectionCount--;
|
||||||
|
connection.dot2.connectionCount--;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
});
|
this.connections[writeIndex] = connection;
|
||||||
|
writeIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connections.length = writeIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawConnections() {
|
samplePoisson(lambda) {
|
||||||
this.connections.forEach(conn => {
|
if (lambda <= 0) return 0;
|
||||||
const { dot1, dot2, startTime, duration } = conn;
|
|
||||||
const elapsed = Date.now() - startTime;
|
const threshold = Math.exp(-lambda);
|
||||||
const opacity = Math.min(1, elapsed / 500);
|
let product = 1;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
count++;
|
||||||
|
product *= Math.random();
|
||||||
|
} while (product > threshold);
|
||||||
|
|
||||||
|
return count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tryCreateConnections(timestamp) {
|
||||||
|
const dotCount = this.dots.length;
|
||||||
|
if (dotCount < 2 || this.connections.length >= this.config.maxConnections) return;
|
||||||
|
|
||||||
|
const pairCount = dotCount * (dotCount - 1) / 2;
|
||||||
|
const attempts = this.samplePoisson(pairCount * this.config.connectionChance);
|
||||||
|
|
||||||
|
for (let attempt = 0;
|
||||||
|
attempt < attempts && this.connections.length < this.config.maxConnections;
|
||||||
|
attempt++) {
|
||||||
|
const firstIndex = Math.floor(Math.random() * dotCount);
|
||||||
|
let secondIndex = Math.floor(Math.random() * (dotCount - 1));
|
||||||
|
|
||||||
|
if (secondIndex >= firstIndex) {
|
||||||
|
secondIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot1 = this.dots[firstIndex];
|
||||||
|
const dot2 = this.dots[secondIndex];
|
||||||
|
const dx = dot2.x - dot1.x;
|
||||||
|
const dy = dot2.y - dot1.y;
|
||||||
|
const distanceSquared = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (distanceSquared >= 22500 || distanceSquared <= 900) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyConnected = this.connections.some((connection) =>
|
||||||
|
(connection.dot1 === dot1 && connection.dot2 === dot2)
|
||||||
|
|| (connection.dot1 === dot2 && connection.dot2 === dot1)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alreadyConnected) {
|
||||||
|
this.createConnection(dot1, dot2, timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawConnections(timestamp) {
|
||||||
|
for (const connection of this.connections) {
|
||||||
|
const elapsed = timestamp - connection.startTime;
|
||||||
|
const opacity = Math.min(1, elapsed / 500);
|
||||||
|
const gradient = this.ctx.createLinearGradient(
|
||||||
|
connection.dot1.x,
|
||||||
|
connection.dot1.y,
|
||||||
|
connection.dot2.x,
|
||||||
|
connection.dot2.y
|
||||||
|
);
|
||||||
|
|
||||||
const gradient = this.ctx.createLinearGradient(dot1.x, dot1.y, dot2.x, dot2.y);
|
|
||||||
gradient.addColorStop(0, this.config.colors.connection.start);
|
gradient.addColorStop(0, this.config.colors.connection.start);
|
||||||
gradient.addColorStop(0.5, this.config.colors.connection.middle);
|
gradient.addColorStop(0.5, this.config.colors.connection.middle);
|
||||||
gradient.addColorStop(1, this.config.colors.connection.end);
|
gradient.addColorStop(1, this.config.colors.connection.end);
|
||||||
|
|
||||||
this.ctx.beginPath();
|
this.ctx.beginPath();
|
||||||
this.ctx.moveTo(dot1.x, dot1.y);
|
this.ctx.moveTo(connection.dot1.x, connection.dot1.y);
|
||||||
this.ctx.lineTo(dot2.x, dot2.y);
|
this.ctx.lineTo(connection.dot2.x, connection.dot2.y);
|
||||||
|
|
||||||
this.ctx.strokeStyle = gradient;
|
this.ctx.strokeStyle = gradient;
|
||||||
this.ctx.lineWidth = 2;
|
this.ctx.lineWidth = 2;
|
||||||
this.ctx.globalAlpha = opacity;
|
this.ctx.globalAlpha = opacity;
|
||||||
@@ -267,51 +392,34 @@ class MatrixBackground {
|
|||||||
const pulse = (Math.sin((elapsed / 2000) * Math.PI * 2) + 1) / 2;
|
const pulse = (Math.sin((elapsed / 2000) * Math.PI * 2) + 1) / 2;
|
||||||
this.ctx.shadowBlur = 4 + pulse * 6;
|
this.ctx.shadowBlur = 4 + pulse * 6;
|
||||||
this.ctx.shadowColor = 'rgba(118, 75, 162, 0.5)';
|
this.ctx.shadowColor = 'rgba(118, 75, 162, 0.5)';
|
||||||
|
|
||||||
this.ctx.stroke();
|
this.ctx.stroke();
|
||||||
});
|
}
|
||||||
|
|
||||||
this.ctx.globalAlpha = 1;
|
this.ctx.globalAlpha = 1;
|
||||||
this.ctx.shadowBlur = 0;
|
this.ctx.shadowBlur = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
tryCreateConnections() {
|
renderFrame(timestamp) {
|
||||||
for (let i = 0; i < this.dots.length; i++) {
|
this.lastTimestamp = timestamp;
|
||||||
for (let j = i + 1; j < this.dots.length; j++) {
|
|
||||||
if (Math.random() < this.config.connectionChance) {
|
|
||||||
const dot1 = this.dots[i];
|
|
||||||
const dot2 = this.dots[j];
|
|
||||||
|
|
||||||
const dx = dot2.x - dot1.x;
|
|
||||||
const dy = dot2.y - dot1.y;
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance < 150 && distance > 30) {
|
|
||||||
const alreadyConnected = this.connections.some(conn =>
|
|
||||||
(conn.dot1 === dot1 && conn.dot2 === dot2) ||
|
|
||||||
(conn.dot1 === dot2 && conn.dot2 === dot1)
|
|
||||||
);
|
|
||||||
if (!alreadyConnected) {
|
|
||||||
this.createConnection(dot1, dot2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
animate() {
|
|
||||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
if (!this.prefersReducedMotion) {
|
if (!this.prefersReducedMotion) {
|
||||||
this.updateDots();
|
this.updateDots();
|
||||||
this.updateConnections();
|
this.updateConnections(timestamp);
|
||||||
this.tryCreateConnections();
|
this.tryCreateConnections(timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.drawConnections();
|
this.drawConnections(timestamp);
|
||||||
this.drawDots();
|
this.drawDots();
|
||||||
|
}
|
||||||
|
|
||||||
this.rafId = requestAnimationFrame(() => this.animate());
|
animate(timestamp) {
|
||||||
|
this.rafId = null;
|
||||||
|
this.renderFrame(timestamp);
|
||||||
|
|
||||||
|
if (this.isRunning && !this.prefersReducedMotion && !document.hidden) {
|
||||||
|
this.rafId = requestAnimationFrame(this.onAnimationFrame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
js/social.js
26
js/social.js
@@ -9,13 +9,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
if (avatar && avatarImg && !prefersReducedMotion) {
|
if (avatar && avatarImg && !prefersReducedMotion) {
|
||||||
['r', 'b'].forEach((channel) => {
|
const createGlitchLayers = () => {
|
||||||
const clone = avatarImg.cloneNode(false);
|
if (avatar.querySelector('.glitch-layer')) return;
|
||||||
clone.classList.add('glitch-layer', channel);
|
|
||||||
clone.removeAttribute('alt');
|
['r', 'b'].forEach((channel) => {
|
||||||
clone.setAttribute('aria-hidden', 'true');
|
const clone = avatarImg.cloneNode(false);
|
||||||
avatar.appendChild(clone);
|
clone.classList.add('glitch-layer', channel);
|
||||||
});
|
clone.removeAttribute('alt');
|
||||||
|
clone.setAttribute('aria-hidden', 'true');
|
||||||
|
avatar.appendChild(clone);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGlitchLayers = () => {
|
||||||
|
avatar.querySelectorAll('.glitch-layer').forEach((layer) => layer.remove());
|
||||||
|
};
|
||||||
|
|
||||||
|
avatar.addEventListener('pointerenter', createGlitchLayers);
|
||||||
|
avatar.addEventListener('pointerleave', removeGlitchLayers);
|
||||||
|
avatar.addEventListener('pointercancel', removeGlitchLayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
79
nginx.conf
79
nginx.conf
@@ -1,10 +1,10 @@
|
|||||||
user nginx;
|
user nginx;
|
||||||
worker_processes auto;
|
worker_processes 1;
|
||||||
error_log /var/log/nginx/error.log warn;
|
error_log /var/log/nginx/error.log warn;
|
||||||
pid /var/run/nginx.pid;
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
events {
|
events {
|
||||||
worker_connections 1024;
|
worker_connections 256;
|
||||||
use epoll;
|
use epoll;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,22 +12,16 @@ http {
|
|||||||
include /etc/nginx/mime.types;
|
include /etc/nginx/mime.types;
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
|
|
||||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
access_log off;
|
||||||
'$status $body_bytes_sent "$http_referer" '
|
|
||||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
|
||||||
|
|
||||||
access_log /var/log/nginx/access.log main;
|
|
||||||
|
|
||||||
# Performance optimizations
|
|
||||||
sendfile on;
|
sendfile on;
|
||||||
tcp_nopush on;
|
tcp_nopush on;
|
||||||
tcp_nodelay on;
|
tcp_nodelay on;
|
||||||
keepalive_timeout 65;
|
keepalive_timeout 15;
|
||||||
keepalive_requests 100;
|
keepalive_requests 100;
|
||||||
types_hash_max_size 2048;
|
types_hash_max_size 2048;
|
||||||
client_max_body_size 20M;
|
client_max_body_size 1m;
|
||||||
|
|
||||||
# Buffers & Timeouts tuning
|
|
||||||
client_body_buffer_size 16k;
|
client_body_buffer_size 16k;
|
||||||
client_header_buffer_size 1k;
|
client_header_buffer_size 1k;
|
||||||
large_client_header_buffers 4 8k;
|
large_client_header_buffers 4 8k;
|
||||||
@@ -35,17 +29,18 @@ http {
|
|||||||
client_header_timeout 12s;
|
client_header_timeout 12s;
|
||||||
send_timeout 10s;
|
send_timeout 10s;
|
||||||
|
|
||||||
# Open File Cache
|
open_file_cache max=100 inactive=60s;
|
||||||
open_file_cache max=1000 inactive=20s;
|
open_file_cache_valid 60s;
|
||||||
open_file_cache_valid 30s;
|
|
||||||
open_file_cache_min_uses 2;
|
open_file_cache_min_uses 2;
|
||||||
open_file_cache_errors on;
|
open_file_cache_errors off;
|
||||||
|
|
||||||
# Gzip compression
|
|
||||||
gzip on;
|
gzip on;
|
||||||
|
gzip_static on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
gzip_proxied any;
|
gzip_proxied any;
|
||||||
gzip_comp_level 6;
|
gzip_comp_level 4;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_disable "msie6";
|
||||||
gzip_types
|
gzip_types
|
||||||
text/plain
|
text/plain
|
||||||
text/css
|
text/css
|
||||||
@@ -62,10 +57,7 @@ http {
|
|||||||
font/woff2
|
font/woff2
|
||||||
application/vnd.ms-fontobject
|
application/vnd.ms-fontobject
|
||||||
application/x-font-ttf;
|
application/x-font-ttf;
|
||||||
gzip_min_length 256;
|
|
||||||
gzip_disable "msie6";
|
|
||||||
|
|
||||||
# Security headers (Global default)
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
@@ -73,67 +65,33 @@ http {
|
|||||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
|
|
||||||
# HTTP (port 80) Server Block
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name localhost ivanch.me www.ivanch.me;
|
server_name localhost ivanch.me www.ivanch.me;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Charset
|
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
# Custom error pages
|
|
||||||
error_page 404 /404.html;
|
error_page 404 /404.html;
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
# Main location
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets & redeclare security headers to bypass Nginx header override gotcha
|
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|webp|woff|woff2|ttf|otf|eot)$ {
|
||||||
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
access_log off;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(css|js)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
|
||||||
access_log off;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(woff|woff2|ttf|otf|eot)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
|
||||||
access_log off;
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTML files - shorter cache
|
|
||||||
location ~* \.html$ {
|
location ~* \.html$ {
|
||||||
expires 1h;
|
expires off;
|
||||||
add_header Cache-Control "public, must-revalidate";
|
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
@@ -142,19 +100,14 @@ http {
|
|||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Deny access to hidden files
|
|
||||||
location ~ /\. {
|
location ~ /\. {
|
||||||
deny all;
|
deny all;
|
||||||
access_log off;
|
|
||||||
log_not_found off;
|
log_not_found off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Deny access to backup files
|
|
||||||
location ~ ~$ {
|
location ~ ~$ {
|
||||||
deny all;
|
deny all;
|
||||||
access_log off;
|
|
||||||
log_not_found off;
|
log_not_found off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1415
package-lock.json
generated
Normal file
1415
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "ivanch-me",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build:production": "node utils/build-production.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "0.28.1",
|
||||||
|
"html-minifier-terser": "7.2.0",
|
||||||
|
"sharp": "0.35.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
250
utils/build-production.mjs
Normal file
250
utils/build-production.mjs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { gzipSync } from 'node:zlib';
|
||||||
|
import { transform } from 'esbuild';
|
||||||
|
import { minify } from 'html-minifier-terser';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const rootDirectory = path.resolve(process.cwd());
|
||||||
|
const outputDirectory = path.resolve(process.env.OUTPUT_DIR || path.join(rootDirectory, 'dist'));
|
||||||
|
|
||||||
|
const cssFiles = [
|
||||||
|
'css/variables.css',
|
||||||
|
'css/main.css',
|
||||||
|
'css/button.css',
|
||||||
|
'css/matrix.css',
|
||||||
|
'css/sections/error.css',
|
||||||
|
'css/sections/hero.css',
|
||||||
|
'css/sections/homelab.css',
|
||||||
|
'css/sections/profile.css',
|
||||||
|
'css/sections/projects.css'
|
||||||
|
];
|
||||||
|
|
||||||
|
const jsFiles = [
|
||||||
|
'js/matrix.js',
|
||||||
|
'js/social.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
const htmlFiles = [
|
||||||
|
'index.html',
|
||||||
|
'404.html'
|
||||||
|
];
|
||||||
|
|
||||||
|
const versionedFiles = new Map();
|
||||||
|
|
||||||
|
function absoluteSourcePath(relativePath) {
|
||||||
|
return path.join(rootDirectory, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteOutputPath(relativePath) {
|
||||||
|
return path.join(outputDirectory, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionFor(buffer) {
|
||||||
|
return createHash('sha256').update(buffer).digest('hex').slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireSourceFile(relativePath) {
|
||||||
|
try {
|
||||||
|
await fs.access(absoluteSourcePath(relativePath));
|
||||||
|
} catch {
|
||||||
|
throw new Error('Required source file is missing: ' + relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeOutput(relativePath, content) {
|
||||||
|
const targetPath = absoluteOutputPath(relativePath);
|
||||||
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fs.writeFile(targetPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function minifyCss(relativePath) {
|
||||||
|
let source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8');
|
||||||
|
const transformed = await transform(source, {
|
||||||
|
loader: 'css',
|
||||||
|
legalComments: 'none',
|
||||||
|
minify: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeOutput(relativePath, transformed.code);
|
||||||
|
versionedFiles.set(relativePath, versionFor(Buffer.from(transformed.code)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function minifyJavaScript(relativePath) {
|
||||||
|
const source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8');
|
||||||
|
const transformed = await transform(source, {
|
||||||
|
loader: 'js',
|
||||||
|
legalComments: 'none',
|
||||||
|
minify: true,
|
||||||
|
target: 'es2020'
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeOutput(relativePath, transformed.code);
|
||||||
|
versionedFiles.set(relativePath, versionFor(Buffer.from(transformed.code)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyStaticFile(relativePath) {
|
||||||
|
const content = await fs.readFile(absoluteSourcePath(relativePath));
|
||||||
|
await writeOutput(relativePath, content);
|
||||||
|
versionedFiles.set(relativePath, versionFor(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalReference(reference) {
|
||||||
|
return /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|data:)/i.test(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionedReference(reference) {
|
||||||
|
if (isExternalReference(reference)) {
|
||||||
|
return reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAbsolute = reference.startsWith('/');
|
||||||
|
const relativePath = (isAbsolute ? reference.slice(1) : reference).split(/[?#]/)[0];
|
||||||
|
const version = versionedFiles.get(relativePath);
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
return reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (isAbsolute ? '/' : '') + relativePath + '?v=' + version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceProductionReferences(html, avatarVersion) {
|
||||||
|
return html.replace(/\b(?:href|src)=(["'])([^"']+)\1/g, (match, quote, reference) => {
|
||||||
|
if (reference === '/assets/lain.png') {
|
||||||
|
return match.replace(reference, '/assets/lain.webp?v=' + avatarVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacement = versionedReference(reference);
|
||||||
|
return replacement === reference ? match : match.replace(reference, replacement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function minifyHtml(relativePath, avatarVersion) {
|
||||||
|
const source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8');
|
||||||
|
const withProductionAssets = replaceProductionReferences(source, avatarVersion);
|
||||||
|
const compactHtml = await minify(withProductionAssets, {
|
||||||
|
collapseWhitespace: true,
|
||||||
|
minifyCSS: false,
|
||||||
|
minifyJS: false,
|
||||||
|
removeComments: true,
|
||||||
|
removeRedundantAttributes: true,
|
||||||
|
sortAttributes: false,
|
||||||
|
sortClassName: false,
|
||||||
|
useShortDoctype: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeOutput(relativePath, compactHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertHtmlReferencesResolve(relativePath) {
|
||||||
|
const html = await fs.readFile(absoluteOutputPath(relativePath), 'utf8');
|
||||||
|
const references = html.matchAll(/\b(?:href|src)=(["'])([^"']+)\1/g);
|
||||||
|
|
||||||
|
for (const referenceMatch of references) {
|
||||||
|
const reference = referenceMatch[2];
|
||||||
|
if (isExternalReference(reference)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = (reference.startsWith('/') ? reference.slice(1) : reference).split(/[?#]/)[0];
|
||||||
|
const targetPath = path.resolve(outputDirectory, relativePath);
|
||||||
|
const relativeTarget = path.relative(outputDirectory, targetPath);
|
||||||
|
|
||||||
|
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
|
||||||
|
throw new Error('Production reference escapes the output directory: ' + reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(targetPath);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Production reference does not resolve: ' + reference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gzipFile(relativePath) {
|
||||||
|
const source = await fs.readFile(absoluteOutputPath(relativePath));
|
||||||
|
await fs.writeFile(absoluteOutputPath(relativePath) + '.gz', gzipSync(source, {
|
||||||
|
level: 9,
|
||||||
|
mtime: 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function byteSize(relativePath) {
|
||||||
|
return (await fs.stat(absoluteOutputPath(relativePath))).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
const requiredFiles = [
|
||||||
|
...cssFiles,
|
||||||
|
...jsFiles,
|
||||||
|
...htmlFiles,
|
||||||
|
'assets/favicon.ico',
|
||||||
|
'assets/lain.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(requiredFiles.map(requireSourceFile));
|
||||||
|
await fs.rm(outputDirectory, { force: true, recursive: true });
|
||||||
|
await fs.mkdir(outputDirectory, { recursive: true });
|
||||||
|
|
||||||
|
for (const cssFile of cssFiles) {
|
||||||
|
await minifyCss(cssFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const jsFile of jsFiles) {
|
||||||
|
await minifyJavaScript(jsFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
await copyStaticFile('assets/favicon.ico');
|
||||||
|
|
||||||
|
const optimizedAvatar = 'assets/lain.webp';
|
||||||
|
await fs.mkdir(path.dirname(absoluteOutputPath(optimizedAvatar)), { recursive: true });
|
||||||
|
await sharp(absoluteSourcePath('assets/lain.png'))
|
||||||
|
.resize(320, 320, { fit: 'cover', position: 'centre' })
|
||||||
|
.webp({ effort: 4, quality: 82 })
|
||||||
|
.toFile(absoluteOutputPath(optimizedAvatar));
|
||||||
|
|
||||||
|
const avatarVersion = versionFor(await fs.readFile(absoluteOutputPath(optimizedAvatar)));
|
||||||
|
|
||||||
|
for (const htmlFile of htmlFiles) {
|
||||||
|
await minifyHtml(htmlFile, avatarVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(htmlFiles.map(assertHtmlReferencesResolve));
|
||||||
|
|
||||||
|
const compressedFiles = [
|
||||||
|
...cssFiles,
|
||||||
|
...jsFiles,
|
||||||
|
...htmlFiles
|
||||||
|
];
|
||||||
|
await Promise.all(compressedFiles.map(gzipFile));
|
||||||
|
|
||||||
|
const avatarBytes = await byteSize(optimizedAvatar);
|
||||||
|
if (avatarBytes > 100 * 1024) {
|
||||||
|
throw new Error('Optimized avatar exceeds the 100 KiB budget: ' + avatarBytes + ' bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPartyFiles = [
|
||||||
|
'index.html.gz',
|
||||||
|
...cssFiles.map((file) => file + '.gz'),
|
||||||
|
...jsFiles.map((file) => file + '.gz'),
|
||||||
|
optimizedAvatar,
|
||||||
|
'assets/favicon.ico'
|
||||||
|
];
|
||||||
|
const firstPartyBytes = (await Promise.all(firstPartyFiles.map(byteSize)))
|
||||||
|
.reduce((total, size) => total + size, 0);
|
||||||
|
|
||||||
|
if (firstPartyBytes > 200 * 1024) {
|
||||||
|
throw new Error('First-party transfer exceeds the 200 KiB budget: ' + firstPartyBytes + ' bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
avatarBytes,
|
||||||
|
firstPartyBytes,
|
||||||
|
outputDirectory
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await build();
|
||||||
Reference in New Issue
Block a user