mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge branch 'opengraph-metadata' into 'main'
OpenGraph metadata support Closes #179 See merge request soapbox-pub/ditto!444
This commit is contained in:
commit
a46c352d3d
17 changed files with 283 additions and 50 deletions
|
|
@ -31,31 +31,12 @@ server {
|
||||||
|
|
||||||
root /opt/ditto/public;
|
root /opt/ditto/public;
|
||||||
|
|
||||||
location @spa {
|
|
||||||
try_files /index.html /dev/null;
|
|
||||||
}
|
|
||||||
|
|
||||||
location @frontend {
|
|
||||||
try_files $uri @ditto-static;
|
|
||||||
}
|
|
||||||
|
|
||||||
location @ditto-static {
|
|
||||||
root /opt/ditto/static;
|
|
||||||
try_files $uri @spa;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /packs {
|
location /packs {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
root /opt/ditto/public;
|
root /opt/ditto/public;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /metrics {
|
|
||||||
allow 127.0.0.1;
|
|
||||||
deny all;
|
|
||||||
proxy_pass http://ditto;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/(instance|sw\.js$|sw\.js\.map$) {
|
location ~ ^/(instance|sw\.js$|sw\.js\.map$) {
|
||||||
root /opt/ditto/public;
|
root /opt/ditto/public;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
|
|
@ -66,11 +47,13 @@ server {
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(api|relay|oauth|manifest.json|nodeinfo|.well-known/(nodeinfo|nostr.json)) {
|
location /metrics {
|
||||||
|
allow 127.0.0.1;
|
||||||
|
deny all;
|
||||||
proxy_pass http://ditto;
|
proxy_pass http://ditto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files /dev/null @frontend;
|
proxy_pass http://ditto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ import {
|
||||||
trendingTagsController,
|
trendingTagsController,
|
||||||
} from '@/controllers/api/trends.ts';
|
} from '@/controllers/api/trends.ts';
|
||||||
import { errorHandler } from '@/controllers/error.ts';
|
import { errorHandler } from '@/controllers/error.ts';
|
||||||
|
import { frontendController } from '@/controllers/frontend.ts';
|
||||||
import { metricsController } from '@/controllers/metrics.ts';
|
import { metricsController } from '@/controllers/metrics.ts';
|
||||||
import { indexController } from '@/controllers/site.ts';
|
import { indexController } from '@/controllers/site.ts';
|
||||||
import '@/startup.ts';
|
import '@/startup.ts';
|
||||||
|
|
@ -324,7 +325,6 @@ app.use('/oauth/*', notImplementedController);
|
||||||
|
|
||||||
const publicFiles = serveStatic({ root: './public/' });
|
const publicFiles = serveStatic({ root: './public/' });
|
||||||
const staticFiles = serveStatic({ root: './static/' });
|
const staticFiles = serveStatic({ root: './static/' });
|
||||||
const frontendController = serveStatic({ path: './public/index.html' });
|
|
||||||
|
|
||||||
// Known frontend routes
|
// Known frontend routes
|
||||||
app.get('/@:acct', frontendController);
|
app.get('/@:acct', frontendController);
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,14 @@ class Conf {
|
||||||
static get cronEnabled(): boolean {
|
static get cronEnabled(): boolean {
|
||||||
return optionalBooleanSchema.parse(Deno.env.get('CRON_ENABLED')) ?? true;
|
return optionalBooleanSchema.parse(Deno.env.get('CRON_ENABLED')) ?? true;
|
||||||
}
|
}
|
||||||
|
/** Crawler User-Agent regex to render link previews to. */
|
||||||
|
static get crawlerRegex(): RegExp {
|
||||||
|
return new RegExp(
|
||||||
|
Deno.env.get('CRAWLER_REGEX') ||
|
||||||
|
'googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp|mastodon|pleroma|Discordbot|AhrefsBot|SEMrushBot|MJ12bot|SeekportBot|Synapse|Matrix',
|
||||||
|
'i',
|
||||||
|
);
|
||||||
|
}
|
||||||
/** Path to the custom policy module. Must be an absolute path, https:, npm:, or jsr: URI. */
|
/** Path to the custom policy module. Must be an absolute path, https:, npm:, or jsr: URI. */
|
||||||
static get policy(): string {
|
static get policy(): string {
|
||||||
return Deno.env.get('DITTO_POLICY') || new URL('../data/policy.ts', import.meta.url).pathname;
|
return Deno.env.get('DITTO_POLICY') || new URL('../data/policy.ts', import.meta.url).pathname;
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
|
||||||
}
|
}
|
||||||
return filters;
|
return filters;
|
||||||
} catch {
|
} catch {
|
||||||
// do nothing
|
// fall through
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -164,7 +164,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
|
||||||
return [{ kinds: [0], authors: [pubkey] }];
|
return [{ kinds: [0], authors: [pubkey] }];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// do nothing
|
// fall through
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
72
src/controllers/frontend.ts
Normal file
72
src/controllers/frontend.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { AppMiddleware } from '@/app.ts';
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { Stickynotes } from '@soapbox/stickynotes';
|
||||||
|
import { Storages } from '@/storages.ts';
|
||||||
|
import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts';
|
||||||
|
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||||
|
import { lookupPubkey } from '@/utils/lookup.ts';
|
||||||
|
import { renderMetadata } from '@/views/meta.ts';
|
||||||
|
import { getAuthor, getEvent } from '@/queries.ts';
|
||||||
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
|
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
|
|
||||||
|
const console = new Stickynotes('ditto:frontend');
|
||||||
|
|
||||||
|
/** Placeholder to find & replace with metadata. */
|
||||||
|
const META_PLACEHOLDER = '<!--server-generated-meta-->' as const;
|
||||||
|
|
||||||
|
export const frontendController: AppMiddleware = async (c, next) => {
|
||||||
|
try {
|
||||||
|
const content = await Deno.readTextFile(new URL('../../public/index.html', import.meta.url));
|
||||||
|
|
||||||
|
const ua = c.req.header('User-Agent');
|
||||||
|
console.debug('ua', ua);
|
||||||
|
|
||||||
|
if (!Conf.crawlerRegex.test(ua ?? '')) {
|
||||||
|
return c.html(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes(META_PLACEHOLDER)) {
|
||||||
|
const params = getPathParams(c.req.path);
|
||||||
|
try {
|
||||||
|
const entities = await getEntities(params ?? {});
|
||||||
|
const meta = renderMetadata(c.req.url, entities);
|
||||||
|
return c.html(content.replace(META_PLACEHOLDER, meta));
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Error building meta tags: ${e}`);
|
||||||
|
return c.html(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.html(content);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getEntities(params: { acct?: string; statusId?: string }): Promise<MetadataEntities> {
|
||||||
|
const store = await Storages.db();
|
||||||
|
|
||||||
|
const entities: MetadataEntities = {
|
||||||
|
instance: await getInstanceMetadata(store),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.statusId) {
|
||||||
|
const event = await getEvent(params.statusId, { kind: 1 });
|
||||||
|
if (event) {
|
||||||
|
entities.status = await renderStatus(event, {});
|
||||||
|
entities.account = entities.status?.account;
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.acct) {
|
||||||
|
const pubkey = await lookupPubkey(params.acct);
|
||||||
|
const event = pubkey ? await getAuthor(pubkey) : undefined;
|
||||||
|
if (event) {
|
||||||
|
entities.account = await renderAccount(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
15
src/entities/MastodonAttachment.ts
Normal file
15
src/entities/MastodonAttachment.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export interface MastodonAttachment {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
preview_url?: string;
|
||||||
|
remote_url?: string | null;
|
||||||
|
description?: string;
|
||||||
|
blurhash?: string | null;
|
||||||
|
meta?: {
|
||||||
|
original?: {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
||||||
|
import { MastodonAttachment } from '@/entities/MastodonAttachment.ts';
|
||||||
import { PreviewCard } from '@/entities/PreviewCard.ts';
|
import { PreviewCard } from '@/entities/PreviewCard.ts';
|
||||||
|
|
||||||
export interface MastodonStatus {
|
export interface MastodonStatus {
|
||||||
|
|
@ -24,7 +25,7 @@ export interface MastodonStatus {
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
reblog: MastodonStatus | null;
|
reblog: MastodonStatus | null;
|
||||||
application: unknown;
|
application: unknown;
|
||||||
media_attachments: unknown[];
|
media_attachments: MastodonAttachment[];
|
||||||
mentions: unknown[];
|
mentions: unknown[];
|
||||||
tags: unknown[];
|
tags: unknown[];
|
||||||
emojis: unknown[];
|
emojis: unknown[];
|
||||||
|
|
|
||||||
22
src/utils/html.ts
Normal file
22
src/utils/html.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { escape } from 'entities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param strings The constant portions of the template string.
|
||||||
|
* @param values The templated values.
|
||||||
|
* @returns The built HTML.
|
||||||
|
* @example
|
||||||
|
* ```
|
||||||
|
* const unsafe = `oops <script>alert(1)</script>`;
|
||||||
|
* testing.innerHTML = html`foo bar baz ${unsafe}`;
|
||||||
|
* console.assert(testing === "foo bar baz oops<script>alert(1)</script>");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function html(strings: TemplateStringsArray, ...values: (string | number)[]) {
|
||||||
|
const built = [];
|
||||||
|
for (let i = 0; i < strings.length; i++) {
|
||||||
|
built.push(strings[i] || '');
|
||||||
|
const val = values[i];
|
||||||
|
built.push(escape((val || '').toString()));
|
||||||
|
}
|
||||||
|
return built.join('');
|
||||||
|
}
|
||||||
|
|
@ -75,7 +75,7 @@ export function extractIdentifier(value: string): string | undefined {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// do nothing
|
// fall through
|
||||||
}
|
}
|
||||||
|
|
||||||
value = value.replace(/^@/, '');
|
value = value.replace(/^@/, '');
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
import { NIP05, NStore } from '@nostrify/nostrify';
|
import { NIP05, NStore } from '@nostrify/nostrify';
|
||||||
import Debug from '@soapbox/stickynotes/debug';
|
import Debug from '@soapbox/stickynotes/debug';
|
||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
import tldts from 'tldts';
|
import tldts from 'tldts';
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
|
import { Storages } from '@/storages.ts';
|
||||||
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
|
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
|
||||||
import { Time } from '@/utils/time.ts';
|
import { Time } from '@/utils/time.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Nip05, parseNip05 } from '@/utils.ts';
|
||||||
import { fetchWorker } from '@/workers/fetch.ts';
|
import { fetchWorker } from '@/workers/fetch.ts';
|
||||||
|
|
||||||
const debug = Debug('ditto:nip05');
|
const debug = Debug('ditto:nip05');
|
||||||
|
|
@ -60,4 +61,20 @@ async function localNip05Lookup(store: NStore, localpart: string): Promise<nip19
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function parseAndVerifyNip05(
|
||||||
|
nip05: string | undefined,
|
||||||
|
pubkey: string,
|
||||||
|
signal = AbortSignal.timeout(3000),
|
||||||
|
): Promise<Nip05 | undefined> {
|
||||||
|
if (!nip05) return;
|
||||||
|
try {
|
||||||
|
const result = await nip05Cache.fetch(nip05, { signal });
|
||||||
|
if (result.pubkey === pubkey) {
|
||||||
|
return parseNip05(nip05);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { localNip05Lookup, nip05Cache };
|
export { localNip05Lookup, nip05Cache };
|
||||||
|
|
|
||||||
66
src/utils/og-metadata.ts
Normal file
66
src/utils/og-metadata.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { match } from 'path-to-regexp';
|
||||||
|
|
||||||
|
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
||||||
|
import { MastodonStatus } from '@/entities/MastodonStatus.ts';
|
||||||
|
import { InstanceMetadata } from '@/utils/instance.ts';
|
||||||
|
|
||||||
|
export interface MetadataEntities {
|
||||||
|
status?: MastodonStatus;
|
||||||
|
account?: MastodonAccount;
|
||||||
|
instance: InstanceMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetadataPathParams {
|
||||||
|
statusId?: string;
|
||||||
|
acct?: string;
|
||||||
|
bech32?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL routes to serve metadata on. */
|
||||||
|
const SSR_ROUTES = [
|
||||||
|
'/\\@:acct/posts/:statusId',
|
||||||
|
'/\\@:acct/:statusId',
|
||||||
|
'/\\@:acct',
|
||||||
|
'/users/:acct/statuses/:statusId',
|
||||||
|
'/users/:acct',
|
||||||
|
'/statuses/:statusId',
|
||||||
|
'/notice/:statusId',
|
||||||
|
'/posts/:statusId',
|
||||||
|
'/:bech32',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SSR_ROUTE_MATCHERS = SSR_ROUTES.map((route) => match(route));
|
||||||
|
|
||||||
|
export function getPathParams(path: string): MetadataPathParams | undefined {
|
||||||
|
for (const matcher of SSR_ROUTE_MATCHERS) {
|
||||||
|
const result = matcher(path);
|
||||||
|
if (!result) continue;
|
||||||
|
|
||||||
|
const params: MetadataPathParams = result.params;
|
||||||
|
|
||||||
|
if (params.bech32) {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(params.bech32);
|
||||||
|
switch (decoded.type) {
|
||||||
|
case 'nevent':
|
||||||
|
params.statusId = decoded.data.id;
|
||||||
|
break;
|
||||||
|
case 'note':
|
||||||
|
params.statusId = decoded.data;
|
||||||
|
break;
|
||||||
|
case 'nprofile':
|
||||||
|
params.acct = decoded.data.pubkey;
|
||||||
|
break;
|
||||||
|
case 'npub':
|
||||||
|
params.acct = decoded.data;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ export async function getRelays(store: NStore, pubkey: string): Promise<Set<stri
|
||||||
relays.add(url.toString() as `wss://${string}`);
|
relays.add(url.toString() as `wss://${string}`);
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// do nothing
|
// fall through
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ async function unfurlCard(url: string, signal: AbortSignal): Promise<PreviewCard
|
||||||
debug(`Unfurling ${url}...`);
|
debug(`Unfurling ${url}...`);
|
||||||
try {
|
try {
|
||||||
const result = await unfurl(url, {
|
const result = await unfurl(url, {
|
||||||
fetch: (url) => fetchWorker(url, { signal }),
|
fetch: (url) =>
|
||||||
|
fetchWorker(url, {
|
||||||
|
headers: { 'User-Agent': 'WhatsApp/2' },
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { oEmbed, title, description, canonical_url, open_graph } = result;
|
const { oEmbed, title, description, canonical_url, open_graph } = result;
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import { Conf } from '@/config.ts';
|
||||||
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
||||||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
import { getLnurl } from '@/utils/lnurl.ts';
|
import { getLnurl } from '@/utils/lnurl.ts';
|
||||||
import { nip05Cache } from '@/utils/nip05.ts';
|
import { parseAndVerifyNip05 } from '@/utils/nip05.ts';
|
||||||
import { getTagSet } from '@/utils/tags.ts';
|
import { getTagSet } from '@/utils/tags.ts';
|
||||||
import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
|
import { nostrDate, nostrNow } from '@/utils.ts';
|
||||||
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
||||||
|
|
||||||
interface ToAccountOpts {
|
interface ToAccountOpts {
|
||||||
|
|
@ -115,20 +115,4 @@ function accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): Promise<Ma
|
||||||
return renderAccount(event, opts);
|
return renderAccount(event, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseAndVerifyNip05(
|
|
||||||
nip05: string | undefined,
|
|
||||||
pubkey: string,
|
|
||||||
signal = AbortSignal.timeout(3000),
|
|
||||||
): Promise<Nip05 | undefined> {
|
|
||||||
if (!nip05) return;
|
|
||||||
try {
|
|
||||||
const result = await nip05Cache.fetch(nip05, { signal });
|
|
||||||
if (result.pubkey === pubkey) {
|
|
||||||
return parseNip05(nip05);
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { accountFromPubkey, renderAccount };
|
export { accountFromPubkey, renderAccount };
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import { MastodonAttachment } from '@/entities/MastodonAttachment.ts';
|
||||||
import { getUrlMediaType } from '@/utils/media.ts';
|
import { getUrlMediaType } from '@/utils/media.ts';
|
||||||
|
|
||||||
/** Render Mastodon media attachment. */
|
/** Render Mastodon media attachment. */
|
||||||
function renderAttachment(media: { id?: string; data: string[][] }) {
|
function renderAttachment(
|
||||||
|
media: { id?: string; data: string[][] },
|
||||||
|
): (MastodonAttachment & { cid?: string }) | undefined {
|
||||||
const { id, data: tags } = media;
|
const { id, data: tags } = media;
|
||||||
|
|
||||||
const url = tags.find(([name]) => name === 'url')?.[1];
|
const url = tags.find(([name]) => name === 'url')?.[1];
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { NostrEvent } from '@nostrify/nostrify';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
|
import { MastodonAttachment } from '@/entities/MastodonAttachment.ts';
|
||||||
import { MastodonMention } from '@/entities/MastodonMention.ts';
|
import { MastodonMention } from '@/entities/MastodonMention.ts';
|
||||||
import { MastodonStatus } from '@/entities/MastodonStatus.ts';
|
import { MastodonStatus } from '@/entities/MastodonStatus.ts';
|
||||||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
|
|
@ -119,7 +120,9 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise<
|
||||||
pinned: Boolean(pinEvent),
|
pinned: Boolean(pinEvent),
|
||||||
reblog: null,
|
reblog: null,
|
||||||
application: null,
|
application: null,
|
||||||
media_attachments: media.map((m) => renderAttachment({ data: m })).filter(Boolean),
|
media_attachments: media.map((m) => renderAttachment({ data: m })).filter((m): m is MastodonAttachment =>
|
||||||
|
Boolean(m)
|
||||||
|
),
|
||||||
mentions,
|
mentions,
|
||||||
tags: [],
|
tags: [],
|
||||||
emojis: renderEmojis(event),
|
emojis: renderEmojis(event),
|
||||||
|
|
|
||||||
55
src/views/meta.ts
Normal file
55
src/views/meta.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { html } from '@/utils/html.ts';
|
||||||
|
import { MetadataEntities } from '@/utils/og-metadata.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a series of meta tags from supplied metadata for injection into the served HTML page.
|
||||||
|
* @param opts the metadata to use to fill the template.
|
||||||
|
* @returns the built OpenGraph metadata.
|
||||||
|
*/
|
||||||
|
export function renderMetadata(url: string, { account, status, instance }: MetadataEntities): string {
|
||||||
|
const tags: string[] = [];
|
||||||
|
|
||||||
|
const title = account ? `${account.display_name} (@${account.acct})` : instance.name;
|
||||||
|
const attachment = status?.media_attachments?.find((a) => a.type === 'image');
|
||||||
|
const description = status?.content || account?.note || instance.tagline;
|
||||||
|
const image = attachment?.preview_url || account?.avatar_static || instance.picture || Conf.local('/favicon.ico');
|
||||||
|
const siteName = instance?.name;
|
||||||
|
const width = attachment?.meta?.original?.width;
|
||||||
|
const height = attachment?.meta?.original?.height;
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
tags.push(html`<title>${title}</title>`);
|
||||||
|
tags.push(html`<meta property="og:title" content="${title}">`);
|
||||||
|
tags.push(html`<meta name="twitter:title" content="${title}">`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
tags.push(html`<meta name="description" content="${description}">`);
|
||||||
|
tags.push(html`<meta property="og:description" content="${description}">`);
|
||||||
|
tags.push(html`<meta name="twitter:description" content="${description}">`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
tags.push(html`<meta property="og:image" content="${image}">`);
|
||||||
|
tags.push(html`<meta name="twitter:image" content="${image}">`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof width === 'number' && typeof height === 'number') {
|
||||||
|
tags.push(html`<meta property="og:image:width" content="${width}">`);
|
||||||
|
tags.push(html`<meta property="og:image:height" content="${height}">`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteName) {
|
||||||
|
tags.push(html`<meta property="og:site_name" content="${siteName}">`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra tags (always present if other tags exist).
|
||||||
|
if (tags.length > 0) {
|
||||||
|
tags.push(html`<meta property="og:url" content="${url}">`);
|
||||||
|
tags.push('<meta property="og:type" content="website">');
|
||||||
|
tags.push('<meta name="twitter:card" content="summary">');
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags.join('');
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue