Search Apps Documentation Source Content File Folder Download Copy Actions Download

memba_reviews_v1.gno

20.79 Kb · 721 lines
  1package memba_reviews_v1
  2
  3// Memba Reviews / Web-of-Trust realm.
  4//
  5// OPEN, on-chain ratings + reviews for any Memba subject (validator/candidate/
  6// individual address, or an org/DAO realm path). Anyone with a wallet may post
  7// ONE editable review per subject, react (like/dislike), reply (flat, one
  8// level), and flag. A running net-likes reputation counter per author drives
  9// ranking. Moderation = author delete (tombstone) + community flag + multisig
 10// hide (soft-delete). Text is permanent on-chain; Hide only omits from reads.
 11//
 12// Reads: exported *JSON funcs queried via RPC vm/qeval (paginated). Render() is
 13// a secondary human view for gnoweb.
 14
 15import (
 16	"strconv"
 17	"strings"
 18
 19	"gno.land/p/nt/avl/v0"
 20	"gno.land/p/nt/ufmt/v0"
 21
 22	"chain"
 23	"chain/runtime"
 24	"chain/runtime/unsafe"
 25)
 26
 27// ── Constants ────────────────────────────────────────────────
 28const (
 29	MaxBodyLen    = 2000 // review body
 30	MaxCommentLen = 1000 // comment body
 31	MaxPageLimit  = 100  // hard cap on any paginated read (DoS guard)
 32
 33	// Memba moderator multisig (samcrew-core-test1 on testnet). CONFIRM before deploy.
 34	ModeratorAddress = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0"
 35)
 36
 37// ── Types ────────────────────────────────────────────────────
 38type Review struct {
 39	ID        uint64
 40	Subject   string  // g1… address OR realm path
 41	Author    address
 42	Rating    int     // 1..5
 43	Body      string  // optional, ≤ MaxBodyLen
 44	CreatedAt int64   // block height
 45	EditedAt  int64   // block height of last edit (0 if never)
 46	Hidden    bool    // multisig soft-delete / auto-hide
 47	Deleted   bool    // author tombstone
 48	Likes     uint64
 49	Dislikes  uint64
 50	FlagCount uint64
 51}
 52
 53type Comment struct {
 54	ID        uint64
 55	ReviewID  uint64
 56	Author    address
 57	Body      string // ≤ MaxCommentLen
 58	CreatedAt int64
 59	EditedAt  int64
 60	Hidden    bool
 61	Deleted   bool
 62	Likes     uint64
 63	Dislikes  uint64
 64	FlagCount uint64
 65}
 66
 67// ── State ────────────────────────────────────────────────────
 68var (
 69	reviews       *avl.Tree // strID(id) -> *Review
 70	comments      *avl.Tree // strID(id) -> *Comment
 71	subjectIndex  *avl.Tree // subject -> []uint64 (review IDs, ascending) — bounds reads
 72	commentIndex  *avl.Tree // strID(reviewID) -> []uint64 (comment IDs, ascending)
 73	authorSubject *avl.Tree // subject + "\x00" + author -> uint64 (reviewID) — one-per-pair
 74	reactions     *avl.Tree // strID(targetID) + "/" + addr -> "like"|"dislike"
 75	flags         *avl.Tree // strID(targetID) + "/" + addr -> true (one flag per acct/target)
 76	reputation    *avl.Tree // addr -> int64 (Σ likes−dislikes on their reviews+comments)
 77	flaggedIDs    *avl.Tree // strID(targetID) -> true (visible targets with ≥1 flag, for the mod dashboard)
 78	nextID        uint64
 79)
 80
 81func init() {
 82	reviews = avl.NewTree()
 83	comments = avl.NewTree()
 84	subjectIndex = avl.NewTree()
 85	commentIndex = avl.NewTree()
 86	authorSubject = avl.NewTree()
 87	reactions = avl.NewTree()
 88	flags = avl.NewTree()
 89	reputation = avl.NewTree()
 90	flaggedIDs = avl.NewTree()
 91	nextID = 1
 92}
 93
 94// ── Helpers ──────────────────────────────────────────────────
 95func strID(id uint64) string { return strconv.FormatUint(id, 10) }
 96
 97func getReview(id uint64) (*Review, bool) {
 98	v, ok := reviews.Get(strID(id))
 99	if !ok {
100		return nil, false
101	}
102	return v.(*Review), true
103}
104
105func getComment(id uint64) (*Comment, bool) {
106	v, ok := comments.Get(strID(id))
107	if !ok {
108		return nil, false
109	}
110	return v.(*Comment), true
111}
112
113func assertModerator() {
114	caller := unsafe.PreviousRealm().Address()
115	if caller != address(ModeratorAddress) {
116		panic("unauthorized: moderator multisig only")
117	}
118}
119
120func getReputation(addr string) int64 {
121	if v, ok := reputation.Get(addr); ok {
122		return v.(int64)
123	}
124	return 0
125}
126
127func addReputation(addr string, delta int64) {
128	reputation.Set(addr, getReputation(addr)+delta)
129}
130
131func idList(t *avl.Tree, key string) []uint64 {
132	if v, ok := t.Get(key); ok {
133		return v.([]uint64)
134	}
135	return nil
136}
137
138// removeID returns ids with the first occurrence of target removed.
139func removeID(ids []uint64, target uint64) []uint64 {
140	out := make([]uint64, 0, len(ids))
141	removed := false
142	for _, id := range ids {
143		if !removed && id == target {
144			removed = true
145			continue
146		}
147		out = append(out, id)
148	}
149	return out
150}
151
152// sanitizeForRender strips markdown/HTML-sensitive chars from user strings used
153// inside Render() markdown (defense-in-depth; the frontend also DOMPurifies).
154// "&" is escaped FIRST to prevent entity-injection (e.g. "<" → "<").
155func sanitizeForRender(s string) string {
156	r := strings.NewReplacer(
157		"&", "&",
158		"<", "&lt;", ">", "&gt;",
159		"[", "(", "]", ")",
160		"`", "'", "|", "/",
161		"\n", " ", "\r", " ",
162	)
163	return r.Replace(s)
164}
165
166// jsonEscape escapes a string for embedding in the realm's hand-built JSON.
167func jsonEscape(s string) string {
168	var b strings.Builder
169	for _, c := range s {
170		switch c {
171		case '"':
172			b.WriteString("\\\"")
173		case '\\':
174			b.WriteString("\\\\")
175		case '\n':
176			b.WriteString("\\n")
177		case '\r':
178			b.WriteString("\\r")
179		case '\t':
180			b.WriteString("\\t")
181		default:
182			if c < 0x20 {
183				const hexDigits = "0123456789abcdef"
184				b.WriteString("\\u00")
185				b.WriteByte(hexDigits[int((c>>4)&0xf)])
186				b.WriteByte(hexDigits[int(c&0xf)])
187			} else {
188				b.WriteRune(c)
189			}
190		}
191	}
192	return b.String()
193}
194
195// ── Validation helpers (pure, no side-effects) ────────────────
196
197func validRating(r int) bool { return r >= 1 && r <= 5 }
198
199func validBody(b string) bool { return len(b) <= MaxBodyLen }
200
201// pairKey returns the authorSubject tree key for a (subject, author) pair.
202// The NUL separator prevents prefix collisions between subject and author.
203func pairKey(subject string, a address) string { return subject + "\x00" + a.String() }
204
205// ── Write functions ───────────────────────────────────────────
206
207// PostReview creates the caller's review for `subject`, or replaces it in place
208// if one already exists (the "one editable review per pair" rule).
209func PostReview(cur realm, subject string, rating int, body string) {
210	caller := unsafe.PreviousRealm().Address()
211	if subject == "" {
212		panic("subject required")
213	}
214	if !validRating(rating) {
215		panic("rating must be 1..5")
216	}
217	if !validBody(body) {
218		panic("body too long")
219	}
220
221	pk := pairKey(subject, caller)
222	if v, ok := authorSubject.Get(pk); ok {
223		r, found := getReview(v.(uint64))
224		if found && !r.Deleted && !r.Hidden {
225			r.Rating = rating
226			r.Body = body
227			r.EditedAt = runtime.ChainHeight()
228			reviews.Set(strID(r.ID), r)
229			chain.Emit("ReviewUpdated", "id", strID(r.ID), "subject", subject)
230			return
231		}
232	}
233
234	id := nextID
235	nextID++
236	r := &Review{
237		ID:        id,
238		Subject:   subject,
239		Author:    caller,
240		Rating:    rating,
241		Body:      body,
242		CreatedAt: runtime.ChainHeight(),
243	}
244	reviews.Set(strID(id), r)
245	authorSubject.Set(pk, id)
246	subjectIndex.Set(subject, append(idList(subjectIndex, subject), id))
247	chain.Emit("ReviewPosted", "id", strID(id), "subject", subject, "author", caller.String())
248}
249
250// EditReview updates rating + body of an existing non-deleted review.
251// Only the original author may edit.
252func EditReview(cur realm, reviewID uint64, rating int, body string) {
253	caller := unsafe.PreviousRealm().Address()
254	r, ok := getReview(reviewID)
255	if !ok || r.Deleted {
256		panic("review not found")
257	}
258	if r.Author != caller {
259		panic("author only")
260	}
261	if !validRating(rating) {
262		panic("rating must be 1..5")
263	}
264	if !validBody(body) {
265		panic("body too long")
266	}
267	r.Rating = rating
268	r.Body = body
269	r.EditedAt = runtime.ChainHeight()
270	reviews.Set(strID(reviewID), r)
271	chain.Emit("ReviewUpdated", "id", strID(reviewID), "subject", r.Subject)
272}
273
274// DeleteReview tombstones the caller's review: keeps the ID + reaction history,
275// clears the body, and frees the (author, subject) pair for a fresh review.
276// Only the original author may delete.
277func DeleteReview(cur realm, reviewID uint64) {
278	caller := unsafe.PreviousRealm().Address()
279	r, ok := getReview(reviewID)
280	if !ok || r.Deleted {
281		panic("review not found")
282	}
283	if r.Author != caller {
284		panic("author only")
285	}
286	r.Deleted = true
287	r.Body = ""
288	reviews.Set(strID(reviewID), r)
289	authorSubject.Remove(pairKey(r.Subject, caller))
290	subjectIndex.Set(r.Subject, removeID(idList(subjectIndex, r.Subject), reviewID))
291	chain.Emit("ReviewDeleted", "id", strID(reviewID), "subject", r.Subject)
292}
293
294// ── Comment validation (pure, no side-effects) ────────────────
295
296// validComment returns true iff body is non-empty and within MaxCommentLen.
297func validComment(b string) bool { return b != "" && len(b) <= MaxCommentLen }
298
299// ── Comment write functions ───────────────────────────────────
300
301// PostComment posts a flat reply to an existing, non-deleted, non-hidden review.
302func PostComment(cur realm, reviewID uint64, body string) {
303	caller := unsafe.PreviousRealm().Address()
304	r, ok := getReview(reviewID)
305	if !ok || r.Deleted || r.Hidden {
306		panic("review not found")
307	}
308	if !validComment(body) {
309		panic("comment length invalid")
310	}
311	id := nextID
312	nextID++
313	c := &Comment{
314		ID:        id,
315		ReviewID:  reviewID,
316		Author:    caller,
317		Body:      body,
318		CreatedAt: runtime.ChainHeight(),
319	}
320	comments.Set(strID(id), c)
321	commentIndex.Set(strID(reviewID), append(idList(commentIndex, strID(reviewID)), id))
322	chain.Emit("CommentPosted", "id", strID(id), "review", strID(reviewID), "author", caller.String())
323}
324
325// EditComment updates the body of an existing, non-deleted comment.
326// Only the original author may edit.
327func EditComment(cur realm, commentID uint64, body string) {
328	caller := unsafe.PreviousRealm().Address()
329	c, ok := getComment(commentID)
330	if !ok || c.Deleted {
331		panic("comment not found")
332	}
333	if c.Author != caller {
334		panic("author only")
335	}
336	if !validComment(body) {
337		panic("comment length invalid")
338	}
339	c.Body = body
340	c.EditedAt = runtime.ChainHeight()
341	comments.Set(strID(commentID), c)
342	chain.Emit("CommentUpdated", "id", strID(commentID))
343}
344
345// DeleteComment tombstones the caller's comment: clears the body.
346// Only the original author may delete.
347func DeleteComment(cur realm, commentID uint64) {
348	caller := unsafe.PreviousRealm().Address()
349	c, ok := getComment(commentID)
350	if !ok || c.Deleted {
351		panic("comment not found")
352	}
353	if c.Author != caller {
354		panic("author only")
355	}
356	c.Deleted = true
357	c.Body = ""
358	comments.Set(strID(commentID), c)
359	chain.Emit("CommentDeleted", "id", strID(commentID))
360}
361
362// ── Reaction helpers (pure, no side-effects) ──────────────────
363
364func boolToInt(b bool) int {
365	if b {
366		return 1
367	}
368	return 0
369}
370
371// reactionDelta returns how a reaction change (old -> newKind, each "" | "like" | "dislike")
372// moves the target's like count, dislike count, and the target author's reputation.
373// repDelta = likesDelta - dislikesDelta.
374func reactionDelta(old, newKind string) (likesDelta, dislikesDelta, repDelta int) {
375	likesDelta = boolToInt(newKind == "like") - boolToInt(old == "like")
376	dislikesDelta = boolToInt(newKind == "dislike") - boolToInt(old == "dislike")
377	repDelta = likesDelta - dislikesDelta
378	return
379}
380
381// applyDelta adjusts an unsigned counter by ±1-style delta without underflow.
382func applyDelta(v uint64, delta int) uint64 {
383	if delta < 0 {
384		d := uint64(-delta)
385		if v < d {
386			return 0
387		}
388		return v - d
389	}
390	return v + uint64(delta)
391}
392
393// ── React ─────────────────────────────────────────────────────
394
395// React records or toggles a like/dislike on a review or comment.
396// Re-reacting with the same kind toggles it off; switching kind replaces it.
397// Updates the target's Likes/Dislikes counters and the author's reputation by
398// Δ(likes−dislikes). Self-reactions are rejected. Deleted/hidden targets are rejected.
399func React(cur realm, targetID uint64, kind string) {
400	caller := unsafe.PreviousRealm().Address()
401	if kind != "like" && kind != "dislike" {
402		panic("kind must be like or dislike")
403	}
404
405	r, isReview := getReview(targetID)
406	c, isComment := getComment(targetID)
407	if !isReview && !isComment {
408		panic("target not found")
409	}
410
411	var author address
412	if isReview {
413		author = r.Author
414		if r.Deleted || r.Hidden {
415			panic("target not found")
416		}
417	} else {
418		author = c.Author
419		if c.Deleted || c.Hidden {
420			panic("target not found")
421		}
422	}
423	if author == caller {
424		panic("cannot react to your own review or comment")
425	}
426
427	rk := strID(targetID) + "/" + caller.String()
428	var old string
429	if v, ok := reactions.Get(rk); ok {
430		old = v.(string)
431	}
432
433	newKind := kind
434	if old == kind {
435		newKind = "" // toggle off
436	}
437
438	likesDelta, dislikesDelta, repDelta := reactionDelta(old, newKind)
439
440	if newKind == "" {
441		reactions.Remove(rk)
442	} else {
443		reactions.Set(rk, newKind)
444	}
445
446	if isReview {
447		r.Likes = applyDelta(r.Likes, likesDelta)
448		r.Dislikes = applyDelta(r.Dislikes, dislikesDelta)
449		reviews.Set(strID(targetID), r)
450	} else {
451		c.Likes = applyDelta(c.Likes, likesDelta)
452		c.Dislikes = applyDelta(c.Dislikes, dislikesDelta)
453		comments.Set(strID(targetID), c)
454	}
455	addReputation(author.String(), int64(repDelta))
456	chain.Emit("Reacted", "target", strID(targetID), "kind", newKind, "by", caller.String())
457}
458
459// ── Flag + moderation ─────────────────────────────────────────
460
461// Flag records one community flag per account per target (review or comment).
462// Auto-hide is NOT performed here; takedowns are multisig-only (HideReview/HideComment).
463// Deleted/hidden targets are rejected.
464func Flag(cur realm, targetID uint64) {
465	caller := unsafe.PreviousRealm().Address()
466	r, isReview := getReview(targetID)
467	c, isComment := getComment(targetID)
468	if !isReview && !isComment {
469		panic("target not found")
470	}
471	if isReview && (r.Deleted || r.Hidden) {
472		panic("target not found")
473	}
474	if isComment && (c.Deleted || c.Hidden) {
475		panic("target not found")
476	}
477	fk := strID(targetID) + "/" + caller.String()
478	if _, ok := flags.Get(fk); ok {
479		panic("already flagged")
480	}
481	flags.Set(fk, true)
482
483	if isReview {
484		r.FlagCount++
485		reviews.Set(strID(targetID), r)
486	} else {
487		c.FlagCount++
488		comments.Set(strID(targetID), c)
489	}
490	flaggedIDs.Set(strID(targetID), true)
491	chain.Emit("Flagged", "target", strID(targetID), "by", caller.String())
492}
493
494// HideReview soft-deletes a review. Moderator (multisig) only.
495func HideReview(cur realm, id uint64) {
496	assertModerator()
497	r, ok := getReview(id)
498	if !ok {
499		panic("review not found")
500	}
501	r.Hidden = true
502	reviews.Set(strID(id), r)
503	chain.Emit("Hidden", "target", strID(id))
504}
505
506// HideComment soft-deletes a comment. Moderator (multisig) only.
507func HideComment(cur realm, id uint64) {
508	assertModerator()
509	c, ok := getComment(id)
510	if !ok {
511		panic("comment not found")
512	}
513	c.Hidden = true
514	comments.Set(strID(id), c)
515	chain.Emit("Hidden", "target", strID(id))
516}
517
518// Unhide reverses a hide (manual or auto-flag) on a review or comment.
519// Moderator (multisig) only. Also removes the target from the mod dashboard index.
520func Unhide(cur realm, targetID uint64) {
521	assertModerator()
522	if r, ok := getReview(targetID); ok {
523		r.Hidden = false
524		reviews.Set(strID(targetID), r)
525		flaggedIDs.Remove(strID(targetID))
526		chain.Emit("Unhidden", "target", strID(targetID))
527		return
528	}
529	if c, ok := getComment(targetID); ok {
530		c.Hidden = false
531		comments.Set(strID(targetID), c)
532		flaggedIDs.Remove(strID(targetID))
533		chain.Emit("Unhidden", "target", strID(targetID))
534		return
535	}
536	panic("target not found")
537}
538
539// ── Read helpers ──────────────────────────────────────────────
540
541// clampLimit ensures limit is in [1, MaxPageLimit]. Zero or negative values and
542// values above MaxPageLimit are all clamped to MaxPageLimit.
543func clampLimit(limit int) int {
544	if limit <= 0 || limit > MaxPageLimit {
545		return MaxPageLimit
546	}
547	return limit
548}
549
550// window slices ids[offset : offset+limit] safely (no panics on out-of-range).
551func window(ids []uint64, offset, limit int) []uint64 {
552	limit = clampLimit(limit)
553	if offset < 0 {
554		offset = 0
555	}
556	if offset >= len(ids) {
557		return nil
558	}
559	end := offset + limit
560	if end > len(ids) {
561		end = len(ids)
562	}
563	return ids[offset:end]
564}
565
566func reviewJSON(r *Review) string {
567	body := ""
568	if !r.Deleted {
569		body = jsonEscape(r.Body)
570	}
571	return ufmt.Sprintf(
572		`{"id":%d,"subject":"%s","author":"%s","rating":%d,"body":"%s","createdAt":%d,"editedAt":%d,"deleted":%t,"likes":%d,"dislikes":%d,"flags":%d,"reputation":%d}`,
573		r.ID, jsonEscape(r.Subject), jsonEscape(r.Author.String()), r.Rating, body,
574		r.CreatedAt, r.EditedAt, r.Deleted, r.Likes, r.Dislikes, r.FlagCount,
575		getReputation(r.Author.String()),
576	)
577}
578
579func commentJSON(c *Comment) string {
580	body := ""
581	if !c.Deleted {
582		body = jsonEscape(c.Body)
583	}
584	return ufmt.Sprintf(
585		`{"id":%d,"reviewId":%d,"author":"%s","body":"%s","createdAt":%d,"editedAt":%d,"deleted":%t,"likes":%d,"dislikes":%d,"flags":%d,"reputation":%d}`,
586		c.ID, c.ReviewID, jsonEscape(c.Author.String()), body,
587		c.CreatedAt, c.EditedAt, c.Deleted, c.Likes, c.Dislikes, c.FlagCount,
588		getReputation(c.Author.String()),
589	)
590}
591
592// GetReviewsJSON returns a JSON array of a subject's non-hidden reviews (paginated).
593// Deleted reviews are included as tombstones (empty body, deleted:true).
594func GetReviewsJSON(subject string, offset, limit int) string {
595	ids := window(idList(subjectIndex, subject), offset, limit)
596	var b strings.Builder
597	b.WriteString("[")
598	first := true
599	for _, id := range ids {
600		r, ok := getReview(id)
601		if !ok || r.Hidden {
602			continue
603		}
604		if !first {
605			b.WriteString(",")
606		}
607		b.WriteString(reviewJSON(r))
608		first = false
609	}
610	b.WriteString("]")
611	return b.String()
612}
613
614// GetCommentsJSON returns a JSON array of a review's non-hidden comments (paginated).
615func GetCommentsJSON(reviewID uint64, offset, limit int) string {
616	ids := window(idList(commentIndex, strID(reviewID)), offset, limit)
617	var b strings.Builder
618	b.WriteString("[")
619	first := true
620	for _, id := range ids {
621		c, ok := getComment(id)
622		if !ok || c.Hidden {
623			continue
624		}
625		if !first {
626			b.WriteString(",")
627		}
628		b.WriteString(commentJSON(c))
629		first = false
630	}
631	b.WriteString("]")
632	return b.String()
633}
634
635// GetSubjectSummaryJSON returns {"count":N,"average":A,"sum":S} over non-hidden,
636// non-deleted reviews for a subject. Average is integer-rounded.
637func GetSubjectSummaryJSON(subject string) string {
638	ids := idList(subjectIndex, subject)
639	var sum, n int64
640	for _, id := range ids {
641		r, ok := getReview(id)
642		if !ok || r.Hidden || r.Deleted {
643			continue
644		}
645		sum += int64(r.Rating)
646		n++
647	}
648	avg := int64(0)
649	if n > 0 {
650		avg = (sum + n/2) / n // round to nearest
651	}
652	return ufmt.Sprintf(`{"count":%d,"average":%d,"sum":%d}`, n, avg, sum)
653}
654
655// GetReputation returns the net likes−dislikes reputation for an address.
656// Returns 0 for unknown addresses. Queried via vm/qeval (bare int64).
657func GetReputation(addr string) int64 { return getReputation(addr) }
658
659// GetFlaggedJSON returns a JSON array of flagged target IDs for the mod dashboard,
660// paginated. IDs are returned as bare integers (not quoted strings).
661// Uses early-stop iteration to avoid loading the entire tree.
662func GetFlaggedJSON(offset, limit int) string {
663	limit = clampLimit(limit)
664	if offset < 0 {
665		offset = 0
666	}
667	var ids []uint64
668	skipped := 0
669	flaggedIDs.Iterate("", "", func(key string, _ interface{}) bool {
670		if skipped < offset {
671			skipped++
672			return false
673		}
674		id, _ := strconv.ParseUint(key, 10, 64)
675		ids = append(ids, id)
676		return len(ids) >= limit // stop once collected enough
677	})
678	var b strings.Builder
679	b.WriteString("[")
680	for i, id := range ids {
681		if i > 0 {
682			b.WriteString(",")
683		}
684		b.WriteString(strID(id))
685	}
686	b.WriteString("]")
687	return b.String()
688}
689
690// Render — human gnoweb view.
691// "" → home (total count); "s/<subject>" → subject review list; else 404.
692func Render(path string) string {
693	if path == "" {
694		return ufmt.Sprintf("# Memba Reviews\n\nOn-chain web-of-trust. %d reviews total.\n", reviews.Size())
695	}
696	if strings.HasPrefix(path, "s/") {
697		subject := strings.TrimPrefix(path, "s/")
698		var b strings.Builder
699		b.WriteString("# Reviews for " + sanitizeForRender(subject) + "\n\n")
700		written := false
701		renderedCount := 0
702		for _, id := range idList(subjectIndex, subject) {
703			if renderedCount >= MaxPageLimit {
704				break
705			}
706			r, ok := getReview(id)
707			if !ok || r.Hidden || r.Deleted {
708				continue
709			}
710			b.WriteString(ufmt.Sprintf("**%d/5** by %s\n\n%s\n\n---\n",
711				r.Rating, r.Author.String(), sanitizeForRender(r.Body)))
712			written = true
713			renderedCount++
714		}
715		if !written {
716			b.WriteString("No reviews yet.\n")
717		}
718		return b.String()
719	}
720	return "# 404\n"
721}