Lossless Changelog
fleet

Splash unfurls properly now — OG system, per-plugin pages, share button on every card

Until today every splash URL unfurled with the same seedling-on-paper default — homepage, ship-note, cite-wide entry, all identical. We wired the elegant-OG-system blueprint into the splash, generated nine per-plugin landing pages whose `og:image` is the plugin's own banner, and dropped a share button on every plugin card so the URL you share unfurls with that plugin's image instead of the default. Filestarter's modals got the same widening treatment we shipped for perplexed two weeks ago, closing the last gap in cross-plugin consistency.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 4.7 (1M context)

Why Care?

If you've ever pasted a link into Slack or LinkedIn and watched it unfurl with a generic favicon and a wall of text, you know how much social-share metadata matters. Until this push, that's exactly what happened to every page on lossless-group.github.io/content-farm/ — every URL surfaced the same seedling-on-stack default OG image, no matter which plugin's ship-note or note-page you were sharing.

That's now fixed. Each plugin's banner_image (the wide horizontal art that lives in their plugin-highlights frontmatter) is the OG image for any page tagged to that plugin. Each card on the splash homepage has a share button that points the share sheet at the plugin's own landing page — so the recipient unfurls the plugin's specific image and copy, not the default seedling.

If you're another team wiring share-card metadata into an Astro site, this push is a working reference for the elegant-OG-system blueprint we'd written but not yet implemented anywhere on this side of the monorepo. (The deck workspaces — calmstorm-decks, reach-edu-hub — already had it; the public splashes did not.)

And if you're an Obsidian user who's been waiting for filestarter's modals to feel like the rest of the family, that wait is over too.

What's New?

  • src/lib/seo.ts — a typed registry: site-wide constants, default OG image (/ogimage__Content-Farm--Default.png at real 1408×704), per-plugin defaults (1200×630 webp), STATIC_SEO for the index pages, helpers including getPluginOgImage(from) that resolves a plugin slug to its banner_image from the plugin-highlights collection. Plus buildPageTitle (suffix appender) and truncate (60-char title / 155-char description for unfurler safety).

  • src/components/basics/MetaTags.astro — one component that emits the full <head> block: <title>, description, robots, canonical, full OG (type/site_name/title/description/url/image+secure_url+type+width+height+alt), conditional article:* block (when ogType="article"), and Twitter card. Handles GitHub Pages BASE_URL correctly when resolving local OG paths to absolute URLs.

  • BaseLayout.astro — replaced the inline 6-meta block with <MetaTags> and forwarded all SEO props.

  • Per-plugin landing pages at /plugins/<slug>/ — generated for all nine plugins from the plugin-highlights collection. Each shows the plugin's banner image, title, lede, status, tags, the highlight markdown body, recent ship notes from that plugin, and a CTA to the GitHub repo. These are the pages the share button targets.

  • Share button on every plugin card — the universal three-node share glyph at the top-right of each card. Click → navigator.share() if the platform supports it (mobile, modern desktops); fallback to clipboard with a "Link copied" toast; last resort opens in a new tab. ARIA-labeled, keyboard-accessible.

  • Filestarter modal widening — CurrentFileModal and BatchDirectoryModal now follow the BEM-scoped modalEl.addClass() pattern from context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md. plugin-basics.css (which leaked rules to every Obsidian modal) is gone; in its place are current-file-modal.css and batch-directory-modal.css aggregated through main.css.

The OG system, three files

The blueprint at content/lost-in-public/blueprints/Maintain-an-Elegant-Open-Graph-System.md describes a buildOgMeta() array helper. Both shipped reference sites (calmstorm-decks gated, reach-edu-hub public) converged on a tighter pattern instead — a typed registry plus a single component that owns the entire <head> block. We adopted that realized pattern verbatim.

src/lib/seo.ts — the registry

TS
export const SITE_NAME = 'content-farm';
export const DEFAULT_OG_IMAGE = '/ogimage__Content-Farm--Default.png';
export const DEFAULT_OG_IMAGE_WIDTH = 1408;
export const DEFAULT_OG_IMAGE_HEIGHT = 704;
export const PLUGIN_OG_IMAGE_WIDTH = 1200;   // Image-Gin standard wide format
export const PLUGIN_OG_IMAGE_HEIGHT = 630;
export const CHAR_LIMITS = { title: 60, description: 155 } as const;

export const STATIC_SEO = {
  root: { title: 'A small farm of writing tools', description: '...' },
  changelogIndex: { title: 'Changelog', description: '...' },
  contextVIndex: { title: 'Notes (context-v)', description: '...' },
};

