Skip to content

Commit 2b3544f

Browse files
committed
fix: align changelog viewer with spec
1 parent d3f0b0b commit 2b3544f

File tree

7 files changed

+130
-76
lines changed

7 files changed

+130
-76
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"fuse.js": "^7.1.0"
1616
},
1717
"devDependencies": {
18+
"@types/node": "^22.0.0",
1819
"tsx": "^4.19.4",
1920
"typescript": "^5.8.3"
2021
}

pnpm-lock.yaml

Lines changed: 23 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/generate-data.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
1414
import { join, dirname } from 'node:path';
1515
import { fileURLToPath } from 'node:url';
1616
import { parseChangelog, type ParsedVersion, type Entry } from './parse-changelog.js';
17-
import { fetchReleases, fetchChangelogCommitDates, fetchNpmPublishDates, interpolateMissingDates } from './fetch-releases.js';
17+
import { fetchReleases, fetchNpmPublishDates, interpolateMissingDates } from './fetch-releases.js';
1818

1919
const __dirname = dirname(fileURLToPath(import.meta.url));
2020
const ROOT_DIR = join(__dirname, '..');

scripts/parse-changelog.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,39 @@ export interface ParsedVersion {
1212
entries: Entry[];
1313
}
1414

15+
function parseMarkdownTableRow(line: string): string[] {
16+
const trimmed = line.trim();
17+
if (!trimmed.startsWith('|')) return [];
18+
19+
const start = 1;
20+
const end = trimmed.endsWith('|') ? trimmed.length - 1 : trimmed.length;
21+
22+
const cells: string[] = [];
23+
let current = '';
24+
25+
for (let i = start; i < end; i++) {
26+
const ch = trimmed[i];
27+
28+
// `\|` はセル内のパイプとして扱う
29+
if (ch === '\\' && i + 1 < end && trimmed[i + 1] === '|') {
30+
current += '|';
31+
i++;
32+
continue;
33+
}
34+
35+
if (ch === '|') {
36+
cells.push(current.trim());
37+
current = '';
38+
continue;
39+
}
40+
41+
current += ch;
42+
}
43+
44+
cells.push(current.trim());
45+
return cells;
46+
}
47+
1548
/**
1649
* CHANGELOGファイルをパースする
1750
* @param content CHANGELOGファイルの内容
@@ -55,12 +88,13 @@ export function parseChangelog(content: string): ParsedVersion[] {
5588

5689
// テーブル行をパース
5790
if (inTable && line.startsWith('|') && currentVersion) {
58-
const cells = line.split('|').map((cell) => cell.trim());
59-
// cells[0] は空文字列、cells[1] が日本語、cells[2] が英語
60-
if (cells.length >= 3 && cells[1] && cells[2]) {
91+
const cells = parseMarkdownTableRow(line);
92+
const ja = cells[0];
93+
const en = cells[1];
94+
if (ja && en) {
6195
currentEntries.push({
62-
ja: cells[1],
63-
en: cells[2],
96+
ja,
97+
en,
6498
});
6599
}
66100
}

src/components/VersionSection.astro

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,27 +38,30 @@ function formatSafeHtml(text: string): string {
3838
* エントリの種類を判定してバッジクラスを返す
3939
* [VSCode] や Windows: などのプレフィックスは除外して動詞を認識する
4040
*/
41-
function getEntryType(text: string): string {
41+
function getEntryType(enText: string, jaText: string): string {
4242
// [Tag] 形式と Tag: 形式のプレフィックスを除去
43-
const cleanedText = text
43+
const cleanedEn = enText
4444
.replace(/^\[.*?\]\s*/, '') // [IDE], [VSCode] 等
45-
.replace(/^[A-Za-z]+:\s*/, ''); // Bedrock:, Windows: 等
46-
const lowerText = cleanedText.toLowerCase();
45+
.replace(/^[A-Za-z][A-Za-z0-9-]*:\s*/, ''); // Bedrock:, Windows: 等
46+
const cleanedJa = jaText
47+
.replace(/^\[.*?\]\s*/, '')
48+
.replace(/^[A-Za-z][A-Za-z0-9-]*:\s*/, '');
49+
const lowerEn = cleanedEn.toLowerCase();
4750
4851
// Added / Add
49-
if (lowerText.startsWith('added') || lowerText.startsWith('add ') || cleanedText.startsWith('追加')) return 'added';
52+
if (lowerEn.startsWith('added') || lowerEn.startsWith('add ') || cleanedJa.startsWith('追加')) return 'added';
5053
// Fixed / Fix / bugfixes
51-
if (lowerText.startsWith('fixed') || lowerText.startsWith('fix ') || lowerText.includes('bugfix') || cleanedText.startsWith('修正')) return 'fixed';
54+
if (lowerEn.startsWith('fixed') || lowerEn.startsWith('fix ') || lowerEn.includes('bugfix') || cleanedJa.startsWith('修正')) return 'fixed';
5255
// Changed / Change
53-
if (lowerText.startsWith('changed') || lowerText.startsWith('change ') || cleanedText.startsWith('変更')) return 'changed';
56+
if (lowerEn.startsWith('changed') || lowerEn.startsWith('change ') || cleanedJa.startsWith('変更')) return 'changed';
5457
// Improved / Improve
55-
if (lowerText.startsWith('improved') || lowerText.startsWith('improve ') || cleanedText.startsWith('改善')) return 'improved';
58+
if (lowerEn.startsWith('improved') || lowerEn.startsWith('improve ') || cleanedJa.startsWith('改善')) return 'improved';
5659
// Enabled / Enable
57-
if (lowerText.startsWith('enabled') || lowerText.startsWith('enable ') || cleanedText.startsWith('有効化')) return 'added';
60+
if (lowerEn.startsWith('enabled') || lowerEn.startsWith('enable ') || cleanedJa.startsWith('有効化')) return 'added';
5861
// Removed / Remove
59-
if (lowerText.startsWith('removed') || lowerText.startsWith('remove ') || cleanedText.startsWith('削除')) return 'changed';
62+
if (lowerEn.startsWith('removed') || lowerEn.startsWith('remove ') || cleanedJa.startsWith('削除')) return 'changed';
6063
// Deprecated / Deprecate
61-
if (lowerText.startsWith('deprecated') || lowerText.startsWith('deprecate ') || cleanedText.startsWith('非推奨')) return 'changed';
64+
if (lowerEn.startsWith('deprecated') || lowerEn.startsWith('deprecate ') || cleanedJa.startsWith('非推奨')) return 'changed';
6265
return 'other';
6366
}
6467
---
@@ -82,7 +85,7 @@ function getEntryType(text: string): string {
8285
</thead>
8386
<tbody>
8487
{entries.map((entry) => {
85-
const entryType = getEntryType(entry.en);
88+
const entryType = getEntryType(entry.en, entry.ja);
8689
return (
8790
<tr class={`entry-row ${entryType}`} data-ja={entry.ja} data-en={entry.en}>
8891
<td class="entry-ja" set:html={formatSafeHtml(entry.ja)} />

src/pages/[year].astro

Lines changed: 49 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -111,61 +111,62 @@ const generatedDate = new Date(generatedAt).toLocaleString('ja-JP', {
111111
includeMatches: true,
112112
});
113113

114-
// HTML特殊文字をエスケープ
115114
function escapeHtml(text: string): string {
116-
const div = document.createElement('div');
117-
div.textContent = text;
118-
return div.innerHTML;
115+
return text
116+
.replace(/&/g, '&amp;')
117+
.replace(/</g, '&lt;')
118+
.replace(/>/g, '&gt;')
119+
.replace(/"/g, '&quot;')
120+
.replace(/'/g, '&#39;');
119121
}
120122

121-
// バッククォートをcodeタグに変換(HTMLエスケープ後)
122-
function formatCode(text: string): string {
123-
const escaped = escapeHtml(text);
124-
return escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
123+
// バッククォート内を <code> 表示(エスケープ後)
124+
function renderWithCode(rawText: string): string {
125+
return escapeHtml(rawText).replace(/`([^`]+)`/g, '<code>$1</code>');
125126
}
126127

127-
// ハイライト適用後にformatCodeを適用(code表示を維持)
128-
function applyCodeFormatting(html: string): string {
129-
// ハイライト用のspanタグを一時的に保護
130-
const placeholders: string[] = [];
131-
const protectedHtml = html.replace(/<span class="highlight">([^<]*)<\/span>/g, (match, content) => {
132-
const idx = placeholders.length;
133-
placeholders.push(match);
134-
return `\x00HIGHLIGHT${idx}\x00`;
135-
});
136-
137-
// バッククォートをcodeタグに変換
138-
let result = protectedHtml.replace(/`([^`]+)`/g, '<code>$1</code>');
139-
140-
// ハイライトを復元
141-
result = result.replace(/\x00HIGHLIGHT(\d+)\x00/g, (_, idx) => placeholders[parseInt(idx)]);
128+
const HIGHLIGHT_START = '\uE000';
129+
const HIGHLIGHT_END = '\uE001';
142130

143-
return result;
144-
}
131+
function renderHighlightedHtml(rawText: string, indices: ReadonlyArray<[number, number]>): string {
132+
if (!indices || indices.length === 0) return renderWithCode(rawText);
145133

146-
// 検索結果をハイライト(code表示を維持)
147-
function highlightText(element: HTMLElement, matches: Fuse.FuseResultMatch[] | undefined, key: string) {
148-
if (!matches) return;
134+
const sorted = [...indices].sort((a, b) => a[0] - b[0]);
135+
const merged: Array<[number, number]> = [];
149136

150-
const match = matches.find((m) => m.key === key);
151-
if (!match || !match.indices || match.indices.length === 0) return;
137+
for (const [start, end] of sorted) {
138+
const last = merged[merged.length - 1];
139+
if (!last || start > last[1] + 1) {
140+
merged.push([start, end]);
141+
} else {
142+
last[1] = Math.max(last[1], end);
143+
}
144+
}
152145

153-
const text = element.textContent || '';
154-
let result = '';
146+
let marked = '';
155147
let lastIndex = 0;
156-
157-
// インデックスをソートして処理
158-
const sortedIndices = [...match.indices].sort((a, b) => a[0] - b[0]);
159-
160-
for (const [start, end] of sortedIndices) {
161-
result += escapeHtml(text.slice(lastIndex, start));
162-
result += `<span class="highlight">${escapeHtml(text.slice(start, end + 1))}</span>`;
148+
for (const [start, end] of merged) {
149+
marked += rawText.slice(lastIndex, start);
150+
marked += HIGHLIGHT_START + rawText.slice(start, end + 1) + HIGHLIGHT_END;
163151
lastIndex = end + 1;
164152
}
165-
result += escapeHtml(text.slice(lastIndex));
153+
marked += rawText.slice(lastIndex);
166154

167-
// ハイライト後にformatCodeを適用してcode表示を維持
168-
element.innerHTML = applyCodeFormatting(result);
155+
let html = escapeHtml(marked).replace(/`([^`]+)`/g, '<code>$1</code>');
156+
html = html.split(HIGHLIGHT_START).join('<span class="highlight">');
157+
html = html.split(HIGHLIGHT_END).join('</span>');
158+
return html;
159+
}
160+
161+
function applyHighlight(
162+
element: HTMLElement,
163+
rawText: string,
164+
matches: Fuse.FuseResultMatch[] | undefined,
165+
key: 'ja' | 'en'
166+
) {
167+
const match = matches?.find((m) => m.key === key);
168+
const indices = match?.indices as ReadonlyArray<[number, number]> | undefined;
169+
element.innerHTML = indices ? renderHighlightedHtml(rawText, indices) : renderWithCode(rawText);
169170
}
170171

171172
// 検索を実行
@@ -186,8 +187,8 @@ const generatedDate = new Date(generatedAt).toLocaleString('ja-JP', {
186187
// ハイライトをリセット
187188
const jaCell = row.querySelector('.entry-ja') as HTMLElement;
188189
const enCell = row.querySelector('.entry-en') as HTMLElement;
189-
if (jaCell) jaCell.innerHTML = formatCode((row as HTMLElement).dataset.ja || '');
190-
if (enCell) enCell.innerHTML = formatCode((row as HTMLElement).dataset.en || '');
190+
if (jaCell) jaCell.innerHTML = renderWithCode((row as HTMLElement).dataset.ja || '');
191+
if (enCell) enCell.innerHTML = renderWithCode((row as HTMLElement).dataset.en || '');
191192
});
192193
});
193194
searchStats.hidden = true;
@@ -254,14 +255,12 @@ const generatedDate = new Date(generatedAt).toLocaleString('ja-JP', {
254255
const jaCell = row.querySelector('.entry-ja') as HTMLElement;
255256
const enCell = row.querySelector('.entry-en') as HTMLElement;
256257

257-
// まずテキストをリセット
258-
if (jaCell) jaCell.innerHTML = formatCode((row as HTMLElement).dataset.ja || '');
259-
if (enCell) enCell.innerHTML = formatCode((row as HTMLElement).dataset.en || '');
260-
261258
// ハイライトを適用
262259
if (matchResult?.matches) {
263-
if (jaCell) highlightText(jaCell, matchResult.matches, 'ja');
264-
if (enCell) highlightText(enCell, matchResult.matches, 'en');
260+
const rawJa = (row as HTMLElement).dataset.ja || '';
261+
const rawEn = (row as HTMLElement).dataset.en || '';
262+
if (jaCell) applyHighlight(jaCell, rawJa, matchResult.matches, 'ja');
263+
if (enCell) applyHighlight(enCell, rawEn, matchResult.matches, 'en');
265264
}
266265
} else {
267266
row.classList.add('hidden');

tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"compilerOptions": {
66
"module": "ESNext",
77
"moduleResolution": "bundler",
8-
"resolveJsonModule": true
8+
"resolveJsonModule": true,
9+
"types": ["node"]
910
}
1011
}

0 commit comments

Comments
 (0)