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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user