diff --git a/backend/src/activitypub/actors/index.ts b/backend/src/activitypub/actors/index.ts index db7e05d..08d311b 100644 --- a/backend/src/activitypub/actors/index.ts +++ b/backend/src/activitypub/actors/index.ts @@ -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.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 diff --git a/backend/src/activitypub/objects/index.ts b/backend/src/activitypub/objects/index.ts index 900edc7..f7b7030 100644 --- a/backend/src/activitypub/objects/index.ts +++ b/backend/src/activitypub/objects/index.ts @@ -216,7 +216,7 @@ export async function sanitizeObjectProperties(properties: unknown): Promise { } /** - * 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 { - return await getNameRewriter().transform(new Response(unsafeName)).text() +export async function getTextContent(unsafeName: string): Promise { + 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 } diff --git a/frontend/src/dummyData.tsx b/frontend/src/dummyData.tsx index 292ef29..8b86ad3 100644 --- a/frontend/src/dummyData.tsx +++ b/frontend/src/dummyData.tsx @@ -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', diff --git a/frontend/src/routes/(frontend)/[accountId]/[statusId]/index.tsx b/frontend/src/routes/(frontend)/[accountId]/[statusId]/index.tsx index 5f3d9e2..26c0e72 100644 --- a/frontend/src/routes/(frontend)/[accountId]/[statusId]/index.tsx +++ b/frontend/src/routes/(frontend)/[accountId]/[statusId]/index.tsx @@ -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, + }, + }) +} diff --git a/frontend/src/routes/(frontend)/[accountId]/index.tsx b/frontend/src/routes/(frontend)/[accountId]/index.tsx index 24ae978..0264525 100644 --- a/frontend/src/routes/(frontend)/[accountId]/index.tsx +++ b/frontend/src/routes/(frontend)/[accountId]/index.tsx @@ -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>( - 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 (
{`Header {`Avatar
-

{accountDetails.display_name}

- - @{accountDetails.acct} - {accountDomain && `@${accountDomain}`} - -
+

{accountDetails.account.display_name}

+ {accountDetails.accountHandle} +
{fields.map(({ name, value }) => (
@@ -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, + }, + }) +} diff --git a/frontend/src/routes/(frontend)/explore/index.tsx b/frontend/src/routes/(frontend)/explore/index.tsx index 5c8e61b..05f10cf 100644 --- a/frontend/src/routes/(frontend)/explore/index.tsx +++ b/frontend/src/routes/(frontend)/explore/index.tsx @@ -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>( @@ -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, + }, + }) +} diff --git a/frontend/src/routes/(frontend)/layout.tsx b/frontend/src/routes/(frontend)/layout.tsx index 1c3fbd6..2b5931f 100644 --- a/frontend/src/routes/(frontend)/layout.tsx +++ b/frontend/src/routes/(frontend)/layout.tsx @@ -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 + ) } diff --git a/frontend/src/routes/(frontend)/public/index.tsx b/frontend/src/routes/(frontend)/public/index.tsx index 3222657..5b30630 100644 --- a/frontend/src/routes/(frontend)/public/index.tsx +++ b/frontend/src/routes/(frontend)/public/index.tsx @@ -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>( 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, + }, + }) +} diff --git a/frontend/src/routes/(frontend)/public/local/index.tsx b/frontend/src/routes/(frontend)/public/local/index.tsx index bccbf23..aefb1c6 100644 --- a/frontend/src/routes/(frontend)/public/local/index.tsx +++ b/frontend/src/routes/(frontend)/public/local/index.tsx @@ -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>( 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, + }, + }) +} diff --git a/frontend/src/utils/getDocumentHead.ts b/frontend/src/utils/getDocumentHead.ts new file mode 100644 index 0000000..88401d4 --- /dev/null +++ b/frontend/src/utils/getDocumentHead.ts @@ -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 +} diff --git a/package.json b/package.json index 3f6ca14..e6922e2 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/ui-e2e-tests/seo.spec.ts b/ui-e2e-tests/seo.spec.ts new file mode 100644 index 0000000..075bb28 --- /dev/null +++ b/ui-e2e-tests/seo.spec.ts @@ -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) { + 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)) +}