From 36d8cd66a5d2e11ea44c4b384cb184906eba0247 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:37:39 +1000 Subject: [PATCH 1/5] feat(frontend): add authentication foundation and i18n --- apps/frontend/README.md | 4 + apps/frontend/app/api/session.ts | 36 +++ apps/frontend/app/components/AppHeader.vue | 32 +++ .../app/components/auth/LoginForm.vue | 90 ++++++++ .../frontend/app/components/auth/UserMenu.vue | 43 ++++ .../app/composables/useAuthSession.ts | 71 ++++++ apps/frontend/app/middleware/auth.ts | 14 ++ apps/frontend/app/middleware/guest.ts | 8 + apps/frontend/app/pages/login.vue | 46 ++++ apps/frontend/app/plugins/session.ts | 4 + apps/frontend/app/stores/auth.ts | 48 ++++ apps/frontend/app/utils/navigation.ts | 11 + apps/frontend/i18n/locales/en.json | 23 ++ apps/frontend/i18n/locales/zh.json | 23 ++ apps/frontend/test/auth-store.test.ts | 43 ++++ apps/frontend/test/navigation.test.ts | 18 ++ apps/frontend/test/session-api.test.ts | 83 +++++++ .../frontend-internationalization.md | 206 ++++++++++++++++++ .../frontend-internationalization.md | 206 ++++++++++++++++++ docs/contributors/zensical.toml | 1 + docs/contributors/zensical.zh.toml | 1 + 21 files changed, 1011 insertions(+) create mode 100644 apps/frontend/app/api/session.ts create mode 100644 apps/frontend/app/components/auth/LoginForm.vue create mode 100644 apps/frontend/app/components/auth/UserMenu.vue create mode 100644 apps/frontend/app/composables/useAuthSession.ts create mode 100644 apps/frontend/app/middleware/auth.ts create mode 100644 apps/frontend/app/middleware/guest.ts create mode 100644 apps/frontend/app/pages/login.vue create mode 100644 apps/frontend/app/plugins/session.ts create mode 100644 apps/frontend/app/stores/auth.ts create mode 100644 apps/frontend/app/utils/navigation.ts create mode 100644 apps/frontend/test/auth-store.test.ts create mode 100644 apps/frontend/test/navigation.test.ts create mode 100644 apps/frontend/test/session-api.test.ts create mode 100644 docs/contributors/docs/en/development/frontend-internationalization.md create mode 100644 docs/contributors/docs/zh/development/frontend-internationalization.md diff --git a/apps/frontend/README.md b/apps/frontend/README.md index afa3f4d..60411d2 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -32,6 +32,10 @@ The application supports English at `/` and Simplified Chinese at `/zh`. Translations live in `i18n/locales`; keep both locale files structurally in sync when adding interface copy. +Authentication uses Django's same-origin session cookie. The Nuxt session +plugin resolves the current user during SSR, while login and logout bootstrap +and submit the required CSRF token in the browser. + ## Production Build the application for production: diff --git a/apps/frontend/app/api/session.ts b/apps/frontend/app/api/session.ts new file mode 100644 index 0000000..a414601 --- /dev/null +++ b/apps/frontend/app/api/session.ts @@ -0,0 +1,36 @@ +import type { components } from "~/api/generated/v1"; + +import type { ApiClient } from "./client"; +import { unwrapApiResponse } from "./errors"; + +export type AuthUser = components["schemas"]["UserRetrieve"]; +export type LoginCredentials = components["schemas"]["UserLoginRequest"]; + +export async function fetchCurrentUser(api: ApiClient): Promise { + const response = unwrapApiResponse(await api.GET("/v1/profiles/me/")); + return response.data; +} + +export async function loginSession( + api: ApiClient, + credentials: LoginCredentials, + csrfToken: string +): Promise { + unwrapApiResponse( + await api.POST("/v1/sessions/login/", { + body: credentials, + params: { header: { "X-CSRFToken": csrfToken } }, + }) + ); +} + +export async function logoutSession( + api: ApiClient, + csrfToken: string +): Promise { + unwrapApiResponse( + await api.POST("/v1/sessions/logout/", { + params: { header: { "X-CSRFToken": csrfToken } }, + }) + ); +} diff --git a/apps/frontend/app/components/AppHeader.vue b/apps/frontend/app/components/AppHeader.vue index ab09629..d6ef410 100644 --- a/apps/frontend/app/components/AppHeader.vue +++ b/apps/frontend/app/components/AppHeader.vue @@ -1,5 +1,21 @@ diff --git a/apps/frontend/app/components/auth/LoginForm.vue b/apps/frontend/app/components/auth/LoginForm.vue new file mode 100644 index 0000000..67c0c03 --- /dev/null +++ b/apps/frontend/app/components/auth/LoginForm.vue @@ -0,0 +1,90 @@ + + + diff --git a/apps/frontend/app/components/auth/UserMenu.vue b/apps/frontend/app/components/auth/UserMenu.vue new file mode 100644 index 0000000..c2a2567 --- /dev/null +++ b/apps/frontend/app/components/auth/UserMenu.vue @@ -0,0 +1,43 @@ + + + diff --git a/apps/frontend/app/composables/useAuthSession.ts b/apps/frontend/app/composables/useAuthSession.ts new file mode 100644 index 0000000..ac0afc2 --- /dev/null +++ b/apps/frontend/app/composables/useAuthSession.ts @@ -0,0 +1,71 @@ +import { ApiResponseError } from "~/api/errors"; +import { fetchCurrentUser, loginSession, logoutSession } from "~/api/session"; +import type { LoginCredentials } from "~/api/session"; + +function isUnauthenticated(error: unknown): boolean { + return ( + error instanceof ApiResponseError && + (error.status === 401 || error.status === 403) + ); +} + +export function useAuthSession() { + const api = useApi(); + const store = useAuthStore(); + const { isAuthenticated, status, user } = storeToRefs(store); + + async function refresh(): Promise { + store.setLoading(); + try { + store.setAuthenticated(await fetchCurrentUser(api)); + } catch (error) { + if (isUnauthenticated(error)) { + store.setAnonymous(); + return; + } + + store.setError(); + } + } + + async function initialize(): Promise { + if (store.status !== "idle") { + return; + } + await refresh(); + } + + async function login(credentials: LoginCredentials): Promise { + const csrfToken = await ensureCsrfToken(); + await loginSession(api, credentials, csrfToken); + await refresh(); + + if (!store.isAuthenticated) { + throw new Error("The session was created without an authenticated user."); + } + } + + async function logout(): Promise { + try { + const csrfToken = await ensureCsrfToken(); + await logoutSession(api, csrfToken); + store.setAnonymous(); + } catch (error) { + if (isUnauthenticated(error)) { + store.setAnonymous(); + return; + } + throw error; + } + } + + return { + initialize, + isAuthenticated: readonly(isAuthenticated), + login, + logout, + refresh, + status: readonly(status), + user: readonly(user), + }; +} diff --git a/apps/frontend/app/middleware/auth.ts b/apps/frontend/app/middleware/auth.ts new file mode 100644 index 0000000..2c58a1a --- /dev/null +++ b/apps/frontend/app/middleware/auth.ts @@ -0,0 +1,14 @@ +export default defineNuxtRouteMiddleware((to) => { + const store = useAuthStore(); + if (store.status !== "anonymous") { + return; + } + + const localePath = useLocalePath(); + return navigateTo( + localePath({ + name: "login", + query: { redirect: to.fullPath }, + }) + ); +}); diff --git a/apps/frontend/app/middleware/guest.ts b/apps/frontend/app/middleware/guest.ts new file mode 100644 index 0000000..60a1463 --- /dev/null +++ b/apps/frontend/app/middleware/guest.ts @@ -0,0 +1,8 @@ +export default defineNuxtRouteMiddleware(() => { + const store = useAuthStore(); + if (!store.isAuthenticated) { + return; + } + + return navigateTo(useLocalePath()("index")); +}); diff --git a/apps/frontend/app/pages/login.vue b/apps/frontend/app/pages/login.vue new file mode 100644 index 0000000..d5f554f --- /dev/null +++ b/apps/frontend/app/pages/login.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/frontend/app/plugins/session.ts b/apps/frontend/app/plugins/session.ts new file mode 100644 index 0000000..0e4ade2 --- /dev/null +++ b/apps/frontend/app/plugins/session.ts @@ -0,0 +1,4 @@ +export default defineNuxtPlugin(async () => { + const { initialize } = useAuthSession(); + await initialize(); +}); diff --git a/apps/frontend/app/stores/auth.ts b/apps/frontend/app/stores/auth.ts new file mode 100644 index 0000000..0af2aa1 --- /dev/null +++ b/apps/frontend/app/stores/auth.ts @@ -0,0 +1,48 @@ +import { defineStore } from "pinia"; +import { computed, shallowRef } from "vue"; + +import type { AuthUser } from "~/api/session"; + +export type AuthStatus = + | "idle" + | "loading" + | "authenticated" + | "anonymous" + | "error"; + +export const useAuthStore = defineStore("auth", () => { + const status = shallowRef("idle"); + const user = shallowRef(); + + const isAuthenticated = computed( + () => status.value === "authenticated" && user.value !== undefined + ); + + function setLoading(): void { + status.value = "loading"; + } + + function setAuthenticated(nextUser: AuthUser): void { + user.value = nextUser; + status.value = "authenticated"; + } + + function setAnonymous(): void { + user.value = undefined; + status.value = "anonymous"; + } + + function setError(): void { + status.value = "error"; + } + + return { + isAuthenticated, + setAnonymous, + setAuthenticated, + setError, + setLoading, + status, + user, + }; +}); diff --git a/apps/frontend/app/utils/navigation.ts b/apps/frontend/app/utils/navigation.ts new file mode 100644 index 0000000..aa5e0f5 --- /dev/null +++ b/apps/frontend/app/utils/navigation.ts @@ -0,0 +1,11 @@ +export function resolveSafeRedirect( + candidate: unknown, + fallback: string +): string { + const path = Array.isArray(candidate) ? candidate[0] : candidate; + return typeof path === "string" && + path.startsWith("/") && + !path.startsWith("//") + ? path + : fallback; +} diff --git a/apps/frontend/i18n/locales/en.json b/apps/frontend/i18n/locales/en.json index 26b8978..815f596 100644 --- a/apps/frontend/i18n/locales/en.json +++ b/apps/frontend/i18n/locales/en.json @@ -2,6 +2,29 @@ "accessibility": { "skipToContent": "Skip to main content" }, + "auth": { + "login": { + "description": "Sign in with your AlienCommons account to continue.", + "email": "Email", + "eyebrow": "Welcome back", + "invalidCredentials": "The email or password is incorrect.", + "metaTitle": "Sign in", + "navigation": "Sign in", + "password": "Password", + "submit": "Sign in", + "submitting": "Signing in…", + "title": "Sign in to AlienCommons", + "unavailable": "Sign-in is temporarily unavailable. Please try again." + }, + "logout": { + "submit": "Sign out", + "submitting": "Signing out…", + "unavailable": "Sign-out failed. Please try again." + }, + "userMenu": { + "avatarAlt": "{username}'s avatar" + } + }, "error": { "genericDescription": "Something prevented this page from loading. You can return home and try again.", "genericTitle": "Something went wrong", diff --git a/apps/frontend/i18n/locales/zh.json b/apps/frontend/i18n/locales/zh.json index db2a9cf..c80b320 100644 --- a/apps/frontend/i18n/locales/zh.json +++ b/apps/frontend/i18n/locales/zh.json @@ -2,6 +2,29 @@ "accessibility": { "skipToContent": "跳到主要内容" }, + "auth": { + "login": { + "description": "使用你的 AlienCommons 账户登录以继续。", + "email": "电子邮箱", + "eyebrow": "欢迎回来", + "invalidCredentials": "邮箱或密码不正确。", + "metaTitle": "登录", + "navigation": "登录", + "password": "密码", + "submit": "登录", + "submitting": "正在登录…", + "title": "登录 AlienCommons", + "unavailable": "暂时无法登录,请稍后重试。" + }, + "logout": { + "submit": "退出登录", + "submitting": "正在退出…", + "unavailable": "退出登录失败,请重试。" + }, + "userMenu": { + "avatarAlt": "{username} 的头像" + } + }, "error": { "genericDescription": "页面加载时遇到了问题。你可以返回首页后重试。", "genericTitle": "出现了一些问题", diff --git a/apps/frontend/test/auth-store.test.ts b/apps/frontend/test/auth-store.test.ts new file mode 100644 index 0000000..83d429f --- /dev/null +++ b/apps/frontend/test/auth-store.test.ts @@ -0,0 +1,43 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import type { AuthUser } from "../app/api/session"; +import { useAuthStore } from "../app/stores/auth"; + +const user: AuthUser = { + avatar: "https://example.test/avatar.png", + date_joined: "2026-01-01T00:00:00Z", + email: "player@example.test", + id: "00000000-0000-0000-0000-000000000001", + is_moderator: false, + signature: "", + username: "Player", +}; + +describe("auth store", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it("moves from loading to an authenticated user", () => { + const store = useAuthStore(); + + store.setLoading(); + store.setAuthenticated(user); + + expect(store.status).toBe("authenticated"); + expect(store.isAuthenticated).toBe(true); + expect(store.user).toEqual(user); + }); + + it("clears user data when the session becomes anonymous", () => { + const store = useAuthStore(); + store.setAuthenticated(user); + + store.setAnonymous(); + + expect(store.status).toBe("anonymous"); + expect(store.isAuthenticated).toBe(false); + expect(store.user).toBeUndefined(); + }); +}); diff --git a/apps/frontend/test/navigation.test.ts b/apps/frontend/test/navigation.test.ts new file mode 100644 index 0000000..d1cf03f --- /dev/null +++ b/apps/frontend/test/navigation.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSafeRedirect } from "../app/utils/navigation"; + +describe("resolveSafeRedirect", () => { + it("accepts a same-origin application path", () => { + expect(resolveSafeRedirect("/zh/articles/42", "/zh")).toBe( + "/zh/articles/42" + ); + }); + + it.each(["//malicious.test", "https://malicious.test", undefined])( + "rejects unsafe redirect %s", + (candidate) => { + expect(resolveSafeRedirect(candidate, "/zh")).toBe("/zh"); + } + ); +}); diff --git a/apps/frontend/test/session-api.test.ts b/apps/frontend/test/session-api.test.ts new file mode 100644 index 0000000..6932c11 --- /dev/null +++ b/apps/frontend/test/session-api.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { createApiClient } from "../app/api/client"; +import { + fetchCurrentUser, + loginSession, + logoutSession, +} from "../app/api/session"; + +const emptyResponseData: unknown = JSON.parse("null"); + +function envelope(data: unknown): Response { + return new Response(JSON.stringify({ data }), { + headers: { "content-type": "application/json" }, + status: 200, + }); +} + +describe("session API", () => { + it("retrieves the current user", async () => { + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async () => + envelope({ + avatar: "https://example.test/avatar.png", + date_joined: "2026-01-01T00:00:00Z", + email: "player@example.test", + id: "00000000-0000-0000-0000-000000000001", + is_moderator: false, + signature: "", + username: "Player", + }), + }); + + await expect(fetchCurrentUser(api)).resolves.toMatchObject({ + username: "Player", + }); + }); + + it("sends credentials and CSRF when logging in", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope(emptyResponseData); + }, + }); + + await loginSession( + api, + { email: "player@example.test", password: "secret" }, + "csrf-token" + ); + + expect(requests[0]?.url).toBe( + "https://example.test/api/v1/sessions/login/" + ); + expect(requests[0]?.headers.get("x-csrftoken")).toBe("csrf-token"); + await expect(requests[0]?.json()).resolves.toEqual({ + email: "player@example.test", + password: "secret", + }); + }); + + it("sends CSRF when logging out", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope(emptyResponseData); + }, + }); + + await logoutSession(api, "csrf-token"); + + expect(requests[0]?.url).toBe( + "https://example.test/api/v1/sessions/logout/" + ); + expect(requests[0]?.headers.get("x-csrftoken")).toBe("csrf-token"); + }); +}); diff --git a/docs/contributors/docs/en/development/frontend-internationalization.md b/docs/contributors/docs/en/development/frontend-internationalization.md new file mode 100644 index 0000000..67c3548 --- /dev/null +++ b/docs/contributors/docs/en/development/frontend-internationalization.md @@ -0,0 +1,206 @@ +# Frontend Internationalization + +The AlienCommons frontend officially supports English and Simplified Chinese. It uses the official `@nuxtjs/i18n` module on top of Vue I18n, so routing, message lookup, language switching, server-side rendering, and localized SEO all share one configuration. + +This page describes the frontend interface localization system. User-generated articles, community posts, comments, and profile content are not translated automatically. + +## Language and Route Strategy + +The i18n configuration lives in `apps/frontend/nuxt.config.ts`. English is the default locale and Simplified Chinese is the secondary locale: + +```ts +i18n: { + defaultLocale: "en", + locales: [ + { + code: "en", + file: "en.json", + language: "en-US", + name: "English", + }, + { + code: "zh", + file: "zh.json", + language: "zh-Hans", + name: "简体中文", + }, + ], + strategy: "prefix_except_default", +} +``` + +The `prefix_except_default` strategy keeps English URLs unprefixed and adds `/zh` to Chinese URLs: + +| Page file | English URL | Chinese URL | +| --- | --- | --- | +| `app/pages/index.vue` | `/` | `/zh` | +| `app/pages/login.vue` | `/login` | `/zh/login` | + +Do not create separate English and Chinese Vue page files. Nuxt I18n generates both localized routes from the same page component. + +## Browser Language Detection + +Language detection runs only when a visitor enters at the root URL. It does not repeatedly redirect visitors while they navigate: + +```ts +detectBrowserLanguage: { + cookieKey: "aliencommons_locale", + fallbackLocale: "en", + redirectOn: "root", + useCookie: true, +} +``` + +The selected locale is remembered in the `aliencommons_locale` cookie. English is used when the browser language cannot be matched. + +## Translation Files + +Interface messages are stored in two JSON files: + +```text +apps/frontend/i18n/locales/ +├── en.json # English +└── zh.json # Simplified Chinese +``` + +Both files must have the same key structure. Group messages by feature instead of by component type: + +```json +{ + "auth": { + "login": { + "title": "Sign in to AlienCommons", + "email": "Email", + "password": "Password" + } + } +} +``` + +The matching Chinese file uses the same keys: + +```json +{ + "auth": { + "login": { + "title": "登录 AlienCommons", + "email": "电子邮箱", + "password": "密码" + } + } +} +``` + +Keep keys semantic and stable. A key such as `auth.login.submit` communicates where and why a message is used; a key such as `blueButtonText` couples translation data to presentation. + +## Using Messages in Components + +Templates can use the injected `$t` function: + +```vue +

