Official GitHub Plugin Builder

Setting

Value

Name

Github Plugin Builder

Setting

Default GitHub repo

Description

For professional developers aspiring to create Amplenote’s greatest new plugin, it is common to want to sync GitHub code with a plugin body. This plugin allows you to define an "entry file" in a public GitHub repo, and then have the main block from that entry file imported into a plugin note.

Better yet, you can import functions from other files and have those functions be inlined in the code that gets inserted into your plugin note. No more 1,500 line single files necessary. See "caveats" for about use of import.

Instructions

To use GitHub plugin builder, you need to add the path to your public GitHub repo to a plugin note. The repo URL needs to be preceded by "repo:" or "entry:"

repo: https://github.com/yourorganization/yourrepo
entry: https://github.com/youroganization/yourrepo/path/to/entryfile.js

If the repo: directive is used, we will check for your plugin's entry file in three locations: lib/plugin.js, plugin.js, and index.js. After discovering the entry file, we will parse its code block and import statements. The functions that exist within the imported modules are collected and inserted into the entry file's code block.

The repo or entry declaration can be placed anywhere in your plugin file. Once you have installed the plugin and added the specified line to a plugin note, you can invoke the plugin by either using the expression "{Github Plugin Builder: Refresh}" or by choosing "GitHub Plugin Builder: Refresh" from the "Note options" menu in the upper-right corner of a note.

Caveats
Namespaces are not supported -- all of the imported files have to declare functions at the root level.

