openvibers/openvibe.community Commits

Forum: move /s to /forum, threads to /forum/<space>/<slug> (#277)

OP OpenVibers committed 6 days ago · 76a4d0d9c0 · 49 files, +511 −417

The forum was the last /s (spaces) leftover. The board is now /forum, a space
/forum/<space>, a thread /forum/<space>/<thread> (the /t/ segment is gone), and
/forum/new and /forum/discuss replace /s/new-space and /s/discuss.

Every old /s address answers a permanent redirect with the query kept: 301 for reads,
308 for form posts, so /s/<space>/t/<slug> lands on /forum/<space>/<slug> without
losing the method or body. The redirects stay permanently (search engines and old
links use them).

A thread slug may no longer be one of a space's own sub-route words (new, log,
settings, feed.xml, moderators, chat-room, members, about, search, rules, wiki,
edit, discuss): a title that would make one takes -2. A space may not take new,
discuss, feed.xml, search or about.

Everything that builds a forum URL moved in the same change: sitemap, robots
(/forum/*/new), IndexNow pings, feeds, llms.txt and llms-full.txt, canonical
links and BreadcrumbList, Search documents, Pulse items, community.thread/post
.created URLs and the Discord relay. Production was read first: no existing space
or thread has a reserved slug, so no data migration is needed.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex de5a728..43f4dfe 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,18 @@
## Unreleased
++- **The forum moved from `/s` to `/forum`** (design 2026-11-07, section 13 "URLs people already know"). The board
++ is `/forum`, a space `/forum/<space>`, and a thread `/forum/<space>/<thread>` (the `/t/` segment is gone);
++ `/forum/new` and `/forum/discuss` replace `/s/new-space` and `/s/discuss`. Every old `/s` address answers a
++ permanent redirect with the query kept — 301 for reads, 308 for form posts, so `/s/<space>/t/<slug>` becomes
++ `/forum/<space>/<slug>` without losing a method or body.
++ - A thread slug may never be one of a space's own sub-route words (`new`, `log`, `settings`, `feed.xml`,
++ `moderators`, `chat-room`, `members`, `about`, `search`, `rules`, `wiki`, `edit`, `discuss`): a title that would
++ make one takes `-2`. A space may not take `new`, `discuss`, `feed.xml`, `search` or `about`.
++ - Sitemap, robots (`/forum/*/new`), IndexNow pings, feeds, `llms.txt`, canonical links and BreadcrumbList
++ (`Forum › Space › Thread`), Search documents, Pulse items, `community.thread/post.created` URLs and the Discord
++ relay all use `/forum`.
++
- **Locked pastes** (Locker design 2026-11-06, "The pilot, spelled out: locked pastes"). A new text paste can be
locked: the browser seals it, Community stores the envelope and never sees the content key.
- `POST /api/pastes` takes `locked: true` and `envelope: <base64 locker.envelope.v1>` **instead of** `content`; a
ddiff --git a/README.md b/README.md
iindex cd9346f..fe20530 100644
---- a/README.md
++++ b/README.md
@@@ -143,15 +143,15 @@ Pages (server-rendered HTML):
| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
| `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` |
| `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |
--| `GET /s` | The Forum (board index); `GET /forum[/…]` answers 301 → `/s[/…]` |
--| `GET /s/:space` | Threads — `?sort=hot\|new\|top`, `?page=`, and the small RSS link beside its title |
--| `GET /s/:space/t/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms, and "Follow this thread by RSS" |
--| `GET /s/:space/log` | The space's public moderation log (newest 100): when, what, action, rule, reason, and the actor's role only |
--| `GET /s/:space/t/:slug/feed.xml` | RSS of the thread's newest 50 posts, under the thread's own visibility |
--| `GET /s/:space/new`, `POST /s/:space/new` | Start a thread (signed in) |
--| `POST /s/:space/t/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete); moderator forms take an optional `rule` and `reason` |
--| `POST /s/:space/t/:slug/status` | The no-JS status form (moderators), with the same optional `rule` and `reason` |
--| `POST /s/:space/t/:slug/post/:id/delete` | A moderator removes one post, with the same optional `rule` and `reason` |
++| `GET /forum` | The Forum (board index); `GET /forum[/…]` answers 301 → `/forum[/…]` |
++| `GET /forum/:space` | Threads — `?sort=hot\|new\|top`, `?page=`, and the small RSS link beside its title |
++| `GET /forum/:space/:slug` | A thread with its posts (`?page=`), reply / vote / moderation forms, and "Follow this thread by RSS" |
++| `GET /forum/:space/log` | The space's public moderation log (newest 100): when, what, action, rule, reason, and the actor's role only |
++| `GET /forum/:space/:slug/feed.xml` | RSS of the thread's newest 50 posts, under the thread's own visibility |
++| `GET /forum/:space/new`, `POST /forum/:space/new` | Start a thread (signed in) |
++| `POST /forum/:space/:slug/reply\|vote\|state\|delete` | The no-JS forms (reply, vote, pin/lock, delete); moderator forms take an optional `rule` and `reason` |
++| `POST /forum/:space/:slug/status` | The no-JS status form (moderators), with the same optional `rule` and `reason` |
++| `POST /forum/:space/:slug/post/:id/delete` | A moderator removes one post, with the same optional `rule` and `reason` |
| `GET /pulse` | Public activity across the network — `?origin=user\|ai\|system`, `?after=` |
| `GET /submissions`, `POST /submissions` | Accepted submissions — `?kind=clip\|art\|idea\|report`, `?mine=1` (your own, any status), `?after=`; signed-in people submit with the no-JS form |
| `GET /submissions/:slug` | One submission — accepted: anyone (canonical, Open Graph); pending, rejected, withdrawn: its author and moderators only (`noindex`), 404 for anyone else |
@@@ -180,8 +180,8 @@ API and machine endpoints:
| `POST /auth/refresh` | Rotate via refresh token |
| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
| `GET /llms.txt`, `GET /llms-full.txt` | The site map for language models, and the latest public threads and text pastes in full |
--| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |
--| `GET /s/:space/t/:slug/feed.xml` | RSS of a thread's newest 50 posts (the thread's own visibility) |
++| `GET /forum/feed.xml`, `GET /forum/:space/feed.xml` | RSS of the latest threads (public spaces) |
++| `GET /forum/:space/:slug/feed.xml` | RSS of a thread's newest 50 posts (the thread's own visibility) |
**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
when the required `db` check fails (a real round trip that names the store, postgresql or pglite, and a migrated schema). `network_jwks` (the
@@@ -346,7 +346,7 @@ member for now), `staff` (moderators only; looks missing to everyone else).
refused). Thread pages carry canonical, Open Graph `article`, `DiscussionForumPosting`
JSON-LD (author, dates, counters, the replies on the page as `Comment`s) and breadcrumbs;
members/staff spaces are `noindex,nofollow` and never in the sitemap or feeds. The sitemap
-- lists `/s`, `/pulse`, public spaces and the latest 1000 threads; `/s/*/new` is disallowed in
++ lists `/forum`, `/pulse`, public spaces and the latest 1000 threads; `/forum/*/new` is disallowed in
robots.txt.
- **Sorting**: `hot` (default), `new`, `top`; pinned threads lead every sort. Hot is the
deterministic `ov_hot(score, age_hours) = (score + 1) / (age_hours + 2) ^ 1.5`
@@@ -375,12 +375,12 @@ member for now), `staff` (moderators only; looks missing to everyone else).
`docs/cutover-community-0005-space-moderators.md`.
- **Two styles, one system.** Each space is a **forum** or a **feed**. A forum reads like vBulletin or SMF:
-- - the board index at `/s` lists every space under its group, with topics, posts and the last post;
++ - the board index at `/forum` lists every space under its group, with topics, posts and the last post;
- a board is a topic table (Replies, Views, Last post) with sticky topics first and replies bumping a topic;
- a topic shows each post beside its author's panel: picture, posts, since when, the ratings they get most.
Quote fills the reply box. A feed is subreddit-like: votes and hot/new/top. Moderators switch per space:
the style, up/down votes and ratings. They also set the group, a parent (one level of child boards),
-- the name and description, and create spaces (`/s/new-space`).
++ the name and description, and create spaces (`/forum/new`).
Both styles share threads, ratings, images, pastes and crossposts: a thread can be crossposted to another
space, and both link to each other.
- **Ratings** (after Facepunch), one per person per post, never your own:
@@@ -431,7 +431,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| `DELETE /spaces/:space/chat-room` | The space's owner or staff (moderator services too): detach it (idempotent) |
| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` `{ rule?, reason? }` · `GET /posts/:id/versions` | Author or moderator |
| `GET /spaces/:space/moderators` | The space's own moderators `{ space, moderators: [{ subject, username, display_name, added_by, added_at }] }` (whoever can read the space) |
--| `PUT`/`DELETE /spaces/:space/moderators/:subject` | The space's moderators or staff: add (blocks refuse, 403 `community.blocked`) or remove one (idempotent). `:subject` is a Network user subject (`usr_…`) or an `@username`, resolved through the Network (404 `moderator.unknown_user` when nobody holds it). The space page shows its moderators a Moderators box (list, remove or step down, add by @username); its no-JS forms are `POST /s/:space/moderators` and `POST /s/:space/moderators/remove` `{ subject }`, and a refusal comes back as a notice in the box |
++| `PUT`/`DELETE /spaces/:space/moderators/:subject` | The space's moderators or staff: add (blocks refuse, 403 `community.blocked`) or remove one (idempotent). `:subject` is a Network user subject (`usr_…`) or an `@username`, resolved through the Network (404 `moderator.unknown_user` when nobody holds it). The space page shows its moderators a Moderators box (list, remove or step down, add by @username); its no-JS forms are `POST /forum/:space/moderators` and `POST /forum/:space/moderators/remove` `{ subject }`, and a refusal comes back as a notice in the box |
### A space's chat room (OpenVibe.Chat)
@@@ -665,9 +665,9 @@ Discovery is written by `openvibe-shared/seo`; Community only supplies the data
`WebSite` on the home page, `Article`/`ImageObject` with author and `datePublished` on paste
pages, `DiscussionForumPosting` on threads, `BreadcrumbList` everywhere.
Unlisted/private pastes, search result pages and members/staff spaces are `noindex`. The
--sitemap lists home, `/pastes`, the latest public pastes, `/s`, `/pulse`, public spaces and
++sitemap lists home, `/pastes`, the latest public pastes, `/forum`, `/pulse`, public spaces and
their latest threads (rebuilt at most hourly). `/robots.txt` welcomes search and AI crawlers
--and keeps them out of `/api/`, `/auth/`, `/my`, `/new`, `/s/*/new` and `?sso=` URLs;
++and keeps them out of `/api/`, `/auth/`, `/my`, `/new`, `/forum/*/new` and `?sso=` URLs;
`/llms.txt` maps the site and `/llms-full.txt` carries the latest public threads and text pastes
in full (no NSFW, no burn-after-read, nothing members-only).
ddiff --git a/docs/forum/starters.json b/docs/forum/starters.json
iindex 00fbcf3..3c34c71 100644
---- a/docs/forum/starters.json
++++ b/docs/forum/starters.json
@@@ -5,7 +5,7 @@
"key": "welcome",
"space": "general",
"title": "Welcome to OpenVibe: start here",
-- "body": "OpenVibe is a network of open-source sites that share one account. Sign in once at [openvibe.network](https://openvibe.network) and the same name, avatar and followers work everywhere.\n\nA few places to start:\n\n- **[OpenVibe.Live](https://openvibe.live)**: go live straight from your browser, with no OBS and no follower minimum. [How it works](https://openvibe.live/docs/go-live-in-your-browser).\n- **[OpenVibe.Tools](https://openvibe.tools)**: free tools that work in the browser: converters, PDF, images, text and developer tools.\n- **[OpenVibe.Games](https://openvibe.games)**: games to play, creator worlds and what is on Steam right now.\n- **[OpenVibe.Space](https://openvibe.space)**: make your own site and publish it.\n- **[OpenVibe.Help](https://openvibe.help)**: answers about every site, and tickets when you need a person.\n\nThis forum is for anything about OpenVibe and the people on it. Introduce yourself, ask what something does, or tell us what you are making.\n\nIdeas and bugs go in [Feedback](/s/feedback), where people vote on them. What we are building next is on the [Roadmap](/s/roadmap). Everything here follows the [terms](/terms)."
++ "body": "OpenVibe is a network of open-source sites that share one account. Sign in once at [openvibe.network](https://openvibe.network) and the same name, avatar and followers work everywhere.\n\nA few places to start:\n\n- **[OpenVibe.Live](https://openvibe.live)**: go live straight from your browser, with no OBS and no follower minimum. [How it works](https://openvibe.live/docs/go-live-in-your-browser).\n- **[OpenVibe.Tools](https://openvibe.tools)**: free tools that work in the browser: converters, PDF, images, text and developer tools.\n- **[OpenVibe.Games](https://openvibe.games)**: games to play, creator worlds and what is on Steam right now.\n- **[OpenVibe.Space](https://openvibe.space)**: make your own site and publish it.\n- **[OpenVibe.Help](https://openvibe.help)**: answers about every site, and tickets when you need a person.\n\nThis forum is for anything about OpenVibe and the people on it. Introduce yourself, ask what something does, or tell us what you are making.\n\nIdeas and bugs go in [Feedback](/forum/feedback), where people vote on them. What we are building next is on the [Roadmap](/forum/roadmap). Everything here follows the [terms](/terms)."
},
{
"key": "how-to-get-help",
@@@ -17,7 +17,7 @@
"key": "how-feedback-works",
"space": "feedback",
"title": "How feedback works",
-- "body": "This is where ideas, bugs and questions about every OpenVibe site go. Posts are ranked by votes, so what most people want rises to the top.\n\n- **One idea or one bug per post.** Search first: if it is already here, vote for it and add what is different for you.\n- **Pick a category** (idea, bug or question) when you post.\n- **For a bug**, say which site and page, what you did, what you expected and what happened. A screenshot helps.\n- **For an idea**, say what you are trying to do. The problem matters more than the exact solution.\n\nStaff mark each post as **planned**, **in progress**, **done** or **declined**, and the status shows on the post. Bigger pieces of work go on the [Roadmap](/s/roadmap), where every item has its own thread.\n\nSecurity problems should not be posted in public. Open a private ticket at [OpenVibe.Help](https://openvibe.help) instead."
++ "body": "This is where ideas, bugs and questions about every OpenVibe site go. Posts are ranked by votes, so what most people want rises to the top.\n\n- **One idea or one bug per post.** Search first: if it is already here, vote for it and add what is different for you.\n- **Pick a category** (idea, bug or question) when you post.\n- **For a bug**, say which site and page, what you did, what you expected and what happened. A screenshot helps.\n- **For an idea**, say what you are trying to do. The problem matters more than the exact solution.\n\nStaff mark each post as **planned**, **in progress**, **done** or **declined**, and the status shows on the post. Bigger pieces of work go on the [Roadmap](/forum/roadmap), where every item has its own thread.\n\nSecurity problems should not be posted in public. Open a private ticket at [OpenVibe.Help](https://openvibe.help) instead."
},
{
"key": "show-what-you-made",
ddiff --git a/public/css/modlog.css b/public/css/modlog.css
iindex 75eaafa..7d48c38 100644
---- a/public/css/modlog.css
++++ b/public/css/modlog.css
@@@ -1,4 +1,4 @@
--/* OpenVibe.Community: the moderation log page (/s/:space/log), linked by that page alone (renderPage pageCss) so the
++/* OpenVibe.Community: the moderation log page (/forum/:space/log), linked by that page alone (renderPage pageCss) so the
other pages carry none of it. A table on a computer; on a phone each row is a card. */
.mod-log th:first-child { width: 7.5em; }
.mod-log th:nth-child(3) { width: 7em; }
ddiff --git a/server/app.js b/server/app.js
iindex 20d3d25..cb15a6d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -13,9 +13,10 @@
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)
* GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)
-- * GET|POST /new create GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml, /feed.xml, /s/feed.xml
++ * GET|POST /new create GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml, /feed.xml, /forum/feed.xml
* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh
-- * GET /s … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)
++ * GET /forum … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)
++ * GET /s … 301/308 to /forum (old forum links)
* GET /pulse the network's public activity
* GET|POST /c/:accessId one comment thread's own page (comments/routes.js)
* GET|POST /submissions … submissions, their pages and the review queue (submissions/routes.js)
ddiff --git a/server/discovery.js b/server/discovery.js
iindex 74e84ea..8afce2f 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -9,10 +9,11 @@ const config = require('./config');
const catalog = require('./pastes/catalog');
const source = require('./pastes/source');
const { abs } = require('./render/layout');
++const { spacePath, threadPath } = require('./forum/paths');
const { isoDate, clean } = require('./render/jsonld');
const SITEMAP_TTL_MS = 60 * 60 * 1000;
--const ROBOTS_DISALLOW = ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso='];
++const ROBOTS_DISALLOW = ['/api/', '/auth/', '/my', '/new', '/forum/*/new', '/*?sso='];
const SUMMARY = 'The discussion side of the OpenVibe network: pastes (text and screenshots), forums with threads, and the Pulse feed of what is happening across the network.';
const CONTENT_LABELS = 'Pastes made by the OpenVibe AI (moments caught from live streams) carry origin "ai" and are labelled as AI-generated; everything else was written by people.';
@@@ -29,7 +30,7 @@ function llmsSections() {
{ title: 'Browse', links: [
{ title: 'OpenVibe.Community', url: `${u}/`, note: 'pastes, forum and network activity' },
{ title: 'Pastes', url: `${u}/pastes`, note: 'public pastes, newest first (?type=pastes|images|all, ?sort=views, ?lang=<language>)' },
-- { title: 'Forum', url: `${u}/s`, note: 'open discussion forums and their threads', html: true },
++ { title: 'Forum', url: `${u}/forum`, note: 'open discussion forums and their threads', html: true },
{ title: 'Pulse', url: `${u}/pulse`, note: 'network activity', html: true },
] },
// The API is a primary entry for an agent, not something to skip.
@@@ -53,7 +54,7 @@ function llmsSections() {
optional: [
{ title: 'Sitemap', url: `${u}/sitemap.xml` },
{ title: 'Latest pastes (RSS)', url: `${u}/feed.xml` },
-- { title: 'Latest threads (RSS)', url: `${u}/s/feed.xml` },
++ { title: 'Latest threads (RSS)', url: `${u}/forum/feed.xml` },
{ title: 'Full text for language models', url: `${u}/llms-full.txt` },
],
};
@@@ -68,7 +69,7 @@ async function llmsFullSections() {
if (_forum) {
const threads = await _forum.recentPublic({ limit: 50 });
sections.push({ title: 'Latest threads', pages: threads.map((t) => ({
-- title: `${t.title} (${t.space_name})`, url: abs(`/s/${t.space_slug}/t/${t.slug}`), text: markdownToText(t.opening, Infinity),
++ title: `${t.title} (${t.space_name})`, url: abs(threadPath(t.space_slug, t.slug)), text: markdownToText(t.opening, Infinity),
})) });
}
const pastes = (await catalog.latest(30)).filter((p) => !Number(p.is_nsfw) && !Number(p.burn_after_read) && p.type !== 'screenshot');
@@@ -93,10 +94,10 @@ async function buildSitemapRows() {
// No /p/:slug rows: a paste is link-shared content and is noindex (SEO-29), so it must not be
// in a sitemap. The /pastes browse and search pages above are the surfaces meant for search.
if (_forum) {
-- add('/s', null, 'hourly', '0.8');
++ add('/forum', null, 'hourly', '0.8');
add('/pulse', null, 'hourly', '0.5');
-- for (const s of await _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');
-- for (const t of await _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');
++ for (const s of await _forum.publicSpaces()) add(spacePath(s.slug), s.last_activity_at || null, 'hourly', '0.7');
++ for (const t of await _forum.recentPublic({ limit: 1000 })) add(threadPath(t.space_slug, t.slug), isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');
}
return rows;
}
@@@ -120,22 +121,22 @@ async function pasteFeedItems() {
});
}
--/** Threads (forum.recentPublic rows) for /s/feed.xml and /s/:space/feed.xml. */
++/** Threads (forum.recentPublic rows) for /forum/feed.xml and /forum/:space/feed.xml. */
function threadFeedItems(threads) {
const { markdownToText } = require('./render/markdown');
return threads.map((t) => {
-- const url = abs(`/s/${t.space_slug}/t/${t.slug}`);
++ const url = abs(threadPath(t.space_slug, t.slug));
const description = markdownToText(t.opening, 300);
return { title: t.title, link: url, guid: url, description, content: description,
author: undefined, published: isoDate(t.created_at), updated: isoDate(t.last_activity_at || t.created_at) };
});
}
--/** A thread's newest posts for /s/:space/t/:slug/feed.xml (opening post and replies). */
++/** A thread's newest posts for /forum/:space/:slug/feed.xml (opening post and replies). */
function postFeedItems(space, thread, posts) {
const { markdownToText } = require('./render/markdown');
return posts.map((p) => {
-- const url = abs(`/s/${space.slug}/t/${thread.slug}#post-${p.id}`);
++ const url = abs(`${threadPath(space.slug, thread.slug)}#post-${p.id}`);
const description = markdownToText(p.body_markdown || '', 300);
return { title: p.is_opening ? thread.title : `Re: ${thread.title}`, link: url, guid: url, description, content: description,
author: undefined, published: isoDate(p.created_at), updated: isoDate(p.updated_at || p.created_at) };
ddiff --git a/server/events.js b/server/events.js
iindex 5dda47d..2443ff1 100644
---- a/server/events.js
++++ b/server/events.js
@@@ -102,14 +102,14 @@ async function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {
const visibility = forumVisibility(thread, spaceVisibility);
return await record('community.thread.created', { type: 'thread', id: String(thread.id) }, {
thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,
-- url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,
++ url: visibility === 'public' ? `${config.baseUrl}/forum/${encodeURIComponent(spaceSlug)}/${encodeURIComponent(thread.slug)}` : null,
}, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });
}
async function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
const visibility = forumVisibility(thread, spaceVisibility);
return await record('community.post.created', { type: 'post', id: String(post.id) }, {
post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,
-- url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
++ url: visibility === 'public' ? `${config.baseUrl}/forum/${encodeURIComponent(spaceSlug)}/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
}, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });
}
/**
ddiff --git a/server/forum/paths.js b/server/forum/paths.js
nnew file mode 100644
iindex 0000000..7ba7e3a
---- /dev/null
++++ b/server/forum/paths.js
@@@ -0,0 +1,30 @@
++'use strict';
++
++/**
++ * The forum's addresses. The forum moved from /s (spaces were its old name) to /forum, and a thread's
++ * /t/ segment went with it (design 2026-11-07): /forum, /forum/<space>, /forum/<space>/<thread>.
++ * A thread's slug therefore shares a namespace with the space's own sub-routes, so those words are
++ * reserved and never become a slug (store.js appends -2 when a title would make one).
++ */
++
++const RESERVED_SPACE_SLUGS = new Set(['new', 'discuss', 'feed.xml', 'search', 'about']);
++const RESERVED_THREAD_SLUGS = new Set(['new', 'log', 'settings', 'feed.xml', 'moderators', 'chat-room', 'members', 'about', 'search', 'rules', 'wiki', 'edit', 'discuss']);
++
++const spacePath = (slug) => `/forum/${encodeURIComponent(slug)}`;
++const threadPath = (spaceSlug, threadSlug) => `/forum/${encodeURIComponent(spaceSlug)}/${encodeURIComponent(threadSlug)}`;
++
++/**
++ * An old /s… path (the remainder express gives a router mounted at /s, query included) → its /forum path.
++ * /s/general/t/a-title becomes /forum/general/a-title; everything else keeps its shape.
++ */
++function legacyForumPath(url) {
++ const qi = url.indexOf('?');
++ const path = qi === -1 ? url : url.slice(0, qi);
++ const query = qi === -1 ? '' : url.slice(qi);
++ let rest = path === '/' ? '' : path.replace(/\/+$/, '');
++ if (rest === '/new-space') return `/forum/new${query}`;
++ rest = rest.replace(/^\/([^/]+)\/t(?=\/|$)/, '/$1');
++ return `/forum${rest}${query}`;
++}
++
++module.exports = { RESERVED_SPACE_SLUGS, RESERVED_THREAD_SLUGS, spacePath, threadPath, legacyForumPath };
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex 3048cf3..e64e629 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -3,18 +3,21 @@
/**
* Server-rendered forum routes (the pages are in render/forum.js, the rules in service.js):
*
-- * GET /s the Forum's board index (a forum is a `space` in code and the API)
-- * GET /forum, /forum/<rest> 301 → /s, /s/<rest> (query kept)
-- * GET /s/feed.xml RSS: latest threads in public spaces
-- * GET /s/:space threads (?sort=hot|new|top&page=)
-- * GET /s/:space/feed.xml RSS: latest threads of one public space
-- * GET /s/:space/log the space's public moderation log (newest 100)
-- * GET /s/:space/t/:slug/feed.xml RSS: a thread's newest posts, same visibility as the thread
-- * GET /s/:space/new POST /s/:space/new start a thread
-- * GET /s/:space/t/:slug a thread (?page=)
-- * POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms
-- * POST /s/:space/t/:slug/post/:id/delete a moderator removes a post (rule and reason)
-- * POST /s/:space/chat-room, /s/:space/chat-room/detach the space's chat room (its owner or staff)
++ * GET /forum the Forum's board index (a forum is a `space` in code and the API)
++ * GET /s, /s/<rest> 301 → /forum, /forum/<rest> (query kept); POST answers 308
++ * GET /forum/feed.xml RSS: latest threads in public spaces
++ * GET /forum/:space threads (?sort=hot|new|top&page=)
++ * GET /forum/:space/feed.xml RSS: latest threads of one public space
++ * GET /forum/:space/log the space's public moderation log (newest 100)
++ * GET /forum/:space/:slug/feed.xml RSS: a thread's newest posts, same visibility as the thread
++ * GET /forum/:space/new POST /forum/:space/new start a thread
++ * GET /forum/:space/:slug a thread (?page=)
++ * POST /forum/:space/:slug/reply | /vote | /state | /delete | /members-only no-JS forms
++ * POST /forum/:space/:slug/post/:id/delete a moderator removes a post (rule and reason)
++ * POST /forum/:space/chat-room, /forum/:space/chat-room/detach the space's chat room (its owner or staff)
++ *
++ * The forum used to be /s (spaces were its old name) and a thread lived at /s/:space/t/:slug. Every old
++ * address redirects permanently: 301 for reads, 308 for form posts so the method and body survive.
*
* A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the
* creator's join link (403), never its posts.
@@@ -33,6 +36,7 @@ const pages = require('../render/pages');
const forumPages = require('../render/forum');
const boardPages = require('../render/board');
const { ApiError } = require('../http/v1');
++const { spacePath, threadPath, legacyForumPath } = require('./paths');
function createForumRoutes({ forum, viewers, config }) {
const router = express.Router();
@@@ -79,33 +83,33 @@ function createForumRoutes({ forum, viewers, config }) {
next();
}
-- // People call it the Forum (spaces are OpenVibe.Space's), so /forum and /forum/<rest> answer too: a 301 to the
-- // /s address every link, sitemap entry and Search document already uses.
-- router.use('/forum', (req, res, next) => {
-- if (req.method !== 'GET' && req.method !== 'HEAD') return next();
-- res.redirect(301, `/s${req.url === '/' || req.url.startsWith('/?') ? req.url.slice(1) : req.url}`);
++ // The old /s address (spaces) still works: every read answers a permanent 301 to /forum, every other
++ // method a 308 (the method and body are kept). Old thread links /s/<space>/t/<slug> become /forum/<space>/<slug>.
++ router.use('/s', (req, res) => {
++ const status = req.method === 'GET' || req.method === 'HEAD' ? 301 : 308;
++ res.redirect(status, legacyForumPath(req.url));
});
// The board index (vBulletin/SMF style): every space, grouped, with topics, posts and the last post.
-- router.get('/s', withViewer, wrap(async (req, res, next) => {
++ router.get('/forum', withViewer, wrap(async (req, res, next) => {
try { html(res, boardPages.boardIndexPage({ ...(await forum.listSpaces(req.viewer)), user: req.user, canModerate: forum.isModerator(req.viewer) })); } catch (err) { failPage(req, res, err, next); }
}));
// Moderators: a new space, either style.
-- router.get('/s/new-space', withViewer, wrap(async (req, res, next) => {
++ router.get('/forum/new', withViewer, wrap(async (req, res, next) => {
try {
-- if (!req.user) return login(res, '/s/new-space');
++ if (!req.user) return login(res, '/forum/new');
if (!forum.isModerator(req.viewer)) throw new ApiError(403, 'capability.denied', 'Only moderators create forums');
html(res, boardPages.newSpacePage({ groups: await forum.groups() }));
} catch (err) { failPage(req, res, err, next); }
}));
-- router.post('/s/new-space', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const values = { name: b.name, slug: b.slug, description: b.description, style: b.style === 'feed' ? 'feed' : 'forum', votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null };
try {
const out = await forum.createSpace(req.viewer, values);
discovery.resetCaches();
-- res.redirect(303, `/s/${out.space.slug}`);
++ res.redirect(303, spacePath(out.space.slug));
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);
html(res, boardPages.newSpacePage({ groups: await forum.groups(), error: err.message, values }), err.status);
@@@ -113,7 +117,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
// "Discuss in a space" from a paste: pick the space, then its new-topic form with the paste attached.
-- router.get('/s/discuss', withViewer, wrap(async (req, res, next) => {
++ router.get('/forum/discuss', withViewer, wrap(async (req, res, next) => {
try {
const paste = String(req.query.paste || '').slice(0, 80);
const { groups } = await forum.listSpaces(req.viewer);
@@@ -124,26 +128,26 @@ function createForumRoutes({ forum, viewers, config }) {
// RSS of the latest threads, overall and per public space (openvibe-shared/seo feedXml).
const threadFeed = async (res, space) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.feedXml({
title: space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`,
-- link: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}`,
++ link: `${config.baseUrl}${space ? spacePath(space.slug) : '/forum'}`,
description: space ? (space.description || space.name) : 'New threads in the public forums of OpenVibe.Community.',
language: 'en',
-- selfUrl: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}/feed.xml`,
++ selfUrl: `${config.baseUrl}${space ? spacePath(space.slug) : '/forum'}/feed.xml`,
items: discovery.threadFeedItems(await forum.recentPublic({ limit: 30, ...(space ? { space: space.slug } : {}) })),
}, { format: 'rss' }));
-- router.get('/s/feed.xml', wrap(async (_req, res) => threadFeed(res, null)));
++ router.get('/forum/feed.xml', wrap(async (_req, res) => threadFeed(res, null)));
-- router.get('/s/:space/feed.xml', wrap(async (req, res) => {
++ router.get('/forum/:space/feed.xml', wrap(async (req, res) => {
const space = (await forum.publicSpaces()).find((s) => s.slug === req.params.space);
if (!space) return res.status(404).type('text/plain').send('Not found');
await threadFeed(res, space);
}));
// A thread's replies as RSS: newest 50, under the same visibility rule as the thread itself.
-- router.get('/s/:space/t/:slug/feed.xml', withViewer, wrap(async (req, res, next) => {
++ router.get('/forum/:space/:slug/feed.xml', withViewer, wrap(async (req, res, next) => {
try {
const out = await forum.threadFeed(req.viewer, req.params.space, req.params.slug);
-- const base = `${config.baseUrl}/s/${out.space.slug}/t/${out.thread.slug}`;
++ const base = `${config.baseUrl}${threadPath(out.space.slug, out.thread.slug)}`;
// A private or members-only thread's feed is the member's own: never kept by Cloudflare or a proxy.
res.type('application/rss+xml').set('Cache-Control', out.shared ? cache.htmlHeaders({ maxAge: 300 }) : 'private, no-store').send(seo.feedXml({
title: `${SITE_NAME} — ${out.thread.title}`,
@@@ -160,14 +164,14 @@ function createForumRoutes({ forum, viewers, config }) {
}));
// The space's public moderation log: what moderation did and why, in role only.
-- router.get('/s/:space/log', withViewer, wrap(async (req, res, next) => {
++ router.get('/forum/:space/log', withViewer, wrap(async (req, res, next) => {
try {
const out = await forum.moderationLog(req.viewer, req.params.space);
html(res, forumPages.moderationLogPage(out));
} catch (err) { failPage(req, res, err, next); }
}));
-- router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
++ router.get('/forum/:space', withViewer, wrap(async (req, res, next) => {
try {
const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
@@@ -179,7 +183,7 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
}));
-- router.get('/s/:space/new', withViewer, wrap(async (req, res, next) => {
++ router.get('/forum/:space/new', withViewer, wrap(async (req, res, next) => {
try {
// A members-only space: the teaser, not the form, for someone who may not post there.
const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
@@@ -190,7 +194,7 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
}));
-- router.post('/s/:space/new', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/new', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
if ((req.body || {}).members_only === '1') values.members_only = true;
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
@@@ -225,11 +229,11 @@ function createForumRoutes({ forum, viewers, config }) {
html(res, out.space.style === 'forum' ? boardPages.forumTopicPage(view) : forumPages.threadPage(view), status);
}
-- router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {
++ router.get('/forum/:space/:slug', withViewer, wrap(async (req, res, next) => {
try { await renderThread(req, res); } catch (err) { failPage(req, res, err, next); }
}));
-- router.post('/s/:space/t/:slug/reply', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/reply', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
const draft = String((req.body || {}).body || '').slice(0, 40_000);
try {
const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];
@@@ -247,9 +251,9 @@ function createForumRoutes({ forum, viewers, config }) {
}
}));
-- const back = (req) => `/s/${encodeURIComponent(req.params.space)}/t/${encodeURIComponent(req.params.slug)}`;
++ const back = (req) => threadPath(req.params.space, req.params.slug);
-- router.post('/s/:space/t/:slug/vote', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/vote', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
await forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });
res.redirect(303, back(req));
@@@ -257,7 +261,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
// Members-only on/off (no JS): on gates the thread to its author's VIP members.
-- router.post('/s/:space/t/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
await forum.setThreadMembersOnly(req.viewer, req.params.space, req.params.slug, { members_only: (req.body || {}).on === '1' ? true : null });
discovery.resetCaches();
@@@ -266,7 +270,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
// Content labels (L1, no JS): the author or a moderator ticks what the thread carries.
-- router.post('/s/:space/t/:slug/labels', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/labels', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
const labels = labelsUi.fromForm(req.body);
await forum.setThreadLabels(req.viewer, req.params.space, req.params.slug, { labels: labels === undefined ? [] : (labels === null ? ['not-a-label'] : labels) });
@@@ -275,7 +279,7 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
}));
-- router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const flags = { rule: b.rule, reason: b.reason };
if (b.pinned !== undefined) flags.pinned = b.pinned === '1';
@@@ -287,7 +291,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
// Rate a post (no JS): the rating's button in the post's form; back to the post.
-- router.post('/s/:space/t/:slug/react', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/react', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
try {
await forum.react(req.viewer, String(b.post || ''), { reaction: String(b.reaction || '') || null });
@@@ -297,7 +301,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
// Crosspost to another space (no JS).
-- router.post('/s/:space/t/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
const out = await forum.crosspost(req.viewer, req.params.space, req.params.slug, { to: String((req.body || {}).to || '') });
discovery.resetCaches();
@@@ -306,21 +310,21 @@ function createForumRoutes({ forum, viewers, config }) {
}));
// Moderators: a space's settings (no JS).
-- router.post('/s/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/settings', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
try {
// The group (the board index) is staff's; a space's own moderators change the rest.
await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: forum.isModerator(req.viewer) ? b.group || null : undefined });
discovery.resetCaches();
-- res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
++ res.redirect(303, spacePath(req.params.space));
} catch (err) { failPage(req, res, err, next); }
}));
// The space's own moderators (no JS): add one by @username (or Network subject), remove one; back to the space's
// moderators box. A refusal about the person named (unknown, blocked, malformed) comes back as a notice there.
-- const modsBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?mod_error=${encodeURIComponent(error)}` : ''}#moderators`;
++ const modsBack = (req, error) => `${spacePath(req.params.space)}${error ? `?mod_error=${encodeURIComponent(error)}` : ''}#moderators`;
const modRefusal = (err) => err instanceof ApiError && (String(err.code).startsWith('moderator.') || err.code === 'community.blocked' || err.code === 'identity.unavailable');
-- router.post('/s/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/moderators', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
await forum.addModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
res.redirect(303, modsBack(req));
@@@ -329,7 +333,7 @@ function createForumRoutes({ forum, viewers, config }) {
failPage(req, res, err, next);
}
}));
-- router.post('/s/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/moderators/remove', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
await forum.removeModerator(req.viewer, req.params.space, String((req.body || {}).subject || '').trim().slice(0, 64));
res.redirect(303, modsBack(req));
@@@ -340,8 +344,8 @@ function createForumRoutes({ forum, viewers, config }) {
}));
// The space's chat room (no JS): attach by address or link, detach. A refusal comes back as a notice on the space.
-- const chatBack = (req, error) => `/s/${encodeURIComponent(req.params.space)}${error ? `?chat_error=${encodeURIComponent(error)}` : ''}#chat-room`;
-- router.post('/s/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ const chatBack = (req, error) => `${spacePath(req.params.space)}${error ? `?chat_error=${encodeURIComponent(error)}` : ''}#chat-room`;
++ router.post('/forum/:space/chat-room', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
await forum.attachChatRoom(req.viewer, req.params.space, { room: String((req.body || {}).room || '').slice(0, 300) });
res.redirect(303, chatBack(req));
@@@ -350,7 +354,7 @@ function createForumRoutes({ forum, viewers, config }) {
failPage(req, res, err, next);
}
}));
-- router.post('/s/:space/chat-room/detach', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/chat-room/detach', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
await forum.detachChatRoom(req.viewer, req.params.space);
res.redirect(303, chatBack(req));
@@@ -358,7 +362,7 @@ function createForumRoutes({ forum, viewers, config }) {
}));
// Status (moderators) and category (the author or moderators), no JS.
-- router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
try {
await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String(b.status || ''), rule: b.rule, reason: b.reason });
@@@ -366,7 +370,7 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
}));
-- router.post('/s/:space/t/:slug/category', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/category', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
await forum.setThreadCategory(req.viewer, req.params.space, req.params.slug, { category: String((req.body || {}).category || '') || null });
res.redirect(303, back(req));
@@@ -374,18 +378,18 @@ function createForumRoutes({ forum, viewers, config }) {
}));
// A moderator removes one post (its opening post removes the thread): rule and reason are optional.
-- router.post('/s/:space/t/:slug/post/:id/delete', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/forum/:space/:slug/post/:id/delete', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
await forum.deletePost(req.viewer, req.params.id, req.body || {});
res.redirect(303, back(req));
} catch (err) { failPage(req, res, err, next); }
}));
-- router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
++ router.post('/forum/:space/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
try {
await forum.deleteThread(req.viewer, req.params.space, req.params.slug, req.body || {});
discovery.resetCaches();
-- res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
++ res.redirect(303, spacePath(req.params.space));
} catch (err) { failPage(req, res, err, next); }
});
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex c8e10cf..8f450c3 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -40,6 +40,7 @@
* (moderator services can detach). Community keeps the link: space_chat_rooms.
*/
const store = require('./store');
++const { spacePath, threadPath, RESERVED_SPACE_SLUGS } = require('./paths');
const events = require('../events');
const { applyVote, myVotes, parseVote } = require('../votes');
const { fail, isoTime } = require('../http/v1');
@@@ -114,11 +115,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const site = String(config.baseUrl || '').replace(/\/$/, '');
const indexableSpace = (s) => !!(s && s.visibility === 'public' && !s.members_only_owner);
const ping = (urls) => { if (indexnow && indexnow.enabled) indexnow.pingSoon(urls); };
-- const pingSpace = (s) => { if (indexableSpace(s)) ping([`${site}/s/${s.slug}`, `${site}/sitemap.xml`]); };
++ const pingSpace = (s) => { if (indexableSpace(s)) ping([`${site}${spacePath(s.slug)}`, `${site}/sitemap.xml`]); };
const pingThread = (space, t, { withSpace = false } = {}) => {
if (!indexableSpace(space) || !t || t.members_only_owner) return;
-- const urls = [`${site}/s/${space.slug}/t/${t.slug}`, `${site}/sitemap.xml`];
-- if (withSpace) urls.push(`${site}/s/${space.slug}`);
++ const urls = [`${site}${threadPath(space.slug, t.slug)}`, `${site}/sitemap.xml`];
++ if (withSpace) urls.push(`${site}${spacePath(space.slug)}`);
ping(urls);
};
@@@ -244,7 +245,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
}
// ── shapes ───────────────────────────────────────────────
-- const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;
++ const threadUrl = (space, t) => threadPath(space.slug, t.slug);
const shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);
@@@ -348,7 +349,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async function shapeSpace(s, mo = null) {
return {
-- slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
++ slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: spacePath(s.slug),
thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],
style: STYLES.includes(s.style) ? s.style : 'feed', votes: s.votes !== 0, reactions: s.reactions !== 0,
group: s.group_slug ? { slug: s.group_slug, name: s.group_name } : null,
@@@ -398,11 +399,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (thread.crosspost_of) {
const t = await store.getThread(db, thread.crosspost_of);
const sp = t ? await store.getSpaceById(db, t.space_id) : null;
-- if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: `/s/${sp.slug}/t/${t.slug}` };
++ if (t && visible(sp) && !t.members_only_owner) from = { space: sp.name, title: t.title, url: threadPath(sp.slug, t.slug) };
}
const to = (await Promise.all((await db.prepare('SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id').all(thread.id))
.map(async (r) => ({ r, sp: await store.getSpace(db, r.space_slug) })))).filter(({ sp }) => visible(sp))
-- .map(({ r, sp }) => ({ space: sp.name, title: r.title, url: `/s/${sp.slug}/t/${r.slug}` }));
++ .map(({ r, sp }) => ({ space: sp.name, title: r.title, url: threadPath(sp.slug, r.slug) }));
const targets = person(v) && !thread.members_only_owner
? (await store.listSpaces(db, moderator(v) ? ['public', 'members', 'staff'] : ['public', 'members'])).filter((sp) => sp.id !== thread.space_id && !sp.members_only_owner && (sp.thread_kind !== 'roadmap' || moderator(v)))
.map((sp) => ({ slug: sp.slug, name: sp.name }))
@@@ -547,7 +548,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const shaped = await Promise.all(rows.map(async (r) => {
const sp = await shapeSpace(r, await membersOnly(r.members_only_owner, projections));
const l = last.get(r.id);
-- sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: `/s/${r.slug}/t/${l.thread_slug}` }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;
++ sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: threadPath(r.slug, l.thread_slug) }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;
return sp;
}));
// The board index: groups in order, each with its top-level spaces; child boards under their parent.
@@@ -593,7 +594,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async createSpace(v, body = {}) {
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators create forums');
const slug = String(body.slug || '').trim().toLowerCase();
-- if (!SPACE_SLUG.test(slug) || ['new-space', 'discuss', 'feed', 'new'].includes(slug)) fail(400, 'space.invalid_slug', 'A forum address is 2 to 40 lowercase letters, digits and dashes (not new-space, discuss, feed or new)');
++ if (!SPACE_SLUG.test(slug) || RESERVED_SPACE_SLUGS.has(slug) || slug === 'new-space') fail(400, 'space.invalid_slug', 'A forum address is 2 to 40 lowercase letters, digits and dashes (not new, discuss, feed.xml, search, about or new-space)');
if (await store.getSpace(db, slug)) fail(409, 'space.slug_taken', 'That address is taken');
const visibility = ['public', 'members', 'staff'].includes(body.visibility) ? body.visibility : 'public';
const fields = await spaceFields({ ...body, name: body.name }, null);
@@@ -636,7 +637,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
.run(space.id, out.room.id, out.room.slug, out.room.name, out.room.kind, out.room.visibility, v.subject || null);
if (before && !same) await chatRooms.detach({ token: v.token, room: before.room_slug, space: space.slug });
pingSpace(space);
-- return { space: { slug: space.slug, name: space.name, url: `/s/${space.slug}` }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };
++ return { space: { slug: space.slug, name: space.name, url: spacePath(space.slug) }, chat_room: shapeChatRoom(await chatRoomRow(space.id)), created: !same };
},
/** Detach the space's chat room — the space's owner or staff (moderator services too). Idempotent. → { detached, chat } */
@@@ -712,7 +713,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
entries: rows.map((r) => ({
id: r.id, action: r.action, rule: r.rule || null, reason: r.reason || null,
actor_role: r.actor_role, created_at: isoTime(r.created_at),
-- thread: r.thread_id && !r.thread_deleted_at ? { slug: r.thread_slug, title: r.thread_title, url: `/s/${space.slug}/t/${r.thread_slug}` } : null,
++ thread: r.thread_id && !r.thread_deleted_at ? { slug: r.thread_slug, title: r.thread_title, url: threadPath(space.slug, r.thread_slug) } : null,
post_id: r.post_id || null,
})),
};
@@@ -787,7 +788,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (await store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay && threadsPerDay > 0) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
const opening = await db.prepare('SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(thread.id);
const excerpt = markdownToText(opening ? opening.body_markdown : '', 400).split('\n').map((l) => `> ${l}`).join('\n');
-- const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](/s/${space.slug}/t/${thread.slug})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;
++ const text = `Crossposted from **${space.name}**: [${thread.title.replace(/[[\]]/g, '')}](${threadPath(space.slug, thread.slug)})${excerpt.trim() !== '>' ? `\n\n${excerpt}` : ''}`;
const { thread: created } = await store.createThread(db, { space_id: target.id, title: thread.title, author_subject: w.author, origin: 'user', body_markdown: text,
kind, status: FIRST_STATUS[kind] || null, crosspost_of: thread.id });
if (pulse) await hook(async () => await pulse.threadCreated(created, target));
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex b322ced..b973d9a 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -10,6 +10,7 @@
*/
const SORTS = ['hot', 'new', 'top', 'active'];
++const { RESERVED_THREAD_SLUGS } = require('./paths');
// ── Spaces ───────────────────────────────────────────────────
@@@ -91,7 +92,10 @@ function slugify(title) {
async function uniqueSlug(db, spaceId, base) {
const taken = db.prepare('SELECT 1 FROM threads WHERE space_id = ? AND slug = ?');
-- if (!await taken.get(spaceId, base)) return base;
++ // /forum/<space>/<slug> shares its namespace with the space's own sub-routes: a title that makes one of
++ // those words takes the -2 suffix instead.
++ const start = RESERVED_THREAD_SLUGS.has(base) ? `${base.slice(0, 54)}-2` : base;
++ if (!await taken.get(spaceId, start)) return start;
for (let n = 2; n < 10_000; n++) {
const candidate = `${base.slice(0, 54)}-${n}`;
if (!await taken.get(spaceId, candidate)) return candidate;
ddiff --git a/server/pulse/service.js b/server/pulse/service.js
iindex b3ee6e5..5067804 100644
---- a/server/pulse/service.js
++++ b/server/pulse/service.js
@@@ -79,14 +79,14 @@ function createPulse({ db, network = null, config = {} } = {}) {
async threadCreated(thread, space) {
if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;
await recordLocal('thread', thread.id, {
-- title: thread.title, path: `/s/${space.slug}/t/${thread.slug}`,
++ title: thread.title, path: `/forum/${space.slug}/${thread.slug}`,
actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,
});
},
async postCreated(post, thread, space) {
if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;
await recordLocal('post', post.id, {
-- title: `Re: ${thread.title}`, path: `/s/${space.slug}/t/${thread.slug}#post-${post.id}`,
++ title: `Re: ${thread.title}`, path: `/forum/${space.slug}/${thread.slug}#post-${post.id}`,
actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,
});
},
ddiff --git a/server/relay/discord.js b/server/relay/discord.js
iindex a9d4401..cdb1093 100644
---- a/server/relay/discord.js
++++ b/server/relay/discord.js
@@@ -198,7 +198,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
}
return 'someone';
}
-- const threadUrl = (thread, space) => `${base}/s/${space.slug}/t/${thread.slug}`;
++ const threadUrl = (thread, space) => `${base}/forum/${space.slug}/${thread.slug}`;
async function postUrl(post, thread, space) {
const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
@@@ -211,13 +211,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
const url = threadUrl(thread, space);
return {
username: 'OpenVibe.Community',
-- content: `New thread in **s/${escapeDiscord(space.slug)}** by ${escapeDiscord(await authorName(thread.author_subject, thread.origin))}: <${url}>`,
++ content: `New thread in **forum/${escapeDiscord(space.slug)}** by ${escapeDiscord(await authorName(thread.author_subject, thread.origin))}: <${url}>`,
allowed_mentions: { parse: [] },
embeds: [{
title: String(thread.title).slice(0, 256),
url,
description: markdownToText(opening ? opening.body_markdown : '', 300),
-- footer: { text: `OpenVibe.Community · s/${space.slug}` },
++ footer: { text: `OpenVibe.Community · forum/${space.slug}` },
timestamp: isoTime(thread.created_at) || undefined,
}],
};
@@@ -229,13 +229,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
const title = String(thread.title).slice(0, 200);
return {
username: 'OpenVibe.Community',
-- content: `${escapeDiscord(await authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **s/${escapeDiscord(space.slug)}**: <${url}>`,
++ content: `${escapeDiscord(await authorName(post.author_subject, post.origin))} replied to **${escapeDiscord(title)}** in **forum/${escapeDiscord(space.slug)}**: <${url}>`,
allowed_mentions: { parse: [] },
embeds: [{
title: `Re: ${title}`.slice(0, 256),
url,
description: markdownToText(post.body_markdown, 500),
-- footer: { text: `OpenVibe.Community · s/${space.slug}` },
++ footer: { text: `OpenVibe.Community · forum/${space.slug}` },
timestamp: isoTime(post.created_at) || undefined,
}],
};
@@@ -476,7 +476,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
status: d.status, attempts: d.attempts, last_status: d.last_status, last_error: d.last_error,
next_attempt_at: d.status === 'pending' ? isoTime(d.next_attempt_at) : null, delivered_at: isoTime(d.delivered_at), created_at: isoTime(d.created_at), updated_at: isoTime(d.updated_at),
mapping: { id: d.mapping_id, webhook_url_ref: d.webhook_url_ref },
-- thread: { id: d.thread_id, title: d.thread_title, url: `/s/${d.space_slug}/t/${d.thread_slug}` },
++ thread: { id: d.thread_id, title: d.thread_title, url: `/forum/${d.space_slug}/${d.thread_slug}` },
post: d.post_id ? { id: d.post_id } : null,
}));
}
ddiff --git a/server/render/board.js b/server/render/board.js
iindex aeb8a17..7970119 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -1,7 +1,7 @@
'use strict';
/**
-- * Forum-style pages, after vBulletin and SMF: the board index at /s (every space, grouped, with topics,
++ * Forum-style pages, after vBulletin and SMF: the board index at /forum (every space, grouped, with topics,
* posts and the last post), a forum-style space as a topic table (Replies, Views, Last post; sticky topics
* first; bumped by the newest post) and a topic as posts with the author's panel beside each (picture,
* posts, since when, the ratings they get most), with Quote. Feed-style spaces keep render/forum.js
@@@ -19,7 +19,7 @@ const labelsUi = require('./labels');
const STYLE_LABELS = { forum: 'Classic', feed: 'Feed' }; // the two styles, as the Forum page and the settings name them
const monthYear = (iso) => { const d = iso ? new Date(iso) : null; return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }) : null; };
const crumbs = (items) => `<nav class="crumbs" aria-label="Breadcrumb">${items.map(([label, href], i) => (i === items.length - 1 ? `<span aria-current="page">${esc(label)}</span>` : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' › ')}</nav>`;
--const trail = (space) => [['Home', '/'], ['Forum', '/s'], ...(space.group ? [[space.group.name, `/s#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/s/${space.parent.slug}`]] : [])];
++const trail = (space) => [['Home', '/'], ['Forum', '/forum'], ...(space.group ? [[space.group.name, `/forum#g-${space.group.slug}`]] : []), ...(space.parent ? [[space.parent.name, `/forum/${space.parent.slug}`]] : [])];
const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */
@@@ -28,10 +28,10 @@ function boardRow(sp) {
const last = sp.last_post
? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${sp.last_post.author ? wearName(sp.last_post.author, sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone'} · ${timeTag(sp.last_post.created_at)}</span>`
: '<span class="muted small">No posts yet</span>';
-- const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
++ const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/forum/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
return `<tr>
<td class="board-name"><span class="board-icon" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div>
-- <a class="board-title" href="/s/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}
++ <a class="board-title" href="/forum/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}
${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}
</div></td>
<td class="num" data-label="Topics">${num(sp.thread_count)}</td>
@@@ -46,14 +46,14 @@ function boardTable(spaces, caption) {
<tbody>${spaces.map(boardRow).join('')}</tbody></table>`;
}
--// ── /s ───────────────────────────────────────────────────────
++// ── /forum ───────────────────────────────────────────────────────
function boardIndexPage({ groups = [], user, canModerate = false }) {
const body = `
<header class="page-head">
-- ${crumbs([['Home', '/'], ['Forum', '/s']])}
++ ${crumbs([['Home', '/'], ['Forum', '/forum']])}
<h1>Forum</h1>
<p class="muted">Forums for the people of OpenVibe. Classic forums read like a message board, newest reply on top. Feed forums rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>
-- ${canModerate ? '<p><a class="btn btn-sm" href="/s/new-space"><i class="fa-solid fa-plus" aria-hidden="true"></i> New forum</a></p>' : ''}
++ ${canModerate ? '<p><a class="btn btn-sm" href="/forum/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New forum</a></p>' : ''}
</header>
${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}">
<h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>
@@@ -64,10 +64,10 @@ ${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post
return renderPage({
title: 'Forum',
description: 'Forums on OpenVibe.Community: general talk, help, feedback and feature requests, the roadmap, a showcase of what people make, and off-topic.',
-- canonicalPath: '/s',
++ canonicalPath: '/forum',
active: 'spaces',
-- feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }])],
++ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/forum/feed.xml' }],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }])],
body,
});
}
@@@ -80,7 +80,7 @@ function topicPages(base, pages) {
}
function topicRow(t, space) {
-- const base = `/s/${space.slug}/t/${t.slug}`;
++ const base = `/forum/${space.slug}/${t.slug}`;
const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire' : 'fa-comment';
const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');
@@@ -100,7 +100,7 @@ function topicRow(t, space) {
function settingsForm(space, groups = []) {
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Forum settings</summary>
--<form class="paste-form" method="post" action="/s/${esc(space.slug)}/settings">
++<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/settings">
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>
<label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic: newest reply first, author panels, quotes', space.style === 'forum')}${opt('feed', 'Feed: ranked by votes, hot/new/top', space.style !== 'forum')}</select></label>
@@@ -121,18 +121,18 @@ function moderatorsBox(space, moderators = [], { me = null, error = null } = {})
const name = m.username ? `@${m.username}` : (m.display_name || 'this person');
const self = me && m.subject === me;
return `<li class="mod-row">${authorHtml(m)}${m.added_at ? ` <span class="muted small">since ${timeTag(m.added_at)}</span>` : ''}
-- <form class="inline-form" method="post" action="/s/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;
++ <form class="inline-form" method="post" action="/forum/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;
}).join('');
return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this forum yet; discussion staff do.</p>'}
--<form class="paste-form" method="post" action="/s/${slug}/moderators">
++<form class="paste-form" method="post" action="/forum/${slug}/moderators">
<label class="field"><span>Add a moderator</span><input name="subject" required maxlength="41" placeholder="@username" autocomplete="off" autocapitalize="none" spellcheck="false"></label>
<div class="form-actions"><button class="btn btn-primary" type="submit">Add moderator</button></div>
</form></details>`;
}
--// ── /s/:space (forum style) ──────────────────────────────────
++// ── /forum/:space (forum style) ──────────────────────────────────
function forumSpacePage({ space, threads, page, pages, total, user, categories = [], category = null, status = null, viewer = {}, children = [], groups = [], chatRoom = '', moderators = [], me = null, modError = null }) {
const filtered = !!(category || status);
const indexable = space.visibility === 'public' && !space.members_only && !filtered;
@@@ -142,13 +142,13 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =
const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';
const statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';
const start = viewer.can_start !== false
-- ? `<a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`
++ ? `<a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`
: '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';
const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);
const body = `
<header class="page-head">
-- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`]])}
-- <h1>${esc(space.name)}${f.visBadge(space)}${feedable ? ` <a class="rss-link" href="/s/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>
++ ${crumbs([...trail(space), [space.name, `/forum/${space.slug}`]])}
++ <h1>${esc(space.name)}${f.visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
</header>
${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}
@@@ -158,19 +158,19 @@ ${tags}${statuses}
<section data-results>
${threads.length ? `<table class="board-table topic-table"><caption class="sr-only">Topics in ${esc(space.name)}</caption>
<thead><tr><th scope="col" class="topic-icon"><span class="sr-only">Kind</span></th><th scope="col">Topic</th><th scope="col" class="num">Replies</th><th scope="col" class="num">Views</th><th scope="col">Last post</th></tr></thead>
-- <tbody>${threads.map((t) => topicRow(t, space)).join('')}</tbody></table>` : `<p class="empty">${filtered ? `Nothing here yet. <a href="${esc(href({ category: null, status: null }))}">See every topic</a>.` : viewer.can_start !== false ? `No topics yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.` : 'Nothing here yet.'}</p>`}
++ <tbody>${threads.map((t) => topicRow(t, space)).join('')}</tbody></table>` : `<p class="empty">${filtered ? `Nothing here yet. <a href="${esc(href({ category: null, status: null }))}">See every topic</a>.` : viewer.can_start !== false ? `No topics yet — <a href="/forum/${esc(space.slug)}/new">start the first one</a>.` : 'Nothing here yet.'}</p>`}
${pagerHtml}
</section>
${viewer.can_moderate ? settingsForm(space, groups) + moderatorsBox(space, moderators, { me, error: modError }) : ''}
--<footer class="space-foot"><a class="small" href="/s/${esc(space.slug)}/log">Moderation log</a></footer>`;
++<footer class="space-foot"><a class="small" href="/forum/${esc(space.slug)}/log">Moderation log</a></footer>`;
return renderPage({
title: `${space.name}${page > 1 ? ` (page ${page})` : ''}`,
description: `${space.description || `Topics in ${space.name}`} ${plural(total, 'topic', 'topics')} on OpenVibe.Community.`,
canonicalPath: href({ page }),
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
-- feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
++ feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : [],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }])],
body,
});
}
@@@ -208,9 +208,9 @@ function postbit(p, n, { base, page, catalogue, canReply, canModerate }) {
</article>`;
}
--// ── /s/:space/t/:slug (forum style) ──────────────────────────
++// ── /forum/:space/:slug (forum style) ──────────────────────────
function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {
-- const base = `/s/${space.slug}/t/${thread.slug}`;
++ const base = `/forum/${space.slug}/${thread.slug}`;
const opening = posts.find((p) => p.is_opening) || null;
const gated = !!(space.members_only || thread.members_only);
const indexable = space.visibility === 'public' && !gated;
@@@ -231,7 +231,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
const labelBox = f.labelsBox(base, thread, viewer);
const body = `
<article class="thread forum-topic">
-- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`], [ld.clean(thread.title, 60), base]])}
++ ${crumbs([...trail(space), [space.name, `/forum/${space.slug}`], [ld.clean(thread.title, 60), base]])}
<header class="thread-head">
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
<div>
@@@ -264,12 +264,12 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
footerVariant: 'compact',
...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
-- ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),
++ ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
{ title: `OpenVibe.Community — ${thread.title}`, href: `${base}/feed.xml` },
],
jsonLd: [
...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
-- ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
++ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }, { name: thread.title, url: base }]),
],
body,
});
@@@ -280,14 +280,14 @@ function newSpacePage({ groups = [], error = null, values = {} }) {
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
const body = `
<header class="page-head">
-- ${crumbs([['Home', '/'], ['Forum', '/s'], ['New forum', '/s/new-space']])}
++ ${crumbs([['Home', '/'], ['Forum', '/forum'], ['New forum', '/forum/new']])}
<h1>New forum</h1>
<p class="muted">A classic forum reads like a message board: the newest reply on top, author panels and quotes. A feed forum ranks posts by votes, like a subreddit. You can change the style later.</p>
</header>
--<form class="paste-form" method="post" action="/s/new-space">
++<form class="paste-form" method="post" action="/forum/new">
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(values.name || '')}"></label>
-- <label class="field"><span>Address (/s/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label>
++ <label class="field"><span>Address (/forum/…)</span><input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,39}" maxlength="40" value="${esc(values.slug || '')}" placeholder="music"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(values.description || '')}"></label>
<label class="field"><span>Style</span><select name="style">${opt('forum', 'Classic', values.style !== 'feed')}${opt('feed', 'Feed', values.style === 'feed')}</select></label>
<label class="check"><input type="checkbox" name="votes" value="1"${values.votes ? ' checked' : ''}> Up and down votes</label>
@@@ -295,7 +295,7 @@ function newSpacePage({ groups = [], error = null, values = {} }) {
<label class="field"><span>Group on the board index</span><select name="group">${opt('', 'None', !values.group)}${groups.map((g) => opt(g.slug, g.name, values.group === g.slug)).join('')}</select></label>
<div class="form-actions"><button class="btn btn-primary" type="submit">Create forum</button></div>
</form>`;
-- return renderPage({ title: 'New forum', canonicalPath: '/s/new-space', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });
++ return renderPage({ title: 'New forum', canonicalPath: '/forum/new', robots: 'noindex,nofollow', active: 'spaces', footerVariant: 'compact', body });
}
/** "Discuss in a space": pick where a paste should be talked about; each space's new-topic form attaches it. */
@@@ -303,13 +303,13 @@ function discussPage({ groups = [], paste = '', user }) {
const q = paste ? `?paste=${encodeURIComponent(paste)}` : '';
const body = `
<header class="page-head">
-- ${crumbs([['Home', '/'], ['Forum', '/s'], ['Discuss', '/s/discuss']])}
++ ${crumbs([['Home', '/'], ['Forum', '/forum'], ['Discuss', '/forum/discuss']])}
<h1>Discuss${paste ? ' this paste' : ''} in the forum</h1>
<p class="muted">Pick a forum. Its new-topic form opens with ${paste ? `the paste <a href="/p/${esc(paste)}">${esc(paste)}</a>` : 'your paste'} attached.</p>
-- ${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}
++ ${user ? '' : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/discuss${q}`)}">Sign in with your OpenVibe account</a> to start a topic.</p>`}
</header>
--${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/s/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;
-- return renderPage({ title: 'Discuss in the forum', canonicalPath: '/s/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });
++${groups.map((g) => `<section class="board-group"><h2 class="board-group-h">${esc(g.name)}</h2><ul class="discuss-list">${g.spaces.filter((sp) => sp.thread_kind !== 'roadmap').map((sp) => `<li><a class="btn" href="/forum/${esc(sp.slug)}/new${q}">${esc(sp.name)}</a> <span class="muted small">${esc(sp.description || '')}</span></li>`).join('')}</ul></section>`).join('')}`;
++ return renderPage({ title: 'Discuss in the forum', canonicalPath: '/forum/discuss', robots: 'noindex,follow', active: 'spaces', footerVariant: 'compact', body });
}
module.exports = { boardIndexPage, forumSpacePage, forumTopicPage, newSpacePage, settingsForm, moderatorsBox, discussPage };
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex 12d86c2..a34d164 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -1,7 +1,7 @@
'use strict';
/**
-- * Forum pages — /s (spaces), /s/:space (threads), /s/:space/t/:slug (a thread), /s/:space/new.
++ * Forum pages — /forum (spaces), /forum/:space (threads), /forum/:space/:slug (a thread), /forum/:space/new.
* Complete without JavaScript: sorting and pagination are links, replying, voting and the
* moderator buttons are plain form posts. Every user value is escaped; post bodies are the safe
* Markdown renderer's output (render/markdown.js).
@@@ -38,7 +38,7 @@ function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = nul
if (sort && sort !== 'hot') q.set('sort', sort);
if (page > 1) q.set('page', String(page));
const s = q.toString();
-- return `/s/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;
++ return `/forum/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;
}
/** The optional "Rule applied" select and reason box every moderator form carries. `id` keeps its ids unique. */
@@@ -67,11 +67,11 @@ const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-v
const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span>` : '');
const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');
--// ── /s ───────────────────────────────────────────────────────
++// ── /forum ───────────────────────────────────────────────────────
function spacesPage({ spaces }) {
const list = spaces.length ? `<ul class="space-list">${spaces.map((s) => `
<li class="space-item">
-- <a class="space-link" href="/s/${esc(s.slug)}"><h2>${esc(s.name)}${visBadge(s)}</h2></a>
++ <a class="space-link" href="/forum/${esc(s.slug)}"><h2>${esc(s.name)}${visBadge(s)}</h2></a>
${s.description ? `<p class="muted">${esc(s.description)}</p>` : ''}
<p class="small muted">${num(s.thread_count)} ${s.thread_count === 1 ? 'thread' : 'threads'}${s.last_activity_at ? ` · last activity ${timeTag(s.last_activity_at)}` : ''}</p>
</li>`).join('')}
@@@ -86,22 +86,22 @@ ${list}`;
return renderPage({
title: 'Forum',
description: 'Forums on OpenVibe.Community: general talk, feedback and feature requests, and a showcase of what the people of OpenVibe make.',
-- canonicalPath: '/s',
++ canonicalPath: '/forum',
active: 'spaces',
-- feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }])],
++ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/forum/feed.xml' }],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }])],
body,
});
}
--// ── /s/:space ────────────────────────────────────────────────
++// ── /forum/:space ────────────────────────────────────────────────
function threadRow(t, space) {
const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;
const labels = labelsUi.inForce(t);
return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>
${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}
<div class="thread-main">
-- <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}
++ <a class="thread-title" href="/forum/${esc(space.slug)}/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}
${labelsUi.chips(labels)}
<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
</div>
@@@ -119,11 +119,11 @@ function chatRoomBox(space, { canManage = false, error = null } = {}) {
const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="fa-solid fa-comments" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';
const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
-- <form class="paste-form" method="post" action="/s/${esc(space.slug)}/chat-room">
++ <form class="paste-form" method="post" action="/forum/${esc(space.slug)}/chat-room">
<label class="field"><span>Room address or link (you must own the room on OpenVibe.Chat)</span><input name="room" required maxlength="300" placeholder="night-owls or https://openvibe.chat/r/night-owls" value="${r ? esc(r.slug) : ''}"></label>
<div class="form-actions"><button class="btn btn-primary" type="submit">${r ? 'Attach this room instead' : 'Attach'}</button></div>
</form>
-- ${r ? `<form class="inline-form" method="post" action="/s/${esc(space.slug)}/chat-room/detach"><button class="btn btn-sm btn-ghost" type="submit">Detach the chat room</button></form>` : ''}
++ ${r ? `<form class="inline-form" method="post" action="/forum/${esc(space.slug)}/chat-room/detach"><button class="btn btn-sm btn-ghost" type="submit">Detach the chat room</button></form>` : ''}
</details>` : '';
return `<section class="chat-room" id="chat-room" aria-labelledby="chat-room-h"><h2 class="h3" id="chat-room-h">Chat room</h2>${link}${manage}</section>`;
}
@@@ -138,13 +138,13 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
const statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';
const canStart = viewer.can_start !== false;
const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';
-- const start = canStart ? `<p><a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`
-- : `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/s/feedback">Feedback</a>.</p>`;
++ const start = canStart ? `<p><a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`
++ : `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/forum/feedback">Feedback</a>.</p>`;
const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;
const body = `
<header class="page-head">
-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>
-- <h1>${esc(space.name)}${visBadge(space)}${feedable ? ` <a class="rss-link" href="/s/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>
++ <h1>${esc(space.name)}${visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${start}
</header>
@@@ -152,19 +152,19 @@ ${chatRoom}
${categoryNav}${statusNav}
<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav>
<section data-results>
-- ${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}
++ ${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/forum/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}
${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}
</section>
${footer}
--<footer class="space-foot"><a class="small" href="/s/${esc(space.slug)}/log">Moderation log</a></footer>`;
++<footer class="space-foot"><a class="small" href="/forum/${esc(space.slug)}/log">Moderation log</a></footer>`;
return renderPage({
title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,
description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,
canonicalPath: spaceHref(space.slug, { sort, page, category, status }),
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
-- feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
++ feeds: feedable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : [],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }])],
body,
});
}
@@@ -181,7 +181,7 @@ function modActions({ base, space, thread, viewer, categories = [] }) {
</div>` : '';
}
--// ── /s/:space/t/:slug ────────────────────────────────────────
++// ── /forum/:space/:slug ────────────────────────────────────────
function postHtml(p, n, { base = '', page = 1, catalogue = [], canModerate = false } = {}) {
if (p.deleted) return `<article class="post post-deleted" id="post-${p.id}"><p class="muted small">#${n} · This post was deleted.</p></article>`;
return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
@@@ -271,7 +271,7 @@ function labelsBox(base, thread, viewer) {
}
function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false, reactions = [], crosspost = null }) {
-- const base = `/s/${space.slug}/t/${thread.slug}`;
++ const base = `/forum/${space.slug}/${thread.slug}`;
const opening = posts.find((p) => p.is_opening) || null;
const gated = !!(space.members_only || thread.members_only);
const indexable = space.visibility === 'public' && !gated;
@@@ -292,7 +292,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
const body = `
<article class="thread">
-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav>
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav>
<header class="thread-head">
${space.votes === false ? '' : voteForm(base, thread, viewer)}
<div>
@@@ -325,21 +325,21 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
footerVariant: 'compact',
...(labelBox ? { pageCss: ['labels.css'] } : {}),
feeds: [
-- ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : []),
++ ...(space.visibility === 'public' && !space.members_only ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/forum/${space.slug}/feed.xml` }] : []),
{ title: `OpenVibe.Community — ${thread.title}`, href: `${base}/feed.xml` },
],
jsonLd: [
...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
-- ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
++ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }, { name: thread.title, url: base }]),
],
body,
});
}
--// ── /s/:space/new ────────────────────────────────────────────
++// ── /forum/:space/new ────────────────────────────────────────────
function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {
const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';
-- const form = user ? `<form class="paste-form" method="post" action="/s/${esc(space.slug)}/new"${attachments ? ' enctype="multipart/form-data"' : ''}>
++ const form = user ? `<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/new"${attachments ? ' enctype="multipart/form-data"' : ''}>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
@@@ -353,17 +353,17 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
<span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>
</div>
--</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/s/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;
++</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;
const body = `
<header class="page-head">
-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav>
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">New thread</span></nav>
<h1>New thread in ${esc(space.name)}</h1>
</header>
${form}`;
return renderPage({
title: `New thread · ${space.name}`,
description: `Start a thread in ${space.name} on OpenVibe.Community.`,
-- canonicalPath: `/s/${space.slug}/new`,
++ canonicalPath: `/forum/${space.slug}/new`,
robots: 'noindex,follow',
active: 'spaces',
footerVariant: 'compact',
@@@ -377,14 +377,14 @@ ${form}`;
* What someone without the creator's VIP membership sees: the space (and thread title), why, and
* the join link — never a post. `reason` is VIP's (not_signed_in, not_a_member, vip_unavailable, …).
*/
--function membersOnlyPage({ space, thread = null, members_only: mo = null, reason, user, next = '/s' }) {
++function membersOnlyPage({ space, thread = null, members_only: mo = null, reason, user, next = '/forum' }) {
const creator = mo && mo.owner_username ? `@${mo.owner_username}` : 'this creator';
const why = reason === 'not_signed_in' || !user
? `<p><a class="btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> if you are already a member.</p>`
: reason === 'vip_unavailable' || reason === 'entitlement_unknown'
? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>'
: '<p class="muted">Your account does not have an active membership for this.</p>';
-- const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;
++ const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › ${thread ? `<a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;
const body = `
<header class="page-head">
${crumbs}
@@@ -399,7 +399,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
return renderPage({
title: `${thread ? thread.title : space.name} · members only`,
description: `For ${creator}'s OpenVibe.VIP members on OpenVibe.Community.`,
-- canonicalPath: thread ? `/s/${space.slug}/t/${thread.slug}` : `/s/${space.slug}`,
++ canonicalPath: thread ? `/forum/${space.slug}/${thread.slug}` : `/forum/${space.slug}`,
robots: 'noindex,nofollow',
active: 'spaces',
footerVariant: 'compact',
@@@ -407,7 +407,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
});
}
--// ── /s/:space/log ────────────────────────────────────────────
++// ── /forum/:space/log ────────────────────────────────────────────
/** What a log row removed, as the public log may name it: a live title, a post in it, or a removed thread. */
function logTarget(e) {
if (!e.thread) return '<span class="muted">a removed thread</span>';
@@@ -432,23 +432,23 @@ function moderationLogPage({ space, entries }) {
</tr>`).join('');
const body = `
<header class="page-head">
-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Forum</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">Moderation log</span></nav>
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <a href="/forum/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">Moderation log</span></nav>
<h1>Moderation log</h1>
<p class="muted">What moderation did in ${esc(space.name)} and why. Newest first; who acted is shown by role only.</p>
</header>
${entries.length ? `<table class="board-table mod-log"><caption class="sr-only">Moderation actions in ${esc(space.name)}</caption>
<thead><tr><th scope="col">When</th><th scope="col">What</th><th scope="col">Action</th><th scope="col">Rule</th><th scope="col">Reason</th><th scope="col">By</th></tr></thead>
<tbody>${rows}</tbody></table>` : '<p class="empty">No moderation actions in this forum yet.</p>'}
--<footer class="space-foot"><a class="small" href="/s/${esc(space.slug)}">Back to ${esc(space.name)}</a>${space.visibility === 'public' && !space.members_only ? `<span class="sep"> · </span><a class="small" href="/s/${esc(space.slug)}/feed.xml">RSS</a>` : ''}</footer>`;
++<footer class="space-foot"><a class="small" href="/forum/${esc(space.slug)}">Back to ${esc(space.name)}</a>${space.visibility === 'public' && !space.members_only ? `<span class="sep"> · </span><a class="small" href="/forum/${esc(space.slug)}/feed.xml">RSS</a>` : ''}</footer>`;
return renderPage({
title: `Moderation log · ${space.name}`,
description: `What moderation did in ${space.name} on OpenVibe.Community, and why: the action, the rule and the reason.`,
-- canonicalPath: `/s/${space.slug}/log`,
++ canonicalPath: `/forum/${space.slug}/log`,
robots: space.visibility === 'public' && !space.members_only ? 'index,follow' : 'noindex,nofollow',
active: 'spaces',
footerVariant: 'compact',
pageCss: ['modlog.css'],
-- jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: 'Moderation log', url: `/s/${space.slug}/log` }])],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Forum', url: '/forum' }, { name: space.name, url: `/forum/${space.slug}` }, { name: 'Moderation log', url: `/forum/${space.slug}/log` }])],
body,
});
}
ddiff --git a/server/render/jsonld.js b/server/render/jsonld.js
iindex b2b1a12..5327d3e 100644
---- a/server/render/jsonld.js
++++ b/server/render/jsonld.js
@@@ -82,7 +82,7 @@ function discussionAuthorLd(a) {
/** DiscussionForumPosting for a thread page: the opening post, counters and the replies on the page. */
function threadLd({ space, thread, posts, opening, description }) {
const { markdownToText } = require('./markdown');
-- const url = abs(`/s/${space.slug}/t/${thread.slug}`);
++ const url = abs(`/forum/${space.slug}/${thread.slug}`);
const replies = posts.filter((p) => !p.is_opening && !p.deleted).slice(0, 20);
return {
'@context': 'https://schema.org',
@@@ -94,7 +94,7 @@ function threadLd({ space, thread, posts, opening, description }) {
author: discussionAuthorLd(thread.author),
datePublished: isoDate(thread.created_at) || undefined,
dateModified: isoDate((opening && opening.updated_at) || thread.created_at) || undefined,
-- isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/s/${space.slug}`) },
++ isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/forum/${space.slug}`) },
commentCount: thread.reply_count,
interactionStatistic: [
{ '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: thread.reply_count },
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 3f00c10..8a95ddc 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -64,7 +64,7 @@ function navbarInit(opts) {
links: [
// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold
// into the navbar's More (Shared 4.22). Search is the navbar's magnifier; What shipped is the footer's.
-- { label: 'Forum', href: '/s', icon: 'fa-comments', active: opts.active === 'spaces' },
++ { label: 'Forum', href: '/forum', icon: 'fa-comments', active: opts.active === 'spaces' },
{ label: 'Pastes', href: '/pastes', icon: 'fa-paste', active: opts.active === 'pastes' },
{ label: 'Pulse', href: '/pulse', icon: 'fa-bolt', active: opts.active === 'pulse' },
{ label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
@@@ -95,11 +95,11 @@ function footerInit(opts) {
heading: 'Community',
items: [
{ name: 'Pastes', url: '/pastes' },
-- { name: 'Forum', url: '/s' },
++ { name: 'Forum', url: '/forum' },
{ name: 'Pulse', url: '/pulse' },
{ name: 'New paste', url: '/new' },
{ name: 'My pastes', url: '/my' },
-- { name: 'Roadmap', url: '/s/roadmap' },
++ { name: 'Roadmap', url: '/forum/roadmap' },
{ name: 'Source code', url: 'https://github.com/OpenVibers/OpenVibe.Community' },
],
}],
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 728650c..bcfa84e 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -137,7 +137,7 @@ function cardGrid(pastes, empty = 'Nothing here yet.') {
// like an app"): what Community is and the two things to do first, the numbers as one strip, the newest pastes and the
// moments from streams as rails of the shared kit's cards (openvibe-shared kit.rail + kit.card, room.css) with See all
// and the kinds as quick filters, the forum's places as a launcher (showcase.apps), the roadmap and Pulse as short lists,
--// and the parts read once (the API) folded. The full lists live on /pastes, /s, /s/roadmap and /pulse.
++// and the parts read once (the API) folded. The full lists live on /pastes, /forum, /forum/roadmap and /pulse.
const TERMINAL = `$ curl -F 'content=<build.log' \\
https://openvibe.community/api/pastes
{ "slug": "amber-fox-42",
@@@ -218,18 +218,18 @@ function forumShelf(spaces) {
return showcase.apps({
id: 'forum', title: 'The forum',
items: spaces.slice(0, 8).map((sp) => ({
-- icon: SPACE_ICON[sp.slug] || 'ov:chat', name: sp.name, href: `/s/${sp.slug}`,
++ icon: SPACE_ICON[sp.slug] || 'ov:chat', name: sp.name, href: `/forum/${sp.slug}`,
text: `${sp.description ? `${ld.clean(sp.description, 90)} · ` : ''}${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}`,
})),
-- more: { href: '/s', label: 'All forums' },
++ more: { href: '/forum', label: 'All forums' },
});
}
const whenOf = (v) => (v ? kit.when(v) : { text: '', iso: '' });
function roadmapShelf(items) {
if (!items || !items.length) return '';
-- return `<section class="cm-sec" id="roadmap" aria-labelledby="h-roadmap">${shelfHead('h-roadmap', 'On the roadmap', { href: '/s/roadmap', label: 'The whole roadmap' })}
--${kit.list(items.slice(0, 5).map((t) => kit.row({ href: `/s/roadmap/t/${t.slug}`, title: t.title, subHtml: kit.badge(STATUS_WORDS[t.status] || 'Open', STATUS_TONE[t.status] || ''), meta: whenOf(t.last_activity_at).text, metaIso: whenOf(t.last_activity_at).iso })))}</section>`;
++ return `<section class="cm-sec" id="roadmap" aria-labelledby="h-roadmap">${shelfHead('h-roadmap', 'On the roadmap', { href: '/forum/roadmap', label: 'The whole roadmap' })}
++${kit.list(items.slice(0, 5).map((t) => kit.row({ href: `/forum/roadmap/${t.slug}`, title: t.title, subHtml: kit.badge(STATUS_WORDS[t.status] || 'Open', STATUS_TONE[t.status] || ''), meta: whenOf(t.last_activity_at).text, metaIso: whenOf(t.last_activity_at).iso })))}</section>`;
}
function pulseShelf(items) {
@@@ -465,7 +465,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
<a class="btn btn-sm" href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
<button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>
-- ${!restricted && p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/s/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
++ ${!restricted && p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/forum/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
</div>`;
ddiff --git a/server/render/pulse.js b/server/render/pulse.js
iindex 8b6c442..b986950 100644
---- a/server/render/pulse.js
++++ b/server/render/pulse.js
@@@ -61,7 +61,7 @@ function pulsePage({ items, origin = '', after = null, nextCursor = null, origin
// The first page of each filter is worth indexing; deep cursors and empty pages are not.
robots: after || !items.length ? 'noindex,follow' : 'index,follow',
active: 'pulse',
-- feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
++ feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/forum/feed.xml' }],
jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pulse', url: '/pulse' }])],
body,
});
ddiff --git a/server/search/documents.js b/server/search/documents.js
iindex a56fc02..162a6bd 100644
---- a/server/search/documents.js
++++ b/server/search/documents.js
@@@ -78,7 +78,7 @@ function createSearchDocuments({ db }) {
if (t.crosspost_of) facets.crosspost = true;
const doc = {
owner: 'community', type: 'thread', id: String(t.id), deleted: false, visibility: 'public',
-- canonical_url: `${config.baseUrl}/s/${encodeURIComponent(t.space_slug)}/t/${encodeURIComponent(t.slug)}`,
++ canonical_url: `${config.baseUrl}/forum/${encodeURIComponent(t.space_slug)}/${encodeURIComponent(t.slug)}`,
title: clean(t.title, 500) || 'Thread',
summary: clean(plain(opening && opening.body_markdown), 300) || `A thread in ${t.space_name} on OpenVibe.Community.`,
body: posts.map((p) => clean(plain(p.body_markdown), 8000)).filter(Boolean).join('\n').slice(0, 40000),
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
iindex c1fc001..09a418a 100644
---- a/test/attachments.test.js
++++ b/test/attachments.test.js
@@@ -113,7 +113,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
assert.strictEqual(page.attachments.enabled, true);
r = await json(alexJwt, `/api/v1/spaces/general/threads/${thread.slug}/posts`, { body: 'again', attachments: [first.media_id] });
assert.strictEqual(r.status, 400, 'already on a post');
-- const html = (await t.get(`/s/general/t/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;
++ const html = (await t.get(`/forum/general/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;
assert.ok(html.includes(`src="${first.url}"`) && html.includes('enctype="multipart/form-data"') && html.includes('name="attachments"'));
});
@@@ -122,7 +122,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
fd.append('body', 'Two more');
fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'a.png');
fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'b.png');
-- const r = await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });
++ const r = await t.get(`/forum/general/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();
const reply = page.posts.find((p) => p.body_markdown === 'Two more');
@@@ -132,7 +132,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
empty.append('body', '');
empty.append('attachments', new Blob([PNG], { type: 'image/png' }), 'c.png');
const before = objects.size;
-- await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: empty, cookies: [`ov_token=${samJwt}`] });
++ await t.get(`/forum/general/${thread.slug}/reply`, { method: 'POST', body: empty, cookies: [`ov_token=${samJwt}`] });
assert.strictEqual(objects.size, before, 'an empty reply uploads nothing');
});
ddiff --git a/test/blocks.test.js b/test/blocks.test.js
iindex 772497e..babee1b 100644
---- a/test/blocks.test.js
++++ b/test/blocks.test.js
@@@ -76,7 +76,7 @@ const SECRET = `whsec_${'cd'.repeat(32)}`;
r = await call(`/api/v1/spaces/general/threads/${threadSlug}/posts`, { method: 'POST', cookie: kimJwt, json: { body: 'kim here' } });
assert.strictEqual(r.status, 201, r.text);
// The no-JS form: the thread page again, the plain message in the reply form, the draft kept.
-- r = await call(`/s/general/t/${threadSlug}/reply`, { method: 'POST', cookie: samJwt, form: { body: 'my <draft>' } });
++ r = await call(`/forum/general/${threadSlug}/reply`, { method: 'POST', cookie: samJwt, form: { body: 'my <draft>' } });
assert.strictEqual(r.status, 403);
assert.ok(r.text.includes('<p class="alert alert-error" role="alert">You cannot reply in this thread: its author blocked you</p>'), 'the message in the form');
assert.ok(r.text.includes('my &lt;draft&gt;</textarea>'), 'the draft kept, escaped');
ddiff --git a/test/categories-roadmap.test.js b/test/categories-roadmap.test.js
iindex 68c7fb0..d7fb77a 100644
---- a/test/categories-roadmap.test.js
++++ b/test/categories-roadmap.test.js
@@@ -115,22 +115,22 @@ const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');
});
await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {
-- let r = await call('/s/feedback?category=ideas', { cookie: alexJwt });
++ let r = await call('/forum/feedback?category=ideas', { cookie: alexJwt });
assert.strictEqual(r.status, 200);
const html = r.text;
assert.ok(html.includes('aria-label="Categories"') && html.includes('aria-label="Status"') && html.includes('New request'));
assert.ok(/<meta name="robots" content="noindex,follow"/.test(html), 'filtered lists are not indexed');
-- r = await call('/s/roadmap', { cookie: alexJwt });
++ r = await call('/forum/roadmap', { cookie: alexJwt });
assert.ok(!r.text.includes('New roadmap item') && r.text.includes('Staff add roadmap items'));
assert.ok(r.text.includes('badge-status status-in_progress'));
-- r = await call('/s/roadmap/new', { cookie: alexJwt });
++ r = await call('/forum/roadmap/new', { cookie: alexJwt });
assert.strictEqual(r.status, 403);
-- r = await call(`/s/feedback/t/${req.slug}`, { cookie: modJwt });
++ r = await call(`/forum/feedback/${req.slug}`, { cookie: modJwt });
assert.ok(r.text.includes('Set status') && r.text.includes('Set category'));
-- r = await call(`/s/feedback/t/${req.slug}/status`, { method: 'POST', cookie: modJwt, form: { status: 'done' } });
++ r = await call(`/forum/feedback/${req.slug}/status`, { method: 'POST', cookie: modJwt, form: { status: 'done' } });
assert.strictEqual(r.status, 303);
assert.strictEqual((await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread.status, 'done');
-- r = await call('/s/feedback/new', { cookie: alexJwt });
++ r = await call('/forum/feedback/new', { cookie: alexJwt });
assert.ok(r.text.includes('name="category"') && r.text.includes('Search first'));
});
ddiff --git a/test/chat-room.test.js b/test/chat-room.test.js
iindex 3d82228..20eaafe 100644
---- a/test/chat-room.test.js
++++ b/test/chat-room.test.js
@@@ -125,9 +125,9 @@ function startStubChat() {
chat.state.down = true;
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
assert.deepStrictEqual([r.status, r.json().code], [503, 'chat_room.unavailable']);
-- const f = await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' });
++ const f = await form(alexJwt, '/forum/showcase/chat-room', { room: 'night-owls' });
assert.strictEqual(f.status, 303);
-- assert.match(f.headers.get('location'), /^\/s\/showcase\?chat_error=.*#chat-room$/);
++ assert.match(f.headers.get('location'), /^\/forum\/showcase\?chat_error=.*#chat-room$/);
const page = await t.get(f.headers.get('location').replace(/#.*$/, ''), { cookies: [`ov_token=${alexJwt}`] });
assert.match(page.text, /<p class="alert alert-error" role="alert">OpenVibe.Chat did not answer/);
chat.state.down = false;
@@@ -135,23 +135,23 @@ function startStubChat() {
});
await check('the space page links the room; its owner gets the forms; others only the link', async () => {
-- let page = await t.get('/s/showcase');
++ let page = await t.get('/forum/showcase');
assert.strictEqual(page.status, 200);
assert.match(page.text, /<section class="chat-room" id="chat-room"/);
assert.match(page.text, /<a class="btn" href="https:\/\/openvibe\.chat\/r\/night-owls" rel="noopener">.*Night Owls<\/a>/);
-- assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'no forms for a visitor');
-- page = await t.get('/s/showcase', { cookies: [`ov_token=${samJwt}`] });
-- assert.ok(!page.text.includes('action="/s/showcase/chat-room"'), 'nor for another person');
-- page = await t.get('/s/showcase', { cookies: [`ov_token=${alexJwt}`] });
-- assert.ok(page.text.includes('action="/s/showcase/chat-room"') && page.text.includes('action="/s/showcase/chat-room/detach"'));
-- page = await t.get('/s/general');
++ assert.ok(!page.text.includes('action="/forum/showcase/chat-room"'), 'no forms for a visitor');
++ page = await t.get('/forum/showcase', { cookies: [`ov_token=${samJwt}`] });
++ assert.ok(!page.text.includes('action="/forum/showcase/chat-room"'), 'nor for another person');
++ page = await t.get('/forum/showcase', { cookies: [`ov_token=${alexJwt}`] });
++ assert.ok(page.text.includes('action="/forum/showcase/chat-room"') && page.text.includes('action="/forum/showcase/chat-room/detach"'));
++ page = await t.get('/forum/general');
assert.ok(!page.text.includes('id="chat-room"'), 'a space without a room shows nothing');
-- page = await t.get('/s/general', { cookies: [`ov_token=${bossJwt}`] });
++ page = await t.get('/forum/general', { cookies: [`ov_token=${bossJwt}`] });
assert.match(page.text, /Attach a chat room/, 'staff may attach one anywhere');
// Forum-style spaces show it too.
await api('PUT', '/general/chat-room', bossJwt, { room: 'staff-room' });
-- assert.match((await t.get('/s/general')).text, /href="https:\/\/openvibe\.chat\/r\/staff-room"/);
-- assert.strictEqual((await form(alexJwt, '/s/showcase/chat-room', { room: 'night-owls' }, 'https://evil.example')).status, 403, 'forms from elsewhere are refused');
++ assert.match((await t.get('/forum/general')).text, /href="https:\/\/openvibe\.chat\/r\/staff-room"/);
++ assert.strictEqual((await form(alexJwt, '/forum/showcase/chat-room', { room: 'night-owls' }, 'https://evil.example')).status, 403, 'forms from elsewhere are refused');
});
await check('replace, then detach: idempotent, and Chat is told when the person may remove its side', async () => {
@@@ -164,8 +164,8 @@ function startStubChat() {
// The owner puts theirs back and detaches it without JavaScript: both sides go.
r = await api('PUT', '/showcase/chat-room', alexJwt, { room: 'night-owls' });
assert.strictEqual(r.status, 201);
-- const f = await form(alexJwt, '/s/showcase/chat-room/detach', {});
-- assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/s/showcase#chat-room']);
++ const f = await form(alexJwt, '/forum/showcase/chat-room/detach', {});
++ assert.deepStrictEqual([f.status, f.headers.get('location')], [303, '/forum/showcase#chat-room']);
assert.ok(!(await rows()).some((x) => x.room_slug === 'night-owls'));
assert.strictEqual(chat.attachments.has('night-owls|showcase'), false, 'Chat\'s side went too');
r = await api('DELETE', '/showcase/chat-room', alexJwt);
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
iindex ed5274a..4ade731 100644
---- a/test/discovery.test.js
++++ b/test/discovery.test.js
@@@ -17,7 +17,7 @@ const { check, done } = require('./helpers/app');
(async () => {
await check('robots.txt keeps the disallow set for every crawler and names the sitemap', () => {
const txt = seo.robotsTxt({ sitemaps: ['https://openvibe.community/sitemap.xml'], disallow: discovery.ROBOTS_DISALLOW });
-- assert.deepStrictEqual(discovery.ROBOTS_DISALLOW, ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso=']);
++ assert.deepStrictEqual(discovery.ROBOTS_DISALLOW, ['/api/', '/auth/', '/my', '/new', '/forum/*/new', '/*?sso=']);
// Shared 4.13 opens robots.txt with its content-signals note (a comment and Content-Signal); the rules follow.
assert.ok(txt.replace(/^(?:#[^\n]*\n|Content-Signal:[^\n]*\n|\n)*/, '').startsWith('User-agent: *\nAllow: /\nDisallow: /api/\n'));
assert.strictEqual(txt.split('Disallow: /*?sso=').length - 1, 2, 'the generic and the search/AI group both disallow');
@@@ -29,10 +29,10 @@ const { check, done } = require('./helpers/app');
const txt = seo.llmsTxt({ name: 'OpenVibe.Community', summary: discovery.SUMMARY, details: discovery.CONTENT_LABELS, markdown: true, base: 'https://openvibe.community', sections, optional });
assert.ok(txt.includes('## Browse\n\n- [OpenVibe.Community](https://openvibe.community/index.md): pastes, forum and network activity'));
assert.ok(txt.includes('- [Pastes](https://openvibe.community/pastes.md): public pastes, newest first'));
-- assert.ok(txt.includes('- [Forum](https://openvibe.community/s) (HTML): open discussion forums and their threads'));
++ assert.ok(txt.includes('- [Forum](https://openvibe.community/forum) (HTML): open discussion forums and their threads'));
assert.ok(txt.includes('- [Pulse](https://openvibe.community/pulse) (HTML): network activity'));
assert.ok(txt.includes('## Optional\n\n- [Sitemap](https://openvibe.community/sitemap.xml)'));
-- assert.ok(txt.includes('- [Latest threads (RSS)](https://openvibe.community/s/feed.xml)'));
++ assert.ok(txt.includes('- [Latest threads (RSS)](https://openvibe.community/forum/feed.xml)'));
assert.ok(txt.includes('- [Full text for language models](https://openvibe.community/llms-full.txt)'));
assert.ok(txt.includes('labelled as AI-generated'));
});
@@@ -57,11 +57,11 @@ const { check, done } = require('./helpers/app');
await check('thread feed items use the 2.5.0 feedXml shape and keep the thread link as guid', () => {
const items = discovery.threadFeedItems([{ title: 'Hi <b>', space_slug: 'general', slug: 'hi-1', opening: 'Some **text**', created_at: '2026-09-15 10:00:00', last_activity_at: '2026-09-16 10:00:00' }]);
assert.deepStrictEqual(Object.keys(items[0]), ['title', 'link', 'guid', 'description', 'content', 'author', 'published', 'updated']);
-- assert.strictEqual(items[0].link, 'https://openvibe.community/s/general/t/hi-1');
++ assert.strictEqual(items[0].link, 'https://openvibe.community/forum/general/hi-1');
assert.strictEqual(items[0].published, '2026-09-15T10:00:00.000Z');
assert.strictEqual(items[0].updated, '2026-09-16T10:00:00.000Z');
-- const xml = seo.feedXml({ title: 't', link: 'https://openvibe.community/s', description: 'd', selfUrl: 'https://openvibe.community/s/feed.xml', items }, { format: 'rss' });
-- assert.ok(xml.includes('<atom:link href="https://openvibe.community/s/feed.xml" rel="self" type="application/rss+xml"/>'));
++ const xml = seo.feedXml({ title: 't', link: 'https://openvibe.community/forum', description: 'd', selfUrl: 'https://openvibe.community/forum/feed.xml', items }, { format: 'rss' });
++ assert.ok(xml.includes('<atom:link href="https://openvibe.community/forum/feed.xml" rel="self" type="application/rss+xml"/>'));
assert.ok(xml.includes('<title>Hi &lt;b&gt;</title>'));
assert.ok(xml.includes('<pubDate>Tue, 15 Sep 2026 10:00:00 GMT</pubDate>'));
});
@@@ -73,7 +73,7 @@ const { check, done } = require('./helpers/app');
assert.strictEqual(out['@type'], 'DiscussionForumPosting');
assert.deepStrictEqual(out.author, { '@type': 'Person', name: 'Alex', url: 'https://openvibe.live/@alex' });
assert.deepStrictEqual(out.comment[0].author, { '@type': 'Organization', name: 'OpenVibe AI' });
-- assert.strictEqual(out.comment[0].url, 'https://openvibe.community/s/general/t/hi-1#post-2');
++ assert.strictEqual(out.comment[0].url, 'https://openvibe.community/forum/general/hi-1#post-2');
assert.strictEqual(ld.isoDate('nope'), null);
});
ddiff --git a/test/events.test.js b/test/events.test.js
iindex 2279778..ebd5c0b 100644
---- a/test/events.test.js
++++ b/test/events.test.js
@@@ -58,7 +58,7 @@ const comments = require('../server/comments/store');
assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);
evs.forEach(ok);
assert.strictEqual(evs[0].visibility, 'public');
-- assert.ok(/\/s\/general\/t\//.test(evs[0].payload.url));
++ assert.ok(/\/forum\/general\//.test(evs[0].payload.url));
assert.strictEqual(evs[2].visibility, 'internal');
assert.strictEqual(evs[2].payload.visibility, 'members');
assert.strictEqual(evs[2].payload.url, null);
ddiff --git a/test/fixtures/n-1/client.json b/test/fixtures/n-1/client.json
iindex a604bd0..ce593eb 100644
---- a/test/fixtures/n-1/client.json
++++ b/test/fixtures/n-1/client.json
@@@ -489,7 +489,7 @@
},
{
"method": "GET",
-- "path": "/s",
++ "path": "/forum",
"auth": "anon",
"accept": "html",
"status": 200,
@@@ -556,7 +556,7 @@
},
{
"method": "GET",
-- "path": "/s/general",
++ "path": "/forum/general",
"auth": "anon",
"accept": "html",
"status": 200,
@@@ -633,7 +633,7 @@
},
{
"method": "GET",
-- "path": "/s/discuss?paste={@paste}",
++ "path": "/forum/discuss?paste={@paste}",
"auth": "anon",
"accept": "html",
"status": 200,
@@@ -644,40 +644,40 @@
},
{
"method": "GET",
-- "path": "/s/general/feed.xml",
++ "path": "/forum/general/feed.xml",
"auth": "anon",
"accept": "html",
"status": 200,
"kind": "other",
"from": [
-- "page /s/general"
++ "page /forum/general"
]
},
{
"method": "GET",
-- "path": "/s/general/new",
++ "path": "/forum/general/new",
"auth": "anon",
"accept": "html",
"status": 200,
"kind": "html",
"from": [
-- "page /s/general"
++ "page /forum/general"
]
},
{
"method": "GET",
-- "path": "/s/general/t/{@thread_slug}",
++ "path": "/forum/general/{@thread_slug}",
"auth": "anon",
"accept": "html",
"status": 200,
"kind": "html",
"from": [
-- "page /s/general"
++ "page /forum/general"
]
},
{
"method": "GET",
-- "path": "/s/general/t/n-1-thread",
++ "path": "/forum/general/n-1-thread",
"auth": "anon",
"accept": "html",
"status": 200,
@@@ -688,14 +688,14 @@
},
{
"method": "GET",
-- "path": "/auth/login?next=%2Fs%2Fgeneral%2Ft%2F{@thread_slug}",
++ "path": "/auth/login?next=%2Fforum%2Fgeneral%2Ft%2F{@thread_slug}",
"auth": "anon",
"accept": "html",
"status": 302,
"kind": "html",
"location": "/oauth/authorize",
"from": [
-- "page /s/general/t/n-1-thread"
++ "page /forum/general/n-1-thread"
]
},
{
@@@ -734,7 +734,7 @@
},
{
"method": "GET",
-- "path": "/s/feed.xml",
++ "path": "/forum/feed.xml",
"auth": "anon",
"accept": "html",
"status": 200,
@@@ -944,7 +944,7 @@
},
{
"method": "POST",
-- "path": "/s/new-space",
++ "path": "/forum/new",
"auth": "anon",
"status": 403,
"kind": "html",
@@@ -954,7 +954,7 @@
},
{
"method": "POST",
-- "path": "/s/general/chat-room",
++ "path": "/forum/general/chat-room",
"auth": "anon",
"status": 303,
"kind": "empty",
@@@ -965,7 +965,7 @@
},
{
"method": "POST",
-- "path": "/s/general/chat-room/detach",
++ "path": "/forum/general/chat-room/detach",
"auth": "anon",
"status": 303,
"kind": "empty",
@@@ -976,7 +976,7 @@
},
{
"method": "POST",
-- "path": "/s/general/new",
++ "path": "/forum/general/new",
"auth": "anon",
"status": 303,
"kind": "empty",
@@@ -987,7 +987,7 @@
},
{
"method": "POST",
-- "path": "/s/general/settings",
++ "path": "/forum/general/settings",
"auth": "anon",
"status": 403,
"kind": "html",
@@@ -997,7 +997,7 @@
},
{
"method": "POST",
-- "path": "/s/general/t/n-1-thread/category",
++ "path": "/forum/general/n-1-thread/category",
"auth": "anon",
"status": 403,
"kind": "html",
@@@ -1007,7 +1007,7 @@
},
{
"method": "POST",
-- "path": "/s/general/t/n-1-thread/crosspost",
++ "path": "/forum/general/n-1-thread/crosspost",
"auth": "anon",
"status": 303,
"kind": "empty",
@@@ -1018,7 +1018,7 @@
},
{
"method": "POST",
-- "path": "/s/general/t/n-1-thread/delete",
++ "path": "/forum/general/n-1-thread/delete",
"auth": "anon",
"status": 403,
"kind": "html",
@@@ -1028,7 +1028,7 @@
},
{
"method": "POST",
-- "path": "/s/general/t/n-1-thread/members-only",
++ "path": "/forum/general/n-1-thread/members-only",
"auth": "anon",
"status": 403,
"kind": "html",
@@@ -1038,7 +1038,7 @@
},
{
"method": "POST",
-- "path": "/s/general/t/n-1-thread/react",
++ "path": "/forum/general/n-1-thread/react",
"auth": "anon",
"status": 303,
"kind": "empty",
@@@ -1049,7 +1049,7 @@
},
{
"method": "POST",
-- "path": "/s/general/t/n-1-thread/reply",
++ "path": "/forum/general/n-1-thread/reply",
"auth": "anon",
"status": 303,
"kind": "empty",
@@@ -1061,7 +1061,7 @@
},
{
"method": "POST",
-- "path": "/s/general/t/n-1-thread/state",
++ "path": "/forum/general/n-1-thread/state",
"auth": "anon",
"status": 403,
"kind": "html",
@@@ -1071,7 +1071,7 @@
},
{
"method": "POST",
-- "path": "/s/general/t/n-1-thread/status",
++ "path": "/forum/general/n-1-thread/status",
"auth": "anon",
"status": 403,
"kind": "html",
@@@ -1081,7 +1081,7 @@
},
{
"method": "POST",
-- "path": "/s/general/t/n-1-thread/vote",
++ "path": "/forum/general/n-1-thread/vote",
"auth": "anon",
"status": 400,
"kind": "html",
ddiff --git a/test/forum-home.test.js b/test/forum-home.test.js
iindex 5a38003..b0e3b6a 100644
---- a/test/forum-home.test.js
++++ b/test/forum-home.test.js
@@@ -1,35 +1,63 @@
'use strict';
/**
-- * The forum is Community's again (2026-10-08 owner decision): /s and its nested paths serve the
-- * forum here. OpenVibe.Space, which briefly held the forum, now hosts code and "spaces" and
-- * redirects its forum paths to us; nothing here redirects to Space.
++ * The forum is Community's (2026-10-08 owner decision) and moved from /s to /forum (2026-11-07):
++ * /forum and its nested paths serve it, and every old /s address answers a permanent redirect
++ * (301 for reads, 308 for form posts) with the query string kept. Nothing here redirects to Space.
*/
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
(async () => {
const t = await boot();
-- await check('GET /s and nested paths serve the forum here, with no redirect to Space', async () => {
-- for (const path of ['/s', '/s/general', '/s/general?sort=new']) {
++ await check('GET /forum and nested paths serve the forum here, with no redirect to Space', async () => {
++ for (const path of ['/forum', '/forum/general', '/forum/general?sort=new']) {
const r = await t.get(path);
assert.strictEqual(r.status, 200, path);
assert.strictEqual(r.headers.get('location'), null, path);
}
-- const board = await t.get('/s');
++ const board = await t.get('/forum');
assert.ok(board.text.includes('Forum · OpenVibe.Community'), 'the board index is Community\'s');
});
-- await check('the boards are called the Forum (openvibe.space is a different product); /forum answers with a 301 to /s', async () => {
-- const board = await t.get('/s');
++ await check('the boards are called the Forum; the nav and canonical use /forum', async () => {
++ const board = await t.get('/forum');
assert.ok(board.text.includes('<h1>Forum</h1>') && !/<h1>Spaces<\/h1>|>New space<|No spaces yet/.test(board.text), 'the page says Forum');
++ assert.ok(board.text.includes('<link rel="canonical" href="https://openvibe.community/forum">'), 'canonical is /forum');
const cfg = JSON.parse(board.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
-- assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').label, 'Forum', 'the nav says Forum');
++ assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/forum').label, 'Forum', 'the nav says Forum');
assert.ok(!cfg.navbar.links.some((l) => /space/i.test(l.label)), 'no nav link says Spaces');
-- for (const [from, to] of [['/forum', '/s'], ['/forum/', '/s'], ['/forum?x=1', '/s?x=1'], ['/forum/general?sort=new', '/s/general?sort=new'], ['/forum/general/t/hello', '/s/general/t/hello']]) {
++ });
++ await check('every old /s address answers a 301 to its /forum address, query kept', async () => {
++ const map = [
++ ['/s', '/forum'],
++ ['/s/', '/forum'],
++ ['/s?x=1', '/forum?x=1'],
++ ['/s/general', '/forum/general'],
++ ['/s/general?sort=new', '/forum/general?sort=new'],
++ ['/s/general/t/hello', '/forum/general/hello'],
++ ['/s/general/t/hello?page=2', '/forum/general/hello?page=2'],
++ ['/s/general/t/hello/feed.xml', '/forum/general/hello/feed.xml'],
++ ['/s/general/new', '/forum/general/new'],
++ ['/s/general/log', '/forum/general/log'],
++ ['/s/general/feed.xml', '/forum/general/feed.xml'],
++ ['/s/feed.xml', '/forum/feed.xml'],
++ ['/s/new-space', '/forum/new'],
++ ['/s/discuss', '/forum/discuss'],
++ ];
++ for (const [from, to] of map) {
const r = await t.get(from, { redirect: 'manual' });
assert.strictEqual(r.status, 301, from);
assert.strictEqual(r.headers.get('location'), to, from);
}
const nope = await t.get('/forums', { redirect: 'manual' });
-- assert.notStrictEqual(nope.status, 301, '/forums is not /forum');
++ assert.notStrictEqual(nope.status, 301, '/forums is not the forum');
++ });
++ await check('an old form post answers a 308 to the /forum address, method and body kept', async () => {
++ const r = await t.get('/s/general/t/hello/reply', {
++ method: 'POST', redirect: 'manual',
++ body: new URLSearchParams({ body: 'old tab' }).toString(),
++ headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ });
++ assert.strictEqual(r.status, 308);
++ assert.strictEqual(r.headers.get('location'), '/forum/general/hello/reply');
});
await t.close();
done();
ddiff --git a/test/forum-ssr.test.js b/test/forum-ssr.test.js
iindex f268f21..a5bf3a2 100644
---- a/test/forum-ssr.test.js
++++ b/test/forum-ssr.test.js
@@@ -1,7 +1,7 @@
'use strict';
/**
-- * Forum pages without JavaScript: /s, /s/:space (sort + pagination links), /s/:space/t/:slug
-- * (posts, reply/vote/moderation forms), /s/:space/new — plain form posts, SEO (canonical, OG,
++ * Forum pages without JavaScript: /forum, /forum/:space (sort + pagination links), /forum/:space/:slug
++ * (posts, reply/vote/moderation forms), /forum/:space/new — plain form posts, SEO (canonical, OG,
* JSON-LD DiscussionForumPosting, robots for members/staff spaces), sitemap, feeds, robots.txt.
*/
const assert = require('assert');
@@@ -25,39 +25,39 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });
const as = (jwt, opts = {}) => ({ ...opts, cookies: [`ov_token=${jwt}`] });
-- await check('/s lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {
-- const r = await t.get('/s');
++ await check('/forum lists the public spaces with SEO head, breadcrumbs and the threads feed', async () => {
++ const r = await t.get('/forum');
assert.strictEqual(r.status, 200);
has(r.text, '<title>Forum · OpenVibe.Community</title>');
-- has(r.text, '<link rel="canonical" href="https://openvibe.community/s">');
++ has(r.text, '<link rel="canonical" href="https://openvibe.community/forum">');
has(r.text, '<meta name="robots" content="index,follow">');
-- has(r.text, 'href="/s/feed.xml"');
-- for (const s of ['general', 'feedback', 'showcase']) has(r.text, `href="/s/${s}"`);
++ has(r.text, 'href="/forum/feed.xml"');
++ for (const s of ['general', 'feedback', 'showcase']) has(r.text, `href="/forum/${s}"`);
assert.ok(ld(r.text).some((o) => o['@type'] === 'BreadcrumbList'));
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
-- assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/s').active, true);
++ assert.strictEqual(cfg.navbar.links.find((l) => l.href === '/forum').active, true);
});
await check('no-JS new thread: anonymous → sign in; signed in → 303 to the thread; errors re-render escaped', async () => {
-- const page = await t.get('/s/general/new');
++ const page = await t.get('/forum/general/new');
assert.strictEqual(page.status, 200);
-- has(page.text, '/auth/login?next=%2Fs%2Fgeneral%2Fnew');
++ has(page.text, '/auth/login?next=%2Fforum%2Fgeneral%2Fnew');
has(page.text, '<meta name="robots" content="noindex,follow">');
-- const anon = await t.get('/s/general/new', form({ title: 'Anon thread', body: 'x' }));
++ const anon = await t.get('/forum/general/new', form({ title: 'Anon thread', body: 'x' }));
assert.strictEqual(anon.status, 303);
-- assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Fs%2Fgeneral%2Fnew$/);
-- const bad = await t.get('/s/general/new', as(alexJwt, form({ title: 'x', body: '<img src=x onerror=alert(1)>' })));
++ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Fforum%2Fgeneral%2Fnew$/);
++ const bad = await t.get('/forum/general/new', as(alexJwt, form({ title: 'x', body: '<img src=x onerror=alert(1)>' })));
assert.strictEqual(bad.status, 400);
has(bad.text, 'Titles are 3 to 200 characters');
has(bad.text, '&lt;img src=x onerror=alert(1)&gt;</textarea>');
-- const ok = await t.get('/s/general/new', as(alexJwt, form({ title: 'How do I <b>embed</b> comments?', body: 'Asking for **Live**.\n\n```js\nconst x = "<y>";\n```\n\n<script>alert(1)</script>' })));
++ const ok = await t.get('/forum/general/new', as(alexJwt, form({ title: 'How do I <b>embed</b> comments?', body: 'Asking for **Live**.\n\n```js\nconst x = "<y>";\n```\n\n<script>alert(1)</script>' })));
assert.strictEqual(ok.status, 303, ok.text);
-- assert.strictEqual(ok.headers.get('location'), '/s/general/t/how-do-i-b-embed-b-comments');
-- const foreign = await t.get('/s/general/new', as(alexJwt, { ...form({ title: 'Cross site', body: 'x' }), headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example' } }));
++ assert.strictEqual(ok.headers.get('location'), '/forum/general/how-do-i-b-embed-b-comments');
++ const foreign = await t.get('/forum/general/new', as(alexJwt, { ...form({ title: 'Cross site', body: 'x' }), headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example' } }));
assert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');
});
-- const url = '/s/general/t/how-do-i-b-embed-b-comments';
++ const url = '/forum/general/how-do-i-b-embed-b-comments';
await check('thread page: escaped title, safe Markdown, canonical, OG article, JSON-LD DiscussionForumPosting', async () => {
const r = await t.get(url);
@@@ -117,57 +117,57 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
assert.ok(!r.text.includes(`action="${url}/reply"`));
has(r.text, '<h1><i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');
assert.ok(!r.text.includes('aria-label="Pinned"'), 'the pin does not carry an aria-label on an icon');
-- const list = await t.get('/s/general');
++ const list = await t.get('/forum/general');
has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');
assert.ok(!list.text.includes('aria-label="Pinned"'), 'the list pin does not carry an aria-label either');
const admin = await t.get(url, as(adminJwt));
has(admin.text, 'Unpin');
has(admin.text, 'Unlock');
-- const mine = await t.get('/s/general/new', as(samJwt, form({ title: 'Short lived', body: 'x' })));
++ const mine = await t.get('/forum/general/new', as(samJwt, form({ title: 'Short lived', body: 'x' })));
const mineUrl = mine.headers.get('location');
assert.strictEqual((await t.get(`${mineUrl}/delete`, as(alexJwt, form({})))).status, 403);
const del = await t.get(`${mineUrl}/delete`, as(samJwt, form({})));
assert.strictEqual(del.status, 303);
-- assert.strictEqual(del.headers.get('location'), '/s/general');
++ assert.strictEqual(del.headers.get('location'), '/forum/general');
assert.strictEqual((await t.get(mineUrl)).status, 404);
});
await check('space page: hot/new/top tabs as links, server pagination with rel=prev/next and canonical', async () => {
const space = await forumStore.getSpace(t.db, 'showcase');
for (let i = 1; i <= 27; i++) await forumStore.createThread(t.db, { space_id: space.id, title: `Showcase item ${i}`, author_subject: alex.subject_id, body_markdown: `item ${i}` });
-- const p1 = await t.get('/s/showcase?sort=new');
++ const p1 = await t.get('/forum/showcase?sort=new');
assert.strictEqual(p1.status, 200);
-- has(p1.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new">');
-- has(p1.text, 'href="/s/showcase"');
-- has(p1.text, 'href="/s/showcase?sort=top"');
-- has(p1.text, 'rel="next" href="/s/showcase?sort=new&amp;page=2"');
++ has(p1.text, '<link rel="canonical" href="https://openvibe.community/forum/showcase?sort=new">');
++ has(p1.text, 'href="/forum/showcase"');
++ has(p1.text, 'href="/forum/showcase?sort=top"');
++ has(p1.text, 'rel="next" href="/forum/showcase?sort=new&amp;page=2"');
has(p1.text, 'Page 1 of 2');
assert.strictEqual((p1.text.match(/class="thread-row"/g) || []).length, 25);
-- const p2 = await t.get('/s/showcase?sort=new&page=2');
-- has(p2.text, 'rel="prev" href="/s/showcase?sort=new"');
-- has(p2.text, '<link rel="canonical" href="https://openvibe.community/s/showcase?sort=new&amp;page=2">');
++ const p2 = await t.get('/forum/showcase?sort=new&page=2');
++ has(p2.text, 'rel="prev" href="/forum/showcase?sort=new"');
++ has(p2.text, '<link rel="canonical" href="https://openvibe.community/forum/showcase?sort=new&amp;page=2">');
has(p2.text, 'Showcase item 1<');
-- assert.strictEqual((await t.get('/s/showcase?page=9')).status, 404);
-- assert.strictEqual((await t.get('/s/nowhere')).status, 404);
++ assert.strictEqual((await t.get('/forum/showcase?page=9')).status, 404);
++ assert.strictEqual((await t.get('/forum/nowhere')).status, 404);
});
await check('members and staff spaces: sign-in redirect, noindex for members, 404 for non-staff; never in feeds or sitemap', async () => {
await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members'), ('mods', 'Mods', 'staff')").run();
const ins = await forumStore.getSpace(t.db, 'insiders');
await forumStore.createThread(t.db, { space_id: ins.id, title: 'Members only chat', author_subject: alex.subject_id, body_markdown: 'psst' });
-- const anon = await t.get('/s/insiders');
++ const anon = await t.get('/forum/insiders');
assert.strictEqual(anon.status, 303);
-- assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fs%2Finsiders');
-- const member = await t.get('/s/insiders', as(samJwt));
++ assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fforum%2Finsiders');
++ const member = await t.get('/forum/insiders', as(samJwt));
assert.strictEqual(member.status, 200);
has(member.text, '<meta name="robots" content="noindex,nofollow">');
-- const th = await t.get('/s/insiders/t/members-only-chat', as(samJwt));
++ const th = await t.get('/forum/insiders/members-only-chat', as(samJwt));
has(th.text, '<meta name="robots" content="noindex,nofollow">');
-- assert.strictEqual((await t.get('/s/mods', as(samJwt))).status, 404);
-- assert.strictEqual((await t.get('/s/mods', as(adminJwt))).status, 200);
-- const feed = await t.get('/s/feed.xml');
++ assert.strictEqual((await t.get('/forum/mods', as(samJwt))).status, 404);
++ assert.strictEqual((await t.get('/forum/mods', as(adminJwt))).status, 200);
++ const feed = await t.get('/forum/feed.xml');
assert.ok(!feed.text.includes('Members only chat'));
-- assert.strictEqual((await t.get('/s/insiders/feed.xml')).status, 404);
++ assert.strictEqual((await t.get('/forum/insiders/feed.xml')).status, 404);
require('../server/discovery').resetCaches();
const map = await t.get('/sitemap.xml');
assert.ok(!map.text.includes('members-only-chat'));
@@@ -176,7 +176,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
await check('AI threads are labelled as AI on the page and in JSON-LD (never a person)', async () => {
const space = await forumStore.getSpace(t.db, 'feedback');
await forumStore.createThread(t.db, { space_id: space.id, title: 'Weekly summary', origin: 'ai', body_markdown: 'What people asked for.' });
-- const r = await t.get('/s/feedback/t/weekly-summary');
++ const r = await t.get('/forum/feedback/weekly-summary');
has(r.text, 'OpenVibe AI');
has(r.text, '<span class="badge badge-ai"');
assert.deepStrictEqual(ld(r.text).find((o) => o['@type'] === 'DiscussionForumPosting').author, { '@type': 'Organization', name: 'OpenVibe AI' });
@@@ -185,17 +185,17 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
await check('sitemap, feeds and robots.txt include the forum', async () => {
require('../server/discovery').resetCaches();
const map = await t.get('/sitemap.xml');
-- for (const loc of ['/s', '/s/general', '/pulse', url]) has(map.text, `<loc>https://openvibe.community${loc}</loc>`);
-- const feed = await t.get('/s/feed.xml');
++ for (const loc of ['/forum', '/forum/general', '/pulse', url]) has(map.text, `<loc>https://openvibe.community${loc}</loc>`);
++ const feed = await t.get('/forum/feed.xml');
assert.strictEqual(feed.status, 200);
has(feed.text, '<rss version="2.0"');
has(feed.text, `<link>https://openvibe.community${url}</link>`);
has(feed.text, '<title>How do I &lt;b&gt;embed&lt;/b&gt; comments?</title>');
-- const spaceFeed = await t.get('/s/showcase/feed.xml');
++ const spaceFeed = await t.get('/forum/showcase/feed.xml');
has(spaceFeed.text, 'Showcase item 27');
assert.ok(!spaceFeed.text.includes('embed'));
const robots = await t.get('/robots.txt');
-- has(robots.text, 'Disallow: /s/*/new');
++ has(robots.text, 'Disallow: /forum/*/new');
});
await t.close();
ddiff --git a/test/forum-starters.test.js b/test/forum-starters.test.js
iindex 0fd68c2..0c03c28 100644
---- a/test/forum-starters.test.js
++++ b/test/forum-starters.test.js
@@@ -42,9 +42,9 @@ const { syncStarters, syncFromFile, FILE } = require('../server/forum/starters')
await check('the page renders the guide with its links (no raw HTML)', async () => {
const th = (await call('/api/v1/spaces/feedback/threads')).json().threads.find((x) => x.title === 'How feedback works');
-- const r = await call(`/s/feedback/t/${th.slug}`);
++ const r = await call(`/forum/feedback/${th.slug}`);
assert.strictEqual(r.status, 200);
-- assert.ok(r.text.includes('href="/s/roadmap"'), 'same-site link');
++ assert.ok(r.text.includes('href="/forum/roadmap"'), 'same-site link');
assert.ok(r.text.includes('<strong>One idea or one bug per post.</strong>'));
});
ddiff --git a/test/forum-style.test.js b/test/forum-style.test.js
iindex 0083dbb..cacccad 100644
---- a/test/forum-style.test.js
++++ b/test/forum-style.test.js
@@@ -37,15 +37,15 @@ const { boot, check, done } = require('./helpers/app');
r = await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'hi from sam' } });
assert.strictEqual(r.status, 201);
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/votes', { method: 'POST', cookie: samJwt, json: { value: 1 } })).json().code, 'space.votes_off');
-- await call('/s/general/t/welcome-thread', { cookie: samJwt });
-- await call('/s/general/t/welcome-thread', { cookie: samJwt });
-- await call('/s/general/t/welcome-thread', { cookie: alexJwt });
++ await call('/forum/general/welcome-thread', { cookie: samJwt });
++ await call('/forum/general/welcome-thread', { cookie: samJwt });
++ await call('/forum/general/welcome-thread', { cookie: alexJwt });
const list = (await call('/api/v1/spaces/general/threads')).json();
assert.strictEqual(list.sort, 'active', 'forum boards list the newest reply first');
const row = list.threads[0];
assert.strictEqual(row.views, 2, 'one view per viewer per half hour');
assert.strictEqual(row.last_post.author.username, 'sam');
-- const page = (await call('/s/general')).text;
++ const page = (await call('/forum/general')).text;
assert.ok(page.includes('class="board-table topic-table"') && page.includes('data-label="Views"') && /Started by <span data-ov-subject="usr_[0-9A-HJKMNP-TV-Z]{26}">Alex<\/span>/.test(page), 'the starter\'s name is marked for what they wear');
assert.ok(!page.includes('class="thread-score"'), 'no score column');
});
@@@ -53,7 +53,7 @@ const { boot, check, done } = require('./helpers/app');
await check('the postbit avatar asks Media for the width it draws (the 64 px picture: 160)', async () => {
const obj = 'https://openvibe.media/o/med_01M644DY8BFX4H7MTRKPGG052R';
await t.db.prepare('UPDATE subject_projection SET avatar_url = ? WHERE subject_id = ?').run(obj, alex.subject_id);
-- const html = (await call('/s/general/t/welcome-thread')).text;
++ const html = (await call('/forum/general/welcome-thread')).text;
assert.ok(html.includes(`<img class="postbit-avatar" src="${obj}?w=160"`), 'the 64 px postbit avatar asks for 160');
await t.db.prepare('UPDATE subject_projection SET avatar_url = NULL WHERE subject_id = ?').run(alex.subject_id);
});
@@@ -63,7 +63,7 @@ const { boot, check, done } = require('./helpers/app');
const general = r.groups[0].spaces.find((s) => s.slug === 'general');
assert.strictEqual(general.thread_count, 1); assert.strictEqual(general.post_count, 2);
assert.strictEqual(general.last_post.thread.title, 'Welcome thread'); assert.strictEqual(general.last_post.author.username, 'sam');
-- const html = (await call('/s')).text;
++ const html = (await call('/forum')).text;
assert.ok(html.includes('id="g-openvibe"') && html.includes('id="g-community"') && html.includes('Off-topic') && html.includes('badge-style">Classic') && html.includes('badge-style">Feed'));
});
@@@ -85,9 +85,9 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(page.posts[0].reactions.map((x) => [x.key, x.count, x.raters]), [['winner', 1, ['Boss']]]);
assert.deepStrictEqual(page.posts[0].author_stats.ratings.map((x) => [x.key, x.count]), [['winner', 1]]);
assert.strictEqual(page.posts[0].can_react, true); assert.strictEqual(page.posts[1].can_react, false, 'sam cannot rate their own reply');
-- const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;
++ const html = (await call('/forum/general/welcome-thread', { cookie: samJwt })).text;
assert.ok(html.includes('class="postbit') && html.includes('postbit-author') && html.includes('<dt>Posts</dt>') && html.includes('Rate</summary>') && html.includes('🏆'));
-- r = await call('/s/general/t/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });
++ r = await call('/forum/general/welcome-thread/react', { method: 'POST', cookie: samJwt, form: { post: String(post.id), reaction: 'funny' } });
assert.strictEqual(r.status, 303); assert.ok(r.headers.get('location').endsWith(`#post-${post.id}`));
await t.db.prepare("UPDATE spaces SET reactions = 0 WHERE slug = 'general'").run();
assert.strictEqual((await rate(samJwt, 'agree')).json().code, 'space.reactions_off');
@@@ -97,7 +97,7 @@ const { boot, check, done } = require('./helpers/app');
await check('quote: ?quote= fills the reply box with the quoted post', async () => {
const post = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts[1];
-- const html = (await call(`/s/general/t/welcome-thread?quote=${post.id}`, { cookie: alexJwt })).text;
++ const html = (await call(`/forum/general/welcome-thread?quote=${post.id}`, { cookie: alexJwt })).text;
assert.ok(html.includes('**@sam** wrote:\n&gt; hi from sam'), 'the quote is in the reply box');
});
@@@ -108,7 +108,7 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(r.json().post.pastes.map((p) => [p.slug, p.lines]), [['snip1', 2], ['snip2', 2]]);
for (const bad of ['secret1', 'burn1']) assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: [bad] } })).json().code, 'pastes.not_shareable');
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/posts', { method: 'POST', cookie: samJwt, json: { body: 'x', pastes: ['nope-nope'] } })).status, 404);
-- const html = (await call('/s/general/t/welcome-thread')).text;
++ const html = (await call('/forum/general/welcome-thread')).text;
assert.ok(html.includes('class="paste-embed"') && html.includes('href="/p/snip1"') && html.includes('hljs'));
await t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = 'snip2'").run();
const after = (await call('/api/v1/spaces/general/threads/welcome-thread')).json().posts.find((p) => p.body_markdown === 'look at these');
@@@ -117,10 +117,10 @@ const { boot, check, done } = require('./helpers/app');
await check('"Discuss in a space" from a paste: the picker, then a new topic with the paste attached', async () => {
const paste = (await call('/p/snip1')).text;
-- assert.ok(paste.includes('/s/discuss?paste=snip1'));
-- const picker = (await call('/s/discuss?paste=snip1', { cookie: alexJwt })).text;
-- assert.ok(picker.includes('/s/general/new?paste=snip1') && !picker.includes('/s/roadmap/new'));
-- const form = (await call('/s/general/new?paste=snip1', { cookie: alexJwt })).text;
++ assert.ok(paste.includes('/forum/discuss?paste=snip1'));
++ const picker = (await call('/forum/discuss?paste=snip1', { cookie: alexJwt })).text;
++ assert.ok(picker.includes('/forum/general/new?paste=snip1') && !picker.includes('/forum/roadmap/new'));
++ const form = (await call('/forum/general/new?paste=snip1', { cookie: alexJwt })).text;
assert.ok(form.includes('name="pastes"') && form.includes('value="snip1"') && form.includes('value="Snippet"'));
});
@@@ -130,13 +130,13 @@ const { boot, check, done } = require('./helpers/app');
const cp = r.json().thread;
assert.strictEqual(cp.space, 'showcase'); assert.strictEqual(cp.crosspost_of, topic.id);
const there = (await call(`/api/v1/spaces/showcase/threads/${cp.slug}`)).json();
-- assert.strictEqual(there.crosspost.from.url, '/s/general/t/welcome-thread');
++ assert.strictEqual(there.crosspost.from.url, '/forum/general/welcome-thread');
assert.ok(there.posts[0].body_markdown.startsWith('Crossposted from **General**'));
const here = (await call('/api/v1/spaces/general/threads/welcome-thread', { cookie: samJwt })).json();
assert.deepStrictEqual(here.crosspost.to.map((x) => x.space), ['Showcase']);
assert.ok(here.crosspost.targets.some((x) => x.slug === 'showcase') && !here.crosspost.targets.some((x) => x.slug === 'roadmap' || x.slug === 'general'));
assert.strictEqual((await call('/api/v1/spaces/general/threads/welcome-thread/crosspost', { method: 'POST', cookie: samJwt, json: { to: 'roadmap' } })).status, 403);
-- const html = (await call('/s/general/t/welcome-thread', { cookie: samJwt })).text;
++ const html = (await call('/forum/general/welcome-thread', { cookie: samJwt })).text;
assert.ok(html.includes('Also in') && html.includes('Crosspost</summary>'));
});
@@@ -145,20 +145,22 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 403);
r = await call('/api/v1/spaces/showcase/settings', { method: 'PUT', cookie: modJwt, json: { style: 'forum', votes: false } });
assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().space.style, 'forum'); assert.strictEqual(r.json().space.votes, false);
-- assert.ok((await call('/s/showcase')).text.includes('topic-table'));
-- r = await call('/s/showcase/settings', { method: 'POST', cookie: modJwt, form: { name: 'Showcase', style: 'feed', votes: '1', reactions: '1', group: 'community' } });
++ assert.ok((await call('/forum/showcase')).text.includes('topic-table'));
++ r = await call('/forum/showcase/settings', { method: 'POST', cookie: modJwt, form: { name: 'Showcase', style: 'feed', votes: '1', reactions: '1', group: 'community' } });
assert.strictEqual(r.status, 303);
-- assert.ok((await call('/s/showcase')).text.includes('class="thread-list"'));
-- assert.strictEqual((await call('/s/new-space', { cookie: alexJwt })).status, 403);
-- r = await call('/s/new-space', { method: 'POST', cookie: modJwt, form: { name: 'Music', slug: 'music', description: 'Songs', style: 'forum', reactions: '1', group: 'community' } });
-- assert.strictEqual(r.status, 303); assert.strictEqual(r.headers.get('location'), '/s/music');
++ assert.ok((await call('/forum/showcase')).text.includes('class="thread-list"'));
++ assert.strictEqual((await call('/forum/new', { cookie: alexJwt })).status, 403);
++ r = await call('/forum/new', { method: 'POST', cookie: modJwt, form: { name: 'Music', slug: 'music', description: 'Songs', style: 'forum', reactions: '1', group: 'community' } });
++ assert.strictEqual(r.status, 303); assert.strictEqual(r.headers.get('location'), '/forum/music');
const music = (await call('/api/v1/spaces')).json().spaces.find((s) => s.slug === 'music');
assert.deepStrictEqual([music.style, music.votes, music.reactions, music.group.slug], ['forum', false, true, 'community']);
r = await call('/api/v1/spaces/music/settings', { method: 'PUT', cookie: modJwt, json: { parent: 'off-topic' } });
assert.strictEqual(r.status, 200, r.text);
const idx = (await call('/api/v1/spaces')).json();
assert.ok(idx.groups.find((g) => g.slug === 'community').spaces.find((s) => s.slug === 'off-topic').children.some((c) => c.slug === 'music'), 'a child board sits under its parent');
-- assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', cookie: modJwt, json: { slug: 'discuss', name: 'Nope' } })).status, 400, 'reserved address');
++ for (const slug of ['new', 'discuss', 'search', 'about', 'new-space', 'feed.xml']) {
++ assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', cookie: modJwt, json: { slug, name: 'Nope' } })).status, 400, `${slug} is a reserved address`);
++ }
});
await done(t);
ddiff --git a/test/forum.test.js b/test/forum.test.js
iindex d3a31ab..8fe2583 100644
---- a/test/forum.test.js
++++ b/test/forum.test.js
@@@ -47,7 +47,7 @@ const forumStore = require('../server/forum/store');
first = r.json().thread;
assert.strictEqual(first.slug, 'hello-world');
assert.strictEqual(first.title, 'Hello, World!');
-- assert.strictEqual(first.url, '/s/general/t/hello-world');
++ assert.strictEqual(first.url, '/forum/general/hello-world');
assert.strictEqual(first.author.subject, alex.subject_id);
const op = r.json().post;
assert.strictEqual(op.is_opening, true);
@@@ -60,6 +60,17 @@ const forumStore = require('../server/forum/store');
assert.strictEqual(forumStore.slugify('☃☃☃'), 'thread');
});
++ await check('a title that makes one of the forum\'s own route words takes -2, so /forum/<space>/<slug> stays a thread', async () => {
++ for (const [title, slug] of [['New', 'new-2'], ['Log', 'log-2'], ['Settings', 'settings-2'], ['Members', 'members-2'], ['Chat room', 'chat-room-2'], ['Discuss', 'discuss-2']]) {
++ const r = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title, body: 'reserved word' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().thread.slug, slug, title);
++ assert.strictEqual(r.json().thread.url, `/forum/general/${slug}`);
++ }
++ const page = await t.get('/forum/general/new-2');
++ assert.strictEqual(page.status, 200, 'the thread renders at its new address');
++ });
++
await check('create thread: validation and who may post', async () => {
assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', json: { title: 'Anon', body: 'x' } })).status, 401);
assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'ab', body: 'x' } })).json().code, 'thread.invalid_title');
@@@ -86,7 +97,7 @@ const forumStore = require('../server/forum/store');
await check('replies: reply_count and last activity move; locked threads refuse people, not moderators', async () => {
const r1 = await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: samJwt, json: { body: 'first reply' } });
assert.strictEqual(r1.status, 201, r1.text);
-- assert.match(r1.json().url, /^\/s\/general\/t\/hello-world#post-\d+$/);
++ assert.match(r1.json().url, /^\/forum\/general\/hello-world#post-\d+$/);
await call('/api/v1/spaces/general/threads/hello-world/posts', { method: 'POST', cookie: alexJwt, json: { body: 'second reply' } });
const th = (await call('/api/v1/spaces/general/threads/hello-world')).json();
assert.strictEqual(th.thread.reply_count, 2);
ddiff --git a/test/markdown.test.js b/test/markdown.test.js
iindex bbfa2f6..4c0ac0a 100644
---- a/test/markdown.test.js
++++ b/test/markdown.test.js
@@@ -20,7 +20,7 @@ const { check, done } = require('./helpers/app');
assert.ok(md('```python\ndef f(): pass\n```').includes('<span class="hljs-keyword">def</span>'));
assert.strictEqual(md('[OpenVibe](https://openvibe.network/a?b=1&c=2)'), '<p><a href="https://openvibe.network/a?b=1&amp;c=2" rel="nofollow ugc noopener">OpenVibe</a></p>');
assert.strictEqual(md('see https://openvibe.live/x.'), '<p>see <a href="https://openvibe.live/x" rel="nofollow ugc noopener">https://openvibe.live/x</a>.</p>');
-- assert.strictEqual(md('[home](/s/general) [top](#post-1) [mail](mailto:[email protected])'), '<p><a href="/s/general" rel="nofollow ugc noopener">home</a> <a href="#post-1" rel="nofollow ugc noopener">top</a> <a href="mailto:[email protected]" rel="nofollow ugc noopener">mail</a></p>');
++ assert.strictEqual(md('[home](/forum/general) [top](#post-1) [mail](mailto:[email protected])'), '<p><a href="/forum/general" rel="nofollow ugc noopener">home</a> <a href="#post-1" rel="nofollow ugc noopener">top</a> <a href="mailto:[email protected]" rel="nofollow ugc noopener">mail</a></p>');
assert.strictEqual(md('snake_case_name stays'), '<p>snake_case_name stays</p>');
assert.strictEqual(md('**`code` in bold**'), '<p><strong><code>code</code> in bold</strong></p>');
});
ddiff --git a/test/members-only.test.js b/test/members-only.test.js
iindex 6d432be..72398fa 100644
---- a/test/members-only.test.js
++++ b/test/members-only.test.js
@@@ -183,11 +183,11 @@ function startVip() {
assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));
assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id))).c, 0);
const sitemap = (await call('/sitemap.xml')).text;
-- assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/s/showcase'), 'no gated thread or space in the sitemap');
-- const feed = (await call('/s/feed.xml')).text;
++ assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/forum/showcase'), 'no gated thread or space in the sitemap');
++ const feed = (await call('/forum/feed.xml')).text;
assert.ok(!feed.includes('Backstage') && !feed.includes('Members lounge'));
-- assert.strictEqual((await call('/s/showcase/feed.xml')).status, 404);
-- const general = (await call('/s/general/feed.xml')).text;
++ assert.strictEqual((await call('/forum/showcase/feed.xml')).status, 404);
++ const general = (await call('/forum/general/feed.xml')).text;
assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));
// A thread gated after the fact leaves Pulse as well.
const later = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Soon private', body: 'soon' } })).json().thread;
@@@ -200,34 +200,34 @@ function startVip() {
});
await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {
-- const anon = await call(`/s/general/t/${secret.slug}`);
++ const anon = await call(`/forum/general/${secret.slug}`);
assert.strictEqual(anon.status, 403);
assert.ok(anon.text.includes('https://openvibe.vip/cora'));
assert.ok(anon.text.includes('Backstage with Cora'));
assert.ok(!anon.text.includes('secret backstage body') && !anon.text.includes('member reply'));
assert.ok(/noindex/.test(anon.text));
assert.ok(anon.text.includes('/auth/login?next='), 'signed out: a sign-in link');
-- const space = await call('/s/showcase', { who: stan });
++ const space = await call('/forum/showcase', { who: stan });
assert.strictEqual(space.status, 403);
assert.ok(space.text.includes('https://openvibe.vip/cora') && !space.text.includes('Members lounge'));
-- assert.strictEqual((await call('/s/showcase/new', { who: stan })).status, 403, 'no new-thread form for outsiders');
-- const reply = await form(`/s/general/t/${secret.slug}/reply`, stan, { body: 'sneaky' });
++ assert.strictEqual((await call('/forum/showcase/new', { who: stan })).status, 403, 'no new-thread form for outsiders');
++ const reply = await form(`/forum/general/${secret.slug}/reply`, stan, { body: 'sneaky' });
assert.strictEqual(reply.status, 403);
-- const member = await call(`/s/general/t/${secret.slug}`, { who: mia });
++ const member = await call(`/forum/general/${secret.slug}`, { who: mia });
assert.strictEqual(member.status, 200);
assert.ok(member.text.includes('secret backstage body'));
assert.ok(/noindex/.test(member.text));
assert.ok(!member.text.includes('DiscussionForumPosting'), 'no structured data for a gated thread');
-- const listing = await call('/s/general', { who: stan });
++ const listing = await call('/forum/general', { who: stan });
assert.ok(listing.text.includes('Backstage with Cora') && listing.text.includes('VIP members only'));
assert.ok(!listing.text.includes('secret backstage body'));
// The author's no-JS toggle.
const own = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Toggle me', body: 'toggle' } })).json().thread;
-- const page = await call(`/s/general/t/${own.slug}`, { who: cora });
-- assert.ok(page.text.includes(`/s/general/t/${own.slug}/members-only`));
-- const on = await form(`/s/general/t/${own.slug}/members-only`, cora, { on: '1' });
++ const page = await call(`/forum/general/${own.slug}`, { who: cora });
++ assert.ok(page.text.includes(`/forum/general/${own.slug}/members-only`));
++ const on = await form(`/forum/general/${own.slug}/members-only`, cora, { on: '1' });
assert.strictEqual(on.status, 303);
-- assert.strictEqual((await call(`/s/general/t/${own.slug}`, { who: stan })).status, 403);
++ assert.strictEqual((await call(`/forum/general/${own.slug}`, { who: stan })).status, 403);
});
await check('convergence: VIP stops granting → Community refuses within the cache TTL (30 s); at once on vip.membership.changed', async () => {
@@@ -255,7 +255,7 @@ function startVip() {
const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: null } });
assert.strictEqual(r.json().space.members_only, null);
assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: stan })).status, 200);
-- assert.ok((await call('/sitemap.xml')).text.includes('/s/showcase'));
++ assert.ok((await call('/sitemap.xml')).text.includes('/forum/showcase'));
});
await t.close();
ddiff --git a/test/moderation-log.test.js b/test/moderation-log.test.js
iindex 2da621f..8c3c6d4 100644
---- a/test/moderation-log.test.js
++++ b/test/moderation-log.test.js
@@@ -47,9 +47,9 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
assert.strictEqual(reply.status, 201, reply.text);
let r = await call(`/api/v1/spaces/general/threads/${locked.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: true, rule: 'spam', reason: ' Repeated links ' } });
assert.strictEqual(r.status, 200, r.text);
-- r = await form(`/s/general/t/${locked.slug}/state`, bossJwt, { pinned: '1', rule: 'off-topic', reason: ` Pinned for ${'x'.repeat(300)} ` });
++ r = await form(`/forum/general/${locked.slug}/state`, bossJwt, { pinned: '1', rule: 'off-topic', reason: ` Pinned for ${'x'.repeat(300)} ` });
assert.strictEqual(r.status, 303, r.text);
-- r = await form(`/s/general/t/${locked.slug}/post/${reply.json().post.id}/delete`, bossJwt, { rule: 'personal information', reason: 'Names someone' });
++ r = await form(`/forum/general/${locked.slug}/post/${reply.json().post.id}/delete`, bossJwt, { rule: 'personal information', reason: 'Names someone' });
assert.strictEqual(r.status, 303, r.text);
feedback = await thread('feedback', 'A request whose status changes');
r = await call(`/api/v1/spaces/feedback/threads/${feedback.slug}/status`, { method: 'PUT', cookie: bossJwt, json: { status: 'planned', rule: 'duplicate', reason: 'Already planned elsewhere' } });
@@@ -80,7 +80,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
let r = await call(`/api/v1/spaces/general/threads/${locked.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: false, rule: 'not-a-rule' } });
assert.strictEqual(r.status, 400, r.text);
assert.strictEqual(r.json().code, 'moderation.invalid_rule');
-- r = await form(`/s/general/t/${locked.slug}/state`, bossJwt, { locked: '1', rule: 'not-a-rule' });
++ r = await form(`/forum/general/${locked.slug}/state`, bossJwt, { locked: '1', rule: 'not-a-rule' });
assert.strictEqual(r.status, 400, r.text);
has(r.text, 'The rule is one of spam');
assert.strictEqual((await rows()).length, before, 'a refused rule writes no row');
@@@ -90,7 +90,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
});
await check('the log page and JSON API show what happened, and the space page links it', async () => {
-- const page = await t.get('/s/general/log');
++ const page = await t.get('/forum/general/log');
assert.strictEqual(page.status, 200, page.text);
has(page.text, '<h1>Moderation log</h1>');
has(page.text, 'A thread that will be moderated');
@@@ -103,12 +103,12 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
assert.deepStrictEqual(json.json().entries.map((e) => e.action), ['delete', 'pin', 'lock']);
assert.strictEqual(json.json().space.slug, 'general');
assert.ok(!json.text.includes(boss.subject_id));
-- const feedbackLog = await t.get('/s/feedback/log');
++ const feedbackLog = await t.get('/forum/feedback/log');
assert.strictEqual(feedbackLog.status, 200);
has(feedbackLog.text, 'A request whose status changes');
has(feedbackLog.text, 'Status changed');
-- const space = await t.get('/s/general');
-- has(space.text, 'href="/s/general/log"');
++ const space = await t.get('/forum/general');
++ has(space.text, 'href="/forum/general/log"');
has(space.text, 'Moderation log');
has(space.text, '<link rel="alternate" type="application/rss+xml"');
});
@@@ -116,10 +116,10 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
await check('a thread feed is its newest posts, advertised in the thread head and linked on the page', async () => {
const reply = await call(`/api/v1/spaces/feedback/threads/${feedback.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'A feed reply with **markup**' } });
assert.strictEqual(reply.status, 201, reply.text);
-- const page = await t.get(`/s/feedback/t/${feedback.slug}`);
-- has(page.text, `<link rel="alternate" type="application/rss+xml" title="OpenVibe.Community — A request whose status changes" href="/s/feedback/t/${feedback.slug}/feed.xml">`);
++ const page = await t.get(`/forum/feedback/${feedback.slug}`);
++ has(page.text, `<link rel="alternate" type="application/rss+xml" title="OpenVibe.Community — A request whose status changes" href="/forum/feedback/${feedback.slug}/feed.xml">`);
has(page.text, 'Follow this thread by RSS');
-- const feed = await t.get(`/s/feedback/t/${feedback.slug}/feed.xml`);
++ const feed = await t.get(`/forum/feedback/${feedback.slug}/feed.xml`);
assert.strictEqual(feed.status, 200, feed.text);
assert.match(feed.headers.get('content-type'), /application\/rss\+xml/);
assert.match(feed.headers.get('cache-control'), /^public/, 'a public thread\'s feed may be cached');
@@@ -129,9 +129,9 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
const items = feed.text.split('<item>').slice(1);
has(items[0], 'A feed reply with markup', 'the newest post is the first item');
has(items[1], 'A request whose status changes');
-- const space = await t.get('/s/feedback');
++ const space = await t.get('/forum/feedback');
has(space.text, '<link rel="alternate" type="application/rss+xml"');
-- has(space.text, '/s/feedback/feed.xml');
++ has(space.text, '/forum/feedback/feed.xml');
});
await check('a private space\'s log is not public; its members and staff read it', async () => {
@@@ -140,11 +140,11 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
assert.strictEqual(insider.status, 201, insider.text);
assert.strictEqual((await call(`/api/v1/spaces/insiders/threads/${insider.json().thread.slug}/state`, { method: 'PUT', cookie: bossJwt, json: { locked: true, rule: 'harassment' } })).status, 200);
assert.strictEqual((await call('/api/v1/spaces/insiders/moderation-log')).status, 401, 'signed out: no JSON log');
-- const anon = await t.get('/s/insiders/log');
++ const anon = await t.get('/forum/insiders/log');
assert.notStrictEqual(anon.status, 200, 'signed out: no page');
-- assert.strictEqual((await t.get('/s/insiders/log', { cookies: [`ov_token=${alexJwt}`] })).status, 200, 'a signed-in member reads it');
++ assert.strictEqual((await t.get('/forum/insiders/log', { cookies: [`ov_token=${alexJwt}`] })).status, 200, 'a signed-in member reads it');
assert.strictEqual((await call('/api/v1/spaces/insiders/moderation-log', { cookie: alexJwt })).status, 200);
-- const feedUrl = `/s/insiders/t/${insider.json().thread.slug}/feed.xml`;
++ const feedUrl = `/forum/insiders/${insider.json().thread.slug}/feed.xml`;
assert.notStrictEqual((await t.get(feedUrl)).status, 200, 'signed out: no feed of a members thread');
const own = await t.get(feedUrl, { cookies: [`ov_token=${alexJwt}`] });
assert.strictEqual(own.status, 200, own.text);
@@@ -157,7 +157,7 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
const log = await rows();
assert.strictEqual(log.at(-1).action, 'delete');
assert.strictEqual(log.at(-1).rule, 'illegal content');
-- const page = await t.get('/s/general/log');
++ const page = await t.get('/forum/general/log');
assert.strictEqual(page.status, 200);
has(page.text, 'a removed thread');
has(page.text, 'Removed by a moderator');
@@@ -171,13 +171,13 @@ const has = (text, s, msg) => assert.ok(text.includes(s), msg || `expected to fi
const thread2 = await thread('general', 'A thread with moderation controls');
const reply = await call(`/api/v1/spaces/general/threads/${thread2.slug}/posts`, { method: 'POST', cookie: alexJwt, json: { body: 'A reply with its own delete form' } });
assert.strictEqual(reply.status, 201, reply.text);
-- const page = await t.get(`/s/general/t/${thread2.slug}`, { cookies: [`ov_token=${bossJwt}`] });
++ const page = await t.get(`/forum/general/${thread2.slug}`, { cookies: [`ov_token=${bossJwt}`] });
assert.strictEqual(page.status, 200);
has(page.text, 'Rule applied…');
has(page.text, 'name="reason"');
has(page.text, 'maxlength="280"');
-- has(page.text, `<form class="mod-form" method="post" action="/s/general/t/${thread2.slug}/state">`);
-- has(page.text, `action="/s/general/t/${thread2.slug}/post/${reply.json().post.id}/delete"`);
++ has(page.text, `<form class="mod-form" method="post" action="/forum/general/${thread2.slug}/state">`);
++ has(page.text, `action="/forum/general/${thread2.slug}/post/${reply.json().post.id}/delete"`);
has(page.text, 'Delete post');
});
ddiff --git a/test/n-1/service.js b/test/n-1/service.js
iindex 86699a8..5ff572a 100644
---- a/test/n-1/service.js
++++ b/test/n-1/service.js
@@@ -36,7 +36,7 @@ module.exports = {
forms: true,
keep: () => true,
/** Pages an open tab may hold (anonymous): every link, script and form in them is replayed too. */
-- crawl: ['/', '/pastes', '/p/{@paste}', '/s/general', '/s/general/t/n-1-thread', '/c/{@comment_access}', '/search', '/pulse', '/new', '/my'],
++ crawl: ['/', '/pastes', '/p/{@paste}', '/forum/general', '/forum/general/n-1-thread', '/c/{@comment_access}', '/search', '/pulse', '/new', '/my'],
origins: ['https://openvibe.community'],
/** Values for template expressions, first match wins; '@name' is a seeded row (boot-child.js ids). */
samples: [
ddiff --git a/test/pulse.test.js b/test/pulse.test.js
iindex 8cd69bb..6b23b3f 100644
---- a/test/pulse.test.js
++++ b/test/pulse.test.js
@@@ -62,10 +62,10 @@ const { boot, check, done } = require('./helpers/app');
await check('threads and replies in public spaces enter; members spaces never; deletes remove', async () => {
const { thread } = await forum.createThread(asAlex, 'general', { title: 'Pulse thread', body: 'hello' });
const it = await bySource('thread', thread.id);
-- assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/s/general/t/pulse-thread', alex.subject_id, 'user']);
++ assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/forum/general/pulse-thread', alex.subject_id, 'user']);
const { post } = await forum.reply(asAlex, 'general', 'pulse-thread', { body: 'a reply' });
assert.strictEqual((await bySource('post', post.id)).title, 'Re: Pulse thread');
-- assert.strictEqual((await bySource('post', post.id)).url, `https://openvibe.community/s/general/t/pulse-thread#post-${post.id}`);
++ assert.strictEqual((await bySource('post', post.id)).url, `https://openvibe.community/forum/general/pulse-thread#post-${post.id}`);
await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
const hidden = await forum.createThread(asAlex, 'insiders', { title: 'Members only', body: 'x' });
assert.strictEqual(await bySource('thread', hidden.thread.id), undefined);
ddiff --git a/test/relay-inbound.test.js b/test/relay-inbound.test.js
iindex cb98a5f..268fb53 100644
---- a/test/relay-inbound.test.js
++++ b/test/relay-inbound.test.js
@@@ -314,7 +314,7 @@ const sent = (gw, op) => gw.sockets.flatMap((s) => s.sent).filter((p) => p.op ==
gw.dispatch('MESSAGE_CREATE', { id: nextId(), channel_id: anchor.external_channel_id, type: 19, content: 'Looks **great**', author: { id: KIM, username: 'kim', global_name: 'Kim' }, message_reference: { message_id: anchor.external_message_id } });
const locked = nextId();
await waitFor(async () => await t.db.prepare("SELECT 1 FROM posts WHERE origin = 'discord'").get(), 'the Discord reply stored');
-- const page = await call(`/s/general/t/${th.json().thread.slug}`);
++ const page = await call(`/forum/general/${th.json().thread.slug}`);
assert.strictEqual(page.status, 200);
assert.ok(page.text.includes('badge-relay') && page.text.includes('Kim') && page.text.includes('<strong>great</strong>'), 'the reply shows with its Discord badge');
await t.db.prepare('UPDATE threads SET locked = 1').run();
ddiff --git a/test/relay.test.js b/test/relay.test.js
iindex f5674d2..89c9a71 100644
---- a/test/relay.test.js
++++ b/test/relay.test.js
@@@ -66,11 +66,11 @@ const { sqlTime } = require('../server/http/v1');
assert.strictEqual(msg.method, 'POST');
assert.strictEqual(msg.path, '/api/webhooks/1/general?wait=true');
assert.deepStrictEqual(msg.body.allowed_mentions, { parse: [] });
-- assert.match(msg.body.content, /^New thread in \*\*s\/general\*\* by Alex @\u200beveryone: <https:\/\/openvibe\.community\/s\/general\/t\/relay-me-please>$/);
++ assert.match(msg.body.content, /^New thread in \*\*forum\/general\*\* by Alex @\u200beveryone: <https:\/\/openvibe\.community\/forum\/general\/relay-me-please>$/);
assert.strictEqual(msg.body.embeds[0].title, 'Relay me <please>');
-- assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.community/s/general/t/relay-me-please');
++ assert.strictEqual(msg.body.embeds[0].url, 'https://openvibe.community/forum/general/relay-me-please');
assert.strictEqual(msg.body.embeds[0].description, 'Hello there, link');
-- assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Community · s/general');
++ assert.strictEqual(msg.body.embeds[0].footer.text, 'OpenVibe.Community · forum/general');
const d = (await deliveries()).find((x) => x.thread_id === th.id);
assert.deepStrictEqual([d.status, d.attempts, d.last_status, d.dedupe_key, d.action, d.source], ['delivered', 1, 200, `thread:${th.id}:mapping:1`, 'create', 'direct']);
const [row] = await mapOf('thread', th.id);
@@@ -234,7 +234,7 @@ const { sqlTime } = require('../server/http/v1');
assert.deepStrictEqual(hook.hits.map((h) => h.method), ['POST', 'POST']);
const r = hook.hits[1];
assert.strictEqual(r.path, '/api/webhooks/1/general?wait=true');
-- assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*s\/general\*\*: <https:\/\/openvibe\.community\/s\/general\/t\/replies-go-too#post-\d+>$/);
++ assert.match(r.body.content, /^Alex @\u200beveryone replied to \*\*Replies go too\*\* in \*\*forum\/general\*\*: <https:\/\/openvibe\.community\/forum\/general\/replies-go-too#post-\d+>$/);
assert.strictEqual(r.body.embeds[0].description, 'First reply');
assert.deepStrictEqual(r.body.allowed_mentions, { parse: [] });
const [row] = await mapOf('post', post.id);
@@@ -428,7 +428,7 @@ const { sqlTime } = require('../server/http/v1');
assert.strictEqual(failed.json().deliveries.length, 1);
const dl = failed.json().deliveries[0];
assert.match(dl.last_error, /400: Invalid Form Body/);
-- assert.deepStrictEqual([dl.thread.url, dl.action, dl.source], ['/s/general/t/this-one-fails', 'create', 'direct']);
++ assert.deepStrictEqual([dl.thread.url, dl.action, dl.source], ['/forum/general/this-one-fails', 'create', 'direct']);
const edits = await call('/api/v1/relay/deliveries?action=edit', { cookie: adminJwt });
assert.deepStrictEqual(edits.json().deliveries.map((x) => [x.action, x.status, x.post.id]), [['edit', 'delivered', reply.json().post.id]]);
const st = await call('/api/v1/relay/status', { cookie: adminJwt });
ddiff --git a/test/search-documents.test.js b/test/search-documents.test.js
iindex e9440b1..63b0797 100644
---- a/test/search-documents.test.js
++++ b/test/search-documents.test.js
@@@ -38,7 +38,7 @@ const { createSearchDocuments } = require('../server/search/documents');
const ev = (await index())[0]; ok(ev);
assert.deepStrictEqual(ev.subject, { type: 'thread', id: String(thread.id), revision: 1 });
assert.strictEqual(ev.visibility, 'internal'); assert.strictEqual(ev.priority, 'low');
-- assert.strictEqual(ev.payload.canonical_url, `${base}/s/general/t/${thread.slug}`);
++ assert.strictEqual(ev.payload.canonical_url, `${base}/forum/general/${thread.slug}`);
assert.strictEqual(ev.payload.summary, 'First post with a link');
assert.ok(ev.payload.body.includes('a reply body'));
assert.strictEqual(ev.payload.facets.space, 'general'); assert.strictEqual(ev.payload.facets.replies, 1);
ddiff --git a/test/search-page.test.js b/test/search-page.test.js
iindex 22d3614..7bca70b 100644
---- a/test/search-page.test.js
++++ b/test/search-page.test.js
@@@ -19,7 +19,7 @@ const { createSearchQuery, SearchUnavailable } = require('../server/search/query
if (down) throw new SearchUnavailable('Search did not answer: connect ECONNREFUSED');
return {
results: [
-- { owner: 'community', type: 'thread', id: '18', title: 'Forum <b>boards</b>', canonical_url: 'https://openvibe.community/s/roadmap/t/forum-boards', facets: { space: 'roadmap' }, authorship: 'human', updated_at: '2026-09-25T18:37:20.000Z', snippet_html: 'classic <mark>forum</mark> boards <img src=x onerror=alert(1)>' },
++ { owner: 'community', type: 'thread', id: '18', title: 'Forum <b>boards</b>', canonical_url: 'https://openvibe.community/forum/roadmap/forum-boards', facets: { space: 'roadmap' }, authorship: 'human', updated_at: '2026-09-25T18:37:20.000Z', snippet_html: 'classic <mark>forum</mark> boards <img src=x onerror=alert(1)>' },
{ owner: 'community', type: 'paste', id: 'amber-fox-42', title: 'Quick sort', canonical_url: 'https://openvibe.community/p/amber-fox-42', facets: { kind: 'paste', syntax: 'javascript' }, authorship: 'ai_generated', summary: 'a sort' },
],
next_cursor: 'abc_123',
@@@ -32,7 +32,7 @@ const { createSearchQuery, SearchUnavailable } = require('../server/search/query
const r = await t.get('/search?q=forum&type=thread');
assert.strictEqual(r.status, 200);
assert.deepStrictEqual(asked.at(-1), { q: 'forum', type: 'thread', cursor: '' });
-- assert.ok(r.text.includes('href="/s/roadmap/t/forum-boards"'));
++ assert.ok(r.text.includes('href="/forum/roadmap/forum-boards"'));
assert.ok(r.text.includes('Forum &lt;b&gt;boards&lt;/b&gt;'), 'the title is escaped');
assert.ok(r.text.includes('classic <mark>forum</mark> boards &lt;img'), 'only <mark> survives');
assert.ok(!r.text.includes('<img src=x'));
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex 3458288..f279347 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -169,7 +169,7 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
await refused('gate it to sam\'s members', 'PUT', `${T}/members-only`, SAMS, { json: { owner: true } });
await refused('gate it to alex\'s members', 'PUT', `${T}/members-only`, SAMS, { json: { owner: alex.subject_id } });
for (const [suffix, form] of [['delete', {}], ['state', { pinned: '1', locked: '1' }], ['members-only', { on: '1' }], ['category', { category: '' }], ['status', { status: 'done' }]]) {
-- await refused(`form ${suffix}`, 'POST', `/s/general/t/${thread.thread.slug}/${suffix}`, ['sam'], { form });
++ await refused(`form ${suffix}`, 'POST', `/forum/general/${thread.thread.slug}/${suffix}`, ['sam'], { form });
}
// The right thread slug under the wrong space: not found, even for its author.
await refused('delete it under another space', 'DELETE', `/api/v1/spaces/feedback/threads/${thread.thread.slug}`, ['alex', 'sam']);
@@@ -203,11 +203,11 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
await check('spaces: the owner of one space cannot manage another\'s chat room; sam cannot change space settings, categories, gating or the board', async () => {
await refused('cora attaches a room to alex\'s space', 'PUT', '/api/v1/spaces/general/chat-room', ['cora', 'sam'], { json: { room: 'cora-room' } });
await refused('cora detaches alex\'s room', 'DELETE', '/api/v1/spaces/general/chat-room', ['cora', 'sam']);
-- await refused('cora detaches it by form', 'POST', '/s/general/chat-room/detach', ['cora'], { form: {} });
-- await refused('cora attaches by form', 'POST', '/s/general/chat-room', ['cora'], { form: { room: 'cora-room' } });
++ await refused('cora detaches it by form', 'POST', '/forum/general/chat-room/detach', ['cora'], { form: {} });
++ await refused('cora attaches by form', 'POST', '/forum/general/chat-room', ['cora'], { form: { room: 'cora-room' } });
assert.ok(!chatCalls.some(([, room]) => room === 'cora-room'), 'Chat was never asked for cora\'s room on alex\'s space');
await refused('space settings', 'PUT', '/api/v1/spaces/general/settings', [...SAMS, 'cora'], { json: { name: 'Taken' } });
-- await refused('space settings by form', 'POST', '/s/general/settings', ['sam', 'alex'], { form: { name: 'Taken' } });
++ await refused('space settings by form', 'POST', '/forum/general/settings', ['sam', 'alex'], { form: { name: 'Taken' } });
await refused('a category', 'PUT', '/api/v1/spaces/general/categories/taken', [...SAMS, 'alex'], { json: { name: 'Taken' } });
await refused('gate the space', 'PUT', '/api/v1/spaces/general/members-only', [...SAMS, 'alex'], { json: { owner: sam.subject_id } });
await refused('a board group', 'PUT', '/api/v1/space-groups/taken', SAMS, { json: { name: 'Taken' } });
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex 08eed8f..a104b80 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -220,9 +220,9 @@ function formsOf(value) {
}
}
for (const [who, p] of Object.entries(bad)) {
-- for (const path of ['/api/pastes', `/api/pastes/${priv.slug}`, '/api/v1/spaces', '/api/v1/relay/status', '/api/v1/pulse', '/auth/me', '/my', '/s', '/']) inspect(`GET ${path} as ${who}`, await send('GET', path, p));
++ for (const path of ['/api/pastes', `/api/pastes/${priv.slug}`, '/api/v1/spaces', '/api/v1/relay/status', '/api/v1/pulse', '/auth/me', '/my', '/forum', '/']) inspect(`GET ${path} as ${who}`, await send('GET', path, p));
}
-- for (const path of ['/nope', '/api/nope', '/api/v1/nope', '/internal/nope', '/api/pastes/admin/nope', '/p/..%2f..%2fetc', '/s/general/t/nope/nope', '/c/nope', '/api/v1/comments/threads/1', '/api/v1/comments/1']) {
++ for (const path of ['/nope', '/api/nope', '/api/v1/nope', '/internal/nope', '/api/pastes/admin/nope', '/p/..%2f..%2fetc', '/forum/general/nope/nope', '/c/nope', '/api/v1/comments/threads/1', '/api/v1/comments/1']) {
for (const [who, p] of Object.entries(personas)) inspect(`GET ${path} as ${who}`, await send('GET', path, p));
}
// Sign-in failures: a bad code, a missing state, a refused refresh token, a refused FedCM assertion.
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 5dc0ca9..2d64293 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -115,7 +115,7 @@ function inventory() {
const th = await send('POST', '/api/v1/spaces/general/threads', { json: { title: 'links', body: `see [this](${urls[1]}) and ![img](${urls[2]}) and <img src="${urls[3]}"> ${urls[1]}` } });
assert.strictEqual(th.status, 201, th.text);
const slug = JSON.parse(th.text).thread.slug;
-- assert.strictEqual((await send('GET', `/s/general/t/${slug}`)).status, 200);
++ assert.strictEqual((await send('GET', `/forum/general/${slug}`)).status, 200);
const p = await send('POST', '/api/pastes', { json: { title: urls[5], content: `curl ${urls[5]}\n![x](${urls[2]})` } });
assert.strictEqual(p.status, 201, p.text);
assert.strictEqual((await send('GET', `/p/${JSON.parse(p.text).slug}`)).status, 200);
@@@ -130,7 +130,7 @@ function inventory() {
await send('PUT', '/api/v1/spaces/general/chat-room', { json: { room: urls[7] } });
const map = await send('POST', '/api/v1/relay/mappings', { cookie: bossJwt, json: { space: 'general', webhook_url_ref: `${C}/hook` } });
assert.strictEqual(map.status, 400, 'a mapping names a variable, never a URL');
-- for (const page of ['/', '/pulse', '/api/v1/pulse', '/s/general', '/feed.xml', '/s/feed.xml', '/sitemap.xml']) await send('GET', page);
++ for (const page of ['/', '/pulse', '/api/v1/pulse', '/forum/general', '/feed.xml', '/forum/feed.xml', '/sitemap.xml']) await send('GET', page);
await new Promise((r) => setTimeout(r, 200));
assert.deepStrictEqual(hits, [], 'Community fetched a URL it was handed');
});
ddiff --git a/test/seo.test.js b/test/seo.test.js
iindex 2f221b5..231c11e 100644
---- a/test/seo.test.js
++++ b/test/seo.test.js
@@@ -21,7 +21,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 200);
assert.ok(r.headers.get('content-type').startsWith('text/plain'));
assert.ok(r.text.includes('User-agent: *\nAllow: /'));
-- for (const d of ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso=']) assert.ok(r.text.includes(`Disallow: ${d}\n`), d);
++ for (const d of ['/api/', '/auth/', '/my', '/new', '/forum/*/new', '/*?sso=']) assert.ok(r.text.includes(`Disallow: ${d}\n`), d);
assert.ok(r.text.includes('Sitemap: https://openvibe.community/sitemap.xml'));
assert.strictEqual(r.headers.get('cache-control'), cache.htmlHeaders({ maxAge: 3600 }));
});
ddiff --git a/test/space-moderators.test.js b/test/space-moderators.test.js
iindex f472875..1025791 100644
---- a/test/space-moderators.test.js
++++ b/test/space-moderators.test.js
@@@ -92,7 +92,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(page.viewer.can_moderate, false);
assert.strictEqual(page.viewer.can_delete, false);
assert.strictEqual(page.posts[0].can_edit, false);
-- assert.strictEqual((await form(`/s/showcase/t/${slug}/state`, alex, { locked: '1' })).status, 403, 'the no-JS form too');
++ assert.strictEqual((await form(`/forum/showcase/${slug}/state`, alex, { locked: '1' })).status, 403, 'the no-JS form too');
assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${slug}`, { who: sam })).json().thread.locked, false);
});
@@@ -113,24 +113,24 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'PUT', who: alex, json: { name: 'Help' } })).status, 200);
assert.strictEqual((await call('/api/v1/spaces/general/categories/help', { method: 'DELETE', who: alex })).status, 200);
const before = (await call('/api/v1/spaces/general')).json().space.group;
-- r = await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style: 'forum', reactions: '1', group: '' });
++ r = await form('/forum/general/settings', alex, { name: 'General', description: 'From the form', style: 'forum', reactions: '1', group: '' });
assert.strictEqual(r.status, 303, r.text);
assert.deepStrictEqual((await call('/api/v1/spaces/general')).json().space.group, before, 'the form leaves the group alone');
});
await check('(e) a space moderator cannot create a space or change the board index', async () => {
assert.strictEqual((await call('/api/v1/spaces', { method: 'POST', who: alex, json: { slug: 'alex-space', name: 'Alex space' } })).status, 403);
-- assert.strictEqual((await form('/s/new-space', alex, { slug: 'alex-space', name: 'Alex space' })).status, 403);
++ assert.strictEqual((await form('/forum/new', alex, { slug: 'alex-space', name: 'Alex space' })).status, 403);
assert.strictEqual((await call('/api/v1/space-groups/alex-group', { method: 'PUT', who: alex, json: { name: 'Alex group' } })).status, 403);
assert.strictEqual((await call('/api/v1/spaces/alex-space')).status, 404);
});
await check('no-JS forms add and remove; a moderator removes another (or themselves)', async () => {
-- let r = await form('/s/general/moderators', alex, { subject: sam.subject_id });
++ let r = await form('/forum/general/moderators', alex, { subject: sam.subject_id });
assert.strictEqual(r.status, 303, r.text);
assert.ok((await mods('general')).includes(sam.subject_id));
-- assert.strictEqual((await form('/s/showcase/moderators', alex, { subject: sam.subject_id })).status, 403);
-- r = await form('/s/general/moderators/remove', carol, { subject: sam.subject_id });
++ assert.strictEqual((await form('/forum/showcase/moderators', alex, { subject: sam.subject_id })).status, 403);
++ r = await form('/forum/general/moderators/remove', carol, { subject: sam.subject_id });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual((await call(`/api/v1/spaces/general/moderators/${sam.subject_id}`, { method: 'DELETE', who: sam })).status, 403, 'no longer one');
r = await call(`/api/v1/spaces/general/moderators/${carol.subject_id}`, { method: 'DELETE', who: carol });
@@@ -139,31 +139,31 @@ const { boot, check, done } = require('./helpers/app');
});
await check('the space page gives its moderators a Moderators box (add by @username, remove, step down) in both styles; nobody else sees it', async () => {
-- let r = await form('/s/general/moderators', alex, { subject: '@Sam' });
++ let r = await form('/forum/general/moderators', alex, { subject: '@Sam' });
assert.strictEqual(r.status, 303, r.text);
-- assert.match(String(r.headers.get('location')), /^\/s\/general#moderators$/);
++ assert.match(String(r.headers.get('location')), /^\/forum\/general#moderators$/);
assert.ok((await mods('general')).includes(sam.subject_id), 'added by @username, in any case');
-- r = await form('/s/general/moderators', alex, { subject: '@nobody-here' });
++ r = await form('/forum/general/moderators', alex, { subject: '@nobody-here' });
assert.strictEqual(r.status, 303, r.text);
-- assert.match(String(r.headers.get('location')), /^\/s\/general\?mod_error=.+#moderators$/, 'an unknown name comes back as a notice');
-- assert.match(String((await form('/s/general/moderators', alex, { subject: 'not a name!' })).headers.get('location')), /mod_error=/);
++ assert.match(String(r.headers.get('location')), /^\/forum\/general\?mod_error=.+#moderators$/, 'an unknown name comes back as a notice');
++ assert.match(String((await form('/forum/general/moderators', alex, { subject: 'not a name!' })).headers.get('location')), /mod_error=/);
assert.strictEqual((await call('/api/v1/spaces/general/moderators/%40sam', { method: 'PUT', who: alex })).status, 200, 'the API takes @username too');
for (const style of ['forum', 'feed']) {
-- assert.strictEqual((await form('/s/general/settings', alex, { name: 'General', description: 'From the form', style, reactions: '1', group: '' })).status, 303);
-- const page = await call(`/s/general?mod_error=${encodeURIComponent('No OpenVibe account is called @nobody-here')}`, { who: alex });
++ assert.strictEqual((await form('/forum/general/settings', alex, { name: 'General', description: 'From the form', style, reactions: '1', group: '' })).status, 303);
++ const page = await call(`/forum/general?mod_error=${encodeURIComponent('No OpenVibe account is called @nobody-here')}`, { who: alex });
assert.strictEqual(page.status, 200, page.text);
assert.ok(page.text.includes('<details class="space-settings" id="moderators" open>'), `${style}: the box, open on a refusal`);
assert.ok(page.text.includes('No OpenVibe account is called @nobody-here'), `${style}: the refusal shows`);
-- assert.ok(page.text.includes('action="/s/general/moderators"'), `${style}: the add form`);
++ assert.ok(page.text.includes('action="/forum/general/moderators"'), `${style}: the add form`);
assert.ok(page.text.includes(`name="subject" value="${sam.subject_id}"`), `${style}: sam is listed with a remove form`);
assert.ok(page.text.includes('>Step down</button>'), `${style}: alex can step down`);
assert.ok(page.text.includes('aria-label="Remove @sam as a moderator"'), `${style}: the remove button names the person`);
for (const who of [bob, undefined]) {
-- const other = await call('/s/general', { who });
++ const other = await call('/forum/general', { who });
assert.ok(!other.text.includes('id="moderators"') && !other.text.includes('/moderators"'), `${style}: not for ${who ? 'a non-moderator' : 'a visitor'}`);
}
}
-- r = await form('/s/general/moderators/remove', alex, { subject: sam.subject_id });
++ r = await form('/forum/general/moderators/remove', alex, { subject: sam.subject_id });
assert.strictEqual(r.status, 303, r.text);
assert.deepStrictEqual(await mods('general'), [alex.subject_id]);
});
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
iindex dd924c5..1cbbe0a 100644
---- a/test/ssr.test.js
++++ b/test/ssr.test.js
@@@ -59,7 +59,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(cfg.navbar.service, 'community');
// The bar (2026-10-28): the forum first, every place with an icon (the phone app bar); search is the magnifier,
// which offers this site's own search page first.
-- assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/s', '/pastes', '/pulse', '/new']);
++ assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/forum', '/pastes', '/pulse', '/new']);
assert.ok(cfg.navbar.links.every((l) => l.icon) && cfg.navbar.appBar === true && cfg.navbar.search && cfg.navbar.search.page === '/search');
assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');
assert.strictEqual(cfg.navbar.history.type, 'page');
@@@ -70,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');
// The home's other shelves: the forum's places and the roadmap (both seeded at boot), and questions whose
// FAQPage JSON-LD is exactly what the page shows.
const forum = (/<section class="sc-sec sc-apps-sec" id="forum"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
-- assert.ok(forum.includes('<ul class="sc-apps">') && forum.includes('<a href="/s/general">') && forum.includes('href="/s">All forums'), 'the forum\'s places as a launcher');
++ assert.ok(forum.includes('<ul class="sc-apps">') && forum.includes('<a href="/forum/general">') && forum.includes('href="/forum">All forums'), 'the forum\'s places as a launcher');
assert.ok(/<dt>public pastes<\/dt><dd>\d+<\/dd>/.test(r.text), 'the real count of public pastes');
const faq = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1]))).find((x) => x['@type'] === 'FAQPage');
const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
ddiff --git a/test/thread-labels.test.js b/test/thread-labels.test.js
iindex 14c7263..cca8e00 100644
---- a/test/thread-labels.test.js
++++ b/test/thread-labels.test.js
@@@ -44,22 +44,22 @@ const form = (pairs) => ({ method: 'POST', body: new URLSearchParams(pairs).toSt
let boardUrl;
let feedUrl;
await check('the new-thread form has the picker, and a thread is posted with its labels', async () => {
-- const page = await t.get('/s/general/new', as(alexJwt));
++ const page = await t.get('/forum/general/new', as(alexJwt));
assert.ok(page.text.includes('<legend>Content labels</legend>') && page.text.includes('name="labels_form"'), 'the picker');
assert.ok(/<link rel="stylesheet" href="\/css\/labels\.css(\?v=[0-9a-f]+)?">/.test(page.text), 'its sheet');
-- let r = await t.get('/s/general/new', as(alexJwt, form([['title', 'Finale talk'], ['body', 'The last episode.'], ['labels_form', '1'], ['labels', 'spoilers'], ['labels', '18+']])));
++ let r = await t.get('/forum/general/new', as(alexJwt, form([['title', 'Finale talk'], ['body', 'The last episode.'], ['labels_form', '1'], ['labels', 'spoilers'], ['labels', '18+']])));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
boardUrl = new URL(r.headers.get('location'), 'https://x').pathname;
-- r = await t.get('/s/feedback/new', as(alexJwt, form([['title', 'Flashing intro'], ['body', 'The intro strobes.'], ['labels_form', '1'], ['labels', 'flashing']])));
++ r = await t.get('/forum/feedback/new', as(alexJwt, form([['title', 'Flashing intro'], ['body', 'The intro strobes.'], ['labels_form', '1'], ['labels', 'flashing']])));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
feedUrl = new URL(r.headers.get('location'), 'https://x').pathname;
-- r = await t.get('/s/general/new', as(alexJwt, form([['title', 'Bad label'], ['body', 'x'], ['labels_form', '1'], ['labels', 'gore']])));
++ r = await t.get('/forum/general/new', as(alexJwt, form([['title', 'Bad label'], ['body', 'x'], ['labels_form', '1'], ['labels', 'gore']])));
assert.strictEqual(r.status, 400, 'not a label');
assert.ok(r.text.includes('Labels are from this list'), r.text.slice(0, 200));
});
await check('the board: the row is labelled, its title cell is what a blur covers, the chips under the title', async () => {
-- const r = await t.get('/s/general');
++ const r = await t.get('/forum/general');
assert.ok(r.text.includes(` data-ov-labels="spoilers,18+" data-ov-labels-owner="${alex.subject_id}">`), 'the row');
assert.ok(r.text.includes('<td class="topic-main" data-ov-labels-media>'), 'the cover goes on the title cell');
assert.ok(r.text.includes('<li class="ovk-label" data-label="spoilers">Spoilers</li><li class="ovk-label ovk-label--adult" data-label="18+">18+</li>'), 'in words');
@@@ -77,7 +77,7 @@ const form = (pairs) => ({ method: 'POST', body: new URLSearchParams(pairs).toSt
assert.ok(r.text.includes('css/labels.css'), 'with its sheet');
r = await t.get(feedUrl);
assert.ok(r.text.includes(`<div class="posts" data-ov-labels="flashing" data-ov-labels-owner="${alex.subject_id}" data-ov-labels-opened>`), 'the feed style too');
-- r = await t.get('/s/feedback');
++ r = await t.get('/forum/feedback');
assert.ok(/<li class="thread-row[^"]*" data-ov-labels="flashing" data-ov-labels-owner="/.test(r.text), 'and its list');
});
ddiff --git a/test/wear-items.test.js b/test/wear-items.test.js
iindex a063479..9389837 100644
---- a/test/wear-items.test.js
++++ b/test/wear-items.test.js
@@@ -30,7 +30,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
});
await check('a thread page marks its author and names the items files; the CSP reaches Inventory', async () => {
-- const ok = await t.get('/s/general/new', { ...form({ title: 'Wearing things', body: 'Look at my name.' }), cookies: [`ov_token=${alexJwt}`] });
++ const ok = await t.get('/forum/general/new', { ...form({ title: 'Wearing things', body: 'Look at my name.' }), cookies: [`ov_token=${alexJwt}`] });
assert.strictEqual(ok.status, 303, ok.text);
const r = await t.get(ok.headers.get('location'));
assert.strictEqual(r.status, 200);