Add OG and Twitter Card markup, generate OG profile images

closes #2
This commit is contained in:
Râu Cao 2024-10-24 14:19:37 +02:00
parent d5793d47ff
commit fdd16d8236
Signed by: raucao
GPG Key ID: 37036C356E56CC51
10 changed files with 182 additions and 20 deletions

View File

@ -1,5 +1,6 @@
import { load } from "@std/dotenv";
import { parse as parseYaml } from "jsr:@std/yaml";
import { checkIfFileExists } from "./utils.ts";
import { log } from "./log.ts";
const dirname = Deno.cwd();
@ -15,8 +16,8 @@ const defaultUserConfigPaths = [
];
for (const path of defaultUserConfigPaths) {
const fileInfo = await Deno.lstat(path).catch((_e) => undefined);
if (fileInfo && fileInfo.isFile) {
const fileExists = await checkIfFileExists(path);
if (fileExists) {
userConfigPath = path;
break;
}

View File

@ -1,7 +1,7 @@
{
"tasks": {
"dev": "deno run --allow-net --allow-read --allow-env --watch server.ts",
"server": "deno run --allow-net --allow-read --allow-env server.ts"
"dev": "deno run --allow-all --watch server.ts",
"server": "deno run --allow-all server.ts"
},
"imports": {
"@deno/gfm": "jsr:@deno/gfm@^0.9.0",

View File

@ -5,6 +5,7 @@ import Article from "../models/article.ts";
import Profile from "../models/profile.ts";
import { articleHtml } from "../html.ts";
import notFoundHandler from "../handlers/not-found.ts";
import generateOgProfileImage from "../tasks/generate_og_profile_image.ts";
const userEventHandler = async function (ctx: Context) {
const username = ctx.state.username.replace(/^(@|~)/, "");
@ -27,12 +28,13 @@ const userEventHandler = async function (ctx: Context) {
const article = new Article(articleEvent);
const profile = new Profile(profileEvent, username);
const html = articleHtml(article, profile);
generateOgProfileImage(profile);
ctx.response.body = html;
} else {
notFoundHandler(ctx);
}
} catch (_e) {
} catch (e) {
notFoundHandler(ctx);
}
};

View File

@ -5,6 +5,7 @@ import Article from "../models/article.ts";
import Profile from "../models/profile.ts";
import { profilePageHtml } from "../html.ts";
import notFoundHandler from "../handlers/not-found.ts";
import generateOgProfileImage from "../tasks/generate_og_profile_image.ts";
const userProfileHandler = async function (ctx: Context) {
const username = ctx.state.path.replace(/^(@|~)/, "");
@ -23,6 +24,7 @@ const userProfileHandler = async function (ctx: Context) {
const articleEvents = await fetchArticlesByAuthor(pubkey);
const articles = articleEvents.map((a) => new Article(a));
const html = profilePageHtml(profile, articles);
generateOgProfileImage(profile);
ctx.response.body = html;
} else {

63
html.ts
View File

@ -2,13 +2,13 @@ import { localizeDate } from "./dates.ts";
import Article from "./models/article.ts";
import Profile from "./models/profile.ts";
function htmlLayout(title: string, body: string, profile?: Profile): string {
let feedLinksHtml = "";
if (profile) {
feedLinksHtml =
`<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
}
interface HtmlLayoutOptions {
title: string;
body: string;
metaHtml?: string;
}
function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string {
return `
<!DOCTYPE html>
<html>
@ -17,7 +17,7 @@ function htmlLayout(title: string, body: string, profile?: Profile): string {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>${title}</title>
${feedLinksHtml}
${metaHtml || ""}
<link rel="stylesheet" type="text/css" href="/assets/css/layout.css" />
<link rel="stylesheet" type="text/css" href="/assets/css/themes/default-light.css" />
</head>
@ -35,7 +35,7 @@ export function errorPageHtml(statusCode: number, title: string): string {
</main>
`;
return htmlLayout(title, body);
return htmlLayout({ title, body });
}
export function articleHtml(article: Article, profile: Profile): string {
@ -68,7 +68,10 @@ export function articleHtml(article: Article, profile: Profile): string {
</main>
`;
return htmlLayout(pageTitle, body, profile);
let metaHtml = articleMetaHtml(article, profile);
metaHtml += feedLinksHtml(profile);
return htmlLayout({ title: pageTitle, body, metaHtml });
}
function articleListItemHtml(article: Article): string {
@ -127,7 +130,10 @@ export function profilePageHtml(profile: Profile, articles: Article[]): string {
</main>
`;
return htmlLayout(title, body, profile);
let metaHtml = profileMetaHtml(profile);
metaHtml += feedLinksHtml(profile);
return htmlLayout({ title, body, metaHtml });
}
function openWithNostrAppHtml(bech32Id: string): string {
@ -156,3 +162,40 @@ function openWithNostrAppHtml(bech32Id: string): string {
</div>
`;
}
function feedLinksHtml(profile) {
return `<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
}
function profileMetaHtml(profile: Profile) {
return `
<meta property="og:url" content="${profile.profileUrl}">
<meta property="og:type" content="website">
<meta property="og:title" content="${profile.name} on Nostr">
<meta property="og:description" content="">
<meta property="og:image" content="${profile.ogImageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="nostr.kosmos.org">
<meta property="twitter:url" content="${profile.profileUrl}">
<meta name="twitter:title" content="${profile.name} on Nostr">
<meta name="twitter:description" content="">
<meta name="twitter:image" content="${profile.ogImageUrl}">
`;
}
function articleMetaHtml(article: Article, profile: Profile) {
const imageUrl = article.image || profile.ogImageUrl;
return `
<meta property="og:url" content="${article.url}">
<meta property="og:type" content="website">
<meta property="og:title" content="${article.title}">
<meta property="og:description" content="${article.summary}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="${article.url}">
<meta name="twitter:title" content="${article.title}">
<meta name="twitter:description" content="${article.summary}">
<meta name="twitter:image" content="${imageUrl}">
`;
}

View File

@ -28,6 +28,11 @@ export default class Article {
return tag ? tag[1] : "Untitled";
}
get image(): string {
const tag = this.event.tags.find((t) => t[0] === "image");
return tag ? tag[1] : undefined;
}
get summary(): string {
const tag = this.event.tags.find((t) => t[0] === "summary");
return tag ? tag[1] : "";

View File

@ -1,5 +1,5 @@
import { nip19, NostrEvent as NEvent } from "@nostr/tools";
// import { NEvent } from "../nostr.ts";
import config from "../config.ts";
export interface ProfileData {
name?: string;
@ -54,4 +54,12 @@ export default class Profile {
get npub(): string {
return nip19.npubEncode(this.pubkey);
}
get profileUrl(): string {
return `${config.base_url}/@${this.username}`;
}
get ogImageUrl(): string {
return `${config.base_url}/assets/g/img/og-p-${this.event.id}.png`;
}
}

View File

@ -1,4 +1,5 @@
import { Application, Router, send } from "@oak/oak";
import { createSubtrTmpDirectories } from "./utils.ts";
import config from "./config.ts";
import naddrHandler from "./handlers/naddr.ts";
import nprofileHandler from "./handlers/nprofile.ts";
@ -54,16 +55,24 @@ router.get("/:username/:identifier", async (ctx) => {
router.get("/assets/:path*", async (ctx) => {
try {
const filePath = ctx.params.path || "";
let filePath = ctx.params.path || "";
let root: string;
await send(ctx, filePath, {
root: `${Deno.cwd()}/assets`,
});
if (filePath.startsWith("g/img/")) {
filePath = filePath.replace(/^g\//, "");
root = "/tmp/substr";
} else {
root = `${Deno.cwd()}/assets`;
}
await send(ctx, filePath, { root });
} catch (_e) {
notFoundHandler(ctx);
}
});
await createSubtrTmpDirectories();
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());

View File

@ -0,0 +1,63 @@
import Profile from "../models/profile.ts";
import { checkIfFileExists, runCommand } from "../utils.ts";
import { log } from "../log.ts";
const tmpImgDir = "/tmp/substr/img";
const createRoundedImage = function (profile: Profile) {
const command = [
'magick',
profile.picture,
'-resize', '256x256',
'(', '+clone', '-alpha', 'extract',
'-draw', "fill black polygon 0,0 0,128 128,0 fill white circle 128,128 128,0",
'(', '+clone', '-flip', ')', '-compose', 'Multiply', '-composite',
'(', '+clone', '-flop', ')', '-compose', 'Multiply', '-composite',
')',
'-alpha', 'off',
'-compose', 'CopyOpacity',
'-composite',
`${tmpImgDir}/p-${profile.event.id}-rounded.png`
];
return runCommand(command);
}
const createOgImage = async function (profile: Profile, ogImagePath: string, backgroundColor: string) {
const status = await createRoundedImage(profile);
if (status.success) {
const command = [
'magick',
`${tmpImgDir}/p-${profile.event.id}-rounded.png`,
'-resize', '256x256',
'-background', backgroundColor,
'-gravity', 'center',
'-extent', '1200x630',
'-size', '1200x630',
"-format", "png",
ogImagePath
];
return runCommand(command);
}
};
const generateOgProfileImage = async function(profile: Profile) {
if (!profile.picture) return false;
const ogImagePath = `${tmpImgDir}/og-p-${profile.event.id}.png`;
const backgroundColor = "#333333";
const fileExists = await checkIfFileExists(ogImagePath);
if (!fileExists) {
const status = await createOgImage(profile, ogImagePath, backgroundColor);
if (status.success) {
log(`Created OG image for ${profile.username}: ${ogImagePath}`, "blue")
} else {
log(`Could not create OG image for ${profile.username}`, "yellow")
}
}
}
export default generateOgProfileImage;

29
utils.ts Normal file
View File

@ -0,0 +1,29 @@
export async function checkIfFileExists(filePath: string): boolean {
try {
await Deno.lstat(filePath);
return true;
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return false;
} else {
throw error;
}
}
}
export async function createSubtrTmpDirectories(): void {
const dirs = [
"/tmp/substr/img/"
]
for (const path of dirs) {
await Deno.mkdir(path, { recursive: true });
}
}
export async function runCommand(command) {
const process = Deno.run({ cmd: command });
const status = await process.status();
process.close();
return status;
}