diff --git a/app/models/tutorial.server.ts b/app/models/tutorial.server.ts index b5014af..f649700 100644 --- a/app/models/tutorial.server.ts +++ b/app/models/tutorial.server.ts @@ -1,11 +1,18 @@ import type { GitHub } from "~/services/github"; import type { Attributes } from "~/services/markdown"; +import * as semver from "semver"; import { z } from "zod"; import { AttributesSchema, Markdown } from "~/services/markdown"; import { isEmpty } from "~/utils/arrays"; +interface Recommendation { + title: string; + tag: string; + slug: string; +} + export class Tutorial { private constructor( public readonly slug: string, @@ -34,6 +41,36 @@ export class Tutorial { }; } + async recommendations({ gh, kv }: { gh: GitHub; kv: KVNamespace }) { + let list = await Tutorial.list({ gh, kv }); + + if (isEmpty(list)) return []; + + // Remove the current tutorial from the list of tutorials + list = list.filter((item) => !item.slug.includes(this.slug)); + + let result: Recommendation[] = []; + + for (let item of list) { + for (let tag of shuffle(this.tags)) { + let { name, version } = getPackageNameAndVersion(tag); + + let match = shuffle(item.tags).find((itemTag) => { + let { name: itemName, version: itemVersion } = + getPackageNameAndVersion(itemTag); + if (itemName !== name) return false; + return semver.gte(version, itemVersion); + }); + + if (match) { + result.push({ title: item.title, tag: match, slug: item.slug }); + } + } + } + + return shuffle(dedupeBySlug(result)).slice(0, 3); + } + static async list( { gh, kv }: { gh: GitHub; kv: KVNamespace }, query?: string, @@ -127,3 +164,40 @@ export class Tutorial { return `tutorials/${slug}.md`; } } + +function shuffle(list: Value[]) { + let result = [...list]; + + for (let i = result.length - 1; i > 0; i--) { + let j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + + return result; +} + +function getPackageNameAndVersion(tag: string) { + if (!tag.startsWith("@")) { + let [name, version] = tag.split("@"); + return { name, version }; + } + + let [, name, version] = tag.split("@"); + return { name: `@${name}`, version }; +} + +function dedupeBySlug(items: Recommendation[]): Recommendation[] { + let result: Recommendation[] = []; + + for (let item of items) { + if (!result.find((resultItem) => resultItem.slug === item.slug)) { + result.push(item); + + if (result.length >= 3) break; + + continue; + } + } + + return result; +} diff --git a/app/routes/tutorials_.$slug.tsx b/app/routes/tutorials_.$slug.tsx index c36c49f..5f2c116 100644 --- a/app/routes/tutorials_.$slug.tsx +++ b/app/routes/tutorials_.$slug.tsx @@ -50,7 +50,10 @@ export async function loader(_: DataFunctionArgs) { title: tutorial.title, content: tutorial.body, }, - recommendations: Promise.resolve([]), + recommendations: tutorial.recommendations({ + gh, + kv: _.context.kv.tutorials, + }), meta: getMeta(), });