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:
parent
ef07e3f785
commit
f4816502ed
11 changed files with 80 additions and 51 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }));
|
||||
}
|
||||
|
||||
|
|
|
|||
10
server/og.ts
10
server/og.ts
|
|
@ -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`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue