web-statics/scripts/assets.mjs
2026-10-22 22:19:50 +08:00

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));
}
}