export async function getPluginOgImage(from: string | undefined) {
  if (!from || from === 'content-farm') return undefined;
  const plugins = await getCollection('plugin-highlights');
  const match = plugins.find((p) => p.id === from);
  const banner = match?.data?.banner_image;
  return typeof banner === 'string' && banner.length > 0 ? banner : undefined;
}

Real pixel dimensions matter — strict unfurlers (LinkedIn, iMessage) reject mismatches. We measured the default with sips (1408×704, a 2:1 aspect — close to but not the standard 1.91:1) and recorded those exact numbers. The CDN-hosted plugin banners follow Image-Gin's documented 1200×630 wide format.

src/components/basics/MetaTags.astro — the renderer

The component takes {title, description, ogImage?, ogType?, noindex?, canonical?, publishedTime?, modifiedTime?, author?, tags?[]} and emits the full block. Two pieces worth flagging:

TS
// Resolve absolute OG URLs. GitHub Pages base path matters here —
// Astro.url.pathname already includes /content-farm/, but local
// ogImage paths from /public/ don't, so we prepend the base before
// the absolute URL.
const siteOrigin = (Astro.site?.toString() ?? Astro.url.origin).replace(/\/$/, '');
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
const canonicalUrl = canonical ?? `${siteOrigin}${Astro.url.pathname}`;
const absoluteOgImage = resolvedImage.startsWith('http')
  ? resolvedImage
  : `${siteOrigin}${basePath}${resolvedImage.startsWith('/') ? resolvedImage : `/${resolvedImage}`}`;
TS
// Public site → default robots is index,follow. Pass noindex={true} on
// any page that needs to opt out (we don't have any yet).
const robotsContent = noindex ? 'noindex, nofollow, noarchive, nosnippet' : 'index, follow';

The article-meta block (article:published_time, article:modified_time, article:author, article:tag) only emits when ogType === 'article'. Char-limit truncation (60/155) happens at render time so authors never have to think about it.

Page wiring

The two index pages and the homepage now read from STATIC_SEO. The two dynamic routes (changelog/[...slug], context-v/[...slug]) derive description from lede ?? summary ?? description ?? purpose, look up the plugin's banner via getPluginOgImage(entry.data.from), and emit ogType: 'article' with publishedTime, modifiedTime, author, and tags populated from the entry's frontmatter.

Verified rendered output across three samples:

Pageogogog:image × height
/websitedefault seedling1408 × 704
/changelog/perplexed/2026-04-30_01/articleImageKit CDN (perplexed banner)1200 × 630
/changelog/content-farm/2026-05-04_01/articledefault seedling1408 × 704