{{ $t("auth.login.title") }}

+``` + +Use `useI18n()` when a translated value is needed in ` + + diff --git a/apps/frontend/app/components/ui/BaseInput.vue b/apps/frontend/app/components/ui/BaseInput.vue new file mode 100644 index 0000000..7a45bb2 --- /dev/null +++ b/apps/frontend/app/components/ui/BaseInput.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/frontend/app/components/ui/EmptyState.vue b/apps/frontend/app/components/ui/EmptyState.vue new file mode 100644 index 0000000..a9990e2 --- /dev/null +++ b/apps/frontend/app/components/ui/EmptyState.vue @@ -0,0 +1,28 @@ + + + diff --git a/apps/frontend/app/components/ui/FormError.vue b/apps/frontend/app/components/ui/FormError.vue new file mode 100644 index 0000000..4db4471 --- /dev/null +++ b/apps/frontend/app/components/ui/FormError.vue @@ -0,0 +1,14 @@ + + + diff --git a/apps/frontend/app/components/ui/FormField.vue b/apps/frontend/app/components/ui/FormField.vue new file mode 100644 index 0000000..bec9351 --- /dev/null +++ b/apps/frontend/app/components/ui/FormField.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/frontend/app/components/ui/LoadingSkeleton.vue b/apps/frontend/app/components/ui/LoadingSkeleton.vue new file mode 100644 index 0000000..3377578 --- /dev/null +++ b/apps/frontend/app/components/ui/LoadingSkeleton.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/frontend/app/components/ui/README.md b/apps/frontend/app/components/ui/README.md new file mode 100644 index 0000000..ed5df73 --- /dev/null +++ b/apps/frontend/app/components/ui/README.md @@ -0,0 +1,37 @@ +# UI foundations + +These components are the smallest shared presentation layer for the Nuxt app. +They contain visual and accessibility behavior, but no product or API logic. + +Nuxt auto-imports this directory with the `Ui` prefix: + +| Component | Responsibility | +| --- | --- | +| `UiBaseButton` | Button variants, sizes, disabled and loading states | +| `UiBaseInput` | Native input styling, `v-model`, invalid and disabled states | +| `UiFormField` | Label, description, error and `aria-describedby` wiring | +| `UiFormError` | Form-level alert message | +| `UiUserAvatar` | Avatar image, initials fallback and size variants | +| `UiLoadingSkeleton` | Content-shaped loading placeholder | +| `UiEmptyState` | Empty result title, description, icon and action slots | + +Use translated strings at the call site. Shared UI components must not own +feature-specific i18n keys. + +```vue + + + +``` + +Prefer these components when their existing contract fits. Extend a contract +only when at least one real feature needs the new behavior; do not add product +state, API requests, or feature-specific layout to this directory. diff --git a/apps/frontend/app/components/ui/UserAvatar.vue b/apps/frontend/app/components/ui/UserAvatar.vue new file mode 100644 index 0000000..f671a97 --- /dev/null +++ b/apps/frontend/app/components/ui/UserAvatar.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/frontend/app/utils/ui.ts b/apps/frontend/app/utils/ui.ts new file mode 100644 index 0000000..62dcf7b --- /dev/null +++ b/apps/frontend/app/utils/ui.ts @@ -0,0 +1,8 @@ +export function getAvatarInitial(name: string): string { + return name.trim().slice(0, 1).toUpperCase() || "?"; +} + +export function getSkeletonRowIds(rows: number): number[] { + const count = Number.isFinite(rows) ? Math.max(1, Math.trunc(rows)) : 1; + return Array.from({ length: count }, (_, index) => index + 1); +} diff --git a/apps/frontend/i18n/locales/en.json b/apps/frontend/i18n/locales/en.json index 815f596..5f69b90 100644 --- a/apps/frontend/i18n/locales/en.json +++ b/apps/frontend/i18n/locales/en.json @@ -21,6 +21,9 @@ "submitting": "Signing out…", "unavailable": "Sign-out failed. Please try again." }, + "session": { + "loading": "Loading account" + }, "userMenu": { "avatarAlt": "{username}'s avatar" } diff --git a/apps/frontend/i18n/locales/zh.json b/apps/frontend/i18n/locales/zh.json index c80b320..6d475d3 100644 --- a/apps/frontend/i18n/locales/zh.json +++ b/apps/frontend/i18n/locales/zh.json @@ -21,6 +21,9 @@ "submitting": "正在退出…", "unavailable": "退出登录失败,请重试。" }, + "session": { + "loading": "正在加载账户" + }, "userMenu": { "avatarAlt": "{username} 的头像" } diff --git a/apps/frontend/test/ui.test.ts b/apps/frontend/test/ui.test.ts new file mode 100644 index 0000000..876db77 --- /dev/null +++ b/apps/frontend/test/ui.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getAvatarInitial, getSkeletonRowIds } from "../app/utils/ui"; + +describe("UI utilities", () => { + it("normalizes avatar initials", () => { + expect(getAvatarInitial(" player")).toBe("P"); + expect(getAvatarInitial(" ")).toBe("?"); + }); + + it("creates stable skeleton row identifiers", () => { + expect(getSkeletonRowIds(3)).toEqual([1, 2, 3]); + expect(getSkeletonRowIds(0)).toEqual([1]); + expect(getSkeletonRowIds(Number.POSITIVE_INFINITY)).toEqual([1]); + }); +}); From 1d3c86dde5e5cd277b8221ef877b2087b68c9d16 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:14:39 +1000 Subject: [PATCH 3/5] feat(frontend): community post browsing --- apps/backend/comments/permissions.py | 9 +- apps/backend/comments/tests/test_views.py | 65 +++++++++++++ apps/backend/comments/views.py | 17 ++++ apps/backend/posts/permissions.py | 7 +- apps/backend/posts/tests/test_permissions.py | 7 +- apps/backend/posts/tests/test_views.py | 21 +++- apps/frontend/app/api/community-posts.ts | 32 +++++++ apps/frontend/app/components/AppHeader.vue | 6 ++ .../community/CommunityPagination.vue | 52 ++++++++++ .../community/CommunityPostCard.vue | 72 ++++++++++++++ .../community/CommunityPostList.vue | 17 ++++ .../app/composables/useCommunityPosts.ts | 42 ++++++++ apps/frontend/app/pages/community/[id].vue | 96 +++++++++++++++++++ apps/frontend/app/pages/community/index.vue | 59 ++++++++++++ apps/frontend/app/pages/index.vue | 80 +++++++++++++--- apps/frontend/app/utils/community-posts.ts | 42 ++++++++ apps/frontend/i18n/locales/en.json | 42 +++++++- apps/frontend/i18n/locales/zh.json | 42 +++++++- .../frontend/test/community-posts-api.test.ts | 81 ++++++++++++++++ .../test/community-posts-utils.test.ts | 45 +++++++++ 20 files changed, 810 insertions(+), 24 deletions(-) create mode 100644 apps/frontend/app/api/community-posts.ts create mode 100644 apps/frontend/app/components/community/CommunityPagination.vue create mode 100644 apps/frontend/app/components/community/CommunityPostCard.vue create mode 100644 apps/frontend/app/components/community/CommunityPostList.vue create mode 100644 apps/frontend/app/composables/useCommunityPosts.ts create mode 100644 apps/frontend/app/pages/community/[id].vue create mode 100644 apps/frontend/app/pages/community/index.vue create mode 100644 apps/frontend/app/utils/community-posts.ts create mode 100644 apps/frontend/test/community-posts-api.test.ts create mode 100644 apps/frontend/test/community-posts-utils.test.ts diff --git a/apps/backend/comments/permissions.py b/apps/backend/comments/permissions.py index c59fb06..db542b7 100644 --- a/apps/backend/comments/permissions.py +++ b/apps/backend/comments/permissions.py @@ -3,14 +3,17 @@ class CommentPermission(permissions.BasePermission): """ - Authenticated users can read and create comments. + Anyone can read comments; authenticated users can create them. Authors can edit and soft-delete their own comments. """ + def has_permission(self, request, view): - return request.user.is_authenticated + return ( + request.method in permissions.SAFE_METHODS + or request.user.is_authenticated + ) def has_object_permission(self, request, view, obj): if request.method in permissions.SAFE_METHODS: return True return obj.author_id == request.user.id - diff --git a/apps/backend/comments/tests/test_views.py b/apps/backend/comments/tests/test_views.py index bd9db47..2a3002c 100644 --- a/apps/backend/comments/tests/test_views.py +++ b/apps/backend/comments/tests/test_views.py @@ -214,6 +214,71 @@ def test_other_user_cannot_delete_comment(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertTrue(Comment.objects.filter(id=comment.id).exists()) + def test_anonymous_users_can_read_comments(self): + comment = create_comment(self.author, self.published, body="Public comment") + + list_response = self.get_json( + reverse("comment-list"), + {"article_publication": str(self.published.id)}, + ) + detail_response = self.get_json(reverse("comment-detail", args=[comment.id])) + + self.assert_success_response( + list_response, + status_code=status.HTTP_200_OK, + code="listed", + ) + self.assert_success_response( + detail_response, + status_code=status.HTTP_200_OK, + code="retrieved", + ) + + def test_anonymous_users_cannot_write_comments(self): + comment = create_comment(self.author, self.published, body="Public comment") + + responses = [ + self.post_json( + reverse("comment-list"), + { + "article_publication": str(self.published.id), + "body": "Anonymous comment", + }, + ), + self.patch_json( + reverse("comment-detail", args=[comment.id]), + {"body": "Anonymous edit"}, + ), + self.delete_json(reverse("comment-detail", args=[comment.id])), + ] + + for response in responses: + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_anonymous_users_cannot_read_comments_on_deleted_posts(self): + post = create_community_post(author=self.author, body="Deleted post") + comment = Comment.objects.create( + author=self.author, + target=post.content_target, + body="Hidden comment", + ) + post.is_deleted = True + post.save(update_fields=["is_deleted", "updated_at"]) + + list_response = self.get_json(reverse("comment-list")) + detail_response = self.get_json(reverse("comment-detail", args=[comment.id])) + + self.assert_success_response( + list_response, + status_code=status.HTTP_200_OK, + code="listed", + ) + self.assertNotIn( + str(comment.id), + {item["id"] for item in list_response.data["data"]["results"]}, + ) + self.assertEqual(detail_response.status_code, status.HTTP_404_NOT_FOUND) + def test_list_filters_comments_by_article_publication(self): top_level = create_comment(self.author, self.published, body="Top level") reply = create_comment(self.other_user, self.published, reply_to=top_level, body="Reply") diff --git a/apps/backend/comments/views.py b/apps/backend/comments/views.py index 5177d88..706d3ed 100644 --- a/apps/backend/comments/views.py +++ b/apps/backend/comments/views.py @@ -3,6 +3,8 @@ from rest_framework import status from rest_framework.viewsets import ModelViewSet +from articles.models import Article + from .models import Comment from .permissions import CommentPermission from .serializers import CommentReadSerializer, CommentWriteSerializer @@ -48,6 +50,21 @@ def get_queryset(self): ), ) ) + if self.request.user.is_anonymous: + queryset = queryset.filter( + Q(target__community_post__is_deleted=False) + | Q( + target__article_publication__article__status=( + Article.ArticleStatus.PUBLISHED + ) + ) + | Q(parent__target__community_post__is_deleted=False) + | Q( + parent__target__article_publication__article__status=( + Article.ArticleStatus.PUBLISHED + ) + ) + ) article_publication_id = self.request.query_params.get("article_publication") community_post_id = self.request.query_params.get("community_post") parent_id = self.request.query_params.get("parent") diff --git a/apps/backend/posts/permissions.py b/apps/backend/posts/permissions.py index af4d654..3674e99 100644 --- a/apps/backend/posts/permissions.py +++ b/apps/backend/posts/permissions.py @@ -3,12 +3,15 @@ class CommunityPostPermission(permissions.BasePermission): """ - Authenticated users can read and create community posts. + Anyone can read community posts; authenticated users can create them. Authors can edit and soft-delete their own community posts. """ def has_permission(self, request, view): - return request.user.is_authenticated + return ( + request.method in permissions.SAFE_METHODS + or request.user.is_authenticated + ) def has_object_permission(self, request, view, obj): if request.method in permissions.SAFE_METHODS: diff --git a/apps/backend/posts/tests/test_permissions.py b/apps/backend/posts/tests/test_permissions.py index 66ada44..e2d800c 100644 --- a/apps/backend/posts/tests/test_permissions.py +++ b/apps/backend/posts/tests/test_permissions.py @@ -20,9 +20,14 @@ def request(self, method, user): request.user = user return request - def test_anonymous_users_do_not_have_general_permission(self): + def test_anonymous_users_have_safe_method_permission(self): request = self.request("get", AnonymousUser()) + self.assertTrue(self.permission.has_permission(request, None)) + + def test_anonymous_users_do_not_have_unsafe_method_permission(self): + request = self.request("post", AnonymousUser()) + self.assertFalse(self.permission.has_permission(request, None)) def test_authenticated_users_have_general_permission(self): diff --git a/apps/backend/posts/tests/test_views.py b/apps/backend/posts/tests/test_views.py index 08c420e..bb3981d 100644 --- a/apps/backend/posts/tests/test_views.py +++ b/apps/backend/posts/tests/test_views.py @@ -239,12 +239,27 @@ def test_other_user_cannot_destroy_post(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertTrue(CommunityPost.objects.filter(id=post.id).exists()) - def test_anonymous_users_cannot_access_posts(self): + def test_anonymous_users_can_read_posts(self): + post = create_community_post(author=self.author, body="Hello community") + + list_response = self.get_json(reverse("community_post-list")) + detail_response = self.get_json(reverse("community_post-detail", args=[post.id])) + + self.assert_success_response( + list_response, + status_code=status.HTTP_200_OK, + code="listed", + ) + self.assert_success_response( + detail_response, + status_code=status.HTTP_200_OK, + code="retrieved", + ) + + def test_anonymous_users_cannot_write_posts(self): post = create_community_post(author=self.author, body="Hello community") responses = [ - self.get_json(reverse("community_post-list")), - self.get_json(reverse("community_post-detail", args=[post.id])), self.post_json(reverse("community_post-list"), {"body": "Hello"}), self.patch_json(reverse("community_post-detail", args=[post.id]), {"body": "After"}), self.delete_json(reverse("community_post-detail", args=[post.id])), diff --git a/apps/frontend/app/api/community-posts.ts b/apps/frontend/app/api/community-posts.ts new file mode 100644 index 0000000..c223df3 --- /dev/null +++ b/apps/frontend/app/api/community-posts.ts @@ -0,0 +1,32 @@ +import type { components } from "~/api/generated/v1"; + +import type { ApiClient } from "./client"; +import { unwrapApiResponse } from "./errors"; + +export type CommunityPost = components["schemas"]["CommunityPostRead"]; +export type CommunityPostPage = + components["schemas"]["PaginatedCommunityPostReadList"]; + +export async function listCommunityPosts( + api: ApiClient, + page = 1 +): Promise { + const response = unwrapApiResponse( + await api.GET("/v1/community_posts/", { + params: { query: { page } }, + }) + ); + return response.data; +} + +export async function getCommunityPost( + api: ApiClient, + id: string +): Promise { + const response = unwrapApiResponse( + await api.GET("/v1/community_posts/{id}/", { + params: { path: { id } }, + }) + ); + return response.data; +} diff --git a/apps/frontend/app/components/AppHeader.vue b/apps/frontend/app/components/AppHeader.vue index f0e5e73..970cf7c 100644 --- a/apps/frontend/app/components/AppHeader.vue +++ b/apps/frontend/app/components/AppHeader.vue @@ -47,6 +47,12 @@ async function handleSignOut(): Promise { > {{ $t("navigation.home") }} + +const props = defineProps<{ + currentPage: number; + totalPages: number; +}>(); + +const localePath = useLocalePath(); + +function pagePath(page: number) { + return localePath({ + name: "community", + query: page === 1 ? {} : { page: String(page) }, + }); +} + +const previousPath = computed(() => pagePath(props.currentPage - 1)); +const nextPath = computed(() => pagePath(props.currentPage + 1)); + + + diff --git a/apps/frontend/app/components/community/CommunityPostCard.vue b/apps/frontend/app/components/community/CommunityPostCard.vue new file mode 100644 index 0000000..fcac7db --- /dev/null +++ b/apps/frontend/app/components/community/CommunityPostCard.vue @@ -0,0 +1,72 @@ + + + diff --git a/apps/frontend/app/components/community/CommunityPostList.vue b/apps/frontend/app/components/community/CommunityPostList.vue new file mode 100644 index 0000000..ecd1430 --- /dev/null +++ b/apps/frontend/app/components/community/CommunityPostList.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/frontend/app/composables/useCommunityPosts.ts b/apps/frontend/app/composables/useCommunityPosts.ts new file mode 100644 index 0000000..b267519 --- /dev/null +++ b/apps/frontend/app/composables/useCommunityPosts.ts @@ -0,0 +1,42 @@ +import type { MaybeRefOrGetter } from "vue"; + +import { getCommunityPost, listCommunityPosts } from "~/api/community-posts"; +import { ApiResponseError } from "~/api/errors"; + +interface CommunityPostListOptions { + key?: string; +} + +export function useCommunityPostList( + page: MaybeRefOrGetter, + options: CommunityPostListOptions = {} +) { + const api = useApi(); + const resolvedPage = computed(() => toValue(page)); + + return useAsyncData( + options.key ?? "community-post-list", + () => listCommunityPosts(api, resolvedPage.value), + { watch: [resolvedPage] } + ); +} + +export function useCommunityPost(id: MaybeRefOrGetter) { + const api = useApi(); + const resolvedId = computed(() => toValue(id)); + + return useAsyncData( + "community-post-detail", + async () => { + try { + return await getCommunityPost(api, resolvedId.value); + } catch (error) { + if (error instanceof ApiResponseError && error.status === 404) { + throw createError({ statusCode: 404, statusMessage: "Not Found" }); + } + throw error; + } + }, + { watch: [resolvedId] } + ); +} diff --git a/apps/frontend/app/pages/community/[id].vue b/apps/frontend/app/pages/community/[id].vue new file mode 100644 index 0000000..f15c821 --- /dev/null +++ b/apps/frontend/app/pages/community/[id].vue @@ -0,0 +1,96 @@ + + + diff --git a/apps/frontend/app/pages/community/index.vue b/apps/frontend/app/pages/community/index.vue new file mode 100644 index 0000000..f4a6153 --- /dev/null +++ b/apps/frontend/app/pages/community/index.vue @@ -0,0 +1,59 @@ + + + diff --git a/apps/frontend/app/pages/index.vue b/apps/frontend/app/pages/index.vue index dd430c8..42fc21d 100644 --- a/apps/frontend/app/pages/index.vue +++ b/apps/frontend/app/pages/index.vue @@ -1,5 +1,6 @@ diff --git a/apps/frontend/app/utils/community-posts.ts b/apps/frontend/app/utils/community-posts.ts new file mode 100644 index 0000000..999001e --- /dev/null +++ b/apps/frontend/app/utils/community-posts.ts @@ -0,0 +1,42 @@ +import type { CommunityPost } from "~/api/community-posts"; + +const MENTION_PATTERN = /\{\{mention:(\d+)\}\}/g; + +export function parsePageNumber(value: unknown): number { + const candidate = Array.isArray(value) ? value[0] : value; + const page = typeof candidate === "string" ? Number(candidate) : candidate; + return typeof page === "number" && Number.isInteger(page) && page > 0 + ? page + : 1; +} + +export function formatContentDate(value: string, locale: string): string { + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeZone: "UTC", + }).format(new Date(value)); +} + +export function resolveCommunityPostBody( + post: Pick +): string { + return post.body.replace(MENTION_PATTERN, (token, indexValue: string) => { + const mention = post.mention_users[Number(indexValue)]; + return mention ? `@${mention.username}` : token; + }); +} + +export function getContentExcerpt(value: string, maximumLength = 220): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maximumLength) { + return normalized; + } + return `${normalized.slice(0, maximumLength).trimEnd()}…`; +} + +export function isUuid(value: unknown): value is string { + return ( + typeof value === "string" && + /^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/i.test(value) + ); +} diff --git a/apps/frontend/i18n/locales/en.json b/apps/frontend/i18n/locales/en.json index 5f69b90..acd0ca5 100644 --- a/apps/frontend/i18n/locales/en.json +++ b/apps/frontend/i18n/locales/en.json @@ -28,6 +28,41 @@ "avatarAlt": "{username}'s avatar" } }, + "community": { + "authorAvatar": "{username}'s avatar", + "comments": "{count} comments", + "deletedUser": "Deleted user", + "detail": { + "back": "Back to community", + "metaTitle": "Post by {username}" + }, + "dislikes": "{count} dislikes", + "empty": { + "description": "The first community post has not been published yet.", + "title": "No posts yet" + }, + "error": { + "description": "We could not load community posts. Please try again.", + "retry": "Try again", + "title": "Community posts are unavailable" + }, + "eyebrow": "Community", + "likes": "{count} likes", + "list": { + "description": "Ideas, discoveries, and conversations from the Technical Minecraft community.", + "metaTitle": "Community posts", + "title": "Community posts" + }, + "loading": "Loading community posts", + "pagination": { + "label": "Community post pages", + "next": "Next", + "previous": "Previous", + "status": "Page {current} of {total}" + }, + "readPost": "Read post", + "readPostBy": "Read post by {username}" + }, "error": { "genericDescription": "Something prevented this page from loading. You can return home and try again.", "genericTitle": "Something went wrong", @@ -42,10 +77,14 @@ "home": { "description": "A shared place to publish knowledge, exchange ideas, and build lasting resources for the Technical Minecraft community.", "eyebrow": "Technical Minecraft, together", + "exploreCommunity": "Explore the community", + "latestEyebrow": "Latest activity", + "latestTitle": "From the community", "metaTitle": "Technical Minecraft community", "statusDescription": "The Nuxt application shell, localized routing, API foundation, and server-side rendering are ready for the first product features.", "statusTitle": "The foundation is ready", - "title": "A home for knowledge built block by block." + "title": "A home for knowledge built block by block.", + "viewAll": "View all posts" }, "locale": { "chinese": "Switch to Simplified Chinese", @@ -53,6 +92,7 @@ "label": "Language" }, "navigation": { + "community": "Community", "home": "Home", "primary": "Primary navigation" } diff --git a/apps/frontend/i18n/locales/zh.json b/apps/frontend/i18n/locales/zh.json index 6d475d3..2ff4d71 100644 --- a/apps/frontend/i18n/locales/zh.json +++ b/apps/frontend/i18n/locales/zh.json @@ -28,6 +28,41 @@ "avatarAlt": "{username} 的头像" } }, + "community": { + "authorAvatar": "{username} 的头像", + "comments": "{count} 条评论", + "deletedUser": "已注销用户", + "detail": { + "back": "返回社区", + "metaTitle": "{username} 发布的帖子" + }, + "dislikes": "{count} 次反对", + "empty": { + "description": "社区的第一篇帖子还没有发布。", + "title": "还没有帖子" + }, + "error": { + "description": "暂时无法加载社区帖子,请重试。", + "retry": "重试", + "title": "社区帖子暂不可用" + }, + "eyebrow": "社区", + "likes": "{count} 次赞同", + "list": { + "description": "浏览技术型 Minecraft 社区分享的想法、发现与讨论。", + "metaTitle": "社区帖子", + "title": "社区帖子" + }, + "loading": "正在加载社区帖子", + "pagination": { + "label": "社区帖子分页", + "next": "下一页", + "previous": "上一页", + "status": "第 {current} 页,共 {total} 页" + }, + "readPost": "阅读帖子", + "readPostBy": "阅读 {username} 发布的帖子" + }, "error": { "genericDescription": "页面加载时遇到了问题。你可以返回首页后重试。", "genericTitle": "出现了一些问题", @@ -42,10 +77,14 @@ "home": { "description": "一个为技术型 Minecraft 社区发布知识、交流想法并共同沉淀长期资源的共享空间。", "eyebrow": "一起探索技术型 Minecraft", + "exploreCommunity": "探索社区", + "latestEyebrow": "最新动态", + "latestTitle": "来自社区", "metaTitle": "技术型 Minecraft 社区", "statusDescription": "Nuxt 应用外壳、本地化路由、API 地基和服务端渲染已经就绪,可以开始构建第一个产品功能。", "statusTitle": "项目地基已经就绪", - "title": "一砖一瓦,共建知识家园。" + "title": "一砖一瓦,共建知识家园。", + "viewAll": "查看全部帖子" }, "locale": { "chinese": "切换到简体中文", @@ -53,6 +92,7 @@ "label": "语言" }, "navigation": { + "community": "社区", "home": "首页", "primary": "主导航" } diff --git a/apps/frontend/test/community-posts-api.test.ts b/apps/frontend/test/community-posts-api.test.ts new file mode 100644 index 0000000..0c15372 --- /dev/null +++ b/apps/frontend/test/community-posts-api.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + getCommunityPost, + listCommunityPosts, +} from "../app/api/community-posts"; +import { createApiClient } from "../app/api/client"; + +const postId = "00000000-0000-0000-0000-000000000001"; + +function envelope(data: unknown): Response { + return new Response(JSON.stringify({ data }), { + headers: { "content-type": "application/json" }, + status: 200, + }); +} + +function post() { + return { + author: { + id: "00000000-0000-0000-0000-000000000002", + signature: "", + username: "Builder", + }, + author_username: "Builder", + body: "Hello community", + comment_count: 0, + created_at: "2026-01-02T00:00:00Z", + dislike_count: 0, + id: postId, + like_count: 1, + mention_users: [], + mentions: [], + render_body: "Hello community", + updated_at: "2026-01-02T00:00:00Z", + }; +} + +describe("community posts API", () => { + it("lists the requested page", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope({ + count: 1, + current_page: 2, + page_size: 20, + results: [post()], + total_pages: 2, + }); + }, + }); + + const result = await listCommunityPosts(api, 2); + + expect(requests[0]?.url).toBe( + "https://example.test/api/v1/community_posts/?page=2" + ); + expect(result.results[0]?.id).toBe(postId); + }); + + it("retrieves a post by id", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope(post()); + }, + }); + + const result = await getCommunityPost(api, postId); + + expect(requests[0]?.url).toBe( + `https://example.test/api/v1/community_posts/${postId}/` + ); + expect(result.author_username).toBe("Builder"); + }); +}); diff --git a/apps/frontend/test/community-posts-utils.test.ts b/apps/frontend/test/community-posts-utils.test.ts new file mode 100644 index 0000000..5f6bb6a --- /dev/null +++ b/apps/frontend/test/community-posts-utils.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + formatContentDate, + getContentExcerpt, + isUuid, + parsePageNumber, + resolveCommunityPostBody, +} from "../app/utils/community-posts"; + +describe("community post utilities", () => { + it.each([ + ["3", 3], + [["2", "4"], 2], + ["0", 1], + ["invalid", 1], + [undefined, 1], + ])("normalizes page value %j", (value, expected) => { + expect(parsePageNumber(value)).toBe(expected); + }); + + it("formats dates in a deterministic UTC timezone", () => { + expect(formatContentDate("2026-01-02T23:30:00-08:00", "en-US")).toBe( + "Jan 3, 2026" + ); + }); + + it("resolves mention tokens as safe plain text", () => { + expect( + resolveCommunityPostBody({ + body: "Hello {{mention:0}} and {{mention:2}}", + mention_users: [{ user_id: "user-id", username: "Builder" }], + }) + ).toBe("Hello @Builder and {{mention:2}}"); + }); + + it("truncates normalized excerpts", () => { + expect(getContentExcerpt(" one\n two three ", 7)).toBe("one two…"); + }); + + it("recognizes UUID route parameters", () => { + expect(isUuid("00000000-0000-0000-0000-000000000001")).toBe(true); + expect(isUuid("not-a-uuid")).toBe(false); + }); +}); From 7a447fb7b21e6f6469618dfda322ac724f0a343f Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:27:27 +1000 Subject: [PATCH 4/5] feat(frontend): article browsing --- apps/backend/articles/serializers/articles.py | 8 +- apps/backend/articles/tests/test_views.py | 39 +++++- apps/backend/articles/views/articles.py | 4 +- apps/backend/core/tests/test_html.py | 36 ++++++ apps/backend/core/utils/html.py | 36 ++++++ apps/backend/pyproject.toml | 1 + apps/frontend/app/api/articles.ts | 32 +++++ apps/frontend/app/components/AppHeader.vue | 6 + .../app/components/articles/ArticleBody.vue | 101 +++++++++++++++ .../app/components/articles/ArticleCard.vue | 60 +++++++++ .../app/components/articles/ArticleList.vue | 17 +++ .../components/articles/ArticlePagination.vue | 52 ++++++++ .../app/components/home/LatestContent.vue | 118 ++++++++++++++++++ apps/frontend/app/composables/useArticles.ts | 42 +++++++ apps/frontend/app/pages/articles/[id].vue | 112 +++++++++++++++++ apps/frontend/app/pages/articles/index.vue | 59 +++++++++ apps/frontend/app/pages/index.vue | 58 +-------- apps/frontend/app/utils/community-posts.ts | 30 ----- apps/frontend/app/utils/content.ts | 29 +++++ apps/frontend/i18n/locales/en.json | 53 +++++++- apps/frontend/i18n/locales/zh.json | 53 +++++++- apps/frontend/test/articles-api.test.ts | 79 ++++++++++++ .../test/community-posts-utils.test.ts | 4 +- uv.lock | 36 ++++++ 24 files changed, 970 insertions(+), 95 deletions(-) create mode 100644 apps/backend/core/tests/test_html.py create mode 100644 apps/backend/core/utils/html.py create mode 100644 apps/frontend/app/api/articles.ts create mode 100644 apps/frontend/app/components/articles/ArticleBody.vue create mode 100644 apps/frontend/app/components/articles/ArticleCard.vue create mode 100644 apps/frontend/app/components/articles/ArticleList.vue create mode 100644 apps/frontend/app/components/articles/ArticlePagination.vue create mode 100644 apps/frontend/app/components/home/LatestContent.vue create mode 100644 apps/frontend/app/composables/useArticles.ts create mode 100644 apps/frontend/app/pages/articles/[id].vue create mode 100644 apps/frontend/app/pages/articles/index.vue create mode 100644 apps/frontend/app/utils/content.ts create mode 100644 apps/frontend/test/articles-api.test.ts diff --git a/apps/backend/articles/serializers/articles.py b/apps/backend/articles/serializers/articles.py index a1fabc6..baf0269 100644 --- a/apps/backend/articles/serializers/articles.py +++ b/apps/backend/articles/serializers/articles.py @@ -12,6 +12,7 @@ from PIL import Image from rest_framework import serializers +from core.utils.html import sanitize_published_html from core.validators import FileSizeValidator, FileTypeValidator from ..models import ( @@ -167,6 +168,11 @@ class ArticlePublicationVersionSerializer(serializers.ModelSerializer): """ Serializer for immutable article publication versions. """ + html = serializers.SerializerMethodField() + + @extend_schema_field(serializers.CharField()) + def get_html(self, obj): + return sanitize_published_html(obj.html) class Meta: model = ArticlePublicationVersion @@ -250,7 +256,7 @@ def get_title(self, obj): @extend_schema_field(serializers.CharField(allow_null=True)) def get_html(self, obj): latest_version = self._get_latest_version(obj) - return latest_version.html if latest_version else None + return sanitize_published_html(latest_version.html) if latest_version else None @extend_schema_field(serializers.DateTimeField(allow_null=True)) def get_publication_at(self, obj): diff --git a/apps/backend/articles/tests/test_views.py b/apps/backend/articles/tests/test_views.py index dabde9c..e2fb8a6 100644 --- a/apps/backend/articles/tests/test_views.py +++ b/apps/backend/articles/tests/test_views.py @@ -378,7 +378,6 @@ def test_publication_list_only_returns_published_articles(self): unpublished_article.status = Article.ArticleStatus.UNPUBLISHED unpublished_article.save(update_fields=["status"]) - self.authenticate(self.viewer) response = self.get_json(reverse("article_publication-list")) self.assert_success_response( @@ -395,6 +394,33 @@ def test_publication_list_only_returns_published_articles(self): self.assertEqual(visible_result["latest_version"]["version"], 2) self.assertEqual(len(visible_result["versions"]), 2) + def test_publication_detail_is_public_and_sanitizes_html(self): + article = create_article(author=self.author, title="Safe publication") + publication = create_article_publication( + article, + html=( + '

