Initial commit.

I have zero faith that this will work.
This commit is contained in:
flash 2024-06-09 19:43:44 +00:00
commit afcb4ed679
16 changed files with 2066 additions and 0 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
[Tt]humbs.db
[Dd]esktop.ini
.DS_Store
/node_modules

30
LICENCE Normal file
View file

@ -0,0 +1,30 @@
Copyright (c) 2024, flashwave <me@flash.moe>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted (subject to the limitations in the disclaimer
below) provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

6
README.md Normal file
View file

@ -0,0 +1,6 @@
# Asset Processor
assproc is the utility I use across my projects to build Javascript, CSS and various other client side assets.
Originally written just for the chat, I ended up using it in pretty much all my web projects.
In a similar vain to my reasoning for setting up the Index library for the backend side of my projects, this will hopefully make it so the asset building side of things doesn't enter the same hell I did there.

1489
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "@flashwave/assproc",
"version": "0.0.1",
"description": "Personal frontend asset processing tool",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://patchii.net/flash/assproc"
},
"author": "flashwave",
"license": "BSD-3-Clause",
"dependencies": {
"@swc/core": "^1.5.25",
"autoprefixer": "^10.4.19",
"cssnano": "^7.0.2",
"html-minifier-terser": "^7.2.0",
"postcss": "^8.4.38"
}
}

92
src/combine.js Normal file
View file

@ -0,0 +1,92 @@
import fs from 'fs';
import readline from 'readline';
import { join as pathJoin } from 'path';
import { trim, trimStart, trimEnd } from './trim.js';
const combine = {
folder: async (root, options) => {
const macroPrefix = options.prefix || '#';
const entryPoint = options.entry || '';
root = fs.realpathSync(root);
const included = [];
const processFile = async fileName => {
const fullPath = pathJoin(root, fileName);
if(included.includes(fullPath))
return '';
included.push(fullPath);
if(!fullPath.startsWith(root))
throw `INVALID INCLUDED PATH: ${fullPath}`;
if(!fs.existsSync(fullPath))
throw `INCLUDED FILE NOT FOUND: ${fullPath}`;
const lines = readline.createInterface({
input: fs.createReadStream(fullPath),
crlfDelay: Infinity,
});
let output = '';
let lastWasEmpty = false;
if(options.showPath)
output += "/* *** PATH: " + fullPath + " */\n";
for await(const line of lines) {
const lineTrimmed = trim(line);
if(lineTrimmed === '')
continue;
if(line.startsWith(macroPrefix)) {
const args = lineTrimmed.split(' ');
const macro = trim(trimStart(args.shift(), macroPrefix));
switch(macro) {
case 'comment':
break;
case 'include': {
const includePath = trimEnd(args.join(' '), ';');
output += trim(await processFile(includePath));
output += "\n";
break;
}
case 'vars':
if(typeof options.vars !== 'object' || options.vars === null)
break;
const bvSourceName = trimEnd(args.join(' '), ';');
const bvSource = options.vars[bvSourceName];
if(typeof bvSource !== 'objects' || bvSource === null)
throw `INVALID VARS SOURCE: ${bvSourceName}`;
const bvProps = [];
for(const bvName in bvSource)
bvProps.push(`${bvName}: { value: ${JSON.stringify(bvSource[bvName])} }`);
if(Object.keys(bvProps).length > 0)
output += `Object.defineProperties(${options.varsTarget}, { ${bvProps.join(', ')} });\n`;
break;
default:
output += line;
output += "\n";
break;
}
} else {
output += line;
output += "\n";
}
}
return output;
};
return await processFile(entryPoint);
},
};
export combine;

52
src/handlers/css.js Normal file
View file

@ -0,0 +1,52 @@
import postcss from 'postcss';
import combine from '../combine.js';
import { join as pathJoin } from 'path';
import { strtr, shortHash, writeFile } from './utils.js';
export const function(env) {
const PREFIX = '@';
const DEFAULT_ENTRY = 'main.css';
const plugins = [
require('autoprefixer')({ remove: false }),
];
if(!env.debug)
plugins.push(require('cssnano')({
preset: [
'cssnano-preset-default',
{
minifyGradients: false,
reduceIdents: false,
zindex: true,
}
],
}));
const pcss = postcss(plugins);
return {
process: async (task, vars) => {
const srcPath = path.join(env.source, task.source);
const output = await pcss.process(
await combine.folder(srcPath, {
prefix: PREFIX,
entry: task.entry ?? DEFAULT_ENTRY,
}),
{
from: srcPath,
}
);
const path = pathJoin(
task.target ?? '',
strtr(task.name, { hash: shortHash(output.css) })
);
writeFile(pathJoin(env.public, path), output.css);
return path;
},
};
};

42
src/handlers/html.js Normal file
View file

