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.
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.pngat real 1408×704), per-plugin defaults (1200×630 webp),STATIC_SEOfor the index pages, helpers includinggetPluginOgImage(from)that resolves a plugin slug to itsbanner_imagefrom theplugin-highlightscollection. PlusbuildPageTitle(suffix appender) andtruncate(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), conditionalarticle:*block (whenogType="article"), and Twitter card. Handles GitHub PagesBASE_URLcorrectly 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 theplugin-highlightscollection. 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 —
CurrentFileModalandBatchDirectoryModalnow follow the BEM-scopedmodalEl.addClass()pattern fromcontext-v/issues/Widen-Modals-in-Obsidian-using-CSS.md.plugin-basics.css(which leaked rules to every Obsidian modal) is gone; in its place arecurrent-file-modal.cssandbatch-directory-modal.cssaggregated throughmain.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
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:
// 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}`}`;// 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:
| Page | og | og | og:image × height |
/ | website | default seedling | 1408 × 704 |
/changelog/perplexed/2026-04-30_01/ | article | ImageKit CDN (perplexed banner) | 1200 × 630 |
/changelog/content-farm/2026-05-04_01/ | article | default seedling | 1408 × 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
.mdfile insrc/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.
<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):
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:
BatchDirectoryModalwas using inline JS (modalContainer.style.width = '95vw') as a workaround for the wrong-element bug.CurrentFileModalhad no modal-specific class at all.plugin-basics.csswas targeting bare.modaland.modal-sectionselectors — which leak to every Obsidian modal globally, ours and others'. Quietly bad.
The fix matches perplexed's structure exactly:
src/styles/main.css—@importaggregatorsrc/styles/current-file-modal.css—.current-file-modalouter + BEM childrensrc/styles/batch-directory-modal.css—.batch-directory-modalouter + BEM childrensrc/modals/*.ts— replacedcontentEl.addClass(...)(and inline-style hacks) withmodalEl.addClass('current-file-modal')/'batch-directory-modal'and BEM children for sections/headers/buttonssrc/modals/sections/HeaderInfoSection.ts— moved the inlineel.style.x = 'y'styling for the header-info table grid into CSS classes; clean componentThe UUID-button "needs UUID" highlight is now a class toggle (
current-file-modal__uuid-button--needs-uuid), not direct style mutationUpdated
esbuild.config.mjsto point at the newmain.cssentry 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_imagecleanlyFacebook 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.
