test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN

This commit is contained in:
2025-11-14 16:40:45 +09:00
parent aaa624a046
commit a9bbc357e7
92 changed files with 8370 additions and 13 deletions
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { AssetManager } from '@/lib/assets/assetManager'
function buf(bytes: number[]): Uint8Array {
return new Uint8Array(bytes)
}
describe('AssetManager', () => {
it('업로드 이미지를 해시하여 assets/<hash>.<ext>로 관리하고 중복을 제거한다', async () => {
const am = new AssetManager({
maxBytes: 10 * 1024 * 1024,
allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'],
})
const a1 = await am.add({ name: 'hero.png', type: 'image/png', bytes: buf([1,2,3,4]) })
const a2 = await am.add({ name: 'another.png', type: 'image/png', bytes: buf([1,2,3,4]) })
const a3 = await am.add({ name: 'logo.svg', type: 'image/svg+xml', bytes: buf([60,115,118,103,62]) })
expect(a1.path).toMatch(/^assets\/[a-f0-9]{8}\.png$/)
expect(a2.path).toBe(a1.path) // 동일 바이트 -> 동일 해시 -> 중복 제거
expect(a3.path).toMatch(/^assets\/[a-f0-9]{8}\.svg$/)
const list = am.list()
expect(list.length).toBe(2)
const zipStruct = am.toZipStructure()
const keys = Object.keys(zipStruct)
expect(keys).toContain(a1.path)
expect(keys).toContain(a3.path)
expect(zipStruct[a1.path]).toBeInstanceOf(Uint8Array)
})
it('허용되지 않은 확장자/크기는 거절한다', async () => {
const am = new AssetManager({ maxBytes: 5, allowedExts: ['png'] })
await expect(
am.add({ name: 'bad.gif', type: 'image/gif', bytes: buf([1]) })
).rejects.toThrow(/extension/i)
await expect(
am.add({ name: 'big.png', type: 'image/png', bytes: buf([1,2,3,4,5,6]) })
).rejects.toThrow(/size/i)
})
it('rewriteUrl: http/https는 그대로, local:<name>은 assets 경로로 치환한다', async () => {
const am = new AssetManager({ maxBytes: 1024, allowedExts: ['png'] })
const external = am.rewriteUrl('https://cdn.example.com/x.png')
expect(external).toBe('https://cdn.example.com/x.png')
const ref = await am.add({ name: 'pic.png', type: 'image/png', bytes: buf([9,9,9]) })
const rewritten = am.rewriteUrl(`local:${ref.originalName}`)
expect(rewritten).toBe(ref.path)
})
})
+98
View File
@@ -0,0 +1,98 @@
export type AssetInput = {
name: string
type: string
bytes: Uint8Array
}
export type AssetRef = {
originalName: string
mimeType: string
bytes: Uint8Array
hash8: string
ext: string
path: string // assets/<hash8>.<ext>
}
export type AssetManagerOptions = {
maxBytes: number
allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg']
}
function extOf(name: string): string {
const i = name.lastIndexOf('.')
return i >= 0 ? name.slice(i + 1).toLowerCase() : ''
}
// Simple FNV-1a 32-bit hash -> 8-hex chars
function hash8(bytes: Uint8Array): string {
let h = 0x811c9dc5 >>> 0
for (let i = 0; i < bytes.length; i++) {
h ^= bytes[i]
h = Math.imul(h, 0x01000193) >>> 0
}
return ('00000000' + h.toString(16)).slice(-8)
}
export class AssetManager {
private readonly maxBytes: number
private readonly allowed: Set<string>
private byHash = new Map<string, AssetRef>()
private byOriginal = new Map<string, string>() // originalName -> path
constructor(opts: AssetManagerOptions) {
this.maxBytes = opts.maxBytes
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
}
async add(file: AssetInput): Promise<AssetRef> {
const ext = extOf(file.name)
if (!this.allowed.has(ext)) {
throw new Error('Invalid extension')
}
if (file.bytes.length > this.maxBytes) {
throw new Error('File size exceeded')
}
const h = hash8(file.bytes)
const existing = this.byHash.get(h)
if (existing) {
// map original name for rewrite convenience
if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, existing.path)
return existing
}
const path = `assets/${h}.${ext}`
const ref: AssetRef = {
originalName: file.name,
mimeType: file.type,
bytes: file.bytes,
hash8: h,
ext,
path,
}
this.byHash.set(h, ref)
if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, path)
return ref
}
list(): AssetRef[] {
return Array.from(this.byHash.values())
}
toZipStructure(): Record<string, Uint8Array> {
const out: Record<string, Uint8Array> = {}
for (const ref of this.byHash.values()) {
out[ref.path] = ref.bytes
}
return out
}
rewriteUrl(urlOrLocalRef: string): string {
if (/^https?:\/\//i.test(urlOrLocalRef)) return urlOrLocalRef
if (urlOrLocalRef.startsWith('local:')) {
const key = urlOrLocalRef.slice('local:'.length)
const p = this.byOriginal.get(key)
return p ?? urlOrLocalRef
}
return urlOrLocalRef
}
}