feat(quick-260916-dcz): CHANGELOG.md, Seite "Was ist neu" mit Kanalfilter, Versionszeile als Link, Bauzeit-Einbettung
- CHANGELOG.md (Wurzel): Unveroeffentlicht (bwo + dyv + diese Seite, 8 Punkte) und 1.0.0 – 2026-09-15 (11 Punkte aus den Handbuechern), Alltagssprache, echte Umlaute - next.config.ts liest CHANGELOG.md zur Bauzeit nach env.TESSERA_CHANGELOG_MD; .dockerignore-Ausnahme !CHANGELOG.md und COPY-Zeile im Web-Dockerfile - lib/changelog.ts: filterChangelogForChannel (live ohne Unveroeffentlicht, beta/dev mit "Noch nicht freigegeben (Beta)", leerer Abschnitt ausgeblendet), 10 Tests - Server-Seite /changelog (MDEditor.Markdown + rehype-sanitize, Hinweis auf Beta, Leer-Text), 3 Tests; Versionszeile ist Link mit aria-label, 2 Tests - de/en: sidebar.whatsNew und Namensraum changelog; Web-Suite 49/309, tsc 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
@@ -22,6 +22,9 @@ COPY --from=deps /app/ ./
|
||||
COPY apps/web/ ./apps/web/
|
||||
COPY packages/shared/ ./packages/shared/
|
||||
COPY tsconfig.base.json ./
|
||||
# quick-260916-dcz: next.config.ts liest CHANGELOG.md zur Bauzeit (Seite "Was ist neu");
|
||||
# .dockerignore schliesst *.md aus und macht fuer genau diese Datei eine Ausnahme.
|
||||
COPY CHANGELOG.md ./
|
||||
ENV NEXT_PUBLIC_API_URL=/api-proxy
|
||||
# Muss VOR dem Build stehen: Next.js bettet NEXT_PUBLIC_* zur Bauzeit ins Bundle ein.
|
||||
# So spaet wie moeglich, damit die COPY-Schichten darueber im Cache bleiben.
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { NextConfig } from 'next';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
|
||||
// Bauzeit-Einbettung der Aenderungsliste (quick-260916-dcz): CHANGELOG.md aus
|
||||
// dem Wurzelverzeichnis wird hier gelesen und als `env.TESSERA_CHANGELOG_MD`
|
||||
// abgelegt. Next.js ersetzt `process.env.TESSERA_CHANGELOG_MD` fuer webpack UND
|
||||
// Turbopack ueber denselben Define-Mechanismus. Der Text bleibt nur im
|
||||
// Server-Bundle, weil ausschliesslich die Server-Seite (changelog/page.tsx)
|
||||
// `@/lib/changelog` importiert — Importdisziplin, kein Client-Chunk.
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
|
||||
function readChangelog(): string {
|
||||
const file = path.resolve(__dirname, '../../CHANGELOG.md');
|
||||
try {
|
||||
return readFileSync(file, 'utf8');
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`CHANGELOG.md konnte nicht gelesen werden (${file}): ${reason}. ` +
|
||||
'Die Datei muss im Wurzelverzeichnis liegen; im Docker-Bau braucht sie die Ausnahme ' +
|
||||
'`!CHANGELOG.md` in .dockerignore und die Zeile `COPY CHANGELOG.md ./` in apps/web/Dockerfile.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone' as const,
|
||||
env: {
|
||||
TESSERA_CHANGELOG_MD: readChangelog(),
|
||||
},
|
||||
async rewrites() {
|
||||
const apiUrl = process.env.API_INTERNAL_URL || 'http://api:3001';
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* changelog-page.test — Seite "Was ist neu" unter /changelog (quick-260916-dcz).
|
||||
*
|
||||
* Die Seite ist eine async Server-Komponente (Vorbild module-access-gate.test:
|
||||
* Funktion awaiten, Ergebnis rendern). `next-intl/server` bekommt eine
|
||||
* Handtabelle, `@/lib/app-version` einen veraenderbaren Kanal, `@/lib/changelog`
|
||||
* wird je Test per `vi.doMock` mit `importOriginal` (echte Filterfunktion,
|
||||
* eigener `changelogMarkdown`) bereitgestellt, `@uiw/react-md-editor` als
|
||||
* Modul mit `default.Markdown`, das die Quelle roh in ein <pre> schreibt.
|
||||
*/
|
||||
|
||||
vi.mock('next-intl/server', () => ({
|
||||
getTranslations: async () => (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
title: 'Was ist neu',
|
||||
intro: 'Alle Änderungen an Tessera, sortiert nach Version – die aktuelle Version steht oben.',
|
||||
unreleasedHeading: 'Noch nicht freigegeben (Beta)',
|
||||
unreleasedHint:
|
||||
'Die Punkte unter „Noch nicht freigegeben“ sind in dieser Beta bereits enthalten, aber noch nicht als Version freigegeben.',
|
||||
empty: 'Noch keine Einträge vorhanden.',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
let mockChannel: 'beta' | 'live' | 'dev' = 'live';
|
||||
vi.mock('@/lib/app-version', () => ({
|
||||
get appVersion() {
|
||||
return { version: 'v1.2.3', channel: mockChannel, commit: 'abc1234' };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@uiw/react-md-editor', () => ({
|
||||
default: {
|
||||
Markdown: ({ source }: { source?: string }) => <pre data-testid="md">{source}</pre>,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ resolvedTheme: 'light' }),
|
||||
}));
|
||||
|
||||
const SAMPLE = [
|
||||
'# Änderungen an Tessera',
|
||||
'',
|
||||
'Vorspann.',
|
||||
'',
|
||||
'## Unveröffentlicht',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'- Punkt U1',
|
||||
'',
|
||||
'## 1.0.0 – 2026-09-15',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'- Punkt A',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function mockChangelog(markdown: string) {
|
||||
vi.doMock('@/lib/changelog', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/lib/changelog')>();
|
||||
return { ...original, changelogMarkdown: markdown };
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const { default: ChangelogPage } = await import('./page');
|
||||
const element = await ChangelogPage();
|
||||
render(element);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetModules();
|
||||
vi.doUnmock('@/lib/changelog');
|
||||
mockChannel = 'live';
|
||||
});
|
||||
|
||||
describe('ChangelogPage /changelog (quick-260916-dcz)', () => {
|
||||
it('Test 1 (live): Titel sichtbar, kein Hinweis, Markdown ohne Unveröffentlicht', async () => {
|
||||
mockChannel = 'live';
|
||||
mockChangelog(SAMPLE);
|
||||
await renderPage();
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Was ist neu');
|
||||
expect(screen.queryByTestId('changelog-unreleased-hint')).not.toBeInTheDocument();
|
||||
const md = screen.getByTestId('md').textContent ?? '';
|
||||
expect(md).toContain('1.0.0');
|
||||
expect(md).not.toContain('Unveröffentlicht');
|
||||
expect(md).not.toContain('Noch nicht freigegeben');
|
||||
});
|
||||
|
||||
it('Test 2 (beta): Hinweis vorhanden, Markdown traegt "Noch nicht freigegeben (Beta)"', async () => {
|
||||
mockChannel = 'beta';
|
||||
mockChangelog(SAMPLE);
|
||||
await renderPage();
|
||||
|
||||
expect(screen.getByTestId('changelog-unreleased-hint')).toHaveTextContent(
|
||||
'Die Punkte unter „Noch nicht freigegeben“ sind in dieser Beta bereits enthalten, aber noch nicht als Version freigegeben.',
|
||||
);
|
||||
const md = screen.getByTestId('md').textContent ?? '';
|
||||
expect(md).toContain('Noch nicht freigegeben (Beta)');
|
||||
expect(md).toContain('- Punkt U1');
|
||||
});
|
||||
|
||||
it('Test 3 (leer): ohne Text erscheint der Leer-Hinweis und kein Markdown-Element', async () => {
|
||||
mockChannel = 'beta';
|
||||
mockChangelog('');
|
||||
await renderPage();
|
||||
|
||||
expect(screen.getByTestId('changelog-empty')).toHaveTextContent('Noch keine Einträge vorhanden.');
|
||||
expect(screen.queryByTestId('md')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { ChangelogView } from '@/components/changelog/changelog-view';
|
||||
import { appVersion } from '@/lib/app-version';
|
||||
import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog';
|
||||
|
||||
/**
|
||||
* Seite "Was ist neu" unter /changelog (quick-260916-dcz).
|
||||
*
|
||||
* Async Server-Komponente ohne 'use client' (Vorbild module-access-gate.tsx):
|
||||
* der Text stammt aus der Bauzeit-Variable `TESSERA_CHANGELOG_MD`
|
||||
* (next.config.ts liest CHANGELOG.md) und bleibt so im Server-Bundle. Die
|
||||
* Kanalregel liegt in `filterChangelogForChannel`: auf Live fehlt der
|
||||
* Abschnitt "Unveröffentlicht", auf Beta/Entwicklung erscheint er als "Noch
|
||||
* nicht freigegeben (Beta)" samt Hinweis. Zugriff nur angemeldet — die
|
||||
* bestehende middleware.ts schuetzt jede Route ausser /login und
|
||||
* /reset-password; /changelog ist keine oeffentliche Route.
|
||||
*/
|
||||
export default async function ChangelogPage() {
|
||||
const t = await getTranslations('changelog');
|
||||
const { markdown, hasUnreleased } = filterChangelogForChannel(changelogMarkdown, appVersion.channel, {
|
||||
unreleasedHeading: t('unreleasedHeading'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl py-8 px-4">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">{t('title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">{t('intro')}</p>
|
||||
|
||||
{hasUnreleased && (
|
||||
<div
|
||||
data-testid="changelog-unreleased-hint"
|
||||
className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 px-4 py-3 text-sm text-yellow-800 dark:text-yellow-200 mb-6"
|
||||
>
|
||||
{t('unreleasedHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{markdown.trim() === '' ? (
|
||||
<p data-testid="changelog-empty" className="text-sm text-muted-foreground">
|
||||
{t('empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ChangelogView markdown={markdown} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
/**
|
||||
* Gerenderte Aenderungsliste fuer die Seite "Was ist neu" (quick-260916-dcz).
|
||||
*
|
||||
* Nutzt `MDEditor.Markdown` aus dem bereits installierten `@uiw/react-md-editor`
|
||||
* (wie die Vorschau des Notiz-Widgets) mit `rehype-sanitize` — kein neues
|
||||
* Paket, kein dangerouslySetInnerHTML. Der Farbmodus folgt `next-themes`
|
||||
* erst nach dem Mount (vorher 'light'), damit Server- und Client-Markup
|
||||
* uebereinstimmen (mounted-Guard wie in AppShell). Der Markdown-Text kommt
|
||||
* als Prop von der Server-Seite; dieses Modul importiert `@/lib/changelog`
|
||||
* bewusst NICHT, damit der Text nicht in Client-Chunks landet.
|
||||
*/
|
||||
export function ChangelogView({ markdown }: { markdown: string }) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const mode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';
|
||||
|
||||
return (
|
||||
<div data-testid="changelog-markdown" className="rounded-md border border-border bg-card p-4">
|
||||
<MDEditor.Markdown
|
||||
source={markdown}
|
||||
rehypePlugins={[[rehypeSanitize]]}
|
||||
wrapperElement={{ 'data-color-mode': mode }}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,33 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
/**
|
||||
* AppVersionBadge.test — die Versionszeile unten in der Seitenleiste
|
||||
* (quick-260914-ku1). Vorlage: sidebar.test.tsx (next-intl-Mock, cleanup,
|
||||
* dynamischer Import nach dem Setzen der Mocks).
|
||||
* dynamischer Import nach dem Setzen der Mocks). Seit quick-260916-dcz ist die
|
||||
* Zeile ein Link zur Seite "Was ist neu" — deshalb der `next/link`-Mock, der
|
||||
* href, className, title, aria-label und data-testid durchreicht (Tests 5/6).
|
||||
*/
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
title,
|
||||
'aria-label': ariaLabel,
|
||||
'data-testid': testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
title?: string;
|
||||
'aria-label'?: string;
|
||||
'data-testid'?: string;
|
||||
}) => (
|
||||
<a href={href} className={className} title={title} aria-label={ariaLabel} data-testid={testId}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string) => {
|
||||
const map: Record<string, Record<string, string>> = {
|
||||
@@ -14,6 +38,7 @@ vi.mock('next-intl', () => ({
|
||||
'channel.beta': 'Beta',
|
||||
'channel.live': 'Live',
|
||||
'channel.dev': 'Entwicklung',
|
||||
whatsNew: 'Was ist neu',
|
||||
},
|
||||
};
|
||||
return map[ns]?.[key] ?? key;
|
||||
@@ -95,4 +120,22 @@ describe('AppVersionBadge (quick-260914-ku1)', () => {
|
||||
expect(el).toHaveTextContent('dev · Entwicklung');
|
||||
expect(el.hasAttribute('title')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 5 (Link): die Versionszeile ist ein Link auf /changelog', async () => {
|
||||
mockLoad.mockResolvedValue(null);
|
||||
const AppVersionBadge = await importBadge();
|
||||
render(<AppVersionBadge />);
|
||||
|
||||
const el = screen.getByTestId('app-version');
|
||||
expect(el.tagName).toBe('A');
|
||||
expect(el).toHaveAttribute('href', '/changelog');
|
||||
});
|
||||
|
||||
it('Test 6 (aria-label): der Link traegt den Namen "Was ist neu"', async () => {
|
||||
mockLoad.mockResolvedValue(null);
|
||||
const AppVersionBadge = await importBadge();
|
||||
render(<AppVersionBadge />);
|
||||
|
||||
expect(screen.getByTestId('app-version')).toHaveAttribute('aria-label', 'Was ist neu');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type ApiVersionInfo, appVersion, loadApiVersion } from '@/lib/app-version';
|
||||
|
||||
@@ -9,6 +10,8 @@ import { type ApiVersionInfo, appVersion, loadApiVersion } from '@/lib/app-versi
|
||||
* `v1.0.0 · Beta` oder lokal `dev · Entwicklung` (quick-260914-ku1).
|
||||
* Der Tooltip nennt den Web-Commit und, sobald geladen, die API-Version
|
||||
* samt Kanal. "Commit" und "API" sind in beiden Sprachen gleich.
|
||||
* Seit quick-260916-dcz ist die Zeile ein Link zur Seite "Was ist neu"
|
||||
* (/changelog); der Tooltip bleibt, der Linkname steht im aria-label.
|
||||
*/
|
||||
export function AppVersionBadge() {
|
||||
const t = useTranslations('sidebar');
|
||||
@@ -30,8 +33,14 @@ export function AppVersionBadge() {
|
||||
const title = parts.length > 0 ? parts.join(' · ') : undefined;
|
||||
|
||||
return (
|
||||
<span data-testid="app-version" className="block truncate text-xs text-muted-foreground" title={title}>
|
||||
<Link
|
||||
href="/changelog"
|
||||
data-testid="app-version"
|
||||
aria-label={t('whatsNew')}
|
||||
title={title}
|
||||
className="block truncate text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
{appVersion.version} · {t(`channel.${appVersion.channel}`)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { filterChangelogForChannel } from './changelog';
|
||||
|
||||
/**
|
||||
* changelog.test — Kanalregel fuer die Seite "Was ist neu" (quick-260916-dcz).
|
||||
*
|
||||
* `filterChangelogForChannel` ist eine reine Funktion ueber dem Markdown-Text
|
||||
* von CHANGELOG.md: auf `live` verschwindet der Abschnitt "Unveröffentlicht",
|
||||
* auf `beta`/`dev` bleibt er mit der Ueberschrift "Noch nicht freigegeben
|
||||
* (Beta)"; H1 und Vorspann entfallen immer. Test 1 ist die Falsifizierung (a)
|
||||
* des Plans: er wird rot, sobald die Funktion den Abschnitt nicht entfernt.
|
||||
* Tests 9/10 pruefen die Bauzeit-Einbettung ueber `TESSERA_CHANGELOG_MD`
|
||||
* (Muster: app-version.test.ts — stubEnv + resetModules + dynamischer Import).
|
||||
*/
|
||||
|
||||
const SAMPLE = [
|
||||
'# Änderungen an Tessera',
|
||||
'',
|
||||
'Vorspann: die neueste Version steht oben.',
|
||||
'',
|
||||
'## Unveröffentlicht',
|
||||
'',
|
||||
'### Geändert',
|
||||
'',
|
||||
'- Punkt U1 (noch nicht freigegeben)',
|
||||
'- Punkt U2 (noch nicht freigegeben)',
|
||||
'',
|
||||
'## 1.0.0 – 2026-09-15',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'- Punkt A der Version 1.0.0',
|
||||
'- Punkt B der Version 1.0.0',
|
||||
'',
|
||||
'## 0.9.0 – 2026-09-01',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'- Punkt C der Version 0.9.0',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('filterChangelogForChannel (quick-260916-dcz)', () => {
|
||||
it('Test 1 (live): entfernt den Abschnitt Unveröffentlicht samt Punkten, behaelt die Versionen in Reihenfolge', () => {
|
||||
const result = filterChangelogForChannel(SAMPLE, 'live');
|
||||
|
||||
expect(result.hasUnreleased).toBe(false);
|
||||
expect(result.markdown).not.toContain('## Unveröffentlicht');
|
||||
expect(result.markdown).not.toContain('Punkt U1');
|
||||
expect(result.markdown).not.toContain('Punkt U2');
|
||||
expect(result.markdown).toContain('## 1.0.0 – 2026-09-15');
|
||||
expect(result.markdown).toContain('## 0.9.0 – 2026-09-01');
|
||||
expect(result.markdown).toContain('- Punkt A der Version 1.0.0');
|
||||
expect(result.markdown).toContain('- Punkt C der Version 0.9.0');
|
||||
expect(result.markdown.indexOf('## 1.0.0')).toBeLessThan(result.markdown.indexOf('## 0.9.0'));
|
||||
});
|
||||
|
||||
it('Test 2 (beta): ersetzt die Ueberschrift durch "Noch nicht freigegeben (Beta)", Punkte bleiben', () => {
|
||||
const result = filterChangelogForChannel(SAMPLE, 'beta');
|
||||
|
||||
expect(result.hasUnreleased).toBe(true);
|
||||
expect(result.markdown).toContain('## Noch nicht freigegeben (Beta)');
|
||||
expect(result.markdown).not.toContain('## Unveröffentlicht');
|
||||
expect(result.markdown).toContain('- Punkt U1');
|
||||
expect(result.markdown).toContain('- Punkt U2');
|
||||
expect(result.markdown).toContain('## 1.0.0 – 2026-09-15');
|
||||
});
|
||||
|
||||
it('Test 3 (dev): verhaelt sich wie beta', () => {
|
||||
const result = filterChangelogForChannel(SAMPLE, 'dev');
|
||||
|
||||
expect(result.hasUnreleased).toBe(true);
|
||||
expect(result.markdown).toContain('## Noch nicht freigegeben (Beta)');
|
||||
expect(result.markdown).not.toContain('## Unveröffentlicht');
|
||||
expect(result.markdown).toContain('- Punkt U1');
|
||||
});
|
||||
|
||||
it('Test 4 (eigenes Label): options.unreleasedHeading bestimmt die Ueberschrift', () => {
|
||||
const result = filterChangelogForChannel(SAMPLE, 'beta', {
|
||||
unreleasedHeading: 'Not yet released (beta)',
|
||||
});
|
||||
|
||||
expect(result.markdown).toContain('## Not yet released (beta)');
|
||||
expect(result.markdown).not.toContain('## Noch nicht freigegeben (Beta)');
|
||||
});
|
||||
|
||||
it('Test 5 (leerer Abschnitt, beta): Unveröffentlicht ohne Listenpunkt wird ausgeblendet', () => {
|
||||
const empty = [
|
||||
'# Titel',
|
||||
'',
|
||||
'## Unveröffentlicht',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'## 1.0.0 – 2026-09-15',
|
||||
'',
|
||||
'- Punkt A der Version 1.0.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = filterChangelogForChannel(empty, 'beta');
|
||||
|
||||
expect(result.hasUnreleased).toBe(false);
|
||||
expect(result.markdown).not.toContain('Unveröffentlicht');
|
||||
expect(result.markdown).not.toContain('Noch nicht freigegeben');
|
||||
expect(result.markdown).toContain('## 1.0.0 – 2026-09-15');
|
||||
expect(result.markdown).toContain('- Punkt A der Version 1.0.0');
|
||||
});
|
||||
|
||||
it('Test 6 (kein Abschnitt): ohne Unveröffentlicht bleiben die Versionsabschnitte unveraendert', () => {
|
||||
const noUnreleased = [
|
||||
'# Titel',
|
||||
'',
|
||||
'## 1.0.0 – 2026-09-15',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'- Punkt A der Version 1.0.0',
|
||||
'',
|
||||
'## 0.9.0 – 2026-09-01',
|
||||
'',
|
||||
'- Punkt C der Version 0.9.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const expected = [
|
||||
'## 1.0.0 – 2026-09-15',
|
||||
'',
|
||||
'### Neu',
|
||||
'',
|
||||
'- Punkt A der Version 1.0.0',
|
||||
'',
|
||||
'## 0.9.0 – 2026-09-01',
|
||||
'',
|
||||
'- Punkt C der Version 0.9.0',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const beta = filterChangelogForChannel(noUnreleased, 'beta');
|
||||
const live = filterChangelogForChannel(noUnreleased, 'live');
|
||||
|
||||
expect(beta.hasUnreleased).toBe(false);
|
||||
expect(live.hasUnreleased).toBe(false);
|
||||
expect(beta.markdown).toBe(expected);
|
||||
expect(live.markdown).toBe(expected);
|
||||
});
|
||||
|
||||
it('Test 7 (Hierarchie): H1 und Vorspann fehlen, Ergebnis beginnt mit "## ", "### " bleibt', () => {
|
||||
const result = filterChangelogForChannel(SAMPLE, 'beta');
|
||||
|
||||
expect(result.markdown).not.toContain('# Änderungen an Tessera');
|
||||
expect(result.markdown).not.toContain('Vorspann:');
|
||||
expect(result.markdown.trim().startsWith('## ')).toBe(true);
|
||||
expect(result.markdown).toContain('### Geändert');
|
||||
expect(result.markdown).toContain('### Neu');
|
||||
});
|
||||
|
||||
it('Test 8 (CRLF): Windows-Zeilenenden werden normalisiert, live entfernt den Abschnitt ebenfalls', () => {
|
||||
const crlf = SAMPLE.replace(/\n/g, '\r\n');
|
||||
const result = filterChangelogForChannel(crlf, 'live');
|
||||
|
||||
expect(result.hasUnreleased).toBe(false);
|
||||
expect(result.markdown).not.toContain('\r');
|
||||
expect(result.markdown).not.toContain('Unveröffentlicht');
|
||||
expect(result.markdown).toContain('## 1.0.0 – 2026-09-15');
|
||||
});
|
||||
|
||||
it('Test 9 (Einbettung): changelogMarkdown ist exakt der Wert von TESSERA_CHANGELOG_MD', async () => {
|
||||
const text = '# T\n\n## 1.0.0 – 2026-01-01\n\n- x';
|
||||
vi.stubEnv('TESSERA_CHANGELOG_MD', text);
|
||||
vi.resetModules();
|
||||
const mod = await import('./changelog');
|
||||
|
||||
expect(mod.changelogMarkdown).toBe(text);
|
||||
});
|
||||
|
||||
it('Test 10 (ohne Variable): changelogMarkdown ist ein leerer String', async () => {
|
||||
vi.stubEnv('TESSERA_CHANGELOG_MD', '');
|
||||
vi.resetModules();
|
||||
const empty = await import('./changelog');
|
||||
expect(empty.changelogMarkdown).toBe('');
|
||||
|
||||
vi.stubEnv('TESSERA_CHANGELOG_MD', undefined);
|
||||
vi.resetModules();
|
||||
const unset = await import('./changelog');
|
||||
expect(unset.changelogMarkdown).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AppChannel } from './app-version';
|
||||
|
||||
/**
|
||||
* Aenderungsliste fuer die Seite "Was ist neu" (quick-260916-dcz).
|
||||
*
|
||||
* Quelle ist CHANGELOG.md im Wurzelverzeichnis; `apps/web/next.config.ts` liest
|
||||
* die Datei zur Bauzeit und legt den Text als `env.TESSERA_CHANGELOG_MD` ab.
|
||||
* Dieses Modul darf NUR von der Server-Seite (`app/(portal)/changelog/page.tsx`)
|
||||
* importiert werden — sonst landet der Text in oeffentlich abrufbaren
|
||||
* Client-Chunks unter /_next/static.
|
||||
*
|
||||
* Kanalregel (`filterChangelogForChannel`): auf `live` fehlt der Abschnitt
|
||||
* "Unveröffentlicht" vollstaendig; auf `beta` und `dev` bleibt er und traegt
|
||||
* die Ueberschrift "Noch nicht freigegeben (Beta)" (uebersetzbar). Ein leerer
|
||||
* Abschnitt (ohne Listenpunkt) wird auf allen Kanaelen ausgeblendet. H1 und
|
||||
* Vorspann vor der ersten `## `-Ueberschrift entfallen — die Seite hat ihren
|
||||
* eigenen Titel. Reine Funktion ohne React/Next, deshalb direkt testbar.
|
||||
*/
|
||||
|
||||
export type ChangelogChannel = AppChannel;
|
||||
|
||||
export interface FilteredChangelog {
|
||||
markdown: string;
|
||||
hasUnreleased: boolean;
|
||||
}
|
||||
|
||||
export const UNRELEASED_HEADING = 'Unveröffentlicht';
|
||||
|
||||
const DEFAULT_UNRELEASED_LABEL = 'Noch nicht freigegeben (Beta)';
|
||||
|
||||
/**
|
||||
* Wie in app-version.ts: Next.js ersetzt `process.env.<NAME>` zur Bauzeit nur,
|
||||
* wenn der Ausdruck woertlich mit vollem Namen im Code steht — kein
|
||||
* Destructuring, kein `process.env[name]`.
|
||||
*/
|
||||
export const changelogMarkdown: string = process.env.TESSERA_CHANGELOG_MD ?? '';
|
||||
|
||||
const SECTION_RE = /^## /;
|
||||
const UNRELEASED_RE = new RegExp(`^## ${UNRELEASED_HEADING}\\s*$`);
|
||||
const LIST_ITEM_RE = /^\s*[-*] /;
|
||||
|
||||
function splitSections(lines: string[]): string[][] {
|
||||
const sections: string[][] = [];
|
||||
let current: string[] | null = null;
|
||||
for (const line of lines) {
|
||||
if (SECTION_RE.test(line)) {
|
||||
current = [line];
|
||||
sections.push(current);
|
||||
} else if (current) {
|
||||
current.push(line);
|
||||
}
|
||||
// Zeilen vor der ersten `## `-Ueberschrift (H1, Vorspann) werden verworfen.
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
export function filterChangelogForChannel(
|
||||
markdown: string,
|
||||
channel: AppChannel,
|
||||
options?: { unreleasedHeading?: string },
|
||||
): FilteredChangelog {
|
||||
const normalized = markdown.replace(/\r\n?/g, '\n');
|
||||
const sections = splitSections(normalized.split('\n'));
|
||||
|
||||
let hasUnreleased = false;
|
||||
const kept: string[][] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (!UNRELEASED_RE.test(section[0])) {
|
||||
kept.push(section);
|
||||
continue;
|
||||
}
|
||||
const isEmpty = !section.slice(1).some((line) => LIST_ITEM_RE.test(line));
|
||||
if (isEmpty || channel === 'live') {
|
||||
continue;
|
||||
}
|
||||
hasUnreleased = true;
|
||||
const label = options?.unreleasedHeading ?? DEFAULT_UNRELEASED_LABEL;
|
||||
kept.push([`## ${label}`, ...section.slice(1)]);
|
||||
}
|
||||
|
||||
const joined = kept.map((section) => section.join('\n')).join('\n').trim();
|
||||
return { markdown: joined ? `${joined}\n` : '', hasUnreleased };
|
||||
}
|
||||
@@ -109,7 +109,8 @@
|
||||
"beta": "Beta",
|
||||
"live": "Live",
|
||||
"dev": "Entwicklung"
|
||||
}
|
||||
},
|
||||
"whatsNew": "Was ist neu"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -512,6 +513,13 @@
|
||||
"errorSendFailed": "Die E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Ihren Administrator.",
|
||||
"errorGeneric": "Die Meldung konnte nicht gesendet werden."
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Was ist neu",
|
||||
"intro": "Alle Änderungen an Tessera, sortiert nach Version – die aktuelle Version steht oben.",
|
||||
"unreleasedHeading": "Noch nicht freigegeben (Beta)",
|
||||
"unreleasedHint": "Die Punkte unter „Noch nicht freigegeben“ sind in dieser Beta bereits enthalten, aber noch nicht als Version freigegeben.",
|
||||
"empty": "Noch keine Einträge vorhanden."
|
||||
},
|
||||
"locale": {
|
||||
"de": "Deutsch",
|
||||
"en": "English"
|
||||
|
||||
@@ -109,7 +109,8 @@
|
||||
"beta": "Beta",
|
||||
"live": "Live",
|
||||
"dev": "Development"
|
||||
}
|
||||
},
|
||||
"whatsNew": "What's new"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -512,6 +513,13 @@
|
||||
"errorSendFailed": "The e-mail could not be sent. Please try again later or contact your administrator.",
|
||||
"errorGeneric": "The report could not be sent."
|
||||
},
|
||||
"changelog": {
|
||||
"title": "What's new",
|
||||
"intro": "All changes to Tessera, sorted by version – the current version is at the top.",
|
||||
"unreleasedHeading": "Not yet released (beta)",
|
||||
"unreleasedHint": "The items under “Not yet released” are already part of this beta but have not been released as a version yet.",
|
||||
"empty": "No entries yet."
|
||||
},
|
||||
"locale": {
|
||||
"de": "Deutsch",
|
||||
"en": "English"
|
||||
|
||||
Reference in New Issue
Block a user