Skip to content
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fba1fc7
feat: localization
AlejandroAkbal Apr 19, 2026
8a74556
chore: Guard console logs with import.meta.client
AlejandroAkbal Apr 29, 2026
9f120ba
chore: update deps
AlejandroAkbal Apr 30, 2026
24e0409
Merge origin/main into i18n
AlejandroAkbal Apr 30, 2026
d71c990
feat: Add i18n & locale-aware routing/translation
AlejandroAkbal May 1, 2026
2541ae0
Handle Matomo fetch errors in sitemap
AlejandroAkbal May 2, 2026
65ba1b7
docs: Add AGENTS.md documentation
AlejandroAkbal May 2, 2026
5ffac1c
test: Add SSR canonical URL tests and mocks
AlejandroAkbal May 2, 2026
d2c8652
chore: Bypass auth in tests; update tests & fetch calls
AlejandroAkbal May 3, 2026
32fd88e
test: Append tags to canonical and stabilize posts tests
AlejandroAkbal May 3, 2026
e81f3d7
feat: Use i18n for UI text; refactor tag/title logic
AlejandroAkbal May 4, 2026
9f6e300
Update components/pages/posts/PostsPageFooter.vue
AlejandroAkbal May 4, 2026
441ac6c
fix: fixes suggested by coderabbit
AlejandroAkbal May 4, 2026
062595d
Merge branch 'i18n' of github.com:Rule-34/App into i18n
AlejandroAkbal May 4, 2026
bca3438
feat: Internationalize posts, saved-posts and settings
AlejandroAkbal May 4, 2026
a4b11a8
fix: address CodeRabbit review — localePath guards, Sentry test disab…
AlejandroAkbal May 4, 2026
6c02b06
fix: address second CodeRabbit review — mirroredRouteRules, window.op…
AlejandroAkbal May 4, 2026
986ab40
fix: third CodeRabbit review — sortLabel translation, hasTags derivat…
AlejandroAkbal May 5, 2026
4b9ebc2
fix: fourth CodeRabbit review — filter fallbacks, JSDoc, map collapse…
AlejandroAkbal May 5, 2026
1837129
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] May 5, 2026
172f5e9
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] May 5, 2026
97dc42a
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] May 5, 2026
ecc1b79
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] May 5, 2026
48bbb3c
Preserve canonical ?tags on SSR and CSR
AlejandroAkbal May 5, 2026
4abb856
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] May 5, 2026
256035a
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] May 5, 2026
9f03318
feat: Move OG/meta to server-side app.vue
AlejandroAkbal May 5, 2026
35aa2d3
Merge branch 'i18n' of github.com:Rule-34/App into i18n
AlejandroAkbal May 5, 2026
0357cc5
test: Add SEO tests for canonical and og:image
AlejandroAkbal May 5, 2026
9e1359a
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] May 5, 2026
e63eeca
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] May 7, 2026
637747c
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] May 7, 2026
3b8c892
Merge branch 'main' into i18n
AlejandroAkbal May 7, 2026
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
127 changes: 127 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# AGENTS.md

## Stack

- **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.

## Setup

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

- **Node ≥ 24** required (`package.json` engines)
- **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 |

## 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) |
| `plugins/` | Client plugins loaded in order by numeric prefix (`020.`, `030.`, `035.`, `040.`, `050.`) |
| `server/api/` | Nitro server API routes |
| `server/middleware/` | Nitro middleware |
| `server/plugins/` | Nitro plugins |
| `assets/js/` | Shared JS utilities, DTOs, custom providers |
| `assets/lib/` | Git submodule for shared resources |
| `i18n/locales/` | i18n JSON files (en, ru, es, ja) |
| `components/` | Vue components — **auto-imported flat** (`pathPrefix: false`, no folder prefix) |
| `test/` | Page tests, server tests, mocks |

## Conventions & Gotchas

### Component auto-imports

Components are registered **without path prefix** (`nuxt.config.js` → `components: [{ pathPrefix: false }]`). Import
them as `<DomainSelector>` not `<Input/DomainSelector>`.

### i18n

