@supersuit/wiki-comments
v0.1.3
Published
Highlight-to-comment, general and voice comments, and a bottom-pinned action bar for wikis.
Maintainers
Readme
@supersuit/wiki-comments
Comments for a wiki page. A reader selects words in the article and comments on them, writes a general comment under the article, replies to someone else's comment, or says any of these instead of typing it: a voice note, with the words written into the comment box as they speak where the browser can. Every action on the page sits in one bar pinned to the bottom of the screen, where it never fights a phone's own copy menu.
The package knows how comments work: the record of a comment, its anchor to the quoted words, every rule (who may read, post and delete, the limits), the request handler, and the whole interface. The wiki says who is reading, who owns the wiki, which pages a reader may see, where comments are kept, and how it looks.
30 seconds
npm install @supersuit/wiki-comments firebase-adminA Vercel function (api/comments.ts), for a wiki that is not a Next.js app:
import { createCommentsHandler } from '@supersuit/wiki-comments/server'
import { host } from '../lib/comments-host'
const handle = createCommentsHandler(host)
export const GET = handle, POST = handle, DELETE = handleA Next.js route (app/api/comments/route.ts) is the same three lines:
import { createCommentsHandler } from '@supersuit/wiki-comments/server'
import { host } from '@/lib/comments-host'
export const runtime = 'nodejs'
const handle = createCommentsHandler(host)
export const GET = handle, POST = handle, DELETE = handleOn the page, mount WikiComments once, beside the article and outside the article element:
import { WikiComments } from '@supersuit/wiki-comments/client'
import '@supersuit/wiki-comments/styles.css'
// One thread per page: /x and /x/ are the same page.
const page = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname
<article ref={articleRef}>{content}</article>
<WikiComments apiBase="/api/comments" page={page} articleRef={articleRef} />It reads the article's text to find quoted passages, and its own list repeats those quotes, so the list must not be inside what it searches. A signed-out reader (the host answers 401) sees nothing at all: no list, no bar.
Docusaurus
Wrap the doc layout (src/theme/DocItem/Layout/index.tsx), mount through BrowserOnly, and key
the mount on the page so each page starts fresh:
import { useEffect, useRef, useState } from 'react'
import Layout from '@theme-original/DocItem/Layout'
import BrowserOnly from '@docusaurus/BrowserOnly'
import { useLocation } from '@docusaurus/router'
import { WikiComments } from '@supersuit/wiki-comments/client'
import '@supersuit/wiki-comments/styles.css'
function Comments({ page }: { page: string }) {
const articleRef = useRef<Element | null>(null)
const [ready, setReady] = useState(false)
// After commit, so a client-side navigation finds the NEW article, not the one leaving.
useEffect(() => { articleRef.current = document.querySelector('article'); setReady(true) }, [])
return ready ? <WikiComments apiBase="/api/comments" page={page} articleRef={articleRef} /> : null
}
export default function LayoutWrapper(props: React.ComponentProps<typeof Layout>) {
const { pathname } = useLocation()
// One thread per page: /x and /x/ are the same page.
const page = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname
return (
<>
<Layout {...props} />
<BrowserOnly>{() => <Comments key={page} page={page} />}</BrowserOnly>
</>
)
}The comments section lands after the <article>, never inside it. The API is the Vercel function
above, deployed with the site.
Three entry points
@supersuit/wiki-comments: the types and the rules, pure and safe anywhere:TextAnchor,WikiComment,PublicComment,Reader,findText,textAnchorFrom,quoteLabel, the limits (MAX_TEXT,MAX_AUDIO_MS,MAX_QUOTE_CHARS,CONTEXT_CHARS),COMMENT_ID, and the rules (parseNewComment,canDelete,pageKey,newCommentId).@supersuit/wiki-comments/server:createCommentsHandler(host),firestoreStore,memoryStore,cloudStorageAudio, and theWikiCommentsHost,CommentsStoreandBuckettypes. WebRequestin,Responseout; no framework.@supersuit/wiki-comments/client:WikiComments, and the parts it is made of for a wiki that wants to assemble its own:ActionBar,CommentsSection,Composer,AudioPlayer,useRecorder,RecordButton,useSelectionIn,commentsApi, the highlight helpers, andDEFAULT_STRINGS. Plain React 19, marked'use client'.@supersuit/wiki-comments/styles.css: the interface's styles.
The host
WikiCommentsHost, exported from /server, documents every member in its JSDoc. In short:
reader(req): who is reading, from the wiki's own session, ornull(answers 401). The server sets every comment's author from this. Nothing the browser sends decides identity.canRead(reader, page): whether this reader may see this page.falseanswers 404, so a page the reader cannot see is never confirmed to exist.isOwner(reader): the wiki's owner may delete anyone's comment or reply. Authors may delete their own.store: where comments live:list,add,get,remove, and optionallyremoveMany(deleting a comment with replies removes them together through it, a batch in Firestore).firestoreStore(db, collection)keeps them at<collection>/<sha1(page)>/items/<id>, replies beside the comments they answer;memoryStore()is for tests and demos. A store of your own must leave out oflistany row carryingdeletedAt(only 0.1.0 and 0.1.1 wrote one).audio: optional. Without it the voice routes answer 404 and a voice comment is refused with 400. The Record control is drawn wherever the browser can record, so a wiki with noaudioshould not leave its readers a Record button that cannot send: give itaudio, or assemble the parts withoutRecordButton.cloudStorageAudio(bucket, prefix)signs a PUT valid 10 minutes and a GET valid an hour, underprefix. The browser uploads straight to the bucket, so the bucket's CORS must allowPUTfrom the wiki's origin with theContent-Typeandx-goog-content-length-rangeheaders.onComment(c): optional. Awaited after a comment is saved (to notify the owner, say); a throw is logged and never fails the request.log(msg, err): optional. Defaults toconsole.error.
import { getFirestore } from 'firebase-admin/firestore'
import { getStorage } from 'firebase-admin/storage'
import { cloudStorageAudio, firestoreStore, type WikiCommentsHost } from '@supersuit/wiki-comments/server'
export const host: WikiCommentsHost = {
reader: async (req) => yourSession(req), // { id, name } or null
canRead: async (reader, page) => true,
isOwner: (reader) => reader.id === process.env.WIKI_OWNER_ID,
store: firestoreStore(getFirestore(), 'wiki-comments'),
audio: cloudStorageAudio(getStorage().bucket(), 'wiki-comments/'),
}The rules the handler holds
A comment is at most 4000 characters, a voice note at most three minutes and only audio/webm or
audio/mp4, a quote at most 500 characters. Comments are never edited.
Replies are one level deep. A reply is a comment with replyTo, the id of a top-level comment
on the same page; it has text, a voice note or both, under the same limits, and never an anchor. A
reply to a reply, to a comment on another page, or to one that does not exist is refused with 400,
and so is a reply carrying an anchor. Replies are listed oldest first, under their comment.
Delete is for good. The comment's document and its recording are both removed, and nothing is
left in its place. Deleting a top-level comment removes every reply to it, and their recordings,
with it; deleting a reply removes only that reply. The author or the wiki's owner may delete. A
document an earlier version marked deletedAt is never listed, and a DELETE removes it for good.
A voice path is accepted only if its shape matches this page and this reader's prefix
(<sha1(page)>/<sha1(reader)>/<22 characters>.webm|m4a), which is the shape the upload route
issues. Readers never see storage paths: each voice comment in a list carries a playUrl, a GET
signed for an hour, and when that has expired the client asks for a fresh one by comment id.
The wire
One route, ?page=<route> on every call:
| Request | Does |
| --- | --- |
| GET ?page= | { comments, me, owner }; a voice comment's audio is { mime, ms, playUrl? } |
| POST ?page= | file a comment { text, anchor?, audio?, replyTo? }, answers 201 { comment } in the same public shape as GET |
| DELETE ?page=&id= | delete for good, with its replies and recordings, answers 204 |
| POST ?op=upload&page= | { mime } in, { path, url, headers } out: PUT the recording to url with exactly headers |
| GET ?op=play&page=&id= | { url }, a signed GET for that comment's recording |
WikiComments props
| Prop | |
| --- | --- |
| apiBase | the route, e.g. '/api/comments' |
| page | the page's canonical route; a change starts the composer empty |
| articleRef | a ref to the rendered article body |
| extraActions | the wiki's own bar buttons, after Comment and Record |
| menu | the wiki's own items for the ••• sheet, before Copy link |
| strings | any of DEFAULT_STRINGS replaced, including the bar's own more, close, pageActions, moreActions and commentOn |
Each top-level comment has a Reply button that opens a composer under it (the same composer and recorder as the one at the bottom, with Cancel); the bar's Comment button always opens the top-level composer. A comment whose quoted words are no longer on the page is kept, listed under "On an earlier version" with the quote as it was. Tapping a highlighted passage scrolls to its comment; tapping a comment's quote scrolls to the passage and flashes it.
Styling
Every colour is a --wc-* variable. The defaults sit in :where(:root), which has no specificity,
so a site sets any of them on :root (or any ancestor) and wins. A dark set applies under
[data-theme='dark'].
| Variable | For |
| --- | --- |
| --wc-ink | text |
| --wc-muted | quiet text: dates, the empty line |
| --wc-on-ink | text on an ink-coloured button |
| --wc-hairline | borders and dividers |
| --wc-surface | the bar |
| --wc-sheet-bg | the ••• sheet |
| --wc-backdrop | behind the sheet |
| --wc-shadow | the bar's and sheet's shadow (a whole box-shadow value) |
| --wc-quote | a highlighted passage |
| --wc-flash | a passage flashed when its comment's quote is tapped |
| --wc-error | an error line |
| --wc-z | the bar's z-index |
Text inputs are 16px or larger, so iPhone Safari does not zoom into the composer.
Browser support
- Highlights are drawn with the CSS Custom Highlight API, never written into the article: Safari 17.2+ (iOS 17.2+) and Chrome 105+. Elsewhere passages simply do not paint; selecting words and commenting on them, the list, and tapping a quote all still work.
- Voice needs
MediaRecorderand a secure context: HTTPS, orlocalhost. The recorder asks foraudio/mp4first, which every current browser plays back, thenaudio/webm;codecs=opus, else the browser's default. Where the browser cannot record, the Record control is not drawn at all. Live transcription into the comment box runs where the browser has speech recognition, except on iPhone and iPad, where running it beside the recorder can silence the voice note; there, and anywhere else without it, the note is recorded without live words. - Playback on iPhone: the list carries each recording's signed URL, so a tap sets the source
and calls
play()inside the tap itself, which iPhone Safari requires. If that URL has expired, a fresh one is fetched and the player says "Tap again to play".
Requirements
Node 20.9 or later. The root and server entries also ship as CommonJS (dist/cjs/), because a
Vercel function in a project without "type": "module" is compiled to require() and Vercel's
runtime refuses require() of an ES module. The client entry is ES modules only. Peers: react and react-dom 19, and firebase-admin 12 or 13 for
firestoreStore and cloudStorageAudio only (optional: a host with its own store needs none).
test/consumer/ in this repository installs the packed package, type-checks against it, and sends
requests through the handler. npm run check runs it.
License
MIT