@ -0,0 +1,42 @@
import { minify as htmlminify } from 'html-minifier-terser';
import { join as pathJoin, dirname } from 'path';
import { strtr, shortHash, writeFile } from './utils.js';
export const function(env) {
const MINIFY_OPTS = {
collapseBooleanAttributes: true,
collapseWhitespace: true,
conservativeCollapse: false,
decodeEntities: false,
quoteCharacter: '"',
removeAttributeQuotes: true,
removeComments: true,
removeEmptyAttributes: true,
removeOptionalTags: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
sortAttributes: true,
sortClassName: true,
};
return {
process: async (task, vars) => {
let data = fs.readFileSync(pathJoin(env.source, task.source));
if(typeof task.vars === 'string' && task.vars in vars)
data = strtr(data, vars[task.vars]);
if(!env.debug)
data = await htmlminify(data, MINIFY_OPTS);
const path = pathJoin(
task.target ?? '',
strtr(task.name, { hash: shortHash(data) })
);
writeFile(pathJoin(env.public, path), data);
return path;
},
};
};

72
src/handlers/js.js Normal file
View file

@ -0,0 +1,72 @@
import swc from '@swc/core';
import combine from '../combine.js';
import { join as pathJoin } from 'path';
import { strtr, shortHash, writeFile } from './utils.js';
export const function(env) {
const PREFIX = '#';
const DEFAULT_ENTRY = 'main.js';
const DEFAULT_VARS_TARGET = 'window';
const createJscOpts = () => {
return {
target: env.swc.es,
loose: false,
externalHelpers: false,
keepClassNames: true,
preserveAllComments: false,
transform: {},
parser: {
syntax: 'ecmascript',
jsx: env.swc.jsx !== false,
dynamicImport: false,
privateMethod: false,
functionBind: false,
exportDefaultFrom: false,
exportNamespaceFrom: false,
decorators: false,
decoratorsBeforeExport: false,
topLevelAwait: true,
importMeta: false,
},
transform: {
react: {
runtime: 'classic',
pragma: env.swc.jsx || '',
},
},
};
};
return {
process: async (task, vars) => {
const jscOpts = createJscOpts();
if('es' in task)
jscOpts.target = task.es;
const output = await swc.transform(
await combine.folder(pathJoin(env.source, task.source), {
prefix: PREFIX,
entry: task.entry ?? DEFAULT_ENTRY,
vars: vars,
varsTarget: task.varsTarget ?? DEFAULT_VARS_TARGET,
}), {
filename: task.source,
sourceMaps: false,
isModule: false,
minify: !env.debug,
jsc: jscOpts,
}
);
const path = pathJoin(
task.target ?? '',
strtr(task.name, { hash: shortHash(output.code) })
);
writeFile(pathJoin(env.public, path), output.code);
return path;
},
};
};

49
src/handlers/twig.js Normal file
View file

@ -0,0 +1,49 @@
import { exec as execLie } from 'child_process';
import { promisify } from 'util';
import { minify as htmlminify } from 'html-minifier-terser';
import { join as pathJoin, dirname } from 'path';
import { strtr, shortHash } from './utils.js';
export const function(env) {
const exec = promisify(execLie);
const MINIFY_OPTS = {
collapseBooleanAttributes: true,
collapseWhitespace: true,
conservativeCollapse: false,
decodeEntities: false,
quoteCharacter: '"',
removeAttributeQuotes: true,
removeComments: true,
removeEmptyAttributes: true,
removeOptionalTags: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
sortAttributes: true,
sortClassName: true,
};
return {
process: async (task, vars) => {
let { stdout, stderr } = await exec(strtr(env.twig.cmdFormat, {
':command': env.twig.cmdPathFull ?? pathJoin(env.root, env.twig.cmdPath),
':path': task.source,
}));
if(stdout.trim() === '')
throw stderr;
if(!env.debug)
stdout = await htmlminify(stdout, htmlMinifyOptions);
const path = pathJoin(
task.target ?? '',
strtr(task.name, { hash: shortHash(stdout) })
);
writeFile(pathJoin(env.public, path), stdout);
return path;
},
};
};

View file

@ -0,0 +1,67 @@
import fs from 'fs';
import { join as pathJoin, dirname } from 'path';
import { strtr, shortHash, writeFile } from './utils.js';
export const function(env) {
return {
process: async (task, vars) => {
let body = JSON.parse(fs.readFileSync(pathJoin(env.source, task.source)));
if(typeof task.body === 'object' && task.body !== null)
body = { ...body, ...task.body };
if(typeof task.icons === 'string') {
const iconsDir = path.join(env.public, task.icons);
if(fs.existsSync(iconsDir)) {
const files = (await fs.promises.readdir(iconsDir)).sort((a, b) => a.localeCompare(b, undefined, {
numeric: true,
sensitivity: 'base',
}));
body.icons = [];
for(const file of files) {
if(!file.endsWith('.png'))
continue;
const icon = {
src: pathJoin(task.icons, file),
type: 'image/png',
};
if(file[0] !== 'c') {
if(file[0] === 'm')
icon.purpose = 'maskable';
else if(file[0] === 'w')
icon.purpose = 'monochrome';
else continue;
}
let res = '';
for(let i = 1; i < file.length; ++i) {
if(file[i] === 'x')
break;
res += file[i];
}
if(res.length > 0)
icon.sizes = `${res}x${res}`;
body.icons.push(icon);
}
}
}
body = JSON.stringify(body);
const path = pathJoin(
task.target ?? '',
strtr(task.name, { hash: shortHash(body) })
);
writeFile(pathJoin(env.public, path), body);
return path;
},
};
};

