Simplify bilingual changelogs by platform and change type
Keep dated history and release notes generated from one source.
This commit is contained in:
parent
7c86dbcd33
commit
1c20a0b6d5
5 changed files with 1586 additions and 911 deletions
1050
CHANGELOG.en.md
1050
CHANGELOG.en.md
File diff suppressed because it is too large
Load diff
1050
CHANGELOG.md
1050
CHANGELOG.md
File diff suppressed because it is too large
Load diff
251
changelog.json
251
changelog.json
File diff suppressed because one or more lines are too long
|
|
@ -2,6 +2,7 @@
|
|||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
|
@ -41,7 +42,7 @@ class ChangelogTests(unittest.TestCase):
|
|||
self.assertTrue(documents['release-body.md'].startswith(self.source['releases'][self.tag]['summary']))
|
||||
for lang in export.LANGUAGES:
|
||||
text = documents[f'CHANGELOG.{lang}.md']
|
||||
self.assertIn('## 2.7-preview.14', text)
|
||||
self.assertIn('## 30/09/2026 : 2.7-preview.14', text)
|
||||
self.assertNotIn('## 2.7-156.0.1', text)
|
||||
future = copy.deepcopy(self.source['entries'][0])
|
||||
future['id'] = 'future'
|
||||
|
|
@ -62,16 +63,47 @@ class ChangelogTests(unittest.TestCase):
|
|||
export.generate(p, Path(d), Path(d)/'site.json', Path(d)/'releases')
|
||||
self.assertFalse((Path(d)/'site.json').exists())
|
||||
|
||||
def test_flat_entry_keeps_explicit_platforms_in_both_languages(self):
|
||||
def test_grouped_entries_keep_explicit_platforms_in_both_languages(self):
|
||||
for language in export.LANGUAGES:
|
||||
for source, rendered in zip(self.source['entries'], self.languages[language]):
|
||||
self.assertEqual(rendered['platforms'], source['metadata']['platforms'])
|
||||
self.assertEqual(rendered['models'], source['metadata']['models'])
|
||||
self.assertEqual(rendered['status'], source['metadata']['status'])
|
||||
self.assertEqual(rendered['groups'][0]['platforms'], rendered['platforms'])
|
||||
self.assertEqual({p for group in rendered['groups'] for p in group['platforms']},
|
||||
set(rendered['platforms']))
|
||||
|
||||
def test_date_first_heading_and_platform_badges(self):
|
||||
for language in export.LANGUAGES:
|
||||
entry = self.languages[language][0]
|
||||
self.assertEqual(entry['id'], '2.7-preview.20')
|
||||
self.assertEqual(entry['date'], '2026-10-13')
|
||||
common = next(g for g in entry['groups'] if g['common'])
|
||||
self.assertEqual(common['platforms'], ['pc', 'apk', 'models'])
|
||||
web = next(g for g in entry['groups'] if g['name'] == 'Web')
|
||||
self.assertEqual(web['platforms'], ['web'])
|
||||
self.assertIn('>Web</span>', web['nameHtml'])
|
||||
self.assertEqual(entry['notes'], '')
|
||||
self.assertEqual(export.group_scope('')[1], ['pc', 'models'])
|
||||
with self.assertRaisesRegex(ValueError, 'Unknown platform'):
|
||||
export.group_scope('Unknown')
|
||||
|
||||
def test_tags_alphabetical_in_every_platform_group(self):
|
||||
for entry in self.source['entries']:
|
||||
for lang in export.LANGUAGES:
|
||||
for group in re.split(r'^### ', entry['text'][lang], flags=re.M)[1:]:
|
||||
tags = re.findall(r'^- !\[([^\]]+)\]', group, re.M)
|
||||
self.assertTrue(tags, (entry['id'], lang))
|
||||
self.assertEqual(tags, sorted(tags), (entry['id'], lang))
|
||||
self.assertLessEqual(set(tags), {'Add', 'Change', 'Fix', 'Note', 'Test'})
|
||||
|
||||
def test_release_summary_follows_the_entry_without_copying_text(self):
|
||||
tag = 'v2.7-preview.20'
|
||||
self.source['entries'][0]['text']['fr'] += '\nA new correction.\n'
|
||||
docs = export.release_documents(self.source, tag)
|
||||
self.assertIn('A new correction.', docs['release-body.md'])
|
||||
|
||||
def test_badges_are_images_with_escaped_attributes(self):
|
||||
rendered = export.inline(' **Camera**')
|
||||
rendered = export.inline(' **Camera**')
|
||||
self.assertIn('<img class="cl-badge"', rendered)
|
||||
self.assertIn('alt="Fix"', rendered)
|
||||
self.assertIn('<strong>Camera</strong>', rendered)
|
||||
|
|
@ -79,6 +111,11 @@ class ChangelogTests(unittest.TestCase):
|
|||
self.assertNotIn('<img', export.inline(''))
|
||||
self.assertNotIn(' onerror="', export.inline(''))
|
||||
|
||||
def test_website_badges_do_not_require_external_images(self):
|
||||
rendered = export.inline('')
|
||||
self.assertEqual(rendered, '<span class="cl-badge" style="background:#52677D">Fix</span>')
|
||||
self.assertNotIn('<script>', export.inline(''))
|
||||
|
||||
def test_invalid_platform_is_rejected(self):
|
||||
self.source['entries'][0]['metadata']['platforms'] = ['unknown']
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ def inline(value):
|
|||
return '<strong>' + html.escape(text[2:-2]) + '</strong>'
|
||||
if text.startswith('!['):
|
||||
label, url = re.fullmatch(r'!\[([^\]]+)\]\(([^)]+)\)', text).groups()
|
||||
badge = re.fullmatch(r'https://img\.shields\.io/badge/[^?]+-([0-9A-Fa-f]{6})\?style=flat-square', url)
|
||||
if badge:
|
||||
# Website badges are text: visible offline, without image-loading delays.
|
||||
return '<span class="cl-badge" style="background:#' + badge[1] + '">' + html.escape(label) + '</span>'
|
||||
if url.startswith('https://'):
|
||||
return '<img class="cl-badge" src="' + html.escape(url, quote=True) + '" alt="' + html.escape(label, quote=True) + '" height="20" loading="lazy" style="vertical-align:middle;width:auto">'
|
||||
return html.escape(label)
|
||||
|
|
@ -62,6 +66,22 @@ def parse(path):
|
|||
return parse_text(path.read_text())
|
||||
|
||||
|
||||
def group_scope(name):
|
||||
"""Read platform badges as labels, not as image URLs."""
|
||||
label = re.sub(r'!\[([^\]]+)\]\([^)]+\)', r'\1', name)
|
||||
common = label.startswith(('Commun', 'Shared', 'Toutes les plateformes', 'All Platforms'))
|
||||
if common:
|
||||
return label, ['pc', 'apk', 'models'], True
|
||||
scope = []
|
||||
if re.search(r'\bPC\b|Desktop', label): scope.append('pc')
|
||||
if 'Android' in label: scope.append('apk')
|
||||
if 'Sargo' in label or 'Tundra' in label: scope.append('models')
|
||||
if label == 'Web': scope.append('web')
|
||||
if not scope:
|
||||
raise ValueError('Unknown platform group: ' + label)
|
||||
return label, scope, False
|
||||
|
||||
|
||||
def parse_text(markdown):
|
||||
entries = []
|
||||
months = {name: index for names in (
|
||||
|
|
@ -71,23 +91,26 @@ def parse_text(markdown):
|
|||
for chunk in re.split(r'^## ', markdown, flags=re.MULTILINE)[1:]:
|
||||
heading, _, body = chunk.partition('\n')
|
||||
if heading.startswith(('Tenue du journal', 'Maintaining this journal')): continue
|
||||
version = re.match(r'\d+\.\d+[.\w-]*', heading)
|
||||
key = version.group() if version else ('development' if heading.startswith(('En développement', 'In development')) else 'history')
|
||||
title = re.sub(r'^\d{2}/\d{2}/\d{4}\s*:\s*', '', heading)
|
||||
version = re.match(r'\d+\.\d+[.\w-]*', title)
|
||||
key = version.group() if version else ('development' if title.startswith(('En développement', 'In development')) else 'history')
|
||||
kind = 'version' if version else key
|
||||
stamp = re.search(r'(\d{1,2}) ([A-Za-zéû]+) (\d{4})', heading)
|
||||
date = None
|
||||
if stamp and stamp[2].lower() in months:
|
||||
date = f'{stamp[3]}-{months[stamp[2].lower()]:02d}-{int(stamp[1]):02d}'
|
||||
numeric_date = re.match(r'(\d{2})/(\d{2})/(\d{4})\s*:', heading)
|
||||
if numeric_date:
|
||||
date = f'{numeric_date[3]}-{numeric_date[2]}-{numeric_date[1]}'
|
||||
names = re.findall(r'^### (.+)', body, re.MULTILINE)
|
||||
platforms = []
|
||||
if any(re.search(r'\bPC\b|Desktop', name) for name in names): platforms.append('pc')
|
||||
if any('Android' in name for name in names): platforms.append('apk')
|
||||
if any('Tundra' in name or 'Sargo' in name for name in names): platforms.append('models')
|
||||
if not platforms:
|
||||
platforms = ['pc', 'apk', 'models'] if any(name.startswith(('Commun', 'Shared')) for name in names) else ['apk']
|
||||
platforms = [p for p in ('pc', 'apk', 'models', 'web')
|
||||
if any(p in group_scope(name)[1] for name in names)] or ['apk']
|
||||
status = 'development' if key == 'development' else 'history' if key == 'history' else 'published' if re.search(r'publi[ée]|published', heading, re.I) else 'built'
|
||||
release = re.search(r'^\[Release\]\((https://[^)]+)\)', body, re.MULTILINE)
|
||||
body = re.sub(r'^\[Release\]\([^)]+\)\s*', '', body, flags=re.MULTILINE)
|
||||
# The website already displays the status beside the date/version.
|
||||
body = re.sub(r'^!\[(?:Publié|Published|En développement|In development|APK construit · non publié|APK built · not published)\]\([^)]+\)\s*',
|
||||
'', body, flags=re.MULTILINE)
|
||||
parts = re.split(r'^### (.+)\n', body, flags=re.MULTILINE)
|
||||
groups = []
|
||||
intro = blocks(parts[0].splitlines())
|
||||
|
|
@ -95,16 +118,11 @@ def parse_text(markdown):
|
|||
groups.append({'name': '', 'platforms': platforms, 'common': False, 'html': intro})
|
||||
intro = ''
|
||||
for name, text in zip(parts[1::2], parts[2::2]):
|
||||
common = name.startswith(('Commun', 'Shared'))
|
||||
scope = platforms if common else []
|
||||
if not common:
|
||||
if re.search(r'\bPC\b|Desktop', name): scope.append('pc')
|
||||
if 'Android' in name: scope.append('apk')
|
||||
if 'Sargo' in name or 'Tundra' in name: scope.append('models')
|
||||
if not scope: scope = ['pc']
|
||||
label, scope, common = group_scope(name)
|
||||
# Closing limits belong to the whole entry, not to the last group.
|
||||
split = re.split(r'(?=^\*\*(?:Limites|Limitations) :)|(?=^\*\*Limitations:)', text, maxsplit=1, flags=re.MULTILINE)
|
||||
groups.append({'name': name, 'platforms': scope, 'common': common, 'html': blocks(split[0].splitlines())})
|
||||
groups.append({'name': label, 'nameHtml': inline(name), 'platforms': scope,
|
||||
'common': common, 'html': blocks(split[0].splitlines())})
|
||||
if len(split)>1: intro += blocks(split[1].splitlines())
|
||||
entries.append({'id': key, 'kind': kind, 'date': date, 'title': heading, 'status': status, 'platforms': platforms,
|
||||
'models': ['sargo'] if 'models' in platforms else [], 'notes': intro,
|
||||
|
|
@ -148,7 +166,7 @@ def load_source(path):
|
|||
require(metadata['status'] in ('development', 'history', 'built', 'published'), 'Invalid entry status: ' + key)
|
||||
require(isinstance(metadata['platforms'], list) and bool(metadata['platforms']) and
|
||||
len(set(metadata['platforms'])) == len(metadata['platforms']) and
|
||||
set(metadata['platforms']) <= {'pc', 'apk', 'models'}, 'Invalid platforms: ' + key)
|
||||
set(metadata['platforms']) <= {'pc', 'apk', 'models', 'web'}, 'Invalid platforms: ' + key)
|
||||
require(isinstance(metadata['models'], list) and
|
||||
all(isinstance(model, str) and re.fullmatch(r'[a-z0-9-]+', model) for model in metadata['models']) and
|
||||
(not metadata['models'] or 'models' in metadata['platforms']), 'Invalid models: ' + key)
|
||||
|
|
@ -162,6 +180,9 @@ def load_source(path):
|
|||
# Flat changelogs keep scope independently of translated prose.
|
||||
if len(item['groups']) == 1 and not item['groups'][0]['name']:
|
||||
item['groups'][0]['platforms'] = metadata['platforms']
|
||||
else:
|
||||
require(set(metadata['platforms']) == {p for g in item['groups'] for p in g['platforms']},
|
||||
'Group/entry platform mismatch: ' + key)
|
||||
if item['kind'] == 'version':
|
||||
require(item['id'] == key, 'Version/ID mismatch: ' + key)
|
||||
# Development IDs are fixed in the source, independent of order/date.
|
||||
|
|
@ -188,15 +209,19 @@ def release_documents(source, tag):
|
|||
histories = {}
|
||||
for lang in LANGUAGES:
|
||||
title = 'Historique cumulatif' if lang == 'fr' else 'Cumulative history'
|
||||
note = ('Les sections datées décrivent l’état à chaque étape. Les limites et correctifs ont évolué ; '
|
||||
'consulter la synthèse de la release pour l’état final. Les chemins de documentation désignent '
|
||||
'les sources locales, dont la publication est distincte de cet APK.' if lang == 'fr' else
|
||||
'Dated sections describe each development stage. Limitations and fixes evolved; the release '
|
||||
'summary describes the final state. Documentation paths refer to local sources; source '
|
||||
'publication is separate from this APK.')
|
||||
note = ('Chaque entrée décrit la situation à sa date. Consulter la synthèse de la release pour son état final.'
|
||||
if lang == 'fr' else
|
||||
'Each entry describes the state at its date. See the release summary for its final state.')
|
||||
histories[lang] = (f'# {title} — {release["after"]} → {release["version"]}\n\n{note}\n\n' +
|
||||
'\n\n'.join(e['text'][lang].strip() for e in selected) + '\n')
|
||||
body = release['summary'].strip() + '\n\n'
|
||||
if release.get('include_entry'):
|
||||
for lang, label in [('fr', '🇫🇷 Français'), ('en', '🇬🇧 English')]:
|
||||
summary = selected[0]['text'][lang].strip()
|
||||
# Historical build status remains in the dated history, not in a published release's summary.
|
||||
summary = re.sub(r'^!\[(?:APK construit · non publié|APK built · not published)\]\([^)]+\)\s*',
|
||||
'', summary, flags=re.MULTILINE)
|
||||
body += label + '\n\n' + summary + '\n\n'
|
||||
for lang, label in [('fr', '🇫🇷 Historique complet, étape par étape'),
|
||||
('en', '🇬🇧 Full history, stage by stage')]:
|
||||
body += f'<details>\n<summary>{label}</summary>\n\n{histories[lang]}\n</details>\n\n'
|
||||
|
|
|
|||
Loading…
Reference in a new issue