Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
# misc
.DS_Store
*.pem
nul

# debug
npm-debug.log*
Expand Down
30 changes: 7 additions & 23 deletions app/api/builds/builds.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import type { Project } from "@/lib/builds/projects";

export const ModrinthFileSchema = z.object({
url: z.string(),
Expand All @@ -19,28 +20,6 @@ export const ModrinthVersionSchema = z.object({

export type ModrinthVersion = z.infer<typeof ModrinthVersionSchema>;

export interface Project {
id: string;
name: string;
githubRepo: string;
modrinthId?: string;
}

export const PROJECTS: Project[] = [
{
id: "eternalcore",
name: "EternalCore",
githubRepo: "EternalCodeTeam/EternalCore",
modrinthId: "eternalcore",
},
{
id: "eternalcombat",
name: "EternalCombat",
githubRepo: "EternalCodeTeam/EternalCombat",
modrinthId: "eternalcombat",
},
];

async function fetchModrinthVersions(
project: Project,
types: ("release" | "beta" | "alpha")[]
Expand All @@ -50,7 +29,12 @@ async function fetchModrinthVersions(
}

try {
const res = await fetch(`https://api.modrinth.com/v2/project/${project.modrinthId}/version`);
const res = await fetch(`https://api.modrinth.com/v2/project/${project.modrinthId}/version`, {
next: {
revalidate: 300,
tags: [`modrinth-builds-${project.modrinthId}`],
},
});
if (!res.ok) {
if (res.status === 404) {
return []; // Project might not exist yet
Expand Down
65 changes: 65 additions & 0 deletions app/api/builds/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { fetchDevBuilds, fetchStableBuilds } from "@/app/api/builds/builds";
import { type BuildTab, PROJECTS } from "@/lib/builds/projects";

const BuildQuerySchema = z.object({
project: z.string().trim().min(1),
type: z.enum(["STABLE", "DEV"]).default("STABLE"),
});

export async function GET(request: Request) {
try {
const url = new URL(request.url);
const query = BuildQuerySchema.safeParse(Object.fromEntries(url.searchParams.entries()));

if (!query.success) {
return NextResponse.json({ error: "Invalid query parameters" }, { status: 400 });
}

const project = PROJECTS.find((item) => item.id === query.data.project);
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}

const versions =
query.data.type === "STABLE"
? await fetchStableBuilds(project)
: await fetchDevBuilds(project);

const builds = mapToBuildResponse(versions, query.data.type, project.modrinthId);

return NextResponse.json(builds, {
headers: {
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=86400",
},
});
} catch (error) {
console.error("[api/builds] Unexpected error", error);
return NextResponse.json({ error: "Failed to fetch builds" }, { status: 500 });
}
}

function mapToBuildResponse(
versions: Awaited<ReturnType<typeof fetchStableBuilds>>,
type: BuildTab,
modrinthId?: string
) {
return versions
.toSorted((a, b) => Date.parse(b.date_published) - Date.parse(a.date_published))
.map((version) => {
const primaryFile = version.files.find((file) => file.primary) ?? version.files[0];

return {
id: version.id,
name: version.name,
type,
date: version.date_published,
downloadUrl: primaryFile?.url ?? "",
version: version.version_number,
runUrl: modrinthId
? `https://modrinth.com/project/${modrinthId}/version/${version.id}`
: undefined,
};
});
}
122 changes: 79 additions & 43 deletions app/api/docs/search-index/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import fs from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";

import matter from "gray-matter";
import { NextResponse } from "next/server";

const MDX_EXTENSION_REGEX = /\.mdx$/;
const TITLE_SPLIT_REGEX = /[-_]/;
const CACHE_TTL_MS = 10 * 60 * 1000;

interface SearchIndexItem {
title: string;
Expand All @@ -21,6 +21,10 @@ const CATEGORY_LABELS: Record<string, string> = {
contribute: "Contribute",
};

let cache: SearchIndexItem[] | null = null;
let cacheTimestamp = 0;
let cachePromise: Promise<SearchIndexItem[]> | null = null;

function toTitleCase(value: string): string {
return value
.split(TITLE_SPLIT_REGEX)
Expand All @@ -29,59 +33,91 @@ function toTitleCase(value: string): string {
}

function getCategoryLabel(relativePath: string): string {
const topLevel = relativePath.split(path.sep)[0] ?? "docs";
const topLevel = relativePath.split("/")[0] ?? "docs";
return CATEGORY_LABELS[topLevel] ?? toTitleCase(topLevel);
}

function findMarkdownFiles(dir: string): string[] {
const files: string[] = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...findMarkdownFiles(fullPath));
} else if (entry.isFile() && entry.name.endsWith(".mdx")) {
files.push(fullPath);
}
}
async function findMarkdownFiles(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map((entry) => {
const fullPath = path.join(dir, entry.name);

if (entry.isDirectory()) {
return findMarkdownFiles(fullPath);
}

if (entry.isFile() && entry.name.endsWith(".mdx")) {
return [fullPath];
}

return files;
return [];
})
);

return files.flat();
}

function generateSearchIndex() {
async function generateSearchIndex(): Promise<SearchIndexItem[]> {
const docsDir = path.join(process.cwd(), "content/docs");
const files = findMarkdownFiles(docsDir);
const searchIndex: SearchIndexItem[] = [];

for (const file of files) {
const content = fs.readFileSync(file, "utf8");
const { data, content: markdownContent } = matter(content);
const relativePath = path.relative(docsDir, file);
const urlPath = `/docs/${relativePath.replace(MDX_EXTENSION_REGEX, "")}`;
const category = getCategoryLabel(relativePath);

const excerpt = markdownContent
.replace(/[#*`_~]/g, "")
.replace(/\n/g, " ")
.trim()
.substring(0, 150);

searchIndex.push({
title: data.title || path.basename(file, ".mdx"),
path: urlPath,
excerpt,
category,
});
}
const files = await findMarkdownFiles(docsDir);

const searchIndex = await Promise.all(
files.map(async (file) => {
const content = await fs.readFile(file, "utf8");
const { data, content: markdownContent } = matter(content);

const relativePath = path.relative(docsDir, file).split(path.sep).join("/");
const urlPath = `/docs/${relativePath.replace(MDX_EXTENSION_REGEX, "")}`;

const excerpt = markdownContent
.replace(/[#*`_~]/g, "")
.replace(/\n/g, " ")
.trim()
.substring(0, 150);

return {
title: data.title || path.basename(file, ".mdx"),
path: urlPath,
excerpt,
category: getCategoryLabel(relativePath),
};
})
);

return searchIndex;
}

export function GET() {
function getSearchIndexCached(): Promise<SearchIndexItem[]> {
const now = Date.now();

if (cache && now - cacheTimestamp < CACHE_TTL_MS) {
return Promise.resolve(cache);
}

if (!cachePromise) {
cachePromise = generateSearchIndex()
.then((result) => {
cache = result;
cacheTimestamp = Date.now();
return result;
})
.finally(() => {
cachePromise = null;
});
}

return cachePromise;
}

export async function GET() {
try {
const searchIndex = generateSearchIndex();
return NextResponse.json(searchIndex);
const searchIndex = await getSearchIndexCached();
return NextResponse.json(searchIndex, {
headers: {
"Cache-Control": "public, s-maxage=600, stale-while-revalidate=86400",
},
});
} catch (error) {
console.error("Error generating search index:", error);
return NextResponse.json({ error: "Failed to generate search index" }, { status: 500 });
Expand Down
86 changes: 83 additions & 3 deletions app/api/og/route.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,96 @@
import { ImageResponse } from "@takumi-rs/image-response";
import type { NextRequest } from "next/server";
import { z } from "zod";
import { OgTemplate } from "@/components/og/og-template";

export const runtime = "nodejs";

const DEFAULT_TITLE = "EternalCode.pl";
const DEFAULT_SUBTITLE = "Open Source Solutions";
const DEFAULT_IMAGE = "https://eternalcode.pl/logo.svg";
const SITE_ORIGIN = "https://eternalcode.pl";

const ALLOWED_IMAGE_HOSTS = new Set([
"eternalcode.pl",
"www.eternalcode.pl",
"github.com",
"avatars.githubusercontent.com",
"private-user-images.githubusercontent.com",
"i.imgur.com",
"imgur.com",
"cms.eternalcode.pl",
]);

const OG_QUERY_SCHEMA = z.object({
title: z.string().trim().min(1).max(120).optional(),
subtitle: z.string().trim().max(200).optional(),
image: z.string().trim().max(2048).optional(),
});

function sanitizeText(value: string): string {
let sanitized = "";

for (const character of value) {
const code = character.charCodeAt(0);
const isControlCode = code <= 31 || code === 127 || (code >= 128 && code <= 159);
if (!isControlCode) {
sanitized += character;
}
}

return sanitized.trim();
}
Comment thread
vLuckyyy marked this conversation as resolved.

function normalizeImageUrl(rawValue: string): string | null {
const value = rawValue.trim();
if (!value) {
return null;
}

let url: URL;

if (value.startsWith("/")) {
url = new URL(value, SITE_ORIGIN);
} else {
try {
url = new URL(value);
} catch {
return null;
}
}

if (url.protocol !== "https:") {
return null;
}

if (!ALLOWED_IMAGE_HOSTS.has(url.hostname)) {
return null;
}

return url.toString();
}

export function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const parsedQuery = OG_QUERY_SCHEMA.safeParse(Object.fromEntries(searchParams.entries()));

if (!parsedQuery.success) {
return Response.json({ error: "Invalid query parameters" }, { status: 400 });
}

const title = sanitizeText(parsedQuery.data.title ?? DEFAULT_TITLE) || DEFAULT_TITLE;
const subtitle =
sanitizeText(parsedQuery.data.subtitle ?? DEFAULT_SUBTITLE) || DEFAULT_SUBTITLE;

const title = searchParams.get("title") || "EternalCode.pl";
const subtitle = searchParams.get("subtitle") || "Open Source Solutions";
const image = searchParams.get("image") || "https://eternalcode.pl/logo.svg";
let image = DEFAULT_IMAGE;
if (parsedQuery.data.image) {
const normalizedImage = normalizeImageUrl(parsedQuery.data.image);
if (!normalizedImage) {
return Response.json({ error: "Invalid image URL" }, { status: 400 });
}
image = normalizedImage;
}

return new ImageResponse(<OgTemplate image={image} subtitle={subtitle} title={title} />, {
width: 1200,
Expand Down
Loading