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 += `
`; + html += ``; + html += isUnreleased ? '🔧 Unreleased' : escHtml('v' + release.version); + html += `${dateStr}`; + html += `View on GitHub ↗`; + html += `
`; + if (release.sections.length === 0) { + html += `

No entries yet.

`; + } + release.sections.forEach((sec) => { + html += `
`; + html += `

${escHtml(sec.heading)}

`; + 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 += `
`; - html += ``; - html += isUnreleased ? '🔧 Unreleased' : escHtml('v' + release.version); - html += `${dateStr}`; - html += `
`; - if (release.sections.length === 0) { - html += `

No entries yet.

`; - } - release.sections.forEach((sec) => { - html += `
`; - html += `

${escHtml(sec.heading)}

`; - html += `
`; - }); + const latest = CHANGELOG_RELEASES[0]; + const older = CHANGELOG_RELEASES.slice(1); + + let html = buildReleaseHtml(latest); + + if (older.length > 0) { + html += ``; + 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('