- Locales are defined in `config/i18n.ts` (single source of truth).
- Non-default locales (ru, es, ja) get URL prefixes. Route rules in `nuxt.config` are mirrored via the
`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.

### SEO & Head Management

- **Static global tags** (favicon, rating, monetization, color-scheme) can live in `nuxt.config.js` `head.meta`.
- **Dynamic global tags** that need the request host (description, keywords, OG image) belong in `app.vue` using
`useSeoMeta` inside an `if (import.meta.server)` guard — `nuxt.config.js` runs too early to know the host.
- **OG image must be absolute**: Open Graph requires absolute URLs. Build it dynamically with
`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.
- **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.
- Legacy redirect: `server/middleware/redirect-to-posts.get.ts` redirects `/?domain=x&page=…&tags=…` →
`/posts/x?page=…&tags=…` (301).

### Images

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.

### PWA

The service worker is intentionally disabled (`selfDestroying: true`). Do not add service worker logic.

### TailwindCSS

Tailwind v4 uses CSS-based config (`assets/css/main.css`), NOT PostCSS. The `tailwind.config.js` remains only for the
`@headlessui/tailwindcss` plugin.

### Sentry

- Client: configured in `sentry.client.options.ts` (replay, third-party error filter, deny URLs).
- Server: `sentry.server.config.ts` reads DSN from env vars directly (before Nuxt boots).
- Source map uploads only happen in production Docker builds (needs `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`
build args).

### Testing

- Tests use `@nuxt/test-utils` with Playwright inside `describe` blocks that call `await setup({ browser: true })`.
- Server-side API calls are mocked via a test-only Nitro plugin at `test/server-mocks/plugin.ts`, injected through
`nuxt.config.js` → `$test.nitro.plugins`.
- In test mode, `$test.runtimeConfig.public.apiUrl` is set to `''` so `$fetch(baseURL: '')` routes to the local Nitro
test server.
- Sentry is fully disabled in tests via `$test.sentry.enabled: false` in `nuxt.config.js`.
- Debug mode: import `debugBrowserOptions` from `test/helper.ts` for headful playback with slowMo.

### 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).
- `NITRO_PRESET` build arg selects the deployment target.

### Prettier
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

94 changes: 17 additions & 77 deletions app.vue
Original file line number Diff line number Diff line change
@@ -1,89 +1,29 @@
<script setup>
import { project } from './config/project.ts'
import {project} from './config/project.ts'

provideHeadlessUseId(() => useId())
provideHeadlessUseId(() => useId())

const route = useRoute()
const runtimeConfig = useRuntimeConfig()

const canonicalUrl = computed(() => {
const url = 'https://' + project.urls.production.hostname + route.fullPath

const parsedUrl = new URL(url)
const canonicalIgnoredParams = new Set(['page', 'cursor', 'source_booru'])

// Keep canonical URLs focused on content-defining params only.
// Redirect attribution params (utm_* and source_booru) are intentionally removed so
// premium-redirect landings don't create duplicate canonicals for the same page.
for (const key of [...parsedUrl.searchParams.keys()]) {
if (canonicalIgnoredParams.has(key) || key.startsWith('utm_')) {
parsedUrl.searchParams.delete(key)
}
}

return parsedUrl.href
})
const { t } = useI18n()

