Skip to content

Latest commit

 

History

History
1421 lines (1098 loc) · 59.7 KB

File metadata and controls

1421 lines (1098 loc) · 59.7 KB

Data Model Deep Dive

OpenLinks is driven by three JSON files:

  • data/profile.json
  • data/links.json
  • data/site.json

The app renders from these files after validation. This document explains what each file controls, what is required, and how to extend safely.

For a complete day-2 audit checklist of every data-driven customization area, use docs/customization-catalog.md.

Recommended CRUD Path

Treat this document as the canonical contract/reference layer, not the default first editing surface.

Recommended order:

  1. Prefer the repo's AI workflows/skills and automation docs for routine CRUD.
  2. Prefer the Studio webapp when the browser-based self-serve editor covers your workflow. For referral editing today, Studio still relies on Advanced JSON rather than first-class referral controls.
  3. Edit data/*.json directly only when you want lower-level control or need the manual fallback path.

Supporting docs:

  • docs/openclaw-update-crud.md
  • docs/ai-guided-customization.md
  • docs/studio-self-serve.md

The three-file contract in this document and the upstream schemas under schema/ are also mirrored by open-links-sites, so breaking changes here can propagate into that downstream project as well.

Guiding Principles

  • Keep core fields in schema-defined keys.
  • Put extensions under custom to avoid collisions.
  • Validate before build.
  • Prefer explicit ordering when you care about exact link sequence.
  • Use rich metadata only where it adds value.

File Responsibilities

File Purpose Required for build Notes
data/profile.json Primary entity identity and bio metadata Yes Stable compatibility path for the primary hero/entity record
data/links.json All rendered links + groups + order Yes Supports simple, rich, and payment cards
data/site.json Theme, UI preferences, quality policy Yes Also controls quality, sharing, and deploy-relevant behavior

profile.json

Schema: schema/profile.schema.json

data/profile.json remains the stable upstream contract path for backward compatibility, but the record itself can represent the primary public entity for the site, including a person or an organization.

Required fields

  • name (string)
  • headline (string)
  • avatar (URI string)
  • bio (string, max 500)

Entity typing

  • entityType (optional string enum: person or organization)
  • When omitted, runtime behavior defaults to person for backward compatibility.
  • Keep using data/profile.json even for organizations; the path is stable across validation, Studio, and downstream consumers.

Avatar materialization behavior

  • profile.avatar remains the source-of-truth URL in data/profile.json.
  • During explicit bun run content:refresh flows, avatar sync fetches and stores a local copy at public/cache/profile-avatar/profile-avatar.<ext>.
  • Avatar sync writes the committed stable manifest data/cache/profile-avatar.json plus the gitignored runtime overlay data/cache/profile-avatar.runtime.json.
  • Runtime rendering uses the local resolved path from the committed avatar manifest, not the raw remote URL.
  • The main profile/site QR also reuses the resolved avatar and automatically pairs it with the site brand mark when both assets are available; there is no separate profile-QR config surface.
  • On fetch failure:
    • cached local avatar is reused when available, or
    • fallback file public/profile-avatar-fallback.svg is used.
  • Force refresh is available via bun run avatar:sync -- --force or OPENLINKS_AVATAR_FORCE=1.
  • All cache-backed remote fetches are governed by data/policy/remote-cache-policy.json plus optional fork-owned additions in data/policy/remote-cache-policy.local.json; adding a new remote host without shared or local policy coverage is a validation error.

Common optional fields

  • entityType
  • location
  • pronouns (primarily relevant for entityType: "person")
  • status
  • profileLinks (array of { label, url })
  • contact (object, supports email, website, plus extensions)
  • custom (extension namespace)

profileLinks remains an optional profile-level field for lightweight identity links, but it is not the source of truth for the shipped Quick Links header strip. Quick Links derive from eligible top-level entries in data/links.json.

Starter profile preset

If you are using the recommended AI or Studio paths, treat the JSON examples below as reference shapes the tools should produce, not as proof that hand-editing is the preferred workflow.

{
  "name": "Your Name",
  "headline": "What you do",
  "avatar": "https://example.com/avatar.jpg",
  "bio": "One to two sentences about your work and interests.",
  "entityType": "person",
  "location": "City, Country",
  "profileLinks": [
    {
      "label": "GitHub",
      "url": "https://github.com/your-handle"
    }
  ],
  "contact": {
    "email": "hello@example.com"
  },
  "custom": {
    "profileVariant": "default"
  }
}

Organization example

{
  "name": "Bright Builds LLC",
  "headline": "Product engineering for ambitious teams",
  "avatar": "https://example.com/logo.png",
  "bio": "We design and ship software systems, automation, and product platforms.",
  "entityType": "organization",
  "contact": {
    "website": "https://example.com",
    "email": "hello@example.com"
  }
}

links.json

Schema: schema/links.schema.json

links.json is where most customization happens.

Root fields

  • links (required array)
  • groups (optional array)
  • order (optional array of link IDs)
  • custom (optional extension object)

Link object required fields

Every item in links must include:

  • id
  • label
  • type (simple, rich, or payment)

url is required for simple and rich links. payment links may omit url if payment.rails is configured.

Link object optional fields

  • icon
  • description
  • group
  • order
  • enabled
  • referral (referral/affiliate/promo disclosure data)
  • metadata (rich card metadata)
  • enrichment (build-time enrichment policy)
  • payment (tips/payment rails + QR settings)
  • custom

Referral disclosures (links[].referral)

Referral support is additive to ordinary URL-based links. Keep type as simple or rich; do not introduce a separate referral link type in your data.

Recommended maintainer path for referral authoring:

  1. Prefer the repo-native AI CRUD docs (docs/openclaw-update-crud.md, docs/ai-guided-customization.md).
  2. Use skills/referral-management/SKILL.md when the work involves reusable families, offer variants, matcher/link shapes, or a shared-vs-fork scope decision.
  3. Use Studio only when the browser-based path fits and Advanced JSON is acceptable for the change.
  4. Edit data/links.json directly only when you intentionally want the lower-level fallback path.

Catalog-backed referral authoring

Treat referral authoring as a layered model:

  1. Shared higher-level catalog: data/policy/referral-catalog.json
  2. Optional fork-owned overlay: data/policy/referral-catalog.local.json
  3. Runtime/render contract: links[].referral

The catalog files are for reusable family, offer, and matcher knowledge. The saved link still resolves to links[].referral at runtime, and manual link fields remain authoritative when they intentionally differ from catalog-backed defaults.

Use the shared catalog when the family or matcher is generic enough to help other forks. Use data/policy/referral-catalog.local.json for fork-specific offers, overrides, or experiments that should stay out of upstream PRs.

Canonical stable fields:

Field Meaning Notes
kind Disclosure umbrella value Allowed values: referral, affiliate, promo, invite
catalogRef Optional pointer to shared catalog data Nested ids may include familyId, offerId, and/or matcherId
visitorBenefit What the visitor gets Optional; either side may be absent
ownerBenefit What the site owner/project gets Optional; broad enough for cash, credit, support, or indirect benefit
offerSummary Short public-facing offer summary Card-friendly summary of the deal
termsSummary Short conditions/caveats summary Keep this factual and condition-oriented
termsUrl Canonical public terms/landing URL Optional quiet verification/source link
code Referral/promo/invite code Optional structured code field
custom Extension namespace Use for link-local extras that should not become shared schema fields

Notes:

  • referral: {} is valid as a soft marker, but bun run validate:data will warn until you add one or more meaningful disclosure fields.
  • kind alone classifies the link but does not count as sufficient disclosure by itself.
  • catalogRef is the higher-level authoring pointer. It does not replace the runtime referral object; it seeds it.
  • One-sided disclosures are valid. You do not need to invent both sides of the offer.
  • For supported profile-family URLs that are acting as referral/promo destinations, use links[].enrichment.profileSemantics: "non_profile" unless you intentionally want profile-style rendering.

Minimal referral marker example

Use this when you want to mark a link as promotional first and fill in fuller disclosures after review:

{
  "id": "support-offer",
  "label": "Supporter Offer",
  "url": "https://example.com/support",
  "type": "simple",
  "icon": "globe",
  "description": "Support offer landing page",
  "referral": {}
}

This is valid, but it intentionally triggers a warning until you add real disclosure content.

Two-sided referral example

{
  "id": "coffee-club",
  "label": "Coffee Club",
  "url": "https://example.com/coffee?ref=openlinks",
  "type": "simple",
  "icon": "globe",
  "description": "Subscription coffee delivery",
  "referral": {
    "kind": "promo",
    "visitorBenefit": "Get 20% off your first order",
    "ownerBenefit": "Supports the project",
    "offerSummary": "Save on your first bag of coffee.",
    "termsSummary": "New customers only. Cannot be combined with other offers.",
    "termsUrl": "https://example.com/referral-terms",
    "code": "OPENLINKS"
  }
}

Supported-family non-profile referral example

{
  "id": "cluborange-referral",
  "label": "Join Club Orange",
  "url": "https://app.cluborange.org/pryszkie",
  "type": "rich",
  "icon": "cluborange",
  "description": "Bitcoin social club",
  "enrichment": {
    "enabled": true,
    "profileSemantics": "non_profile"
  },
  "referral": {
    "kind": "referral",
    "ownerBenefit": "Supports the project",
    "offerSummary": "Use this signup link to support OpenLinks while joining Club Orange."
  }
}

Catalog-backed referral example

Use this when a saved link should inherit shared family/offer defaults while keeping one or two manual link-level disclosures authoritative:

{
  "id": "cluborange-referral",
  "label": "Join Club Orange",
  "url": "https://signup.cluborange.org/co/pryszkie",
  "type": "rich",
  "icon": "cluborange",
  "description": "Bitcoin social club",
  "enrichment": {
    "enabled": true,
    "profileSemantics": "non_profile"
  },
  "referral": {
    "catalogRef": {
      "offerId": "club-orange-signup"
    },
    "ownerBenefit": "Supports the project",
    "termsUrl": "https://www.cluborange.org/signup"
  }
}

Manual, catalog, and generated referral precedence

Short rule:

  • Manual links[].referral fields win.
  • Catalog data seeds the runtime referral object.
  • Generated referral data only fills remaining blanks.

Deeper behavior:

  • Manual, catalog, and generated values merge field-by-field rather than replacing the whole referral object.
  • Catalog data may come from an explicit catalogRef or a deterministic matcher hit when the link does not point at a catalog entry directly.
  • Generated data is allowed to fill empty visitorBenefit, ownerBenefit, offerSummary, termsSummary, termsUrl, or code fields when public enrichment found something useful and neither manual data nor the catalog already supplied them.
  • Generated data is assistive, not authoritative. Maintain the saved manual disclosure whenever a generated phrase is incomplete, stale, jurisdiction-specific, or simply worse than the human-authored text.
  • If manual and generated referral fields disagree, validation surfaces a drift warning instead of silently picking the generated text.
  • If you need a fork-only catalog override, prefer data/policy/referral-catalog.local.json over turning the shared upstream catalog into a personalized file.

Generated referral fields maintainers may see

Generated referral output and reports can include a few extra fields beyond the authoring contract:

  • completeness: full, partial, or none
  • provenance: which referral fields came from manual, catalog, or generated sources
  • catalog: resolved catalog contribution details such as family, offer, matcher, and whether the match came from an explicit ref or matcher lookup
  • originalUrl
  • resolvedUrl
  • strategyId
  • termsSourceUrl

You usually do not need to author these by hand. They exist to make enrichment/debug output transparent and auditable.

Treat public extraction as assistive:

  • headline-like public promo copy may populate offerSummary
  • termsSummary is intentionally stricter and may stay blank
  • public extraction should help you author faster, not replace your judgment

For script-backed verification and warning interpretation after changing a referral link, use docs/social-card-verification.md.

Downstream note:

  • links[].referral remains the additive runtime/render contract mirrored by open-links-sites, so downstream maintainers should review the referral contract notes when bumping upstream pins.
  • data/policy/referral-catalog.json is also downstream-visible because it lives on the shared policy path; changes there deserve the same compatibility review as other shared policy data.
  • data/policy/referral-catalog.local.json is the fork-owned overlay side of the model and should not be treated as shared upstream contract data.
  • Later referral card UI-only changes are lower-risk for downstream than contract/schema, policy, or script-entrypoint changes.

Quick Links behavior

The profile header Quick Links strip is derived behavior from existing eligible links[] entries. It is not backed by profileLinks and does not use a separate manual registry.

Current shipped behavior:

  • derives from enabled top-level social/profile links in data/links.json
  • only considers supported social/profile-style destinations
  • keeps one winner per platform
  • uses a locked platform-priority order first, then existing content order
  • renders as an icon-first strip above the profile action bar when eligible links exist
  • disappears entirely when no eligible Quick Links exist

The current tie-break path for duplicate same-platform links is links[].custom.quickLinks.canonical=true, which only resolves conflicts inside the same platform and does not force inclusion outside the default eligibility rules.

Quick Links are intentionally constrained by default in the current release:

  • there is no dedicated Quick Links registry
  • there is no separate global Quick Links config surface yet
  • deeper ordering, visibility, or color-mode controls are future work

This is a renderer-level behavior only. It does not change the upstream open-links-sites data/schema contract because it consumes the existing links[] shape rather than introducing a new shared data surface. For broader downstream compatibility notes, see docs/downstream-open-links-sites.md.

Icon resolution behavior

Link icons resolve using this precedence:

  1. links[].icon alias match from src/lib/icons/known-sites-data.ts
  2. URL domain match (exact or subdomain) from the same static registry
  3. Optional single-step remap through site.ui.brandIcons.iconOverrides
  4. Generic fallback glyph

If links[].icon is unknown, validation emits a warning and runtime still attempts domain-based resolution.

Known-site logo rendering is contrast-aware by default: brand color is preferred, and fallback palette adjustments are applied when needed to keep icons visible.

Simple card example

{
  "id": "github",
  "label": "GitHub",
  "url": "https://github.com/your-handle",
  "type": "simple",
  "icon": "github",
  "description": "Code, experiments, and OSS",
  "enabled": true,
  "custom": {}
}

Rich card example

{
  "id": "project-home",
  "label": "Project",
  "url": "https://example.com/project",
  "type": "rich",
  "icon": "github",
  "description": "Project landing page",
  "enabled": true,
  "metadata": {
    "title": "Project title",
    "description": "One-line project summary",
    "image": "https://example.com/preview.png",
    "handle": "project-owner",
    "sourceLabel": "example.com"
  },
  "enrichment": {
    "enabled": true
  },
  "custom": {}
}

Minimal social profile starter

For supported profile links, start with the link shell and let build-time enrichment plus the committed public/authenticated caches fill in the richer metadata:

{
  "id": "github",
  "label": "GitHub",
  "url": "https://github.com/pRizz",
  "type": "rich",
  "icon": "github",
  "description": "Code, experiments, and open-source projects",
  "enabled": true,
  "enrichment": {
    "enabled": true
  }
}

When enrichment succeeds, runtime can reuse the normalized metadata for avatar-first profile rendering, handle display, audience metrics, follower-history snapshots, and card analytics availability without requiring manual metadata in data/links.json. The same identity metadata now also feeds QR center badges for non-payment links: when runtime resolves both a primary identity image (metadata.profileImage first, then a distinct metadata.image) and a known site identity from links[].icon or the URL, the QR code automatically renders a two-item side-by-side badge with both identities. No extra JSON configuration is required.

If you are using AI workflows or Studio for CRUD, this is the reference shape to target. Directly editing data/links.json is the manual fallback path.

Grouping and ordering behavior

OpenLinks supports grouped or flat list presentation.

Grouping

  • Set group on each link.
  • Define matching group objects in groups.
  • Group labels are rendered depending on site.ui.groupingStyle.

Group example:

"groups": [
  { "id": "social", "label": "Social", "order": 1 },
  { "id": "work", "label": "Work", "order": 2 }
]

Ordering precedence

Rendering order resolves using this precedence:

  1. Explicit order array at root (links.order).
  2. Per-link numeric order values.
  3. Input order fallback.

This supports:

  • fully curated order,
  • partially curated order,
  • or default natural order.

Rich metadata and enrichment

Rich links can include manual metadata and/or generated metadata.

Payment links and rails

Payment support is available in two ways:

  1. Dedicated payment cards with type: "payment".
  2. Payment metadata on regular simple/rich links via payment (the link upgrades to payment-card rendering at runtime).

payment supports:

  • qrDisplay: always, toggle, hidden
  • primaryRailId
  • effects.enabled: opt the card into decorative special effects
  • effects.effects: optional explicit effect list (particles, lightning-particles, glitter-particles)
  • effects.glitterPalette: gold, ice
  • effects.bombasticity: normalized effect intensity from 0 to 1; 0 disables the decorative layer, the live curve reaches its busiest/fastest presentation by 0.1, and 0.1..1 intentionally plateau at that maximum
  • rails: array of rail objects

Each payment rail keeps two identity concepts separate when needed:

  • rail: the payment network, protocol, or canonical payment rail (lightning, bitcoin, cashapp, etc.)
  • provider: optional branded provider/service/wallet identity for that rail, using a known-site id or alias from src/lib/icons/known-sites-data.ts

Use provider when the payment brand differs from the rail itself, for example Strike on Lightning. If provider is omitted, runtime falls back to existing identity inference from payment.rails[].icon, payment.rails[].url, links[].icon, and links[].url where possible.

Supported rail values:

  • patreon
  • kofi
  • paypal
  • cashapp
  • stripe
  • coinbase
  • bitcoin
  • lightning
  • ethereum
  • solana
  • custom-crypto

Per-rail QR settings (payment.rails[].qr) support:

  • enabled
  • fullscreen: enabled, disabled
  • style: square, rounded, dots
  • foregroundColor, backgroundColor: optional explicit overrides for the QR modules/background
  • logoMode: rail-default, custom, none
  • logoUrl (required when logoMode is custom)
  • logoSize
  • badge.mode: auto, custom, none
  • badge.size: optional center-badge size override using the same normalized scale as logoSize
  • badge.items: up to 2 entries using { "type": "rail" }, { "type": "site", "value": "<known-site-id>" }, or { "type": "asset", "value": "/payment-logos/example.svg" }
  • payload (optional explicit QR payload override)

Payment rails can include explicit app links via payment.rails[].appLinks for wallet/app-specific deep links. When payment.rails[].qr.badge.mode is auto, runtime resolves provider identity in this order:

  1. explicit payment.rails[].provider
  2. known payment.rails[].icon
  3. known payment.rails[].url
  4. known links[].icon
  5. known links[].url

Auto mode composes the provider brand and rail symbol when both resolve and differ. If only the rail identity resolves, or the provider and rail are the same brand, runtime falls back to the normal single-logo behavior for the active logoMode. badge.mode: custom always wins over auto/provider inference. For branded payment cards, that same explicit or inferred provider identity also feeds card chrome and other shared payment identity surfaces. If a new provider should render outside the QR too, register it in the shared known-site/icon registry instead of relying on badge.items.asset. badge.items.asset affects only the QR center badge. It does not change card-shell icon resolution, known-site matching, or other shared identity surfaces. Without explicit payment.rails[].qr.badge.mode or payment.rails[].qr.logoMode overrides, runtime applies the same shared identity logic by default: compose provider/company + rail when both resolve distinctly, otherwise fall back to the normal single-logo behavior for the active/default logo mode. When QR colors are omitted, runtime defaults follow the active theme using --text-primary for QR modules and --surface-panel for the background. When payment.effects.enabled is true and no explicit effect list is provided, runtime defaults to subtle ambient particles for standard payment cards, and to both lightning-particles and gold glitter-particles for cards whose primary rail is Lightning. When payment.effects.bombasticity is omitted, runtime falls back to site.ui.payments.effects.bombasticityDefault, then to the built-in midpoint default of 0.5, which now renders at the maximum live treatment.

Payment example

{
  "id": "support",
  "label": "Support My Work",
  "type": "payment",
  "description": "Tips help me ship more features",
  "payment": {
    "qrDisplay": "always",
    "primaryRailId": "btc",
    "rails": [
      {
        "id": "btc",
        "rail": "bitcoin",
        "address": "bc1qexampleaddress",
        "amount": "0.0005",
        "message": "Thanks for the support",
        "qr": {
          "style": "dots",
          "logoMode": "rail-default",
          "fullscreen": "enabled"
        }
      },
      {
        "id": "patreon",
        "rail": "patreon",
        "url": "https://patreon.com/example"
      }
    ]
  },
  "custom": {}
}

Provider-over-rail example

Use provider when the payment website/company is distinct from the rail itself:

{
  "id": "strike-lightning",
  "label": "Strike Tips",
  "type": "payment",
  "description": "Tip me on Lightning via Strike",
  "payment": {
    "primaryRailId": "lightning",
    "rails": [
      {
        "id": "lightning",
        "rail": "lightning",
        "provider": "strike",
        "label": "Strike Lightning",
        "address": "openlinks@strike.me",
        "qr": {
          "badge": {
            "mode": "auto"
          }
        }
      }
    ]
  }
}

With the example above:

  • card chrome can use Strike identity
  • QR auto-badge mode can compose Strike + Lightning automatically
  • downstream repos only need the upstream sync/pin update unless they want to opt into explicit provider on their own data

Adding future payment providers

To add a future branded provider such as River, Voltage, or Wallet of Satoshi through the same path:

  1. Register the provider once in src/lib/icons/known-sites-data.ts.
  2. Add its graphic in src/lib/icons/site-icon-graphics.ts and src/lib/icons/known-site-icons.tsx.
  3. Add an optional asset in public/payment-logos/ if you want an explicit custom-logo file path.
  4. Use payment.rails[].provider or rely on known icon/URL inference.

No QR resolver branch should be needed for a new provider once the central registration work is done.

Rich image materialization behavior

  • Remote rich-image URLs are source data, but runtime does not render raw remote URLs.
  • During explicit bun run content:refresh flows, images:sync fetches remote rich-link and SEO image candidates and writes:
    • committed baked files in public/cache/content-images/<content-hash>.<ext>
    • committed stable manifest data/cache/content-images.json
    • gitignored runtime overlay data/cache/content-images.runtime.json
  • Runtime rich-card metadata.image values resolve to baked local paths when available.
  • Runtime also localizes metadata.ogImage, metadata.twitterImage, and metadata.profileImage when baked local assets are available.
  • If a link would render as a rich card without a materialized preview image, bun run validate:data (and therefore bun run build) fails with remediation guidance. bun run dev remains Vite-only, so run validation or refresh before local preview after content edits.
  • Header-only revalidation data is kept in the runtime overlay so routine images:sync runs do not rewrite tracked cache files unless the cached asset payload actually changes.
  • Force refresh is available via bun run images:sync -- --force or OPENLINKS_IMAGES_FORCE=1.

Manual metadata (links[].metadata)

Supported keys include:

  • title
  • description
  • profileDescription: profile-authored bio/summary for supported social profile links; when present, this wins over generic description-source rules
  • descriptionSource: fetched (prefer fetched metadata description), manual (prefer top-level links[].description)
  • image: canonical render image used by cards today
  • ogImage: raw Open Graph image candidate when present
  • twitterImage: raw Twitter card image candidate when present
  • profileImage: canonical identity/avatar image
  • handle (canonical username/handle without leading @; runtime renders as @handle)
  • followersCount
  • followersCountRaw
  • followingCount
  • followingCountRaw
  • subscribersCount
  • subscribersCountRaw
  • imageFit
  • mobileImageLayout
  • sourceLabel
    • when this is a host-like custom domain for a known platform, footer rendering clarifies it as Platform · domain (for example Substack · peter.ryszkiewicz.us)
  • sourceLabelVisible
  • enrichmentStatus
  • enrichmentReason
  • enrichedAt
  • custom

Image-role rules:

  • image remains the backward-compatible preview/render image for card rendering.
  • ogImage and twitterImage preserve source provenance separately instead of being folded into image.
  • Generic metadata parsing defaults image to ogImage ?? twitterImage.
  • Platform-specific augmentation may keep low-value placeholders in ogImage/twitterImage for completeness while choosing a different image for rendering.
  • profileImage is independent from all preview/social image roles and may equal image.
  • For supported social-profile links, runtime now backfills image -> profileImage by default when profileImage is missing, unless the platform is explicitly excluded because its image commonly represents preview/banner media instead of an avatar.
  • Exclusion reasons live in shared code, currently src/lib/content/social-profile-fields.ts, rather than a user-edited JSON policy file.

Minimal manual override example:

{
  "id": "x",
  "label": "X",
  "url": "https://x.com/pryszkie",
  "type": "rich",
  "icon": "x",
  "description": "Short updates and project notes",
  "metadata": {
    "handle": "pryszkie",
    "profileDescription": "We the people demand justice for the victims.",
    "sourceLabel": "x.com"
  },
  "enrichment": {
    "enabled": true
  }
}

Runtime notes:

  • profileDescription only changes card copy for supported social profile links. Non-profile links continue to use descriptionSource plus the existing fetched/manual fallback order.
  • followersCount*, followingCount*, and subscribersCount* drive the profile-header metric chips on supported social cards.
  • Those audience fields also feed the follower-history pipeline when a nightly or local bun run followers:history:sync snapshot is taken.
  • links[].enrichment.profileSemantics="non_profile" is the supported way to keep a rich link on generic rich-card behavior even when the URL belongs to a profile-capable site family.

URL-first handle extraction (v1)

  • Runtime and enrichment use URL-only handle extraction (no HTML/meta-tag scraping).
  • Resolution precedence is:
    1. manual links[].metadata.handle
    2. URL-derived handle when supported
  • links[].enrichment.profileSemantics controls whether a rich link participates in profile handling:
    • auto (default): infer from URL family plus the handle rules above
    • profile: require profile semantics when a supported profile can be resolved; validation warns when it cannot
    • non_profile: opt out of profile semantics, handle warnings, profile-header metadata expectations, and avatar-first profile layout
  • Supported extractor families in v1: GitHub, X/Twitter, LinkedIn, Facebook, Instagram, Medium, Substack patterns.
  • If a URL is from a supported family but no handle can be resolved and metadata.handle is missing, validation emits a warning-level handle coverage issue unless links[].enrichment.profileSemantics="non_profile" is set.
  • Handle coverage warnings are non-strict-blocking and do not fail bun run validate:data:strict.

Current profile-card-capable rich-link families include:

  • GitHub: avatar + follower/following counts when public profile HTML exposes them
  • Instagram: avatar + follower/following counts
  • LinkedIn: authenticated-cache-backed avatar-first profile cards, including profileDescription when the cached metadata provides it
  • Medium: avatar-first profile cards with public follower-count augmentation
  • Primal: avatar-first profile cards with public follower/following metrics when available
  • Substack: avatar-first profile cards with subscriber counts and custom-domain support via explicit/manual handle fallback
  • X: avatar-first profile cards with best-effort public follower/following metrics and optional profileDescription
  • YouTube: avatar + subscriber counts
  • Facebook: authenticated-cache-backed avatar-first profile cards without audience-count guarantees in the current pass

Profile styling is still data-driven rather than a separate link type. A supported social profile URL plus the normalized metadata above is what flips a card onto the avatar-first profile path.

Enrichment policy (links[].enrichment)

Per-link controls:

  • enabled
  • profileSemantics: auto (default), profile, or non_profile
  • allowKnownBlocker: explicit override to force-attempt enrichment for a known blocked domain
  • authenticatedExtractor: use committed authenticated cache instead of public enrichment for true auth-required domains (currently LinkedIn and Facebook)
  • authenticatedCacheKey: optional cache-key override (default uses link.id)
  • facebookPageMetrics: opt into Meta Graph Page followers_count capture for Facebook Page links; set enabled: true, pageId, and optionally apiVersion (default v24.0). pageId is the Meta Graph Page ID, which may differ from the numeric ID in a public Facebook URL. Runtime sync reads OPENLINKS_FACEBOOK_PAGE_ACCESS_TOKEN and does not use fan_count or crawler-visible likes as follower history. If Graph returns code 100 / subcode 33, verify the Business Suite Page ID, the token's Page access, and the Page object ID that succeeds in Graph API Explorer.
  • sourceLabel
  • sourceLabelVisible
  • custom

Site-level default enrichment behavior is defined in site.ui.richCards.enrichment.

site.ui.richCards.enrichment supports:

  • enabledByDefault: whether rich links attempt enrichment when link-level enrichment.enabled is omitted.
  • timeoutMs: per-attempt request timeout.
  • retries: retry count after the first attempt.
  • metadataPath: generated metadata output path.
  • reportPath: generated enrichment report path.
  • publicCachePath: committed stable public metadata cache manifest path (default data/cache/rich-public-cache.json), refreshed only by explicit write-cache flows.
  • authenticatedCachePath: authenticated cache manifest path (default data/cache/rich-authenticated-cache.json).
  • authenticatedCacheWarnAgeDays: stale-cache warning threshold in days (default 30, warning-only).
  • failureMode: immediate (default) or aggregate.
    • immediate: stop strict enrichment on first blocking failure.
    • aggregate: process all eligible links, then fail after reporting all blockers.
  • failOn: blocking reasons for strict enrichment. Default: ["fetch_failed", "metadata_missing"].
  • allowManualMetadataFallback: default true. When metadata_missing occurs, any manual links[].metadata.title|description|image downgrades to warning.

Canonical known-blocker policy registry:

  • data/policy/rich-enrichment-blockers.json
  • Schema: schema/rich-enrichment-blockers.schema.json

Canonical public enrichment cache registry:

  • data/cache/rich-public-cache.json
  • schema/rich-public-cache.schema.json
  • local runtime overlay: data/cache/rich-public-cache.runtime.json (gitignored)
  • local runtime overlay schema: schema/rich-public-cache.runtime.schema.json

Canonical authenticated extractor + cache registries:

  • data/policy/rich-authenticated-extractors.json
  • schema/rich-authenticated-extractors.schema.json
  • data/cache/rich-authenticated-cache.json
  • schema/rich-authenticated-cache.schema.json
  • public/cache/rich-authenticated/ (committed local assets)
  • output/playwright/auth-rich-sync/ (diagnostics, gitignored)

Canonical committed image cache registry:

  • data/cache/content-images.json
  • public/cache/content-images/ (committed local assets)
  • data/cache/content-images.runtime.json (gitignored runtime revalidation state)

When an enrichment-enabled rich link URL matches a status=blocked registry entry, enrichment fails early with reason known_blocker unless links[].enrichment.allowKnownBlocker=true is set for that link.

During routine bun run enrich:rich / bun run enrich:rich:strict runs, successful direct/public enrichment uses fetched metadata for the current generated output but only persists volatile revalidation state (etag, lastModified, cacheControl, expiresAt, checkedAt) into the local runtime overlay. The committed stable manifest at data/cache/rich-public-cache.json is refreshed only by explicit write flows such as bun run enrich:rich:strict:write-cache or other dedicated cache-sync commands like bun run public:rich:sync. If fetched metadata drifts while stable writes are disabled, OpenLinks clears runtime freshness for that cache key so stale committed metadata is never treated as fresh on later runs.

If a direct/public fetch fails but a committed public cache entry already exists, enrichment reuses that stale cached metadata as a warning-level fallback. In bun run validate:data:strict, that stale-cache reuse remains non-strict-blocking when the cached metadata is complete and does not require manual fallback; stale cache entries that are incomplete still fail strict validation. No raw public HTML snapshots are committed, and header-only refreshes no longer rewrite tracked cache timestamps.

Built-in public augmentation currently covers Medium (RSS/feed), Rumble (public about-page fetch with avatar/banner separation), Substack (canonical public profile fetch with original source-label preservation), X profiles (oEmbed + avatar), X communities (public OG community metadata), Instagram (public page metadata), and YouTube (public page metadata) without using authenticatedExtractor.

When links[].enrichment.authenticatedExtractor is configured, enrichment uses committed cache entries (reason=authenticated_cache) and fails early with authenticated_cache_missing if cache data/assets are missing or invalid.

bun run content:refresh runs strict enrichment with runtime-only public-cache behavior and fails on configured blocking reasons plus known-blocker policy violations. bun run content:refresh:write-cache intentionally persists stable public-cache updates. bun run dev, bun run build, and bun run build:strict consume committed content without running enrichment or modifying tracked outputs. Temporary emergency local bypass is available with OPENLINKS_RICH_ENRICHMENT_BYPASS=1.

Public follower-history artifacts

OpenLinks can publish append-only follower/subscriber snapshots for the profile-oriented platforms that currently expose audience counts.

Canonical public outputs:

  • public/history/followers/index.json
  • public/history/followers/<link-id>.csv

Update command:

bun run followers:history:sync

History rules:

  • one row is appended per nightly/local snapshot run even if the count is unchanged
  • observed drops are preserved exactly as captured
  • rows stay append-only unless a maintainer manually edits the CSV later
  • each row keeps audit columns: observedAt, linkId, platform, handle, canonicalUrl, audienceKind, audienceCount, audienceCountRaw, source

Current index.json shape:

{
  "version": 1,
  "updatedAt": "2026-03-10T02:29:19.676Z",
  "entries": [
    {
      "linkId": "github",
      "label": "GitHub",
      "platform": "github",
      "handle": "prizz",
      "canonicalUrl": "https://github.com/pRizz",
      "audienceKind": "followers",
      "csvPath": "history/followers/github.csv",
      "latestAudienceCount": 90,
      "latestAudienceCountRaw": "90 followers",
      "latestObservedAt": "2026-03-10T02:29:19.676Z"
    }
  ]
}

Representative CSV:

observedAt,linkId,platform,handle,canonicalUrl,audienceKind,audienceCount,audienceCountRaw,source
2026-03-10T02:29:19.676Z,github,github,prizz,https://github.com/pRizz,followers,90,90 followers,public-cache

The follower-history sync reads the normalized audience metadata that runtime also uses for profile-card header metrics. That means public-augmented profiles, authenticated-cache-backed profiles, and opt-in Facebook Page Graph metrics can contribute history when they expose a primary audience field.

History files are link-scoped rather than platform-scoped. This avoids blending separate entities that share a platform, such as two x profiles or an x profile plus an x community. When adding or changing Instagram, Medium, X, Primal, YouTube, or Facebook Page links that should publish analytics, run bun run public:rich:sync -- --only-link <link-id> for each link before bun run followers:history:sync; Instagram metadata can lag the browser-rendered public counts, X oEmbed enrichment alone does not expose follower counts, and Facebook Page history uses Meta Graph followers_count rather than crawler-visible likes.

Analytics and share surfaces

The social-card system now exposes a few behavior-only surfaces that are driven by the data above rather than separate config keys:

  • Profile header:
    • analytics button appears when follower-history index data is available
    • share button is always present and uses a clean-URL native-share/copy payload
    • vCard download button appears only when site.sharing.vcard.enabled is true
  • Card header row:
    • analytics button appears only for cards that have public follower-history data
    • share button appears on non-payment cards, including cards without history
  • Analytics UI:
    • default range is 30D
    • available presets are 30D, 90D, 180D, and All
    • charts stay mostly separate by platform unless a future charting change can preserve legible multi-axis comparison

Use docs/social-card-verification.md for the current manual QA checklist and automated coverage map for these surfaces.

First-run authenticated setup command:

  • bun run setup:rich-auth (captures only missing/invalid authenticated cache entries)
  • Optional one-link capture: bun run auth:rich:sync -- --only-link <link-id>
  • Force one-link refresh even when cache is valid: bun run auth:rich:sync -- --only-link <link-id> --force
  • Clear cache entries/assets before recapture: bun run auth:rich:clear -- --only-link <link-id> (or --all)

Extractor scaffolding command:

  • bun run auth:extractor:new -- --id <extractor-id> --domains <csv> --summary \"<summary>\"

Local auth wait tuning for one-off LinkedIn flows:

  • OPENLINKS_AUTH_SESSION_TIMEOUT_MS (default 600000)
  • OPENLINKS_AUTH_SESSION_POLL_MS (default 2000)

LinkedIn debug commands:

  • bun run linkedin:debug:bootstrap
  • bun run linkedin:debug:login
  • bun run linkedin:debug:validate
  • bun run linkedin:debug:validate:cookie-bridge

site.json

Schema: schema/site.schema.json

site.json controls display defaults, theme, interaction policy, and quality policy.

Required fields

  • title
  • description
  • theme (active, available)

High-signal sections

sharing

Sharing controls live under top-level site.sharing.

  • badge.enabled: controls whether the generated site badge is published.
  • badge.message: optional badge label override.
  • vcard.enabled: opt-in switch for the profile-header vCard download button.
  • vcard.filename: optional .vcf download filename. When omitted, runtime derives one from profile.name.
  • vcard.fields: optional business-card fields. Supported keys are email, phone, organization, title, role, and note.
  • vcard.include.photo: opt-in switch to embed the current profile avatar as the vCard PHOTO.
  • vcard.include.profileUrl: includes the canonical OpenLinks profile URL by default. Set to false to exclude it.
  • vcard.include.linkIds: explicit allowlist of links[] ids to include as extra vCard URLs.
  • vcard.include.customUrls: extra URL entries as { "label": "...", "url": "https://..." }.

The vCard export is deliberately conservative. Enabling the button exports FN from profile.name and the canonical profile URL by default. Contact details, the profile photo, and extra URLs are exported only when explicitly configured under site.sharing.vcard. Runtime currently emits an Apple-compatible vCard 3.0 file: profile photos are embedded as inline base64 when the resolved avatar is a supported contact image type, and labeled extra URLs use Apple grouped URL labels so Contacts can preserve names like GitHub or Calendar.

Example:

{
  "sharing": {
    "vcard": {
      "enabled": true,
      "filename": "peter-ryszkiewicz.vcf",
      "fields": {
        "email": "hello@example.com",
        "phone": "+15551234567",
        "organization": "Example LLC",
        "title": "Founder",
        "role": "Engineer",
        "note": "Optional business-card note"
      },
      "include": {
        "photo": true,
        "profileUrl": true,
        "linkIds": ["github", "linkedin"],
        "customUrls": [
          {
            "label": "Calendar",
            "url": "https://example.com/book"
          }
        ]
      }
    }
  }
}

The browser builds the .vcf file on demand from bundled site data and the current canonical URL; no generated vCard asset is tracked in git.

theme

  • active: selected theme id
  • available: allowed theme ids

Current theme IDs are resolved by src/lib/theme/theme-registry.ts.

ui

Main presentation controls include:

  • compositionMode: balanced, identity-first, links-first, links-only
  • groupingStyle: subtle, none, bands
  • profileRichness: minimal, standard, rich
  • modePolicy: dark-toggle, static-dark, static-light
  • linkTarget: new-tab-external, same-tab, new-tab-all
  • desktopColumns: one, two
  • density: compact, medium, spacious
  • typographyScale: fixed, compact, expressive
  • typography: optional global/per-theme typography overrides
  • targetSize: comfortable, compact, large
  • cardStyle: standard (default) or glassy; glassy applies subtle translucent, glossy, and reduced-motion-aware effects to public simple, rich, and payment link cards only
  • profileAvatarScale: number between 0 and 4; default 1.5 (avatar size multiplier)
  • profileHeaderAlignment: leading, center, or { "default": "leading", "small": "center" }; when omitted, defaults to leading alignment on larger screens and centered alignment at the existing max-width: 760px mobile breakpoint
  • brandIcons.colorMode: brand, theme
  • brandIcons.contrastMode: auto, always-theme, always-brand
  • brandIcons.minContrastRatio: number between 1 and 21 (default 3)
  • brandIcons.sizeMode: normal, large
  • brandIcons.iconOverrides: optional known-site alias remap map ({ "x": "twitter" })
  • richCards.imageFit: contain (default preserve mode), cover
  • richCards.descriptionSource: fetched (default), manual
  • richCards.descriptionImageRow.default: auto (default) or off for extra rich-profile preview media
  • richCards.descriptionImageRow.sites: optional override map keyed by exact hostnames or known site ids such as substack
  • richCards.descriptionImageRow.placement.default: top-banner (default) or bottom-row
  • richCards.descriptionImageRow.placement.sites: optional placement override map keyed like descriptionImageRow.sites
  • richCards.descriptionImageRow.bannerMinAspectRatio: numeric banner cutoff (default 2)
  • richCards.descriptionImageRow.nonBannerFallback.default: off (default) or compact-end
  • richCards.descriptionImageRow.nonBannerFallback.sites: optional fallback override map keyed like descriptionImageRow.sites
  • richCards.mobile.imageLayout: legacy inline / full-width setting retained for backward compatibility; unified non-payment card layout now ignores it
  • richCards.enrichment.publicCachePath: path to committed public rich-cache manifest
  • richCards.enrichment.authenticatedCachePath: path to authenticated rich-cache manifest
  • richCards.enrichment.authenticatedCacheWarnAgeDays: stale warning threshold for authenticated cache entries
  • richCards.enrichment.failureMode: immediate (default), aggregate
  • richCards.enrichment.failOn: blocking reasons (fetch_failed, metadata_missing)
  • richCards.enrichment.allowManualMetadataFallback: use manual metadata as warning-level fallback when remote metadata is missing
  • payments.qr.displayDefault: always, toggle, hidden
  • payments.qr.styleDefault: square, rounded, dots
  • payments.qr.foregroundColorDefault, payments.qr.backgroundColorDefault: optional site-wide overrides; when omitted, runtime defaults follow the active theme using --text-primary and --surface-panel
  • payments.qr.logoModeDefault: rail-default, custom, none rail-default preserves the single-rail fallback when no site/company identity resolves, while explicit per-rail logoMode still overrides the newer implicit composite default
  • payments.qr.logoSizeDefault
  • payments.qr.fullscreenDefault: enabled, disabled
  • payments.effects.enabledDefault: opt payment cards into special effects by default
  • payments.effects.defaultEffects: optional site-wide effect list (particles, lightning-particles, glitter-particles)
  • payments.effects.glitterPaletteDefault: gold, ice
  • footer.description: optional descriptive footer text
  • footer.ctaLabel: optional CTA button label
  • footer.ctaUrl: optional CTA target URL (defaults to the canonical upstream OpenLinks GitHub repository URL; explicit values still override)
  • footer.prompt.enabled: toggle the bootstrap prompt card in the footer
  • footer.prompt.title: optional prompt section title
  • footer.prompt.explanation: optional short explanation above the prompt text
  • footer.prompt.text: optional copyable bootstrap prompt text (defaults to the canonical generated bootstrap prompt with absolute GitHub doc URLs for this repository)
  • footer.showBuildInfo: toggle the footer build-provenance row (Built <UTC> plus an optional commit link)
  • footer.showLastUpdated: legacy alias for footer.showBuildInfo

Maintainers should keep the markdown OpenClaw prompt snippets synchronized with bun run openclaw:prompts:sync and verify drift with bun run openclaw:prompts:check.

Rich-card policy settings live under ui.richCards.

Description selection precedence for rich links:

  1. links[].metadata.descriptionSource
  2. site.ui.richCards.descriptionSource
  3. default fetched

fetched resolves descriptions as metadata.description -> links[].description -> URL/domain fallback. manual resolves descriptions as links[].description -> metadata.description -> URL/domain fallback.

ui.richCards.imageFit

Controls how rich-card preview images fill media tiles.

  • contain (default): preserves full image content and avoids clipping.
  • cover: fills the media tile and may crop edges.

Resolution precedence:

  1. links[].metadata.imageFit
  2. site.ui.richCards.imageFit
  3. fallback default: contain

Migration note: previous behavior was effectively crop-first (cover). If you want that look globally, set site.ui.richCards.imageFit to cover.

ui.richCards.descriptionImageRow

Controls optional preview media for rich profile cards when metadata.image is distinct from metadata.profileImage.

  • default
    • auto (default): enable profile preview media when the card is rich, profile-oriented, and has a distinct preview image
    • off: never render the extra preview media
  • sites
    • optional override map keyed by exact hostnames (peter.ryszkiewicz.us) or known site ids (substack, github, medium)
  • placement.default
    • top-banner (default): render banner-shaped preview images above the avatar/title block
    • bottom-row: preserve the legacy full-width media row after the description
  • placement.sites
    • optional override map keyed like sites
  • bannerMinAspectRatio
    • default 2
    • preview images below the cutoff are treated as non-banner media
  • nonBannerFallback.default
    • off (default): hide non-banner preview images when placement.default resolves to top-banner
    • compact-end: render non-banner preview images as a compact end-of-card tile in the content column
  • nonBannerFallback.sites
    • optional override map keyed like sites

Resolution precedence:

  1. Exact hostname key from the link URL or host-like source label
  2. Known site id from the link icon/URL
  3. site.ui.richCards.descriptionImageRow.default

Additional notes:

  • The preview media is rich-card only; simple cards ignore it.
  • richCards.imageTreatment: "off" also suppresses it.
  • top-banner qualification is runtime-only and uses the image's natural width/height ratio.
  • Cards without a distinct preview image continue to render only the avatar/header plus description/footer flow.

ui.richCards.mobile.imageLayout

Legacy mobile rich-card image placement setting retained for backward-compatible config/schema support.

  • inline
  • full-width

Current runtime behavior: unified non-payment cards always use the shared lead-left layout across breakpoints, so this setting and links[].metadata.mobileImageLayout no longer affect rendering.

quality.seo image materialization behavior

  • SEO image candidates (socialImageFallback, defaults, profile overrides) are included in images:sync.
  • Starter config uses a generated local site preview at /generated/seo/social-preview.png, produced by bun run social:preview:generate.
  • Runtime SEO tags (og:image, twitter:image) use baked local assets when available.
  • Runtime never falls back to remote SEO image URLs; unresolved remote candidates fall back to /openlinks-social-fallback.png.

ui.brandIcons

ui.brandIcons.colorMode controls icon tinting strategy for known-site logos:

  • brand: uses the registry brand color (default)
  • theme: uses theme text/icon color

ui.brandIcons.contrastMode controls fallback behavior:

  • auto: preserve brand where possible, then auto-adjust to satisfy contrast target
  • always-theme: always use theme-driven glyph color
  • always-brand: always force brand glyph color

ui.brandIcons.minContrastRatio sets the contrast target for icon glyphs against icon chip backgrounds.

ui.brandIcons.sizeMode controls default icon scale:

  • normal: baseline icon size
  • large: moderately larger icons (default)

ui.brandIcons.iconOverrides lets you remap known-site icon identities globally. Remapping applies after base icon resolution and is single-step only.

Example:

{
  "ui": {
    "brandIcons": {
      "iconOverrides": {
        "x": "twitter"
      }
    }
  }
}

ui.footer

Footer content and CTA are configurable from data/site.json.

  • description: descriptive body copy shown in the footer.
  • ctaLabel: button text for the footer CTA.
  • ctaUrl: CTA destination URL (when omitted, runtime uses https://github.com/pRizz/open-links; explicit values still override).
  • prompt.enabled: controls whether the footer renders the copyable bootstrap prompt card.
  • prompt.title: heading shown above the bootstrap prompt.
  • prompt.explanation: short helper copy explaining how to use the prompt.
  • prompt.text: copyable prompt text shown in the footer compact field for single-line values, with a preformatted fallback for multiline values (when omitted, runtime uses repo-aware absolute GitHub doc URLs).
  • showBuildInfo: controls rendering of the footer build-provenance row (Built <UTC> plus an optional Commit <shortSha> link).
  • showLastUpdated: legacy alias for showBuildInfo. showBuildInfo wins when both are present.

This fallback is renderer-level only: downstream consumers such as open-links-sites keep the same data contract, but sites that omit footer.ctaUrl inherit the canonical upstream repository CTA by default.

Example:

{
  "ui": {
    "footer": {
      "description": "OpenLinks is a personal, free, open source links site. It stores no cookies, and we will never serve ads. Welcome to the new Open Web, where privacy and freedom reign.",
      "ctaLabel": "Create Your OpenLinks",
      "prompt": {
        "enabled": true,
        "title": "Create your own OpenLinks site",
        "explanation": "Paste this bootstrap prompt into OpenClaw, Claude, or Codex to create a new OpenLinks site from this repository."
      },
      "showBuildInfo": true
    }
  }
}

The footer build-provenance row and /build-info.json expose the same runtime fields: builtAtIso, commitSha, commitShortSha, and commitUrl.

ui.typography

ui.typography provides data-driven typography overrides without editing CSS files.

Precedence order:

  1. Built-in token defaults
  2. ui.typographyScale preset baseline (fixed, compact, expressive)
  3. ui.typography.global
  4. ui.typography.themes[theme.active]

Supported keys in each override object:

  • Font families: fontDisplay, fontBody
  • Type sizes: sizeTitle, sizeHeadline, sizeBody, sizeCaption, sizeCardTitle, sizeLinkTitle, sizeIcon
  • Line heights: lineHeightTitle, lineHeightBody, lineHeightCardTitle, lineHeightCardDescription
  • Font weights: weightCardTitle, weightLinkTitle, weightIcon
  • Letter spacing: trackingUtilityTitle, trackingSectionHeading, trackingCardSource, trackingIcon
  • Text transforms: transformUtilityTitle, transformSectionHeading, transformContactLabel

Valid transform values:

  • none
  • uppercase
  • lowercase
  • capitalize

Example:

{
  "ui": {
    "typographyScale": "compact",
    "typography": {
      "global": {
        "fontBody": "\"IBM Plex Sans\", \"Segoe UI\", sans-serif",
        "sizeBody": "1.02rem",
        "lineHeightBody": 1.6
      },
      "themes": {
        "editorial": {
          "fontDisplay": "\"Fraunces\", \"Iowan Old Style\", serif"
        }
      }
    }
  }
}

quality

Quality controls include:

  • report output paths
  • blocking domains
  • SEO defaults/overrides
  • accessibility smoke-check labels
  • performance profile budgets

These values are consumed by:

  • scripts/quality/run-quality-checks.ts
  • scripts/quality/seo.ts
  • scripts/quality/a11y.ts
  • scripts/quality/perf.ts

Validation Workflow

Standard mode

bun run validate:data

Behavior:

  • fails on errors
  • allows warnings

Strict mode

bun run validate:data:strict

Behavior:

  • fails on errors
  • fails on warnings

JSON mode

bun run validate:data:json

Behavior:

  • machine-readable output for scripts/agents

Interpreting Validation Output

Validator output includes source file, JSON path, issue type, and remediation.

Error example

[data/links.json] $.links[1].url: URL scheme 'ftp:' is not allowed.
Fix: Use one of: http, https, mailto, tel.

Action:

  1. Open data/links.json.
  2. Navigate to links[1].url.
  3. Replace with supported scheme.
  4. Re-run validation.

Warning example

[data/site.json] $.experimentalFlag: Unknown top-level key 'experimentalFlag' is allowed but not part of the core contract.
Fix: Move 'experimentalFlag' into a dedicated custom block if it is extension data, or document why it belongs at top level.

Action:

  1. Move extension to site.custom.experimentalFlag.
  2. Re-run standard or strict validation.

custom Extension Namespace

OpenLinks supports extensions through custom, but there are guardrails.

Allowed extension locations

  • profile.custom
  • links.custom
  • site.custom
  • links[].custom
  • links[].metadata.custom
  • links[].enrichment.custom

Do

  • Use descriptive prefixes for project-specific fields.
  • Keep extension values serializable JSON.
  • Document custom keys in your fork README or docs.

Do not

  • Reuse reserved core keys (title, theme, type, etc.) inside custom at the same object level.
  • Put required core behavior behind undocumented custom flags.
  • Depend on unknown top-level keys long-term.

Collision example (invalid)

{
  "custom": {
    "title": "Collides with reserved core key"
  }
}

Safer alternative

{
  "custom": {
    "projectTitleOverride": "Custom semantic key"
  }
}

Copy-Paste Starter Presets

You can use these ready presets directly:

  • data/examples/minimal/ for quick launch.
  • data/examples/grouped/ for grouped + ordered links.
  • data/examples/invalid/ for testing validation and CI checks.

Recommended Edit Loop

For most maintainers, prefer the AI-assisted or Studio paths above and use the loop below only when you intentionally choose the manual fallback path.

  1. Update JSON in data/.
  2. Run bun run validate:data.
  3. Run bun run build.
  4. Preview with bun run preview.
  5. Commit and push.

Related Docs

  • Root overview: README.md
  • Fast setup and deployment path: docs/quickstart.md
  • AI-assisted change flow: docs/ai-guided-customization.md
  • Exhaustive customization checklist: docs/customization-catalog.md
  • Social-card verification guide: docs/social-card-verification.md
  • Authenticated extractor architecture/workflow: docs/authenticated-rich-extractors.md
  • New extractor implementation workflow: docs/create-new-rich-content-extractor.md