Initial commit.
I have zero faith that this will work.
This commit is contained in:
commit
afcb4ed679
16 changed files with 2066 additions and 0 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* text=auto
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
[Tt]humbs.db
|
||||
[Dd]esktop.ini
|
||||
.DS_Store
|
||||
/node_modules
|
30
LICENCE
Normal file
30
LICENCE
Normal 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
6
README.md
Normal 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
1489
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
package.json
Normal file
22
package.json
Normal 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
92
src/combine.js
Normal 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
52
src/handlers/css.js
Normal 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
42
src/handlers/html.js
Normal 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
72
src/handlers/js.js
Normal 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
49
src/handlers/twig.js
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
67
src/handlers/webmanifest.js
Normal file
67
src/handlers/webmanifest.js
Normal 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
26
src/housekeep.js
Normal 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
72
src/index.js
Normal 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
21
src/trim.js
Normal 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
21
src/utils.js
Normal 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);
|
||||
};
|
Loading…
Reference in a new issue