Safe

' + 'link' + ), + ) + + response = self.get_json( + reverse("article_publication-detail", args=[publication.id]) + ) + + self.assert_success_response( + response, + status_code=status.HTTP_200_OK, + code="retrieved", + ) + serialized = response.data["data"] + self.assertIn("

Safe

", serialized["html"]) + self.assertNotIn("script", serialized["html"]) + self.assertNotIn("javascript:", serialized["html"]) + self.assertNotIn("onclick", serialized["html"]) + self.assertEqual(serialized["html"], serialized["latest_version"]["html"]) + self.assertEqual(serialized["html"], serialized["versions"][0]["html"]) + def test_publication_detail_returns_404_after_article_is_unpublished(self): article = create_article(author=self.author) publication = create_article_publication(article) @@ -405,3 +431,14 @@ def test_publication_detail_returns_404_after_article_is_unpublished(self): response = self.get_json(reverse("article_publication-detail", args=[publication.id])) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_publication_endpoint_has_no_edit_operation(self): + article = create_article(author=self.author) + publication = create_article_publication(article) + + response = self.patch_json( + reverse("article_publication-detail", args=[publication.id]), + {"title": "Not editable"}, + ) + + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/apps/backend/articles/views/articles.py b/apps/backend/articles/views/articles.py index 6459feb..76aec0d 100644 --- a/apps/backend/articles/views/articles.py +++ b/apps/backend/articles/views/articles.py @@ -3,7 +3,7 @@ from drf_std_response import EnvelopeMixin from rest_framework import status from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from core.utils.permissions import is_moderator @@ -260,7 +260,7 @@ def trash(self, request, pk=None): class ArticlePublicationViewSet(EnvelopeMixin, ReadOnlyModelViewSet): queryset = ArticlePublication.objects.select_related("article").prefetch_related("versions") serializer_class = ArticlePublicationSerializer - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def get_queryset(self): from comments.querysets import with_article_publication_comment_count diff --git a/apps/backend/core/tests/test_html.py b/apps/backend/core/tests/test_html.py new file mode 100644 index 0000000..adbbc42 --- /dev/null +++ b/apps/backend/core/tests/test_html.py @@ -0,0 +1,36 @@ +from django.test import SimpleTestCase + +from core.utils.html import sanitize_published_html + + +class PublishedHtmlSanitizerTests(SimpleTestCase): + def test_preserves_alienmark_markup(self): + html = ( + '

