fix(markdown): render code tags on code-enabled boards

Enable [code] tag parsing for both /g/ and /q/ rendering paths, show the post-form hint on code-enabled boards, and preserve whitespace in BBCode previews.
This commit is contained in:
Tommaso Casaburi
2026-06-28 23:37:59 +07:00
committed by GitHub
parent 1de28afd45
commit 7258e21f6f
45 changed files with 382 additions and 113 deletions
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "لا تظهر مرة أخرى",
"site_legal_meta_contributors_link": "المساهمون",
"post_link_required_alert": "يجب أن تنشر رابطًا.",
"post_media_link_required_alert": "يجب أن تنشر رابط صورة أو فيديو."
"post_media_link_required_alert": "يجب أن تنشر رابط صورة أو فيديو.",
"post_form_code_tags_prompt": "يمكنك تمييز بناء الجملة والحفاظ على المسافات البيضاء باستخدام وسوم [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "আর দেখাবেন না",
"site_legal_meta_contributors_link": "অবদানকারীরা",
"post_link_required_alert": "আপনাকে একটি লিঙ্ক পোস্ট করতে হবে।",
"post_media_link_required_alert": "আপনাকে একটি ছবি বা ভিডিওর লিঙ্ক পোস্ট করতে হবে।"
"post_media_link_required_alert": "আপনাকে একটি ছবি বা ভিডিওর লিঙ্ক পোস্ট করতে হবে।",
"post_form_code_tags_prompt": "আপনি [code] ট্যাগ ব্যবহার করে সিনট্যাক্স হাইলাইট করতে এবং সাদা স্থান সংরক্ষণ করতে পারেন।"
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "už nezobrazovat",
"site_legal_meta_contributors_link": "Přispěvatelé",
"post_link_required_alert": "Musíte přidat odkaz.",
"post_media_link_required_alert": "Musíte přidat odkaz na obrázek nebo video."
"post_media_link_required_alert": "Musíte přidat odkaz na obrázek nebo video.",
"post_form_code_tags_prompt": "Syntax můžete zvýraznit a zachovat mezery pomocí značek [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "vis aldrig igen",
"site_legal_meta_contributors_link": "Bidragydere",
"post_link_required_alert": "Du skal poste et link.",
"post_media_link_required_alert": "Du skal poste et billed- eller videolink."
"post_media_link_required_alert": "Du skal poste et billed- eller videolink.",
"post_form_code_tags_prompt": "Du kan fremhæve syntaks og bevare mellemrum ved at bruge [code]-tags."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "nie wieder anzeigen",
"site_legal_meta_contributors_link": "Mitwirkende",
"post_link_required_alert": "Du musst einen Link posten.",
"post_media_link_required_alert": "Du musst einen Bild- oder Videolink posten."
"post_media_link_required_alert": "Du musst einen Bild- oder Videolink posten.",
"post_form_code_tags_prompt": "Sie können Syntax hervorheben und Leerzeichen beibehalten, indem Sie [code]-Tags verwenden."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "να μην εμφανιστεί ξανά",
"site_legal_meta_contributors_link": "Συνεισφέροντες",
"post_link_required_alert": "Πρέπει να δημοσιεύσετε έναν σύνδεσμο.",
"post_media_link_required_alert": "Πρέπει να δημοσιεύσετε σύνδεσμο εικόνας ή βίντεο."
"post_media_link_required_alert": "Πρέπει να δημοσιεύσετε σύνδεσμο εικόνας ή βίντεο.",
"post_form_code_tags_prompt": "Μπορείτε να επισημάνετε τη σύνταξη και να διατηρήσετε τα κενά χρησιμοποιώντας ετικέτες [code]."
}
+2 -1
View File
@@ -464,5 +464,6 @@
"settings_upgrade_never_show_again": "never show again",
"site_legal_meta_contributors_link": "Contributors",
"post_link_required_alert": "You must post a link.",
"post_media_link_required_alert": "You must post an image or video link."
"post_media_link_required_alert": "You must post an image or video link.",
"post_form_code_tags_prompt": "You may highlight syntax and preserve whitespace by using [code] tags."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "no volver a mostrar",
"site_legal_meta_contributors_link": "Colaboradores",
"post_link_required_alert": "Debes publicar un enlace.",
"post_media_link_required_alert": "Debes publicar un enlace a una imagen o un video."
"post_media_link_required_alert": "Debes publicar un enlace a una imagen o un video.",
"post_form_code_tags_prompt": "Puede resaltar la sintaxis y conservar los espacios en blanco usando etiquetas [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "دیگر نشان نده",
"site_legal_meta_contributors_link": "مشارکت‌کنندگان",
"post_link_required_alert": "باید یک پیوند ارسال کنید.",
"post_media_link_required_alert": "باید پیوند یک تصویر یا ویدیو را ارسال کنید."
"post_media_link_required_alert": "باید پیوند یک تصویر یا ویدیو را ارسال کنید.",
"post_form_code_tags_prompt": "می‌توانید با استفاده از برچسب‌های [code] نحو را برجسته کرده و فاصله‌ها را حفظ کنید."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "älä näytä uudelleen",
"site_legal_meta_contributors_link": "Avustajat",
"post_link_required_alert": "Sinun on julkaistava linkki.",
"post_media_link_required_alert": "Sinun on julkaistava kuva- tai videolinkki."
"post_media_link_required_alert": "Sinun on julkaistava kuva- tai videolinkki.",
"post_form_code_tags_prompt": "Voit korostaa syntaksia ja säilyttää välilyönnit käyttämällä [code]-tageja."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "huwag nang ipakita muli",
"site_legal_meta_contributors_link": "Mga Kontribyutor",
"post_link_required_alert": "Kailangan mong mag-post ng link.",
"post_media_link_required_alert": "Kailangan mong mag-post ng link ng larawan o video."
"post_media_link_required_alert": "Kailangan mong mag-post ng link ng larawan o video.",
"post_form_code_tags_prompt": "Maaari mong i-highlight ang syntax at panatilihin ang whitespace gamit ang [code] tags."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "ne plus afficher",
"site_legal_meta_contributors_link": "Contributeurs",
"post_link_required_alert": "Vous devez publier un lien.",
"post_media_link_required_alert": "Vous devez publier un lien vers une image ou une vidéo."
"post_media_link_required_alert": "Vous devez publier un lien vers une image ou une vidéo.",
"post_form_code_tags_prompt": "Vous pouvez surligner la syntaxe et préserver les espaces en utilisant des balises [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "לא להציג שוב",
"site_legal_meta_contributors_link": "תורמים",
"post_link_required_alert": "עליך לפרסם קישור.",
"post_media_link_required_alert": "עליך לפרסם קישור לתמונה או לסרטון."
"post_media_link_required_alert": "עליך לפרסם קישור לתמונה או לסרטון.",
"post_form_code_tags_prompt": "ניתן להדגיש תחביר ולשמור על רווחים לבנים באמצעות תגיות [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "फिर कभी न दिखाएँ",
"site_legal_meta_contributors_link": "योगदानकर्ता",
"post_link_required_alert": "आपको एक लिंक पोस्ट करना होगा।",
"post_media_link_required_alert": "आपको एक छवि या वीडियो लिंक पोस्ट करना होगा।"
"post_media_link_required_alert": "आपको एक छवि या वीडियो लिंक पोस्ट करना होगा।",
"post_form_code_tags_prompt": "आप [code] टैग का उपयोग करके सिंटैक्स को हाइलाइट कर सकते हैं और व्हाइटस्पेस को संरक्षित कर सकते हैं।"
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "ne jelenjen meg újra",
"site_legal_meta_contributors_link": "Közreműködők",
"post_link_required_alert": "Közzé kell tenned egy linket.",
"post_media_link_required_alert": "Közzé kell tenned egy kép- vagy videólinket."
"post_media_link_required_alert": "Közzé kell tenned egy kép- vagy videólinket.",
"post_form_code_tags_prompt": "A szintaxis kiemelhető és a szóközök megőrizhetők a [code] címkék használatával."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "jangan tampilkan lagi",
"site_legal_meta_contributors_link": "Kontributor",
"post_link_required_alert": "Anda harus memposting tautan.",
"post_media_link_required_alert": "Anda harus memposting tautan gambar atau video."
"post_media_link_required_alert": "Anda harus memposting tautan gambar atau video.",
"post_form_code_tags_prompt": "Anda dapat menyorot sintaks dan mempertahankan spasi dengan menggunakan tag [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "non mostrare più",
"site_legal_meta_contributors_link": "Collaboratori",
"post_link_required_alert": "Devi pubblicare un link.",
"post_media_link_required_alert": "Devi pubblicare un link a un'immagine o a un video."
"post_media_link_required_alert": "Devi pubblicare un link a un'immagine o a un video.",
"post_form_code_tags_prompt": "Puoi evidenziare la sintassi e preservare gli spazi bianchi usando i tag [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "今後表示しない",
"site_legal_meta_contributors_link": "貢献者",
"post_link_required_alert": "リンクを投稿する必要があります。",
"post_media_link_required_alert": "画像または動画のリンクを投稿する必要があります。"
"post_media_link_required_alert": "画像または動画のリンクを投稿する必要があります。",
"post_form_code_tags_prompt": "[code] タグを使用して、構文をハイライトし、空白を保持できます。"
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "다시 표시하지 않기",
"site_legal_meta_contributors_link": "기여자",
"post_link_required_alert": "링크를 게시해야 합니다.",
"post_media_link_required_alert": "이미지 또는 동영상 링크를 게시해야 합니다."
"post_media_link_required_alert": "이미지 또는 동영상 링크를 게시해야 합니다.",
"post_form_code_tags_prompt": "[code] 태그를 사용하여 구문을 강조 표시하고 공백을 유지할 수 있습니다."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "पुन्हा दाखवू नका",
"site_legal_meta_contributors_link": "योगदानकर्ते",
"post_link_required_alert": "तुम्हाला एक लिंक पोस्ट करावी लागेल.",
"post_media_link_required_alert": "तुम्हाला प्रतिमा किंवा व्हिडिओची लिंक पोस्ट करावी लागेल."
"post_media_link_required_alert": "तुम्हाला प्रतिमा किंवा व्हिडिओची लिंक पोस्ट करावी लागेल.",
"post_form_code_tags_prompt": "आप [code] टॅग वापरून सिंटॅक्स हायलाइट करू शकता आणि व्हाइटस्पेस जतन करू शकता."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "niet meer tonen",
"site_legal_meta_contributors_link": "Bijdragers",
"post_link_required_alert": "Je moet een link plaatsen.",
"post_media_link_required_alert": "Je moet een afbeelding- of videolink plaatsen."
"post_media_link_required_alert": "Je moet een afbeelding- of videolink plaatsen.",
"post_form_code_tags_prompt": "U kunt syntaxis markeren en witruimte behouden door [code]-tags te gebruiken."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "vis aldri igjen",
"site_legal_meta_contributors_link": "Bidragsytere",
"post_link_required_alert": "Du må poste en lenke.",
"post_media_link_required_alert": "Du må poste en bilde- eller videolenke."
"post_media_link_required_alert": "Du må poste en bilde- eller videolenke.",
"post_form_code_tags_prompt": "Du kan fremheve syntaks og bevare mellomrom ved å bruke [code]-tagger."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "nie pokazuj ponownie",
"site_legal_meta_contributors_link": "Współtwórcy",
"post_link_required_alert": "Musisz opublikować link.",
"post_media_link_required_alert": "Musisz opublikować link do obrazu lub filmu."
"post_media_link_required_alert": "Musisz opublikować link do obrazu lub filmu.",
"post_form_code_tags_prompt": "Możesz podświetlać składnię i zachować spacje, używając tagów [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "não mostrar novamente",
"site_legal_meta_contributors_link": "Colaboradores",
"post_link_required_alert": "Você deve postar um link.",
"post_media_link_required_alert": "Você deve postar um link de imagem ou vídeo."
"post_media_link_required_alert": "Você deve postar um link de imagem ou vídeo.",
"post_form_code_tags_prompt": "Você pode destacar a sintaxe e preservar espaços em branco usando tags [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "nu mai afișa",
"site_legal_meta_contributors_link": "Contributori",
"post_link_required_alert": "Trebuie să postezi un link.",
"post_media_link_required_alert": "Trebuie să postezi un link către o imagine sau un videoclip."
"post_media_link_required_alert": "Trebuie să postezi un link către o imagine sau un videoclip.",
"post_form_code_tags_prompt": "Puteți evidenția sintaxa și păstra spațiile albe folosind tag-uri [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "больше не показывать",
"site_legal_meta_contributors_link": "Участники",
"post_link_required_alert": "Вы должны опубликовать ссылку.",
"post_media_link_required_alert": "Вы должны опубликовать ссылку на изображение или видео."
"post_media_link_required_alert": "Вы должны опубликовать ссылку на изображение или видео.",
"post_form_code_tags_prompt": "Вы можете выделять синтаксис и сохранять пробелы с помощью тегов [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "mos e shfaq më",
"site_legal_meta_contributors_link": "Kontribuesit",
"post_link_required_alert": "Duhet të postosh një lidhje.",
"post_media_link_required_alert": "Duhet të postosh një lidhje imazhi ose videoje."
"post_media_link_required_alert": "Duhet të postosh një lidhje imazhi ose videoje.",
"post_form_code_tags_prompt": "Mund të theksosh sintaksën dhe të ruash hapësirat duke përdorur etiketat [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "visa inte igen",
"site_legal_meta_contributors_link": "Bidragsgivare",
"post_link_required_alert": "Du måste posta en länk.",
"post_media_link_required_alert": "Du måste posta en bild- eller videolänk."
"post_media_link_required_alert": "Du måste posta en bild- eller videolänk.",
"post_form_code_tags_prompt": "Du kan markera syntax och bevara mellanslag genom att använda [code]-taggar."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "మళ్లీ చూపించవద్దు",
"site_legal_meta_contributors_link": "సహకారులు",
"post_link_required_alert": "మీరు ఒక లింక్ పోస్ట్ చేయాలి.",
"post_media_link_required_alert": "మీరు చిత్రం లేదా వీడియో లింక్ పోస్ట్ చేయాలి."
"post_media_link_required_alert": "మీరు చిత్రం లేదా వీడియో లింక్ పోస్ట్ చేయాలి.",
"post_form_code_tags_prompt": "మీరు [code] ట్యాగ్‌లను ఉపయోగించి సింటాక్స్‌ను హైలైట్ చేయవచ్చు మరియు ఖాళీ స్థలాలను కాపాడవచ్చు."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "ไม่ต้องแสดงอีก",
"site_legal_meta_contributors_link": "ผู้มีส่วนร่วม",
"post_link_required_alert": "คุณต้องโพสต์ลิงก์",
"post_media_link_required_alert": "คุณต้องโพสต์ลิงก์รูปภาพหรือวิดีโอ"
"post_media_link_required_alert": "คุณต้องโพสต์ลิงก์รูปภาพหรือวิดีโอ",
"post_form_code_tags_prompt": "คุณสามารถไฮไลต์ไวยากรณ์และรักษาช่องว่างได้โดยใช้แท็ก [code]"
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "bir daha gösterme",
"site_legal_meta_contributors_link": "Katkıda Bulunanlar",
"post_link_required_alert": "Bir bağlantı paylaşmalısınız.",
"post_media_link_required_alert": "Bir görsel veya video bağlantısı paylaşmalısınız."
"post_media_link_required_alert": "Bir görsel veya video bağlantısı paylaşmalısınız.",
"post_form_code_tags_prompt": "[code] etiketlerini kullanarak sözdizimini vurgulayabilir ve boşlukları koruyabilirsiniz."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "більше не показувати",
"site_legal_meta_contributors_link": "Учасники",
"post_link_required_alert": "Потрібно опублікувати посилання.",
"post_media_link_required_alert": "Потрібно опублікувати посилання на зображення або відео."
"post_media_link_required_alert": "Потрібно опублікувати посилання на зображення або відео.",
"post_form_code_tags_prompt": "Ви можете підсвічувати синтаксис і зберігати пробіли, використовуючи теги [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "دوبارہ نہ دکھائیں",
"site_legal_meta_contributors_link": "شراکت کنندگان",
"post_link_required_alert": "آپ کو ایک لنک پوسٹ کرنا ہوگا۔",
"post_media_link_required_alert": "آپ کو تصویر یا ویڈیو کا لنک پوسٹ کرنا ہوگا۔"
"post_media_link_required_alert": "آپ کو تصویر یا ویڈیو کا لنک پوسٹ کرنا ہوگا۔",
"post_form_code_tags_prompt": "آپ [code] ٹیگز استعمال کر کے نحو کو نمایاں کر سکتے ہیں اور خالی جگہ کو محفوظ رکھ سکتے ہیں۔"
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "không hiển thị lại",
"site_legal_meta_contributors_link": "Người đóng góp",
"post_link_required_alert": "Bạn phải đăng một liên kết.",
"post_media_link_required_alert": "Bạn phải đăng liên kết hình ảnh hoặc video."
"post_media_link_required_alert": "Bạn phải đăng liên kết hình ảnh hoặc video.",
"post_form_code_tags_prompt": "Bạn có thể tô sáng cú pháp và giữ nguyên khoảng trắng bằng cách sử dụng thẻ [code]."
}
+2 -1
View File
@@ -445,5 +445,6 @@
"settings_upgrade_never_show_again": "不再显示",
"site_legal_meta_contributors_link": "贡献者",
"post_link_required_alert": "你必须发布一个链接。",
"post_media_link_required_alert": "你必须发布图片或视频链接。"
"post_media_link_required_alert": "你必须发布图片或视频链接。",
"post_form_code_tags_prompt": "您可以使用 [code] 标签来高亮语法并保留空白字符。"
}
@@ -0,0 +1,74 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import BbcodeContent from '../bbcode-content';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
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(() => ({
directories: [
{ address: 'site-feedback.bso', directoryCode: 'q', title: '/q/ - 5chan Feedback' },
{ address: 'technology-posting.bso', directoryCode: 'g', title: '/g/ - Technology' },
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
],
}));
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
}));
let container: HTMLDivElement;
let root: Root;
const renderBbcodeContent = async ({
communityAddress = 'site-feedback.bso',
content,
route = '/q/thread/post-1',
}: {
communityAddress?: string;
content: string;
route?: string;
}) => {
await act(async () => {
root.render(createElement(MemoryRouter, { initialEntries: [route] }, createElement(BbcodeContent, { communityAddress, content, postCid: 'post-1' })));
});
};
describe('BbcodeContent', () => {
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders [code] blocks literally on /q/ while preserving normal BBCode outside them', async () => {
await renderBbcodeContent({
content: '[b]test[/b] [code]>not a quote\n[b]bitsocial[/b][/code]',
});
const code = container.querySelector('code');
expect(container.querySelector('strong')?.textContent).toBe('test');
expect(code?.textContent).toBe('>not a quote\n[b]bitsocial[/b]');
expect(code?.querySelectorAll('span').length).toBeGreaterThan(0);
expect(container.textContent).not.toContain('[code]');
});
it('keeps [code] literal off non-code-enabled boards', async () => {
await renderBbcodeContent({
communityAddress: 'music-posting.eth',
content: 'test [code]bitsocial[/code]',
route: '/mu/thread/post-1',
});
expect(container.querySelector('code')).toBeNull();
expect(container.textContent).toBe('test [code]bitsocial[/code]');
});
});
@@ -1,7 +1,11 @@
import { useMemo, type ReactNode } from 'react';
import { parse } from '@bbob/parser';
import { useLocation } from 'react-router-dom';
import { parseHttpUrl } from '../../lib/utils/url-utils';
import Markdown from '../markdown';
import { HAS_CODE_TAG_REGEX, isCodeTagsEnabledForContext, splitCodeTagSegments } from '../../lib/code-tags';
import { useDirectories } from '../../hooks/use-directories';
import CodeBlock from '../code-block/code-block';
import Markdown from '../markdown/markdown';
import styles from './bbcode-content.module.css';
const ALLOWED_BBCODE_TAGS = ['b', 'i', 'u', 's', 'color', 'size', 'quote', 'url'];
@@ -55,15 +59,17 @@ const getPlainTextContent = (content: BbcodeNode | BbcodeNode[] | undefined): st
})
.join('');
const renderNode = (node: BbcodeNode, key: string, props: BbcodeContentProps): ReactNode => {
type BbcodeRenderContext = Pick<BbcodeContentProps, 'communityAddress' | 'postCid'>;
const renderNode = (node: BbcodeNode, key: string, context: BbcodeRenderContext): ReactNode => {
if (node === null) return null;
if (typeof node === 'string' || typeof node === 'number') {
const content = String(node);
return content ? <Markdown key={key} content={content} postCid={props.postCid} communityAddress={props.communityAddress} /> : null;
return content ? <Markdown key={key} content={content} postCid={context.postCid} communityAddress={context.communityAddress} /> : null;
}
const tag = typeof node.tag === 'string' ? node.tag.toLowerCase() : '';
const children = normalizeContent(node.content).map((child, index) => renderNode(child, `${key}-${index}`, props));
const children = normalizeContent(node.content).map((child, index) => renderNode(child, `${key}-${index}`, context));
switch (tag) {
case 'b':
@@ -122,17 +128,43 @@ const renderNode = (node: BbcodeNode, key: string, props: BbcodeContentProps): R
}
};
const BbcodeContent = (props: BbcodeContentProps) => {
const nodes = useMemo(
() =>
parse(props.content || '', {
caseFreeTags: true,
onlyAllowTags: ALLOWED_BBCODE_TAGS,
}) as BbcodeNode[],
[props.content],
);
const renderBbcodeSegment = (content: string, key: string, context: BbcodeRenderContext): ReactNode[] => {
const nodes = parse(content, {
caseFreeTags: true,
onlyAllowTags: ALLOWED_BBCODE_TAGS,
}) as BbcodeNode[];
return <span className={styles.content}>{nodes.map((node, index) => renderNode(node, `bbcode-${index}`, props))}</span>;
return nodes.map((node, index) => renderNode(node, `${key}-${index}`, context));
};
const BbcodeContent = ({ communityAddress, content, postCid }: BbcodeContentProps) => {
const location = useLocation();
const directories = useDirectories();
const enableCodeTags = isCodeTagsEnabledForContext(location.pathname, communityAddress, directories);
const nodes = useMemo<ReactNode[]>(() => {
const context = { communityAddress, postCid };
const raw = content || '';
if (!enableCodeTags || !HAS_CODE_TAG_REGEX.test(raw)) {
return renderBbcodeSegment(raw, 'bbcode', context);
}
const elements: ReactNode[] = [];
splitCodeTagSegments(raw).forEach((segment) => {
if (segment.type === 'code') {
elements.push(<CodeBlock key={`code-${segment.start}`} source={segment.value} />);
return;
}
if (!segment.value) return;
elements.push(...renderBbcodeSegment(segment.value, `bbcode-${segment.start}`, context));
});
return elements;
}, [communityAddress, content, enableCodeTags, postCid]);
return <span className={styles.content}>{nodes}</span>;
};
export default BbcodeContent;
+1 -1
View File
@@ -2,7 +2,7 @@ import { highlightCode } from '../../lib/utils/code-highlight';
import styles from './code-block.module.css';
/**
* Renders a [code] block the way 4chan does on /g/: a flat, square, monospaced box
* Renders a [code] block the way 4chan does on code-enabled boards: a flat, square, monospaced box
* that preserves whitespace and syntax-highlights tokens using a Prettify-style palette.
* Uses a block-level <code> element (phrasing content) so it nests validly inside the
* inline markdown <span>.
@@ -43,6 +43,10 @@ const testState = vi.hoisted(() => ({
commentsByCid: {} as Record<string, TestComment>,
formattedDate: '2024-01-01 12:00:00',
formattedTimeAgo: '2 hours ago',
directories: [
{ address: 'site-feedback.bso', directoryCode: 'q', title: '/q/ - 5chan Feedback' },
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
],
isMobile: false,
params: {} as Record<string, string>,
pathname: '/mu',
@@ -132,6 +136,10 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
default: () => testState.isMobile,
}));
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
}));
vi.mock('../../../hooks/use-state-string', () => ({
default: () => testState.stateString,
}));
@@ -339,6 +347,28 @@ describe('CommentContent', () => {
expect(queryMarkdownText()).toEqual(['[b]plain[/b]']);
});
it('renders code blocks for moderator authors on code-enabled boards', async () => {
testState.pathname = '/q/thread/post-1';
await renderContent(
{
author: { address: '0xmod' },
cid: 'post-1',
communityAddress: 'site-feedback.bso',
content: 'test [code]bitsocial[/code]',
postCid: 'post-1',
},
{
'0xmod': { role: 'admin' },
},
);
const code = container.querySelector('code');
expect(code?.textContent).toBe('bitsocial');
expect(container.textContent).toContain('test');
expect(container.textContent).not.toContain('[code]');
});
it('waits for role data before rendering role-sensitive BBCode content', async () => {
const comment = {
author: { address: '0xmod' },
@@ -302,7 +302,7 @@ describe('Markdown', () => {
it('renders [code] blocks as syntax-highlighted code on /g/', async () => {
await renderMarkdown(
{
content: 'before\n[code]>not a quote\nconst x = 1;[/code]\nafter',
content: 'before\r\n[code]\r\n>not a quote\r\nconst x = 1;\r\n[/code]\r\nafter',
communityAddress: 'technology-posting.bso',
},
'/g/thread/post-1',
@@ -310,7 +310,7 @@ describe('Markdown', () => {
const code = container.querySelector('code');
expect(code).not.toBeNull();
expect(code?.textContent).toBe('>not a quote\nconst x = 1;');
expect(code?.textContent).toBe('>not a quote\r\nconst x = 1;');
// Code contents are tokenized into spans and never parsed as greentext.
expect(code?.querySelectorAll('span').length).toBeGreaterThan(0);
expect(container.querySelector('.greentext')).toBeNull();
@@ -318,7 +318,22 @@ describe('Markdown', () => {
expect(container.textContent).toContain('after');
});
it('renders [code] as literal text off /g/', async () => {
it('renders [code] blocks as syntax-highlighted code on /q/', async () => {
await renderMarkdown(
{
content: '[code]>not a quote\nconst x = 1;[/code]',
communityAddress: 'site-feedback.bso',
},
'/q/thread/post-1',
);
const code = container.querySelector('code');
expect(code).not.toBeNull();
expect(code?.textContent).toBe('>not a quote\nconst x = 1;');
expect(container.querySelector('.greentext')).toBeNull();
});
it('renders [code] as literal text off code-tag boards', async () => {
await renderMarkdown({
content: '[code]const x = 1;[/code]',
});
+11 -60
View File
@@ -19,8 +19,7 @@ import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bit
import { useComment } from '@bitsocial/bitsocial-react-hooks';
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';
import { useDirectories, type DirectoryCommunity } from '../../hooks/use-directories';
import { getCatalogSearchRoute } from '../../lib/utils/route-utils';
import {
createDiceRollMarkupRegex,
@@ -30,6 +29,7 @@ import {
isFortuneDirectoryCode,
} from '../../lib/utils/post-options-utils';
import { HAS_MATH_TAG_REGEX, isMathDirectoryCode, splitMathSegments } from '../../lib/math-tags';
import { HAS_CODE_TAG_REGEX, getDirectoryCodeForIdentifier, getRouteBoardIdentifier, isCodeTagsEnabledForContext, splitCodeTagSegments } from '../../lib/code-tags';
const safeParseUrl = (href: string): URL | null => {
try {
@@ -180,25 +180,6 @@ const QST_BBCODE_COLOR_STYLES = {
blue: { color: QST_BBCODE_COLORS.blue },
} satisfies Record<Extract<QstBbcodeTag, 'red' | 'green' | 'blue'>, React.CSSProperties>;
const getDirectoryCodeFromDirectory = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): string | undefined =>
directory?.directoryCode?.trim().toLowerCase() || directory?.title?.match(/^\/([^/]+)\//)?.[1]?.toLowerCase();
const getRouteBoardIdentifier = (pathname: string): string | undefined => pathname.split('/').filter(Boolean)[0]?.toLowerCase();
const getDirectoryCodeForIdentifier = (identifier: string | undefined, directories: DirectoryCommunity[]): string | undefined => {
if (!identifier) return undefined;
const normalizedIdentifier = identifier.trim().toLowerCase();
if (!normalizedIdentifier) return undefined;
const matchingDirectory = directories.find((directory) => getDirectoryCodeFromDirectory(directory) === normalizedIdentifier);
if (matchingDirectory) {
return getDirectoryCodeFromDirectory(matchingDirectory);
}
return getDirectoryCodeFromDirectory(findDirectoryByAddress(directories, identifier)) ?? getDirectoryCodeForBoardAddress(identifier) ?? normalizedIdentifier;
};
const getActiveDirectoryCode = (pathname: string, communityAddress: string | undefined, directories: DirectoryCommunity[]): string | undefined => {
const routeDirectoryCode = getDirectoryCodeForIdentifier(getRouteBoardIdentifier(pathname), directories);
if (isFortuneDirectoryCode(routeDirectoryCode)) {
@@ -747,36 +728,8 @@ const renderLineContent = (line: string, context: RenderContext): React.ReactNod
return elements;
};
// [code]...[/code] blocks (4chan rule 4 of /g/): rendered literally, never parsed for
// [code]...[/code] blocks: rendered literally, never parsed for
// greentext/quotelinks/spoilers, and syntax-highlighted via <CodeBlock>.
const CODE_TAG_REGEX = /\[code\]([\s\S]*?)\[\/code\]/gi;
const HAS_CODE_TAG_REGEX = /\[code\]/i;
const CODE_DIRECTORY_CODE = 'g';
type ContentSegment = { type: 'text' | 'code'; value: string; start: number };
const splitCodeSegments = (raw: string): ContentSegment[] => {
const segments: ContentSegment[] = [];
const regex = new RegExp(CODE_TAG_REGEX.source, 'gi');
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(raw)) !== null) {
if (match.index > lastIndex) {
segments.push({ type: 'text', value: raw.slice(lastIndex, match.index), start: lastIndex });
}
// Drop one leading/trailing newline so [code]\n...\n[/code] has no blank first/last line.
segments.push({ type: 'code', value: match[1].replace(/^\n/, '').replace(/\n$/, ''), start: match.index });
lastIndex = regex.lastIndex;
}
if (lastIndex < raw.length) {
segments.push({ type: 'text', value: raw.slice(lastIndex), start: lastIndex });
}
return segments;
};
const renderTextLines = (normalized: string, context: RenderContext, keyPrefix: string): React.ReactNode[] => {
const lines = normalized.split('\n');
const elements: React.ReactNode[] = [];
@@ -818,11 +771,9 @@ const Markdown = ({ content, title, postCid, communityAddress, parseSpoilers = t
const enableQstBbcode = location.pathname.split('/').filter(Boolean)[0] === 'qst';
const activeDirectoryCode = getActiveDirectoryCode(location.pathname, communityAddress, directories);
const enableFortuneMarkup = isFortuneDirectoryCode(activeDirectoryCode);
// [code] tags are a /g/ feature (4chan rule 4): enabled when the post's board or the current
// route resolves to /g/, and rendered as literal text everywhere else.
const enableCodeTags =
getDirectoryCodeForIdentifier(getRouteBoardIdentifier(location.pathname), directories) === CODE_DIRECTORY_CODE ||
getDirectoryCodeForIdentifier(communityAddress, directories) === CODE_DIRECTORY_CODE;
// [code] tags are board-scoped: enabled when the post's board or the current route resolves to
// a code-tag board, and rendered as literal text everywhere else.
const enableCodeTags = isCodeTagsEnabledForContext(location.pathname, communityAddress, directories);
// [math]/[eqn] TeX tags are a /sci/ feature (like 4chan). The catalog is excluded, matching
// 4chan, where teasers show the raw tags and MathJax only runs on board and thread pages.
const enableMathTags =
@@ -842,9 +793,8 @@ const Markdown = ({ content, title, postCid, communityAddress, parseSpoilers = t
return;
}
if (!segment.value) return;
elements.push(
<React.Fragment key={`text-${segment.start}`}>{renderTextLines(normalizeContent(segment.value), context, `${segment.start}:`)}</React.Fragment>,
);
const textElements = renderTextLines(normalizeContent(segment.value), context, `${segment.start}:`);
elements.push(<React.Fragment key={`text-${segment.start}`}>{textElements}</React.Fragment>);
});
return elements;
}
@@ -854,13 +804,14 @@ const Markdown = ({ content, title, postCid, communityAddress, parseSpoilers = t
}
const elements: React.ReactNode[] = [];
splitCodeSegments(raw).forEach((segment) => {
splitCodeTagSegments(raw).forEach((segment) => {
if (segment.type === 'code') {
elements.push(<CodeBlock key={`code-${segment.start}`} source={segment.value} />);
return;
}
if (!segment.value) return;
elements.push(<React.Fragment key={`text-${segment.start}`}>{renderTextLines(normalizeContent(segment.value), context, `${segment.start}:`)}</React.Fragment>);
const textElements = renderTextLines(normalizeContent(segment.value), context, `${segment.start}:`);
elements.push(<React.Fragment key={`text-${segment.start}`}>{textElements}</React.Fragment>);
});
return elements;
@@ -98,6 +98,7 @@ vi.mock('react-i18next', async () => {
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'choose_one') return 'Choose one:';
if (key === 'post_form_code_tags_prompt') return 'You may highlight syntax and preserve whitespace by using [code] tags.';
if (typeof options?.count !== 'undefined') return `${key}:${options.count}`;
return options?.domain ? `${key}:${options.domain}` : key;
},
@@ -554,6 +555,8 @@ describe('PostForm', () => {
features: { postFlairs: true, requirePostFlairs: true, requirePostLink: true, requirePostLinkIsMedia: false },
title: '/f/ - Flash',
},
{ address: 'technology-posting.bso', directoryCode: 'g', features: {}, title: '/g/ - Technology' },
{ address: 'site-feedback.bso', directoryCode: 'q', features: {}, title: '/q/ - 5chan Feedback' },
{ address: 'silly-stuff.bso', features: {}, title: '/s5s/ - Silly Stuff' },
{ address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' },
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
@@ -1594,6 +1597,33 @@ describe('PostForm', () => {
expect(links.map((link) => link.getAttribute('href'))).toEqual(['/rules#mu', '/faq']);
});
it('shows the code tag prompt on /g/ forms', async () => {
testState.resolvedCommunityAddress = 'technology-posting.bso';
await renderPostForm('/g');
await clickByText(container, 'start_new_thread');
const rulesItems = Array.from(container.querySelectorAll('tr.rules li')).map((item) => item.textContent);
expect(rulesItems).toEqual(['Please read the Rules and FAQ before posting.', 'You may highlight syntax and preserve whitespace by using [code] tags.']);
});
it('shows the code tag prompt on /q/ forms', async () => {
testState.resolvedCommunityAddress = 'site-feedback.bso';
await renderPostForm('/q');
await clickByText(container, 'start_new_thread');
const rulesItems = Array.from(container.querySelectorAll('tr.rules li')).map((item) => item.textContent);
expect(rulesItems).toEqual(['Please read the Rules and FAQ before posting.', 'You may highlight syntax and preserve whitespace by using [code] tags.']);
});
it('does not show the code tag prompt off code-tag boards', async () => {
testState.resolvedCommunityAddress = 'music-posting.eth';
await renderPostForm('/mu');
await clickByText(container, 'start_new_thread');
const rulesItems = Array.from(container.querySelectorAll('tr.rules li')).map((item) => item.textContent);
expect(rulesItems).toEqual(['Please read the Rules and FAQ before posting.']);
});
it('uses the plain rules page link for custom boards without a directory hash', async () => {
testState.resolvedCommunityAddress = 'custom-board.bso';
+7
View File
@@ -30,6 +30,7 @@ import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, is
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
import { FLASH_TAG_OPTIONS, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectoryCode, type FlashTagOption } from '../../lib/flash-tags';
import { isMathDirectoryCode } from '../../lib/math-tags';
import { isCodeTagDirectoryCode } from '../../lib/code-tags';
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
import { useDirectories } from '../../hooks/use-directories';
import { useDirectoryEntry } from '../../hooks/use-directory-entry';
@@ -182,6 +183,7 @@ interface PostFormFieldsProps {
showFlashTagSelector: boolean;
showFlashUploadPrompt: boolean;
showMathTagsPrompt: boolean;
showCodeTagsPrompt: boolean;
showBbcodeToolbar: boolean;
onBbcodePreviewToggle: () => void;
onPublishReply: () => void | Promise<void>;
@@ -236,6 +238,7 @@ const PostFormFields = ({
showFlashTagSelector,
showFlashUploadPrompt,
showMathTagsPrompt,
showCodeTagsPrompt,
showBbcodeToolbar,
onBbcodePreviewToggle,
onPublishReply,
@@ -492,6 +495,7 @@ const PostFormFields = ({
}}
/>
</li>
{showCodeTagsPrompt ? <li>{t('post_form_code_tags_prompt')}</li> : null}
{showFlashUploadPrompt && (
<li>
<Trans
@@ -580,6 +584,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const showFlashUploadPrompt = isFlashDirectoryCode(postOptionsDirectoryCode);
const showFlashTagSelector = showFlashUploadPrompt && !isInPostView;
const showMathTagsPrompt = isMathDirectoryCode(postOptionsDirectoryCode) || isMathDirectoryCode(directoryEntry?.directoryCode);
const showCodeTagsPrompt =
isCodeTagDirectoryCode(postOptionsDirectoryCode) || isCodeTagDirectoryCode(directoryEntry?.directoryCode) || isCodeTagDirectoryCode(params?.boardIdentifier);
const accountCommunityAddresses = useAccountCommunityAddresses();
const accountAddress = account?.author?.address;
@@ -971,6 +977,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
showFlashTagSelector={showFlashTagSelector}
showFlashUploadPrompt={showFlashUploadPrompt}
showMathTagsPrompt={showMathTagsPrompt}
showCodeTagsPrompt={showCodeTagsPrompt}
showBbcodeToolbar={showBbcodeToolbar}
onBbcodePreviewToggle={handleBbcodePreviewToggle}
onPublishReply={onPublishReply}
@@ -1211,6 +1211,35 @@ describe('ReplyModal', () => {
expect(container.textContent).not.toContain('warning: posting as admin');
});
it('renders [code] blocks in the BBCode reply preview on /q/', async () => {
testState.account = { author: { address: 'mod.eth', displayName: 'Alice' } };
testState.directoryByAddress['site-feedback.bso'] = {
address: 'site-feedback.bso',
directoryCode: 'q',
features: {},
title: '/q/ - 5chan Feedback',
};
testState.rolesByCommunity = {
'site-feedback.bso': {
'mod.eth': { role: 'admin' },
},
};
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/q/thread/post-1', 'site-feedback.bso');
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(textarea as HTMLTextAreaElement, 'test [code]bitsocial[/code]');
await clickButtonByText('Preview');
const preview = container.querySelector('[aria-label="BBCode preview"]');
const code = preview?.querySelector('code');
expect(code?.textContent).toBe('bitsocial');
expect(preview?.textContent).toContain('test');
expect(preview?.textContent).not.toContain('[code]');
});
it('updates account state, applies upload completions, and closes once publishing succeeds', async () => {
await renderReplyModal('/mu/thread/post-1');
+66
View File
@@ -0,0 +1,66 @@
import type { DirectoryCommunity } from './utils/directory-list-utils';
import { getDirectoryCodeForBoardAddress, normalizeBoardAddress } from './utils/directory-list-lookup-utils';
const CODE_TAG_DIRECTORY_CODES = new Set(['g', 'q']);
const CODE_TAG_REGEX = /\[code\]([\s\S]*?)\[\/code\]/gi;
export const HAS_CODE_TAG_REGEX = /\[code\]/i;
export type CodeTagSegment = { type: 'text' | 'code'; value: string; start: number };
export const isCodeTagDirectoryCode = (directoryCode: string | undefined): boolean => !!directoryCode && CODE_TAG_DIRECTORY_CODES.has(directoryCode.toLowerCase());
export const splitCodeTagSegments = (raw: string): CodeTagSegment[] => {
const segments: CodeTagSegment[] = [];
const regex = new RegExp(CODE_TAG_REGEX.source, 'gi');
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(raw)) !== null) {
if (match.index > lastIndex) {
segments.push({ type: 'text', value: raw.slice(lastIndex, match.index), start: lastIndex });
}
segments.push({ type: 'code', value: match[1].replace(/^\r?\n/, '').replace(/\r?\n$/, ''), start: match.index });
lastIndex = regex.lastIndex;
}
if (lastIndex < raw.length) {
segments.push({ type: 'text', value: raw.slice(lastIndex), start: lastIndex });
}
return segments;
};
const getDirectoryCodeFromDirectory = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): string | undefined =>
directory?.directoryCode?.trim().toLowerCase() || directory?.title?.match(/^\/([^/]+)\//)?.[1]?.toLowerCase();
export const getRouteBoardIdentifier = (pathname: string): string | undefined => pathname.split('/').filter(Boolean)[0]?.toLowerCase();
const getDirectoryIdentifiers = (directory: DirectoryCommunity): string[] => [
...new Set([directory.address, directory.name, directory.publicKey].filter((value): value is string => typeof value === 'string' && value.length > 0)),
];
const findDirectoryByIdentifier = (directories: DirectoryCommunity[], identifier: string): DirectoryCommunity | undefined => {
const normalizedIdentifier = normalizeBoardAddress(identifier);
return directories.find((directory) =>
getDirectoryIdentifiers(directory).some((directoryIdentifier) => normalizeBoardAddress(directoryIdentifier) === normalizedIdentifier),
);
};
export const getDirectoryCodeForIdentifier = (identifier: string | undefined, directories: DirectoryCommunity[]): string | undefined => {
if (!identifier) return undefined;
const normalizedIdentifier = identifier.trim().toLowerCase();
if (!normalizedIdentifier) return undefined;
const matchingDirectory = directories.find((directory) => getDirectoryCodeFromDirectory(directory) === normalizedIdentifier);
if (matchingDirectory) {
return getDirectoryCodeFromDirectory(matchingDirectory);
}
return getDirectoryCodeFromDirectory(findDirectoryByIdentifier(directories, identifier)) ?? getDirectoryCodeForBoardAddress(identifier) ?? normalizedIdentifier;
};
export const isCodeTagsEnabledForContext = (pathname: string, communityAddress: string | undefined, directories: DirectoryCommunity[]): boolean =>
isCodeTagDirectoryCode(getDirectoryCodeForIdentifier(getRouteBoardIdentifier(pathname), directories)) ||
isCodeTagDirectoryCode(getDirectoryCodeForIdentifier(communityAddress, directories));