Export Page

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-tags gem.

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
  • canonical when 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,nofollow when 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

  1. Rich OG even when noindex — unfurls ≠ search indexing.
  2. Photo / author image beats generated card.
  3. Private content must not leak via OG endpoints (public gate / share-token gate).
  4. Broken card must never break the page share — soft-fail the image endpoint.
  5. Canonical URL is the real page URL, not a temporary share token URL.

StockLive gap

StockLive already ships solid pieces:

  • meta-tags gem; layouts call display_meta_tags site: "StockLive"
  • View-level set_meta_tags on landing pages, blogs, auctions, sales (sales/_metatags)
  • Real images when available (blog_og_image, sale thumbnails, GCS landing heroes)
  • image_processing already in the Gemfile (same stack Kuickr uses for vips)

Gaps vs Kuickr:

  1. No shared controller/PORO API — every view hand-builds title/description/og/twitter hashes (drift + missed twitter cards).
  2. No dynamic generated card when an entity has no photo (auctions list, past results, thin sale records).
  3. No central fallback OG default for the whole site.
  4. Inconsistent twitter card type (summary vs summary_large_image).
  5. 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-tags gem 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: true for 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)
  • png via ruby-vips; skip tests gracefully if libvips missing locally (same pattern as Kuickr's OgCardTest)

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)

  1. Inventory remaining public GET routes; ensure each sets meta via Slice 1 API.
  2. Standardize twitter to summary_large_image.
  3. set_noindex on dashboard / manage / passwordless login flows if missing.
  4. Integration tests: sample unfurl tags for Blog, Sale, one Landing, Auctions index, Past Sales Results.
  5. 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.png and returns PNG
  • Blog show keeps existing blog_og_image behaviour; 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 noindex where 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 into stocklive/seo-og or stocklive/docs/plans/ when you have Space write access.