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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user