Files
landing-builder/lib/assets/assetManager.ts
T
jaybe a5b0f07f2a
Auto PR / open-pr (push) Failing after 11s
Auto Label PR / add-automerge-label (pull_request) Successful in 7s
CI / test (pull_request) Successful in 1m17s
fix(ci): 누락 파일 추가 및 AssetManager 경로 전략 변경사항 포함
2025-11-15 15:01:17 +09:00

118 lines
3.2 KiB
TypeScript

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']
pathStrategy?: 'flat' | 'grouped'
}
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 readonly pathStrategy: 'flat' | 'grouped'
private byHash = new Map<string, AssetRef>()
private byOriginal = new Map<string, string>() // originalName -> path
private referenced = new Set<string>() // originalName referenced via rewrite
constructor(opts: AssetManagerOptions) {
this.maxBytes = opts.maxBytes
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
this.pathStrategy = opts.pathStrategy ?? 'flat'
}
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 = this.makePath(h, ext, file.type)
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)
if (p) this.referenced.add(key)
return p ?? urlOrLocalRef
}
return urlOrLocalRef
}
referencedList(): string[] {
return Array.from(this.referenced)
}
private makePath(hash8: string, ext: string, mime: string): string {
if (this.pathStrategy === 'grouped') {
let group = 'other'
if (mime.startsWith('image/')) group = 'images'
else if (mime.startsWith('font/')) group = 'fonts'
return `assets/${group}/${hash8}.${ext}`
}
return `assets/${hash8}.${ext}`
}
}