aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-22 08:42:47 -0700
committerAdam Soltys <[email protected]>2026-10-22 08:42:47 -0700
commitf3a3d88c7d5a86ae1fa06cf23932631eaa050151 (patch)
tree0f4322e7ec206150ca16c067fd7b698f70705ec9
parent743011c02300ef884333d41c87a88900173023c9 (diff)
downloadcoinosv3-f3a3d88c7d5a86ae1fa06cf23932631eaa050151.tar.gz
coinosv3-f3a3d88c7d5a86ae1fa06cf23932631eaa050151.zip
Links to coinos unfurl with a share card: site-wide Open Graph/Twitter tags and a 1200×630 og-image.png. Note and profile pages swap the site's tags for their own and fall back to the card when they have no picture. README links coinosv3-server on gittr.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--README.md3
-rw-r--r--build.js24
-rw-r--r--feed/note-page.js12
-rw-r--r--feed/profile-page.js8
-rw-r--r--static/og-image.pngbin0 -> 122763 bytes
5 files changed, 37 insertions, 10 deletions
diff --git a/README.md b/README.md
index 4227479..077b71a 100644
--- a/README.md
+++ b/README.md
@@ -225,7 +225,8 @@ bun run build # → dist/
The app is static. The small services that run beside it (Bun) live in
their own repository, **[coinosv3-server](https://github.com/coinos/coinosv3-server)**
-(also at https://git.coinos.io/coinosv3-server.git): the payment-name
+(also at https://git.coinos.io/coinosv3-server.git and on Nostr via
+[gittr](https://gittr.space/npub1w27mc4aa6m0ufe3xs5z3m6qyr52gc0rglept7vqlwx4xeaf72tasnqz70r/coinosv3-server)): the payment-name
registrar (`names.coinos.io`: BIP-353 records, LNURL-pay for `@coinos.io`
names, link previews, translation), the NWC push notifier (`nwcpush`), the
Blossom server for encrypted sync envelopes (`blossom.coinos.io`), the
diff --git a/build.js b/build.js
index 5a279c4..eb81f80 100644
--- a/build.js
+++ b/build.js
@@ -14,7 +14,7 @@ import { mkdir, readdir } from 'node:fs/promises';
const FAVICON = 'data:image/svg+xml,%3Csvg%20width%3D%2272%22%20height%3D%2272%22%20viewBox%3D%220%200%2072%2072%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%20%3Cpath%20d%3D%22M69.8824%2036C69.8824%2054.7127%2054.7127%2069.8824%2036%2069.8824C17.2873%2069.8824%202.11765%2054.7127%202.11765%2036C2.11765%2017.2873%2017.2873%202.11765%2036%202.11765C54.7127%202.11765%2069.8824%2017.2873%2069.8824%2036Z%22%20fill%3D%22white%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36%204.23529C18.4568%204.23529%204.23529%2018.4568%204.23529%2036C4.23529%2053.5432%2018.4568%2067.7647%2036%2067.7647C53.5432%2067.7647%2067.7647%2053.5432%2067.7647%2036C67.7647%2018.4568%2053.5432%204.23529%2036%204.23529ZM0%2036C0%2016.1177%2016.1177%200%2036%200C55.8823%200%2072%2016.1177%2072%2036C72%2055.8823%2055.8823%2072%2036%2072C16.1177%2072%200%2055.8823%200%2036Z%22%20fill%3D%22black%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36%2058.5882C48.4751%2058.5882%2058.5882%2048.4751%2058.5882%2036C58.5882%2023.5248%2048.4751%2013.4117%2036%2013.4117C23.5249%2013.4117%2013.4118%2023.5248%2013.4118%2036C13.4118%2048.4751%2023.5249%2058.5882%2036%2058.5882ZM36%2054C45.9411%2054%2054%2045.9411%2054%2036C54%2026.0589%2045.9411%2018%2036%2018C26.0589%2018%2018%2026.0589%2018%2036C18%2045.9411%2026.0589%2054%2036%2054Z%22%20fill%3D%22black%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36.0001%2022.8988C36.0001%2022.8988%2036%2022.8988%2036%2022.8988C28.7644%2022.8988%2022.8988%2028.7644%2022.8988%2036C22.8988%2043.2356%2028.7644%2049.1012%2036%2049.1012C36%2049.1012%2036.0001%2049.1012%2036.0001%2049.1012V22.8988Z%22%20fill%3D%22black%22/%3E%20%3C/svg%3E';
// Static assets copied verbatim into dist/ (icons referenced by the manifest).
-const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg', 'badge-96.png', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'PublicSans-OFL.txt'];
+const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg', 'badge-96.png', 'og-image.png', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'PublicSans-OFL.txt'];
const MANIFEST = {
id: '/',
@@ -394,6 +394,26 @@ const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za
// fetch the live site over the top of them.
const NO_SW = !!env('NO_SW');
+// What a link to the site unfurls as (chat apps, social posts, search). The
+// server-rendered note and profile pages swap this block for their own
+// (feed/note-page.js siteTagsOut), so the markers must stay.
+const SHARE_IMAGE = 'https://v3.coinos.io/og-image.png';
+const SHARE_DESC = 'Self-custody Bitcoin wallet in your browser: Lightning, Ark and Nostr, with your own keys.';
+const siteTags = (staging) => `<!--site-tags-->
+<meta name="description" content="${SHARE_DESC}">
+<meta property="og:type" content="website">
+<meta property="og:site_name" content="Coinos">
+<meta property="og:title" content="${staging ? 'Coinos Staging' : 'Coinos'}">
+<meta property="og:description" content="${SHARE_DESC}">
+<meta property="og:image" content="${SHARE_IMAGE}">
+<meta property="og:image:width" content="1200">
+<meta property="og:image:height" content="630">
+<meta property="og:image:alt" content="The coinos logo">
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:image" content="${SHARE_IMAGE}">
+<!--/site-tags-->
+`;
+
async function pageHtml({ css, staging, pwa, scriptHtml }) {
// On https the page asks for its http:// pictures and clips (and ws://
// relays) over https: a post's one http:// clip made Chrome call the whole
@@ -408,7 +428,7 @@ async function pageHtml({ css, staging, pwa, scriptHtml }) {
<meta name="color-scheme" content="light dark">
<title>${staging ? 'Coinos Staging' : 'Coinos'}</title>
<link rel="icon" href="${FAVICON}">
-${pwa ? PWA_HEAD : ''}<script>try{var t=localStorage.getItem('btc-wallet-theme');if(t!=='dark'&&t!=='light')t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.dataset.theme=t;var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',t==='dark'?'#15171a':'#eef0f3');}catch(e){}</script>
+${siteTags(staging)}${pwa ? PWA_HEAD : ''}<script>try{var t=localStorage.getItem('btc-wallet-theme');if(t!=='dark'&&t!=='light')t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.dataset.theme=t;var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',t==='dark'?'#15171a':'#eef0f3');}catch(e){}</script>
<style>${css}</style>
</head>
<body>
diff --git a/feed/note-page.js b/feed/note-page.js
index 3145f14..89b2f43 100644
--- a/feed/note-page.js
+++ b/feed/note-page.js
@@ -126,6 +126,12 @@ function nameOf(data, pk) {
try { const m = JSON.parse(p.content); return { name: m.display_name || m.name || null, picture: m.picture || null }; } catch { return {}; }
}
+// index.html carries the site's own share tags between these markers; a
+// rendered page drops them for its own (crawlers take the first of a pair).
+// The site's picture stays as the stand-in for a page that has none.
+export const siteTagsOut = (html) => html.replace(/<!--site-tags-->[\s\S]*?<!--\/site-tags-->\n?/, '');
+export const siteImage = (origin) => (origin ? origin + '/og-image.png' : '');
+
// The app's index.html with the thread and its preview tags in the head.
export function renderNotePage(html, data, ref, origin) {
const focus = [data.root, ...data.replies].find((e) => e.id === ref.id) || data.root;
@@ -141,13 +147,13 @@ export function renderNotePage(html, data, ref, origin) {
desc ? `<meta property="og:description" content="${esc(desc)}">` : '',
desc ? `<meta name="description" content="${esc(desc)}">` : '',
origin ? `<meta property="og:url" content="${esc(origin + '/' + (ref.path || ''))}">` : '',
- image ? `<meta property="og:image" content="${esc(image)}">` : '',
- `<meta name="twitter:card" content="${image && image !== who.picture ? 'summary_large_image' : 'summary'}">`,
+ image || siteImage(origin) ? `<meta property="og:image" content="${esc(image || siteImage(origin))}">` : '',
+ `<meta name="twitter:card" content="${!image || image !== who.picture ? 'summary_large_image' : 'summary'}">`,
// the author's face starts downloading with the page, not after the app boots
who.picture && /^https:\/\//.test(who.picture) ? `<link rel="preload" as="image" href="${esc(who.picture)}">` : '',
`<script type="application/json" id="boot-thread">${scriptJson(data)}</script>`,
].filter(Boolean).join('\n');
- let out = html.replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
+ let out = siteTagsOut(html).replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
out = out.includes('</head>') ? out.replace('</head>', head + '\n</head>') : head + out;
return out;
}
diff --git a/feed/profile-page.js b/feed/profile-page.js
index a30c2ab..623dc09 100644
--- a/feed/profile-page.js
+++ b/feed/profile-page.js
@@ -11,7 +11,7 @@
// page goes out (and the build carries on into the cache).
import { decode } from 'nostr-tools/nip19';
import { verifyEvent } from 'nostr-tools/pure';
-import { NOTE_RELAYS, esc, scriptJson } from './note-page.js';
+import { NOTE_RELAYS, esc, scriptJson, siteTagsOut, siteImage } from './note-page.js';
export const PROFILE_PATH = /^\/((?:npub1|nprofile1)[a-z0-9]+|[A-Za-z0-9._-]{1,30})\/?$/;
// single-segment app routes and files that are never a person
@@ -186,14 +186,14 @@ export function renderProfilePage(html, data, ref, origin) {
desc ? `<meta property="og:description" content="${esc(desc)}">` : '',
desc ? `<meta name="description" content="${esc(desc)}">` : '',
origin ? `<meta property="og:url" content="${esc(origin + '/' + ref.path)}">` : '',
- picture ? `<meta property="og:image" content="${esc(picture)}">` : '',
- `<meta name="twitter:card" content="summary">`,
+ picture || siteImage(origin) ? `<meta property="og:image" content="${esc(picture || siteImage(origin))}">` : '',
+ `<meta name="twitter:card" content="${picture ? 'summary' : 'summary_large_image'}">`,
// the face and the cover start downloading with the page
picture ? `<link rel="preload" as="image" href="${esc(picture)}">` : '',
banner ? `<link rel="preload" as="image" href="${esc(banner)}">` : '',
`<script type="application/json" id="boot-profile">${scriptJson({ ...data, path: ref.path })}</script>`,
].filter(Boolean).join('\n');
- let out = html.replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
+ let out = siteTagsOut(html).replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
out = out.includes('</head>') ? out.replace('</head>', head + '\n</head>') : head + out;
return out;
}
diff --git a/static/og-image.png b/static/og-image.png
new file mode 100644
index 0000000..c048083
--- /dev/null
+++ b/static/og-image.png
Binary files differ