# Discourse Rich Previews Theme Component

**URL:** <https://noobish.me/t/discourse-rich-previews-theme-component/795>\
**Category:** Site Development\
**Tags:** discourse, in-development, theme-component\
**Created:** [April 27, 2026, 9:21am UTC](https://noobish.me/t/discourse-rich-previews-theme-component/795 "2026-04-27T09:21:23Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![nOObish](https://noobish.me/user_avatar/noobish.me/noobish/32/13_2.png) [@nOObish](https://noobish.me/u/nOObish)\
**Post date:** [April 27, 2026, 9:21am UTC](https://noobish.me/t/discourse-rich-previews-theme-component/795/1 "2026-04-27T09:21:24Z")

</div>

[preview][https://meta.discourse.org/t/verso-theme/401635](https://meta.discourse.org/t/verso-theme/401635)[/preview] (Remote Discourse Topic)

[Verso Theme - Theme - Discourse Meta](https://meta.discourse.org/t/verso-theme/401635) (Remote Discourse Topic)

[Time To Get Online wiki - The Archive - Tobias Eigen's Discourse](https://discourse.tobiaseigen.org/t/time-to-get-online-wiki/78) (Remote Discourse Topic)

[WikiCredCon 2026 - Events - Tobias Eigen's Discourse](https://discourse.tobiaseigen.org/t/wikicredcon-2026/171) (Remote Discourse Topic)

[What would it take to De-Google as an org? - Learning journal - Tobias Eigen's Discourse](https://discourse.tobiaseigen.org/t/what-would-it-take-to-de-google-as-an-org/268) (Remote Discourse Topic)

[HTML Kitchen Sink](https://noobish.me/t/html-kitchen-sink/770) (Auto Topic)

[preview][Wikipedia - Discourse](https://en.wikipedia.org/wiki/Discourse)[/preview] (Composer Wikipedia)

[Discourse - Wikipedia](https://en.wikipedia.org/wiki/Discourse) (Auto Wikipedia)

[preview][Topic 7 On This Site](https://noobish.me/t/hello-there-i-am-noobish-i-am-simple/7)[/preview] (Composer Topic)

[Hello there. I am nOObish. I am simple.](https://noobish.me/t/hello-there-i-am-noobish-i-am-simple/7) (Auto Topic)

[preview][https://denverit.com](https://denverit.com)[/preview] (Composer External)

[https://denverit.com](https://denverit.com) (Auto External)

[Topic100 - General - Dev.Story.Pub](https://dev.story.pub/t/topic100/100) (Auto Remote Topic)

> **Summary**
>
> ## **Current Repo State — `v3` Branch**
> 
> ## **File Tree (relevant JS/config files)**
> 
> **`javascripts/discourse/`  
> ` api-initializers/`  
> ` discourse-rich-previews.gjs ← main entry point`  
> ` lib/`  
> ` rich-preview-utils.js ← shared utilities + readConfig`  
> ` preview-router.js ← matchPreviewTarget dispatcher`  
> ` preview-renderer.js ← HTML builders for all preview types`  
> ` preview-bbcode.js ← BBCode [preview] tag registration`  
> ` preview-composer-button.js ← composer toolbar button`  
> ` providers/`  
> ` topic-provider.js ← local + remote Discourse topic fetch`  
> ` wikipedia-provider.js ← Wikipedia REST API fetch`  
> ` external-provider.js ← generic external link fetch via proxy`  
> `settings.yml`**
> 
> * * *
> 
> ## **File-by-File Summary**
> 
> ## **`settings.yml`**
> 
> Fully object-schema `preview_providers` (no longer flat). Key settings:
> 
> - **`preview_providers`** — `type: objects` with 4 defaults: `topic`, `remote_topic`, `external`, `wikipedia`. Each has: `key`, `enabled`, `label`, `glyph_mode` (none/icon/emoji), `icon`, `emoji`, `remote_hosts`, `require_https`, `timeout_ms`.
> 
> - **`previews_remote_topic_mode`** — newly added enum (auto\_only / composer\_only / auto\_and\_composer / disabled), mirrors the topic/external/wikipedia mode settings.
> 
> - **`svg_icons`** — `"comment|up-right-from-square|wikipedia-w"` (compact list).
> 
> - Visual: underline, icon, color settings per type.
> 
> - Thumbnail: desktop + mobile placement/size/mode settings.
> 
> - Content: title, excerpt, category, tags, op, publish date, views, reply count, likes, activity — all per desktop/mobile toggle.
> 
> - Area enables: topic list, latest, categories, tags, search, topic page, user profile, other, kanban boards.
> 
> - Filtering: included/excluded tags + classes, excerpt excluded selectors.
> 
> - Wikipedia: base URL, show image, use extract HTML, density desktop/mobile.
> 
> - User preference: `disable_topic_hover_cards` field, admin field resolution.
> 
> - Performance: prefetch enabled/margin, topic cache max.
> 
> - Debug mode.
> 
> * * *
> 
> ## **`rich-preview-utils.js`**
> 
> The central utility and config module. Exports:
> 
> **Constants:** `DELAY_HIDE` (120ms), `VIEWPORT_MARGIN` (8px), `TOOLTIP_ID`, `TOOLTIP_SELECTOR`
> 
> **Config:** `readConfig(settings)` — maps all settings into a normalized config object. Includes all desktop/mobile pairs, provider normalization via `normalizePreviewProviders()`.
> 
> **Provider helpers:** `getPreviewProvider`, `providerEnabled`, `getRemoteTopicProvider`, `getWikipediaProvider`, `providerKeyForTarget`, `renderProviderGlyph`
> 
> **URL parsing:** `parseTopicUrl`, `parseRemoteDiscourseTopicUrl`, `parsePreviewTopicUrl`
> 
> **Link eligibility:** `linkInSupportedArea`, `isEligiblePreviewLink`, `classifyLink`, `isManuallyWrapped`, `autoPreviewEnabled`, `composerPreviewEnabled`, `previewTypeEnabled`, `composerButtonShouldShow`
> 
> **Filtering:** `matchesTagList`, `matchesClassList` (included/excluded rules)
> 
> **Caching:** `getCachedValue`, `setCachedValue` (LRU Map)
> 
> **DOM/text:** `sanitizeURL`, `escapeHTML`, `sanitizeExcerpt`, `safeAvatarURL`, `safeRemoteAvatarURL`, `normalizeTag`, `formatNumber`
> 
> **Viewport:** `createViewportState` — `isMobileLayout()`, `isMobileInteractionMode()`
> 
> **User fields:** `normalizedFieldKeyVariants`, `findTruthyFieldMatch`, `currentUserIsStaffLike`
> 
> **Other:** `getJSON`, `logDebug`, `clamp`, `isElementVisible`, `inCookedPost`, `isCookedPostFragmentLink`, `currentTopicIdFromPage`, `isCurrentTopicLink`
> 
> * * *
> 
> ## **`preview-router.js`**
> 
> Single export: `matchPreviewTarget(link, config)`.
> 
> Priority order:
> 
> 1. `matchWikipediaPreview` — checks `matchesWikipediaTarget`, returns `{ type: "wikipedia", providerKey: "wikipedia", key: "wikipedia:{host}:{pageKey}", ... }`
> 
> 2. `matchTopicPreview` — local (`parseTopicUrl`) → `{ type: "topic", providerKey: "topic" }`, then remote (`parseRemoteDiscourseTopicUrl`) → `{ type: "topic", providerKey: "remote_topic" }`
> 
> 3. `matchExternalPreview` — checks `matchesExternalTarget`, returns `{ type: "external", providerKey: "external", ... }`
> 
> * * *
> 
> ## **`preview-renderer.js`**
> 
> Exports: `buildPreviewHTML`, `buildLoadingPreviewHTML`, `buildErrorPreviewHTML`
> 
> **`buildPreviewHTML`** dispatches by `preview.type`:
> 
> - `"wikipedia"` → `buildWikipediaPreviewHTML`
> 
> - `"external"` → `buildExternalPreviewHTML`
> 
> - `"topic"` → `buildTopicPreviewHTML`
> 
> Internal helpers: `densityFor`, `buildSharedThumbnailHTML` (placement-aware), `buildMetaRow`, `buildMetaItem`, `buildTopicCategoryHTML`, `buildTagsHTML`, `buildAuthorHTML`, `buildExcerptHTML`, `buildTitleHTML`
> 
> Each builder produces a `<article class="topic-hover-card ...">` HTML string. Density class is set per type/mobile.
> 
> * * *
> 
> ## **`providers/topic-provider.js`**
> 
> `createTopicProvider(api, config, topicCache, inFlightFetches)`
> 
> - Checks Ember store for local topic first (`store.peekRecord`)
> 
> - For local: fetches `/t/{id}.json` via `getJSON`
> 
> - For remote: fetches `{origin}/t/{id}.json` via `fetchViaProxy` (`/discourse-proxy-safe?url=...`)
> 
> - Normalizes result via `normalizeTopic` — extracts excerpt, image URL, author, sets `is_remote_discourse_topic` flag
> 
> - LRU cache keyed on `{origin}:topic:{id}`, in-flight deduplication
> 
> * * *
> 
> ## **`providers/wikipedia-provider.js`**
> 
> `createWikipediaProvider(config, previewCache, inFlightFetches)`
> 
> - Two-step fetch: Wikipedia REST search → `summary` API
> 
> - Cache key: `wikipedia:{host}:{title}`
> 
> - Returns: `{ type: "wikipedia", title, excerpt, html (extract_html), imageurl, url, raw }`
> 
> - `fetchWikipediaPreview` sends `Api-User-Agent` header
> 
> - `matchesWikipediaTarget` checks `WIKIPEDIA_HOST_RE` + `/wiki/` pathname
> 
> - `getWikipediaHost` extracts hostname from link href
> 
> - `getWikipediaTitle` decodes URL pathname, replaces underscores
> 
> * * *
> 
> ## **`providers/external-provider.js`**
> 
> `createExternalProvider(config)`
> 
> - `matchesExternalTarget` — excludes: same origin, non-http(s), Wikipedia, local `/t/` paths, non-HTTPS if `require_https`
> 
> - `fetch(target)` → `buildProxyUrl` → `/discourse-proxy-safe?url=...`
> 
> - Response parsed as JSON (if content-type is JSON) or HTML (via `parseExternalHTML` using DOMParser + og:/twitter: meta tags)
> 
> - `normalizeExternalPreview` normalizes title, siteName, excerpt, imageUrl from multiple possible field names
> 
> * * *
> 
> ## **`api-initializers/discourse-rich-previews.gjs`**
> 
> The main initializer (~800+ lines). Key structure:
> 
> **Imports:** All utils, `matchPreviewTarget`, `buildPreviewHTML/Loading/Error`, all three providers, `registerPreviewBBCode`, `registerPreviewComposerButton`
> 
> **Local HTML builders** (topic-specific, in-initializer):  
> `discourseIcon`, `joinMetadataGroups`, `getSiteCategories`, `findCategoryById`, `pick`, `buildThumbnailHTML`, `buildCategoryHTML`, `buildTagsHTML`, `buildBadgesHTML`, `buildTitleHTML`, `buildExcerptHTML`, `buildOpHTML`, `buildPublishDateHTML`, `buildStatsHTML`, `buildMetadataHTML`, `buildMobileActionsHTML`, `buildCardHTML`
> 
> **State:** tooltip DOM element, show/hide timers, abort controllers, caches (topicCache, renderCache, previewCache, inFlightFetches), providers
> 
> **Card lifecycle:**
> 
> - `showCard` → `fetchPreview` → `getRenderedCard` (uses `buildCardHTML` for topic type, `buildPreviewHTML` from renderer for others)
> 
> - `hideCard`, `scheduleShow`, `scheduleHide`
> 
> - `positionTooltip` — desktop: gap-below/above viewport logic; mobile: centered
> 
> **Events:** mouseover/mouseout (desktop), touchstart (mobile), document click (mobile tap-dismiss), scroll, resize
> 
> **Prefetch:** `IntersectionObserver` on eligible links + `MutationObserver` for dynamically added content
> 
> **User opt-out:** `hoverCardsDisabledForUser` — checks `currentUser.custom_fields`, `user_fields`, resolves numeric user field ID via admin API for staff
> 
> **Initialization flow:**
> 
> ```auto
> js
> 
> ```
> 
> **`(async () => {`  
> ` if (await hoverCardsDisabledForUser()) return;`  
> ` bindEvents();`  
> ` applyBodyClasses();`  
> ` api.onPageChange(...);`  
> `})()`**
> 
> * * *
> 
> ## **Key Architectural Notes**
> 
> 1. **Dual renderer path** : Topic previews use the in-initializer `buildCardHTML` (rich, with stats/badges/thumbnail layout options). Wikipedia and external previews use `buildPreviewHTML` from `preview-renderer.js`.
> 
> 2. **Provider system** : All three providers share the same `fetch(target, signal)` interface and in-flight deduplication Map pattern.
> 
> 3. **Config normalization** : `readConfig` in `rich-preview-utils.js` is the single source of truth for all config shapes. `normalizePreviewProviders` merges settings object schema defaults with user overrides.
> 
> 4. **Remote topics** : Routed through `remote_topic` provider key, fetched via `/discourse-proxy-safe` proxy, distinguished from local topics via `isRemote` flag.
> 
> 5. **`settings.yml` is fully objects-schema** : `preview_providers` uses `type: objects` with a defined `schema:` block — no flat legacy fields remain for provider config.
> 
> > **Summary**
> >
> > Current Repo State — external-topics Branch  
> > File Tree (relevant JS/config files)  
> > text  
> > javascripts/discourse/  
> > api-initializers/  
> > discourse-rich-previews.gjs ← main entry point  
> > lib/  
> > rich-preview-utils.js ← shared utilities + readConfig  
> > preview-router.js ← matchPreviewTarget dispatcher  
> > preview-renderer.js ← HTML builders for all preview types  
> > preview-bbcode.js ← BBCode [preview] tag registration  
> > preview-composer-button.js ← composer toolbar button  
> > providers/  
> > topic-provider.js ← local + remote Discourse topic fetch  
> > wikipedia-provider.js ← Wikipedia REST API fetch  
> > external-provider.js ← generic external link fetch via proxy  
> > settings.yml  
> > File-by-File Summary  
> > settings.yml  
> > Fully object-schema preview\_providers (no longer flat). Key settings:
> > 
> > preview\_providers — type: objects with 4 defaults: topic, remote\_topic, external, wikipedia. Each has: key, enabled, label, glyph\_mode (none/icon/emoji), icon, emoji, remote\_hosts, require\_https, timeout\_ms.
> > 
> > previews\_remote\_topic\_mode — newly added enum (auto\_only / composer\_only / auto\_and\_composer / disabled), mirrors the topic/external/wikipedia mode settings.
> > 
> > svg\_icons — “comment|up-right-from-square|wikipedia-w” (compact list).
> > 
> > Visual: underline, icon, color settings per type.
> > 
> > Thumbnail: desktop + mobile placement/size/mode settings.
> > 
> > Content: title, excerpt, category, tags, op, publish date, views, reply count, likes, activity — all per desktop/mobile toggle.
> > 
> > Area enables: topic list, latest, categories, tags, search, topic page, user profile, other, kanban boards.
> > 
> > Filtering: included/excluded tags + classes, excerpt excluded selectors.
> > 
> > Wikipedia: base URL, show image, use extract HTML, density desktop/mobile.
> > 
> > User preference: disable\_topic\_hover\_cards field, admin field resolution.
> > 
> > Performance: prefetch enabled/margin, topic cache max.
> > 
> > Debug mode.
> > 
> > rich-preview-utils.js  
> > The central utility and config module. Exports:
> > 
> > Constants: DELAY\_HIDE (120ms), VIEWPORT\_MARGIN (8px), TOOLTIP\_ID, TOOLTIP\_SELECTOR
> > 
> > Config: readConfig(settings) — maps all settings into a normalized config object. Includes all desktop/mobile pairs, provider normalization via normalizePreviewProviders().
> > 
> > Provider helpers: getPreviewProvider, providerEnabled, getRemoteTopicProvider, getWikipediaProvider, providerKeyForTarget, renderProviderGlyph
> > 
> > URL parsing: parseTopicUrl, parseRemoteDiscourseTopicUrl, parsePreviewTopicUrl
> > 
> > Link eligibility: linkInSupportedArea, isEligiblePreviewLink, classifyLink, isManuallyWrapped, autoPreviewEnabled, composerPreviewEnabled, previewTypeEnabled, composerButtonShouldShow
> > 
> > Filtering: matchesTagList, matchesClassList (included/excluded rules)
> > 
> > Caching: getCachedValue, setCachedValue (LRU Map)
> > 
> > DOM/text: sanitizeURL, escapeHTML, sanitizeExcerpt, safeAvatarURL, safeRemoteAvatarURL, normalizeTag, formatNumber
> > 
> > Viewport: createViewportState — isMobileLayout(), isMobileInteractionMode()
> > 
> > User fields: normalizedFieldKeyVariants, findTruthyFieldMatch, currentUserIsStaffLike
> > 
> > Other: getJSON, logDebug, clamp, isElementVisible, inCookedPost, isCookedPostFragmentLink, currentTopicIdFromPage, isCurrentTopicLink
> > 
> > preview-router.js  
> > Single export: matchPreviewTarget(link, config).
> > 
> > Priority order:
> > 
> > matchWikipediaPreview — checks matchesWikipediaTarget, returns { type: “wikipedia”, providerKey: “wikipedia”, key: “wikipedia:{host}:{pageKey}”, … }
> > 
> > matchTopicPreview — local (parseTopicUrl) → { type: “topic”, providerKey: “topic” }, then remote (parseRemoteDiscourseTopicUrl) → { type: “topic”, providerKey: “remote\_topic” }
> > 
> > matchExternalPreview — checks matchesExternalTarget, returns { type: “external”, providerKey: “external”, … }
> > 
> > preview-renderer.js  
> > Exports: buildPreviewHTML, buildLoadingPreviewHTML, buildErrorPreviewHTML
> > 
> > buildPreviewHTML dispatches by preview.type:
> > 
> > “wikipedia” → buildWikipediaPreviewHTML
> > 
> > “external” → buildExternalPreviewHTML
> > 
> > “topic” → buildTopicPreviewHTML
> > 
> > Internal helpers: densityFor, buildSharedThumbnailHTML (placement-aware), buildMetaRow, buildMetaItem, buildTopicCategoryHTML, buildTagsHTML, buildAuthorHTML, buildExcerptHTML, buildTitleHTML
> > 
> > Each builder produces a HTML string. Density class is set per type/mobile.
> > 
> > providers/topic-provider.js  
> > createTopicProvider(api, config, topicCache, inFlightFetches)
> > 
> > Checks Ember store for local topic first (store.peekRecord)
> > 
> > For local: fetches /t/{id}.json via getJSON
> > 
> > For remote: fetches {origin}/t/{id}.json via fetchViaProxy (/discourse-proxy-safe?url=…)
> > 
> > Normalizes result via normalizeTopic — extracts excerpt, image URL, author, sets is\_remote\_discourse\_topic flag
> > 
> > LRU cache keyed on {origin}:topic:{id}, in-flight deduplication
> > 
> > providers/wikipedia-provider.js  
> > createWikipediaProvider(config, previewCache, inFlightFetches)
> > 
> > Two-step fetch: Wikipedia REST search → summary API
> > 
> > Cache key: wikipedia:{host}:{title}
> > 
> > Returns: { type: “wikipedia”, title, excerpt, html (extract\_html), imageurl, url, raw }
> > 
> > fetchWikipediaPreview sends Api-User-Agent header
> > 
> > matchesWikipediaTarget checks WIKIPEDIA\_HOST\_RE + /wiki/ pathname
> > 
> > getWikipediaHost extracts hostname from link href
> > 
> > getWikipediaTitle decodes URL pathname, replaces underscores
> > 
> > providers/external-provider.js  
> > createExternalProvider(config)
> > 
> > matchesExternalTarget — excludes: same origin, non-http(s), Wikipedia, local /t/ paths, non-HTTPS if require\_https
> > 
> > fetch(target) → buildProxyUrl → /discourse-proxy-safe?url=…
> > 
> > Response parsed as JSON (if content-type is JSON) or HTML (via parseExternalHTML using DOMParser + og:/twitter: meta tags)
> > 
> > normalizeExternalPreview normalizes title, siteName, excerpt, imageUrl from multiple possible field names
> > 
> > api-initializers/discourse-rich-previews.gjs  
> > The main initializer (~800+ lines). Key structure:
> > 
> > Imports: All utils, matchPreviewTarget, buildPreviewHTML/Loading/Error, all three providers, registerPreviewBBCode, registerPreviewComposerButton
> > 
> > Local HTML builders (topic-specific, in-initializer):  
> > discourseIcon, joinMetadataGroups, getSiteCategories, findCategoryById, pick, buildThumbnailHTML, buildCategoryHTML, buildTagsHTML, buildBadgesHTML, buildTitleHTML, buildExcerptHTML, buildOpHTML, buildPublishDateHTML, buildStatsHTML, buildMetadataHTML, buildMobileActionsHTML, buildCardHTML
> > 
> > State: tooltip DOM element, show/hide timers, abort controllers, caches (topicCache, renderCache, previewCache, inFlightFetches), providers
> > 
> > Card lifecycle:
> > 
> > showCard → fetchPreview → getRenderedCard (uses buildCardHTML for topic type, buildPreviewHTML from renderer for others)
> > 
> > hideCard, scheduleShow, scheduleHide
> > 
> > positionTooltip — desktop: gap-below/above viewport logic; mobile: centered
> > 
> > Events: mouseover/mouseout (desktop), touchstart (mobile), document click (mobile tap-dismiss), scroll, resize
> > 
> > Prefetch: IntersectionObserver on eligible links + MutationObserver for dynamically added content
> > 
> > User opt-out: hoverCardsDisabledForUser — checks currentUser.custom\_fields, user\_fields, resolves numeric user field ID via admin API for staff
> > 
> > Initialization flow:
> > 
> > js  
> > (async () =\> {  
> > if (await hoverCardsDisabledForUser()) return;  
> > bindEvents();  
> > applyBodyClasses();  
> > api.onPageChange(…);  
> > })()  
> > Key Architectural Notes  
> > Dual renderer path: Topic previews use the in-initializer buildCardHTML (rich, with stats/badges/thumbnail layout options). Wikipedia and external previews use buildPreviewHTML from preview-renderer.js.
> > 
> > Provider system: All three providers share the same fetch(target, signal) interface and in-flight deduplication Map pattern.
> > 
> > Config normalization: readConfig in rich-preview-utils.js is the single source of truth for all config shapes. normalizePreviewProviders merges settings object schema defaults with user overrides.
> > 
> > Remote topics: Routed through remote\_topic provider key, fetched via /discourse-proxy-safe proxy, distinguished from local topics via isRemote flag.
> > 
> > settings.yml is fully objects-schema: preview\_providers uses type: objects with a defined schema: block — no flat legacy fields remain for provider config.

---

<div class="post-metadata">

**Author:** ![nOObish](https://noobish.me/user_avatar/noobish.me/noobish/32/13_2.png) [@nOObish](https://noobish.me/u/nOObish)\
**Post date:** [October 3, 2026, 7:17pm UTC](https://noobish.me/t/discourse-rich-previews-theme-component/795/2 "2026-10-03T19:17:16Z")

</div>


