bookmarklet
Version:
A JavaScript bookmarklet compiler and demo page creator with greasemonkey userscript-like metadata options
272 lines (246 loc) • 6.9 kB
JavaScript
const version = [3, 0, 0];
// const babel = require('@babel/core');
// const babelPresetEnv = require('@babel/preset-env');
const md5 = require('md5');
const Terser = require('terser');
// metadata
const str = 1;
const list = 2;
const bool = 3;
const metadata = {
types: {
string: str,
list: list,
boolean: bool
},
keys: {
name: str,
version: str,
description: str,
repository: str,
author: str,
email: str,
url: str,
license: str,
script: list,
style: list
}
};
function quoteEscape(x) {
return x.replace('"', '\\"').replace("'", "\\'");
}
function extractOptions(path) {
// Returns {
// path: the updated path string (minus any options)
// opts: plain object of options
// }
//
// You can prefix a path with options in the form of:
//
// ```
// @style !loadOnce !foo=false https://example.com/foo.css
// ```
//
// If there is no `=`, then the value of the option defaults to `true`.
// Values get converted via JSON.parse if possible, o/w they're a string.
//
let opts = {};
while (true) {
let m = path.match(/^(\![^\s]+)\s+/);
if (m) {
path = path.substring(m.index + m[0].length);
let opt = m[1].substring(1).split('=');
opts[opt[0]] = opt[1] === undefined ? true : _fuzzyParse(opt[1]);
} else {
break;
}
}
return { path, opts };
}
const _fuzzyParse = val => {
try {
return JSON.parse(val);
} catch (e) {
return val;
}
};
function loadScript(code, path, loadOnce) {
loadOnce = !!loadOnce;
let id = `bookmarklet__script_${md5(path).substring(0, 7)}`;
return `
function callback(){
${code}
}
if (!${loadOnce} || !document.getElementById("${id}")) {
var s = document.createElement("script");
if (s.addEventListener) {
s.addEventListener("load", callback, false)
} else if (s.readyState) {
s.onreadystatechange = callback
}
if (${loadOnce}) {
s.id = "${id}";
}
s.src = "${quoteEscape(path)}";
document.body.appendChild(s);
} else {
callback();
}
`;
}
function loadStyle(code, path, loadOnce) {
loadOnce = !!loadOnce;
let id = `bookmarklet__style_${md5(path).substring(0, 7)}`;
return `${code}
if (!${loadOnce} || !document.getElementById("${id}")) {
var link = document.createElement("link");
if (${loadOnce}) {
link.id = "${id}";
}
link.rel="stylesheet";
link.href = "${quoteEscape(path)}";
document.body.appendChild(link);
}
`;
}
async function minify(code) {
// const result = babel.transform(code, {
// presets: [
// [
// babelPresetEnv,
// {
// targets: 'ie 8', // '> 0.25%, not dead',
// corejs: { version: '3.9', proposals: true },
// useBuiltIns: 'usage'
// }
// ]
// ]
// });
const result = await Terser.minify(code);
return result.code;
}
async function convert(code, options) {
code = await minify(code);
let stylesCode = '';
if (options.script) {
options.script = options.script.reverse();
options.script.forEach(s => {
let { path, opts } = extractOptions(s);
code = loadScript(code, path, opts.loadOnce);
});
code = await minify(code);
}
if (options.style) {
options.style.forEach(s => {
let { path, opts } = extractOptions(s);
stylesCode = loadStyle(stylesCode, path, opts.loadOnce);
});
const minifiedStyles = await minify(stylesCode);
code = minifiedStyles + code;
}
code = `(function(){${code}})()`;
return `javascript:${encodeURIComponent(code)}`;
}
function parseFile(data) {
let inMetadataBlock = false;
let openMetadata = '==Bookmarklet==';
let closeMetadata = '==/Bookmarklet==';
let rComment = /^(\s*\/\/\s*)/;
let mdKeys = metadata.keys;
let mdTypes = metadata.types;
let options = {};
let code = [];
let errors = [];
// parse file and gather options from metadata block if available
data.match(/[^\r\n]+/g).forEach(function (line, i, lines) {
// comment
if (rComment.test(line)) {
let comment = line.replace(rComment, '').trim(),
canonicalComment = comment.toLowerCase().replace(/\s+/g, '');
if (!inMetadataBlock) {
if (canonicalComment == openMetadata.toLowerCase()) {
inMetadataBlock = true;
}
} else {
if (canonicalComment == closeMetadata.toLowerCase()) {
inMetadataBlock = false;
} else {
let m = comment.match(/^@([^\s]+)\s+(.*)$/);
if (m) {
let k = m[1];
let v = m[2];
if (k) {
if (mdKeys[k] == mdTypes.list) {
options[k] = options[k] || [];
options[k].push(v);
} else if (mdKeys[k] == mdTypes.boolean) {
options[k] = v.toLowerCase() == 'true';
} else {
options[k] = v;
}
} else {
warn(`ignoring invalid metadata option: '${k}'`);
}
}
}
}
// code
} else {
code.push(line);
}
if (inMetadataBlock && i + 1 == lines.length) {
errors.push(`missing metdata block closing '${closeMetadata}'`);
}
});
return {
code: code.join('\n'),
options,
errors: errors.length ? errors : null
};
}
function makeDemo(bookmarkletCode, options) {
options = options || {};
const name = options.name || 'Bookmarklet';
const createdWith = 'https://github.com/mrcoles/bookmarklet';
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html,body,div { margin: 0; padding: 0; font: normal 16px/24px Helvetica Neue, Helvetica, sans-serif; color: #333; }
#main { max-width: 630px; margin: 3em auto; }
.bookmarklet { display: inline-block; padding: .5em 1em; color: #fff; background: #50dcf3; border-radius: 4px; text-decoration: none; }
a { color: #50dcf3; }
.dim { color: #999; }
</style>
</head>
<body>
<div id="main">
<h1>${name}</h1>
<p>
Drag this button to your bookmarks bar to save it as a bookmarklet:
</p>
<p>
<a class="bookmarklet" href="${bookmarkletCode}">${name}</a>
</p>
${
options.repo
? `<p>See source at <a href="${options.repo}">${options.repo}</a></p>`
: ''
}
<p class="dim">This page was created with the <a href="${createdWith}">bookmarklet</a> npm library.</p>
</div>
</body>
</html>
`;
return html;
}
// Exports
Object.assign(exports, {
version,
convert,
parseFile,
makeDemo,
metadata
});