og:url is fully absolute (https://lossless-group.github.io/content-farm/...) on every page. Article pages additionally emit one <meta property="article:tag"> per tag.

Per-plugin pages — the share targets

The cards on the splash homepage link to the plugin's external GitHub repo — that primary click behavior is unchanged. But the share button needs to point at a URL on our domain that carries the plugin's specific OG metadata, otherwise sharing falls back to whatever GitHub renders for the repo (which is GitHub's OG, not ours).

So we created /plugins/<slug>/ pages from the plugin-highlights content collection. Each plugin page is intentionally modest:

  • The plugin's banner image as a hero (lazy-loaded)

  • Title, lede, status pill, tag pills

  • The plugin highlight body (rendered from the .md file in src/content/plugin-highlights/)

  • A CTA button to the GitHub repo

  • The five most recent ship notes from this plugin (filtered from the rolled-up changelog)

That's enough to be a useful destination if someone clicks through from a social share, while still being primarily useful as a share target with proper OG. Nine plugins × one page each = 9 new routes, taking the build from 68 to 77 pages.

Share button on every card

The recognizable cue: the three-node share glyph (Feather's share-2) — universal across iOS, Android, and modern desktop UIs. Subtle by default at the top-right of each card, brightening on hover/focus.

ASTRO
<button
  type="button"
  class="card-share"
  data-share-url={shareUrl}
  data-share-title={shareTitle}
  data-share-text={shareText}
  aria-label={`Share ${title}`}
>
  <svg viewBox="0 0 24 24" ...>
    <circle cx="18" cy="5" r="3" />
    <circle cx="6" cy="12" r="3" />
    <circle cx="18" cy="19" r="3" />
    <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
    <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
  </svg>
</button>

The architectural detail worth flagging: the button is a sibling of the card-link anchor, not nested inside it. Buttons inside anchors are invalid HTML and produce confusing keyboard / screen-reader behavior. Positioning the button absolutely at top-right with z-index: 2 keeps it visible without nesting.

<article class="card">
  <a class="card-link" href={primaryHref}>
    <div class="card-inner">…</div>
  </a>
  <button class="card-share" …>…</button>   ← sibling, not child
</article>

The handler is plain vanilla JS (Astro Knots tech hierarchy: HTML/CSS first, vanilla JS second, frameworks last):

TS
button.addEventListener('click', async (e) => {
  e.preventDefault();
  e.stopPropagation();

  const fullUrl = new URL(button.dataset.shareUrl!, window.location.origin).toString();
  const title = button.dataset.shareTitle!;
  const text = button.dataset.shareText!;

  if (typeof navigator.share === 'function') {
    try {
      await navigator.share({ title, text, url: fullUrl });
      return;
    } catch (err) {
      if ((err as DOMException)?.name === 'AbortError') return;
    }
  }

  try {
    await navigator.clipboard.writeText(fullUrl);
    showCopied('Link copied');
  } catch {
    window.open(fullUrl, '_blank', 'noopener');
  }
});

Native share sheet first, clipboard fallback with a toast (aria-live="polite" for screen readers), open-in-new-tab as last resort. No third-party share library.

Filestarter rejoins the wide-modal pattern

A separate but related thread: filestarter was the last plugin in the family still using the old narrow-modal CSS. The other plugins (especially perplexed) had already been updated to the pattern documented in context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md — attach your class to modalEl (the outer .modal element), not to contentEl (the inner .modal-content), so width rules actually widen the popup.

Filestarter had three problems:

  1. BatchDirectoryModal was using inline JS (modalContainer.style.width = '95vw') as a workaround for the wrong-element bug.

  2. CurrentFileModal had no modal-specific class at all.

  3. plugin-basics.css was targeting bare .modal and .modal-section selectors — which leak to every Obsidian modal globally, ours and others'. Quietly bad.

The fix matches perplexed's structure exactly:

  • src/styles/main.css — @import aggregator

  • src/styles/current-file-modal.css — .current-file-modal outer + BEM children

  • src/styles/batch-directory-modal.css — .batch-directory-modal outer + BEM children

  • src/modals/*.ts — replaced contentEl.addClass(...) (and inline-style hacks) with modalEl.addClass('current-file-modal') / 'batch-directory-modal' and BEM children for sections/headers/buttons

  • src/modals/sections/HeaderInfoSection.ts — moved the inline el.style.x = 'y' styling for the header-info table grid into CSS classes; clean component

  • The UUID-button "needs UUID" highlight is now a class toggle (current-file-modal__uuid-button--needs-uuid), not direct style mutation

  • Updated esbuild.config.mjs to point at the new main.css entry point

Tsc and esbuild both pass clean. The user-facing result: filestarter's two modals are now visually consistent with perplexed's (same wide-modal silhouette, same hairline section borders, same uppercase muted section labels), and CSS rules no longer bleed onto other plugins' modals.

What this enables

For the splash itself. Per-page OG is the foundation that lets every other downstream sharing concern work. Now:

  • LinkedIn / X / Slack / iMessage all unfurl with plugin-specific imagery

  • The Twitter card validator reports summary_large_image cleanly

  • Facebook Sharing Debugger reads the article meta block on changelog/note pages

  • The share button on each card is end-user actionable — no copy-paste-the-URL ceremony

For the next splash. memopop-site is the obvious next adopter — same shape of work, two files to copy and retokenize (src/lib/seo.ts + src/components/basics/MetaTags.astro), one wiring step in the boilerplate HTML wrapper. Half-a-day's lift, end-to-end.

For the plugin family. filestarter joining the wide-modal pattern means new contributors looking at how to build a modal can read any of the nine plugins as a reference and see the same shape — modalEl.addClass, scoped BEM CSS, one main.css entry. The "convention by example" plays only when the examples agree.

What's next

  • QA in production validators — paste a homepage URL, a per-plugin URL, and a per-changelog URL into LinkedIn Post Inspector, Twitter card validator, and Facebook Sharing Debugger after the next deploy. Real fetch, not a screenshot. The blueprint checklist is firm on this and we haven't done it yet.

  • Add a plugin-highlights/index.astro — currently /plugins/ returns a 404. The per-plugin slug routes work; an index that lists all of them would be nice but isn't blocking. Low priority since the homepage already serves as the gallery.

  • Recraft the default OG to the standard 1.91:1 (1200×630). Current default is 2:1 (1408×704). Most platforms render fine, but LinkedIn and Twitter prefer the standard ratio. Cosmetic, not blocking.