117 lines
6.6 KiB
JavaScript
117 lines
6.6 KiB
JavaScript
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));
|
|
}
|
|
}
|