kopia lustrzana https://github.com/cloudflare/wildebeest
rodzic
1d9775b3aa
commit
e3271478e1
|
@ -1,6 +1,6 @@
|
||||||
import { defaultImages } from 'wildebeest/config/accounts'
|
import { defaultImages } from 'wildebeest/config/accounts'
|
||||||
import { generateUserKey } from 'wildebeest/backend/src/utils/key-ops'
|
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'
|
import { addPeer } from 'wildebeest/backend/src/activitypub/peers'
|
||||||
|
|
||||||
const PERSON = 'Person'
|
const PERSON = 'Person'
|
||||||
|
@ -63,10 +63,10 @@ export async function get(url: string | URL): Promise<Actor> {
|
||||||
actor.content = await sanitizeContent(data.content)
|
actor.content = await sanitizeContent(data.content)
|
||||||
}
|
}
|
||||||
if (data.name) {
|
if (data.name) {
|
||||||
actor.name = await sanitizeName(data.name)
|
actor.name = await getTextContent(data.name)
|
||||||
}
|
}
|
||||||
if (data.preferredUsername) {
|
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
|
// This is mostly for testing where for convenience not all values
|
||||||
|
|
|
@ -216,7 +216,7 @@ export async function sanitizeObjectProperties(properties: unknown): Promise<APO
|
||||||
sanitized.content = await sanitizeContent(properties.content as string)
|
sanitized.content = await sanitizeContent(properties.content as string)
|
||||||
}
|
}
|
||||||
if ('name' in properties) {
|
if ('name' in properties) {
|
||||||
sanitized.name = await sanitizeName(properties.name as string)
|
sanitized.name = await getTextContent(properties.name as string)
|
||||||
}
|
}
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
@ -235,12 +235,12 @@ export async function sanitizeContent(unsafeContent: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitizes given string as an ActivityPub Object name.
|
* This method removes all HTML elements from the string leaving only the text content.
|
||||||
*
|
|
||||||
* This sanitization removes all HTML elements from the string leaving only the text content.
|
|
||||||
*/
|
*/
|
||||||
export async function sanitizeName(unsafeName: string): Promise<string> {
|
export async function getTextContent(unsafeName: string): Promise<string> {
|
||||||
return await getNameRewriter().transform(new Response(unsafeName)).text()
|
const rawContent = getTextContentRewriter().transform(new Response(unsafeName))
|
||||||
|
const text = await rawContent.text()
|
||||||
|
return text.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getContentRewriter() {
|
function getContentRewriter() {
|
||||||
|
@ -263,12 +263,15 @@ function getContentRewriter() {
|
||||||
return contentRewriter
|
return contentRewriter
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNameRewriter() {
|
function getTextContentRewriter() {
|
||||||
const nameRewriter = new HTMLRewriter()
|
const textContentRewriter = new HTMLRewriter()
|
||||||
nameRewriter.on('*', {
|
textContentRewriter.on('*', {
|
||||||
element(el) {
|
element(el) {
|
||||||
el.removeAndKeepContent()
|
el.removeAndKeepContent()
|
||||||
|
if (['p', 'br'].includes(el.tagName)) {
|
||||||
|
el.after(' ')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return nameRewriter
|
return textContentRewriter
|
||||||
}
|
}
|
||||||
|
|
|
@ -923,7 +923,7 @@ const mastodonRawStatuses: MastodonStatus[] = [
|
||||||
favourites_count: 537,
|
favourites_count: 537,
|
||||||
edited_at: null,
|
edited_at: null,
|
||||||
content:
|
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,
|
reblog: null,
|
||||||
application: {
|
application: {
|
||||||
name: 'Web',
|
name: 'Web',
|
||||||
|
|
|
@ -5,17 +5,19 @@ import { formatDateTime } from '~/utils/dateTime'
|
||||||
import { formatRoundedNumber } from '~/utils/numbers'
|
import { formatRoundedNumber } from '~/utils/numbers'
|
||||||
import * as statusAPI from 'wildebeest/functions/api/v1/statuses/[id]'
|
import * as statusAPI from 'wildebeest/functions/api/v1/statuses/[id]'
|
||||||
import * as contextAPI from 'wildebeest/functions/api/v1/statuses/[id]/context'
|
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 StickyHeader from '~/components/StickyHeader/StickyHeader'
|
||||||
import { Avatar } from '~/components/avatar'
|
import { Avatar } from '~/components/avatar'
|
||||||
import { MediaGallery } from '~/components/MediaGallery.tsx'
|
import { MediaGallery } from '~/components/MediaGallery.tsx'
|
||||||
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
|
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
|
||||||
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
|
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
|
||||||
import styles from '../../../../utils/innerHtmlContent.scss?inline'
|
import styles from '../../../../utils/innerHtmlContent.scss?inline'
|
||||||
|
import { getTextContent } from 'wildebeest/backend/src/activitypub/objects'
|
||||||
|
import { getDocumentHead } from '~/utils/getDocumentHead'
|
||||||
|
|
||||||
export const statusLoader = loader$<
|
export const statusLoader = loader$<
|
||||||
{ DATABASE: D1Database },
|
{ DATABASE: D1Database },
|
||||||
Promise<{ status: MastodonStatus; context: StatusContext }>
|
Promise<{ status: MastodonStatus; statusTextContent: string; context: StatusContext }>
|
||||||
>(async ({ request, html, platform, params }) => {
|
>(async ({ request, html, platform, params }) => {
|
||||||
const domain = new URL(request.url).hostname
|
const domain = new URL(request.url).hostname
|
||||||
let statusText = ''
|
let statusText = ''
|
||||||
|
@ -28,6 +30,9 @@ export const statusLoader = loader$<
|
||||||
if (!statusText) {
|
if (!statusText) {
|
||||||
throw html(404, getNotFoundHtml())
|
throw html(404, getNotFoundHtml())
|
||||||
}
|
}
|
||||||
|
const status: MastodonStatus = JSON.parse(statusText)
|
||||||
|
const statusTextContent = await getTextContent(status.content)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contextResponse = await contextAPI.handleRequest(domain, platform.DATABASE, params.statusId)
|
const contextResponse = await contextAPI.handleRequest(domain, platform.DATABASE, params.statusId)
|
||||||
const contextText = await contextResponse.text()
|
const contextText = await contextResponse.text()
|
||||||
|
@ -35,7 +40,7 @@ export const statusLoader = loader$<
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error(`No context present for status with ${params.statusId}`)
|
throw new Error(`No context present for status with ${params.statusId}`)
|
||||||
}
|
}
|
||||||
return { status: JSON.parse(statusText), context }
|
return { status, statusTextContent, context }
|
||||||
} catch {
|
} catch {
|
||||||
throw html(500, getErrorHtml('No context for the status has been found, please try again later'))
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { component$, useStyles$ } from '@builder.io/qwik'
|
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 { MastodonAccount } from 'wildebeest/backend/src/types'
|
||||||
import StickyHeader from '~/components/StickyHeader/StickyHeader'
|
import StickyHeader from '~/components/StickyHeader/StickyHeader'
|
||||||
import { formatDateTime } from '~/utils/dateTime'
|
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 { getAccount } from 'wildebeest/backend/src/accounts/getAccount'
|
||||||
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
|
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
|
||||||
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
|
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
|
||||||
|
import { getDocumentHead } from '~/utils/getDocumentHead'
|
||||||
|
|
||||||
export const accountLoader = loader$<{ DATABASE: D1Database }, Promise<MastodonAccount>>(
|
export const accountLoader = loader$<
|
||||||
async ({ platform, request, html }) => {
|
{ DATABASE: D1Database },
|
||||||
let account: MastodonAccount | null = null
|
Promise<{ account: MastodonAccount; accountHandle: string }>
|
||||||
try {
|
>(async ({ platform, request, html }) => {
|
||||||
const url = new URL(request.url)
|
let account: MastodonAccount | null = null
|
||||||
const domain = url.hostname
|
try {
|
||||||
const accountId = url.pathname.split('/')[1]
|
const url = new URL(request.url)
|
||||||
|
const domain = url.hostname
|
||||||
|
const accountId = url.pathname.split('/')[1]
|
||||||
|
|
||||||
account = await getAccount(domain, accountId, platform.DATABASE)
|
account = await getAccount(domain, accountId, platform.DATABASE)
|
||||||
} catch {
|
} catch {
|
||||||
throw html(
|
throw html(
|
||||||
500,
|
500,
|
||||||
getErrorHtml(`An error happened when trying to retrieve the account's details, please try again later`)
|
getErrorHtml(`An error happened when trying to retrieve the account's details, please try again later`)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
throw html(404, getNotFoundHtml())
|
|
||||||
}
|
|
||||||
return account
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
if (!account) {
|
||||||
|
throw html(404, getNotFoundHtml())
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountDomain = getAccountDomain(account)
|
||||||
|
|
||||||
|
const accountHandle = `@${account.acct}${accountDomain ? `@${accountDomain}` : ''}`
|
||||||
|
|
||||||
|
return { account, accountHandle }
|
||||||
|
})
|
||||||
|
|
||||||
export default component$(() => {
|
export default component$(() => {
|
||||||
useStyles$(styles)
|
useStyles$(styles)
|
||||||
|
@ -40,50 +47,45 @@ export default component$(() => {
|
||||||
const fields = [
|
const fields = [
|
||||||
{
|
{
|
||||||
name: 'Joined',
|
name: 'Joined',
|
||||||
value: formatDateTime(accountDetails.created_at, false),
|
value: formatDateTime(accountDetails.account.created_at, false),
|
||||||
},
|
},
|
||||||
...accountDetails.fields,
|
...accountDetails.account.fields,
|
||||||
]
|
]
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
name: 'Posts',
|
name: 'Posts',
|
||||||
value: formatRoundedNumber(accountDetails.statuses_count),
|
value: formatRoundedNumber(accountDetails.account.statuses_count),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Following',
|
name: 'Following',
|
||||||
value: formatRoundedNumber(accountDetails.following_count),
|
value: formatRoundedNumber(accountDetails.account.following_count),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Followers',
|
name: 'Followers',
|
||||||
value: formatRoundedNumber(accountDetails.followers_count),
|
value: formatRoundedNumber(accountDetails.account.followers_count),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const accountDomain = getAccountDomain(accountDetails)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StickyHeader withBackButton />
|
<StickyHeader withBackButton />
|
||||||
<div class="relative mb-16">
|
<div class="relative mb-16">
|
||||||
<img
|
<img
|
||||||
src={accountDetails.header}
|
src={accountDetails.account.header}
|
||||||
alt={`Header of ${accountDetails.display_name}`}
|
alt={`Header of ${accountDetails.account.display_name}`}
|
||||||
class="w-full h-40 object-cover bg-wildebeest-500"
|
class="w-full h-40 object-cover bg-wildebeest-500"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
class="rounded h-24 w-24 absolute bottom-[-3rem] left-5 border-2 border-wildebeest-600"
|
class="rounded h-24 w-24 absolute bottom-[-3rem] left-5 border-2 border-wildebeest-600"
|
||||||
src={accountDetails.avatar}
|
src={accountDetails.account.avatar}
|
||||||
alt={`Avatar of ${accountDetails.display_name}`}
|
alt={`Avatar of ${accountDetails.account.display_name}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-5">
|
<div class="px-5">
|
||||||
<h2 class="font-bold">{accountDetails.display_name}</h2>
|
<h2 class="font-bold">{accountDetails.account.display_name}</h2>
|
||||||
<span class="block my-1 text-wildebeest-400">
|
<span class="block my-1 text-wildebeest-400">{accountDetails.accountHandle}</span>
|
||||||
@{accountDetails.acct}
|
<div class="inner-html-content my-5" dangerouslySetInnerHTML={accountDetails.account.note} />
|
||||||
{accountDomain && `@${accountDomain}`}
|
|
||||||
</span>
|
|
||||||
<div class="inner-html-content my-5" dangerouslySetInnerHTML={accountDetails.note} />
|
|
||||||
<dl class="mb-6 flex flex-col bg-wildebeest-800 border border-wildebeest-600 rounded-md">
|
<dl class="mb-6 flex flex-col bg-wildebeest-800 border border-wildebeest-600 rounded-md">
|
||||||
{fields.map(({ name, value }) => (
|
{fields.map(({ name, value }) => (
|
||||||
<div class="border-b border-wildebeest-600 p-3 text-sm" key={name}>
|
<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
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { $, component$, useClientEffect$, useSignal } from '@builder.io/qwik'
|
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 * as timelines from 'wildebeest/functions/api/v1/timelines/public'
|
||||||
import Status from '~/components/Status'
|
import Status from '~/components/Status'
|
||||||
import type { MastodonStatus } from '~/types'
|
import type { MastodonStatus } from '~/types'
|
||||||
|
import { getDocumentHead } from '~/utils/getDocumentHead'
|
||||||
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
|
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
|
||||||
|
|
||||||
export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string }, Promise<MastodonStatus[]>>(
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import RightColumn from '~/components/layout/RightColumn/RightColumn'
|
||||||
import { WildebeestLogo } from '~/components/MastodonLogo'
|
import { WildebeestLogo } from '~/components/MastodonLogo'
|
||||||
import { getCommitHash } from '~/utils/getCommitHash'
|
import { getCommitHash } from '~/utils/getCommitHash'
|
||||||
import { InstanceConfigContext } from '~/utils/instanceConfig'
|
import { InstanceConfigContext } from '~/utils/instanceConfig'
|
||||||
|
import { getDocumentHead } from '~/utils/getDocumentHead'
|
||||||
|
|
||||||
export const instanceLoader = loader$<
|
export const instanceLoader = loader$<
|
||||||
{ DATABASE: D1Database; INSTANCE_TITLE: string; INSTANCE_DESCR: string; ADMIN_EMAIL: string },
|
{ DATABASE: D1Database; INSTANCE_TITLE: string; INSTANCE_DESCR: string; ADMIN_EMAIL: string },
|
||||||
|
@ -63,15 +64,18 @@ export default component$(() => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const head: DocumentHead = (props) => {
|
export const head: DocumentHead = ({ getData, head }) => {
|
||||||
const config = props.getData(instanceLoader)
|
const instance = getData(instanceLoader)
|
||||||
return {
|
|
||||||
title: config.short_description,
|
return getDocumentHead(
|
||||||
meta: [
|
{
|
||||||
{
|
description: instance.short_description ?? instance.description,
|
||||||
name: 'description',
|
og: {
|
||||||
content: config.description,
|
type: 'website',
|
||||||
|
url: instance.uri,
|
||||||
|
image: instance.thumbnail,
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
}
|
head
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,10 @@ import { component$ } from '@builder.io/qwik'
|
||||||
import { MastodonStatus } from '~/types'
|
import { MastodonStatus } from '~/types'
|
||||||
import * as timelines from 'wildebeest/functions/api/v1/timelines/public'
|
import * as timelines from 'wildebeest/functions/api/v1/timelines/public'
|
||||||
import Status from '~/components/Status'
|
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 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[]>>(
|
export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string }, Promise<MastodonStatus[]>>(
|
||||||
async ({ platform, html }) => {
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -2,8 +2,10 @@ import { component$ } from '@builder.io/qwik'
|
||||||
import { MastodonStatus } from '~/types'
|
import { MastodonStatus } from '~/types'
|
||||||
import * as timelines from 'wildebeest/functions/api/v1/timelines/public'
|
import * as timelines from 'wildebeest/functions/api/v1/timelines/public'
|
||||||
import Status from '~/components/Status'
|
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 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[]>>(
|
export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string }, Promise<MastodonStatus[]>>(
|
||||||
async ({ platform, html }) => {
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -37,8 +37,8 @@
|
||||||
"pages": "NO_D1_WARNING=true wrangler pages",
|
"pages": "NO_D1_WARNING=true wrangler pages",
|
||||||
"database:migrate": "yarn d1 migrations apply DATABASE",
|
"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",
|
"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",
|
"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 --compatibility-date=2022-12-20",
|
"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:init": "yarn pages project create wildebeest && yarn d1 create wildebeest",
|
||||||
"deploy": "yarn build && yarn database:migrate && yarn pages publish frontend/dist --project-name=wildebeest"
|
"deploy": "yarn build && yarn database:migrate && yarn pages publish frontend/dist --project-name=wildebeest"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
Ładowanie…
Reference in New Issue