246 lines
7.2 KiB
TypeScript
246 lines
7.2 KiB
TypeScript
import { localizeDate } from "./dates.ts";
|
|
import Article from "./models/article.ts";
|
|
import Profile from "./models/profile.ts";
|
|
|
|
interface HtmlLayoutOptions {
|
|
title: string;
|
|
body: string;
|
|
metaHtml?: string;
|
|
}
|
|
|
|
function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string {
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
<title>${title}</title>
|
|
${metaHtml || ""}
|
|
<link rel="stylesheet" type="text/css" href="/assets/css/layout.css" />
|
|
<link rel="stylesheet" type="text/css" href="/assets/css/prism.css" />
|
|
<link rel="stylesheet" type="text/css" href="/assets/css/themes/default-light.css" />
|
|
</head>
|
|
<body>
|
|
${body}
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
|
|
export function errorPageHtml(statusCode: number, title: string): string {
|
|
const body = `
|
|
<main>
|
|
<h1>${statusCode} - ${title}</h1>
|
|
</main>
|
|
`;
|
|
|
|
return htmlLayout({ title, body });
|
|
}
|
|
|
|
export async function articleHtml(
|
|
article: Article,
|
|
profile: Profile,
|
|
): Promise<string> {
|
|
const publishedAtFormatted = localizeDate(article.publishedAt);
|
|
const pageTitle = article.isDraft ? `Draft: ${article.title}` : article.title;
|
|
let draftLabel = ``;
|
|
if (article.isDraft) {
|
|
draftLabel = `<p class="draft-label">Draft version</p>`;
|
|
}
|
|
|
|
const body = `
|
|
<main>
|
|
<header>
|
|
${draftLabel}
|
|
<h1>${titleHtml(article.title)}</h1>
|
|
<div class="meta">
|
|
<img class="avatar" src="${profile.avatarImageUrl}" alt="User Avatar" />
|
|
<div class="content">
|
|
<span class="name"><a href="/@${profile.username}">${profile.name}</a></span>
|
|
<span class="date">${publishedAtFormatted}</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<article>
|
|
${await article.buildContentHtml()}
|
|
<footer>
|
|
${openWithNostrAppHtml(article.naddr)}
|
|
</footer>
|
|
</article>
|
|
</main>
|
|
`;
|
|
|
|
let metaHtml = articleMetaHtml(article, profile);
|
|
metaHtml += feedLinksHtml(profile);
|
|
|
|
return htmlLayout({ title: pageTitle, body, metaHtml });
|
|
}
|
|
|
|
function titleHtml(title: string) {
|
|
return title.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
}
|
|
|
|
function articleListItemHtml(article: Article): string {
|
|
const formattedDate = localizeDate(article.publishedAt);
|
|
|
|
return `
|
|
<div class="item">
|
|
<h3><a href="/${article.naddr}">${titleHtml(article.title)}</a></h3>
|
|
<p class="meta">
|
|
${formattedDate}
|
|
</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export function articleListHtml(articles: Article[]): string {
|
|
if (articles.length === 0) return "";
|
|
let html = "";
|
|
|
|
for (const article of articles) {
|
|
html += articleListItemHtml(article);
|
|
}
|
|
|
|
return `
|
|
<h2>Articles</h2>
|
|
<div class="article-list">
|
|
${html}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function userAddressHtml(profile: Profile) {
|
|
let html = "";
|
|
|
|
if (profile.lud16) {
|
|
html += `<dt>Lightning address</dt><dd>${profile.lud16}</dd>\n`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
function nip05VerifiedHtml(verified: boolean): string {
|
|
if (verified) {
|
|
return ` <span class="verified" title="Verified">✔</span>`;
|
|
} else {
|
|
return ` <span class="not-verified" title="Verification failed">✕</span>`;
|
|
}
|
|
}
|
|
|
|
export async function profilePageHtml(
|
|
profile: Profile,
|
|
articles: Article[],
|
|
): Promise<string> {
|
|
const title = `${profile.name} on Nostr`;
|
|
let nip05Html = "";
|
|
|
|
if (profile.nip05) {
|
|
const nip05Verified = await profile.verifyNip05();
|
|
nip05Html += `<p class="nip05">${profile.nip05}${
|
|
nip05VerifiedHtml(nip05Verified)
|
|
}</p>\n`;
|
|
}
|
|
|
|
const body = `
|
|
<main class="profile-page">
|
|
<header>
|
|
<img class="avatar" src="${profile.avatarImageUrl}" alt="User Avatar" />
|
|
<div class="bio">
|
|
<h1>${profile.name}</h1>
|
|
${nip05Html}
|
|
<p class="about">
|
|
${profile.about}
|
|
</p>
|
|
</div>
|
|
</header>
|
|
<details>
|
|
<summary>Details</summary>
|
|
<dl>
|
|
<dt>Public key</dt>
|
|
<dd>${profile.npub}</dd>
|
|
${userAddressHtml(profile)}
|
|
</dl>
|
|
</details>
|
|
<section>
|
|
${articleListHtml(articles)}
|
|
</section>
|
|
</main>
|
|
`;
|
|
|
|
let metaHtml = profileMetaHtml(profile);
|
|
metaHtml += feedLinksHtml(profile);
|
|
|
|
return htmlLayout({ title, body, metaHtml });
|
|
}
|
|
|
|
function openWithNostrAppHtml(bech32Id: string): string {
|
|
let appLinksHtml = "";
|
|
const appLinks = [
|
|
{ title: "Habla", href: `https://habla.news/a/${bech32Id}` },
|
|
{
|
|
title: "noStrudel",
|
|
href: `https://nostrudel.ninja/#/articles/${bech32Id}`,
|
|
},
|
|
{ title: "Coracle", href: `https://coracle.social/${bech32Id}` },
|
|
{ title: "YakiHonne", href: `https://yakihonne.com/article/${bech32Id}` },
|
|
];
|
|
|
|
for (const link of appLinks) {
|
|
appLinksHtml += `<a href="${link.href}" target="_blank">${link.title}</a>`;
|
|
}
|
|
|
|
return `
|
|
<div class="open-with dropdown">
|
|
<button class="dropdown-button">Open with Nostr app</button>
|
|
<div class="dropdown-content">
|
|
<a href="nostr:${bech32Id}" target="_blank">🔗 Nostr Link</a>
|
|
<h4 class="title">Apps</h4>
|
|
${appLinksHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function feedLinksHtml(profile: Profile) {
|
|
return `<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
|
|
}
|
|
|
|
function profileMetaHtml(profile: Profile) {
|
|
return `
|
|
<link rel="icon" href="${profile.avatarImageUrl}" type="image/png">
|
|
<link rel="alternate" type="application/nostr+json" href="nostr:${profile.npub}" title="${profile.name} on Nostr">
|
|
<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 `
|
|
<link rel="icon" href="${profile.avatarImageUrl}" type="image/png">
|
|
<link rel="alternate" type="application/nostr+json" href="nostr:${article.naddr}" title="This article on Nostr">
|
|
<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}">
|
|
`;
|
|
}
|