Guide

Use redstone.

' + '
const x = 1;
' + 'Guide' + 'Reference' + ) + + sanitized = sanitize_published_html(html) + + self.assertIn("

Guide

", sanitized) + self.assertIn("redstone", sanitized) + self.assertIn('class="language-ts"', sanitized) + self.assertIn('src="/media/article_images/guide.webp"', sanitized) + self.assertIn('href="https://example.com"', sanitized) + self.assertIn('rel="noopener noreferrer"', sanitized) + + def test_removes_executable_markup_and_unsafe_urls(self): + html = ( + '' + '' + 'unsafe' + ) + + sanitized = sanitize_published_html(html) + + self.assertNotIn("script", sanitized) + self.assertNotIn("data:", sanitized) + self.assertNotIn("onerror", sanitized) + self.assertNotIn("javascript:", sanitized) diff --git a/apps/backend/core/utils/html.py b/apps/backend/core/utils/html.py new file mode 100644 index 0000000..e597387 --- /dev/null +++ b/apps/backend/core/utils/html.py @@ -0,0 +1,36 @@ +import nh3 + +PUBLISHED_HTML_CLEANER = nh3.Cleaner( + tags={ + "a", + "blockquote", + "code", + "em", + "h1", + "h2", + "h3", + "h4", + "hr", + "img", + "li", + "ol", + "p", + "pre", + "strong", + "ul", + }, + clean_content_tags={"script", "style"}, + attributes={ + "a": {"href"}, + "code": {"class"}, + "img": {"alt", "src"}, + "ol": {"start"}, + }, + link_rel="noopener noreferrer", + url_schemes={"http", "https", "mailto"}, +) + + +def sanitize_published_html(value: str) -> str: + """Return the safe HTML subset supported by the article renderer.""" + return PUBLISHED_HTML_CLEANER.clean(value) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index d587038..266890d 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "drf-spectacular==0.30.0", "drf-std-response==0.1.0", "environs==15.1.0", + "nh3==0.3.6", "pillow==12.3.0", "psycopg[binary]==3.3.4", "requests==2.34.2", diff --git a/apps/frontend/app/api/articles.ts b/apps/frontend/app/api/articles.ts new file mode 100644 index 0000000..a3e2a4c --- /dev/null +++ b/apps/frontend/app/api/articles.ts @@ -0,0 +1,32 @@ +import type { components } from "~/api/generated/v1"; + +import type { ApiClient } from "./client"; +import { unwrapApiResponse } from "./errors"; + +export type ArticlePublication = components["schemas"]["ArticlePublication"]; +export type ArticlePublicationPage = + components["schemas"]["PaginatedArticlePublicationList"]; + +export async function listArticlePublications( + api: ApiClient, + page = 1 +): Promise { + const response = unwrapApiResponse( + await api.GET("/v1/article_publications/", { + params: { query: { page } }, + }) + ); + return response.data; +} + +export async function getArticlePublication( + api: ApiClient, + id: string +): Promise { + const response = unwrapApiResponse( + await api.GET("/v1/article_publications/{id}/", { + params: { path: { id } }, + }) + ); + return response.data; +} diff --git a/apps/frontend/app/components/AppHeader.vue b/apps/frontend/app/components/AppHeader.vue index 970cf7c..e4d66bd 100644 --- a/apps/frontend/app/components/AppHeader.vue +++ b/apps/frontend/app/components/AppHeader.vue @@ -53,6 +53,12 @@ async function handleSignOut(): Promise { > {{ $t("navigation.community") }} + +defineProps<{ + html: string; +}>(); + + + + + diff --git a/apps/frontend/app/components/articles/ArticleCard.vue b/apps/frontend/app/components/articles/ArticleCard.vue new file mode 100644 index 0000000..f3586d9 --- /dev/null +++ b/apps/frontend/app/components/articles/ArticleCard.vue @@ -0,0 +1,60 @@ + + + diff --git a/apps/frontend/app/components/articles/ArticleList.vue b/apps/frontend/app/components/articles/ArticleList.vue new file mode 100644 index 0000000..693b46a --- /dev/null +++ b/apps/frontend/app/components/articles/ArticleList.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/frontend/app/components/articles/ArticlePagination.vue b/apps/frontend/app/components/articles/ArticlePagination.vue new file mode 100644 index 0000000..95ed883 --- /dev/null +++ b/apps/frontend/app/components/articles/ArticlePagination.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/frontend/app/components/home/LatestContent.vue b/apps/frontend/app/components/home/LatestContent.vue new file mode 100644 index 0000000..facd1e1 --- /dev/null +++ b/apps/frontend/app/components/home/LatestContent.vue @@ -0,0 +1,118 @@ + + + diff --git a/apps/frontend/app/composables/useArticles.ts b/apps/frontend/app/composables/useArticles.ts new file mode 100644 index 0000000..18d8ee9 --- /dev/null +++ b/apps/frontend/app/composables/useArticles.ts @@ -0,0 +1,42 @@ +import type { MaybeRefOrGetter } from "vue"; + +import { getArticlePublication, listArticlePublications } from "~/api/articles"; +import { ApiResponseError } from "~/api/errors"; + +interface ArticleListOptions { + key?: string; +} + +export function useArticleList( + page: MaybeRefOrGetter, + options: ArticleListOptions = {} +) { + const api = useApi(); + const resolvedPage = computed(() => toValue(page)); + + return useAsyncData( + options.key ?? "article-publication-list", + () => listArticlePublications(api, resolvedPage.value), + { watch: [resolvedPage] } + ); +} + +export function useArticlePublication(id: MaybeRefOrGetter) { + const api = useApi(); + const resolvedId = computed(() => toValue(id)); + + return useAsyncData( + "article-publication-detail", + async () => { + try { + return await getArticlePublication(api, resolvedId.value); + } catch (error) { + if (error instanceof ApiResponseError && error.status === 404) { + throw createError({ statusCode: 404, statusMessage: "Not Found" }); + } + throw error; + } + }, + { watch: [resolvedId] } + ); +} diff --git a/apps/frontend/app/pages/articles/[id].vue b/apps/frontend/app/pages/articles/[id].vue new file mode 100644 index 0000000..20fdce2 --- /dev/null +++ b/apps/frontend/app/pages/articles/[id].vue @@ -0,0 +1,112 @@ + + + diff --git a/apps/frontend/app/pages/articles/index.vue b/apps/frontend/app/pages/articles/index.vue new file mode 100644 index 0000000..095cd85 --- /dev/null +++ b/apps/frontend/app/pages/articles/index.vue @@ -0,0 +1,59 @@ + + + diff --git a/apps/frontend/app/pages/index.vue b/apps/frontend/app/pages/index.vue index 42fc21d..7a20dac 100644 --- a/apps/frontend/app/pages/index.vue +++ b/apps/frontend/app/pages/index.vue @@ -8,11 +8,6 @@ useSeoMeta({ ogTitle: () => t("home.metaTitle"), title: () => t("home.metaTitle"), }); - -const { data, error, refresh, status } = await useCommunityPostList(1, { - key: "home-community-posts", -}); -const latestPosts = computed(() => data.value?.results.slice(0, 3) ?? []); diff --git a/apps/frontend/app/utils/community-posts.ts b/apps/frontend/app/utils/community-posts.ts index 999001e..49f5104 100644 --- a/apps/frontend/app/utils/community-posts.ts +++ b/apps/frontend/app/utils/community-posts.ts @@ -2,21 +2,6 @@ import type { CommunityPost } from "~/api/community-posts"; const MENTION_PATTERN = /\{\{mention:(\d+)\}\}/g; -export function parsePageNumber(value: unknown): number { - const candidate = Array.isArray(value) ? value[0] : value; - const page = typeof candidate === "string" ? Number(candidate) : candidate; - return typeof page === "number" && Number.isInteger(page) && page > 0 - ? page - : 1; -} - -export function formatContentDate(value: string, locale: string): string { - return new Intl.DateTimeFormat(locale, { - dateStyle: "medium", - timeZone: "UTC", - }).format(new Date(value)); -} - export function resolveCommunityPostBody( post: Pick ): string { @@ -25,18 +10,3 @@ export function resolveCommunityPostBody( return mention ? `@${mention.username}` : token; }); } - -export function getContentExcerpt(value: string, maximumLength = 220): string { - const normalized = value.replace(/\s+/g, " ").trim(); - if (normalized.length <= maximumLength) { - return normalized; - } - return `${normalized.slice(0, maximumLength).trimEnd()}…`; -} - -export function isUuid(value: unknown): value is string { - return ( - typeof value === "string" && - /^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/i.test(value) - ); -} diff --git a/apps/frontend/app/utils/content.ts b/apps/frontend/app/utils/content.ts new file mode 100644 index 0000000..6b6ae10 --- /dev/null +++ b/apps/frontend/app/utils/content.ts @@ -0,0 +1,29 @@ +export function parsePageNumber(value: unknown): number { + const candidate = Array.isArray(value) ? value[0] : value; + const page = typeof candidate === "string" ? Number(candidate) : candidate; + return typeof page === "number" && Number.isInteger(page) && page > 0 + ? page + : 1; +} + +export function formatContentDate(value: string, locale: string): string { + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeZone: "UTC", + }).format(new Date(value)); +} + +export function getContentExcerpt(value: string, maximumLength = 220): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maximumLength) { + return normalized; + } + return `${normalized.slice(0, maximumLength).trimEnd()}…`; +} + +export function isUuid(value: unknown): value is string { + return ( + typeof value === "string" && + /^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/i.test(value) + ); +} diff --git a/apps/frontend/i18n/locales/en.json b/apps/frontend/i18n/locales/en.json index acd0ca5..ac07e5f 100644 --- a/apps/frontend/i18n/locales/en.json +++ b/apps/frontend/i18n/locales/en.json @@ -28,6 +28,48 @@ "avatarAlt": "{username}'s avatar" } }, + "articles": { + "card": { + "label": "Published guide" + }, + "comments": "{count} comments", + "detail": { + "back": "Back to articles", + "label": "Published article", + "metaDescription": "Read {title} on AlienCommons." + }, + "dislikes": "{count} dislikes", + "empty": { + "description": "The first community article has not been published yet.", + "title": "No articles yet" + }, + "emptyBody": { + "description": "This publication does not currently contain a readable article body.", + "title": "Article body unavailable" + }, + "error": { + "description": "We could not load published articles. Please try again.", + "retry": "Try again", + "title": "Articles are unavailable" + }, + "eyebrow": "Knowledge base", + "likes": "{count} likes", + "list": { + "description": "Browse reviewed guides and lasting resources published by the Technical Minecraft community.", + "metaTitle": "Published articles", + "title": "Published articles" + }, + "loading": "Loading published articles", + "pagination": { + "label": "Article pages", + "next": "Next", + "previous": "Previous", + "status": "Page {current} of {total}" + }, + "read": "Read article", + "readNamed": "Read {title}", + "untitled": "Untitled article" + }, "community": { "authorAvatar": "{username}'s avatar", "comments": "{count} comments", @@ -77,14 +119,18 @@ "home": { "description": "A shared place to publish knowledge, exchange ideas, and build lasting resources for the Technical Minecraft community.", "eyebrow": "Technical Minecraft, together", + "exploreArticles": "Browse articles", "exploreCommunity": "Explore the community", - "latestEyebrow": "Latest activity", - "latestTitle": "From the community", + "latestArticlesEyebrow": "Latest knowledge", + "latestArticlesTitle": "Recently published articles", + "latestPostsEyebrow": "Latest activity", + "latestPostsTitle": "From the community", "metaTitle": "Technical Minecraft community", "statusDescription": "The Nuxt application shell, localized routing, API foundation, and server-side rendering are ready for the first product features.", "statusTitle": "The foundation is ready", "title": "A home for knowledge built block by block.", - "viewAll": "View all posts" + "viewAllArticles": "View all articles", + "viewAllPosts": "View all posts" }, "locale": { "chinese": "Switch to Simplified Chinese", @@ -92,6 +138,7 @@ "label": "Language" }, "navigation": { + "articles": "Articles", "community": "Community", "home": "Home", "primary": "Primary navigation" diff --git a/apps/frontend/i18n/locales/zh.json b/apps/frontend/i18n/locales/zh.json index 2ff4d71..e174359 100644 --- a/apps/frontend/i18n/locales/zh.json +++ b/apps/frontend/i18n/locales/zh.json @@ -28,6 +28,48 @@ "avatarAlt": "{username} 的头像" } }, + "articles": { + "card": { + "label": "已发布指南" + }, + "comments": "{count} 条评论", + "detail": { + "back": "返回文章列表", + "label": "已发布文章", + "metaDescription": "在 AlienCommons 阅读《{title}》。" + }, + "dislikes": "{count} 次反对", + "empty": { + "description": "社区的第一篇文章还没有发布。", + "title": "还没有文章" + }, + "emptyBody": { + "description": "这个发布版本暂时没有可阅读的文章正文。", + "title": "文章正文不可用" + }, + "error": { + "description": "暂时无法加载已发布文章,请重试。", + "retry": "重试", + "title": "文章暂不可用" + }, + "eyebrow": "知识库", + "likes": "{count} 次赞同", + "list": { + "description": "浏览由技术型 Minecraft 社区发布并经过审核的指南和长期资源。", + "metaTitle": "已发布文章", + "title": "已发布文章" + }, + "loading": "正在加载已发布文章", + "pagination": { + "label": "文章分页", + "next": "下一页", + "previous": "上一页", + "status": "第 {current} 页,共 {total} 页" + }, + "read": "阅读文章", + "readNamed": "阅读《{title}》", + "untitled": "无标题文章" + }, "community": { "authorAvatar": "{username} 的头像", "comments": "{count} 条评论", @@ -77,14 +119,18 @@ "home": { "description": "一个为技术型 Minecraft 社区发布知识、交流想法并共同沉淀长期资源的共享空间。", "eyebrow": "一起探索技术型 Minecraft", + "exploreArticles": "浏览文章", "exploreCommunity": "探索社区", - "latestEyebrow": "最新动态", - "latestTitle": "来自社区", + "latestArticlesEyebrow": "最新知识", + "latestArticlesTitle": "最近发布的文章", + "latestPostsEyebrow": "最新动态", + "latestPostsTitle": "来自社区", "metaTitle": "技术型 Minecraft 社区", "statusDescription": "Nuxt 应用外壳、本地化路由、API 地基和服务端渲染已经就绪,可以开始构建第一个产品功能。", "statusTitle": "项目地基已经就绪", "title": "一砖一瓦,共建知识家园。", - "viewAll": "查看全部帖子" + "viewAllArticles": "查看全部文章", + "viewAllPosts": "查看全部帖子" }, "locale": { "chinese": "切换到简体中文", @@ -92,6 +138,7 @@ "label": "语言" }, "navigation": { + "articles": "文章", "community": "社区", "home": "首页", "primary": "主导航" diff --git a/apps/frontend/test/articles-api.test.ts b/apps/frontend/test/articles-api.test.ts new file mode 100644 index 0000000..b4ebfab --- /dev/null +++ b/apps/frontend/test/articles-api.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + getArticlePublication, + listArticlePublications, +} from "../app/api/articles"; +import { createApiClient } from "../app/api/client"; + +const publicationId = "00000000-0000-0000-0000-000000000001"; + +function envelope(data: unknown): Response { + return new Response(JSON.stringify({ data }), { + headers: { "content-type": "application/json" }, + status: 200, + }); +} + +function publication() { + return { + article: "00000000-0000-0000-0000-000000000002", + comment_count: 2, + created_at: "2026-01-02T00:00:00Z", + dislike_count: 0, + html: "

