Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions scripts/build-changelog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
129 changes: 99 additions & 30 deletions src/js/08-static-renders.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<a href="${escHtml(href)}" target="_blank" rel="noopener noreferrer">${escHtml(m[1])}</a>`
);
} 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
? `<span class="changelog-date">${escHtml(release.date)}</span>`
: '';
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 = `<div class="changelog-release${isUnreleased ? ' changelog-release--unreleased' : ''}">`;
html += `<div class="changelog-release-header">`;
html += `<a class="changelog-version" href="${ghUrl}" target="_blank" rel="noopener noreferrer">`;
html += isUnreleased ? '🔧 Unreleased' : escHtml('v' + release.version);
html += `</a>${dateStr}`;
html += `<a class="changelog-gh-link" href="${ghUrl}" target="_blank" rel="noopener noreferrer">View on GitHub ↗</a>`;
html += `</div>`;
if (release.sections.length === 0) {
html += `<p class="changelog-empty">No entries yet.</p>`;
}
release.sections.forEach((sec) => {
html += `<div class="changelog-section">`;
html += `<h4 class="changelog-section-heading">${escHtml(sec.heading)}</h4>`;
html += `<ul class="changelog-items">`;
sec.items.forEach((item) => {
html += `<li class="changelog-item">${mdLinksToHtml(item)}</li>`;
});
html += `</ul></div>`;
});
html += `</div>`;
return html;
}

function renderChangelog() {
const list = document.getElementById('changelogList');
if (!list) return;
Expand All @@ -44,39 +114,38 @@
return;
}

let html = '';
CHANGELOG_RELEASES.forEach((release) => {
const isUnreleased = release.version === 'Unreleased';
const dateStr = release.date
? `<span class="changelog-date">${escHtml(release.date)}</span>`
: '';
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 += `<div class="changelog-release${isUnreleased ? ' changelog-release--unreleased' : ''}">`;
html += `<div class="changelog-release-header">`;
html += `<a class="changelog-version" href="${ghUrl}" target="_blank" rel="noopener noreferrer">`;
html += isUnreleased ? '🔧 Unreleased' : escHtml('v' + release.version);
html += `</a>${dateStr}`;
html += `</div>`;
if (release.sections.length === 0) {
html += `<p class="changelog-empty">No entries yet.</p>`;
}
release.sections.forEach((sec) => {
html += `<div class="changelog-section">`;
html += `<h4 class="changelog-section-heading">${escHtml(sec.heading)}</h4>`;
html += `<ul class="changelog-items">`;
sec.items.forEach((item) => {
html += `<li class="changelog-item">${escHtml(item)}</li>`;
});
html += `</ul></div>`;
});
const latest = CHANGELOG_RELEASES[0];
const older = CHANGELOG_RELEASES.slice(1);

let html = buildReleaseHtml(latest);

if (older.length > 0) {
html += `<button class="changelog-show-more" id="changelogShowMore" aria-expanded="false">` +
`Show ${older.length} older release${older.length === 1 ? '' : 's'} ↓</button>`;
html += `<div class="changelog-older" id="changelogOlder" hidden>`;
older.forEach((r) => { html += buildReleaseHtml(r); });
html += `</div>`;
});
}

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 -------------------------
Expand Down
52 changes: 52 additions & 0 deletions styles/content-pages.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}


99 changes: 97 additions & 2 deletions tests/script.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<script>');
test('markdown links in items are rendered as anchor tags', () => {
// Re-initialise with an item containing a markdown link
document.body.innerHTML = MIN_HTML;
global.ChangelogData = {
SITE_VERSION: '2.0.0',
CHANGELOG_RELEASES: [{
version: '2.0.0',
date: '2025-07-01',
sections: [{
heading: 'Added',
items: ['New feature ([#99](https://github.com/nitrocode/token-deathclock/issues/99)) ([abc1234](https://github.com/nitrocode/token-deathclock/commit/abc1234))'],
}],
}],
};
global.requestAnimationFrame = jest.fn();
// eslint-disable-next-line no-eval
eval(scriptCode);
const html = document.getElementById('changelogList').innerHTML;
// Markdown link syntax must not appear verbatim
expect(html).not.toContain('[#99]');
// PR link should be an anchor
expect(html).toContain('href="https://github.com/nitrocode/token-deathclock/issues/99"');
expect(html).toContain('>#99<');
// Commit link should be an anchor
expect(html).toContain('href="https://github.com/nitrocode/token-deathclock/commit/abc1234"');
});

test('non-https markdown link text is escaped, not rendered as anchor', () => {
document.body.innerHTML = MIN_HTML;
global.ChangelogData = {
SITE_VERSION: '2.0.0',
CHANGELOG_RELEASES: [{
version: '2.0.0',
date: null,
sections: [{
heading: 'Added',
items: ['bad link [click](javascript:alert(1))'],
}],
}],
};
global.requestAnimationFrame = jest.fn();
// eslint-disable-next-line no-eval
eval(scriptCode);
const html = document.getElementById('changelogList').innerHTML;
expect(html).not.toContain('href="javascript:');
// The raw markdown text should be escaped as literal text, not injected as a link
expect(html).not.toContain('<a href="javascript');
});

test('show-more button is rendered when there are older releases', () => {
const list = document.getElementById('changelogList');
const btn = list.querySelector('#changelogShowMore');
expect(btn).not.toBeNull();
expect(btn.textContent).toContain('older release');
});

test('older releases container is hidden by default', () => {
const list = document.getElementById('changelogList');
const older = list.querySelector('#changelogOlder');
expect(older).not.toBeNull();
expect(older.hidden).toBe(true);
});

test('clicking show-more reveals older releases and updates button text', () => {
const list = document.getElementById('changelogList');
const btn = list.querySelector('#changelogShowMore');
const older = list.querySelector('#changelogOlder');
btn.click();
expect(older.hidden).toBe(false);
expect(btn.textContent).toContain('Hide older releases');
});

test('clicking show-more again re-hides older releases', () => {
const list = document.getElementById('changelogList');
const btn = list.querySelector('#changelogShowMore');
const older = list.querySelector('#changelogOlder');
btn.click(); // expand
btn.click(); // collapse
expect(older.hidden).toBe(true);
expect(btn.textContent).toContain('older release');
});

test('no show-more button when there is only one release', () => {
document.body.innerHTML = MIN_HTML;
global.ChangelogData = {
SITE_VERSION: '1.0.0',
CHANGELOG_RELEASES: [{
version: '1.0.0',
date: '2025-04-14',
sections: [{ heading: 'Added', items: ['Initial release'] }],
}],
};
global.requestAnimationFrame = jest.fn();
// eslint-disable-next-line no-eval
eval(scriptCode);
const btn = document.getElementById('changelogShowMore');
expect(btn).toBeNull();
});

test('renders gracefully when ChangelogData is absent', () => {
Expand Down
Loading