test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user