26
src/housekeep.js Normal file
View file

@ -0,0 +1,26 @@
import fs from 'fs';
import { join as pathJoin } from 'path';
export const housekeep = path => {
const files = fs.readdirSync(path).map(fileName => {
const stats = fs.statSync(pathJoin(path, fileName));
return {
name: fileName,
lastMod: stats.mtimeMs,
};
}).sort((a, b) => b.lastMod - a.lastMod).map(info => info.name);
const regex = /^(.+)[\-\.]([a-f0-9]+)\.(.+)$/i;
const counts = {};
for(const fileName of files) {
const match = fileName.match(regex);
if(match) {
const name = match[1] + '-' + match[3];
counts[name] = (counts[name] || 0) + 1;
if(counts[name] > 5)
fs.unlinkSync(pathJoin(path, fileName));
} else console.log(`Encountered file name in assets folder with unexpected format: ${fileName}`);
}
};

72
src/index.js Normal file
View file

@ -0,0 +1,72 @@
import apCss from './handlers/css.js';
import apHtml from './handlers/html.js';
import apJs from './handlers/js.js';
import apTwig from './handlers/twig.js';
import apWebManifest from './handlers/webmanifest.js';
const DEFAULT_ENV = {
debug: false,
source: undefined,
public: undefined,
order: undefined,
vars: undefined,
swc: {
es: 'es2021',
jsx: '$er',
},
twig: {
cmdFormat: ':command :path',
cmdPathFull: undefined,
cmdPath: 'tools/render-tpl',
},
};
const public = {
process: async (env, tasks) => {
if(typeof env.source !== 'string')
throw 'env.source must be a path to the source directories';
if(typeof env.public !== 'string')
throw 'env.public must be a path to the root output directory';
if(typeof tasks !== 'object' || tasks === null)
throw 'tasks must be a non-null object';
env = { ...DEFAULT_ENV, ...env };
const types = {
js: new apJs(env),
css: new apCss(env),
webmanifest: new apWebManifest(env),
html: new apHtml(env),
twig: new apTwig(env),
};
const order = env.order ?? Object.keys(types);
if(!Array.isArray(order))
throw 'env.order must be undefined or an array';
const vars = env.vars ?? {};
if(typeof vars !== 'object' || vars === null)
throw 'env.vars must be a non-null object';
for(const type of order) {
if(!(type in types))
throw `${type} is not a supported build task type`;
const typeTasks = tasks[type];
if(!Array.isArray(typeTasks))
throw 'children of the tasks object must be arrays';
const handler = types[type];
for(const task of typeTasks) {
const path = await handler.process(task, vars);
if(typeof task.varsName === 'string')
vars[task.varsGroup ?? ''][task.varsName] = path;
}
}
},
};
export public;

21
src/trim.js Normal file
View file

@ -0,0 +1,21 @@
const trim = (str, chars = " \n\r\t\v\0", flags = 0) => {
let start = 0;
let end = str.length;
if(flags & 0x01)
while(start < end && chars.indexOf(str[start]) >= 0)
++start;
if(flags & 0x02)
while(end > start && chars.indexOf(str[end - 1]) >= 0)
--end;
if(start > 0 || end < str.length)
return str.substring(start, end);
return str;
};
export const trimStart = (str, chars) => trim(str, chars, 0x01);
export const trimEnd = (str, chars) => trim(str, chars, 0x02);
export const trim = (str, chars) => trim(str, chars, 0x03);

21
src/utils.js Normal file
View file

@ -0,0 +1,21 @@
import crypto from 'crypto';
import fs from 'fs';
import { dirname } from 'path';
export const strtr = (str, replacements) => str.toString().replace(
/{([^}]+)}/g, (match, key) => replacements[key] || match
);
export const shortHash = text => {
const hash = crypto.createHash('sha256');
hash.update(text);
return hash.digest('hex').substring(0, 8);
};
export const writeFile = (path, data) => {
const folderPath = dirname(path);
if(!fs.existsSync(folderPath))
fs.mkdirSync(folderPath, { recursive: true });
fs.writeFileSync(path, data);
};