Known issues
1. If a function is specified with the const func = (variable) => { syntax, you must use parenthesis around your parameter


Repo: https://github.com/alloy-org/plugin-builder


To improve:


linkCode block

// Javascript updated 7/14/2023, 10:22:39 AM by Amplenote Plugin Builder from source code within "https://github.com/alloy-org/plugin-builder"
{
//----------------------------------------------------------------------
_constants: {
defaultBranch: "main",
codeHeading: "Code block",
entryLocations: [ "lib/plugin.js", "plugin.js", "index.js" ],
maxReplaceContentLength: 100000,
},
 
//----------------------------------------------------------------------
insertText: {
"Refresh": {
check: async function(app) {
return !!(await this._githubRepoUrl(app, { quietFail: true }));
},
run: async function(app) {
const githubUrl = await this._githubRepoUrl(app);
if (githubUrl) {
await this._syncUrlToNote(app, githubUrl);
} else {
app.alert(`Could not find a line beginning in "repo:" or "entry:" in the note.`);
}
}
},
"Sync": {
check: async function(app) {
const boundCheck = this.insertText["Refresh"].check.bind(this);
return await boundCheck(app);
},
run: async function(app) {
const boundRun = this.insertText["Refresh"].run.bind(this);
return await boundRun(app);
}
}
},
 
//----------------------------------------------------------------------
noteOption: {
"Refresh": {
check: async function(app) {
const boundCheck = this.insertText["Refresh"].check.bind(this);
return await boundCheck(app);
},
run: async function(app) {
const boundRun = this.insertText["Refresh"].run.bind(this);
return await boundRun(app);
}
}
},
 
//----------------------------------------------------------------------
async _syncUrlToNote(app, repoUrl) {
const entryPoint = await this._entryPointFromUrl(app, repoUrl);
if (entryPoint.url) {
const note = await app.notes.find(app.context.noteUUID);
let noteContent = await note.content();
if (!(await this._isAbleToSync(app, noteContent))) {
return null;
}
 
if (!entryPoint.content) {
console.error("Could not find a valid entry point in repo", repoUrl, "at", entryPoint.url);
return null;
}
const mainPluginBlock = entryPoint.content.match(/.*(\{\n[\S\s]*\n\})/)?.at(1);
const functionTranslations = [];
let newPluginBlock = await this._inlined_plugin_import_inliner_js_inlineImportsFromGithub(entryPoint, mainPluginBlock, functionTranslations);
if (newPluginBlock) {
if (newPluginBlock.length > this._constants.maxReplaceContentLength) {
app.alert(`The code block (length ${ newPluginBlock.length }) is too long to replace (max size ${ this._constants.maxReplaceContentLength }).` +
`Please manually replace the code block in the note, or email support@amplenote.com to request an increase in the size of replaceContent.`)
} else {
newPluginBlock = `\`\`\`\n// Javascript updated ${ (new Date()).toLocaleString() } by Amplenote Plugin Builder from source code within "${ repoUrl }"\n${ newPluginBlock }\n\`\`\``;
const replaceTarget = this._sectionFromHeadingText(this._constants.codeHeading);
await note.replaceContent(newPluginBlock, replaceTarget);
await app.alert(`🎉 Plugin refresh from "${ repoUrl }" succeeded at ${ (new Date()).toLocaleString() }`);
}
} else {
app.alert("Could not construct a code block from the entry point URL. Please check the console for more details.")
return null;
}
}
},
 
//----------------------------------------------------------------------
_sectionFromHeadingText(headingText, { level = 1 } = {}) {
return { section: { heading: { text: headingText, level }}};
},
 
//----------------------------------------------------------------------
async _isAbleToSync(app, noteContent) {
if (noteContent.includes(this._constants.codeHeading)) {
return true;
} else {
if (/^```/m.test(noteContent)) {
await app.alert(this._noSyncMessage());
return false;
} else {
console.log("Adding code block heading to note");
const note = await app.notes.find(app.context.noteUUID);
await note.insertContent(`\n\n# ${ this._constants.codeHeading }\n\n`, { atEnd: true });
return true;
}
}
},
 
//----------------------------------------------------------------------
_noSyncMessage() {
return `Could not sync plugin because the note already contains code but no code block heading. Please add ` +
`an h1 heading labeled "${ this._constants.codeHeading }" above your code block and try again.\n\nOr you can just delete` +
`the code block and run the plugin again to re-create it with a heading.`
},
 
//----------------------------------------------------------------------
async _githubRepoUrl(app, { quietFail = false } = {}) {
const noteContent = await app.getNoteContent({ uuid: app.context.noteUUID });
const urlRegex = /^\s*(entry|repo)\s*[=:]\s*(https:\/\/github.com\/)?(?[\w\-_.]+)\/(?[\w\-_.]+)\/?(?[\w\-_.\/]+\.(ts|js))?(?:$|\n|\r)/im;
const match = noteContent.match(urlRegex);
if (match?.groups?.organizationSlug && match?.groups?.repoSlug) {
return `https://github.com/${ match.groups.organizationSlug }/${ match.groups.repoSlug }${ match.groups.entryFile ? `/${ match.groups.entryFile }` : "" }`
 
} else {
if (!quietFail) {
await app.alert("Could not find a repo URL in the note. Please include a line that begins with 'repo:' and has the URL of repo to sync");
}
return null;
}
},
 
//----------------------------------------------------------------------
/** Details about the entry point for this repo
* @param {string} app
* @param {string} repoOrFileUrl - URL to a Github repo or a file in a Github repo
* @returns {object} - { content: string, url: string }
*/
async _entryPointFromUrl(app, repoOrFileUrl) {
if (!repoOrFileUrl) {
throw new Error("Missing repoUrl");
}
 
let content, url;
if (/\.(js|ts)$/.test(repoOrFileUrl)) {
let path = repoOrFileUrl.replace("https://github.com/", "");
const components = path.split("/");
if (components.length >= 3) {
url = `https://github.com/${ components[0] }/${ components[1] }/blob/${ this._constants.defaultBranch }/${ components.slice(2).join("/") }`;
content = await this._inlined_plugin_import_inliner_js_fileContentFromUrl(url);
if (!content) {
app.alert(`Could not find a valid Github file at the entry point URL "${ url }" (derived from "${ repoOrFileUrl }")`);
url = null;
}
} else {
// Perhaps the user is using a non-standard branch name? We might want to make that configurable?
app.alert(`Could not parse a valid Github file at "${ repoOrFileUrl }"`);
}
} else {
for (const entryLocation of this._constants.entryLocations) {
url = `${ repoOrFileUrl }/blob/${ this._constants.defaultBranch }/${ entryLocation }`;
content = await this._inlined_plugin_import_inliner_js_fileContentFromUrl(url);
if (content) {
break;
} else {
url = null;
}
}
 
if (!url) {
app.alert(`Could not find any entry point file in the given repo "${ repoOrFileUrl }". Please add a "plugin.js" file to the repo, or specify the location of your entry file with the "entry:" directive. \n\nSee plugin instructions for more detail.`)
}
}
 
return { content, url };
},
 
 
async _inlined_plugin_import_inliner_js_inlineImportsFromGithub(entryPoint, codeBlockString, functionTranslations) {
const { content, url } = entryPoint;
if (!content) return null;
 
const extension = url.split(".").pop();
const importUrls = this._inlined_plugin_import_inliner_js_importUrlsFromContent(content, extension, url);
 
if (!importUrls.length) {
console.log("No import URLs found in", url);
return codeBlockString;
}
 
// Ensure that final closing brace in the object is followed by a comma so we can add more after it
const codeWithoutFinalBrace = codeBlockString.substring(0, codeBlockString.lastIndexOf("}"));
const finalBrace = codeWithoutFinalBrace.lastIndexOf("}");
if (finalBrace === -1) throw new Error("Could not find any functions in code block");
if (codeBlockString[finalBrace + 1] !== ",") {
codeBlockString = codeBlockString.substring(0, finalBrace + 1) + "," + codeBlockString.substring(finalBrace + 1);
}
 
// Process each importUrl mentioned in the entryPoint.content
for (const importUrl of importUrls) {
// Returns { [functionName]: [functionCode minus leading "export"], ... }
if (functionTranslations.find(translation => translation.importUrl === importUrl)) {
console.log("Skipping", importUrl, "because it was already inlined");
continue;
}
const importFileContent = await this._inlined_plugin_import_inliner_js_fileContentFromUrl(importUrl);
if (!importFileContent) {
console.error("No file content found for", importUrl, "in", url);
continue;
}
 
const functionBlocks = await this._inlined_plugin_import_inliner_js_functionBlocksFromFileContent(importFileContent);
if (functionBlocks) {
for (let [ functionName, functionBlock ] of Object.entries(functionBlocks)) {
const definition = functionBlock.split("\n")[0];
const isAsync = /\basync\b/.test(definition);
const params = definition.match(/\(([^)]+)\)/)[1];
const urlSegments = importUrl.split("/");
const newFunctionName = `_inlined_${ urlSegments[urlSegments.length - 1].replace(/[^\w]/g, "_") }_${ functionName }`;
functionTranslations.push({ functionName, newFunctionName, importUrl });
const newDefinition = `${ isAsync ? "async " : "" }${ newFunctionName }(${ params }) {`;
let newFunctionBlock = functionBlock.replace(definition, newDefinition).split("\n").map(line => ` ${ line }`).join("\n");
newFunctionBlock = `\n ${ newFunctionBlock.trim() }${ newFunctionBlock.trim().endsWith(",") ? "" : "," }\n`;
codeBlockString = codeBlockString.replaceAll(`${ functionName }(`, `this.${ newFunctionName }(`)
const endBracket = codeBlockString.lastIndexOf("}");
codeBlockString = codeBlockString.substring(0, endBracket) + newFunctionBlock + codeBlockString.substring(endBracket);
}
}
 
// If the function we're inlining mentioned another function that was inlined, ensure we update those calls
functionTranslations.forEach(translation => {
// The (?
// names preceded by an underscore (as our new function names are)
const replaceFunctionRegex = new RegExp(`(?${ translation.functionName }\\(`, "g");
codeBlockString = codeBlockString.replace(replaceFunctionRegex, `this.${ translation.newFunctionName }(`);
});
 
// Recurse to check if entryPoint.content has imports of its own to inline
codeBlockString = await this._inlined_plugin_import_inliner_js_inlineImportsFromGithub({ url: importUrl, content: importFileContent }, codeBlockString, functionTranslations);
}
 
return codeBlockString;
},
 
async _inlined_plugin_import_inliner_js_fetchWithRetry(url, { retries = 2, gracefulFail = false } = {}) {
const timeoutSeconds = 30; // this._constants.requestTimeoutSeconds;
let error;
const apiURL = new URL("https://plugins.amplenote.com/cors-proxy");
apiURL.searchParams.set("apiurl", url);
 
for (let i = 0; i < retries; i++) {
try {
return await Promise.race([
fetch(apiURL, {
cache: "no-store",
method: "GET",
headers: { "Content-Type": "text/plain" },
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutSeconds * 1000)
)
]);
} catch (e) {
if (gracefulFail) {
console.log(`Failed to grab ${ url }`, e, `at ${ new Date() }. Oh well, moving on...`);
} else {
error = e;
console.error(`Fetch attempt ${ i + 1 } failed with`, e, `at ${ new Date() }. Retrying...`);
}
}
}
 
return null;
},
 
async _inlined_plugin_import_inliner_js_functionBlocksFromFileContent(fileContent) {
let result = {};
const functionRegex = /^(?:export\s+)?((?:async\s+)?function\s+(?[^\s\(]+)\s*\(|(?:const|let)\s+(?[^\s=]+)\s*=\s*(?:async)?\s*(?:\(\)|\((?[^)]+)\))\s*=>)/gm;
const functionCodeDeclarations = Array.from(fileContent.matchAll(functionRegex));
for (const functionDeclarationMatch of functionCodeDeclarations) {
if (Number.isInteger(functionDeclarationMatch?.index)) {
const functionStartIndex = functionDeclarationMatch.index;
const remainingContent = fileContent.substring(functionStartIndex);
const endMatch = remainingContent.match(/^}\)?;?\s*(\n|$)/m);
 
if (endMatch?.index) {
const functionEndIndex = functionStartIndex + endMatch.index + endMatch[0].length;
const functionBlock = fileContent.substring(functionStartIndex, functionEndIndex);
const functionName = functionDeclarationMatch.groups?.functionName || functionDeclarationMatch.groups?.variableName;
const newFunctionBlock = functionBlock.replace(/export\s+/, "");
console.log("Got object block length", newFunctionBlock?.length, "for", functionName);
result[functionName] = newFunctionBlock;
}
}
}
 
return result;
},
 
