fix(rules): use hash links for directory deep links

Switch rules navigation from /rules/:code paths to /rules#code hashes,
reject legacy subpaths, and ignore address hashes in markdown links.
This commit is contained in:
Tommaso Casaburi
2026-06-10 16:38:31 +07:00
parent f3d59c3345
commit e9c729a9e3
8 changed files with 121 additions and 70 deletions
+16
View File
@@ -444,6 +444,22 @@ describe('App', () => {
expect(container.querySelector('[data-testid="boards-bar"]')).toBeNull();
});
it('renders the rules route as a global static page', async () => {
await renderApp('/rules#mu');
expect(latestLocation).toBe('/rules');
expect(container.querySelector('[data-testid="rules-view"]')).toBeTruthy();
expect(container.querySelector('[data-testid="boards-bar"]')).toBeNull();
});
it('rejects legacy rules subpaths instead of treating them as board routes', async () => {
await renderApp('/rules/music-posting.eth');
expect(latestLocation).toBe('/not-found');
expect(container.querySelector('[data-testid="not-found-view"]')).toBeTruthy();
expect(container.querySelector('[data-testid="rules-view"]')).toBeNull();
});
it('canonicalizes board address routes to directory codes while preserving query strings', async () => {
await renderApp('/music-posting.eth/thread/comment-1?focus=1');
+2 -1
View File
@@ -292,7 +292,8 @@ const App = () => {
<Route path='/' element={<Home />} />
<Route path='/faq' element={<Faq />} />
<Route path='/pass' element={<Pass />} />
<Route path='/rules/:boardIdentifier?' element={<Rules />} />
<Route path='/rules' element={<Rules />} />
<Route path='/rules/*' element={<Navigate to='/not-found' replace />} />
<Route path='/blotter' element={<Blotter />} />
<Route
path='/settings/account-data'
@@ -158,7 +158,7 @@ vi.mock('../../../stores/use-post-number-store', () => ({
}),
}));
vi.mock('../../comment-media', () => ({
vi.mock('../../comment-media/comment-media', () => ({
default: ({
commentMediaInfo,
isFloatingEmbed,
@@ -183,11 +183,11 @@ vi.mock('../../comment-media', () => ({
},
}));
vi.mock('../../embed', () => ({
vi.mock('../../embed/embed-utils', () => ({
canEmbed: (parsedUrl: URL) => testState.embeddableHosts.has(parsedUrl.host),
}));
vi.mock('../../reply-quote-preview', () => ({
vi.mock('../../reply-quote-preview/reply-quote-preview', () => ({
default: ({
isOP,
isQuotelinkUnavailable,
@@ -365,14 +365,14 @@ describe('Markdown', () => {
it('renders inline Markdown links for app routes and inert empty hrefs', async () => {
await renderMarkdown({
content: '[Rules](/rules/biz) [AI moderation]( )',
content: '[Rules](/rules#biz) [Old rules](/rules/biz) [Address rules](/rules#custom-board.bso) [AI moderation]( )',
});
const links = Array.from(container.querySelectorAll('a'));
expect(links).toHaveLength(1);
expect(links[0]?.getAttribute('href')).toBe('/rules/biz');
expect(links[0]?.getAttribute('href')).toBe('/rules#biz');
expect(links[0]?.textContent).toBe('Rules');
expect(container.textContent).toBe('Rules AI moderation');
expect(container.textContent).toBe('Rules Old rules Address rules AI moderation');
});
it('does not render unsafe inline Markdown hrefs', async () => {
+23 -10
View File
@@ -5,18 +5,18 @@ import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, s
import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import { isCatalogView } from '../../lib/utils/view-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import CommentMedia from '../comment-media';
import CodeBlock from '../code-block';
import CommentMedia from '../comment-media/comment-media';
import CodeBlock from '../code-block/code-block';
import styles from './markdown.module.css';
import { Link, useLocation, useParams } from 'react-router-dom';
import { canEmbed } from '../embed';
import { canEmbed } from '../embed/embed-utils';
import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } from '../../lib/utils/url-utils';
import { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils';
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
import usePostNumberStore, { getCidForPostNumber } from '../../stores/use-post-number-store';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { useComment } from '@bitsocial/bitsocial-react-hooks';
import ReplyQuotePreview from '../reply-quote-preview';
import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
import ExternalNumberQuoteLink from './external-number-quote-link';
import { findDirectoryByAddress, useDirectories, type DirectoryCommunity } from '../../hooks/use-directories';
import { getDirectoryCodeForBoardAddress } from '../../lib/utils/directory-list-lookup-utils';
@@ -216,6 +216,7 @@ const COMBINED_REGEX_WITHOUT_SPOILER = new RegExp(
);
const makeTokenKey = (prefix: string, type: Token['type'], start: number, end: number): string => `${prefix}${type}:${start}:${end}`;
const RULES_HASH_ROUTE_REGEX = /^\/rules#[A-Za-z0-9_-]+$/;
const isGreentextLine = (line: string): boolean => {
if (line === '>') return true;
@@ -233,6 +234,10 @@ function normalizeInternalRouteHref(href: string): string {
return href;
}
function isUnsupportedRulesRouteHref(href: string): boolean {
return href.startsWith('/rules/') || (href.startsWith('/rules#') && !RULES_HASH_ROUTE_REGEX.test(href));
}
function splitUrlTrailingText(rawHref: string): { href: string; trailingText: string } {
let href = rawHref;
let trailingText = '';
@@ -475,6 +480,9 @@ const AnchorLink = ({ href, text }: { href: string; text: string }) => {
const internalPath = transform5chanLinkToInternal(href);
if (internalPath) {
const internalRoute = normalizeInternalRouteHref(internalPath);
if (isUnsupportedRulesRouteHref(internalRoute)) {
return <span>{text}</span>;
}
let displayText: React.ReactNode = text;
const isAutolinkedUrl = text.startsWith('http');
@@ -493,16 +501,21 @@ const AnchorLink = ({ href, text }: { href: string; text: string }) => {
}
}
const normalizedHref = normalizeInternalRouteHref(href);
if (isUnsupportedRulesRouteHref(normalizedHref)) {
return <span>{text}</span>;
}
if (
href.startsWith('#/') ||
href.startsWith('/#/') ||
href.startsWith('/p/') ||
href.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) ||
href.match(/^\/rules\/[^/]+$/) ||
href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) ||
href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/)
normalizedHref.startsWith('/p/') ||
normalizedHref.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) ||
normalizedHref.match(RULES_HASH_ROUTE_REGEX) ||
normalizedHref.match(/^\/[^/#]+(\/thread\/[^/]+)?$/) ||
normalizedHref.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/)
) {
return <Link to={normalizeInternalRouteHref(href)}>{text}</Link>;
return <Link to={normalizedHref}>{text}</Link>;
}
return (
@@ -1389,7 +1389,20 @@ describe('PostForm', () => {
expect(promptRow?.className).toBe('rules');
expect(promptRow?.querySelector('ul')?.className).toBe('rules');
expect(links.map((link) => link.textContent)).toEqual(['Rules', 'FAQ']);
expect(links.map((link) => link.getAttribute('href'))).toEqual(['/rules/mu', '/faq']);
expect(links.map((link) => link.getAttribute('href'))).toEqual(['/rules#mu', '/faq']);
});
it('uses the plain rules page link for custom boards without a directory hash', async () => {
testState.resolvedCommunityAddress = 'custom-board.bso';
await renderPostForm('/custom-board.bso');
await clickByText(container, 'start_new_thread');
const promptRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent === 'Please read the Rules and FAQ before posting.');
const links = Array.from(promptRow?.querySelectorAll('a') || []);
expect(links.map((link) => link.textContent)).toEqual(['Rules', 'FAQ']);
expect(links.map((link) => link.getAttribute('href'))).toEqual(['/rules', '/faq']);
});
it('shortens long pasted file-link filenames next to the upload button', async () => {
+4 -3
View File
@@ -30,7 +30,7 @@ import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
import { isValidPublishURL, isValidURL } from '../../lib/utils/url-utils';
import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { getBoardPath } from '../../lib/utils/route-utils';
import { getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
import { FLASH_TAG_OPTIONS, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectoryCode, type FlashTagOption } from '../../lib/flash-tags';
@@ -545,8 +545,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const subscriptions = account?.subscriptions || [];
const directories = useDirectories();
const directoryEntry = useDirectoryEntry(effectiveBoardAddress, params?.boardIdentifier);
const pendingPostBoardPath = effectiveBoardAddress ? getBoardPath(effectiveBoardAddress, directories) : undefined;
const rulesPath = effectiveBoardAddress ? `/rules/${getBoardPath(effectiveBoardAddress, directories)}` : '/rules';
const effectiveBoardPath = effectiveBoardAddress ? getBoardPath(effectiveBoardAddress, directories) : undefined;
const pendingPostBoardPath = effectiveBoardPath;
const rulesPath = effectiveBoardPath && isDirectoryRoute(effectiveBoardPath, directories) ? `/rules#${effectiveBoardPath}` : '/rules';
const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true;
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
+30 -26
View File
@@ -9,7 +9,6 @@ import Rules from '../rules';
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
boardIdentifier: undefined as string | undefined,
communities: {} as Record<string, { rules?: string[]; shortAddress?: string; state?: string; title?: string }>,
directories: [
{ address: 'anime-posting.eth', title: '/a/ - Anime & Manga' },
@@ -31,16 +30,6 @@ vi.mock('react-i18next', () => ({
}),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useParams: () => ({
boardIdentifier: testState.boardIdentifier,
}),
};
});
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useClientsStates: () => ({
states: {},
@@ -60,12 +49,12 @@ vi.mock('../../../hooks/use-directories', async () => {
};
});
vi.mock('../../home', () => ({
vi.mock('../../home/home', () => ({
Footer: () => createElement('div', { 'data-testid': 'footer' }, 'footer'),
HomeLogo: () => createElement('div', { 'data-testid': 'home-logo' }, 'home-logo'),
}));
vi.mock('../../../components/markdown', () => ({
vi.mock('../../../components/markdown/markdown', () => ({
default: ({ content }: { content: string }) => createElement('div', { 'data-testid': 'markdown' }, content),
}));
@@ -81,9 +70,9 @@ let container: HTMLDivElement;
let root: Root;
let scrollIntoViewMock: ReturnType<typeof vi.fn>;
const renderRules = async () => {
const renderRules = async (initialEntry = '/rules') => {
await act(async () => {
root.render(createElement(MemoryRouter, null, createElement(Rules)));
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(Rules)));
});
};
@@ -110,7 +99,6 @@ const submitBoardAddress = async (address: string) => {
describe('Rules', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.boardIdentifier = undefined;
testState.communities = {};
testState.directories = [
{ address: 'anime-posting.eth', title: '/a/ - Anime & Manga' },
@@ -141,9 +129,9 @@ describe('Rules', () => {
it('renders a quick-jump nav link and a rules section for every directory', async () => {
await renderRules();
// Quick-jump nav links use the directory name (like 4chan's board list) and point at the per-directory route.
expect(container.querySelector('a[href="/rules/a"]')?.textContent).toBe('Anime & Manga');
expect(container.querySelector('a[href="/rules/b"]')?.textContent).toBe('Random');
// Quick-jump nav links use the directory name (like 4chan's board list) and point at per-directory hash links.
expect(container.querySelector('a[href="/rules#a"]')?.textContent).toBe('Anime & Manga');
expect(container.querySelector('a[href="/rules#b"]')?.textContent).toBe('Random');
// One anchored rules section per directory.
expect(container.querySelector('#a')).toBeTruthy();
@@ -162,14 +150,28 @@ describe('Rules', () => {
expect(container.textContent).not.toContain('Rules for:');
});
it('insta-scrolls to a directory section when deep-linked via /rules/:code', async () => {
testState.boardIdentifier = 'a';
await renderRules();
it('insta-scrolls to a directory section when deep-linked via /rules#code', async () => {
await renderRules('/rules#a');
expect(scrollIntoViewMock).toHaveBeenCalled();
});
it('ignores address hashes instead of resolving them to directory rules or P2P rules', async () => {
testState.communities = {
'anime-posting.eth': {
rules: ['P2P address rules should not render from a hash.'],
shortAddress: 'anime-posting.eth',
state: 'succeeded',
},
};
await renderRules('/rules#anime-posting.eth');
expect(scrollIntoViewMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain('Rules for: anime-posting.eth');
expect(container.textContent).not.toContain('P2P address rules should not render from a hash.');
});
it('loads a board over P2P when an address is submitted in the loader', async () => {
testState.communities = {
'custom-board.eth': {
@@ -186,7 +188,7 @@ describe('Rules', () => {
expect(container.textContent).toContain('No spamming.');
});
it('clears a loaded P2P rules box when navigating to a directory route', async () => {
it('clears a loaded P2P rules box when navigating to a directory hash link', async () => {
testState.communities = {
'custom-board.eth': {
rules: ['No spamming.'],
@@ -199,8 +201,10 @@ describe('Rules', () => {
await submitBoardAddress('custom-board.eth');
expect(container.textContent).toContain('Rules for: custom-board.eth');
testState.boardIdentifier = 'a';
await renderRules();
const link = container.querySelector('a[href="/rules#a"]') as HTMLAnchorElement;
await act(async () => {
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
expect(container.textContent).not.toContain('Rules for: custom-board.eth');
expect(scrollIntoViewMock).toHaveBeenCalled();
+26 -23
View File
@@ -1,12 +1,12 @@
import { Fragment, useEffect, useRef, useState, FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { useCommunity } from '@bitsocial/bitsocial-react-hooks';
import { Footer, HomeLogo } from '../home';
import { Footer, HomeLogo } from '../home/home';
import { useDirectories, useDirectoryDefaults, DirectoryCommunity, DirectoryDefaultsData } from '../../hooks/use-directories';
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
import { getCommunityAddress, getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
import Markdown from '../../components/markdown';
import LoadingEllipsis from '../../components/loading-ellipsis';
import { getCommunityAddress, isDirectoryRoute } from '../../lib/utils/route-utils';
import Markdown from '../../components/markdown/markdown';
import LoadingEllipsis from '../../components/loading-ellipsis/loading-ellipsis';
import useStateString from '../../hooks/use-state-string';
import styles from './rules.module.css';
import { useTranslation } from 'react-i18next';
@@ -59,13 +59,16 @@ const groupDirectoriesByCategory = (directories: DirectoryCommunity[], defaults:
}))
.filter((group) => group.communities.length > 0);
// Resolve a /rules/:boardIdentifier segment (directory code or board address) to a directory code.
const resolveDirectoryCode = (identifier: string, directories: DirectoryCommunity[]): string | null => {
if (isDirectoryRoute(identifier, directories)) {
return identifier;
const getRulesHashCode = (hash: string): string => {
if (!hash) {
return '';
}
const rawHash = hash.startsWith('#') ? hash.slice(1) : hash;
try {
return decodeURIComponent(rawHash);
} catch {
return rawHash;
}
const code = getBoardPath(getCommunityAddress(identifier, directories), directories);
return isDirectoryRoute(code, directories) ? code : null;
};
// A single directory's rules (h3 title + ordered rules), anchored by code for deep-link scrolling.
@@ -109,7 +112,7 @@ const CategoryRulesBox = ({ group, defaults }: { group: CategoryGroup; defaults:
</div>
);
// Quick-jump nav (left column) grouped by category; clicking a directory insta-scrolls to its rules via /rules/:code.
// Quick-jump nav (left column) grouped by category; clicking a directory insta-scrolls to its rules via /rules#code.
const DirectoryNav = ({ groups }: { groups: CategoryGroup[] }) => (
<div className={`${styles.box} ${styles.selectorBox}`}>
<div className={styles.boxBar}>
@@ -129,7 +132,7 @@ const DirectoryNav = ({ groups }: { groups: CategoryGroup[] }) => (
const code = getDirectoryCode(community);
return (
<li key={community.address}>
<Link to={`/rules/${code}`}>{getBoardName(community.title) || getDirectoryDisplayTitle(community)}</Link>
<Link to={`/rules#${code}`}>{getBoardName(community.title) || getDirectoryDisplayTitle(community)}</Link>
</li>
);
})}
@@ -233,11 +236,12 @@ const LoadBoardRules = ({ onLoad, onClear, isLoaded }: { onLoad: (address: strin
};
const Rules = () => {
const { boardIdentifier } = useParams();
const { hash } = useLocation();
const directories = useDirectories();
const directoryDefaults = useDirectoryDefaults();
const [loadedAddress, setLoadedAddress] = useState('');
const scrolledForRef = useRef<string | null>(null);
const hashCode = getRulesHashCode(hash);
// Order directories alphabetically by directory code (e.g. /3/, /a/, /aco/...), like 4chan, not by title.
const directoriesWithCode = directories.filter((community) => getDirectoryCode(community)).toSorted((a, b) => getDirectoryCode(a).localeCompare(getDirectoryCode(b)));
@@ -253,24 +257,23 @@ const Rules = () => {
useEffect(() => {
setLoadedAddress('');
if (!boardIdentifier) {
scrolledForRef.current = null;
scrolledForRef.current = null;
if (!hashCode) {
window.scrollTo(0, 0);
}
}, [boardIdentifier]);
}, [hashCode]);
// Deep-link: /rules/:code insta-scrolls to that directory's rules once the matching section is rendered.
// Deep-link: /rules#code insta-scrolls to a known directory's rules once the matching section is rendered.
useEffect(() => {
if (!boardIdentifier || scrolledForRef.current === boardIdentifier) {
if (!hashCode || scrolledForRef.current === hashCode || !isDirectoryRoute(hashCode, directories)) {
return;
}
const code = resolveDirectoryCode(boardIdentifier, directories);
const element = code ? document.getElementById(code) : null;
const element = document.getElementById(hashCode);
if (element) {
element.scrollIntoView();
scrolledForRef.current = boardIdentifier;
scrolledForRef.current = hashCode;
}
}, [boardIdentifier, directories]);
}, [hashCode, directories]);
return (
<div className={styles.wrapper}>