Created
May 5, 2023 21:17
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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'); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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'>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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