async _inlined_plugin_import_inliner_js_fileContentFromUrl(url) {
let fileContent;
const moduleFetchResponse = await this._inlined_plugin_import_inliner_js_fetchWithRetry(url, { retries: 1, gracefulFail: true });
if (moduleFetchResponse?.ok && (fileContent = await moduleFetchResponse.text())) {
const json = JSON.parse(fileContent);
const lines = json.payload.blob.rawLines;
fileContent = lines.join("\n");
return fileContent;
} else {
console.log("Failed to fetch", url, "with", moduleFetchResponse);
return null;
}
},
 
_inlined_plugin_import_inliner_js_importUrlsFromContent(content, extension, contentFileUrl) {
let match;
const importUrls = [];
const importRegex = /import\s+\{\s*([^}]+)\s*}\s+from\s+['"]([^'"]+)['"]/g;
 
while ((match = importRegex.exec(content)) !== null) {
let importUrl = match[2];
if (importUrl.startsWith("./")) {
// Grab all of the URL up to the file, which will be replaced by the file we're importing
importUrl = `${ contentFileUrl.split("/").slice(0, -1).join("/") }/${ importUrl.replace("./", "") }`;
} else {
// slice(0, 7) is the URL up through the branch e.g., https://github.com/alloy-org/plugin-builder/blob/main
const baseUrl = contentFileUrl.split("/").slice(0, 7).join("/");
importUrl = `${ baseUrl }/${ importUrl }`;
}
if (!/\.[jt]s$/.test(importUrl)) {
importUrl += `.${ extension }`;
}
importUrls.push(importUrl);
}
return importUrls;
},
}

linkVersion history

July 13, 2023. Allow recursive import processing, fix initially busted functionality of entry:. Fix raw files being cached for 5 mins, leading to unreliable output.

July 12, 2023. Initial launch.