feat: apply feedback updates and streamline online delivery
Publish GitHub Pages output / publish (push) Successful in 34s
Publish GitHub Pages output / publish (push) Successful in 34s
This commit is contained in:
@@ -1,67 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderReleaseMarkdown } from './markdown.ts';
|
||||
import { ReleaseNoteService, type ReleaseNoteConfig } from './release-note-service.ts';
|
||||
import { versionTagIsNewer, versionTagMatches } from './version.ts';
|
||||
|
||||
const config: ReleaseNoteConfig = {
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
includePrereleases: false,
|
||||
perPage: 30,
|
||||
timeoutMs: 100
|
||||
};
|
||||
|
||||
function response(payload: unknown, ok = true): Response {
|
||||
return { ok, json: async () => payload } as Response;
|
||||
}
|
||||
|
||||
test('정식 릴리즈만 최신순으로 변환하고 앱 실행 중 결과를 재사용한다', async () => {
|
||||
let requests = 0;
|
||||
const service = new ReleaseNoteService(config, async () => {
|
||||
requests += 1;
|
||||
return response([
|
||||
{ id: 1, tag_name: 'v1.0.0', name: null, published_at: '2026-01-01T00:00:00Z', body: null, html_url: 'https://example.com/1', draft: false, prerelease: false },
|
||||
{ id: 3, tag_name: 'v1.2.0-beta', name: 'beta', published_at: '2026-03-01T00:00:00Z', body: '', html_url: 'https://example.com/3', draft: false, prerelease: true },
|
||||
{ id: 2, tag_name: 'v1.1.0', name: 'one one', published_at: '2026-02-01T00:00:00Z', body: '# 변경', html_url: 'https://example.com/2', draft: false, prerelease: false },
|
||||
{ id: 4, tag_name: 'v9.0.0', name: 'draft', published_at: '2026-04-01T00:00:00Z', body: '', html_url: 'https://example.com/4', draft: true, prerelease: false }
|
||||
]);
|
||||
});
|
||||
|
||||
const first = await service.load({ online: true });
|
||||
const second = await service.load({ online: true });
|
||||
|
||||
assert.equal(requests, 1);
|
||||
assert.deepEqual(first, second);
|
||||
assert.deepEqual(first?.map((release) => release.tagName), ['v1.1.0', 'v1.0.0']);
|
||||
assert.equal(first?.[1].name, 'v1.0.0');
|
||||
assert.equal(first?.[1].body, '');
|
||||
});
|
||||
|
||||
test('오프라인 및 API 실패는 예외 없이 unavailable 값으로 캐시한다', async () => {
|
||||
let requests = 0;
|
||||
const service = new ReleaseNoteService(config, async () => {
|
||||
requests += 1;
|
||||
return response({}, false);
|
||||
});
|
||||
|
||||
assert.equal(await service.load({ online: false }), null);
|
||||
assert.equal(await service.load({ online: true }), null);
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test('현재 버전 표시는 v 접두사와 빌드 메타데이터를 무시한다', () => {
|
||||
assert.equal(versionTagMatches('v1.0.4', '1.0.4'), true);
|
||||
assert.equal(versionTagMatches('1.0.4+offline', 'v1.0.4'), true);
|
||||
assert.equal(versionTagMatches('v1.0.3', '1.0.4'), false);
|
||||
assert.equal(versionTagIsNewer('v1.1.0', '1.0.4'), true);
|
||||
assert.equal(versionTagIsNewer('v1.0.4', '1.0.4'), false);
|
||||
assert.equal(versionTagIsNewer('v1.0.3', '1.0.4'), false);
|
||||
});
|
||||
|
||||
test('릴리즈 Markdown의 원문 HTML과 위험한 링크를 실행 가능한 태그로 만들지 않는다', () => {
|
||||
const rendered = renderReleaseMarkdown('<script>alert(1)</script> [위험](javascript:alert(1))');
|
||||
assert.equal(rendered.includes('<script>'), false);
|
||||
assert.equal(rendered.includes('href="javascript:'), false);
|
||||
assert.match(rendered, /<script>/);
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
export type ReleaseNote = {
|
||||
id: number;
|
||||
tagName: string;
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
body: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ReleaseNoteConfig = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
includePrereleases: boolean;
|
||||
perPage: number;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
type GitHubRelease = {
|
||||
id?: unknown;
|
||||
tag_name?: unknown;
|
||||
name?: unknown;
|
||||
published_at?: unknown;
|
||||
body?: unknown;
|
||||
html_url?: unknown;
|
||||
draft?: unknown;
|
||||
prerelease?: unknown;
|
||||
};
|
||||
|
||||
type LoadOptions = {
|
||||
online?: boolean;
|
||||
};
|
||||
|
||||
export const RELEASE_NOTE_CONFIG: ReleaseNoteConfig = {
|
||||
owner: 'DD4SN',
|
||||
repo: 'HolodoriCalc',
|
||||
// 정식 릴리즈만 노출한다. 베타 릴리즈도 보여주려면 true로 변경한다.
|
||||
includePrereleases: false,
|
||||
perPage: 30,
|
||||
timeoutMs: 6_000
|
||||
};
|
||||
|
||||
export const RELEASES_PAGE_URL = `https://github.com/${RELEASE_NOTE_CONFIG.owner}/${RELEASE_NOTE_CONFIG.repo}/releases`;
|
||||
|
||||
function browserIsOnline(): boolean {
|
||||
return typeof navigator !== 'undefined'
|
||||
&& navigator.onLine
|
||||
&& typeof location !== 'undefined'
|
||||
&& location.protocol !== 'file:';
|
||||
}
|
||||
|
||||
function toReleaseNote(value: GitHubRelease): ReleaseNote | null {
|
||||
if (typeof value.id !== 'number'
|
||||
|| typeof value.tag_name !== 'string'
|
||||
|| typeof value.published_at !== 'string'
|
||||
|| typeof value.html_url !== 'string') return null;
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
tagName: value.tag_name,
|
||||
name: typeof value.name === 'string' && value.name.trim() ? value.name : value.tag_name,
|
||||
publishedAt: value.published_at,
|
||||
body: typeof value.body === 'string' ? value.body : '',
|
||||
url: value.html_url
|
||||
};
|
||||
}
|
||||
|
||||
export class ReleaseNoteService {
|
||||
private readonly config: ReleaseNoteConfig;
|
||||
private readonly fetcher: typeof fetch | undefined;
|
||||
private cached: ReleaseNote[] | null | undefined;
|
||||
private pending: Promise<ReleaseNote[] | null> | null = null;
|
||||
|
||||
constructor(
|
||||
config: ReleaseNoteConfig = RELEASE_NOTE_CONFIG,
|
||||
fetcher: typeof fetch | undefined = globalThis.fetch?.bind(globalThis)
|
||||
) {
|
||||
this.config = config;
|
||||
this.fetcher = fetcher;
|
||||
}
|
||||
|
||||
get releasesUrl(): string {
|
||||
return `https://github.com/${this.config.owner}/${this.config.repo}/releases`;
|
||||
}
|
||||
|
||||
async load(options: LoadOptions = {}): Promise<ReleaseNote[] | null> {
|
||||
if (this.cached !== undefined) return this.cached;
|
||||
if (this.pending) return this.pending;
|
||||
|
||||
const online = options.online ?? browserIsOnline();
|
||||
if (!online || !this.fetcher) {
|
||||
this.cached = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.pending = this.request();
|
||||
try {
|
||||
this.cached = await this.pending;
|
||||
return this.cached;
|
||||
} finally {
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async request(): Promise<ReleaseNote[] | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
||||
const apiUrl = `https://api.github.com/repos/${this.config.owner}/${this.config.repo}/releases?per_page=${this.config.perPage}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetcher!(apiUrl, {
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
if (!Array.isArray(payload)) return null;
|
||||
|
||||
return payload
|
||||
.filter((release): release is GitHubRelease => Boolean(release) && typeof release === 'object')
|
||||
.filter((release) => release.draft !== true)
|
||||
.filter((release) => this.config.includePrereleases || release.prerelease !== true)
|
||||
.map(toReleaseNote)
|
||||
.filter((release): release is ReleaseNote => release !== null)
|
||||
.sort((left, right) => Date.parse(right.publishedAt) - Date.parse(left.publishedAt));
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const releaseNoteService = new ReleaseNoteService();
|
||||
@@ -1,34 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import packageInfo from '../../../package.json';
|
||||
import { releaseNoteService, type ReleaseNote } from './release-note-service';
|
||||
import { versionTagMatches } from './version';
|
||||
|
||||
export type ReleaseNoteStatus = 'idle' | 'loading' | 'available' | 'unavailable';
|
||||
|
||||
export type ReleaseNoteState = {
|
||||
status: ReleaseNoteStatus;
|
||||
releases: ReleaseNote[];
|
||||
};
|
||||
|
||||
const state = writable<ReleaseNoteState>({ status: 'idle', releases: [] });
|
||||
let loadStarted = false;
|
||||
|
||||
export const currentAppVersion = packageInfo.version;
|
||||
|
||||
export const releaseNotes = {
|
||||
subscribe: state.subscribe,
|
||||
async load(): Promise<void> {
|
||||
if (loadStarted) return;
|
||||
loadStarted = true;
|
||||
state.set({ status: 'loading', releases: [] });
|
||||
|
||||
const loaded = await releaseNoteService.load();
|
||||
state.set(loaded && loaded.length > 0
|
||||
? { status: 'available', releases: loaded }
|
||||
: { status: 'unavailable', releases: [] });
|
||||
}
|
||||
};
|
||||
|
||||
export function isCurrentVersionTag(tagName: string, currentVersion = currentAppVersion): boolean {
|
||||
return versionTagMatches(tagName, currentVersion);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export function normalizedVersion(version: string): string {
|
||||
return version.trim().replace(/^v/i, '').split('+', 1)[0];
|
||||
}
|
||||
|
||||
export function versionTagMatches(tagName: string, currentVersion: string): boolean {
|
||||
return normalizedVersion(tagName) === normalizedVersion(currentVersion);
|
||||
}
|
||||
|
||||
export function versionTagIsNewer(tagName: string, currentVersion: string): boolean {
|
||||
const candidate = normalizedVersion(tagName).split('-', 1)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
|
||||
const current = normalizedVersion(currentVersion).split('-', 1)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
|
||||
const length = Math.max(candidate.length, current.length, 3);
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const difference = (candidate[index] ?? 0) - (current[index] ?? 0);
|
||||
if (difference !== 0) return difference > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user