feat: rebuild mintel.me as a single-page freelance site
The site now presents one freelance developer on one page: hero, introduction, four typical cases (developer for a product, make an app reliable, automate a process, a website) with what you get, how it runs and what it costs, a process automation section, technology and project fit, how I work, two live references, questions and contact. - Hero and closing headline are drawn from running binary digits. - Each case explains its four steps with an animated drawing; the automation section shows example workflows with work travelling between systems; a pipeline run shows what a test catches before a release. - Contact is one email address, kept in parts and assembled in the browser so it never appears in the page source (src/domain/email.ts, spec first). - Imprint and privacy pages added. - The old site (blog, case studies, contact form, fixed-price pages, video rendering and related scripts) is removed. Its URLs redirect to the homepage. - The deploy smoke test checks the legal pages and the redirects instead of the removed case study. - Dev scripts use docker compose v2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
-101
@@ -1,101 +0,0 @@
|
||||
import type { CacheAdapter, CacheConfig } from "./interfaces";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
|
||||
|
||||
export class FileCacheAdapter implements CacheAdapter {
|
||||
private cacheDir: string;
|
||||
private prefix: string;
|
||||
private defaultTTL: number;
|
||||
|
||||
constructor(config?: CacheConfig & { cacheDir?: string }) {
|
||||
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), ".cache");
|
||||
this.prefix = config?.prefix || "";
|
||||
this.defaultTTL = config?.defaultTTL || 3600;
|
||||
|
||||
if (!existsSync(this.cacheDir)) {
|
||||
fs.mkdir(this.cacheDir, { recursive: true }).catch((err) => {
|
||||
console.error(
|
||||
`Failed to create cache directory: ${this.cacheDir}`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(key: string): string {
|
||||
const clean = key.replace(/[^a-z0-9]/gi, "_");
|
||||
if (clean.length > 64) {
|
||||
return crypto.createHash("md5").update(key).digest("hex");
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private getFilePath(key: string): string {
|
||||
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
|
||||
return path.join(this.cacheDir, `${safeKey}.json`);
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const filePath = this.getFilePath(key);
|
||||
try {
|
||||
if (!existsSync(filePath)) return null;
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (data.expiry && Date.now() > data.expiry) {
|
||||
await this.del(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.value;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const filePath = this.getFilePath(key);
|
||||
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
|
||||
const data = {
|
||||
value,
|
||||
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||
} catch (error) {
|
||||
console.error(`Failed to write cache file: ${filePath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
const filePath = this.getFilePath(key);
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
if (existsSync(this.cacheDir)) {
|
||||
const files = await fs.readdir(this.cacheDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
await fs.unlink(path.join(this.cacheDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-101
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Cache Service - Main entry point with DI
|
||||
* Simple constructor-based dependency injection
|
||||
*/
|
||||
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
import { MemoryCacheAdapter } from './memory-adapter';
|
||||
import { RedisCacheAdapter } from './redis-adapter';
|
||||
|
||||
export class CacheService {
|
||||
private adapter: CacheAdapter;
|
||||
|
||||
/**
|
||||
* Create cache service with dependency injection
|
||||
* @param adapter - Cache adapter implementation
|
||||
*/
|
||||
constructor(adapter: CacheAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return this.adapter.get<T>(key);
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
return this.adapter.set(key, value, ttl);
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
return this.adapter.del(key);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
return this.adapter.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache wrapper for functions
|
||||
* Returns cached result or executes function and caches result
|
||||
*/
|
||||
async wrap<T>(key: string, fn: () => Promise<T>, ttl?: number): Promise<T> {
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const result = await fn();
|
||||
await this.set(key, result, ttl);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Factory functions for easy instantiation
|
||||
export function createMemoryCache(config?: CacheConfig): CacheService {
|
||||
return new CacheService(new MemoryCacheAdapter(config));
|
||||
}
|
||||
|
||||
export function createRedisCache(config?: CacheConfig & { redisUrl?: string }): CacheService {
|
||||
return new CacheService(new RedisCacheAdapter(config));
|
||||
}
|
||||
|
||||
// Default singleton instance - uses Redis if available, otherwise Memory
|
||||
let defaultCache: CacheService | null = null;
|
||||
|
||||
export async function getDefaultCache(): Promise<CacheService> {
|
||||
if (!defaultCache) {
|
||||
// Try Redis first
|
||||
const redisAdapter = new RedisCacheAdapter({
|
||||
prefix: 'mintel:blog:',
|
||||
defaultTTL: 3600
|
||||
});
|
||||
|
||||
const redisAvailable = await redisAdapter.get('__test__')
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (redisAvailable) {
|
||||
console.log('✅ Using Redis cache');
|
||||
defaultCache = new CacheService(redisAdapter);
|
||||
} else {
|
||||
console.log('⚠️ Redis unavailable, using Memory cache');
|
||||
defaultCache = createMemoryCache({
|
||||
prefix: 'mintel:blog:',
|
||||
defaultTTL: 3600
|
||||
});
|
||||
}
|
||||
}
|
||||
return defaultCache;
|
||||
}
|
||||
|
||||
// Convenience function using default cache
|
||||
export async function withCache<T>(
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
const cache = await getDefaultCache();
|
||||
return cache.wrap(key, fn, ttl);
|
||||
}
|
||||
|
||||
// Re-export interfaces and adapters for advanced usage
|
||||
export type { CacheAdapter, CacheConfig };
|
||||
export { MemoryCacheAdapter, RedisCacheAdapter };
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Cache interfaces - decoupled contracts
|
||||
*/
|
||||
|
||||
export interface CacheAdapter {
|
||||
get<T>(_key: string): Promise<T | null>;
|
||||
set<T>(_key: string, _value: T, _ttl?: number): Promise<void>;
|
||||
del(_key: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CacheConfig {
|
||||
prefix?: string;
|
||||
defaultTTL?: number;
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Memory Cache Adapter
|
||||
* Simple in-memory implementation
|
||||
*/
|
||||
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
|
||||
export class MemoryCacheAdapter implements CacheAdapter {
|
||||
private cache = new Map<string, { value: any; expiry: number }>();
|
||||
private defaultTTL: number;
|
||||
|
||||
constructor(config: CacheConfig = {}) {
|
||||
this.defaultTTL = config.defaultTTL || 3600;
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return null;
|
||||
|
||||
if (Date.now() > item.expiry) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const finalTTL = ttl || this.defaultTTL;
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
expiry: Date.now() + (finalTTL * 1000)
|
||||
});
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Redis Cache Adapter
|
||||
* Decoupled Redis implementation
|
||||
*/
|
||||
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
|
||||
export class RedisCacheAdapter implements CacheAdapter {
|
||||
private client: any = null;
|
||||
private prefix: string;
|
||||
private defaultTTL: number;
|
||||
private redisUrl: string;
|
||||
|
||||
constructor(config: CacheConfig & { redisUrl?: string } = {}) {
|
||||
this.prefix = config.prefix || 'mintel:';
|
||||
this.defaultTTL = config.defaultTTL || 3600;
|
||||
this.redisUrl = config.redisUrl || process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
}
|
||||
|
||||
private async init(): Promise<boolean> {
|
||||
if (this.client !== null) return true;
|
||||
|
||||
try {
|
||||
const Redis = await import('ioredis');
|
||||
this.client = new Redis.default(this.redisUrl);
|
||||
|
||||
this.client.on('error', (err: Error) => {
|
||||
console.warn('Redis connection error:', err.message);
|
||||
this.client = null;
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
console.log('✅ Redis connected');
|
||||
});
|
||||
|
||||
// Test connection
|
||||
await this.client.set(this.prefix + '__test__', 'ok', 'EX', 1);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Redis unavailable:', error);
|
||||
this.client = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return null;
|
||||
|
||||
try {
|
||||
const data = await this.client.get(this.prefix + key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
console.warn('Redis get error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return;
|
||||
|
||||
try {
|
||||
const finalTTL = ttl || this.defaultTTL;
|
||||
await this.client.setex(this.prefix + key, finalTTL, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn('Redis set error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return;
|
||||
|
||||
try {
|
||||
await this.client.del(this.prefix + key);
|
||||
} catch (error) {
|
||||
console.warn('Redis del error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const available = await this.init();
|
||||
if (!available || !this.client) return;
|
||||
|
||||
try {
|
||||
const keys = await this.client.keys(this.prefix + '*');
|
||||
if (keys.length > 0) {
|
||||
await this.client.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Redis clear error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user