Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
27 changes: 19 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,19 @@ The project uses **pnpm 11** (pinned via `packageManager` in `package.json`) and
```bash
git clone https://github.com/techmc-wiki/gtmc.git
cd gtmc
pnpm install # also runs scripts/postinstall.mjs (see below)
pnpm install # also runs scripts/postinstall.ts (see below)
cp .env.example .env # fill in GitHub OAuth, DATABASE_URL, etc.
pnpm dev # http://localhost:3000
```

`pnpm install` triggers `scripts/postinstall.mjs`, which:
`pnpm install` triggers `scripts/postinstall.ts`, which:

1. Adds `.gitconfig` to the local Git config include path.
2. Initializes the `articles/` submodule if it is missing or empty (otherwise leaves the existing checkout alone).
3. Runs `prisma generate` (with a placeholder `DATABASE_URL` if none is set, to allow client codegen offline).
4. Runs `tsx scripts/generate-article-manifest.ts` to seed `data/manifest.json`.
5. Runs `playwright install chromium` for the PDF generator and any browser tests.
2. Initializes the `articles/` and `glossary/` submodules at their pinned commits if needed.
3. Generates the glossary manifest.
4. Runs `prisma generate` (with a placeholder `DATABASE_URL` if none is set, to allow client codegen offline), unless heavy postinstall steps are explicitly skipped.
5. Runs `tsx scripts/generate-article-manifest.ts` and `tsx scripts/generate-author-profiles.ts`.
6. Runs `playwright install chromium` for the PDF generator and any browser tests.

### Environment variables

Expand Down Expand Up @@ -101,6 +102,7 @@ pnpm style:fix # prettier --write
pnpm build:content # Generate content artifacts (manifest, glossary, articles, PDF)
pnpm build:next # Next.js production build
pnpm build # Both phases: content generation then Next build
pnpm prepare:articles # Prepare articles submodule for build (pinned by default)
pnpm analyze # ANALYZE=true pnpm build (bundle analyzer)
pnpm lighthouse # Run Lighthouse CI locally (requires running server)
```
Expand All @@ -118,12 +120,21 @@ Key things to know:
pnpm articles:status # Show submodule status
pnpm articles:init # Reinitialize at the pinned commit
pnpm articles:update # Pull the latest articles commit
pnpm prepare:articles # Prepare articles for a build using GTMC_ARTICLES_SOURCE
pnpm generate:manifest # Rebuild data/manifest.json
pnpm generate:content # Re-render rendered article content
pnpm articles:pdf # Re-render the offline PDF (public/gtmc.pdf)
```

Vercel checkouts use whatever commit is pinned by this repo; to ship updated article content, commit the new submodule pointer here. **Do not mix submodule pointer updates into feature/fix commits** (see `CONTRIBUTING.md`).
Article build source is controlled by `GTMC_ARTICLES_SOURCE`:

- unset or blank: leave the current `articles/` checkout untouched; no `git submodule` command runs.
- `pinned`: run `git submodule update --init --recursive articles` to check out the commit pinned by this repo.
- `latest`: run `git submodule update --init --recursive --remote articles` before the content build, advancing to the branch configured in `.gitmodules` (currently `main`).

Vercel runs `pnpm build:vercel`, which sets `GTMC_ARTICLES_SOURCE=latest` before calling `pnpm prepare:articles`, so deployments consume the newest configured article branch. For reproducible local or CI builds, set `GTMC_ARTICLES_SOURCE=pinned`; leaving it unset preserves the checkout already prepared by `pnpm install`.

For reproducible releases, update and commit the submodule pointer here with `pnpm articles:update`. **Do not mix submodule pointer updates into feature/fix commits** (see `CONTRIBUTING.md`).

### Glossary submodule

Expand Down Expand Up @@ -197,7 +208,7 @@ Notes:

- `pnpm build` is **non-trivial** — phase 1 regenerates all content artifacts before phase 2 invokes `next build`. Allow time and disk space accordingly.
- `next.config.ts` configures `outputFileTracingIncludes` / `Excludes` so search and litematica endpoints get the right files but article binaries are not pulled into every lambda. Keep these patterns in sync if you add similar routes. (Future: glossary manifests may need similar treatment if served from dedicated API routes.)
- Vercel uses `vercel.json` to install Chromium system libraries on Amazon Linux before `pnpm install`, then runs `pnpm build` exactly as above.
- Vercel uses `vercel.json` to install Chromium system libraries on Amazon Linux before `pnpm install`, then runs `pnpm build:vercel`. That script installs Playwright Chromium, prepares the articles submodule according to `GTMC_ARTICLES_SOURCE`, deploys Prisma migrations, and runs the two-phase `pnpm build`.
- CI workflows (`.github/workflows/`):
- `build.yml` — runs on every push and PR; installs deps with `--frozen-lockfile`, generates the Prisma client with a placeholder `DATABASE_URL`, then runs `pnpm typecheck` and `pnpm build`.
- `style_and_lint.yml` — runs on pushes to `main`; runs `pnpm lint:check` and `pnpm style:check`.
Expand Down
36 changes: 8 additions & 28 deletions app/[locale]/(private)/draft/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { decodeStoredDraftFiles } from "@/lib/drafts/files"
import { notFound, redirect } from "next/navigation"
import { readFile } from "fs/promises"
import path from "path"
import { ARTICLES_PATH } from "@/lib/articles/fs"

