import path from 'node:path'; import postcss from 'postcss'; import valueParser from 'postcss-value-parser'; import safeParser from 'postcss-safe-parser'; import * as cheerio from 'cheerio'; import fs from 'node:fs/promises'; import { root, sha, resource, relative, write, writeJSON } from './lib.mjs'; export class Assets { constructor(site) { this.site = site; this.records = new Map(); this.queue = []; this.active = 0; this.failures = []; } resolve(value, base) { const u = new URL(value, base); if (this.site.hosts.includes(u.hostname)) { u.protocol = 'https:'; u.host = new URL(this.site.origin).host; } u.hash = ''; return u.href; } ensure(value, base, hint = '') { value = value?.trim(); if (!value || value.startsWith('#') || /^(?:javascript:|about:|blob:)/i.test(value)) return value; if (value.startsWith('data:')) { const match = value.match(/^data:(image\/[^;,]+);base64,([\s\S]+)$/); if (!match || value.length < 1000) return value; const ext = ({'image/png':'png','image/jpeg':'jpg','image/gif':'gif','image/webp':'webp','image/svg+xml':'svg'})[match[1]] || 'bin'; const file = `assets/${sha(value).slice(0,20)}.${ext}`; if (!this.records.has(value)) { const entry = { file, url: 'embedded:'+sha(value), status: 200 }; entry.promise = write(path.join(root,this.site.id,file), Buffer.from(match[2],'base64')); this.records.set(value,entry); } return file; } let url; try { url = this.resolve(value,base); } catch { return ''; } if (!/^https?:/.test(url)) return ''; if (this.records.has(url)) return this.records.get(url).file; const u = new URL(url); let basename = decodeURIComponent(u.pathname.split('/').pop() || 'resource').replace(/[^a-zA-Z0-9._-]/g,'-').slice(-70); if (!/\.[a-z0-9]{1,6}$/i.test(basename)) basename += hint || (/fonts\.googleapis/.test(u.host) ? '.css' : '.bin'); const entry = { url, file: `assets/${sha(url).slice(0,14)}-${basename}` }; this.records.set(url,entry); entry.promise = new Promise(resolve => { this.queue.push({entry,resolve}); this.drain(); }); return entry.file; } drain() { while (this.active < 8 && this.queue.length) { const {entry,resolve} = this.queue.shift(); this.active++; this.download(entry).catch(e => { entry.error=e.message; this.failures.push({url:entry.url,file:entry.file,message:e.message.slice(0,200)}); }).finally(()=> {this.active--;resolve();this.drain();}); } } async download(entry) { const r = await resource(entry.url); entry.status = r.status; entry.type = r.type; entry.fetchedAt = r.fetchedAt; if (r.status !== 200 || /text\/html/.test(r.type)) throw new Error(`Source resource unavailable (${r.status}, ${r.type})`); let body = r.body; if (/\.css$/i.test(new URL(entry.url).pathname) || /text\/css/.test(r.type)) body = this.css(body.toString(),entry.url,entry.file); else if (/javascript/.test(r.type) || /\.js$/i.test(new URL(entry.url).pathname)) body = body.toString().replace(/\/\/[#@]\s*sourceMappingURL=.*$/gm,''); await write(path.join(root,this.site.id,entry.file),body); } value(text, base, from) { const parsed = valueParser(text); parsed.walk(node => { if (node.type === 'function' && node.value.toLowerCase() === 'url') { const value = valueParser.stringify(node.nodes).replace(/^['"]|['"]$/g,'').trim(); const target = this.ensure(value,base); if (target && !target.startsWith('data:') && !target.startsWith('#')) node.nodes = [{type:'string',quote:'"',value:relative(from,target)}]; } }); return parsed.toString(); } css(text, base, from) { text = text.replace(/\/\*[#@]\s*sourceMappingURL=[\s\S]*?\*\//g,''); let css; try { css = postcss.parse(text); } catch { css = safeParser(text); } css.walkDecls(decl => { if (/url\(/i.test(decl.value)) decl.value = this.value(decl.value,base,from); }); css.walkAtRules('import', rule => { const p = valueParser(rule.params); const first = p.nodes.find(n => n.type !== 'space'); if (first?.type === 'string') first.value = relative(from,this.ensure(first.value,base,'.css')); else rule.params = this.value(rule.params,base,from); if(first?.type==='string') rule.params=p.toString(); }); if(new URL(base).hostname==='fonts.googleapis.com'){ const text=new URL(base).searchParams.get('text'); if(text){const ranges=[...new Set([...text].map(c=>'U+'+c.codePointAt(0).toString(16)))].join(',');css.walkAtRules('font-face',rule=>rule.append({prop:'unicode-range',value:ranges}));} } return css.toString(); } async settle() { let count; do { count=this.records.size; await Promise.all([...this.records.values()].map(e=>e.promise)); } while(this.records.size!==count); await writeJSON(path.join(root,this.site.id,'assets','manifest.json'),[...this.records.values()].map(({promise,...entry})=>entry)); } async prune(files) { const failed=new Set([...this.records.values()].filter(e=>e.error).map(e=>e.file)); if(!failed.size)return; const dead=(value,from)=>!value.startsWith('data:')&&failed.has(path.posix.normalize(path.posix.join(path.posix.dirname(from),value.split(/[?#]/)[0]))); const cleanCSS=(text,from)=>{ const css=safeParser(text); css.walkDecls(decl=>{let remove=false;valueParser(decl.value).walk(n=>{if(n.type==='function'&&n.value==='url'&&dead(valueParser.stringify(n.nodes).replace(/^['"]|['"]$/g,''),from))remove=true;});if(remove)decl.remove();}); return css.toString(); }; for(const entry of this.records.values()){ if(entry.error){entry.omitted=true;continue;} if(entry.type?.includes('text/css')){const file=path.join(root,this.site.id,entry.file);await write(file,cleanCSS(await fs.readFile(file,'utf8'),entry.file));} } for(const file of files){ const destination=path.join(root,this.site.id,file);const $=cheerio.load(await fs.readFile(destination,'utf8')); $('[src],[data-src],[href]').each((_,el)=>{for(const attr of ['src','data-src','href']){const value=$(el).attr(attr);if(value&&dead(value,file)){if(el.name==='img'||el.name==='link')$(el).remove();else $(el).removeAttr(attr).removeAttr('uk-img');}}}); $('[style]').each((_,el)=>$(el).attr('style',cleanCSS($(el).attr('style'),file))); $('style').each((_,el)=>$(el).text(cleanCSS($(el).text(),file))); await write(destination,$.html()); } for(const failure of this.failures)failure.referencesRemoved=true; await writeJSON(path.join(root,this.site.id,'assets','manifest.json'),[...this.records.values()].map(({promise,...entry})=>entry)); } }