Safe article

", + id: publicationId, + latest_version: undefined, + like_count: 4, + my_reaction: undefined, + publication_at: "2026-01-02T00:00:00Z", + published_at: "2026-01-02T00:00:00Z", + title: "Redstone guide", + updated_at: "2026-01-02T00:00:00Z", + versions: [], + }; +} + +describe("article publications API", () => { + it("lists the requested page", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope({ + count: 1, + current_page: 3, + page_size: 20, + results: [publication()], + total_pages: 3, + }); + }, + }); + + const result = await listArticlePublications(api, 3); + + expect(requests[0]?.url).toBe( + "https://example.test/api/v1/article_publications/?page=3" + ); + expect(result.results[0]?.title).toBe("Redstone guide"); + }); + + it("retrieves a publication by id", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope(publication()); + }, + }); + + const result = await getArticlePublication(api, publicationId); + + expect(requests[0]?.url).toBe( + `https://example.test/api/v1/article_publications/${publicationId}/` + ); + expect(result.html).toBe("

Safe article

"); + }); +}); diff --git a/apps/frontend/test/community-posts-utils.test.ts b/apps/frontend/test/community-posts-utils.test.ts index 5f6bb6a..4a62bd2 100644 --- a/apps/frontend/test/community-posts-utils.test.ts +++ b/apps/frontend/test/community-posts-utils.test.ts @@ -5,8 +5,8 @@ import { getContentExcerpt, isUuid, parsePageNumber, - resolveCommunityPostBody, -} from "../app/utils/community-posts"; +} from "../app/utils/content"; +import { resolveCommunityPostBody } from "../app/utils/community-posts"; describe("community post utilities", () => { it.each([ diff --git a/uv.lock b/uv.lock index f67ade5..859e962 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,7 @@ dependencies = [ { name = "drf-spectacular" }, { name = "drf-std-response" }, { name = "environs" }, + { name = "nh3" }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "requests" }, @@ -55,6 +56,7 @@ requires-dist = [ { name = "drf-spectacular", specifier = "==0.30.0" }, { name = "drf-std-response", editable = "packages/drf-std-response" }, { name = "environs", specifier = "==15.1.0" }, + { name = "nh3", specifier = "==0.3.6" }, { name = "pillow", specifier = "==12.3.0" }, { name = "psycopg", extras = ["binary"], specifier = "==3.3.4" }, { name = "requests", specifier = "==2.34.2" }, @@ -777,6 +779,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] +[[package]] +name = "nh3" +version = "0.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/1b/ef84624f14954d270f74060a19fc550dd4f06656399447569afb584d8c06/nh3-0.3.6.tar.gz", hash = "sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7", size = 24684, upload-time = "2026-06-22T00:47:02.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/3e/6506aa4f23dc7b7993a2d0a45dca3ce864ec48380adfe15a173e643c63e8/nh3-0.3.6-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2", size = 1421679, upload-time = "2026-06-22T00:46:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e1/e96e7864a7a53bd6b6fab7e9632467382a2a2c1f3fed951918ad131542fb/nh3-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d", size = 792570, upload-time = "2026-06-22T00:46:22.179Z" }, + { url = "https://files.pythonhosted.org/packages/59/62/5b6108bedaef2b2637fed04c87bdbcb5967b9961758b41f0e466ef22a022/nh3-0.3.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e", size = 842243, upload-time = "2026-06-22T00:46:23.801Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4a/526f199626bfcb496bc01a268051b44737962005553b158e985ed7e64865/nh3-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917", size = 1001468, upload-time = "2026-06-22T00:46:25.481Z" }, + { url = "https://files.pythonhosted.org/packages/49/09/0d8e3101636d9ad88cdefb2914e764cb8e876ebdbb4286bfc251277d9c67/nh3-0.3.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4", size = 1082933, upload-time = "2026-06-22T00:46:27.15Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/ea83abe738a3fbaa203dfdb836ca7cbab0e7e9609faaee4fe1d4652599c0/nh3-0.3.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9", size = 1043120, upload-time = "2026-06-22T00:46:28.89Z" }, + { url = "https://files.pythonhosted.org/packages/66/69/0654482b8635012fbae67826bd6c381abb05d841ac7388b9b4666300fdad/nh3-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6", size = 1023824, upload-time = "2026-06-22T00:46:30.453Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/1f7285ffadc8307c4dbeb08d21b920536d5117785056d1079e998c4dfa44/nh3-0.3.6-cp314-cp314t-win32.whl", hash = "sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796", size = 599253, upload-time = "2026-06-22T00:46:32.072Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/5542f3c45da4c00290d9d67a65e996702e23e613c4b627de3e09cb9fe357/nh3-0.3.6-cp314-cp314t-win_amd64.whl", hash = "sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6", size = 612553, upload-time = "2026-06-22T00:46:33.53Z" }, + { url = "https://files.pythonhosted.org/packages/66/35/26bd47e6af5915a628281dccdac354ddf4e32f7397047894270acd8c9870/nh3-0.3.6-cp314-cp314t-win_arm64.whl", hash = "sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41", size = 595151, upload-time = "2026-06-22T00:46:34.878Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25", size = 1443564, upload-time = "2026-06-22T00:46:36.66Z" }, + { url = "https://files.pythonhosted.org/packages/41/21/e1084ab18eb589506335c7c7576f2d4643e9a0c0e33983ef0e549a256b96/nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69", size = 838002, upload-time = "2026-06-22T00:46:38.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/94/f48d08e6f72a406300fa11d8acd929fea1a80d4bf750fa292cb10785f126/nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e", size = 823045, upload-time = "2026-06-22T00:46:39.495Z" }, + { url = "https://files.pythonhosted.org/packages/25/bb/431615ba1d1d3eb63cde0f974f2114edf863a8a3f6049a12fed23fc241d3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae", size = 1093171, upload-time = "2026-06-22T00:46:41.21Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/a0d80182a18919665fefd19c1c06f1d1df1c9a6455d0252de40c034a0bc3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d", size = 1049217, upload-time = "2026-06-22T00:46:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/0a/13/6f1e302ca674ac74362e150848ad56a1be5145391204f74facdb8e94df12/nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf", size = 917372, upload-time = "2026-06-22T00:46:44.495Z" }, + { url = "https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e", size = 806699, upload-time = "2026-06-22T00:46:45.99Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a6/bfaa00046e58603507dcfc266c4778e3ab7adf68a5dedd73b6274b8d9314/nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec", size = 835165, upload-time = "2026-06-22T00:46:47.617Z" }, + { url = "https://files.pythonhosted.org/packages/30/a8/fb2c38845efb703a9173bffdfc745fc64d2b0e55cfc73a3647d2f028250c/nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0", size = 858282, upload-time = "2026-06-22T00:46:49.276Z" }, + { url = "https://files.pythonhosted.org/packages/68/17/06e72a18ee9b572914447338237ca7eb164c0df901f141bc10d1282247a2/nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78", size = 1014328, upload-time = "2026-06-22T00:46:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/11/f9/3966c61455668c08853bf5e33b4bed93c421f3194ce4de896dc248d6f6ce/nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438", size = 1098207, upload-time = "2026-06-22T00:46:52.674Z" }, + { url = "https://files.pythonhosted.org/packages/19/d3/479cb4ae440424825735d60525b53e3c77fd60fd6e6afc0e984f00eb0178/nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56", size = 1056961, upload-time = "2026-06-22T00:46:54.335Z" }, + { url = "https://files.pythonhosted.org/packages/17/0c/6cdb5ee1e127be50dc8391e54bddc1f64e87bf4bfad0c55633320e2e02db/nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f", size = 1033829, upload-time = "2026-06-22T00:46:56.258Z" }, + { url = "https://files.pythonhosted.org/packages/e9/55/9de666ad975d6ccd77d799ea0add55ee2347aa81286ce21b2a97c070746b/nh3-0.3.6-cp38-abi3-win32.whl", hash = "sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da", size = 609081, upload-time = "2026-06-22T00:46:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/82/fa/2b5d684e3edf1e81bfd02d298c78c3e3da77ca1d8a2be3183a79544a7548/nh3-0.3.6-cp38-abi3-win_amd64.whl", hash = "sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10", size = 624461, upload-time = "2026-06-22T00:46:59.163Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e5/7cafee2f0413ca4cb0ef3bd111e94d408a48810008b283ad8aee00dd1809/nh3-0.3.6-cp38-abi3-win_arm64.whl", hash = "sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21", size = 603060, upload-time = "2026-06-22T00:47:00.596Z" }, +] + [[package]] name = "packaging" version = "26.2" From d6efd180a04ca10a0b7cc09c525fd9fe5f8c0848 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:46:55 +1000 Subject: [PATCH 5/5] fix(ci): sync generated API contract artifacts --- AGENTS.md | 15 +++++++++++++++ apps/backend/openapi/v1.yaml | 4 ++-- apps/frontend/app/api/generated/v1.d.ts | 4 ---- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 45530fe..0b4a27c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,9 +101,24 @@ Run the **smallest** check that covers your change. If a check cannot be run, sa | Any Node package (`apps/frontend`, `apps/alienmark`, `packages/alienmark`) | `pnpm run check` (full workspace) or `pnpm turbo run check --filter=` (single package) | | Backend behavior | `uv run python manage.py test` from `apps/backend/`, or `make dev-backend-test` | | Backend lint | `uv run ruff check manage.py` from `apps/backend/` | +| API contract | Regenerate `apps/backend/openapi/v1.yaml`, then run `pnpm --filter frontend api:generate` and commit both generated artifacts | | Docs site | Run both strict Zensical builds from `docs//` (default English config, then `zensical.zh.toml`) | | Unused-code audit (advisory) | `pnpm run knip` | +### API contract synchronization + +When backend permissions, serializers, views, response schemas, or routes change the public API contract: + +```bash +cd apps/backend +DJANGO_SETTINGS_MODULE=backend.settings.test uv run --project ../.. --package aliencommons-backend python manage.py spectacular --file openapi/v1.yaml --validate --fail-on-warn +cd ../.. +pnpm --filter frontend api:generate +pnpm --filter frontend api:check +``` + +Commit both `apps/backend/openapi/v1.yaml` and `apps/frontend/app/api/generated/v1.d.ts` when they change. CI regenerates these files and fails if either committed artifact is stale. + CI mirrors these in `.github/workflows/ci.yml`. If your change alters app names, settings modules, build commands, or verification steps, update the workflow too. ## Working rules diff --git a/apps/backend/openapi/v1.yaml b/apps/backend/openapi/v1.yaml index 4cfabe5..6436939 100644 --- a/apps/backend/openapi/v1.yaml +++ b/apps/backend/openapi/v1.yaml @@ -160,6 +160,7 @@ paths: - article_publications security: - cookieAuth: [] + - {} responses: '200': content: @@ -231,6 +232,7 @@ paths: - article_publications security: - cookieAuth: [] + - {} responses: '200': content: @@ -7367,8 +7369,6 @@ components: html: type: string readOnly: true - title: Article in html - description: The article in HTML format publication_at: type: string format: date-time diff --git a/apps/frontend/app/api/generated/v1.d.ts b/apps/frontend/app/api/generated/v1.d.ts index 704045e..642775d 100644 --- a/apps/frontend/app/api/generated/v1.d.ts +++ b/apps/frontend/app/api/generated/v1.d.ts @@ -1046,10 +1046,6 @@ export interface components { readonly version: number; /** @description The title of the article publication version */ readonly title: string; - /** - * Article in html - * @description The article in HTML format - */ readonly html: string; /** * Published at