refactor: Norwegian URL paths

User-visible SPA routes were a mix of English and Norwegian. Bring
them in line with the rest of the project's language:

  /home              → /hjem
  /a/:id             → /aktivitet/:id
  /<username>/list   → /<username>/liste
  /tags/:tag         → /etiketter/:etikett
  /invite/:token     → /invitasjon/:token

The English forms remain accepted by the SPA router (parsePath) and
the server OG handlers so links shared before the rename — invite
URLs in particular — still resolve. Outgoing links always use the
Norwegian forms. API paths (/api/users/:username/list etc.) stay in
English — they're internal contracts between client and server, not
user-visible URLs.

Server OG registration orders /<username>/liste before /aktivitet/:id
so a hypothetical user with the slug "aktivitet" still gets their
profile page rather than an activity-not-found. For normal activity
URLs the user-list route doesn't match (second segment must be the
literal "liste").

Profile copy referencing the URL slug also updated.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 18:20:50 +02:00
commit f4816502ed
11 changed files with 80 additions and 51 deletions

View file

@ -49,7 +49,7 @@ interface ActivityRow {
* NOT surfaced that's a contact identifier, not an attribution.
*
* Also returns the user's URL slug if they've opted into a public list; the
* client uses that to link the attribution to /<username>/list. Returns null
* client uses that to link the attribution to /<username>/liste. Returns null
* for the slug whenever the user hasn't opted in, so the link decision is
* purely server-side.
*/
@ -88,7 +88,7 @@ function viewerBookmarked(activityId: string, viewerId: string | null): boolean
*
* `username` (the link target) is returned independently and is only non-null
* when the owner has opted into a public list drives whether the
* attribution renders as a link to /<username>/list.
* attribution renders as a link to /<username>/liste.
*/
function ownerAttribution(ownerId: string): { display: string | null; username: string | null } {
const row = getDb()

View file

@ -42,11 +42,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
-- Inviter, if this account was created via an invite token. Nullable.
invited_by TEXT REFERENCES users(id),
-- Optional public URL slug. When set + opt-in, the user's public
-- activities are reachable at "/<username>/list". Distinct from
-- activities are reachable at "/<username>/liste". Distinct from
-- display_name because URL slugs need uniqueness and shape constraints
-- (lowercase, [a-z0-9_-]).
username TEXT UNIQUE,
-- Opt-in flag: gates whether /<username>/list actually returns data.
-- Opt-in flag: gates whether /<username>/liste actually returns data.
-- Defaults to 0 so the route stays opt-in.
public_list_enabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
@ -174,7 +174,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
)`,
`CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx ON user_blocks(blocked_id)`,
// External profile links: a small ordered list (max enforced at the app
// layer) the user can attach to their profile. Shown on /<username>/list.
// layer) the user can attach to their profile. Shown on /<username>/liste.
// UNIQUE(user_id, position) keeps ordering stable across rewrites.
`CREATE TABLE IF NOT EXISTS user_links (
id TEXT PRIMARY KEY,

View file

@ -54,23 +54,44 @@ if (process.env.NODE_ENV === 'production') {
// OG-aware routes: render the SPA shell with route-specific OpenGraph
// meta injected, so shared links get rich previews. The SPA still
// bootstraps the same way — only the <head> changes per route.
// Order matters: specific paths first so /personvern doesn't get
// captured by /:username/list (10 chars matches the slug regex).
//
// Both Norwegian (canonical) and English (legacy) paths are registered so
// links shared before the rename still get rich previews. Order matters:
// specific paths first so /personvern doesn't get captured by
// /:username/liste (10 chars matches the slug regex).
const html = (body: string) =>
new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
app.get('/', (c) => html(renderWithOG(ogForHome(c.req.raw))));
app.get('/personvern', (c) => html(renderWithOG(ogForPersonvern(c.req.raw))));
app.get('/a/:id', (c) => html(renderWithOG(ogForActivity(c.req.raw, c.req.param('id')))));
app.get('/tags/:tag', (c) => {
// /<username>/liste registered before /aktivitet/:id so the literal path
// /aktivitet/liste (a hypothetical user with username "aktivitet") routes
// to their public list rather than an activity-not-found page. For normal
// activity URLs (/aktivitet/<cuid>) this route doesn't match anyway —
// second segment must literally be "liste".
app.get('/:username/liste', (c) => html(renderWithOG(ogForUserList(c.req.raw, c.req.param('username')))));
app.get('/:username/list', (c) => html(renderWithOG(ogForUserList(c.req.raw, c.req.param('username'))))); // legacy alias
app.get('/aktivitet/:id', (c) => html(renderWithOG(ogForActivity(c.req.raw, c.req.param('id')))));
app.get('/a/:id', (c) => html(renderWithOG(ogForActivity(c.req.raw, c.req.param('id'))))); // legacy alias
// Hono types each route's context generically, so we can't share a single
// handler closure across two different paths. Inline both — they're tiny.
app.get('/etiketter/:tag', (c) => {
let tag: string;
try { tag = decodeURIComponent(c.req.param('tag')); }
catch { tag = c.req.param('tag'); }
return html(renderWithOG(ogForTag(c.req.raw, tag)));
});
app.get('/tags/:tag', (c) => { // legacy alias
let tag: string;
try { tag = decodeURIComponent(c.req.param('tag')); }
catch { tag = c.req.param('tag'); }
return html(renderWithOG(ogForTag(c.req.raw, tag)));
});
app.get('/:username/list', (c) => html(renderWithOG(ogForUserList(c.req.raw, c.req.param('username')))));
// SPA fallback for anything else (login/signup/profile/admin/feedback views,
// /invite/<token>, etc. — none need route-specific OG).
// /invitasjon/<token>, etc. — none need route-specific OG).
app.get('*', serveStatic({ path: './frontend/dist/index.html' }));
}

View file

@ -133,13 +133,13 @@ export function ogForActivity(req: Request, id: string): OG {
// Private and friends-only behave like nonexistent rows for the scraper:
// we don't reveal that the id resolves to a hidden activity.
if (!row || row.visibility === 'private' || row.visibility === 'friends') {
return NOT_FOUND_OG(req, `/a/${id}`);
return NOT_FOUND_OG(req, `/aktivitet/${id}`);
}
return {
title: `${row.title ?? 'Vinteraktivitet'} · Vinterliste`,
description: row.description ?? 'Vinteraktivitet delt på Vinterliste.',
type: 'article',
url: `${baseUrl(req)}/a/${id}`,
url: `${baseUrl(req)}/aktivitet/${id}`,
image: `${baseUrl(req)}/icon.svg`,
};
}
@ -149,7 +149,7 @@ export function ogForTag(req: Request, tag: string): OG {
return {
title: `Etikett: ${cleaned} · Vinterliste`,
description: `Aktiviteter merket med «${cleaned}» på Vinterliste.`,
url: `${baseUrl(req)}/tags/${encodeURIComponent(cleaned)}`,
url: `${baseUrl(req)}/etiketter/${encodeURIComponent(cleaned)}`,
image: `${baseUrl(req)}/icon.svg`,
};
}
@ -164,14 +164,14 @@ export function ogForUserList(req: Request, username: string): OG {
// Same "not found" treatment for not-opted-in users as for truly missing
// usernames — matches what GET /api/users/:username/list returns.
if (!row || row.public_list_enabled !== 1) {
return NOT_FOUND_OG(req, `/${username}/list`);
return NOT_FOUND_OG(req, `/${username}/liste`);
}
const name = row.display_name?.trim() || username;
return {
title: `${name} · Vinterliste`,
description: `Offentlige vinteraktiviteter fra ${name}.`,
type: 'profile',
url: `${baseUrl(req)}/${username}/list`,
url: `${baseUrl(req)}/${username}/liste`,
image: `${baseUrl(req)}/icon.svg`,
};
}

View file

@ -75,7 +75,7 @@ usersRoutes.get('/:username/list', (c) => {
// Prefer the display name; fall back to the username slug (which is
// what the URL already shows). Never falls through to email/id.
owner_display: (user.display_name && user.display_name.trim()) || username,
// The list itself is at /<username>/list, so we already know the slug.
// The list itself is at /<username>/liste, so we already know the slug.
// Surfacing it on each row keeps ActivityRow's rendering uniform.
owner_username: username,
title: r.title ?? '',