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