Merge branch 'seo' into 'main'

seo improvements

See merge request PronounsPage/PronounsPage!515
This commit is contained in:
Valentyne Stigloher 2024-09-12 15:44:03 +00:00
commit acca283120
22 changed files with 160 additions and 144 deletions

28
app.vue
View File

@ -2,15 +2,13 @@
import { useNuxtApp, useRuntimeConfig } from 'nuxt/app';
import { useHead, useSeoMeta } from '@unhead/vue';
import useConfig from '~/composables/useConfig.ts';
import { getDefaultSeo } from '~/composables/useSimpleHead.ts';
const { $translator: translator } = useNuxtApp();
const runtimeConfig = useRuntimeConfig();
const config = useConfig();
const title = translator.translate('title');
const description = translator.translate('description');
const keywords = (translator.translate<string[]>('seo.keywords') || []).join(', ');
const banner = `${runtimeConfig.public.baseUrl}/api/banner/zaimki.png`;
const defaultSeo = getDefaultSeo(translator, runtimeConfig);
const colour = '#C71585';
useHead({
@ -26,25 +24,25 @@ useHead({
});
useSeoMeta({
title,
title: defaultSeo.title,
charset: 'utf-8',
description,
keywords,
description: defaultSeo.description,
keywords: defaultSeo.keywords,
viewport: 'width=device-width, initial-scale=1',
appleMobileWebAppTitle: title,
appleMobileWebAppTitle: defaultSeo.title,
themeColor: colour,
ogType: 'article',
ogTitle: title,
ogDescription: description,
ogSiteName: title,
ogImage: banner,
ogTitle: defaultSeo.title,
ogDescription: defaultSeo.description,
ogSiteName: defaultSeo.title,
ogImage: defaultSeo.banner,
twitterCard: 'summary_large_image',
twitterTitle: title,
twitterDescription: description,
twitterTitle: defaultSeo.title,
twitterDescription: defaultSeo.description,
twitterSite: runtimeConfig.public.baseUrl,
twitterImage: banner,
twitterImage: defaultSeo.banner,
});
</script>

View File

@ -52,7 +52,7 @@ export default defineComponent({
},
setup() {
const { convertName } = useSpelling();
const { data: authors } = useFetch<ContactAuthor[]>('/api/admin/list/footer');
const { data: authors } = useFetch<ContactAuthor[]>('/api/admin/list/footer', { lazy: true });
return {
config: useConfig(),
convertName,

View File

@ -1,40 +1,40 @@
<template>
<span v-if="config.calendar?.enabled">
<component
:is="event.level === EventLevel.CustomDay ? 'span' : 'nuxt-link'"
v-if="range"
:to="`/${config.calendar.route}/${eventDays[0]}`"
class="badge bg-primary text-white"
>
<T v-if="month" :params="{ day: event.getRange(year) }">calendar.dates.{{ eventDays[0].month }}</T>
<template v-else>{{ event.getRange(year) }}</template>
</component>
<Tooltip v-if="event.level === EventLevel.CustomDay" :text="$t('profile.calendar.customEvents.disclaimer')">
<Icon v="user" />
</Tooltip>
<component
:is="event.level === EventLevel.CustomDay ? 'span' : 'nuxt-link'"
v-if="range"
:to="`/${config.calendar?.route}/${eventDays[0]}`"
class="badge bg-primary text-white"
>
<T v-if="month" :params="{ day: event.getRange(year) }">calendar.dates.{{ eventDays[0].month }}</T>
<template v-else>
<Flag v-if="event.flag" :alt="$t(`flags_alt.${event.flag.replace(/'/g, '*').replace(/ /g, '_')}`) || ''" :img="`/flags/${event.flag}.png`" />
<Icon v-else v="arrow-circle-right" />
{{ event.getRange(year) }}
</template>
<T v-if="$te(`calendar.events.${eventName}`, true)" :params="{ param: eventParam }">calendar.events.{{ eventName }}</T>
<LinkedText v-else :text="eventName" />
<a
v-if="ics && event.level !== EventLevel.CustomDay"
:href="`/api/queer-calendar-${config.locale}-${year}-${event.getUuid($config.public.baseUrl)}.ics`"
class="small"
:aria-label="`${$t('crud.download')} .ics`"
:title="`${$t('crud.download')} .ics`"
>
<Icon v="calendar-plus" />
</a>
<small v-if="event.localCalendar">({{ event.localCalendar }})</small>
<a v-if="addButton" href="#" class="text-success" @click.prevent="$emit('add', eventName)">
<Icon v="plus-circle" hover />
</a>
<a v-if="removeButton" href="#" class="text-danger" @click.prevent="$emit('delete', eventName)">
<Icon v="minus-circle" hover />
</a>
</span>
</component>
<Tooltip v-if="event.level === EventLevel.CustomDay" :text="$t('profile.calendar.customEvents.disclaimer')">
<Icon v="user" />
</Tooltip>
<template v-else>
<Flag v-if="event.flag" :alt="$t(`flags_alt.${event.flag.replace(/'/g, '*').replace(/ /g, '_')}`) || ''" :img="`/flags/${event.flag}.png`" />
<Icon v-else v="arrow-circle-right" />
</template>
<T v-if="$te(`calendar.events.${eventName}`, true)" :params="{ param: eventParam }">calendar.events.{{ eventName }}</T>
<LinkedText v-else :text="eventName" />
<a
v-if="ics && event.level !== EventLevel.CustomDay"
:href="`/api/queer-calendar-${config.locale}-${year}-${event.getUuid($config.public.baseUrl)}.ics`"
class="small"
:aria-label="`${$t('crud.download')} .ics`"
:title="`${$t('crud.download')} .ics`"
>
<Icon v="calendar-plus" />
</a>
<small v-if="event.localCalendar">({{ event.localCalendar }})</small>
<a v-if="addButton" href="#" class="text-success" @click.prevent="$emit('add', eventName)">
<Icon v="plus-circle" hover />
</a>
<a v-if="removeButton" href="#" class="text-danger" @click.prevent="$emit('delete', eventName)">
<Icon v="minus-circle" hover />
</a>
</template>
<script lang="ts">

View File

@ -1,23 +1,21 @@
<template>
<span>
<span v-for="(part, index) in exampleParts">
<strong v-if="part.variable"><Morpheme
:pronoun="pronoun"
:morpheme="part.str"
:counter="counter"
:prepend="getPrepend(index)"
:append="getAppend(index)"
/></strong>
<span v-else><Spelling :text="getPrepend(index) + part.str + getAppend(index)" /></span>
</span>
<small v-if="link">
(<nuxt-link :to="`/${pronoun.canonicalName}`"><Spelling escape :text="pronoun.canonicalName" /></nuxt-link>)
</small>
<Pronunciation
v-if="pronunciation && pronoun.pronounceable && example.toPronunciationString(pronoun)"
:pronunciation="example.toPronunciationString(pronoun) as string"
/>
</span>
<template v-for="(part, index) in exampleParts" :key="index">
<strong v-if="part.variable"><Morpheme
:pronoun="pronoun"
:morpheme="part.str"
:counter="counter"
:prepend="getPrepend(index)"
:append="getAppend(index)"
/></strong>
<Spelling v-else :text="getPrepend(index) + part.str + getAppend(index)" />
</template>
<small v-if="link">
(<nuxt-link :to="`/${pronoun.canonicalName}`"><Spelling escape :text="pronoun.canonicalName" /></nuxt-link>)
</small>
<Pronunciation
v-if="pronunciation && pronoun.pronounceable && example.toPronunciationString(pronoun)"
:pronunciation="example.toPronunciationString(pronoun) as string"
/>
</template>
<script lang="ts">

View File

@ -201,7 +201,7 @@ import { useMainStore } from '../store/index.ts';
export default {
setup() {
const translationModeVisibleCookie = useCookie('translationModeVisible', sessionCookieSetting);
const versionBackend = useFetch('/api/version', { server: false });
const versionBackend = useFetch('/api/version', { server: false, lazy: true });
return {
config: useConfig(),
store: useMainStore(),

View File

@ -18,7 +18,7 @@
<Avatar v-if="link.avatar" :user="link.avatar" dsize="1.6rem" />
<Icon v-else :v="link.icon" :size="1.6" />
<br>
<span class="text-nowrap"><Spelling :text="link.text" /></span>
<Spelling class="text-nowrap" :text="link.text" />
</template>
</PotentiallyExternalLink>
</template>

View File

@ -26,7 +26,7 @@ export default defineComponent({
text = escapeHtml(text);
}
if (!text) {
return h('span');
return h('span', this.$attrs);
}
let isLink = false;
@ -40,10 +40,10 @@ export default defineComponent({
return h(Icon, { v: buffer });
}
const bufferNode = h('span', { innerHTML: this.handleSpelling(buffer) });
const attrs = { ...this.$attrs, innerHTML: this.handleSpelling(buffer) };
if (!isLink) {
return bufferNode;
return h('span', attrs);
}
linkBuffer = linkBuffer.replace(/≡/g, '='); // meh workaround, i know
@ -59,23 +59,17 @@ export default defineComponent({
) {
return h(
'a',
{ href: linkBuffer, target: '_blank', rel: 'noopener' },
[bufferNode],
{ ...attrs, href: linkBuffer, target: '_blank', rel: 'noopener' },
);
}
if (linkBuffer.indexOf('#') === 0) {
return h(
'a',
{ href: linkBuffer },
[bufferNode],
);
return h('a', { ...attrs, href: linkBuffer });
}
return h(
NuxtLink,
{ to: linkBuffer || `/${this.config.nouns.route}#${this.handleSpelling(buffer)}` },
() => [bufferNode],
{ ...attrs, to: linkBuffer || `/${this.config.nouns.route}#${this.handleSpelling(buffer)}` },
);
};
const addChild = (): void => {
@ -127,7 +121,7 @@ export default defineComponent({
}
addChild();
return h('span', children);
return children;
},
});
</script>

View File

@ -48,11 +48,14 @@ export default defineComponent({
async setup() {
const runtimeConfig = useRuntimeConfig();
const { data: stats } = await useFetch<{ overall: { users: number } }>('/api/admin/stats-public');
const { data: stats } = await useFetch<{ overall: { users: number } }>(
'/api/admin/stats-public',
{ lazy: true },
);
return {
stats,
selectedDay: storeToRefs(useMainStore()).selectedDay,
celebrate1M: stats.value && stats.value.overall.users >= 1_000_000 && stats.value.overall.users < 1_005_000,
calendar: buildCalendar(runtimeConfig.public.baseUrl),
};
},
@ -67,6 +70,9 @@ export default defineComponent({
$tRefs(): Refs {
return this.$refs as unknown as Refs;
},
celebrate1M(): boolean {
return this.stats !== null && this.stats.overall.users >= 1_000_000 && this.stats.overall.users < 1_005_000;
},
},
watch: {
selectedDay() {

View File

@ -26,7 +26,7 @@ export default {
emphasise: { type: Boolean },
},
async setup() {
const { data: moderation } = await useFetch('/api/admin/moderation', { server: false });
const { data: moderation } = await useFetch('/api/admin/moderation', { server: false, lazy: true });
return {
moderation,
};

View File

@ -1,23 +1,21 @@
<template>
<div>
<div class="main-asides d-flex flex-column flex-xxl-row justify-content-center align-items-center align-items-xxl-stretch">
<aside v-if="!wide" class="aside-left p-0 p-xxl-3">
<slot name="aside-left">
<AdPlaceholder :phkey="['aside-left', null]" class="d-none d-xxl-block" />
</slot>
</aside>
<main :class="[wide ? 'wide' : '']">
<slot></slot>
</main>
<aside v-if="!wide" class="aside-right">
<slot name="aside-right">
<AdPlaceholder :phkey="['aside-right', null]" />
</slot>
</aside>
</div>
<div class="d-flex justify-content-center">
<main><slot name="below"></slot></main>
</div>
<div class="main-asides d-flex flex-column flex-xxl-row justify-content-center align-items-center align-items-xxl-stretch">
<aside v-if="!wide" class="aside-left p-0 p-xxl-3">
<slot name="aside-left">
<AdPlaceholder :phkey="['aside-left', null]" class="d-none d-xxl-block" />
</slot>
</aside>
<main :class="[wide ? 'wide' : '']">
<slot></slot>
</main>
<aside v-if="!wide" class="aside-right">
<slot name="aside-right">
<AdPlaceholder :phkey="['aside-right', null]" />
</slot>
</aside>
</div>
<div class="d-flex justify-content-center">
<main><slot name="below"></slot></main>
</div>
</template>

View File

@ -1,14 +1,12 @@
<template>
<div>
<div ref="svg" :class="border ? 'border' : ''" v-html="svg"></div>
<button v-if="download && svg" class="btn btn-outline-primary btn-sm m-3" :disabled="generating" @click.prevent="generateDownload">
<Spinner v-if="generating" />
<template v-else>
<Icon v="download" />
<T>user.qr.download</T>
</template>
</button>
</div>
<div ref="svg" :class="border ? 'border' : ''" v-bind="$attrs" v-html="svg"></div>
<button v-if="download && svg" class="btn btn-outline-primary btn-sm m-3" :disabled="generating" @click.prevent="generateDownload">
<Spinner v-if="generating" />
<template v-else>
<Icon v="download" />
<T>user.qr.download</T>
</template>
</button>
</template>
<script>

View File

@ -207,7 +207,7 @@ interface Data {
export default defineComponent({
async setup() {
const dialogue = useDialogue();
const { data: keys } = await useFetch('/api/sources/keys');
const { data: keys } = await useFetch('/api/sources/keys', { lazy: true, default: () => [] });
return {
config: useConfig(),

View File

@ -26,7 +26,7 @@ export default defineComponent({
text = safeInlineMarkdown(text);
}
return h('span', { innerHTML: this.handleSpelling(text) });
return h('span', { ...this.$attrs, innerHTML: this.handleSpelling(text) });
},
});
</script>

View File

@ -71,7 +71,7 @@
<script>
export default {
setup() {
const { data: stats } = useFetch('/api/admin/stats-public');
const { data: stats } = useFetch('/api/admin/stats-public', { lazy: true });
return {
stats,
};

View File

@ -1,13 +1,14 @@
<template>
<component :is="Array.isArray(txt) ? 'div' : 'span'" :class="[translationMode ? 't-translation-mode' : '', modified ? 't-modified' : '']" @click="clicked">
<template v-if="Array.isArray(txt)">
<p v-for="p in txt">
<Icon v-if="icon" :v="icon" /><LinkedText :text="p" />
<template v-if="Array.isArray(txt)">
<div :class="classes" @click="clicked">
<p v-for="(paragraph, i) in txt" :key="i">
<Icon v-if="icon" :v="icon" /><LinkedText :text="paragraph" />
</p>
</template><template v-else>
<Icon v-if="icon" :v="icon" /><LinkedText :text="txt" />
</template>
</component>
</div>
</template>
<template v-else>
<Icon v-if="icon" :v="icon" /><LinkedText :text="txt" :class="classes" @click="clicked" />
</template>
</template>
<script>
@ -47,6 +48,9 @@ export default {
? this.$translator.applyParams(this.translationChanges[this.key], this.params || {})
: this.$translator.translate(this.key, this.params || {});
},
classes() {
return [this.translationMode ? 't-translation-mode' : '', this.modified ? 't-modified' : ''];
},
},
methods: {
async clicked(e) {
@ -87,7 +91,7 @@ export default {
};
</script>
<style lang="scss" scoped>
<style lang="scss">
@import "assets/variables";
.t-translation-mode {

View File

@ -130,7 +130,7 @@ interface Data {
export default defineComponent({
async setup() {
const dialogue = useDialogue();
const { data: keys } = await useFetch('/api/terms/keys');
const { data: keys } = await useFetch('/api/terms/keys', { lazy: true, default: () => [] });
return {
config: useConfig(),

View File

@ -1,4 +1,5 @@
import { useRuntimeConfig } from 'nuxt/app';
import type { RuntimeConfig } from 'nuxt/schema';
import { unref } from 'vue';
import { useSeoMeta } from '@unhead/vue';
import type { MaybeRef } from 'vue';
@ -6,24 +7,35 @@ import type { UseSeoMetaInput } from '@unhead/vue';
import { clearLinkedText } from '../src/helpers.ts';
import type { Translator } from '../src/translator';
export const getDefaultSeo = (translator: Translator, runtimeConfig: RuntimeConfig) => ({
title: translator.translate('title'),
description: translator.translate('description'),
keywords: (translator.translate<string[]>('seo.keywords') || []).join(', '),
banner: `${runtimeConfig.public.baseUrl}/api/banner/zaimki.png`,
});
export interface HeadParams {
title?: MaybeRef<string | null>;
description?: MaybeRef<string | null>;
banner?: MaybeRef<string | null>;
noindex?: boolean;
keywords?: string[];
keywords?: MaybeRef<string[]>;
}
const DESCRIPTION_MAX_WORDCOUNT = 24;
export default (
{ title, description, banner, noindex = false, keywords }: HeadParams,
translator: Translator,
): void => {
const runtimeConfig = useRuntimeConfig();
const defaultSeo = getDefaultSeo(translator, runtimeConfig);
const seo: UseSeoMetaInput = {};
const seoTitle = () => {
let titleUnwrapped = unref(title);
if (!titleUnwrapped) {
return translator.translate('title');
return defaultSeo.title;
}
titleUnwrapped = titleUnwrapped.replace(/&#39;/g, '\'');
titleUnwrapped = titleUnwrapped.replace(/<\/?[^>]+(>|$)/g, ''); // html tags
@ -38,11 +50,14 @@ export default (
const seoDescription = () => {
let descriptionUnwrapped = unref(description);
if (!descriptionUnwrapped) {
return undefined;
return defaultSeo.description;
}
descriptionUnwrapped = clearLinkedText(descriptionUnwrapped);
descriptionUnwrapped = `${descriptionUnwrapped.split(' ').slice(0, 24)
.join(' ')}`;
const words = descriptionUnwrapped.split(' ');
if (words.length > DESCRIPTION_MAX_WORDCOUNT) {
descriptionUnwrapped = `${words.slice(0, DESCRIPTION_MAX_WORDCOUNT)
.join(' ')}`;
}
return descriptionUnwrapped;
};
seo.description = seoDescription;
@ -52,7 +67,7 @@ export default (
const seoImage = () => {
let bannerUnwrapped = unref(banner);
if (!bannerUnwrapped) {
return undefined;
return defaultSeo.banner;
}
bannerUnwrapped = bannerUnwrapped.replace(/^\//, '');
if (!bannerUnwrapped.startsWith('https://')) {
@ -67,10 +82,11 @@ export default (
seo.robots = 'noindex';
}
if (keywords) {
const mergedKeywords = (translator.get<string[]>('seo.keywords', false, false, false) || []).concat(keywords);
seo.keywords = mergedKeywords.join(', ');
}
seo.keywords = () => {
const keywordsUnwrapped = unref(keywords) ?? [];
const mergedKeywords = defaultSeo.keywords.split(', ').concat(keywordsUnwrapped);
return mergedKeywords.join(', ');
};
useSeoMeta(seo);
};

View File

@ -344,12 +344,12 @@ export default defineComponent({
const flags = buildFlags(config.locale);
useSimpleHead({
title: `@${username.value}`,
description: profile.value ? profile.value.description : null,
description: computed(() => profile.value ? profile.value.description : null),
banner: `api/banner/@${username.value}.png`,
noindex: true,
keywords: profile.value
keywords: computed(() => profile.value
? profile.value.flags.map((flag) => translateForPronoun(flags[flag].display, mainPronoun.value))
: undefined,
: undefined),
}, translator);
await user;

View File

@ -107,7 +107,7 @@ export default {
}
useSimpleHead({
title: `${translator.translate('pronouns.intro')}: ${short}`.trim(),
banner: `api/banner/${translator.translate('pronouns.any.short')}.png`,
banner: `api/banner${route.path.replace(/\/$/, '')}.png`,
}, translator);
return {
config,

View File

@ -35,11 +35,13 @@ export default {
});
const { $translator: translator } = useNuxtApp();
const route = useRoute();
const config = useConfig();
useSimpleHead({
title: config.pronouns.null.description,
description: config.pronouns.null.history,
banner: `api/banner${route.path.replace(/\/$/, '')}.png`,
}, translator);
return {

View File

@ -36,10 +36,12 @@ export default {
});
const { $translator: translator } = useNuxtApp();
const route = useRoute();
const config = useConfig();
useSimpleHead({
title: config.pronouns.mirror.name,
description: config.pronouns.mirror.description,
banner: `api/banner${route.path.replace(/\/$/, '')}.png`,
}, translator);
return {
config,

View File

@ -172,7 +172,7 @@ export default {
}, translator);
}
const { data: sources } = useFetch('/api/sources');
const { data: sources } = useFetch('/api/sources', { lazy: true });
return {
config,