UNPKG

@lipsurf/cli

Version:

Build LipSurf plugins to add custom voice commands to any website.

720 lines (677 loc) 21.6 kB
/*; * Split the plugin into a frontend/backend and a matching/non-matching URL for * frontend. Frontend needs to be loaded on every page, so it should be lightweight. * * order of parts: * backend * matching frontend * non-matching frontend * * We operate on the eval'd plugin js (with the node VM module) * because manipulating the js object is much more straightforward, less tedious * and less error prone. * * Using a parser like SWC is tedious and error prone because depending on the format * of the output, many cases would need to be handled. E.g. consider the various ways * that the various syntax that can be used to export the default module: * * export default Plugin; * export { * Plugin as default * } * export default { ... } * * Before we eval the plugin, we replace all fn calls with: * (specialVarName = () => {/* ...fn call... * /}) * * Tried making it a string, but ran into unicode escaping issues. * A comment in a function can still be extracted with .toString() * * For frontend: * * remove homophones, contexts, settings and other props only needed on the backend * * remove command properties such as match, description, fn, test, delay etc. * * replace non-default exports * * TODO: remove commands that have no pageFn or dynamic match * Backend: * * remove init, destroy * * remove command properties: dynamic match fn, test, pageFn */ /// <reference types="@lipsurf/types/extension"/> import { build } from "esbuild"; import { PluginPartType } from "./util"; import { evalPlugin } from "./evaluator"; import keyBy from "lodash/keyBy"; import mapValues from "lodash/mapValues"; import omit from "lodash/omit"; import { join } from "path"; import { promises as fs } from "fs"; import { CallExpression, parse, ModuleItem, Span, Identifier, Expression, MemberExpression, } from "@swc/core"; import Visitor from "@swc/core/Visitor"; import clone from "clone"; type PluginSpreadParts = [ backend: string, freeCS: string, freeNonMatchingCS: string, plusCS: string, plusNonMatchingCS: string, premiumCS: string, premiumNonMatchingCS: string ]; // --- hack until we have public + private common // import { // PLANS, // PLUGIN_SPLIT_SEQ, // EXT_ID, // FREE_PLAN, // PLUS_PLAN, // PREMIUM_PLAN, // } from "@lipsurf/common/constants.cjs"; const FREE_PLAN = 0; const PLUS_PLAN = 10; const PREMIUM_PLAN = 20; const EXT_ID = process.env.EXT_ID || process.env.BROWSER === "safari" ? "11C90F6F-8158-43E7-B45D-61FBD96C7288" : "lnnmjmalakahagblkkcnjkoaihlfglon"; const EXT_RESOURCES_PREFIX = process.env.BROWSER === "safari" ? `safari-web-extension://${EXT_ID}` : `chrome-extension://${EXT_ID}`; const BEAUTIFY = process.env.BEAUTIFY === "true"; const PLANS: [plan, plan, plan] = [FREE_PLAN, PLUS_PLAN, PREMIUM_PLAN]; const PLUGIN_SPLIT_SEQ = "\vLS-SPLIT"; // import { escapeRegex } from "@lipsurf/common/util.cjs"; const escaper = /[.*+?^${}()|[\]\\]/g; export function escapeRegex(s) { return s.replace(escaper, "\\$&"); } const PURE_FUNCS = (process.env["STRIP_LOGS"] || "").toLowerCase() === "false" ? [] : [ "console.log", "console.dir", "console.trace", "console.debug", "console.time", "console.timeEnd", ]; // -- end hack const PLUGIN_PROPS_TO_REMOVE_FROM_CS = [ "description", "homophones", "version", "authors", "icon", "match", "plan", "apiVersion", "contexts", "niceName", "replacements", "settings", ]; const COMMAND_PROPS_TO_REMOVE_FROM_CS = [ "fn", "delay", "description", "test", "global", "normal", "context", "onlyFinal", "minConfidence", "enterContext", "activeDocument", ]; const PLUGIN_BASE_PROPS = [ "annotations", "util", "help", "constants", "languages", ]; const importPluginBase = `import PluginBase from '${EXT_RESOURCES_PREFIX}/dist/modules/plugin-base.js';`; const importExtensionUtil = `import ExtensionUtil from '${EXT_RESOURCES_PREFIX}/dist/modules/extension-util.js';`; const FN_ESCAPE_TAG = `LIPSURF_FN_ESC`; const COMMENT_ENDER_PLACEHOLDER = "LIPSURF_CMT_ENDER"; const COMMENT_ENDER_PLACEHOLDER_REGX = new RegExp( COMMENT_ENDER_PLACEHOLDER, "g" ); const COMMENT_ENDER_REGEX = new RegExp("\\*/", "g"); const FN_ESCAPE_PREFIX = `(()=>{/*${FN_ESCAPE_TAG}`; const FN_ESCAPE_SUFFIX = `*/})`; class BlankPartError extends Error {} function replaceCmdsAbovePlan(plugin: IPlugin, buildForPlan: plan): IPlugin { let cmdsOnThisPlan: boolean = false; const pluginPlan = plugin.plan || 0; // if nothing is replaced, and the highestLevel is lower than build for plan, then return false so we // don't build for this level (the highest level might have been 10 or 0, and already built) plugin.commands = plugin.commands.map((cmd) => { const cmdPlan = cmd.plan; const minNeededPlan = cmdPlan ? cmdPlan : pluginPlan; let replace = false; if (!cmdPlan) { if (pluginPlan === buildForPlan) cmdsOnThisPlan = true; else if (pluginPlan > buildForPlan) replace = true; } else { if (buildForPlan === cmdPlan) cmdsOnThisPlan = true; if (minNeededPlan > buildForPlan) replace = true; } if (replace) { // @ts-ignore // `top.` since this can be called on frame-beacon cmd.pageFn = new Function( `top.showNeedsUpgrade({plan: ${minNeededPlan}})` ); } return cmd; }); if (!cmdsOnThisPlan && buildForPlan !== 0) throw new BlankPartError(); return plugin; } /** * Slim down backend code * TODO: remove languages.commands.match.fn * @param plugin */ function makeBackend(plugin: IPlugin, prod: boolean = false) { delete plugin.init; delete plugin.extraClickables; delete plugin.destroy; // @ts-ignore delete plugin.annotations; // @ts-ignore delete plugin.util; // @ts-ignore delete plugin.help; // delete plugin.languages; for (let i = plugin.commands.length - 1; i >= 0; i--) { const cmd = plugin.commands[i]; if (prod) delete cmd.test; delete cmd.pageFn; // @ts-ignore if (cmd.match.fn) { // @ts-ignore cmd.match.fn = () => {}; } } return plugin; } function makeCS(plugin: IPlugin, plan: plan, type: PluginPartType) { // must happen before we remove COMMAND_PROPS_TO_REMOVE_FROM_CS and PLUGIN_PROPS_TO_REMOVE_FROM_CS // since both have plan property plugin = replaceCmdsAbovePlan(plugin, plan); for (const prop of PLUGIN_PROPS_TO_REMOVE_FROM_CS) { delete plugin[prop]; } for (let i = plugin.commands.length - 1; i >= 0; i--) { const cmd = plugin.commands[i]; if (type === PluginPartType.nonmatching && !cmd.global) { plugin.commands.splice(i, 1); continue; } for (const prop of COMMAND_PROPS_TO_REMOVE_FROM_CS) { delete cmd[prop]; } if (Array.isArray(cmd.match) || typeof cmd.match === "string") { // @ts-ignore delete cmd.match; } else { // merge localized match fns into the plugin.commands.match object const oldMatch = cmd.match; // @ts-ignore cmd.match = Object.keys(plugin.languages || []).reduce( (memo, lang) => { const localizedFn = plugin.languages![lang]?.commands[cmd.name]?.match.fn; if (localizedFn) memo[lang] = localizedFn; return memo; }, { en: oldMatch.fn } ); } if (typeof cmd.nice !== "function") { delete cmd.nice; } // only the name key if (Object.keys(cmd).length === 1) plugin.commands.splice(i, 1); } if (!plugin.commands.length && !plugin.init && !plugin.destroy) throw new BlankPartError(); // array to obj // @ts-ignore plugin.commands = mapValues(keyBy(plugin.commands, "name"), (v) => omit(v, "name") ); if (type === PluginPartType.nonmatching) { delete plugin.extraClickables; } // remove plugin-base props for (const prop of PLUGIN_BASE_PROPS) { delete plugin[prop]; } return plugin; } function replaceSpans( code: string, spans: Span[], replacer?: (s: string) => string ) { const codeParts: string[] = []; let prevEnd = 0; let i = 0; for (; i < spans.length; i++) { const curSpan = spans[i]; // console.log("replacing span", code.substring(spans[i].start, spans[i].end)); codeParts.push(code.substring(prevEnd, curSpan.start)); if (replacer) { codeParts.push(replacer(code.substring(curSpan.start, curSpan.end))); } prevEnd = curSpan.end; } codeParts.push(code.substring(prevEnd)); return codeParts.join(""); } function getLanguageDefSpans(pluginId: string, body: ModuleItem[]): Span[] { return body .filter( (x) => x.type === "ExpressionStatement" && x.expression && x.expression.type === "AssignmentExpression" && x.expression.left && x.expression.left.type === "MemberExpression" && x.expression.left.object.type === "MemberExpression" && x.expression.left.object.object.type === "Identifier" && x.expression.left.object.object.value === `${pluginId}_default` && x.expression.left.property.type === "Identifier" && x.expression.left.property.value.length === 2 ) .map((x) => x.span); } /** * replace all called fns with strings, so that they * aren't resolved in the wrong context (when plugin * is building) * Important so that closures are preserved (eg. when * pageFn is a called fn with closure vars) */ class FnReplacer extends Visitor { public callExpressionSpans: Span[] = []; // hack to get around span not starting at 0 in swc (last tested in v1.2.62) constructor(private offsetHack) { super(); } visitCallExpression(c: CallExpression) { // console.log(c); // return c; // hack to get around span not starting at 0 in swc (last tested in v1.2.62) const start = c.span.start - this.offsetHack; const end = c.span.end - this.offsetHack; this.callExpressionSpans.push({ start, end, ctxt: c.span.ctxt }); return c; } /** * Need to rescue PluginBase, ExtensionUtil... calls from being optimized out * @param n * @returns */ visitMemberExpression(n: MemberExpression): Expression { if ( n.object.type === "MemberExpression" && n.object.object.type == "Identifier" && (n.object.object.value === "PluginBase" || n.object.object.value === "ExtensionUtil") ) { // // @ts-ignore // console.log("pushing member expression", n.property.value); this.callExpressionSpans.push({ start: n.span.start - this.offsetHack, end: n.span.end - this.offsetHack, ctxt: n.span.ctxt, }); } return n; } } function getPluginSpan(pluginId: string, body: ModuleItem[]): Span { return body.find( (x) => x.type === "VariableDeclaration" && x.declarations[0].type === "VariableDeclarator" && x.declarations[0].id.type === "Identifier" && x.declarations[0].id.value === `${pluginId}_default` && x.declarations[0].init && x.declarations[0].init.type == "ObjectExpression" // @ts-ignore )!.declarations[0].init.span; } function versionConvertDots(v) { return v.replace(/\./g, "-"); } export function transformJSToPlugin( pluginId: string, globbedTs: string[], outdir: string, prod: boolean, baseImports: boolean, define: {} ) { const pluginWLanguageFiles = globbedTs .map((f) => f.replace(/^src\//, "dist/tmp/").replace(/.ts$/, ".js")) .filter((x) => x.substring(x.lastIndexOf("/")).includes(`/${pluginId}.`)) .sort((a, b) => a.length - b.length); const resolveDir = pluginWLanguageFiles[0].substring( 0, pluginWLanguageFiles[0].lastIndexOf("/") ); return makePlugin( pluginId, pluginWLanguageFiles, resolveDir, prod, baseImports, define ) .then((res) => { const version = versionConvertDots(res[1]); return Promise.all( res[0].map((c, i) => c ? fs.writeFile( `${join(outdir, pluginId)}.${version}.${PLANS[i]}.ls`, c, "utf8" ) : undefined ) ); }) .catch((e: any) => { console.error(e, `Error building ${pluginId}`); throw e; }); } /** * * The enclosing parenthesis are something stripped in evaluation, * so we don't look for them in this regex. * * "s" flag is so dot can match newlines as well */ const ARTIFACT_REGX = new RegExp( `${escapeRegex(FN_ESCAPE_PREFIX.substring(1))}(.*?)${escapeRegex( FN_ESCAPE_SUFFIX.substring(0, FN_ESCAPE_SUFFIX.length - 1) )}`, "gs" ); function removeReplacedCallArtifacts(s: string): string { return s .replace(ARTIFACT_REGX, "$1") .replace(COMMENT_ENDER_PLACEHOLDER_REGX, "*/"); } async function makePlugin( pluginId: string, pluginWLanguageFiles: string[], resolveDir: string, prod = false, baseImports = true, define = {} ): Promise<[pluginForSub: PluginSub, version: string]> { let buildRes; try { const importPluginDepsCode = [ ...pluginWLanguageFiles.map((x, i) => { const name = x.substring(x.lastIndexOf("/") + 1, x.length - 3); return i === 0 ? `import plugin from "./${name}";` : `import "./${name}";`; }), `export default plugin;`, ].join("\n"); const dumbySrcName = `dumby`; // bundle the deps buildRes = await build({ stdin: { contents: importPluginDepsCode, sourcefile: `${dumbySrcName}.js`, resolveDir, loader: "js", }, // charset: "utf8", format: "esm", write: false, bundle: true, incremental: true, define, minifyWhitespace: true, // does not minify syntax because missing comments cause offset issues with swc }); const resolvedPluginCode = <string>buildRes.outputFiles[0].text .replace("...PluginBase", "...PluginBase, ...{languages: {}}") // needed because ES build does not escape unicode characters in regex literals .replace(/[^\x00-\x7F]/g, (x) => { const escaped = escape(x); if (escaped.startsWith("%u")) return `\\${escaped.substring(1)}`; return `\\x${escaped.substring(1)}`; }); const byPlanAndMatching = { [FREE_PLAN]: {}, [PLUS_PLAN]: {}, [PREMIUM_PLAN]: {}, }; const ast = await parse(resolvedPluginCode, { syntax: "ecmascript", dynamicImport: true, }); let { start: pluginSrcReplacementStartI, end: pluginSrcReplacementEndI } = getPluginSpan(pluginId, ast.body); // hack to get around span not starting at 0 in swc (last tested in v1.2.60) pluginSrcReplacementEndI = pluginSrcReplacementEndI - ast.span.start; pluginSrcReplacementStartI = pluginSrcReplacementStartI - ast.span.start; // assume languages come after plugin definition (otherwise pluginSrcReplacementStartI would need to be adjusted) const languageObjsRemovedCode = replaceSpans( resolvedPluginCode, getLanguageDefSpans(pluginId, ast.body) ); // console.time("escape fn calls"); const fnReplacer = new FnReplacer(ast.span.start); fnReplacer.visitProgram(ast); const noFnCallsCode = replaceSpans( resolvedPluginCode, fnReplacer.callExpressionSpans, (code) => `${FN_ESCAPE_PREFIX}${code.replace( COMMENT_ENDER_REGEX, COMMENT_ENDER_PLACEHOLDER )}${FN_ESCAPE_SUFFIX}` ); // console.log(noFnCallsCode); // console.timeEnd("escape fn calls"); let parsedPluginObj: IPlugin; try { parsedPluginObj = await evalPlugin( `var ${FN_ESCAPE_TAG};${noFnCallsCode}` ); } catch (e) { throw new Error(`Error evaluating ${e}\n\ncode: ${noFnCallsCode}`); } const version = parsedPluginObj.version || "1.0.0"; const exportRegx = new RegExp( `var\\s*${dumbySrcName}_default\\s*=\\s*${pluginId}_default;\\s*export\\s*{\\s*${dumbySrcName}_default\\s+as\\s+default\\s*};?` ); for (const plan of PLANS) { let type: PluginPartType; for (type of Object.values<PluginPartType>(<any>PluginPartType).filter( (x) => !isNaN(Number(x)) )) { const cloned: IPlugin = clone(parsedPluginObj, false); let code: string; try { code = `${languageObjsRemovedCode.substring( 0, pluginSrcReplacementStartI )}{...PluginBase, ...${removeReplacedCallArtifacts( uneval(makeCS(cloned, plan, type)) )}}${languageObjsRemovedCode.substring(pluginSrcReplacementEndI)}`; } catch (e) { if (e instanceof BlankPartError) code = ""; else throw new Error(`Error transforming ${pluginId}.${plan} ${e}`); } /** * Might look like this for production build: * var t = { ... }, * l = t; * var randomStuff; * function randomFn() { .. } * export { * l as * default */ if (code && !exportRegx.test(code)) { throw new Error(`Could not find the export regex in code.`); } byPlanAndMatching[plan][type] = code ? `window.allPlugins.${pluginId} = (() => { ${code.replace( exportRegx, "" )} return ${pluginId}_default; })();` : ""; } } // make backend const backend = `${resolvedPluginCode.substring( 0, pluginSrcReplacementStartI )}${removeReplacedCallArtifacts( uneval(makeBackend(parsedPluginObj, prod)) )}`.replace(new RegExp(`var ${pluginId}_default\s*=`), "export default "); const transformedPluginsTuple = [ backend, ...PLANS.reduce( (memo, p) => memo.concat([ byPlanAndMatching[p][PluginPartType.matching], byPlanAndMatching[p][PluginPartType.nonmatching], ]), <string[]>[] ), ] as PluginSpreadParts; // Only for minifying and treeshaking const builtParts = await Promise.all( transformedPluginsTuple.map((code, i) => // it would put in the allPlugins.${pluginId} = ... code if we build with code="" code ? build({ // entryPoints: , // outdir: options.outDir, stdin: { contents: code, sourcefile: `${pluginId}.js`, resolveDir, loader: "js", }, charset: "utf8", write: false, bundle: true, // for iife // format: "iife", // globalName: `allPlugins.${pluginId}`, format: "esm", treeShaking: true, // handles minifyWhitespace, minifyIdentifiers, and minifySyntax minify: !BEAUTIFY && prod, pure: prod ? PURE_FUNCS : [], // incremental: true, // defaults to esNext (we build to the target with tsc) target: "es2019", }).catch((e) => { console.log(`Error building ${i}\n${e}`); throw e; }) : Promise.resolve({ outputFiles: [{ text: "" }] }) ) ); const splitPluginTuple = builtParts.map((f) => f ? f.outputFiles[0].text : f ); let baseImportsStr = ""; if (baseImports) { baseImportsStr = importPluginBase + importExtensionUtil; } const finalPluginsTuple: Partial<PluginSub> = []; // combine the files into .ls file for (let i = 0; i < PLANS.length; i++) { const matchingNonMatching = splitPluginTuple.slice( i * 2 + 1, (1 + i) * 2 + 1 ); if ( PLANS[i] !== FREE_PLAN && // @ts-ignore matchingNonMatching.reduce((memo, x) => memo + x.length, 0) === 0 ) // no plugin for this level finalPluginsTuple.push(""); else finalPluginsTuple.push( [baseImportsStr + splitPluginTuple[0], ...matchingNonMatching].join( PLUGIN_SPLIT_SEQ ) ); } return [<PluginSub>finalPluginsTuple, version]; } catch (e) { console.error(e); throw e; } finally { // cleanup buildRes.rebuild?.dispose(); } } function uneval(l: any): string { switch (true) { // case l instanceof Function: // (does not work for async functions) case typeof l === "function": /** * Handle named functions. * e.g. * { * init() { ... } * } */ const stringified: string = l.toString(); const name = l.name; return name && new RegExp(`^(async )?${name}`).test(stringified) ? stringified.replace(name, "function") : stringified.toString(); /** * instanceof RegExp breaks when coming from different window */ case Object.prototype.toString.call(l) == "[object RegExp]": // hmm, this might break if there's a new RegExp line with slashes or newlines? return `/${l.source}/${l.flags}`; case Array.isArray(l): return `[${l.map((item) => uneval(item)).join(",")}]`; case l === Object(l): return `{${Object.keys(l) // JSON.stringify properly escapes the quotes .map((k) => `${JSON.stringify(k)}: ${uneval(l[k])}`) .join(",")}}`; // instanceof String doesn't work case typeof l === "string": // JSON.stringify properly escapes the quotes return JSON.stringify(l); default: return l; } }