Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use GitHub app #59

Merged
merged 10 commits into from
Dec 9, 2023
Merged
12 changes: 12 additions & 0 deletions app/entities/bookmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { z } from "zod";

export const BookmarkSchema = z.object({
id: z.string(),
title: z.string(),
// description: z.string(),
// comment: z.string().nullable(),
url: z.string().url(),

// Timestamps
created_at: z.string(),
});
7 changes: 7 additions & 0 deletions app/entities/date-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from "zod";

export const DateTimeSchema = z
.string()
.datetime()
.transform((value) => new Date(value))
.pipe(z.date());
63 changes: 63 additions & 0 deletions app/entities/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Scalar, Tag } from "@markdoc/markdoc";

import { parse, transform } from "@markdoc/markdoc";
import { z } from "zod";

export const TagSchema: z.ZodType<Tag> = z.object({
$$mdtype: z.literal("Tag"),
name: z.string(),
attributes: z.record(z.any()),
children: z.lazy(() => RenderableTreeNodeSchema.array()),
});

export const ScalarSchema: z.ZodType<Scalar> = z.union([
z.null(),
z.boolean(),
z.number(),
z.string(),
z.lazy(() => ScalarSchema.array()),
z.record(z.lazy(() => ScalarSchema)),
]);

export const RenderableTreeNodeSchema = z.union([TagSchema, ScalarSchema]);

export const MarkdownSchema = z
.string()
.transform((content) => {
if (content.startsWith("# ")) {
let [title, ...body] = content.split("\n");

return {
attributes: {
title: title.slice(1).trim(),
tags: [],
},
body: transform(parse(body.join("\n").trimStart())),
};
}

let [tags, ...rest] = content.split("\n");
let [title, ...body] = rest.join("\n").trim().split("\n");

return {
attributes: {
title: title.slice(1).trim(),
tags: tags
.split("#")
.map((tag) => tag.trim())
.filter(Boolean),
},
body: transform(parse(body.join("\n").trimStart())),
};
})
.pipe(
z.object({
attributes: z.object({
title: z.string().min(1).max(140),
tags: z.string().array(),
}),
body: RenderableTreeNodeSchema,
}),
);

export type Markdown = z.infer<typeof MarkdownSchema>;
52 changes: 52 additions & 0 deletions app/entities/note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { z } from "zod";

const NoteVisibilitySchema = z.union([
z.literal("public"),
z.literal("private"),
z.literal("public_unlisted"),
z.literal("public_site"),
]);

export const NoteSchema = z.object({
id: z.number(),
site_id: z.number(),
user_id: z.number(),
body: z.string(),
path: z.string(),
headline: z.string(),
title: z.string(),
created_at: z.string(),
updated_at: z.string(),
visibility: NoteVisibilitySchema.default("public"),
poster: z.string().nullable(),
curated: z.boolean(),
ordering: z.number(),
url: z.string(),
});

const NotesReorderedEventSchema = z.object({
event: z.literal("notes-reordered"),
data: z.object({ notes: NoteSchema.array() }),
});

const NoteUpdatedEventSchema = z.object({
event: z.literal("note-updated"),
data: z.object({ note: NoteSchema }),
});

const NoteCreatedEventSchema = z.object({
event: z.literal("note-created"),
data: z.object({ note: NoteSchema }),
});

const NoteDeletedEventSchema = z.object({
event: z.literal("note-deleted"),
data: z.object({ note: NoteSchema }),
});

export const NoteEventSchema = z.union([
NotesReorderedEventSchema,
NoteUpdatedEventSchema,
NoteCreatedEventSchema,
NoteDeletedEventSchema,
]);
6 changes: 6 additions & 0 deletions app/entities/semver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as semver from "semver";
import { z } from "zod";

export const SemanticVersionSchema = z
.string()
.refine((value) => semver.valid(value), { message: "INVALID_VERSION" });
10 changes: 10 additions & 0 deletions app/entities/tutorial.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from "zod";

import { RenderableTreeNodeSchema } from "~/entities/markdown";

export const TutorialSchema = z.object({
content: RenderableTreeNodeSchema,
slug: z.string(),
tags: z.string().array(),
title: z.string(),
});
243 changes: 243 additions & 0 deletions app/models/tutorial.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import type { Attributes } from "~/entities/markdown";
import type { GitHub } from "~/services/github";

import { z } from "zod";

import { AttributesSchema, Markdown } from "~/services/markdown";
import { isEmpty } from "~/utils/arrays";

