StockLive · SEO tags & dynamic OG cards
Source: extracted from Intellectaco/kuickr.co (SeoMeta, ContentMeta, MetaTagsHelper, OgCard, OgController) · Target: StockLive (mallpup_bidding) · Status: plan · Date: 2026-08-10
Port Kuickr's per-content social identity pattern into StockLive so shared auction, sale, blog, and landing links unfurl with that page's title/description/image — not a generic StockLive default — while keeping the existing
meta-tagsgem.
At a glance
| Layer | Kuickr today | StockLive today | Port decision |
|---|---|---|---|
| Tag emission | Hand-rolled MetaTagsHelper#meta_tags
|
meta-tags gem + display_meta_tags
|
Keep the gem |
| Controller API | SeoMeta#set_meta(...) |
Ad-hoc set_meta_tags in ~25 views |
Thin concern / PORO that calls set_meta_tags
|
| Content identity |
ContentMeta + Node#share_*
|
Scattered view locals + GCS hero URLs | Domain meta resolvers (Sale / Blog / Auction / Landing) |
| Dynamic OG PNG |
OgCard SVG→PNG via libvips |
Real images when present; else static fallbacks | Add Stocklive::OgCard for entities without a good photo |
| Endpoint |
GET /og/n/*ref + /og/s/:short_id
|
None |
GET /og/:kind/:id.png (public, cached, noindex) |
| Defaults | /images/og-default.png |
Per-surface fallbacks (blog imprint, GCS heroes) | Keep photo-first; generate card only as last resort |
How Kuickr builds SEO + OG (extracted)
1. Request → identity (SeoMeta)
Included on ApplicationController. Controllers call:
set_meta(title:, description:, image:, url:, type:, noindex:)
# or set_article_meta / set_profile_meta / set_noindex
That fills @page_title, @page_description, @page_image, @page_url, @page_type, @noindex.
Static marketing pages (PagesController) set explicit copy + canonical URL per action.
2. Content-derived identity (ContentMeta)
Hosted docs/spaces don't hardcode strings. Resolvers pick the best available fields:
| Method | Title | Description | Image |
|---|---|---|---|
set_page_meta(node, folder) |
node.title · org.label |
HTML <meta description> → content excerpt → folder/org fallback |
Author og:image → dynamic /og/n/... if public |
set_folder_meta |
folder · org | Folder blurb | (default) |
set_space_meta |
org · Kuickr | Space blurb | (default) |
set_public_profile_meta |
display name | bio / default | text-only unfurl |
meta_excerpt strips markdown/HTML noise and truncates (~200 chars).
3. Share fields on the model (Node)
-
share_title— humanizes slug-like titles -
share_description— prefers HTML meta description, else game/doc fallbacks -
html_og_image/html_meta_description— regex over the first 4KB of HTML source
Priority for og:image: author-supplied URL wins → else dynamic card URL for publicly visible content.
4. Tag rendering (MetaTagsHelper)
Layouts call <%= meta_tags %>. Emits:
-
<title>with site suffix description-
canonicalwhen URL set - Open Graph:
og:site_name,og:title,og:description,og:type,og:image,og:url - Twitter:
summary_large_image+ title/description/image -
robots: noindex,nofollowwhen flagged
Relative image paths are absolutized against the request host. Missing image → DEFAULT_OG_IMAGE.
5. Dynamic card image (OgCard + OgController)
Facade (app/facades/og_card.rb):
- Fixed 1200×630 (OG standard)
- Themes:
:doc(Winter light) vs:game(NOCTURNE dark) - Builds SVG (brand mark, kicker, title wrap, description wrap, footer/maker, domain)
- Renders PNG via
Vips::Image.new_from_buffer(svg, ...)(image_processing/ruby-vips)
Endpoint:
GET /og/n/<org>/<folder>/<path>.png → OgController#show (public nodes only)
GET /og/s/:short_id → OgController#share (share-link auth)
- Skips auth; sets
X-Robots-Tag: noindex, nofollow - Cache key
og:#{node.id}:#{node.updated_at.to_i}, TTL 1 day,Cache-Control: public, max-age=86400 - On render failure → 404 (unfurl falls back to static default)
6. Product rules worth keeping
- Rich OG even when noindex — unfurls ≠ search indexing.
- Photo / author image beats generated card.
- Private content must not leak via OG endpoints (public gate / share-token gate).
- Broken card must never break the page share — soft-fail the image endpoint.
- Canonical URL is the real page URL, not a temporary share token URL.
StockLive gap
StockLive already ships solid pieces:
-
meta-tagsgem; layouts calldisplay_meta_tags site: "StockLive" - View-level
set_meta_tagson landing pages, blogs, auctions, sales (sales/_metatags) - Real images when available (
blog_og_image, sale thumbnails, GCS landing heroes) -
image_processingalready in the Gemfile (same stack Kuickr uses for vips)
Gaps vs Kuickr:
- No shared controller/PORO API — every view hand-builds title/description/og/twitter hashes (drift + missed twitter cards).
- No dynamic generated card when an entity has no photo (auctions list, past results, thin sale records).
- No central fallback OG default for the whole site.
-
Inconsistent twitter card type (
summaryvssummary_large_image). - Auth/dashboard pages may still be indexable unless explicitly noindexed.
Implementation plan for StockLive
Principles (map to WARP / .cursorrules)
- Prefer POROs / facades (
Stocklive::PageMeta,Stocklive::OgCard) over fat helpers. - Controllers (or a thin concern) set identity; views stay free of SEO hash boilerplate where practical.
- Keep
meta-tagsgem as the emitter — do not port Kuickr's hand-rolled helper. - Composition: one resolver per domain concept (
SaleMeta,BlogMeta,LandingPageMeta,AuctionIndexMeta).
Slice 0 — Contract (1 PR)
Define the page-identity shape every public surface must fill:
# Conceptual — not a gem API
{
title: String,
description: String, # ≤ ~155–200 chars
canonical: String, # absolute URL
type: "website" | "article",
image: String | nil, # absolute URL; nil → generated card or site default
noindex: Boolean
}
Add site defaults in an initializer or PORO constants:
SITE_NAME = "StockLive"DEFAULT_DESCRIPTION-
DEFAULT_OG_IMAGE(brand asset, 1200×630) TWITTER_CARD = "summary_large_image"
Acceptance: document the priority chain attached photo → GCS/static hero → generated OG card → site default.
Slice 1 — Stocklive::PageMeta + concern (1 PR)
module SeoMeta
def apply_page_meta(meta) # meta responds to title/description/canonical/image/type/noindex
set_meta_tags(
title: meta.title,
description: meta.description,
canonical: meta.canonical,
og: {
title: meta.title,
description: meta.description,
type: meta.type,
url: meta.canonical,
image: meta.image || default_or_og_card(meta),
site_name: "StockLive"
},
twitter: {
card: "summary_large_image",
title: meta.title,
description: meta.description,
image: meta.image || default_or_og_card(meta)
},
noindex: meta.noindex
)
end
end
Migrate one surface end-to-end first (recommend Blog show — already has blog_og_image tests):
- Controller or view calls
apply_page_meta(Blogs::PageMeta.for(@blog)) - Assert title, description, canonical, og:url, og:image, twitter:card in controller tests
Then migrate Sales (sales/_metatags), Auctions indexes, Landing pages in follow-up PRs (same helper, different resolvers).
Slice 2 — Domain meta resolvers (1–2 PRs)
Declarative POROs, DI-friendly, no DB writes:
| Resolver | Inputs | Title / description / image |
|---|---|---|
Blogs::PageMeta |
Blog | title, short_description, header blob or fallback |
Sales::PageMeta |
Sale + Saleyard | sale.name/description, thumbnail |
Auctions::IndexMeta |
filter context | index copy, hero or generated card |
LandingPages::PageMeta |
route key / CMS data | existing hardcoded strings centralized |
Auctions::PastSalesResultsMeta |
— | current past-results copy + hero |
Rules:
- Descriptions truncated to a shared constant
- Canonical always the public route helper URL
-
noindex: truefor auth, portal internals, draft blogs, manage engine
Slice 3 — Dynamic OG cards (1–2 PRs)
Facade Stocklive::OgCard (noun-named, mirrors Kuickr):
- Themes keyed by kind:
:sale,:auction,:blog,:landing(StockLive brand tokens — not Kuickr Winter/NOCTURNE) - SVG template: StockLive mark + kicker + title (2-line wrap) + description (2-line) + footer (saleyard / category / stocklive.com.au)
-
pngvia ruby-vips; skip tests gracefully if libvips missing locally (same pattern as Kuickr'sOgCardTest)
Controller OgController:
GET /og/:kind/:id.png
# kind ∈ sale|blog|auction|landing
- Public only;
X-Robots-Tag: noindex - Cache
og:#{kind}:#{id}:#{updated_at.to_i}for 1 day - Rescue → 404 (caller already has static default in meta)
Wire og:image only when resolver image is blank:
og_card_url_for(kind: :sale, id: sale.id)
# → https://stocklive.../og/sale/123.png
Do not generate cards when a real photo exists (sale thumbnail, blog header, landing hero).
Slice 4 — Coverage + hardening (1 PR)
- Inventory remaining public GET routes; ensure each sets meta via Slice 1 API.
- Standardize twitter to
summary_large_image. -
set_noindexon dashboard / manage / passwordless login flows if missing. - Integration tests: sample unfurl tags for Blog, Sale, one Landing, Auctions index, Past Sales Results.
- Optional: Facebook Sharing Debugger / Slack paste checklist in the PR body.
Out of scope (later)
- Per-page CMS fields for OG overrides beyond existing content
- Pre-generating and storing PNGs in GCS (on-demand + cache is enough v1)
- Schema.org JSON-LD (separate SEO track)
- Copying Kuickr's sandboxed HTML / share-link OG gates (StockLive is not a hosted-HTML product)
Suggested delivery sequence
| Order | Slice | Outcome |
|---|---|---|
| 1 | Contract + defaults | One identity shape + site default image |
| 2 |
SeoMeta + Blog migration |
Pattern proven with tests |
| 3 | Sale + Auction + Landing resolvers | View boilerplate collapses |
| 4 |
OgCard + /og/:kind/:id.png
|
Thin entities unfurl with brand cards |
| 5 | Hardening / noindex / inventory | No public page left on generic defaults only |
File sketch (target)
app/controllers/concerns/seo_meta.rb
app/controllers/og_controller.rb
app/models/stocklive/page_meta.rb # value object
app/models/blogs/page_meta.rb
app/models/sales/page_meta.rb
app/models/auctions/index_meta.rb
app/models/landing_pages/page_meta.rb
app/facades/stocklive/og_card.rb # or app/models/stocklive/og_card.rb
config/routes.rb # get "/og/:kind/:id", …
public/images/og-default.png # brand 1200×630
test/integration/og_card_test.rb
test/models/.../page_meta_test.rb
Verification checklist
- Shared link to a sale with thumbnail still uses the thumbnail (not generated card)
-
Shared link to a sale without thumbnail hits
/og/sale/:id.pngand returns PNG -
Blog show keeps existing
blog_og_imagebehaviour; card only if no header + no fallback policy says so - Landing pages keep GCS heroes; meta hashes move out of ERB
-
Auth/manage pages emit
noindexwhere appropriate - CI installs/runs libvips path; local without vips skips OG render tests
- Slack / iMessage / X unfurl shows title + description + large image
Source map (kuickr.co)
| Concern | Path |
|---|---|
| Controller API | app/controllers/concerns/seo_meta.rb |
| Content resolvers | app/controllers/concerns/content_meta.rb |
| Tag emitter | app/helpers/meta_tags_helper.rb |
| Card facade | app/facades/og_card.rb |
| Image endpoint | app/controllers/og_controller.rb |
| Routes |
config/routes.rb (og_image, og_share_image) |
| Share fields |
app/models/node.rb (share_title, share_description, html_og_image) |
| Spec notes |
docs/kuickr/plans/19-hosted-page-hardening.md (OG section) |
| Tests |
test/integration/og_card_test.rb, content_meta_test.rb, game_og_meta_test.rb
|
Plan authored from a read of kuickr.co main; adapt brand tokens and resource kinds for StockLive — do not copy Kuickr Winter/NOCTURNE themes into product chrome.
Note: MCP write to
stocklive/*folders was denied for this session (org/folder create not available). This page landed in Inbox — move it intostocklive/seo-ogorstocklive/docs/plans/when you have Space write access.