function buildDraftEditorData(
draft: {
Expand Down Expand Up @@ -126,33 +125,14 @@ export default async function EditDraftPage({
}

async function loadContributingGuides() {
const guideTargets = [
{
id: "web",
title: "GTMC Web",
filePath: path.join(process.cwd(), "CONTRIBUTING.md"),
},
{
id: "articles",
title: "Articles",
filePath: path.join(ARTICLES_PATH, "CONTRIBUTING.md"),
},
]

const guides = await Promise.all(
guideTargets.map(async (guide) => {
try {
const content = await readFile(guide.filePath, "utf8")
return {
id: guide.id,
title: guide.title,
content,
}
} catch {
return null
}
})
)
const guides = await Promise.all([
readFile(path.join(process.cwd(), "CONTRIBUTING.md"), "utf8")
.then((content) => ({ id: "web", title: "GTMC Web", content }))
.catch(() => null),
readFile(path.join(process.cwd(), "articles", "CONTRIBUTING.md"), "utf8")
.then((content) => ({ id: "articles", title: "Articles", content }))
.catch(() => null),
])

return guides.filter(
(
Expand Down
8 changes: 5 additions & 3 deletions app/[locale]/_homepage/continue-reading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import {

export function ContinueReading() {
const t = useTranslations("Homepage")
const [bookmark] = React.useState<ReadingBookmark | null>(() =>
typeof window === "undefined" ? null : readBookmark()
)
const [bookmark, setBookmark] = React.useState<ReadingBookmark | null>(null)

React.useEffect(() => {
setBookmark(readBookmark())
}, [])

const pct = bookmark ? Math.round(bookmark.progress * 100) : 0
const progressStyle = React.useMemo(
Expand Down
2 changes: 1 addition & 1 deletion app/[locale]/_homepage/hero-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function HeroCard({

<div className="animate-fade-in border-tech-signal fill-mode-forwards flex max-w-xl flex-col gap-2 border-l-[3px] pl-3 opacity-0 [animation-delay:1.2s] [animation-duration:1s] motion-reduce:animate-none motion-reduce:opacity-100 sm:gap-4 sm:pl-5">
<span className="text-tech-main-dark/85 text-xs/relaxed sm:text-base/relaxed">
{t("heroDescription")}
{t("heroTagline")}
</span>

<span className="text-tech-main font-mono text-[0.5625rem] tracking-wider sm:text-xs">
Expand Down
37 changes: 2 additions & 35 deletions app/[locale]/pdf/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,10 @@
import * as React from "react"
import { getTranslations } from "next-intl/server"
import { DesktopNav } from "@/components/layout/desktop-nav"
import { MobileNav } from "@/components/layout/mobile-nav"
import { LanguageSwitcher } from "@/components/layout/language-switcher"
import { ThemeToggle } from "@/components/layout/theme-toggle"
import { SiteShell } from "@/components/layout/site-shell"
import { Logo } from "@/components/ui/logo"

function buildNavLinks(t: Awaited<ReturnType<typeof getTranslations<"Nav">>>) {
return [
{ href: "/articles", label: t("articles") },
{ href: "/pdf", label: "PDF" },
]
}
import { MainSiteShell } from "@/components/layout/main-site-shell"

export default async function PdfLayout({
children,
}: {
children: React.ReactNode
}) {
const t = await getTranslations("Nav")
const navLinks = buildNavLinks(t)

return (
<SiteShell
leftSlot={
<>
<Logo size="md" />
<DesktopNav navLinks={navLinks} />
</>
}
rightSlot={
<>
<MobileNav navLinks={navLinks} />
<ThemeToggle className="hidden sm:flex" />
<LanguageSwitcher className="hidden sm:flex" />
</>
}>
{children}
</SiteShell>
)
return <MainSiteShell>{children}</MainSiteShell>
}
54 changes: 37 additions & 17 deletions app/api/litematica-assets/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from "fs"
import path from "path"

const BASE_MINECRAFT_DIR = path.join(
/* turbopackIgnore: true */ process.cwd(),
process.cwd(),
"litematica-renderer",
"assets",
"minecraft"
Expand All @@ -22,12 +22,12 @@ function getCachedFilePath(targetName: string) {
return cached
}

function setCachedFilePath(targetName: string, fullPath: string) {
function setCachedFilePath(targetName: string, relativePath: string) {
if (fileCache.has(targetName)) {
fileCache.delete(targetName)
}

fileCache.set(targetName, fullPath)
fileCache.set(targetName, relativePath)

if (fileCache.size > FILE_CACHE_LIMIT) {
const oldestKey = fileCache.keys().next().value
Expand All @@ -52,6 +52,16 @@ export async function GET(
const assetPath = pathArray.join("/")
const fileName = pathArray[pathArray.length - 1]

const normalizedAssetPath = path.normalize(assetPath).replaceAll("\\", "/")
if (
normalizedAssetPath === ".." ||
normalizedAssetPath.startsWith("../") ||
normalizedAssetPath.split("/").includes("..") ||
path.isAbsolute(normalizedAssetPath)
) {
return new NextResponse("Forbidden", { status: 403 })
}

// 递归查找文件函数
const findFile = async (
dir: string,
Expand All @@ -69,46 +79,56 @@ export async function GET(
const found = await findFile(fullPath, targetName)
if (found) return found
} else if (entry.name === targetName) {
setCachedFilePath(targetName, fullPath)
return fullPath
const relativePath = path.relative(BASE_MINECRAFT_DIR, fullPath)
setCachedFilePath(targetName, relativePath)
return relativePath
}
}
/* oxlint-enable eslint/no-await-in-loop */
return null
}

let localTarget: string | null = null
let relativeTarget: string | null = null

// 允许直接以 models/block/xxx.json 或者 textures/block/xxx.png 访问
const explicitTarget = path.join(BASE_MINECRAFT_DIR, assetPath)
const explicitTarget = path.join(BASE_MINECRAFT_DIR, normalizedAssetPath)
if (fs.existsSync(explicitTarget)) {
localTarget = explicitTarget
relativeTarget = normalizedAssetPath
} else {
// 后备:旧逻辑直接查找 block/xxx 目录
const directTarget = path.join(BASE_TEXTURES_DIR, "block", assetPath)
const directTarget = path.join(
BASE_TEXTURES_DIR,
"block",
normalizedAssetPath
)
if (fs.existsSync(directTarget)) {
localTarget = directTarget
relativeTarget = path.join("textures", "block", normalizedAssetPath)
} else {
// 否则我们在整个 textures 目录中进行全局搜索
localTarget = await findFile(BASE_TEXTURES_DIR, fileName)
relativeTarget = await findFile(BASE_TEXTURES_DIR, fileName)
}
}

if (!localTarget) {
if (!relativeTarget) {
return new NextResponse("Asset Not Found", { status: 404 })
}

// 安全检查:防止路径穿越攻击
if (!localTarget.startsWith(BASE_MINECRAFT_DIR)) {
if (
path.isAbsolute(relativeTarget) ||
relativeTarget === ".." ||
relativeTarget.startsWith(".." + path.sep)
) {
return new NextResponse("Forbidden", { status: 403 })
}

try {
const fileBuffer = await fs.promises.readFile(localTarget)
const fileBuffer = await fs.promises.readFile(
path.join(BASE_MINECRAFT_DIR, relativeTarget)
)

let contentType = "image/png"
if (localTarget.endsWith(".json")) contentType = "application/json"
if (localTarget.endsWith(".mcmeta")) contentType = "application/json"
if (relativeTarget.endsWith(".json")) contentType = "application/json"
if (relativeTarget.endsWith(".mcmeta")) contentType = "application/json"

return new NextResponse(new Uint8Array(fileBuffer), {
headers: {
Expand Down
1 change: 0 additions & 1 deletion app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ if (
process.env.VERCEL = "0"
}

export const runtime = "edge"
export const alt = "Graduate Texts in Minecraft"
export const size = { width: 1200, height: 630 }
export const contentType = "image/png"
Expand Down
6 changes: 4 additions & 2 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MetadataRoute } from "next"
import { cacheLife } from "next/cache"

import { listAllIssues } from "@/lib/github"
import { getSiteUrl } from "@/lib/site-url"
Expand All @@ -12,8 +13,6 @@ import {
} from "@/lib/articles/manifest"
import { loadGlossaryManifest } from "@/lib/glossary/manifest"

export const revalidate = 3600

const STATIC_LAST_MODIFIED = new Date("2024-12-08T10:28:55.000Z")

function localizedAlternates(base: string, path: string) {
Expand Down Expand Up @@ -46,6 +45,9 @@ function flattenTree(
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
"use cache"
cacheLife("hours")

const BASE = getSiteUrl()

const staticUrls: MetadataRoute.Sitemap = [
Expand Down
2 changes: 1 addition & 1 deletion articles
Loading
Loading