14 changed files with 655 additions and 196 deletions
@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display" |
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1. |
||||
This license is copied below, and is also available with a FAQ at: |
||||
http://scripts.sil.org/OFL |
||||
|
||||
|
||||
----------------------------------------------------------- |
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 |
||||
----------------------------------------------------------- |
||||
|
||||
PREAMBLE |
||||
The goals of the Open Font License (OFL) are to stimulate worldwide |
||||
development of collaborative font projects, to support the font creation |
||||
efforts of academic and linguistic communities, and to provide a free and |
||||
open framework in which fonts may be shared and improved in partnership |
||||
with others. |
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and |
||||
redistributed freely as long as they are not sold by themselves. The |
||||
fonts, including any derivative works, can be bundled, embedded, |
||||
redistributed and/or sold with any software provided that any reserved |
||||
names are not used by derivative works. The fonts and derivatives, |
||||
however, cannot be released under any other type of license. The |
||||
requirement for fonts to remain under this license does not apply |
||||
to any document created using the fonts or their derivatives. |
||||
|
||||
DEFINITIONS |
||||
"Font Software" refers to the set of files released by the Copyright |
||||
Holder(s) under this license and clearly marked as such. This may |
||||
include source files, build scripts and documentation. |
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the |
||||
copyright statement(s). |
||||
|
||||
"Original Version" refers to the collection of Font Software components as |
||||
distributed by the Copyright Holder(s). |
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, |
||||
or substituting -- in part or in whole -- any of the components of the |
||||
Original Version, by changing formats or by porting the Font Software to a |
||||
new environment. |
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical |
||||
writer or other person who contributed to the Font Software. |
||||
|
||||
PERMISSION & CONDITIONS |
||||
Permission is hereby granted, free of charge, to any person obtaining |
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify, |
||||
redistribute, and sell modified and unmodified copies of the Font |
||||
Software, subject to the following conditions: |
||||
|
||||
1) Neither the Font Software nor any of its individual components, |
||||
in Original or Modified Versions, may be sold by itself. |
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled, |
||||
redistributed and/or sold with any software, provided that each copy |
||||
contains the above copyright notice and this license. These can be |
||||
included either as stand-alone text files, human-readable headers or |
||||
in the appropriate machine-readable metadata fields within text or |
||||
binary files as long as those fields can be easily viewed by the user. |
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font |
||||
Name(s) unless explicit written permission is granted by the corresponding |
||||
Copyright Holder. This restriction only applies to the primary font name as |
||||
presented to the users. |
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font |
||||
Software shall not be used to promote, endorse or advertise any |
||||
Modified Version, except to acknowledge the contribution(s) of the |
||||
Copyright Holder(s) and the Author(s) or with their explicit written |
||||
permission. |
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole, |
||||
must be distributed entirely under this license, and must not be |
||||
distributed under any other license. The requirement for fonts to |
||||
remain under this license does not apply to any document created |
||||
using the Font Software. |
||||
|
||||
TERMINATION |
||||
This license becomes null and void if any of the above conditions are |
||||
not met. |
||||
|
||||
DISCLAIMER |
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF |
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT |
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE |
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, |
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL |
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING |
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM |
||||
OTHER DEALINGS IN THE FONT SOFTWARE. |
||||
Binary file not shown.
|
Before Width: | Height: | Size: 794 KiB After Width: | Height: | Size: 82 KiB |
@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
|
||||
/** |
||||
* Rasterize public/og-image.svg → public/og-image.png with a true Playfair Display wordmark. |
||||
* ImageMagick/Inkscape copy the SVG to /tmp, so @font-face + file URLs often never load; |
||||
* we outline "Imwald" with opentype.js so the PNG is font-independent. |
||||
* |
||||
* Wordmark fill + weight are read from the `#og-imwald` <text> in the SVG so PNG matches |
||||
* the green-tinged off-white you see in the browser (rasterizers often look harsher than live text). |
||||
*/ |
||||
import { execFileSync } from 'node:child_process' |
||||
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs' |
||||
import { dirname, join } from 'node:path' |
||||
import { fileURLToPath } from 'node:url' |
||||
import opentype from 'opentype.js' |
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..') |
||||
const svgPath = join(root, 'public/og-image.svg') |
||||
const fontPath = join(root, 'public/fonts/PlayfairDisplay-wght.ttf') |
||||
const outPng = join(root, 'public/og-image.png') |
||||
const tmpSvg = join(root, 'public/.og-image.raster.svg') |
||||
|
||||
function parseOgImwaldFromSvg(svg) { |
||||
const defaults = { fill: '#d4ebe0', weight: 700, letterSpacing: 0.018 } |
||||
const idPos = svg.indexOf('id="og-imwald"') |
||||
if (idPos < 0) return defaults |
||||
const textOpen = svg.lastIndexOf('<text', idPos) |
||||
const textClose = svg.indexOf('</text>', idPos) |
||||
if (textOpen < 0 || textClose < 0) return defaults |
||||
const t = svg.slice(textOpen, textClose + '</text>'.length) |
||||
const fill = t.match(/\bfill="([^"]+)"/)?.[1] ?? defaults.fill |
||||
const weight = parseInt(t.match(/font-weight="(\d+)"/)?.[1] ?? String(defaults.weight), 10) |
||||
return { |
||||
fill, |
||||
weight: Number.isFinite(weight) ? weight : defaults.weight, |
||||
letterSpacing: defaults.letterSpacing |
||||
} |
||||
} |
||||
|
||||
let svg = readFileSync(svgPath, 'utf8') |
||||
const { fill: imwaldFill, weight: imwaldWght, letterSpacing } = parseOgImwaldFromSvg(svg) |
||||
|
||||
const font = opentype.loadSync(fontPath) |
||||
const pathObj = font.getPath('Imwald', 72, 300, 108, { |
||||
variation: { wght: imwaldWght }, |
||||
letterSpacing |
||||
}) |
||||
let pathTag = pathObj.toSVG(2) |
||||
if (!pathTag.includes('fill=')) { |
||||
pathTag = pathTag.replace('<path ', `<path fill="${imwaldFill}" `) |
||||
} |
||||
|
||||
svg = svg.replace(/<text[^>]*id="og-imwald"[^>]*>[\s\S]*?<\/text>/, pathTag) |
||||
|
||||
writeFileSync(tmpSvg, svg, 'utf8') |
||||
|
||||
try { |
||||
execFileSync('convert', ['-background', 'none', '-density', '150', tmpSvg, outPng], { |
||||
stdio: 'inherit', |
||||
cwd: root |
||||
}) |
||||
} finally { |
||||
try { |
||||
unlinkSync(tmpSvg) |
||||
} catch { |
||||
/* ignore */ |
||||
} |
||||
} |
||||
|
||||
console.info('[og:image] wrote', outPng) |
||||
@ -0,0 +1,271 @@
@@ -0,0 +1,271 @@
|
||||
/** Shared Open Graph / Twitter / document title helpers (client-side). */ |
||||
|
||||
export const SITE_NAME = 'Imwald' |
||||
|
||||
export const SITE_TAGLINE = |
||||
'A user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery.' |
||||
|
||||
export function getSiteOrigin(): string { |
||||
if (typeof window === 'undefined') return 'https://jumble.imwald.eu' |
||||
return window.location.origin |
||||
} |
||||
|
||||
export function defaultOgImageAbsoluteUrl(): string { |
||||
return `${getSiteOrigin()}/og-image.png` |
||||
} |
||||
|
||||
export function avatarProxyUrl(pubkey: string): string { |
||||
return `${getSiteOrigin()}/api/avatar/${pubkey}` |
||||
} |
||||
|
||||
export function updateMetaTag(property: string, content: string): void { |
||||
if (typeof document === 'undefined') return |
||||
const prop = |
||||
property.startsWith('og:') || property.startsWith('article:') || property.startsWith('profile:') |
||||
? property |
||||
: property.replace(/^property="|"$/, '') |
||||
|
||||
const isTwitterTag = prop.startsWith('twitter:') |
||||
const selector = isTwitterTag ? `meta[name="${prop}"]` : `meta[property="${prop}"]` |
||||
|
||||
let meta = document.querySelector(selector) |
||||
if (!meta) { |
||||
meta = document.createElement('meta') |
||||
if (isTwitterTag) { |
||||
meta.setAttribute('name', prop) |
||||
} else { |
||||
meta.setAttribute('property', prop) |
||||
} |
||||
document.head.appendChild(meta) |
||||
} |
||||
meta.setAttribute('content', content) |
||||
} |
||||
|
||||
export function removeMetaByProperty(property: string): void { |
||||
if (typeof document === 'undefined') return |
||||
document.querySelectorAll(`meta[property="${property}"]`).forEach((m) => m.remove()) |
||||
} |
||||
|
||||
export function applyDefaultSiteSocialMeta(): void { |
||||
if (typeof window === 'undefined') return |
||||
const href = window.location.href |
||||
const truncatedUrl = href.length > 150 ? href.substring(0, 147) + '...' : href |
||||
const desc = `${truncatedUrl} — ${SITE_TAGLINE}` |
||||
const img = defaultOgImageAbsoluteUrl() |
||||
|
||||
updateMetaTag('og:title', SITE_NAME) |
||||
updateMetaTag('og:description', desc) |
||||
updateMetaTag('og:image', img) |
||||
updateMetaTag('og:type', 'website') |
||||
updateMetaTag('og:url', href) |
||||
updateMetaTag('og:site_name', SITE_NAME) |
||||
|
||||
updateMetaTag('twitter:card', 'summary_large_image') |
||||
updateMetaTag('twitter:title', SITE_NAME) |
||||
updateMetaTag('twitter:description', desc) |
||||
updateMetaTag('twitter:image', img) |
||||
} |
||||
|
||||
const PRIMARY_PAGE_LABEL: Record<string, string> = { |
||||
explore: 'Explore', |
||||
feed: 'Feed', |
||||
me: 'Me', |
||||
profile: 'Profile', |
||||
relay: 'Relay', |
||||
search: 'Search', |
||||
'follows-latest': 'Latest follows', |
||||
rss: 'RSS', |
||||
settings: 'Settings', |
||||
spells: 'Spells' |
||||
} |
||||
|
||||
function relayHostnameFromPath(pathname: string): string | null { |
||||
const m = |
||||
pathname.match(/\/(?:home|explore)\/relays\/(.+)$/i) || pathname.match(/^\/relays\/(.+)$/i) |
||||
if (!m?.[1]) return null |
||||
try { |
||||
const decoded = decodeURIComponent(m[1].split('/')[0]) |
||||
const asHttp = decoded.startsWith('wss://') |
||||
? 'https://' + decoded.slice(6) |
||||
: decoded.startsWith('ws://') |
||||
? 'http://' + decoded.slice(5) |
||||
: decoded |
||||
const u = new URL(asHttp.includes('://') ? asHttp : `https://${asHttp}`) |
||||
return u.hostname || decoded |
||||
} catch { |
||||
return m[1].slice(0, 80) |
||||
} |
||||
} |
||||
|
||||
export type TRouteSocialCopy = { pageTitle: string; ogTitle: string; description: string } |
||||
|
||||
/** Note detail URLs set OG tags in NotePage. */ |
||||
export function isNoteDetailPathname(pathname: string): boolean { |
||||
const path = pathname.split('?')[0].split('#')[0] |
||||
return ( |
||||
/\/notes\/[^/?#]+/.test(path) || |
||||
/\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/[^/?#]+/.test( |
||||
path |
||||
) |
||||
) |
||||
} |
||||
|
||||
/** Profile detail (/users/:id) sets OG in ProfilePage. */ |
||||
export function isProfileDetailPathname(pathname: string): boolean { |
||||
const path = pathname.split('?')[0].split('#')[0].replace(/\/$/, '') || '/' |
||||
return /^\/users\/[^/]+$/.test(path) |
||||
} |
||||
|
||||
/** Labels for static Imwald URLs (in-app link previews + route-level OG when no note/profile). */ |
||||
export function resolveImwaldRouteSocialCopy( |
||||
pathname: string, |
||||
currentPrimaryPage: string |
||||
): TRouteSocialCopy { |
||||
const path = pathname.split('?')[0].split('#')[0].replace(/\/$/, '') || '/' |
||||
const href = typeof window !== 'undefined' ? window.location.href : '' |
||||
const relayHost = relayHostnameFromPath(path) |
||||
|
||||
let pageTitle = SITE_NAME |
||||
let ogTitle = SITE_NAME |
||||
let description = href ? `${SITE_TAGLINE} ${href}` : SITE_TAGLINE |
||||
|
||||
if (path.startsWith('/settings')) { |
||||
if (path.includes('/general')) { |
||||
pageTitle = `General · ${SITE_NAME}` |
||||
ogTitle = `General settings · ${SITE_NAME}` |
||||
} else if (path.includes('/relays')) { |
||||
pageTitle = `Relays · ${SITE_NAME}` |
||||
ogTitle = `Relay & storage settings · ${SITE_NAME}` |
||||
} else if (path.includes('/cache')) { |
||||
pageTitle = `Cache · ${SITE_NAME}` |
||||
ogTitle = `Cache & offline storage · ${SITE_NAME}` |
||||
} else if (path.includes('/wallet')) { |
||||
pageTitle = `Wallet · ${SITE_NAME}` |
||||
ogTitle = `Wallet settings · ${SITE_NAME}` |
||||
} else if (path.includes('/posts')) { |
||||
pageTitle = `Posts · ${SITE_NAME}` |
||||
ogTitle = `Post settings · ${SITE_NAME}` |
||||
} else if (path.includes('/translation')) { |
||||
pageTitle = `Translation · ${SITE_NAME}` |
||||
ogTitle = `Translation settings · ${SITE_NAME}` |
||||
} else if (path.includes('/rss-feeds')) { |
||||
pageTitle = `RSS feeds · ${SITE_NAME}` |
||||
ogTitle = `RSS feed settings · ${SITE_NAME}` |
||||
} else if (path.includes('/follow-sets')) { |
||||
pageTitle = `Follow sets · ${SITE_NAME}` |
||||
ogTitle = `Follow sets · ${SITE_NAME}` |
||||
} else if (path.includes('/personal-lists')) { |
||||
pageTitle = `Lists · ${SITE_NAME}` |
||||
ogTitle = `Personal lists · ${SITE_NAME}` |
||||
} else { |
||||
pageTitle = `Settings · ${SITE_NAME}` |
||||
ogTitle = `Settings · ${SITE_NAME}` |
||||
} |
||||
description = `${ogTitle}. ${SITE_TAGLINE}` |
||||
} else if (path === '/search' || path.startsWith('/search/')) { |
||||
pageTitle = `Search · ${SITE_NAME}` |
||||
ogTitle = pageTitle |
||||
description = `Search notes and people on Nostr with ${SITE_NAME}.` |
||||
} else if (relayHost) { |
||||
const host = relayHost |
||||
pageTitle = `${host} · ${SITE_NAME}` |
||||
ogTitle = `Relay ${host} · ${SITE_NAME}` |
||||
description = `Relay ${host} on ${SITE_NAME}. ${SITE_TAGLINE}` |
||||
} else if (path.startsWith('/rss-item') || path.includes('/rss-item/')) { |
||||
pageTitle = `Article · ${SITE_NAME}` |
||||
ogTitle = `RSS article · ${SITE_NAME}` |
||||
description = `Read an RSS-sourced article in ${SITE_NAME}.` |
||||
} else if (path === '/users') { |
||||
pageTitle = `People · ${SITE_NAME}` |
||||
ogTitle = `People on ${SITE_NAME}` |
||||
description = `Browse Nostr profiles in ${SITE_NAME}.` |
||||
} else if (path === '/bookmarks') { |
||||
pageTitle = `Bookmarks · ${SITE_NAME}` |
||||
ogTitle = pageTitle |
||||
description = `Your bookmarked notes on ${SITE_NAME}.` |
||||
} else if (path === '/mutes') { |
||||
pageTitle = `Muted users · ${SITE_NAME}` |
||||
ogTitle = pageTitle |
||||
description = `Muted users in ${SITE_NAME}.` |
||||
} else if (path === '/pins') { |
||||
pageTitle = `Pinned notes · ${SITE_NAME}` |
||||
ogTitle = pageTitle |
||||
description = `Pinned notes in ${SITE_NAME}.` |
||||
} else if (path === '/interests') { |
||||
pageTitle = `Interests · ${SITE_NAME}` |
||||
ogTitle = pageTitle |
||||
description = `Interest lists in ${SITE_NAME}.` |
||||
} else if (path === '/profile-editor') { |
||||
pageTitle = `Edit profile · ${SITE_NAME}` |
||||
ogTitle = pageTitle |
||||
description = `Edit your Nostr profile in ${SITE_NAME}.` |
||||
} else if (path === '/follow-packs') { |
||||
pageTitle = `Follow packs · ${SITE_NAME}` |
||||
ogTitle = pageTitle |
||||
description = `Follow packs on ${SITE_NAME}.` |
||||
} else if (path === '/notes') { |
||||
pageTitle = `Notes · ${SITE_NAME}` |
||||
ogTitle = pageTitle |
||||
description = `Notes in ${SITE_NAME}.` |
||||
} else if (path.match(/^\/users\/[^/]+\/following$/)) { |
||||
pageTitle = `Following · ${SITE_NAME}` |
||||
ogTitle = `Following list · ${SITE_NAME}` |
||||
description = `Following list on ${SITE_NAME}.` |
||||
} else if (path.match(/^\/users\/[^/]+\/relays$/)) { |
||||
pageTitle = `Relays · ${SITE_NAME}` |
||||
ogTitle = `User relays · ${SITE_NAME}` |
||||
description = `Relay list on ${SITE_NAME}.` |
||||
} else if (path === '/' || path === '/home') { |
||||
pageTitle = `Home · ${SITE_NAME}` |
||||
ogTitle = `Home · ${SITE_NAME}` |
||||
description = `${SITE_TAGLINE} ${href}` |
||||
} else { |
||||
const seg = path.split('/').filter(Boolean)[0] |
||||
if (seg && PRIMARY_PAGE_LABEL[seg]) { |
||||
const label = PRIMARY_PAGE_LABEL[seg] |
||||
pageTitle = `${label} · ${SITE_NAME}` |
||||
ogTitle = pageTitle |
||||
description = `${label} in ${SITE_NAME}. ${SITE_TAGLINE}` |
||||
} else if (currentPrimaryPage && PRIMARY_PAGE_LABEL[currentPrimaryPage]) { |
||||
const label = PRIMARY_PAGE_LABEL[currentPrimaryPage] |
||||
pageTitle = `${label} · ${SITE_NAME}` |
||||
ogTitle = pageTitle |
||||
description = `${label} in ${SITE_NAME}. ${SITE_TAGLINE}` |
||||
} |
||||
} |
||||
|
||||
return { pageTitle, ogTitle, description } |
||||
} |
||||
|
||||
/** |
||||
* Browser tab + social tags for routes that do not set their own (e.g. settings, lists). |
||||
* Note and profile detail pages set richer tags in their components. |
||||
*/ |
||||
export function applyRouteDocumentMeta(pathname: string, currentPrimaryPage: string): void { |
||||
if (typeof window === 'undefined') return |
||||
|
||||
const { pageTitle, ogTitle, description } = resolveImwaldRouteSocialCopy(pathname, currentPrimaryPage) |
||||
const href = window.location.href |
||||
const img = defaultOgImageAbsoluteUrl() |
||||
|
||||
document.title = pageTitle |
||||
|
||||
updateMetaTag('og:title', ogTitle) |
||||
updateMetaTag('og:description', description.length > 300 ? description.slice(0, 297) + '...' : description) |
||||
updateMetaTag('og:image', img) |
||||
updateMetaTag('og:type', 'website') |
||||
updateMetaTag('og:url', href) |
||||
updateMetaTag('og:site_name', SITE_NAME) |
||||
|
||||
updateMetaTag('twitter:card', 'summary_large_image') |
||||
updateMetaTag('twitter:title', ogTitle) |
||||
updateMetaTag( |
||||
'twitter:description', |
||||
description.length > 200 ? description.slice(0, 197) + '...' : description |
||||
) |
||||
updateMetaTag('twitter:image', img) |
||||
|
||||
removeMetaByProperty('article:tag') |
||||
removeMetaByProperty('article:author') |
||||
document.querySelector('meta[property="article:author:url"]')?.remove() |
||||
} |
||||
Loading…
Reference in new issue