Skip to content

Instantly share code, notes, and snippets.

@BeSpunky
Created May 5, 2023 21:17
Show Gist options
  • Save BeSpunky/ff5dcb62887cbee686dd6c3ba31cabb5 to your computer and use it in GitHub Desktop.
Save BeSpunky/ff5dcb62887cbee686dd6c3ba31cabb5 to your computer and use it in GitHub Desktop.
ScriptKit Script: Creates a gist of the selected script and its lib dependencies.
// Name: Gist It
// Description: Creates a gist of the selected script and its lib dependencies.
// Author: Shy Agam
// Twitter: @shyagam
// GitHub: https://github.com/BeSpunky
import '@johnlindquist/kit';
import { extractDependenciesRecoursively, toLibFilePath } from '../lib/dependency-graph';
import { scriptChoices } from '../lib/utils';
import { TsFilePath } from '../lib/types/paths.types';
import { pulseGlow, fadeIn, bounce } from '../lib/ui';
type GistFilesRecord = Record<string, { content: string }>;
// ***** Main *****
(async function()
{
const { filePath, command, description, name } = await arg({
placeholder: `Choose a script...`,
hint: 'Only .ts files are supported',
height: 600,
}, () => scriptChoices({ valueAs: s => s }));
ui(workingMd(`Gathering depdendencies for '${name}'...`), true);
// Adding '_\\' at the beggining to make the script file show at the top of the Gist
const scriptName = `_\\${ command }.ts`;
const files = {
...await produceGistFileRecord(scriptName, filePath as TsFilePath),
...await produceGistFileRecordsForDependencies(filePath as TsFilePath)
};
ui(workingMd(`Creating gist... 🔨`));
const url = await createGist(description, files);
await copy(url);
ui(doneMd(url), false);
})();
// ***** Helpers *****
async function produceGistFileRecord(scriptName: string, scriptPath: TsFilePath): Promise<GistFilesRecord>
{
return ({ [scriptName]: { content: await readFile(scriptPath, 'utf-8') } });
}
async function produceGistFileRecordsForDependencies(scriptPath: TsFilePath): Promise<GistFilesRecord>
{
const dependencies = await extractDependenciesRecoursively(scriptPath)
const files = {};
for (const dependency of dependencies)
{
// Gists show in alphabetical order. After trail and error, I found that the best way to give lib files less precedence
// is to replace'../' with '__/', then give the main script name a prefix of '_\\' (see above).
// GitHub doesn't allow slashes in file names, so we replace them with backslashes.
const libFilePath = toLibFilePath(dependency);
const libFileName = toLibFilePath(dependency, true).replace('../', '__/').replaceAll('/', '\\');
Object.assign(files, await produceGistFileRecord(libFileName, libFilePath));
}
return files;
}
// Gist creation based on Gregor Martynus's script: https://github.com/johnlindquist/kit/discussions/266
async function createGist(description: string, files: GistFilesRecord): Promise<string>
{
const { Octokit } = await import('scriptkit-octokit');
const octokit = new Octokit({ auth: { scopes: ["gist"] } });
try
{
const { data } = await octokit.rest.gists.create({
description: `ScriptKit Script: ${ description }`,
public: true,
files
});
return data.html_url;
}
catch (e) { inspect(e); }
}
// ******* UI ********
function workingMd(text: string)
{
return `
<h1>🧑‍🍳 Cooking Gist...</h1>
<br/>
${ pulseGlow(text) }
`;
}
function doneMd(url: string)
{
return `
${fadeIn('<h1>✨ Gist created successfully! 🥳</h1>')}
<br/>
The url is in your clipboard. 📋
<br/>
[Click here to open your Gist](${ url }).
${ bounce('☝️')}
`;
}
function ui(markdown: string, loading?: boolean): void
{
if (loading !== undefined)
setRunning(loading);
div({
html: md(markdown, 'text-lg'),
footer: '✨ Gist It',
}, 'flex items-center justify-center h-full text-center');
}
import { TsFilePath } from './types/paths.types';
import { isTsFilePath } from './utils';
export type ScriptDependencyGraph = Record<TsFilePath, TsFilePath[]>;
const DbName = '_update-scripts-dependency-graph';
export class ScriptDependencyGraphManager
{
private static storage: ReturnType<typeof db<{ graph: ScriptDependencyGraph }>> extends Promise<infer T> ? T : never;
private static graph: ScriptDependencyGraph;
private static instance: ScriptDependencyGraphManager;
public static async init(): Promise<ScriptDependencyGraphManager>
{
this.storage ??= await db(DbName, { graph: {} as ScriptDependencyGraph });
this.graph ??= this.storage.graph;
this.instance ??= new ScriptDependencyGraphManager();
return this.instance;
}
public async getDependantScripts(libraryPath: TsFilePath): Promise<TsFilePath[]>
{
return ScriptDependencyGraphManager.graph[libraryPath] ?? [];
}
public async updateDependantScripts(libraryPath: TsFilePath, dependantScriptPaths: TsFilePath[], saveIt: boolean = true): Promise<ScriptDependencyGraph>
{
// Aggregate instead of overwriting the library's dependencies to make sure we don't lose any dependencies that were not discovered in the current run.
const existingUnrelatedScriptPaths = (ScriptDependencyGraphManager.graph[libraryPath] ?? []).filter(path => !dependantScriptPaths.includes(path));
ScriptDependencyGraphManager.graph[libraryPath] = [...new Set([...existingUnrelatedScriptPaths, ...dependantScriptPaths])];
saveIt && ScriptDependencyGraphManager.storage.write();
return ScriptDependencyGraphManager.graph;
}
public async rebuild(saveIt: boolean = true): Promise<ScriptDependencyGraph>
{
const scriptPaths = await getAllTsScripts();
const graph = await createDependencyGraph(scriptPaths);
ScriptDependencyGraphManager.graph = graph;
ScriptDependencyGraphManager.storage.graph = graph;
saveIt && ScriptDependencyGraphManager.storage.write();
return graph;
}
public async partialRebuild(libOrScriptPath: TsFilePath, saveIt: boolean = true): Promise<ScriptDependencyGraph>
{
const isScriptPath = (/.*\.kenv[\/\\]scripts[\/\\].*\.ts$/g.test(libOrScriptPath))
const scriptPaths = isScriptPath ? [libOrScriptPath] : await this.getDependantScripts(libOrScriptPath);
const partialGraph = await createDependencyGraph(scriptPaths);
await Promise.all(
Object.entries(partialGraph)
.map(([libraryPath, dependantScriptPaths]) => this.updateDependantScripts(libraryPath as TsFilePath, dependantScriptPaths, false))
);
saveIt && this.save();
return ScriptDependencyGraphManager.graph;
}
public async save(): Promise<void>
{
ScriptDependencyGraphManager.storage.write();
}
}
export async function getAllTsScripts(): Promise<TsFilePath[]>
{
const scripts = await readdir('./scripts');
return scripts.filter(isTsFilePath)
.map(fileName => kenvPath('scripts', fileName) as TsFilePath);
}
export async function extractImportsFromFile(path: TsFilePath): Promise<TsFilePath[]>
{
const scriptContent = await readFile(path, 'utf-8');
const libImportRegex = /import.+from ['"]\.\.\/lib\/(?<dependency>.*)['"]/gm;
const libs = [];
let match: RegExpMatchArray | null;
while ((match = libImportRegex.exec(scriptContent)) !== null)
{
// This is necessary to avoid infinite loops with zero-width matches
if (match.index === libImportRegex.lastIndex)
++libImportRegex.lastIndex;
libs.push(match.groups?.dependency);
}
return libs ?? [];
}
export function toLibFilePath(libImportExpression: string, relative: boolean = false): TsFilePath
{
return relative
? `../lib/${ libImportExpression }.ts`
: kenvPath('lib', `${ libImportExpression }.ts`) as TsFilePath;
}
export async function extractDependenciesRecoursively(path: TsFilePath, visited: Set<TsFilePath> = new Set()): Promise<TsFilePath[]>
{
if (visited.has(path)) return [];
visited.add(path);
const discoveredImports = await extractImportsFromFile(path);
if (!discoveredImports.length) return [];
const guessedFilePaths = discoveredImports.map(dep => toLibFilePath(dep));
const tsFiles = await filterNonExistingFiles(guessedFilePaths);
const dependencies = await Promise.all(tsFiles.map(tsFile => extractDependenciesRecoursively(tsFile, visited)));
return [...discoveredImports, ...dependencies.flat()];
}
export async function filterNonExistingFiles(libFilePaths: TsFilePath[])
{
const existingFiles = [];
for (const libFilePath of libFilePaths)
await isFile(libFilePath) && existingFiles.push(libFilePath);
return existingFiles;
}
export async function createDependencyGraph(scriptPaths: TsFilePath[]): Promise<ScriptDependencyGraph>
{
const dependencyGraph: ScriptDependencyGraph = {};
for (const scriptPath of scriptPaths)
{
const dependencies = await extractDependenciesRecoursively(scriptPath);
for (const dependency of dependencies)
(dependencyGraph[toLibFilePath(dependency)] ??= []).push(scriptPath);
}
return dependencyGraph;
}
/**
* Creates a string pattern that forces .extention at the end.
*/
export type FilePath<Extension extends string = string> = `${ string }.${ Extension }`;
export type HtmlFilePath = FilePath<'html' | 'htm'>;
export type JsFilePath = FilePath<'js' | 'mjs'>;
export type TsFilePath = FilePath<'ts'>;
export type ScriptFilePath = JsFilePath | TsFilePath;
export type JsonFilePath = FilePath<'json'>;
export type CssFilePath = FilePath<'css'>;
export const animate = (text: string, animation: string) => `<span class="inline-block animate-${ animation }">${ text }</span>`;
export const pulse = (text: string) => animate(text, 'pulse');
export const pulseGlow = (text: string) => animate(text, 'pulse-green-glow');
export const spin = (text: string) => animate(text, 'spin');
export const ping = (text: string) => animate(text, 'ping');
export const bounce = (text: string) => animate(text, 'bounce');
export const fadeIn = (text: string) => animate(text, 'fade-in');
import type { Choice, Script } from '@johnlindquist/kit';
import type { TsFilePath } from './types/paths.types';
export function getRandomItemFromArray<T>(array: T[]): T
{
return array[Math.floor(Math.random() * array.length)];
}
export function randomId(): number
{
return Math.round(Math.random() * 1000000000);
}
export function isTsFilePath(fileName: string): fileName is TsFilePath
{
return fileName.endsWith('.ts');
}
export function isTsScript(script: Script): script is Script & { filePath: TsFilePath }
{
return isTsFilePath(script.filePath);
}
export function ensureIsTsFilePath(path: string, message?: string): asserts path is TsFilePath
{
if (!isTsFilePath(path))
{
console.log(message ?? `🤔 '${ path }' is not a ts file. Exiting...`);
exit();
}
}
export function ensureIsWatchChangeEvent(event: string): asserts event is 'change'
{
if (event !== 'change')
{
console.log(`🤔 '${ event }' is not a watch change event. Exiting...`);
exit();
}
}
interface ScriptChoicesConfig<ValueAs extends keyof Script | ((script: Script) => any)>
{
scripts: Script[];
valueAs: ValueAs;
tsOnly: boolean;
addPreview: boolean
}
type ScriptChoice<Value extends keyof Script | ((script: Script) => any)> = Choice<
Value extends keyof Script ? Script[Value] :
Value extends (script: Script) => any ? ReturnType<Value> : never
>
export async function scriptChoices<ValueAs extends keyof Script | ((script: Script) => any)>(
{
scripts,
valueAs,
tsOnly = true,
addPreview = true
}: Partial<ScriptChoicesConfig<ValueAs>> = {}
): Promise<ScriptChoice<ValueAs>[]>
{
scripts ??= await getScripts();
const createValue = (script: Script) =>
typeof valueAs === 'function'
? valueAs(script)
: script[valueAs as keyof Script ?? 'fullPath'];
return (tsOnly ? scripts.filter(isTsScript) : scripts)
.map(script => ({
name: script.name,
description: script.description,
value: createValue(script),
preview: addPreview ? () => highlightScript(script) : undefined
}));
}
export async function highlightScript(script: Script, language: string = 'ts'): Promise<string>
{
const scriptContent = await readFile(script.filePath, 'utf-8');
const markdown = `
\`\`\`${ language }
${ scriptContent }
\`\`\`
`;
return highlight(md(markdown, 'p-0 m-0 h-full w-full'));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment