aboutsummaryrefslogtreecommitdiff
path: root/scripts/build-changelog.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/build-changelog.py')
-rw-r--r--scripts/build-changelog.py207
1 files changed, 188 insertions, 19 deletions
diff --git a/scripts/build-changelog.py b/scripts/build-changelog.py
index 8973761..391581b 100644
--- a/scripts/build-changelog.py
+++ b/scripts/build-changelog.py
@@ -54,20 +54,44 @@ def git(*args):
def commits():
- """Oldest first: [{sha, date, subject, files:[...]}]"""
+ """Oldest first: [{sha, date, subject, body, files:[...]}]
+
+ `%b` is read, not just `%s`: the shipped fixes carry their symptom -> cause -> fix ->
+ verified prose in the commit BODY (5b55af9 is a typical one), so a subject-only changelog
+ rendered "one-line summaries" for every entry and made README's claim about this file
+ false as served. The body is terminated by %x02 because it is multi-line and the parser
+ below is line-based; without a terminator a body line is indistinguishable from a path.
+ """
raw = git('log', '--reverse', '--date=iso-strict',
- '--format=%x01%H%x01%aI%x01%s', '--name-only', '--', APP_REL)
- out, cur = [], None
+ '--format=%x01%H%x01%aI%x01%s%x01%b%x02', '--name-only', '--', APP_REL)
+ out, cur, where = [], None, 'body'
for line in raw.split('\n'):
if line.startswith('\x01'):
- _, sha, iso, subject = line.split('\x01', 3)
- cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject, 'files': []}
+ parts = line.split('\x01', 4)
+ if len(parts) < 5:
+ continue
+ _, sha, iso, subject, line = parts
+ cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject,
+ 'body': [], 'files': []}
out.append(cur)
- elif line.strip() and cur is not None:
+ where = 'body'
+ if cur is None:
+ continue
+ if where == 'body':
+ if '\x02' in line:
+ head = line.split('\x02', 1)[0]
+ if head.strip():
+ cur['body'].append(head)
+ where = 'files'
+ else:
+ # Blank lines are kept: they are the paragraph boundary the renderer splits on.
+ cur['body'].append(line.rstrip())
+ elif line.strip():
cur['files'].append(line.strip())
+ for row in out:
+ row['body'] = '\n'.join(row['body']).strip()
return out
-
_TAGMAP = None
@@ -132,8 +156,84 @@ def clean(subject):
"""
body = subject.split(':', 1)[1].strip() if ':' in subject else subject
body = body[0].upper() + body[1:] if body else subject
- return re.sub(r'`([^`]+)`', lambda m: '<code>' + html.escape(m.group(1)) + '</code>',
- html.escape(body))
+ return inline(body)
+
+
+def inline(text):
+ """Escape, then backticks -> <code>. Shared by subjects and body prose.
+
+ Escape ONCE, before wrapping: escaping the captured group again double-encodes any
+ `&`/`<`/`>` that sits inside a code span (measured: the "run `git show <sha>`" marker
+ shipped as &amp;lt;sha&amp;gt; and reached CHANGELOG.md as &lt;sha&gt;).
+ """
+ return re.sub(r'`([^`]+)`', lambda m: '<code>' + m.group(1) + '</code>', html.escape(text))
+
+
+# A commit body can run to 2 KB and this page lists every build, so body detail is capped and
+# the cut is MARKED: a reader must be able to tell a summary from a full transcript.
+DETAIL_MAX_CHARS = 900
+DETAIL_MAX_BLOCKS = 8
+
+
+def detail_blocks(text):
+ """Commit body -> [('p'|'li', plain text)], blank-line separated.
+
+ Bullet runs become list items (they read as the symptom/cause/fix/verified lines this
+ project writes); wrapped prose lines are rejoined into one paragraph. Without %b the
+ changelog could only ever show a subject, whatever the commit actually documented.
+ """
+ blocks = []
+ for chunk in re.split(r'\n\s*\n', (text or '').strip()):
+ lines = [ln.strip() for ln in chunk.splitlines() if ln.strip()]
+ if not lines:
+ continue
+ if all(re.match(r'^[-*\u2022]\s+', ln) for ln in lines):
+ for ln in lines:
+ blocks.append(('li', re.sub(r'^[-*\u2022]\s+', '', ln)))
+ continue
+ prose = []
+ for ln in lines:
+ if re.match(r'^[-*\u2022]\s+', ln):
+ if prose:
+ blocks.append(('p', ' '.join(prose)))
+ prose = []
+ blocks.append(('li', re.sub(r'^[-*\u2022]\s+', '', ln)))
+ else:
+ prose.append(ln)
+ if prose:
+ blocks.append(('p', ' '.join(prose)))
+
+ out, used = [], 0
+ for kind, body in blocks:
+ if len(out) >= DETAIL_MAX_BLOCKS or used + len(body) > DETAIL_MAX_CHARS:
+ out.append(('p', '\u2026 run `git show <sha>` for the rest'))
+ break
+ out.append((kind, body))
+ used += len(body)
+ return out
+
+
+def detail_html(blocks):
+ """Collapsible detail inside the list item: native <details>, so the page's renderer
+ (item.innerHTML = html) needs no change and a reader can ignore it."""
+ if not blocks:
+ return ''
+ out, in_ul = ['<details><summary>detail</summary>'], False
+ for kind, body in blocks:
+ if kind == 'li':
+ if not in_ul:
+ out.append('<ul>')
+ in_ul = True
+ out.append('<li>%s</li>' % inline(body))
+ else:
+ if in_ul:
+ out.append('</ul>')
+ in_ul = False
+ out.append('<p>%s</p>' % inline(body))
+ if in_ul:
+ out.append('</ul>')
+ out.append('</details>')
+ return ''.join(out)
def build():
@@ -168,11 +268,16 @@ def build():
entries = []
for ver, group in releases.items():
items = []
- for row in group[:MAX_ITEMS]:
- items.append('<strong>%s</strong> <code>%s</code>' % (
- clean(row['subject']), html.escape(row['sha'][:7])))
+ # The NEWEST commits of a build, not the oldest: group is chronological, so group[:N]
+ # showed the beginning of a 17-commit build and dropped the fixes that shipped last —
+ # measured 2026-11-06 when this fix's own commit was cut off its own entry.
+ shown = group[-MAX_ITEMS:]
if len(group) > MAX_ITEMS:
- items.append('…and %d more commit(s) in this build' % (len(group) - MAX_ITEMS))
+ items.append('…and %d earlier commit(s) in this build' % (len(group) - MAX_ITEMS))
+ for row in shown:
+ items.append('<strong>%s</strong> <code>%s</code>%s' % (
+ clean(row['subject']), html.escape(row['sha'][:7]),
+ detail_html(detail_blocks(row.get('body')))))
kinds = Counter(kind_of(r['subject']) for r in group)
# The dominant kind makes the chip honest: a build that was 9 fixes and 1
# feature is a fix build, not a feature release.
@@ -189,7 +294,7 @@ def build():
'note': 'Touches ' + ', '.join('%s (%d)' % (a, n) for a, n in areas.most_common(4))
+ ' · ' + ', '.join('%s %d' % (k, n) for k, n in kinds.most_common(3)),
'sources': sorted({f for r in group for f in r['files'][:1]})[:4],
- 'commits': [r['sha'][:7] for r in group[:MAX_ITEMS]],
+ 'commits': [r['sha'][:7] for r in shown],
})
entries.reverse() # newest build first
@@ -206,9 +311,11 @@ def build():
'builds whose tree reached a commit can appear here. Read a jump in the '
'version chip as "those bumps were superseded before commit".'})
gaps.append({'range': 'every build',
- 'why': 'Entries summarise commit subjects, not diffs. A build with one squashed '
- 'commit shows one line even if it changed ten files; run '
- '`git show <sha>` for the change itself.'})
+ 'why': 'Entries carry the commit subject and, where the commit body has '
+ 'substance, its symptom / cause / fix / verification text (folded open '
+ 'per line on the page, nested under the line in CHANGELOG.md). A build '
+ 'whose work was squashed into one commit still shows one line; the diff '
+ 'itself is always `git show <sha>`.'})
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
prev = existing_island()
@@ -232,7 +339,8 @@ def build():
DEK = ('Every change that has a commit behind it. Each line names the build it landed in and the '
- 'commit it came from, so any claim here can be checked with <code>git show</code>. Where '
+ 'commit it came from, and folds open to the commit body — the symptom, cause, fix and how '
+ 'it was verified — so any claim here can be checked with <code>git show</code>. Where '
'the repository has no record, the gap is written down instead of filled in.')
GAP_INTRO = ('This page is generated from the repository\'s own history for '
'<code>spaces/newos4</code> — the same source the running build comes from, so '
@@ -257,6 +365,27 @@ def existing_island():
return None
+# Rendered as native <details> inside each list item, so the page's own JS needs no change.
+# Injected once (sentinel-guarded) because the page is a static document: a builder that only
+# rewrites its JSON island cannot restyle anything else.
+DETAIL_CSS = """ /* wx11-changelog-detail:BEGIN */
+ .entry li>details{margin:.3rem 0 0}
+ .entry li>details>summary{
+ cursor:pointer; font-family:var(--mono); font-size:.7rem; letter-spacing:.08em;
+ text-transform:uppercase; color:var(--ink-3); padding:.45rem 0;
+ }
+ .entry li>details>summary:hover{color:var(--cyan)}
+ .entry li>details p{margin:.35rem 0 0; font-size:.88rem; color:var(--ink-2); line-height:1.55; overflow-wrap:anywhere}
+ .entry li>details ul{margin:.3rem 0 0; padding-left:1rem; font-size:.88rem; color:var(--ink-2); line-height:1.55; overflow-wrap:anywhere}
+ /* wx11-changelog-detail:END */
+"""
+DETAIL_CSS_RX = re.compile(r'/\* wx11-changelog-detail:BEGIN \*/[\s\S]*?/\* wx11-changelog-detail:END \*/\n?')
+# The first version shipped a single-line sentinel and no end marker; strip it or the page
+# would keep both copies forever.
+LEGACY_CSS_RX = re.compile(r'\s*/\* wx11-changelog-detail: collapsible commit body \*/'
+ r'[\s\S]*?\.entry li>details ul\{[^}]*\}\n')
+
+
def write_page(page):
with open(PAGE, encoding='utf-8') as fh:
doc = fh.read()
@@ -271,6 +400,14 @@ def write_page(page):
new = re.sub(r'(<section class="panel" aria-labelledby="gaps-h">[\s\S]*?<h2 id="gaps-h">[^<]*</h2>\s*<p>)[\s\S]*?(</p>)',
lambda m: m.group(1) + GAP_INTRO + m.group(2), new, count=1)
new = re.sub(r'(<b id="cur-ver">)[^<]*(</b>)', lambda m: m.group(1) + html.escape(page['currentVersion']) + m.group(2), new, count=1)
+ new = LEGACY_CSS_RX.sub('\n', new, count=1)
+ if DETAIL_CSS_RX.search(new):
+ # Owned block: re-emitted every run, so the rules can be edited here (the page is
+ # static; a stale copy on disk would silently win).
+ new = DETAIL_CSS_RX.sub(lambda m: DETAIL_CSS, new, count=1)
+ else:
+ new, n = re.subn(r'(\n\s*</style>)', lambda m: '\n' + DETAIL_CSS + m.group(1), new, count=1)
+ assert n == 1, 'no </style> to inject the detail CSS before - page layout changed'
write_atomic(PAGE, new)
return len(new)
@@ -286,6 +423,38 @@ PREAMBLE_RE = re.compile(re.escape('# newOS4 changelog') +
r'do not edit by hand between the markers\.\n+')
+DETAIL_RX = re.compile(r'<details[^>]*>\s*<summary>[^<]*</summary>(.*?)</details>\s*$', re.S)
+
+
+def md_inline(markup):
+ """Our own generated item markup -> markdown.
+
+ The old line replaced EVERY tag with a backtick, so `<strong>` became a stray tick and a
+ body's paragraph tags would have shredded the file. Explicit rules beat a generic strip:
+ <code>/<strong> survive as markdown, everything else becomes a space.
+ """
+ text = re.sub(r'<code>(.*?)</code>', lambda m: '`' + m.group(1) + '`', markup, flags=re.S)
+ text = re.sub(r'<strong>(.*?)</strong>', lambda m: '**' + m.group(1) + '**', text, flags=re.S)
+ text = re.sub(r'<[^>]+>', ' ', text)
+ return re.sub(r'\s+', ' ', html.unescape(text)).strip()
+
+
+def md_item(item):
+ """One changelog line, with the commit body as nested bullets.
+
+ This is the file the public mirror serves, so the README claim "symptom -> cause -> fix ->
+ verified" has to be true HERE, not only on the rendered page.
+ """
+ m = DETAIL_RX.search(item)
+ lines = ['- ' + md_inline(item[:m.start()] if m else item)]
+ if m:
+ for _tag, body in re.findall(r'<(p|li)>(.*?)</\1>', m.group(1), re.S):
+ body = md_inline(body)
+ if body:
+ lines.append(' - ' + body)
+ return lines
+
+
def write_md(page, rows):
"""App-side Keep-a-Changelog history; only the region between the markers is generated."""
region = ['<!-- generated:start -->', '']
@@ -295,7 +464,7 @@ def write_md(page, rows):
if entry['note']:
region.extend(['_' + re.sub(r'<[^>]+>', '', entry['note']) + '_', ''])
for item in entry['items']:
- region.append('- ' + re.sub(r'<[^>]+>', '`', item).replace('``', '`').replace('``', '`'))
+ region.extend(md_item(item))
region.append('')
region.extend(['<!-- generated:end -->', ''])
region_text = '\n'.join(region)