Add Logbuch: project update blog with admin, media and API

Public archive with sidebar navigation, project pages, month archive,
search and entry pages with image blocks. Admin area for brands,
projects, post types, users, api clients, media and the entry editor
with drag and drop images, preview per audience, scheduling and
publish checks. Read and write API with bearer tokens, audience
scoping, idempotent creation, OpenAPI document and editorial guide.
Magic link login with configurable allowed domains, whole app behind
the session gate. 456 tests including design rule checks.
This commit is contained in:
Matthias Giesselmann
2026-07-31 21:33:42 +02:00
commit b90ff252d1
291 changed files with 43671 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const root = process.cwd()
function walk(dir: string, found: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const path = join(dir, name)
if (statSync(path).isDirectory()) {
walk(path, found)
continue
}
if (name.endsWith('.tsx') || name.endsWith('.ts')) {
found.push(path)
}
}
return found
}
const sources = walk(join(root, 'src'))
function read(path: string): string {
return readFileSync(path, 'utf8')
}
function offenders(test: (text: string, path: string) => boolean): string[] {
return sources.filter(path => test(read(path), path)).map(path => path.replace(`${root}/`, ''))
}
const css = read(join(root, 'src/app/globals.css'))
describe('Farbmodus', () => {
it('setzt data-theme nicht fest ins Server-HTML, sonst überschreibt React die gespeicherte Wahl', () => {
expect(offenders(text => /<html[^>]*\sdata-theme=/u.test(text))).toEqual([])
})
it('definiert jede Farbe für beide Modi', () => {
const block = (start: string): string => {
const from = css.indexOf(start)
return from === -1 ? '' : css.slice(from, css.indexOf('\n}', from))
}
const names = (text: string): string[] =>
[...text.matchAll(/--color-([a-z0-9-]+):/gu)]
.map(match => match[1])
.filter((name): name is string => name !== undefined)
const light = names(block('@theme {'))
const dark = new Set(names(block(':root[data-theme="dark"] {')))
const missing = light.filter(name => !dark.has(name) && !name.startsWith('shot'))
expect(missing).toEqual([])
})
it('mischt Schatten nicht aus der Tintenfarbe, weil die im Dunkeln hell ist', () => {
expect(offenders(text => /shadow-ink|shadow-\[.*var\(--color-ink/u.test(text))).toEqual([])
})
it('führt Schatten über ein Token, das den Modus kennt', () => {
expect(css).toContain('--shadow-lift')
expect(css.match(/--shadow-lift/gu)?.length ?? 0).toBeGreaterThanOrEqual(2)
})
})
describe('Aufbau', () => {
it('hält den Fuß unten, auch wenn die Seite kurz ist', () => {
expect(read(join(root, 'src/components/layout/SiteFooter.tsx'))).toContain('mt-auto')
expect(read(join(root, 'src/components/layout/AppShell.tsx'))).toContain('min-h-full')
})
it('benutzt für alle Bereiche dieselbe Hülle', () => {
const site = read(join(root, 'src/app/(site)/layout.tsx'))
const admin = read(join(root, 'src/app/admin/layout.tsx'))
expect(site).toContain('AppShell')
expect(admin).toContain('AppShell')
})
it('kennt genau zwei Breiten und keine erfundenen', () => {
expect(css).toContain('--container-page')
expect(css).toContain('--container-read')
expect(offenders(text => /max-w-\[\d/u.test(text))).toEqual([])
})
})
describe('Schreibweisen', () => {
it('benutzt keine Pixelangaben in Klassen', () => {
expect(offenders(text => /(?:w|h|p|m|gap|text|top|left|right|bottom)-\[\d+px\]/u.test(text))).toEqual([])
})
it('lässt keine Kommentare im Quelltext', () => {
expect(offenders((text, path) => !path.endsWith('.d.ts') && /^\s*\/\//mu.test(text))).toEqual([])
})
it('benutzt keine Gedankenstriche', () => {
const files = [...sources, join(root, 'messages/de.json'), join(root, 'messages/en.json')]
const hits = files.filter(path => /[]/u.test(read(path))).map(path => path.replace(`${root}/`, ''))
expect(hits).toEqual([])
})
it('schreibt Umlaute aus', () => {
const de = read(join(root, 'messages/de.json'))
expect(de).not.toMatch(/\b(ueber|fuer|koennen|muessen|groesse|waehlen)\b/u)
})
it('benutzt keine Emojis', () => {
const files = [...sources, join(root, 'messages/de.json'), join(root, 'messages/en.json')]
const hits = files.filter(path => /\p{Extended_Pictographic}/u.test(read(path))).map(path => path.replace(`${root}/`, ''))
expect(hits).toEqual([])
})
})
describe('Bedienbarkeit', () => {
it('führt jeden scrollbaren Bereich über den Scroller statt nativ', () => {
expect(offenders(text => /overflow-(?:y|x)-(?:auto|scroll)/u.test(text))).toEqual([])
})
it('hält das globale Stylesheet klein', () => {
expect(css.split('\n').length).toBeLessThanOrEqual(150)
})
it('macht den Tastaturfokus sichtbar', () => {
expect(css).toMatch(/:focus-visible/u)
})
it('nimmt Rücksicht auf abgeschaltete Bewegung', () => {
const moving = offenders(text => /animate-rise|transition-/u.test(text))
const careless = moving.filter(path => !/motion-reduce/u.test(read(join(root, path))))
expect(careless).toEqual([])
})
})
describe('Sprache der Oberfläche', () => {
it('hält beide Sprachdateien auf denselben Schlüsseln', () => {
const keys = (value: unknown, prefix = ''): string[] => {
if (typeof value !== 'object' || value === null) {
return [prefix]
}
return Object.entries(value as Record<string, unknown>).flatMap(([key, inner]) =>
keys(inner, prefix === '' ? key : `${prefix}.${key}`))
}
const de = keys(JSON.parse(read(join(root, 'messages/de.json')))).sort()
const en = keys(JSON.parse(read(join(root, 'messages/en.json')))).sort()
expect(de.filter(key => !en.includes(key))).toEqual([])
expect(en.filter(key => !de.includes(key))).toEqual([])
})
})