add title, description and seo metadata to various pages

resolves #218
pull/246/head
Dario Piotrowicz 2023-02-10 11:52:21 +00:00
rodzic 1d9775b3aa
commit e3271478e1
12 zmienionych plików z 307 dodań i 70 usunięć

Wyświetl plik

@ -1,6 +1,6 @@
import { defaultImages } from 'wildebeest/config/accounts'
import { generateUserKey } from 'wildebeest/backend/src/utils/key-ops'
import { type APObject, sanitizeContent, sanitizeName } from '../objects'
import { type APObject, sanitizeContent, getTextContent } from '../objects'
import { addPeer } from 'wildebeest/backend/src/activitypub/peers'
const PERSON = 'Person'
@ -63,10 +63,10 @@ export async function get(url: string | URL): Promise<Actor> {
actor.content = await sanitizeContent(data.content)
}
if (data.name) {
actor.name = await sanitizeName(data.name)
actor.name = await getTextContent(data.name)
}
if (data.preferredUsername) {
actor.preferredUsername = await sanitizeName(data.preferredUsername)
actor.preferredUsername = await getTextContent(data.preferredUsername)
}
// This is mostly for testing where for convenience not all values

Wyświetl plik

@ -216,7 +216,7 @@ export async function sanitizeObjectProperties(properties: unknown): Promise<APO
sanitized.content = await sanitizeContent(properties.content as string)
}
if ('name' in properties) {
sanitized.name = await sanitizeName(properties.name as string)
sanitized.name = await getTextContent(properties.name as string)
}
return sanitized
}
@ -235,12 +235,12 @@ export async function sanitizeContent(unsafeContent: string): Promise<string> {
}
/**
* Sanitizes given string as an ActivityPub Object name.
*
* This sanitization removes all HTML elements from the string leaving only the text content.
* This method removes all HTML elements from the string leaving only the text content.
*/
export async function sanitizeName(unsafeName: string): Promise<string> {
return await getNameRewriter().transform(new Response(unsafeName)).text()
export async function getTextContent(unsafeName: string): Promise<string> {
const rawContent = getTextContentRewriter().transform(new Response(unsafeName))
const text = await rawContent.text()
return text.trim()
}
function getContentRewriter() {
@ -263,12 +263,15 @@ function getContentRewriter() {
return contentRewriter
}
function getNameRewriter() {
const nameRewriter = new HTMLRewriter()
nameRewriter.on('*', {
function getTextContentRewriter() {
const textContentRewriter = new HTMLRewriter()
textContentRewriter.on('*', {
element(el) {
el.removeAndKeepContent()
if (['p', 'br'].includes(el.tagName)) {
el.after(' ')
}
},
})
return nameRewriter
return textContentRewriter
}

Wyświetl plik

@ -923,7 +923,7 @@ const mastodonRawStatuses: MastodonStatus[] = [
favourites_count: 537,
edited_at: null,
content:
'\u003cp\u003eHi, meet HiDock!\u003c/p\u003e\u003cp\u003eIt\u0026#39;s a free Mac app that lets you set different Dock settings for different display configurations\u003c/p\u003e\u003cp\u003e\u003ca href="https://hidock.app" target="_blank" rel="nofollow noopener noreferrer"\u003e\u003cspan class="invisible"\u003ehttps://\u003c/span\u003e\u003cspan class=""\u003ehidock.app\u003c/span\u003e\u003cspan class="invisible"\u003e\u003c/span\u003e\u003c/a\u003e →\u003c/p\u003e',
'\u003cp\u003eHi, meet HiDock!\u003c/p\u003e\u003cp\u003eIt\'s a free Mac app that lets you set different Dock settings for different display configurations\u003c/p\u003e\u003cp\u003e\u003ca href="https://hidock.app" target="_blank" rel="nofollow noopener noreferrer"\u003e\u003cspan class="invisible"\u003ehttps://\u003c/span\u003e\u003cspan class=""\u003ehidock.app\u003c/span\u003e\u003cspan class="invisible"\u003e\u003c/span\u003e\u003c/a\u003e →\u003c/p\u003e',
reblog: null,
application: {
name: 'Web',

Wyświetl plik

@ -5,17 +5,19 @@ import { formatDateTime } from '~/utils/dateTime'
import { formatRoundedNumber } from '~/utils/numbers'
import * as statusAPI from 'wildebeest/functions/api/v1/statuses/[id]'
import * as contextAPI from 'wildebeest/functions/api/v1/statuses/[id]/context'
import { Link, loader$ } from '@builder.io/qwik-city'
import { DocumentHead, Link, loader$ } from '@builder.io/qwik-city'
import StickyHeader from '~/components/StickyHeader/StickyHeader'
import { Avatar } from '~/components/avatar'
import { MediaGallery } from '~/components/MediaGallery.tsx'
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
import styles from '../../../../utils/innerHtmlContent.scss?inline'
import { getTextContent } from 'wildebeest/backend/src/activitypub/objects'
import { getDocumentHead } from '~/utils/getDocumentHead'
export const statusLoader = loader$<
{ DATABASE: D1Database },
Promise<{ status: MastodonStatus; context: StatusContext }>
Promise<{ status: MastodonStatus; statusTextContent: string; context: StatusContext }>
>(async ({ request, html, platform, params }) => {
const domain = new URL(request.url).hostname
let statusText = ''
@ -28,6 +30,9 @@ export const statusLoader = loader$<
if (!statusText) {
throw html(404, getNotFoundHtml())
}
const status: MastodonStatus = JSON.parse(statusText)
const statusTextContent = await getTextContent(status.content)
try {
const contextResponse = await contextAPI.handleRequest(domain, platform.DATABASE, params.statusId)
const contextText = await contextResponse.text()
@ -35,7 +40,7 @@ export const statusLoader = loader$<
if (!context) {
throw new Error(`No context present for status with ${params.statusId}`)
}
return { status: JSON.parse(statusText), context }
return { status, statusTextContent, context }
} catch {
throw html(500, getErrorHtml('No context for the status has been found, please try again later'))
}
@ -124,3 +129,21 @@ export const Info = component$<{ href: string | null }>(({ href }) => {
</>
)
})
export const head: DocumentHead = ({ getData }) => {
const { status, statusTextContent } = getData(statusLoader)
const title = `${status.account.display_name}: ${statusTextContent.substring(0, 30)}${
statusTextContent.length > 30 ? '…' : ''
} - Wildebeest`
return getDocumentHead({
title,
description: statusTextContent,
og: {
type: 'article',
url: status.url,
image: status.account.avatar,
},
})
}

Wyświetl plik

@ -1,5 +1,5 @@
import { component$, useStyles$ } from '@builder.io/qwik'
import { loader$ } from '@builder.io/qwik-city'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import { MastodonAccount } from 'wildebeest/backend/src/types'
import StickyHeader from '~/components/StickyHeader/StickyHeader'
import { formatDateTime } from '~/utils/dateTime'
@ -8,29 +8,36 @@ import styles from '../../../utils/innerHtmlContent.scss?inline'
import { getAccount } from 'wildebeest/backend/src/accounts/getAccount'
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
import { getDocumentHead } from '~/utils/getDocumentHead'
export const accountLoader = loader$<{ DATABASE: D1Database }, Promise<MastodonAccount>>(
async ({ platform, request, html }) => {
let account: MastodonAccount | null = null
try {
const url = new URL(request.url)
const domain = url.hostname
const accountId = url.pathname.split('/')[1]
export const accountLoader = loader$<
{ DATABASE: D1Database },
Promise<{ account: MastodonAccount; accountHandle: string }>
>(async ({ platform, request, html }) => {
let account: MastodonAccount | null = null
try {
const url = new URL(request.url)
const domain = url.hostname
const accountId = url.pathname.split('/')[1]
account = await getAccount(domain, accountId, platform.DATABASE)
} catch {
throw html(
500,
getErrorHtml(`An error happened when trying to retrieve the account's details, please try again later`)
)
}
if (!account) {
throw html(404, getNotFoundHtml())
}
return account
account = await getAccount(domain, accountId, platform.DATABASE)
} catch {
throw html(
500,
getErrorHtml(`An error happened when trying to retrieve the account's details, please try again later`)
)
}
)
if (!account) {
throw html(404, getNotFoundHtml())
}
const accountDomain = getAccountDomain(account)
const accountHandle = `@${account.acct}${accountDomain ? `@${accountDomain}` : ''}`
return { account, accountHandle }
})
export default component$(() => {
useStyles$(styles)
@ -40,50 +47,45 @@ export default component$(() => {
const fields = [
{
name: 'Joined',
value: formatDateTime(accountDetails.created_at, false),
value: formatDateTime(accountDetails.account.created_at, false),
},
...accountDetails.fields,
...accountDetails.account.fields,
]
const stats = [
{
name: 'Posts',
value: formatRoundedNumber(accountDetails.statuses_count),
value: formatRoundedNumber(accountDetails.account.statuses_count),
},
{
name: 'Following',
value: formatRoundedNumber(accountDetails.following_count),
value: formatRoundedNumber(accountDetails.account.following_count),
},
{
name: 'Followers',
value: formatRoundedNumber(accountDetails.followers_count),
value: formatRoundedNumber(accountDetails.account.followers_count),
},
]
const accountDomain = getAccountDomain(accountDetails)
return (
<div>
<StickyHeader withBackButton />
<div class="relative mb-16">
<img
src={accountDetails.header}
alt={`Header of ${accountDetails.display_name}`}
src={accountDetails.account.header}
alt={`Header of ${accountDetails.account.display_name}`}
class="w-full h-40 object-cover bg-wildebeest-500"
/>
<img
class="rounded h-24 w-24 absolute bottom-[-3rem] left-5 border-2 border-wildebeest-600"
src={accountDetails.avatar}
alt={`Avatar of ${accountDetails.display_name}`}
src={accountDetails.account.avatar}
alt={`Avatar of ${accountDetails.account.display_name}`}
/>
</div>
<div class="px-5">
<h2 class="font-bold">{accountDetails.display_name}</h2>
<span class="block my-1 text-wildebeest-400">
@{accountDetails.acct}
{accountDomain && `@${accountDomain}`}
</span>
<div class="inner-html-content my-5" dangerouslySetInnerHTML={accountDetails.note} />
<h2 class="font-bold">{accountDetails.account.display_name}</h2>
<span class="block my-1 text-wildebeest-400">{accountDetails.accountHandle}</span>
<div class="inner-html-content my-5" dangerouslySetInnerHTML={accountDetails.account.note} />
<dl class="mb-6 flex flex-col bg-wildebeest-800 border border-wildebeest-600 rounded-md">
{fields.map(({ name, value }) => (
<div class="border-b border-wildebeest-600 p-3 text-sm" key={name}>
@ -113,3 +115,17 @@ export function getAccountDomain(account: MastodonAccount): string | null {
return null
}
}
export const head: DocumentHead = ({ getData }) => {
const { account, accountHandle } = getData(accountLoader)
return getDocumentHead({
title: `${account.display_name} (${accountHandle}) - Wildebeest`,
description: `${account.display_name} account page - Wildebeest`,
og: {
url: account.url,
type: 'article',
image: account.avatar,
},
})
}

Wyświetl plik

@ -1,8 +1,10 @@
import { $, component$, useClientEffect$, useSignal } from '@builder.io/qwik'
import { loader$ } from '@builder.io/qwik-city'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import { RequestContext } from '@builder.io/qwik-city/middleware/request-handler'
import * as timelines from 'wildebeest/functions/api/v1/timelines/public'
import Status from '~/components/Status'
import type { MastodonStatus } from '~/types'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string }, Promise<MastodonStatus[]>>(
@ -86,3 +88,18 @@ export const StatusesPanel = component$(({ initialStatuses }: StatusesPanelProps
</>
)
})
export const requestLoader = loader$(async ({ request }) => {
// Manually parse the JSON to ensure that Qwik finds the resulting objects serializable.
return JSON.parse(JSON.stringify(request)) as RequestContext
})
export const head: DocumentHead = ({ getData }) => {
const { url } = getData(requestLoader)
return getDocumentHead({
title: 'Explore - Wildebeest',
og: {
url,
},
})
}

Wyświetl plik

@ -8,6 +8,7 @@ import RightColumn from '~/components/layout/RightColumn/RightColumn'
import { WildebeestLogo } from '~/components/MastodonLogo'
import { getCommitHash } from '~/utils/getCommitHash'
import { InstanceConfigContext } from '~/utils/instanceConfig'
import { getDocumentHead } from '~/utils/getDocumentHead'
export const instanceLoader = loader$<
{ DATABASE: D1Database; INSTANCE_TITLE: string; INSTANCE_DESCR: string; ADMIN_EMAIL: string },
@ -63,15 +64,18 @@ export default component$(() => {
)
})
export const head: DocumentHead = (props) => {
const config = props.getData(instanceLoader)
return {
title: config.short_description,
meta: [
{
name: 'description',
content: config.description,
export const head: DocumentHead = ({ getData, head }) => {
const instance = getData(instanceLoader)
return getDocumentHead(
{
description: instance.short_description ?? instance.description,
og: {
type: 'website',
url: instance.uri,
image: instance.thumbnail,
},
],
}
},
head
)
}

Wyświetl plik

@ -2,8 +2,10 @@ import { component$ } from '@builder.io/qwik'
import { MastodonStatus } from '~/types'
import * as timelines from 'wildebeest/functions/api/v1/timelines/public'
import Status from '~/components/Status'
import { loader$ } from '@builder.io/qwik-city'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import StickyHeader from '~/components/StickyHeader/StickyHeader'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { RequestContext } from '@builder.io/qwik-city/middleware/request-handler'
export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string }, Promise<MastodonStatus[]>>(
async ({ platform, html }) => {
@ -40,3 +42,18 @@ export default component$(() => {
</>
)
})
export const requestLoader = loader$(async ({ request }) => {
// Manually parse the JSON to ensure that Qwik finds the resulting objects serializable.
return JSON.parse(JSON.stringify(request)) as RequestContext
})
export const head: DocumentHead = ({ getData }) => {
const { url } = getData(requestLoader)
return getDocumentHead({
title: 'Federated timeline - Wildebeest',
og: {
url,
},
})
}

Wyświetl plik

@ -2,8 +2,10 @@ import { component$ } from '@builder.io/qwik'
import { MastodonStatus } from '~/types'
import * as timelines from 'wildebeest/functions/api/v1/timelines/public'
import Status from '~/components/Status'
import { loader$ } from '@builder.io/qwik-city'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import StickyHeader from '~/components/StickyHeader/StickyHeader'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { RequestContext } from '@builder.io/qwik-city/middleware/request-handler'
export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string }, Promise<MastodonStatus[]>>(
async ({ platform, html }) => {
@ -39,3 +41,18 @@ export default component$(() => {
</>
)
})
export const requestLoader = loader$(async ({ request }) => {
// Manually parse the JSON to ensure that Qwik finds the resulting objects serializable.
return JSON.parse(JSON.stringify(request)) as RequestContext
})
export const head: DocumentHead = ({ getData }) => {
const { url } = getData(requestLoader)
return getDocumentHead({
title: 'Local timeline - Wildebeest',
og: {
url,
},
})
}

Wyświetl plik

@ -0,0 +1,50 @@
import { DocumentHeadValue } from '@builder.io/qwik-city'
type DocumentHeadData = {
title?: string
description?: string
og?: {
type?: 'website' | 'article'
url?: string
image?: string
}
}
export function getDocumentHead(data: DocumentHeadData, head?: DocumentHeadValue) {
const result: DocumentHeadValue = { meta: [] }
const setMeta = (name: string, content: string) => {
if (head?.meta?.find((meta) => meta.name === name)) {
return
}
result.meta = result.meta?.filter((meta) => meta.name !== name) ?? []
result.meta?.push({
name,
content,
})
}
if (data.title) {
result.title = data.title
setMeta('og:title', data.title)
}
if (data.description) {
setMeta('description', data.description)
setMeta('og:description', data.description)
}
if (data.og) {
if (data.og.type) {
setMeta('og:type', data.og.type)
}
if (data.og.url) {
setMeta('og:url', data.og.url)
}
if (data.og.image) {
setMeta('og:image', data.og.image)
}
}
return result
}

Wyświetl plik

@ -37,8 +37,8 @@
"pages": "NO_D1_WARNING=true wrangler pages",
"database:migrate": "yarn d1 migrations apply DATABASE",
"database:create-mock": "rm -f .wrangler/state/d1/DATABASE.sqlite3 && yarn database:migrate --local && node ./frontend/mock-db/run.mjs",
"dev": "export COMMIT_HASH=$(git rev-parse HEAD) && yarn build && yarn database:migrate --local && yarn pages dev frontend/dist --d1 DATABASE --persist --compatibility-date=2022-12-20 --live-reload",
"ci-dev-test-ui": "yarn build && yarn database:create-mock && yarn pages dev frontend/dist --d1 DATABASE --persist --port 8788 --compatibility-date=2022-12-20",
"dev": "export COMMIT_HASH=$(git rev-parse HEAD) && yarn build && yarn database:migrate --local && yarn pages dev frontend/dist --d1 DATABASE --persist --compatibility-date=2022-12-20 --binding 'INSTANCE_DESCR=My Wildebeest Instance' --live-reload",
"ci-dev-test-ui": "yarn build && yarn database:create-mock && yarn pages dev frontend/dist --d1 DATABASE --persist --port 8788 --binding 'INSTANCE_DESCR=My Wildebeest Instance' --compatibility-date=2022-12-20",
"deploy:init": "yarn pages project create wildebeest && yarn d1 create wildebeest",
"deploy": "yarn build && yarn database:migrate && yarn pages publish frontend/dist --project-name=wildebeest"
},

Wyświetl plik

@ -0,0 +1,90 @@
import { test, expect, Page } from '@playwright/test'
test('Presence of appropriate SEO metadata across the application', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
await checkPageSeoData(page, {
title: 'Explore - Wildebeest',
description: 'My Wildebeest Instance',
ogType: 'website',
ogUrl: 'http://127.0.0.1:8788/explore',
ogImage: 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/thumbnail',
})
await page.goto('http://127.0.0.1:8788/public/local')
await checkPageSeoData(page, {
title: 'Local timeline - Wildebeest',
description: 'My Wildebeest Instance',
ogType: 'website',
ogUrl: 'http://127.0.0.1:8788/public/local',
ogImage: 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/thumbnail',
})
await page.goto('http://127.0.0.1:8788/public')
await checkPageSeoData(page, {
title: 'Federated timeline - Wildebeest',
description: 'My Wildebeest Instance',
ogType: 'website',
ogUrl: 'http://127.0.0.1:8788/public',
ogImage: 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/thumbnail',
})
await page.goto('http://127.0.0.1:8788/explore')
await page.locator('article').filter({ hasText: 'Hi, meet HiDock' }).locator('i.fa-globe + span').click()
await checkPageSeoData(page, {
title: "Rafa: Hi, meet HiDock! It's a free M… - Wildebeest",
description:
"Hi, meet HiDock! It's a free Mac app that lets you set different Dock settings for different display configurations https://hidock.app →",
ogType: 'article',
ogUrl: /https:\/\/127.0.0.1\/statuses\/[\w-]*\/?/,
ogImage: 'https://cdn.masto.host/mastodondesign/accounts/avatars/000/011/932/original/8f601be03c98b2e8.png',
})
await page.goto('http://127.0.0.1:8788/@rafa')
await checkPageSeoData(page, {
title: 'Rafa (@rafa@0.0.0.0) - Wildebeest',
description: 'Rafa account page - Wildebeest',
ogType: 'article',
ogUrl: 'https://0.0.0.0/@rafa',
ogImage: 'https://cdn.masto.host/mastodondesign/accounts/avatars/000/011/932/original/8f601be03c98b2e8.png',
})
await page.goto('http://127.0.0.1:8788/explore')
await page.locator('article').filter({ hasText: 'Ken White' }).locator('i.fa-globe + span').click()
await checkPageSeoData(page, {
title: 'Ken White: Just recorded the first Seriou… - Wildebeest',
description:
'Just recorded the first Serious Trouble episode of the new year, out tomorrow. This week: George Santos is in serious trouble. Sam Bankman-Fried is in REALLY serious trouble. And Scott Adams is still making dumb defamation threats.',
ogType: 'article',
ogUrl: /https:\/\/127.0.0.1\/statuses\/[\w-]*\/?/,
ogImage: 'https://files.mastodon.social/accounts/avatars/109/502/260/753/916/593/original/f721da0f38083abf.jpg',
})
await page.goto('http://127.0.0.1:8788/@Popehat')
await checkPageSeoData(page, {
title: 'Ken White (@Popehat@0.0.0.0) - Wildebeest',
description: 'Ken White account page - Wildebeest',
ogType: 'article',
ogUrl: 'https://0.0.0.0/@Popehat',
ogImage: 'https://files.mastodon.social/accounts/avatars/109/502/260/753/916/593/original/f721da0f38083abf.jpg',
})
})
type ExpectedSeoValues = {
title: string | RegExp
description: string | RegExp
ogType: 'website' | 'article'
ogUrl: string | RegExp
ogImage: string | RegExp
}
async function checkPageSeoData(page: Page, expected: Partial<ExpectedSeoValues>) {
const metaLocator = (name: string) => page.locator(`meta[name="${name}"]`)
expected.title && (await expect(page).toHaveTitle(expected.title))
expected.title && (await expect(metaLocator('og:title')).toHaveAttribute('content', expected.title))
expected.description && (await expect(metaLocator('description')).toHaveAttribute('content', expected.description))
expected.description && (await expect(metaLocator('og:description')).toHaveAttribute('content', expected.description))
expected.ogType && (await expect(metaLocator('og:type')).toHaveAttribute('content', expected.ogType))
expected.ogUrl && (await expect(metaLocator('og:url')).toHaveAttribute('content', expected.ogUrl))
expected.ogImage && (await expect(metaLocator('og:image')).toHaveAttribute('content', expected.ogImage))
}