export class Tutorial {
private constructor(
public readonly slug: string,
private file: Markdown,
) {}

get title() {
return this.file.attributes.title;
}

get tags() {
return this.file.attributes.tags;
}

get body() {
return this.file.body;
}

toJSON() {
return {
path: Tutorial.slugToPath(this.slug),
slug: this.slug,
title: this.title,
tags: this.tags,
body: this.body,
};
}

static async list(
{ gh, kv }: { gh: GitHub; kv: KVNamespace },
query?: string,
) {
let tutorials: Array<Attributes & { slug: string }> = [];

let list = await kv.list({ prefix: "tutorial:", limit: 1000 });

for (let key of list.keys) {
if (!key.metadata) {
console.info("Missing Metadata in Key: %s", key.name);
await kv.delete(key.name);
continue;
}

let result = AttributesSchema.extend({
slug: z.string(),
}).safeParse(key.metadata);

if (!result.success) {
console.info("Invalid Metadata in Key: %s", key.name);
await kv.delete(key.name);
continue;
}

tutorials.push(result.data);
}

if (isEmpty(tutorials)) {
console.info("Cache Miss: /tutorials");

let filePaths = await gh.listMarkdownFiles("tutorials");
for await (let filePath of filePaths) {
let slug = Tutorial.pathToSlug(filePath);
let tutorial = await Tutorial.show({ gh, kv }, slug);
tutorials.push({ slug, title: tutorial.title, tags: tutorial.tags });
}
} else console.info("Cache Hit: /tutorials");

if (query) {
tutorials.filter((tutorial) => {
for (let word of query.toLowerCase().split(" ")) {
if (tutorial.title.toLowerCase().includes(word)) return true;
}
return false;
});
}

return tutorials;
}

static async show(
{ gh, kv }: { gh: GitHub; kv: KVNamespace },
slug: string,
): Promise<Tutorial> {
let cached = await kv.get<Markdown>(Tutorial.slugToKey(slug), "json");

if (cached) {
console.info("Cache Hit: /tutorials/%s", slug);
try {
let markdown = new Markdown(cached.body, cached.attributes);
return new Tutorial(slug, markdown);
} catch {
await kv.delete(Tutorial.slugToKey(slug));
}
} else console.info("Cache Miss: /tutorials/%s", slug);

let content = await gh.fetchMarkdownFile(`tutorials/${slug}.md`);

let markdown = new Markdown(content);
let tutorial = new Tutorial(slug, markdown);

await kv.put(Tutorial.slugToKey(slug), JSON.stringify(markdown), {
metadata: { slug, tags: tutorial.tags, title: tutorial.title },
expirationTtl: 60 * 60 * 24 * 7,
});

return tutorial;
}

private static pathToSlug(path: string) {
return path.split("/").slice(2).join("/").slice(0, -3);
}

private static slugToKey(slug: string) {
return `tutorial:${slug}`;
}

private static slugToPath(slug: string) {
return `tutorials/${slug}.md`;
}
}

// export namespace _Tutorial {
// export class List {
// constructor(
// private kv: KVNamespace,
// private gh: GitHub,
// ) {}

// async perform({
// page = 1,
// size = PAGE_SIZE,
// }: { page?: number; size?: number } = {}) {
// void this.#fillTutorialsFromRepo();
// let list = await this.#list();
// return this.#paginate(list, page, size);
// }

// async #list() {
// let keys: KVNamespaceListKey<{ tags: string[]; title: string }>[] = [];

// let hasMore = true;
// while (hasMore) {
// let result = await this.kv.list<{ tags: string[]; title: string }>({
// prefix: PREFIX,
// });

// keys.push(...result.keys);
// if (result.list_complete) hasMore = false;
// }

// return z
// .object({
// slug: z
// .string()
// .transform((value) => value.split(":").at(1))
// .pipe(z.string()),
// tags: z.string().array(),
// title: z.string(),
// })
// .array()
// .parse(
// keys.map((key) => {
// return {
// slug: key.name,
// tags: key.metadata?.tags,
// title: key.metadata?.title,
// };
// }),
// );
// }

// #paginate<Item>(
// items: Item[],
// page: number,
// size: number,
// ): Paginated<Item> {
// let total = items.length;
// let last = Math.ceil(total / size);
// let first = 1;
// let next = page < last ? page + 1 : null;
// let prev = page > first ? page - 1 : null;

// return {
// items: items.slice((page - 1) * size, page * size),
// total,
// page: { size, current: page, first, next, prev, last },
// };
// }

// async #fillTutorialsFromRepo() {
// let files = await this.gh.listMarkdownFiles("tutorials");
// let read = new Read(this.kv, this.gh);
// await Promise.all(
// files.map(async (file) => {
// let tutorial = await read.perform(file);
// if (!tutorial) return;
// await this.kv.put(
// `${PREFIX}${tutorial.slug}`,
// JSON.stringify(tutorial),
// {
// expirationTtl: 60 * 60 * 24 * 7,
// metadata: { tags: tutorial.tags, title: tutorial.title },
// },
// );
// }),
// );
// }
// }

// export class Read {
// constructor(
// private kv: KVNamespace,
// private gh: GitHub,
// ) {}

// async perform(file: string) {
// let result = await this.gh.fetchMarkdownFile(file);
// let kvResult = await this.kv.get(`${PREFIX}${result.path}`, "json");
// if (!kvResult) return null;

// return TutorialSchema.parse(result);
// }
// }

// export class Save {
// constructor(private kv: KVNamespace) {}

// async save(slug: string, data: z.infer<typeof TutorialSchema>) {
// await this.kv.put(`${PREFIX}${slug}`, JSON.stringify(data), {
// expirationTtl: 60 * 60 * 24 * 7,
// metadata: { tags: data.tags, title: data.title },
// });
// }
// }
// }
Loading
Loading