Skip to content

Commit

Permalink
Allow loading scripts from GitHub Gists
Browse files Browse the repository at this point in the history
  • Loading branch information
desplesda committed Dec 4, 2024
1 parent 198593d commit 5604297
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 22 deletions.
11 changes: 10 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"jquery": "^3.6.0",
"monaco-editor": "^0.32.1",
"monaco-editor-webpack-plugin": "^7.0.1",
"popper.js": "^1.16.1"
"popper.js": "^1.16.1",
"zod": "^3.23.8"
}
}
116 changes: 116 additions & 0 deletions src/gist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { z } from "zod";

const GistFileSchema = z.object({
filename: z.string().default(""),
content: z.string(),
truncated: z.boolean().default(false),
});

const GistSchema = z.object({
files: z.record(GistFileSchema),
});

const fetchAndValidate = async <T extends z.ZodSchema>(
url: string,
schema: T,
request: RequestInit = {},
): Promise<z.output<T>> => {
const response = await fetch(url, request);
const data = await response.json();

if (response.ok === false) {
if (data && typeof data === "object") {
throw new Error("Failed to get gist: " + JSON.stringify(data));
} else {
throw new Error("Failed to get gist: Unknown error");
}
}

return await schema.parseAsync(data);
};

export const fetchGist = async (gistID: string): Promise<string> => {
const url = `https://api.github.com/gists/${gistID}`;
const gistData = await fetchAndValidate(url, GistSchema, {
method: "GET",
redirect: "follow",
headers: {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});

const files = Object.entries(gistData.files).map((f) => f[1]);

if (files.length == 0) {
throw new Error("Gist contains no files");
}

const firstFile = files[0];

if (firstFile.truncated) {
throw new Error("File is truncated");
}

if (!firstFile.content || firstFile.content.length == 0) {
throw new Error("Gist is empty");
}

return firstFile.content;

/*
const response = await
const gistData = (await response.json()) as unknown;
if (response.ok === false) {
if (gistData && typeof gistData === "object" && "message" in gistData) {
throw new Error("Failed to get gist: " + gistData["message"]);
} else {
throw new Error("Failed to get gist: Unknown error");
}
}
if (typeof gistData !== "object") {
throw new Error("Invalid gist contents: gist did not contain an object");
}
const file = Object.entries(gistData["files"])[0][1] as {
truncated: boolean;
content: string;
};
if (file.truncated) {
throw new Error("Invalid gist contents: file is truncated (too large)");
}
let fileData;
try {
fileData = JSON.parse(file.content);
} catch {
throw new Error("Invalid gist contents: file did not contain valid JSON");
}
if (!("yarn" in fileData)) {
throw new Error("Invalid gist contents: no Yarn data");
}
if (!("map" in fileData)) {
throw new Error("Invalid gist contents: no map data");
}
return {
map: fileData["map"],
yarn: fileData["yarn"],
info: {
username: gistData["owner"]["login"],
profile_link: gistData["owner"]["html_url"],
gist_link: gistData["html_url"],
title: gistData["description"],
},
};*/
};

type GistInfo = {
username: string;
profile_link: string;
gist_link: string;
title: string;
};
63 changes: 46 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { scriptStorageKey } from "./constants";
import { fetchGist } from "./gist";
import { initialContent } from "./starter-content";

// Hide the PDF button if 'pdf' is not part of the query
Expand All @@ -11,28 +12,56 @@ window.addEventListener("load", async function () {
// First, determine what content we want to load. If the url contains a
// hash, and that hash matches a key inside the initialContent data, then we
// want to laod that content.
let location = window.location.href;
let url = new URL(location);
let hashComponents = url.hash.replace(/^#/, "").split("/");

let contentName: string | undefined;
let playground: typeof import("./playground");

if (url.hash.length > 0 && initialContent[hashComponents[0]]) {
contentName = hashComponents[0];
}
const loadPlaygroundPromise = (async () => {
playground = await import("./playground");
return playground;
})();

// Wait for the playground module to finish being downloaded, and then
// import it. Once that's done, load the playground with the content that we
// selected.
const playground = await import("./playground");
const fetchContent = (async (): Promise<string> => {
let location = window.location.href;
let url = new URL(location);
let content: string | undefined;

const existingScript = window.localStorage.getItem(scriptStorageKey);
const existingScript = window.localStorage.getItem(scriptStorageKey);

if (existingScript !== null && existingScript !== "") {
await playground.load(existingScript);
} else {
await playground.loadInitialContent(contentName);
}
let hashComponents = url.hash.replace(/^#/, "").split("/");

let contentName: string | undefined;

if (url.hash.length > 0 && initialContent[hashComponents[0]]) {
contentName = hashComponents[0];
}

const gistID = url.searchParams.get("gist");
if (gistID !== null) {
try {
console.log(`Loading from Gist ${gistID}`);
return fetchGist(gistID);
} catch {
console.warn(`Failed to load from gist. Loading default content.`);
const playground = await loadPlaygroundPromise;
return playground.getInitialContent(undefined);
}
} else if (contentName) {
console.log(`Loading initial content "${contentName}"`);
const playground = await loadPlaygroundPromise;
return playground.getInitialContent(contentName);
} else if (existingScript) {
console.log(`Loading existing script from storage`);
return existingScript;
} else {
console.log(`Loading default content`);
const playground = await loadPlaygroundPromise;
return playground.getInitialContent(undefined);
}
})();

await loadPlaygroundPromise;
const content = await fetchContent;
await playground.load(content);

// Hide the loading element, which is visible before any script runs.
global.document.getElementById("loader").classList.add("d-none");
Expand Down
5 changes: 2 additions & 3 deletions src/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,8 @@ export interface IFunctionDefinition {
function: Function;
}

export function loadInitialContent(initialContentName: string = "default") {
let script = initialContent[initialContentName];
return load(script);
export function getInitialContent(initialContentName: string = "default") {
return initialContent[initialContentName];
}

export async function load(script: string) {
Expand Down

0 comments on commit 5604297

Please sign in to comment.