Personal Website, vers. 1 (pushed far to late)

This commit is contained in:
Harlekin committed 2026-10-25 16:02:12 +01:00
commit c8df66d3da
103 files changed
+8977

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import { rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { after, before, test } from 'node:test';
import { createApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import { createContentStore } from '../src/content.js';
import { createFixture } from './fixtures.js';
let dir;
let server;
let base;
const sent = [];
before(async () => {
dir = await createFixture();
const config = loadConfig({
production: true,
showDrafts: false,
contentDir: dir,
cacheDir: path.join(os.tmpdir(), `site-cache-${process.pid}`),
siteUrl: 'https://example.org',
});
const content = createContentStore(config);
const mailer = { enabled: true, send: async (message) => sent.push(message) };
const app = await createApp({ config, content, mailer });
server = app.listen(0);
await new Promise((resolve) => server.once('listening', resolve));
base = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
server.close();
await rm(dir, { recursive: true, force: true });
await rm(path.join(os.tmpdir(), `site-cache-${process.pid}`), { recursive: true, force: true });
});
const get = (url, options) => fetch(base + url, { redirect: 'manual', ...options });
test('pages render in both languages', async () => {
for (const url of [
'/de/',
'/en/',
'/de/portfolio',
'/en/portfolio',
'/de/portfolio/alpha',
'/en/portfolio/alpha',
'/de/werdegang',
'/en/journey',
'/de/kontakt',
'/en/contact',
'/de/datenschutz',
'/en/privacy',
]) {
const res = await get(url);
assert.equal(res.status, 200, url);
assert.match(res.headers.get('content-security-policy'), /script-src 'self'/);
}
});
test('the root redirects by Accept-Language', async () => {
let res = await get('/', { headers: { 'Accept-Language': 'en-GB,en;q=0.9' } });
assert.equal(res.status, 302);
assert.equal(res.headers.get('location'), '/en/');
res = await get('/', { headers: { 'Accept-Language': 'fr' } });
assert.equal(res.headers.get('location'), '/de/');
});
test('drafts, hidden pages and wrong slugs are 404', async () => {
for (const url of ['/de/portfolio/beta', '/de/homelab', '/de/journey', '/en/werdegang', '/de/nope', '/xx']) {
const res = await get(url);
assert.equal(res.status, 404, url);
}
});
test('homepage hides links to pages that do not exist yet', async () => {
const html = await (await get('/de/')).text();
assert.doesNotMatch(html, /href="\/de\/homelab"/);
assert.match(html, /<h1 class="hero__title"/);
});
test('portfolio filter works without JavaScript', async () => {
const html = await (await get('/de/portfolio?tag=nothing')).text();
assert.doesNotMatch(html, /data-tags="web" hidden/);
});
test('media: resized images, no traversal, no source files', async () => {
let res = await get('/media/projects/alpha/cover.png?w=480');
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/webp');
res = await get('/media/projects/alpha/cover.png?w=480&v=abc');
assert.match(res.headers.get('cache-control'), /immutable/);
res = await get('/media/projects/alpha/cover.png?w=123');
assert.equal(res.status, 404);
for (const url of ['/media/site.yml', '/media/projects/alpha/de.md', '/media/..%2Fpackage.json', '/media/projects/%2e%2e/%2e%2e/site.yml']) {
res = await get(url);
assert.equal(res.status, 404, url);
}
});
test('sitemap and robots', async () => {
const sitemap = await (await get('/sitemap.xml')).text();
assert.match(sitemap, /<loc>https:\/\/example\.org\/en\/portfolio\/alpha<\/loc>/);
assert.doesNotMatch(sitemap, /beta|homelab/);
assert.match(await (await get('/robots.txt')).text(), /Sitemap: https:\/\/example\.org\/sitemap\.xml/);
});
const form = (fields) =>
new URLSearchParams({ name: 'Ada', email: 'ada@example.org', message: 'Hallo, ich habe eine Frage.', ts: String(Date.now() - 10_000), ...fields });
const post = (body, headers = {}) =>
get('/de/kontakt', { method: 'POST', body, headers: { Accept: 'application/json', ...headers } });
test('contact form', async (t) => {
await t.test('sends valid messages', async () => {
const res = await post(form());
assert.equal(res.status, 200);
assert.equal((await res.json()).ok, true);
assert.equal(sent.length, 1);
assert.equal(sent[0].replyTo.address, 'ada@example.org');
});
await t.test('rejects invalid input with field errors', async () => {
const res = await post(form({ email: 'nope', message: 'kurz' }));
assert.equal(res.status, 422);
const data = await res.json();
assert.ok(data.errors.email);
assert.ok(data.errors.message);
});
await t.test('quietly drops bots', async () => {
let res = await post(form({ website: 'http://spam' }));
assert.equal((await res.json()).ok, true);
res = await post(form({ ts: String(Date.now()) }));
assert.equal((await res.json()).ok, true);
assert.equal(sent.length, 1);
});
await t.test('blocks foreign origins', async () => {
const res = await post(form(), { Origin: 'https://evil.example' });
assert.equal(res.status, 403);
});
await t.test('rate limits', async () => {
// 4 requests above counted, plus this one = 5, the next one is too many
assert.equal((await post(form())).status, 200);
const res = await post(form());
assert.equal(res.status, 429);
assert.match((await res.json()).message, /Zu viele/);
});
await t.test('works as a plain form post', async () => {
const res = await get('/en/contact', { method: 'POST', body: form() });
assert.equal(res.status, 429);
assert.match(await res.text(), /Too many messages/);
});
});
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { after, before, test } from 'node:test';
import { createContentStore, loadContent, parseFrontmatter, youtubeId } from '../src/content.js';
import { formatDate, localize, pickLocale } from '../src/i18n.js';
import { createFixture } from './fixtures.js';
let dir;
before(async () => {
dir = await createFixture();
});
after(() => rm(dir, { recursive: true, force: true }));
test('parseFrontmatter splits data and body', () => {
const { data, body } = parseFrontmatter('---\ntitle: Hi\n---\nBody\n');
assert.equal(data.title, 'Hi');
assert.equal(body, 'Body\n');
assert.deepEqual(parseFrontmatter('no frontmatter').data, {});
});
test('youtubeId understands the usual URL shapes', () => {
for (const url of [
'https://youtu.be/dQw4w9WgXcQ',
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://www.youtube.com/watch?feature=share&v=dQw4w9WgXcQ',
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ',
]) {
assert.equal(youtubeId(url), 'dQw4w9WgXcQ', url);
}
assert.equal(youtubeId('https://vimeo.com/123'), null);
});
test('i18n helpers', () => {
assert.equal(localize({ de: 'Hallo', en: 'Hello' }, 'en'), 'Hello');
assert.equal(localize({ de: 'Nur Deutsch' }, 'en'), 'Nur Deutsch');
assert.equal(localize('plain', 'de'), 'plain');
assert.equal(pickLocale('en-US,en;q=0.9,de;q=0.8'), 'en');
assert.equal(pickLocale('fr-FR,de;q=0.5'), 'de');
assert.equal(pickLocale('fr'), 'de');
assert.equal(formatDate(2024, 'de'), '2024');
assert.equal(formatDate('2026-10', 'de'), 'Oktober 2026');
assert.equal(formatDate('2026-10-01', 'en'), 'October 2026');
});
test('drafts are hidden unless asked for', async () => {
const { content } = await loadContent(dir);
assert.deepEqual(content.projects.map((p) => p.slug), ['alpha']);
assert.equal(content.homelab, null);
assert.deepEqual(content.journey.map((e) => e.title), ['Live']);
const { content: withDrafts } = await loadContent(dir, { showDrafts: true });
assert.deepEqual(withDrafts.projects.map((p) => p.slug), ['alpha', 'beta']);
assert.ok(withDrafts.homelab);
});
test('projects: fallback language, images, video', async () => {
const { content, warnings } = await loadContent(dir, { showDrafts: true });
const alpha = content.projects.find((p) => p.slug === 'alpha');
assert.equal(alpha.text.de.title, 'Alpha');
assert.equal(alpha.text.en.title, 'Alpha');
assert.equal(alpha.text.en.fallback, 'de');
assert.equal(alpha.text.de.fallback, undefined);
assert.equal(alpha.cover.width, 1200);
assert.match(alpha.cover.srcset, /w=480&v=\w+ 480w, .*w=960.* 960w, .*w=1600.* 1200w$/);
assert.match(alpha.text.de.html, /<img src="\/media\/projects\/alpha\/cover\.png\?w=1600[^"]*" srcset=/);
assert.deepEqual(alpha.video, {
type: 'youtube',
id: 'dQw4w9WgXcQ',
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
poster: null,
});
const beta = content.projects.find((p) => p.slug === 'beta');
assert.equal(beta.text.de.fallback, 'en');
assert.ok(warnings.some((w) => w.startsWith('projects/broken/meta.yml:')), 'broken project is reported');
assert.ok(!content.projects.some((p) => p.slug === 'broken'));
});
test('a broken site.yml keeps the previous content', async () => {
const store = createContentStore({ contentDir: dir, showDrafts: false });
await store.reload();
const before = store.get();
const file = path.join(dir, 'site.yml');
await writeFile(file, 'name: [unclosed\n');
await assert.rejects(store.reload());
assert.equal(store.get(), before);
await writeFile(file, 'name: Test\n');
});
+35
View File
@@ -0,0 +1,35 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import sharp from 'sharp';
// Builds a small content directory in /tmp so the tests don't depend on the real content.
export async function createFixture() {
const dir = await mkdtemp(path.join(os.tmpdir(), 'site-content-'));
const write = async (file, text) => {
await mkdir(path.dirname(path.join(dir, file)), { recursive: true });
await writeFile(path.join(dir, file), text);
};
await write('site.yml', 'name: Test\npronouns:\n de: alle Pronomen\n en: any pronouns\nemail: test@example.org\n');
await write('home.yml', 'lead:\n de: Hallo\n en: Hello\nareas:\n - visual: homelab\n title: Homelab\n link: homelab\n');
await write('journey.yml', 'entries:\n - title: Live\n from: 2024\n - title: Hidden\n from: 2023\n draft: true\n');
await write('homelab.yml', 'draft: true\nservices:\n - group: Web\n items:\n - name: nginx\n');
await write('pages/journey.de.md', '---\ntitle: Werdegang\n---\nÜber mich');
await write('pages/privacy.de.md', '---\ntitle: Datenschutz\n---\nText');
await write('pages/privacy.en.md', '---\ntitle: Privacy\n---\nText');
await write('projects/alpha/meta.yml', 'date: 2026-05\ntags: [web]\nfeatured: true\ncover: cover.png\nvideo: https://youtu.be/dQw4w9WgXcQ\n');
await write('projects/alpha/de.md', '---\ntitle: Alpha\nsummary: Erstes Projekt\n---\nText mit Bild\n\n![Ein Bild](cover.png)\n');
await sharp({ create: { width: 1200, height: 750, channels: 3, background: '#c011d7' } })
.png()
.toFile(path.join(dir, 'projects/alpha/cover.png'));
await write('projects/beta/meta.yml', 'date: 2026-04\ntags: [video]\ndraft: true\n');
await write('projects/beta/en.md', '---\ntitle: Beta\n---\nDraft');
await write('projects/broken/meta.yml', 'tags: [oops\n');
await write('projects/broken/de.md', '---\ntitle: Kaputt\n---\n');
return dir;
}