diff --git a/scripts/build-changelog.js b/scripts/build-changelog.js
index f9d1b12..635bc02 100644
--- a/scripts/build-changelog.js
+++ b/scripts/build-changelog.js
@@ -39,13 +39,14 @@ let currentRelease = null;
let currentSection = null;
for (const line of raw.split('\n')) {
- // Release header: ## [Unreleased] or ## [1.0.0] - 2025-04-14
- const releaseMatch = line.match(/^## \[([^\]]+)\](?:\s+-\s+(\d{4}-\d{2}-\d{2}))?/);
+ // Release header: ## [Unreleased] or ## [1.0.0] - 2025-04-14 (Keep a Changelog)
+ // Also handles release-please format: ## [1.0.0](url) (2025-04-14)
+ const releaseMatch = line.match(/^## \[([^\]]+)\](?:\([^)]*\))?(?:\s+-\s+(\d{4}-\d{2}-\d{2})|\s+\((\d{4}-\d{2}-\d{2})\))?/);
if (releaseMatch) {
if (currentRelease) releases.push(currentRelease);
currentRelease = {
version: releaseMatch[1],
- date: releaseMatch[2] || null,
+ date: releaseMatch[2] || releaseMatch[3] || null,
sections: [],
};
currentSection = null;
diff --git a/src/js/08-static-renders.js b/src/js/08-static-renders.js
index 8f95bd6..e167ac7 100644
--- a/src/js/08-static-renders.js
+++ b/src/js/08-static-renders.js
@@ -30,6 +30,76 @@
}
// ---- Render changelog tab -----------------------------------
+
+ /**
+ * Convert markdown inline links [text](url) in a plain-text string into
+ * safe HTML anchor elements. All other content is HTML-escaped normally.
+ * Only https?:// URLs are converted; anything else is rendered as escaped text.
+ * @param {string} text
+ * @returns {string}
+ */
+ function mdLinksToHtml(text) {
+ const parts = [];
+ const re = /\[([^\]]+)\]\(([^)]+)\)/g;
+ let last = 0;
+ let m;
+ while ((m = re.exec(text)) !== null) {
+ if (m.index > last) {
+ parts.push(escHtml(text.slice(last, m.index)));
+ }
+ const href = m[2].trim();
+ if (/^https?:\/\//i.test(href)) {
+ parts.push(
+ `${escHtml(m[1])}`
+ );
+ } else {
+ parts.push(escHtml(m[0]));
+ }
+ last = m.index + m[0].length;
+ }
+ if (last < text.length) {
+ parts.push(escHtml(text.slice(last)));
+ }
+ return parts.join('');
+ }
+
+ /** Build the inner HTML for a single changelog release card.
+ * @param {{ version: string, date: string|null, sections: Array<{heading:string,items:string[]}> }} release
+ * @returns {string}
+ */
+ function buildReleaseHtml(release) {
+ const isUnreleased = release.version === 'Unreleased';
+ const dateStr = release.date
+ ? `${escHtml(release.date)}`
+ : '';
+ const ghUrl = isUnreleased
+ ? 'https://github.com/nitrocode/token-deathclock/compare/v' +
+ escHtml(SITE_VERSION) + '...HEAD'
+ : 'https://github.com/nitrocode/token-deathclock/releases/tag/v' +
+ escHtml(release.version);
+ let html = `
`;
+ html += ``;
+ if (release.sections.length === 0) {
+ html += `
No entries yet.
`;
+ }
+ release.sections.forEach((sec) => {
+ html += `
`;
+ html += `
${escHtml(sec.heading)}
`;
+ html += `
`;
+ sec.items.forEach((item) => {
+ html += `- ${mdLinksToHtml(item)}
`;
+ });
+ html += `
`;
+ });
+ html += `
`;
+ return html;
+ }
+
function renderChangelog() {
const list = document.getElementById('changelogList');
if (!list) return;
@@ -44,39 +114,38 @@
return;
}
- let html = '';
- CHANGELOG_RELEASES.forEach((release) => {
- const isUnreleased = release.version === 'Unreleased';
- const dateStr = release.date
- ? `${escHtml(release.date)}`
- : '';
- const ghUrl = isUnreleased
- ? 'https://github.com/nitrocode/token-deathclock/compare/v' +
- escHtml(SITE_VERSION) + '...HEAD'
- : 'https://github.com/nitrocode/token-deathclock/releases/tag/v' +
- escHtml(release.version);
- html += ``;
- html += ``;
- if (release.sections.length === 0) {
- html += `
No entries yet.
`;
- }
- release.sections.forEach((sec) => {
- html += `
`;
- html += `
${escHtml(sec.heading)}
`;
- html += `
`;
- sec.items.forEach((item) => {
- html += `- ${escHtml(item)}
`;
- });
- html += `
`;
- });
+ const latest = CHANGELOG_RELEASES[0];
+ const older = CHANGELOG_RELEASES.slice(1);
+
+ let html = buildReleaseHtml(latest);
+
+ if (older.length > 0) {
+ html += `
`;
+ html += `
`;
+ older.forEach((r) => { html += buildReleaseHtml(r); });
html += `
`;
- });
+ }
list.innerHTML = html;
+
+ const btn = document.getElementById('changelogShowMore');
+ if (btn) {
+ btn.addEventListener('click', () => {
+ const container = document.getElementById('changelogOlder');
+ if (!container) return;
+ const expanded = btn.getAttribute('aria-expanded') === 'true';
+ if (expanded) {
+ container.hidden = true;
+ btn.setAttribute('aria-expanded', 'false');
+ btn.textContent = `Show ${older.length} older release${older.length === 1 ? '' : 's'} ↓`;
+ } else {
+ container.hidden = false;
+ btn.setAttribute('aria-expanded', 'true');
+ btn.textContent = `Hide older releases ↑`;
+ }
+ });
+ }
}
// ---- Render footer meta-irony stats -------------------------
diff --git a/styles/content-pages.css b/styles/content-pages.css
index 4ca6507..a0af1e2 100644
--- a/styles/content-pages.css
+++ b/styles/content-pages.css
@@ -457,4 +457,56 @@
margin-top: 0.5rem;
}
+.changelog-gh-link {
+ margin-left: auto;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ text-decoration: none;
+ opacity: 0.7;
+ transition: opacity 0.2s;
+}
+
+.changelog-gh-link:hover {
+ opacity: 1;
+ color: var(--accent);
+}
+
+.changelog-item a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.changelog-item a:hover {
+ text-decoration: underline;
+ color: var(--accent-2);
+}
+
+.changelog-show-more {
+ display: block;
+ width: 100%;
+ margin-top: 0.5rem;
+ padding: 0.6rem 1rem;
+ background: transparent;
+ border: 1px dashed var(--border);
+ border-radius: 0.5rem;
+ color: var(--accent);
+ font-family: 'Share Tech Mono', monospace;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: background 0.2s, border-color 0.2s;
+ text-align: center;
+}
+
+.changelog-show-more:hover {
+ background: var(--surface);
+ border-color: var(--accent);
+}
+
+.changelog-older {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ margin-top: 0.5rem;
+}
+
diff --git a/tests/script.test.js b/tests/script.test.js
index fa519e6..5d3d44b 100644
--- a/tests/script.test.js
+++ b/tests/script.test.js
@@ -362,8 +362,103 @@ describe('renderChangelog (DOM)', () => {
expect(el.textContent).toBe('v1.2.3');
});
- test('changelog HTML does not contain unescaped script tags', () => {
- expect(document.getElementById('changelogList').innerHTML).not.toContain('