useHead({
htmlAttrs: {
lang: 'en'
},

titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} | ${project.name}` : project.seo.title
},

link: [
// Favicon
{
rel: 'icon',
href: '/favicon.ico',
sizes: '48x48'
},
{
rel: 'icon',
href: '/icon.svg',
sizes: 'any',
type: 'image/svg+xml'
},
{
rel: 'apple-touch-icon',
href: '/apple-touch-icon-180x180.png'
},
// Canonical URL
{
rel: 'canonical',
href: canonicalUrl
},
// Preconnect to API
{
rel: 'preconnect',
href: runtimeConfig.public.apiUrl
}
]
})

useSeoMeta({
charset: 'utf-8',
viewport: 'width=device-width, initial-scale=1',

description: project.seo.description,

keywords: project.seo.keywords.join(', '),

rating: 'adult',

colorScheme: 'dark',
themeColor: project.branding.colors.background,

monetization: '$ilp.uphold.com/Hf3zAn3pQ7fD',

ogImage: {
url: '/social.jpg',
width: 1200,
height: 630
return titleChunk ? `${titleChunk} | ${project.name}` : t('seo.title')
}
})

// These meta tags will only be added during server-side rendering
if (import.meta.server) {
const requestUrl = useRequestURL()

useSeoMeta({
description: computed(() => t('seo.description')),
keywords: computed(() => t('seo.keywords')),
ogImage: `${requestUrl.origin}/social.jpg`,
ogImageWidth: 1200,
ogImageHeight: 630
})
}
</script>

<template>
Expand Down
46 changes: 0 additions & 46 deletions assets/js/SeoHelper.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,5 @@
import Tag from '~/assets/js/tag.dto'
import { lowerCase, startCase } from 'es-toolkit'

export function tagArrayToTitle(tags: Tag[], addWith: boolean = true, addWithout: boolean = true) {
if (!tags.length) {
return null
}

const cleanedTags: string[] = tags
//
.map((tag) => tag.name)
//
.map((tag) => normalizeStringForTitle(tag))
// Remove null
.filter((tag) => tag != null)

const tagsThatStartWithNothing = cleanedTags.filter((tag) => !tag.startsWith('-'))

const tagsThatStartWithMinus = cleanedTags
//
.filter((tag) => tag.startsWith('-'))
.map((tag) => tag.replace('-', ''))

let title = ''

if (tagsThatStartWithNothing.length) {
if (addWith) {
title += 'with '
}

title += tagsThatStartWithNothing.join(', ')
}

if (addWith && addWithout && tagsThatStartWithNothing.length && tagsThatStartWithMinus.length) {
title += ', and'
}

if (tagsThatStartWithMinus.length) {
if (addWithout) {
title += ' without '
}

title += tagsThatStartWithMinus.join(', ')
}

return title
}

export function normalizeStringForTitle(string: string) {
if (!string) {
return null
Expand Down
22 changes: 8 additions & 14 deletions assets/js/sidebarLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,14 @@ import { project } from '@/config/project'

export const sidebarNavigation = [
{
name: 'Home',
nameKey: 'nav.home',
icon: HomeIcon,

href: '/',
isExternal: false
},
{
name: 'Other sites',
nameKey: 'nav.otherSites',
icon: UserGroupIcon,

href: '/other-sites',
isExternal: false
},
Expand All @@ -30,38 +28,34 @@ export const sidebarNavigation = [
? []
: [
{
name: 'Install App',
nameKey: 'nav.installApp',
icon: ArrowDownTrayIcon,
href: `https://www.installpwa.com/from/${project.urls.production.hostname}`,
isExternal: true
}
]),

{
name: 'F.A.Q.',
nameKey: 'nav.faq',
icon: QuestionMarkCircleIcon,

href: 'https://rule34.app/frequently-asked-questions',
isExternal: true
},
{
name: 'Blog',
nameKey: 'nav.blog',
icon: NewspaperIcon,

href: `${project.urls.production.toString()}blog`,
isExternal: false
isExternal: true
},
{
name: 'Legal',
nameKey: 'nav.legal',
icon: BuildingLibraryIcon,

href: '/legal',
isExternal: false
},
{
name: 'Settings',
nameKey: 'nav.settings',
icon: Cog6ToothIcon,

href: '/settings',
isExternal: false
}
Expand Down
12 changes: 6 additions & 6 deletions components/PostPageError.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
const props = defineProps<Props>()

const config = useRuntimeConfig()
const { t } = useI18n()
</script>

<template>
Expand All @@ -24,11 +25,10 @@
/>

<div v-if="error.status === 429">
<h3 class="text-lg leading-10 font-semibold">Too many requests</h3>
<h3 class="text-lg leading-10 font-semibold">{{ t('errors.tooManyRequests') }}</h3>

<span class="w-full overflow-x-auto text-pretty">
You sent too many requests in a short period of time. Use the button below to continue using the
{{ project.name }}
{{ t('errors.tooManyRequestsDescription', { name: project.name }) }}
</span>

<NuxtLink
Expand All @@ -37,12 +37,12 @@
rel="nofollow noopener noreferrer"
target="_blank"
>
Verify I am not a Bot
{{ t('errors.verifyNotBot') }}
</NuxtLink>
</div>

<div v-else>
<h3 class="text-lg leading-10 font-semibold">Failed to load posts</h3>
<h3 class="text-lg leading-10 font-semibold">{{ t('errors.failedToLoadPosts') }}</h3>

<span class="w-full overflow-x-auto text-pretty">
{{ error.data?.message ?? error.message }}
Expand All @@ -55,7 +55,7 @@
type="button"
@click="onRetry"
>
Retry
{{ t('errors.retry') }}
</button>
</div>
</template>
Loading