Documentation
¶
Overview ¶
Package render provides markdown to HTML rendering using goldmark.
Index ¶
- Constants
- Variables
- func AvatarHTML(cfg AvatarConfig, initial string, sizePx int) string
- func LoadLocalCommentContent(dataDir, commentURL string) string
- func LoadOrFetchCommentHTML(dataDir, commentURL string, ttl time.Duration) (string, bool)
- func LoadOwnCommentContent(dataDir, commentURL string) string
- func MarkdownToHTML(markdown string) (string, error)
- func MarkdownToPlainText(markdown string, maxLen int) string
- func StripHTMLComments(s string) string
- func StripLeadingTitleHeading(body, title string) string
- func TitleStartsFirstSentence(title, body string) bool
- type AvatarConfig
- type PageConfig
- type PageRenderer
- func (r *PageRenderer) PublishStream(postSourceRelPath string) error
- func (r *PageRenderer) RenderAll(force bool) (*RenderStats, error)
- func (r *PageRenderer) RenderArchive() error
- func (r *PageRenderer) RenderFile(path string, fileType string, force bool) (string, bool, error)
- func (r *PageRenderer) RenderIndex() error
- func (r *PageRenderer) RenderTags() (int, error)
- func (r *PageRenderer) UnpublishStream(postSourceRelPath string) error
- type RenderStats
- type ReplyContextEntry
Constants ¶
const WidgetVersion = "1.4.5"
WidgetVersion is the single source of truth for the current polis widget version. Update this constant when widget.js changes. Theme snippets reference it via the {{widget_version}} template variable, so they never need manual version bumps.
── HANDBOOK TRAIL MARKER ─ foreign-site widget thread ───────────────── This constant is what every rendered tenant page references in its <script src="https://{{base_domain}}/widget-{{widget_version}}.js"> tag. Bump this when widget/widget.js changes; the hosted server's serveWidgetJS 301-redirects old version URLs to the current one for old HTML. Tour: github.com/vdibart/polis-cli/blob/main/docs/handbook/foreign-site-widget.md Map: github.com/vdibart/polis-cli/blob/main/AGENTS.md ───────────────────────────────────────────────────────────────────────
Variables ¶
var BlessedCacheFiller func(dataDir, commentURL string) bool
BlessedCacheFiller is the render-time read-through for a blessed-comment cache MISS, installed by the webapp at startup. It fetches the comment from its author and populates the isolated blessed cache THROUGH cache.Ingest (the same verify-and-gate + provenance plumbing Rosie uses), returning true when it wrote a cache entry (so LoadLocalCommentContent re-reads + renders it). It must NOT block page render unboundedly — it is single-flighted and negative-cached by the webapp. nil by default → CLI static render / offline returns a placeholder (empty string) on a miss. See LoadLocalCommentContent.
var DefaultHTTPClient *http.Client
DefaultHTTPClient is an optional shared HTTP client for outbound requests (e.g., fetching reply context during rendering). Set by the calling application to enable connection pooling. If nil, a short-timeout client is created per call.
Functions ¶
func AvatarHTML ¶
func AvatarHTML(cfg AvatarConfig, initial string, sizePx int) string
AvatarHTML renders a <span class="avatar-initial"> for the given avatar config. When cfg.BG is set it applies bg/fg/border and (if present) the SVG pattern as a base64 data-URI background; a pattern blanks the initial (matching nav.js/app.js). When cfg.BG is empty it returns the default unstyled initial (theme CSS styles it). sizePx > 0 sets width/height/ line-height inline so one config renders at any size (stream chip vs settings preview); sizePx == 0 leaves sizing to CSS (the local nav avatar). initial is the already-derived display glyph — the caller chooses its source.
This is the single source of avatar markup, shared by the local site avatar (buildAvatarHTML) and per-author stream enrichment. The JS renderer (shapes/v4/stream.js buildAvatar) mirrors this output; keep them in step.
func LoadLocalCommentContent ¶
LoadLocalCommentContent renders a foreign blessed comment for inline display, reading it ONLY from the isolated blessed-comment cache (.polis/ds/<ds>/pub.polis.core/cache/blessed/<author>/…) through the same goldmark + bluemonday pipeline as per-post rendering. It NEVER reads a public path (comments/ or content/pub.polis.core/comment/): serving one tenant's comment from another tenant's public tree is exactly the Defect-3 violation the cache exists to prevent.
On a cache MISS it read-throughs via BlessedCacheFiller (installed by the webapp), which fetches the comment from its author and populates the cache via the SAME cache.Ingest plumbing Rosie uses — so render-created entries carry a proper provenance sidecar and are never rogue to Rosie's integrity/GC. When the filler is unset (CLI static render / offline), a miss is a graceful empty string. Callers MUST treat "" as "no inline content available" and degrade.
func LoadOrFetchCommentHTML ¶
LoadOrFetchCommentHTML fetches a remote comment's markdown body and renders it to sanitized HTML, reusing the reply-context cache / SSRF gate (replyURLAllowed) / singleflight / 24h disk cache / negative cache via LoadOrFetchReplyContext.
Used by the stream's read-focus mode to show exactly one comment fetched from the commenter's origin site. Returns ("", false) when the comment can't be fetched or carries no body — callers degrade gracefully (render the comment header with author + date, empty body). MarkdownToHTML (goldmark + bluemonday) sanitizes the body server-side before it reaches the client, which is the load-bearing guarantee for rendering unblessed remote comments inline.
func LoadOwnCommentContent ¶
LoadOwnCommentContent renders the SITE OWNER's OWN comment for inline display, reading it from the owner's local public path (the comments/ mount or the content/pub.polis.core/comment/ source). This is the owner's canonical content living in the owner's own tree — reading it here is NOT a foreign-content violation. Callers MUST use this only for comments the owner authored (author domain == the owner's domain); FOREIGN blessed comments go through LoadLocalCommentContent (isolated cache only, never a public path). Returns "" when not found, rendered through the same goldmark + bluemonday pipeline.
func MarkdownToHTML ¶
MarkdownToHTML converts markdown content to HTML. The output is sanitized to remove dangerous elements (script, iframe, etc.) and event handlers while preserving safe structural HTML.
func MarkdownToPlainText ¶
MarkdownToPlainText converts markdown to plain text, truncated to maxLen characters. It renders markdown to HTML, strips all tags and entities, collapses whitespace, and truncates with "..." if the result exceeds maxLen.
func StripHTMLComments ¶
StripHTMLComments removes all HTML comments from rendered output. Idempotent. Safe to call on any string; non-HTML content with no `<!--` substring is returned unchanged after the regex's fast path.
func StripLeadingTitleHeading ¶
StripLeadingTitleHeading removes a leading ATX heading (# / ## / ...) from the markdown body when its text matches the post's frontmatter title (whitespace-trimmed, case-insensitive). v4's focus + sibling templates always render the frontmatter title as an explicit <h1>; without this strip, posts whose markdown leads with the same title produce a visible duplicate.
No-op when title is empty, when the body doesn't lead with a heading, or when the heading text doesn't match — so posts whose first heading is an intentional, distinct subhead are untouched.
func TitleStartsFirstSentence ¶
TitleStartsFirstSentence reports whether the post body's first non-blank line begins with the title text. Powers the stream templates' "hide redundant title chrome" rule (see RenderContext.TitleLinkState): when a post's body leads with a sentence whose opening matches the frontmatter title — common when titles are auto-derived as a truncation of the body's first sentence — the explicit title <h1>/<h3> reads as a duplicate of the body. Hiding the title chrome lets the body absorb the title naturally.
Normalization is deliberately lenient so the check survives small punctuation differences between title and body — curly vs. straight quotes, em-dash vs. hyphen, smart apostrophes — that frontmatter quoting and markdown rendering routinely introduce.
No-op when title is empty or no first text line is found.
CALLERS — every render or response path that emits a (title, body|excerpt) pair to a renderer is responsible for calling this function and propagating the result. The renderer (template, SPA component, etc.) reads the boolean and suppresses the title chrome accordingly. If you are adding a new render path that ships title + body content, add it here:
v3 per-post pages — cli-go/pkg/render/page.go (RenderContext.TitleLinkState) v4 focus-entry SSR — cli-go/pkg/render/stream.go (RenderContext.TitleLinkState) v4 sibling SSR — cli-go/pkg/render/stream.go (siblings_above/below loop) /api/v1/stream/items own-handle scope — buildPostTitleRedundancy (reads markdown body) cross-tenant scope — handleStreamItems inline (uses cached excerpt) /api/feed/grouped — handleFeedGrouped inline (uses cached excerpt)
Surfaces that should consume the flag (renderer-side):
v3/v4 SSR templates — .is-redundant CSS class on title-link wrapper v4 stream.js renderPost — meta.title_redundant → entry-title.is-redundant legacy app.js renderConversationsTabbed — group.post_title_redundant gate
When the title is derived from the body via excerpt-fetch (no markdown source on disk), use the cached excerpt as the body argument — the detector matches on the first non-blank line, which the excerpt preserves. Lower fidelity than reading the full markdown but catches the common auto-titled-post case.
Types ¶
type AvatarConfig ¶
type AvatarConfig struct {
BG string `json:"bg"`
FG string `json:"fg"`
Border string `json:"border"`
BorderW int `json:"border_w"`
Pattern string `json:"pattern"`
PatternColor string `json:"pattern_color"`
}
AvatarConfig is a site's avatar customization. Sourced from the author's .well-known/polis "avatar" block today (moving into the content bundle later) — callers needing a REMOTE author's avatar should go through the LoadOrFetchAuthorAvatar seam rather than reading .well-known directly. The zero value (empty BG) means "no custom avatar" → default initial.
func FallbackAvatarConfig ¶
func FallbackAvatarConfig(domain string) AvatarConfig
FallbackAvatarConfig returns a deterministic hue-based config for an author with no custom avatar. The hue is SERVER-AUTHORITATIVE — it's sent to the client in author_avatar.bg, so the JS side renders it directly rather than recomputing (only the single initial letter is derived client-side). The hue formula matches stream.js avatarFallback / app.js domainToAvatar in spirit; exact byte-parity isn't required since the server is the source.
func LoadOrFetchAuthorAvatar ¶
func LoadOrFetchAuthorAvatar(domain string) (AvatarConfig, bool)
LoadOrFetchAuthorAvatar returns the avatar config for a remote author domain, fetching + caching on miss/expiry. ok=false means the fetch failed (the caller should fall back to FallbackAvatarConfig). A reachable site with no avatar block returns (zero AvatarConfig, true) — cached as a successful "no custom avatar" so we don't re-hit it.
type PageConfig ¶
type PageConfig struct {
DataDir string // Site data directory
CLIThemesDir string // CLI themes directory (fallback)
BaseURL string // Site base URL
RenderMarkers bool // Add snippet markers for editing
// Source/output separation: when MountDir is set, rendered output goes to
// mount dirs (e.g. "posts/") instead of alongside source files in content dirs.
// Empty MountDir falls back to legacy behavior (write next to source).
PostsSourceDir string // e.g. "content/pub.polis.core/post"
PostsMountDir string // e.g. "posts"
CommentsSourceDir string // e.g. "content/pub.polis.core/comment"
CommentsMountDir string // e.g. "comments"
}
PageConfig holds configuration for page rendering.
type PageRenderer ¶
type PageRenderer struct {
// contains filtered or unexported fields
}
PageRenderer renders polis pages using templates.
func NewPageRenderer ¶
func NewPageRenderer(cfg PageConfig) (*PageRenderer, error)
NewPageRenderer creates a new page renderer.
func (*PageRenderer) PublishStream ¶
func (r *PageRenderer) PublishStream(postSourceRelPath string) error
PublishStream incrementally re-renders v4 output after a single post is published or republished. Compared to renderStreamAll (full corpus), PublishStream touches only the affected post + the neighbors whose sibling rail referenced it + the homepage. Per-publish work is bounded by maxStreamSiblings + 2, regardless of corpus size — the design goal of step 3.a.
postSourceRelPath is the index-entry-style path ("content/pub.polis.core/post/YYYYMMDD/slug.md"). The corresponding URL is derived via sourceToMountPath so it matches what loadPublicIndex emits.
Caller is responsible for confirming the tenant's active_shape is v4. This method has no v3 fallback by design — blog-shape tenants continue through their existing render path.
func (*PageRenderer) RenderAll ¶
func (r *PageRenderer) RenderAll(force bool) (*RenderStats, error)
RenderAll renders all posts and comments, and generates the index.
func (*PageRenderer) RenderArchive ¶
func (r *PageRenderer) RenderArchive() error
RenderArchive generates the posts/index.html archive page. No-ops silently if the theme doesn't have a posts.html template.
func (*PageRenderer) RenderFile ¶
RenderFile renders a single file (post or comment) to HTML. Returns the rendered HTML, whether it was rendered (vs skipped), and any error.
func (*PageRenderer) RenderIndex ¶
func (r *PageRenderer) RenderIndex() error
RenderIndex generates the index.html page.
func (*PageRenderer) RenderTags ¶
func (r *PageRenderer) RenderTags() (int, error)
RenderTags renders tag pages and a tag index page. Returns the number of tags rendered, or 0 if no tag template is available.
func (*PageRenderer) UnpublishStream ¶
func (r *PageRenderer) UnpublishStream(postSourceRelPath string) error
UnpublishStream cascades v4 re-renders after a post is removed from the corpus. The post's HTML is deleted; every neighbor in the pad-back zone is re-rendered (their sibling rail may have referenced the removed post via pickStreamSiblings's pad-back-with-newer logic); /index.html is re-rendered.
Caller is responsible for: (a) confirming active_shape is v4; (b) ensuring the index entry has already been removed (e.g. via metadata.RemoveIndexEntry) so loadPublicIndex returns the post-removal corpus.
type RenderStats ¶
type RenderStats struct {
PostsRendered int
PostsSkipped int
CommentsRendered int
CommentsSkipped int
IndexGenerated bool
ArchiveGenerated bool
TagsRendered int
}
RenderStats holds statistics from a render operation.
type ReplyContextEntry ¶
type ReplyContextEntry struct {
Title string `json:"title"`
Excerpt string `json:"excerpt"`
Domain string `json:"domain"`
BodyMD string `json:"body_md,omitempty"`
Published string `json:"published,omitempty"`
FetchedAt string `json:"fetched_at,omitempty"` // RFC3339; empty on legacy entries
}
ReplyContextEntry holds cached metadata about a remote post referenced by a comment. Lower-case fields (Title/Excerpt/Domain) have always been part of the on-disk cache; BodyMD/Published/FetchedAt were added with the stream's `type=comments` thread-entry shape (2026-04-29) and stay omitempty so old cache files keep loading without migration.
func FetchReplyContextFull ¶
func FetchReplyContextFull(htmlURL string) (entry ReplyContextEntry, ok bool)
FetchReplyContextFull fetches a remote post's .md source and extracts title, excerpt, body markdown, and published timestamp from the frontmatter. Same fetch semantics as fetchReplyContext (3s timeout via DefaultHTTPClient, 32KB body cap, fallback to /content/ source path). Failed fetches return ok=false and a zero entry; callers should degrade gracefully (e.g. render comment without inline post body).
SSRF defense: the URL host is validated through dm.ValidateDomain so attacker-supplied `in_reply_to` values can't be used to reach internal services (Redis, Postgres, cloud metadata at 169.254.169.254, Fly's internal DNS, etc.). The render path runs server-side on hosted polis where one poisoned remote comment would otherwise persist in the reply cache.
func LoadOrFetchReplyContext ¶
func LoadOrFetchReplyContext(dataDir, htmlURL string, ttl time.Duration) (ReplyContextEntry, bool)
LoadOrFetchReplyContext returns a cached ReplyContextEntry for the given target URL, fetching + persisting on cache miss or expiry. `ttl` controls how long body data stays valid; pass 0 for "never expire" (only refetch on missing entries). Cache lives at .polis/bundles/pub.polis.core/comments/reply-context-cache.json.
Used by the stream's `type=comments` enrichment path. Failed fetches return ok=false; the cache is NOT written for failures so retries get a fresh attempt next call.
Concurrency: load + save halves are serialized via replyCacheMu (R19-1). The outbound HTTP fetch is NOT held under the mutex — concurrent same-URL fetches still race to write (last write wins on the duplicate), but R19-3 (singleflight) will close that gap. The invariant this mutex preserves: every entry that gets written stays in the cache.
Cache-hit predicate (R19-2): any present entry counts as a hit, regardless of whether BodyMD is populated. The remaining live caller (buildCommentThreadEnrichments in handlers_stream.go) only consumes Title + Excerpt, which partial entries already have. The old `present && entry.BodyMD != ""` predicate was a vestige of buildPostBodies (deleted in commit 174bab8e), which needed full bodies; it now just forces re-fetches that don't help anyone.