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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"i18n-ally.localesPaths": ["i18n", "locales", "i18n/locales"]
}
67 changes: 51 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,38 @@
- **Nuxt 4** (SSR, Nitro server) with **Vue 3** + TypeScript
- **TailwindCSS v4** via `@tailwindcss/vite` Vite plugin (NOT PostCSS)
- **Vitest** + `@nuxt/test-utils` with Playwright browser mode for testing
- **Prettier** (formatting). ESLint config exists but is not wired to any npm script.
- **Prettier** (formatting). ESLint config exists but is not wired to any package script.

## Setup

```bash
cp .example.env .env # then edit .env
npm install # triggers nuxt prepare via postinstall
pnpm install # triggers nuxt prepare via postinstall
```

- **Node ≥ 24** required (`package.json` engines)
- **pnpm ≥ 11.1** required (`packageManager` pins the expected version)
- **Git submodule** at `assets/lib/rule-34-shared-resources` — clone with `--recursive`
- **External API**: the app calls a separate API service at `NUXT_PUBLIC_API_URL` (default `http://localhost:8081`). The
API codebase is at [github.com/Rule-34/API](https://github.com/Rule-34/API).

## Commands

| Command | What it does |
|----------------------|-----------------------------------------------|
| `npm run dev` | Dev server at `localhost:8080` |
| `npm run build` | Production build into `.output/` |
| `npm run generate` | Static generation |
| `npm test` | `vitest run` |
| `npm run test:watch` | `vitest watch` |
| `npm run release` | `standard-version` for versioning + changelog |
| Command | What it does |
| ----------------- | --------------------------------------------- |
| `pnpm dev` | Dev server at `localhost:8080` |
| `pnpm build` | Production build into `.output/` |
| `pnpm generate` | Static generation |
| `pnpm test` | `vitest run` |
| `pnpm test:watch` | `vitest watch` |
| `pnpm release` | `standard-version` for versioning + changelog |

## Architecture

Single Nuxt app. Key directories:

| Dir | Purpose |
|----------------------|-------------------------------------------------------------------------------------------|
| -------------------- | ----------------------------------------------------------------------------------------- |
| `config/` | Centralized project config (`project.ts` for branding/URLs, `i18n.ts` for locales) |
| `app/` | Nuxt app-level config (router options, SPA loading template) |
| `composables/` | Shared Vue composables (auto-imported by Nuxt) |
Expand Down Expand Up @@ -63,8 +64,8 @@ them as `<DomainSelector>` not `<Input/DomainSelector>`.
`mirroredRouteRules()` helper so prefixed paths get the same caching/SSR rules.
- **Known bug**: `canonicalQueries` in the i18n module config is a no-op in v10. A two-part workaround is required:
1. SSR: `server/plugins/fix-canonical-queries.ts` patches the canonical `<link>` in rendered HTML.
2. CSR: `pages/posts/[domain].vue` uses `useHead` to re-apply the canonical after i18n overwrites it on hydration.
See the removal checklist in `fix-canonical-queries.ts` for when upstream fixes this.
2. CSR: `pages/posts/[domain]/index.vue` uses `useHead` to re-apply the canonical after i18n overwrites it on hydration.
See the removal checklist in `fix-canonical-queries.ts` for when upstream fixes this.

### SEO & Head Management

Expand All @@ -75,11 +76,16 @@ them as `<DomainSelector>` not `<Input/DomainSelector>`.
`useRequestURL().origin` on the server only (`app.vue`). i18n does not touch `og:image` during hydration.
- **Canonical URLs must point to production** (`https://r34.app/…`) even when served from clone domains. This is
intentional for SEO — canonicals prevent duplicate content. Use `project.urls.production` for canonicals.
- **Schema.org breadcrumb item URLs should stay local/locale-relative**. Do not convert breadcrumb items to
`project.urls.production`; production-absolute URLs are for canonicals.
- **Page-specific tags** (title, description) should use `useSeoMeta` in the page component.

### Router

- Custom scroll behavior: skips scroll-to-top when only the `page` query param changes between same-route navigations.
- Query filters intentionally use flat bracket keys (`filter[sort]`, `filter[rating]`, etc.) with Vue Router's default
query handling. Do not re-add `qs` for nested `route.query.filter` objects unless the URL contract changes; `qs` puts
a measurable parser/stringifier cost on the first-load router path.
- Legacy redirect: `server/middleware/redirect-to-posts.get.ts` redirects `/?domain=x&page=…&tags=…` →
`/posts/x?page=…&tags=…` (301).

Expand All @@ -88,6 +94,35 @@ them as `<DomainSelector>` not `<Input/DomainSelector>`.
A custom `imgproxy` provider is registered for `<NuxtImg>` (see `nuxt.config.js` → `image.providers`). Images are
deliberately generated at 1x density only (webp format) to reduce bandwidth.

- `@nuxt/image` v2 supports `preload: { fetchPriority: 'high' }`. Use the module API for image preload priority instead
of patching rendered HTML in Nitro.
- `PostMedia` uses imgproxy for SSR post images, including local development. Non-premium SPA navigations keep the direct
image path; validate image delivery in an environment where imgproxy can resolve the source URL.

### Headless UI

- Do not add `provideHeadlessUseId` in `app.vue` while the project uses Vue 3.5+ and `@headlessui/vue` 1.7.23+; those
versions use Vue's native `useId` and the Nuxt Headless UI workaround is only for older versions.

### Performance

- Prefer high-impact, measurable optimizations over small rewrites. Keep battle-tested dependencies unless replacing one
has a clear, measured payoff.
- After substantial performance changes, verify with a production build, relevant tests, request traces, and Lighthouse
against the built app before deciding the change is worth keeping.
- Production is behind Cloudflare, which Brotli-compresses HTML responses. Do not add app-level HTML compression unless
a direct-origin deployment needs it and the change is verified with headers, byte sizes, warm TTFB, and Lighthouse.
- Keep the global TanStack Vue Query plugin unless a larger measured payoff appears. A route-scoped `QueryClient`
experiment on 2026-05-17 saved only about 8 KB compressed on the homepage and did not move Lighthouse, while adding
custom SSR hydration logic.
- Keep `features.inlineStyles: false` unless new measurements justify revisiting it. Enabling it on 2026-05-17 doubled
homepage HTML from about 51 KB to 106 KB, increased Lighthouse byte weight from 361 KiB to 406 KiB, and did not improve
the performance score.
- Keep `@formkit/auto-animate` route-scoped unless it is used broadly. The Nuxt module registers a global directive and
puts the runtime in the first-load entry; local `vAutoAnimate` imports on the premium CSR pages saved about 3 KB gzip.
- For URL validation/parsing, prefer `URL.canParse()` or `URL.parse()` over constructor `try/catch`; use `URL.parse()`
when the parsed URL object is needed, with a `URL.canParse()` fallback in browser code if compatibility matters.

### PWA

The service worker is intentionally disabled (`selfDestroying: true`). Do not add service worker logic.
Expand Down Expand Up @@ -116,12 +151,12 @@ Tailwind v4 uses CSS-based config (`assets/css/main.css`), NOT PostCSS. The `tai

### Docker production build

- Multi-stage: build stage needs `SENTRY_*` args for source map uploads; production stage copies only `.output/` (no
`node_modules` needed — Nitro bundles everything).
- Multi-stage: build stage uses `pnpm install --frozen-lockfile`. Source map uploads need `SENTRY_ORG`,
`SENTRY_PROJECT`, and `SENTRY_AUTH_TOKEN`; set `SENTRY_UPLOAD_SOURCE_MAPS=false` to skip them. The production stage
copies only `.output/` (no `node_modules` needed — Nitro bundles everything).
- `NITRO_PRESET` build arg selects the deployment target.

### Prettier

Key settings: 120-char print width, no semicolons, single quotes, trailing commas removed, single attribute per line in
Vue templates.

10 changes: 6 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,23 @@ ARG NITRO_PRESET
ARG SENTRY_ORG
ARG SENTRY_PROJECT
ARG SENTRY_AUTH_TOKEN
ARG SENTRY_UPLOAD_SOURCE_MAPS=true

ENV NITRO_PRESET=${NITRO_PRESET} \
SENTRY_ORG=${SENTRY_ORG} \
SENTRY_PROJECT=${SENTRY_PROJECT} \
SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} \
SENTRY_UPLOAD_SOURCE_MAPS=${SENTRY_UPLOAD_SOURCE_MAPS}

WORKDIR /app

COPY package.json package-lock.json ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./

RUN npm ci
RUN corepack enable && pnpm install --frozen-lockfile

COPY . .

RUN npm run build
RUN pnpm run build

# Stage 2: Production
FROM node:${NODE_VERSION}-alpine AS production
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,16 @@ cp .example.env .env

```bash
# Install dependencies
npm install
pnpm install

# Serve with hot reload at localhost:8080
npm run dev
pnpm dev

# Build for production
npm run build
pnpm build

# Generate static project
npm run generate
pnpm generate
```
Comment on lines +103 to 113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Align prerequisites with the new pnpm workflow.

The setup commands now use pnpm, but the requirements section still lists NPM. Please update prerequisites to avoid contradictory setup instructions.

Suggested doc fix
- - NPM
+ - pnpm (recommended via Corepack)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 103 - 113, Update the README prerequisites to match
the new pnpm workflow: replace any mention of "NPM" with "pnpm", add a short
note to install pnpm (e.g., via npm: `npm install -g pnpm`) or link to pnpm
docs, and ensure the example setup commands (pnpm install, pnpm dev, pnpm build,
pnpm generate) are consistent with the prerequisites section so users aren’t
shown conflicting package manager instructions.


For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org).
3 changes: 0 additions & 3 deletions app.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
<script setup>
import { project } from './config/project.ts'

provideHeadlessUseId(() => useId())

const runtimeConfig = useRuntimeConfig()
const { t } = useI18n()

useHead({
Expand Down
51 changes: 35 additions & 16 deletions app/router.options.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
import type { RouterConfig } from '@nuxt/schema'
import qs from 'qs'
import { isEqual } from 'es-toolkit'
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded } from 'vue-router'

// https://router.vuejs.org/api/interfaces/routeroptions.html
export default <RouterConfig>{
// Fix router query parser - https://github.com/vuejs/vue-router/issues/1259#issuecomment-1571553624
parseQuery: qs.parse,

stringifyQuery: qs.stringify,

scrollBehavior: (to, from, savedPosition) => {
if (savedPosition) {
return savedPosition
Expand Down Expand Up @@ -50,17 +43,43 @@ function shouldSkipIfOnlyPageQueryChanged(to: RouteLocationNormalized, from: Rou
return false
}

const toQueryWithoutPage = { ...to.query, page: undefined }
const fromQueryWithoutPage = { ...from.query, page: undefined }
return areQueriesEqualWithoutPage(to.query, from.query)
}

const queriesWithoutPageAreEqual = isEqual(toQueryWithoutPage, fromQueryWithoutPage)
function areQueriesEqualWithoutPage(
toQuery: RouteLocationNormalized['query'],
fromQuery: RouteLocationNormalized['query']
) {
const { page: _toPage, ...toQueryWithoutPage } = toQuery
const { page: _fromPage, ...fromQueryWithoutPage } = fromQuery

// console.log('toQueryWithoutPage', toQueryWithoutPage)
// console.log('fromQueryWithoutPage', fromQueryWithoutPage)
const toKeys = Object.keys(toQueryWithoutPage)
const fromKeys = Object.keys(fromQueryWithoutPage)

// if (!queriesWithoutPageAreEqual) {
// return false
// }
if (toKeys.length !== fromKeys.length) {
return false
}

return toKeys.every((key) => {
if (!Object.prototype.hasOwnProperty.call(fromQueryWithoutPage, key)) {
return false
}

return areQueryValuesEqual(toQueryWithoutPage[key], fromQueryWithoutPage[key])
})
}

function areQueryValuesEqual(
toValue: RouteLocationNormalized['query'][string],
fromValue: RouteLocationNormalized['query'][string]
) {
if (Array.isArray(toValue) || Array.isArray(fromValue)) {
if (!Array.isArray(toValue) || !Array.isArray(fromValue)) {
return false
}

return toValue.length === fromValue.length && toValue.every((value, index) => value === fromValue[index])
}

return true
return toValue === fromValue
}
66 changes: 63 additions & 3 deletions assets/js/RouterHelper.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import Tag from './tag.dto'
import type { RouteLocationRaw } from 'vue-router'
import type { LocationQuery, LocationQueryRaw, RouteLocationRaw } from 'vue-router'

export const fallbackBooruDomain = 'rule34.xxx'

const facetedTagPrefixes = new Set([
'date',
'height',
'id',
'limit',
'mpixels',
'order',
'parent',
'rating',
'score',
'sort',
'source',
'user',
'width'
])

export function generatePostsRoute(
path: string = '/posts',
domain?: string | undefined | null,
page?: number | undefined | null,
tags?: Tag[] | undefined | null,
filters?: Object | undefined | null
filters?: Record<string, unknown> | undefined | null
) {
const route: RouteLocationRaw = {
path,
Expand All @@ -29,12 +45,46 @@ export function generatePostsRoute(

// Check if object keys are not undefined
if (filters != null && !isObjectEmpty(filters)) {
route.query.filter = filters
assignFilterQuery(route.query, filters)
}

return route
}

export function generatePostTagLandingPath(domain: string, tag: string, basePath: string = '/posts') {
return `${basePath}/${domain}/${encodeURIComponent(tag)}`
}

export function getSinglePositiveTagQueryValue(value: string | string[] | null | (string | null)[] | undefined) {
if (Array.isArray(value) && value.length !== 1) {
return undefined
}

const tag = getSingleQueryValue(value)

if (!tag || tag.startsWith('-') || tag.includes('|') || /\s/.test(tag)) {
return undefined
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const tagPrefix = tag.split(':', 1)[0]

if (facetedTagPrefixes.has(tagPrefix)) {
return undefined
}

return tag
}

export function getFilterQueryValue(query: LocationQuery, key: string) {
const nestedFilter = query.filter

if (nestedFilter && typeof nestedFilter === 'object' && !Array.isArray(nestedFilter)) {
return getSingleQueryValue((nestedFilter as LocationQuery)[key])
}

return getSingleQueryValue(query[`filter[${key}]`])
}

export function getSingleQueryValue(value: string | string[] | null | (string | null)[] | undefined) {
if (Array.isArray(value)) {
const firstValue = value[0]
Expand All @@ -49,6 +99,16 @@ export function getSingleQueryValue(value: string | string[] | null | (string |
return value
}

function assignFilterQuery(query: LocationQueryRaw, filters: Record<string, unknown>) {
for (const [key, value] of Object.entries(filters)) {
if (value === undefined) {
continue
}

query[`filter[${key}]`] = value as LocationQueryRaw[string]
}
}

function isObjectEmpty(obj) {
return obj && Object.keys(obj).length === 0 && obj.constructor === Object
}
4 changes: 2 additions & 2 deletions assets/js/nuxt-image/imgproxy.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const operationsGenerator = createOperationsGenerator({

function urlSafeBase64(string: string) {
const bytes = new TextEncoder().encode(string)
const binaryString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('')
const binaryString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join('')

return btoa(binaryString).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}
Expand Down Expand Up @@ -76,7 +76,7 @@ const getImage: ProviderGetImage<ImgproxyProviderOptions> = (src, options) => {

const mergeModifiers = { ...defaultModifiers, ...modifiers }

// Remove width and height to generate more common URLs for better caching
// Remove dimensions from imgproxy operations so the same source reuses a common cached URL.
const { width, height, ...modifiersWithoutSize } = mergeModifiers

// Build rewriter URL: nginx-proxy fetches the source and strips headers
Expand Down
Loading