per-page-title

This commit is contained in:
Ulf Gebhardt 2024-01-28 12:25:43 +01:00
parent 335de9753b
commit 5fd3b4f7ad
Signed by: ulfgebhardt
GPG Key ID: DA6B843E748679C9
20 changed files with 174 additions and 91 deletions

View File

@ -1,3 +1,5 @@
# META # META
PUBLIC_ENV__META__DEFAULT_TITLE="IT4C" PUBLIC_ENV__META__BASE_URL="http://localhost:3000"
PUBLIC_ENV__META__DEFAULT_DESCRIPTION="IT4C Frontend Boilerplate" PUBLIC_ENV__META__DEFAULT_AUTHOR="IT Team 4 Change"
PUBLIC_ENV__META__DEFAULT_DESCRIPTION="IT4C Frontend Boilerplate"
PUBLIC_ENV__META__DEFAULT_TITLE="IT4C"

View File

@ -3,4 +3,14 @@ export default {
clientRouting: true, clientRouting: true,
prefetchStaticAssets: 'viewport', prefetchStaticAssets: 'viewport',
passToClient: ['pageProps', /* 'urlPathname', */ 'routeParams'], passToClient: ['pageProps', /* 'urlPathname', */ 'routeParams'],
meta: {
title: {
// Make the value of `title` available on both the server- and client-side
env: { server: true, client: true },
},
description: {
// Make the value of `description` available only on the server-side
env: { server: true },
},
},
} }

View File

@ -1,15 +1,18 @@
import { createApp } from './app' import { PageContext } from 'vike/types'
import type { PageContext, VikePageContext } from '#types/PageContext' import { createApp } from './app'
import { getTitle } from './utils'
let instance: ReturnType<typeof createApp> let instance: ReturnType<typeof createApp>
/* async */ function render(pageContext: VikePageContext & PageContext) { /* async */ function render(pageContext: PageContext) {
if (!instance) { if (!instance) {
instance = createApp(pageContext) instance = createApp(pageContext)
instance.app.mount('#app') instance.app.mount('#app')
} else { } else {
instance.app.changePage(pageContext) instance.app.changePage(pageContext)
} }
document.title = getTitle(pageContext)
} }
export default render export default render

View File

@ -1,12 +1,15 @@
import { renderToString as renderToString_ } from '@vue/server-renderer' import { renderToString as renderToString_ } from '@vue/server-renderer'
import { escapeInject, dangerouslySkipEscape } from 'vike/server' import { escapeInject, dangerouslySkipEscape } from 'vike/server'
import { PageContext, PageContextServer } from 'vike/types'
import logoUrl from '#assets/favicon.ico' import logoUrl from '#assets/favicon.ico'
import image from '#assets/it4c-logo2-clean-bg_alpha-128x128.png'
import { META } from '#src/env' import { META } from '#src/env'
import { createApp } from './app'
import type { PageContextServer, PageContext } from '#types/PageContext' import { createApp } from './app'
import { getDescription, getTitle } from './utils'
import type { App } from 'vue' import type { App } from 'vue'
async function render(pageContext: PageContextServer & PageContext) { async function render(pageContext: PageContextServer & PageContext) {
@ -17,9 +20,8 @@ async function render(pageContext: PageContextServer & PageContext) {
const appHtml = await renderToString(app) const appHtml = await renderToString(app)
// See https://vike.dev/head // See https://vike.dev/head
const { documentProps } = pageContext.exports const title = getTitle(pageContext)
const title = (documentProps && documentProps.title) || META.DEFAULT_TITLE const description = getDescription(pageContext)
const desc = (documentProps && documentProps.description) || META.DEFAULT_DESCRIPTION
const documentHtml = escapeInject`<!DOCTYPE html> const documentHtml = escapeInject`<!DOCTYPE html>
<html lang="${locale}"> <html lang="${locale}">
@ -27,7 +29,23 @@ async function render(pageContext: PageContextServer & PageContext) {
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="${logoUrl}" /> <link rel="icon" href="${logoUrl}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="${desc}" /> <meta name="description" content="${description}" />
<meta name="author" content="${META.DEFAULT_AUTHOR}">
<meta property="og:title" content="${title}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="${META.BASE_URL}" />
<meta property="og:description" content="${description}" />
<meta property="og:image" content="${META.BASE_URL}${image}" />
<meta property="og:image:alt" content="${title}" />
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="601"/>
<meta name="twitter:card" content="summary_large_image" />
<!--<meta name="twitter:site" content="@YourTwitterUsername" />-->
<meta name="twitter:title" content="${title}" />
<meta name="twitter:text:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
<meta name="twitter:image" content="${META.BASE_URL}${image}" />
<meta name="twitter:image:alt" content="${title}" />
<title>${title}</title> <title>${title}</title>
</head> </head>
<body> <body>

View File

@ -7,11 +7,11 @@ import i18n from '#plugins/i18n'
import pinia from '#plugins/pinia' import pinia from '#plugins/pinia'
import CreateVuetify from '#plugins/vuetify' import CreateVuetify from '#plugins/vuetify'
import type { PageContext, VikePageContext } from '#types/PageContext' import { PageContext } from 'vike/types'
const vuetify = CreateVuetify(i18n) const vuetify = CreateVuetify(i18n)
function createApp(pageContext: VikePageContext & PageContext, isClient = true) { function createApp(pageContext: PageContext, isClient = true) {
// eslint-disable-next-line no-use-before-define // eslint-disable-next-line no-use-before-define
let rootComponent: InstanceType<typeof PageWithWrapper> let rootComponent: InstanceType<typeof PageWithWrapper>
const PageWithWrapper = defineComponent({ const PageWithWrapper = defineComponent({
@ -47,7 +47,7 @@ function createApp(pageContext: VikePageContext & PageContext, isClient = true)
app.use(vuetify) app.use(vuetify)
objectAssign(app, { objectAssign(app, {
changePage: (pageContext: VikePageContext & PageContext) => { changePage: (pageContext: PageContext) => {
Object.assign(pageContextReactive, pageContext) Object.assign(pageContextReactive, pageContext)
rootComponent.Page = markRaw(pageContext.Page) rootComponent.Page = markRaw(pageContext.Page)
rootComponent.pageProps = markRaw(pageContext.pageProps || {}) rootComponent.pageProps = markRaw(pageContext.pageProps || {})

View File

@ -1,13 +1,12 @@
// `usePageContext` allows us to access `pageContext` in any Vue component. // `usePageContext` allows us to access `pageContext` in any Vue component.
// See https://vike.dev/pageContext-anywhere // See https://vike.dev/pageContext-anywhere
import { PageContext } from 'vike/types'
import { inject } from 'vue' import { inject } from 'vue'
import { PageContext, VikePageContext } from '#types/PageContext'
import type { App, InjectionKey } from 'vue' import type { App, InjectionKey } from 'vue'
export const vikePageContext: InjectionKey<VikePageContext & PageContext> = Symbol('pageContext') export const vikePageContext: InjectionKey<PageContext> = Symbol('pageContext')
function usePageContext() { function usePageContext() {
const pageContext = inject(vikePageContext) const pageContext = inject(vikePageContext)
@ -15,7 +14,7 @@ function usePageContext() {
return pageContext return pageContext
} }
function setPageContext(app: App, pageContext: VikePageContext & PageContext) { function setPageContext(app: App, pageContext: PageContext) {
app.provide(vikePageContext, pageContext) app.provide(vikePageContext, pageContext)
} }

19
renderer/utils.ts Normal file
View File

@ -0,0 +1,19 @@
import { PageContext } from 'vike/types'
import { META } from '#src/env'
function getTitle(pageContext: PageContext) {
// The value exported by /pages/**/+title.js is available at pageContext.config.title
const val = pageContext.config.title
if (typeof val === 'string') return val
if (typeof val === 'function') return String(val(pageContext))
return META.DEFAULT_TITLE
}
function getDescription(pageContext: PageContext) {
const val = pageContext.config.description
if (typeof val === 'string') return val
if (typeof val === 'function') return val(pageContext)
return META.DEFAULT_DESCRIPTION
}
export { getTitle, getDescription }

View File

@ -5,8 +5,10 @@ import { META } from './env'
describe('env', () => { describe('env', () => {
it('has correct default values', () => { it('has correct default values', () => {
expect(META).toEqual({ expect(META).toEqual({
DEFAULT_TITLE: 'IT4C', BASE_URL: 'http://localhost:3000',
DEFAULT_AUTHOR: 'IT Team 4 Change',
DEFAULT_DESCRIPTION: 'IT4C Frontend Boilerplate', DEFAULT_DESCRIPTION: 'IT4C Frontend Boilerplate',
DEFAULT_TITLE: 'IT4C',
}) })
}) })
}) })

View File

@ -1,8 +1,10 @@
const META = { const META = {
DEFAULT_TITLE: (import.meta.env.PUBLIC_ENV__META__DEFAULT_TITLE as string) ?? 'IT4C', BASE_URL: (import.meta.env.PUBLIC_ENV__META__BASE_URL ?? 'http://localhost:3000') as string,
DEFAULT_DESCRIPTION: DEFAULT_AUTHOR: (import.meta.env.PUBLIC_ENV__META__DEFAULT_AUTHOR ??
(import.meta.env.PUBLIC_ENV__META__DEFAULT_DESCRIPTION as string) ?? 'IT Team 4 Change') as string,
'IT4C Frontend Boilerplate', DEFAULT_DESCRIPTION: (import.meta.env.PUBLIC_ENV__META__DEFAULT_DESCRIPTION ??
'IT4C Frontend Boilerplate') as string,
DEFAULT_TITLE: (import.meta.env.PUBLIC_ENV__META__DEFAULT_TITLE ?? 'IT4C') as string,
} }
export { META } export { META }

View File

@ -0,0 +1 @@
export const title = 'IT4C | Error'

View File

@ -1,49 +1,74 @@
import { VueWrapper, mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { ComponentPublicInstance } from 'vue' import { Component, h } from 'vue'
import { VApp } from 'vuetify/components'
import ErrorPage from './+Page.vue' import ErrorPage from './+Page.vue'
import { title } from './+title'
describe('ErrorPage', () => { describe('ErrorPage', () => {
let wrapper: VueWrapper<unknown, ComponentPublicInstance<unknown, Omit<unknown, never>>> it('title returns correct title', () => {
const Wrapper = () => { expect(title).toBe('DreamMall | Fehler')
return mount(ErrorPage)
}
beforeEach(() => {
wrapper = Wrapper()
}) })
describe('500 Error', () => {
const WrapperUndefined = () => {
return mount(VApp, {
slots: {
default: h(ErrorPage as Component),
},
})
}
const WrapperFalse = () => {
return mount(VApp, {
slots: {
default: h(ErrorPage as Component, {
is404: false,
}),
},
})
}
describe('no is404 property set', () => { let wrapper: ReturnType<typeof WrapperUndefined>
it('renders error 500', () => { beforeEach(() => {
expect(wrapper.find('h1').text()).toEqual("$t('error.500.h1')") wrapper = WrapperUndefined()
expect(wrapper.find('p').text()).toEqual("$t('error.500.text')")
}) })
}) describe('no is404 property set', () => {
it('renders error 500', () => {
describe('is404 property is false', () => { expect(wrapper.find('h1').text()).toEqual("$t('error.500.h1')")
beforeEach(async () => { expect(wrapper.find('p').text()).toEqual("$t('error.500.text')")
await wrapper.setProps({
is404: false,
}) })
}) })
it('renders error 500', () => { describe('is404 property is false', () => {
expect(wrapper.find('h1').text()).toEqual("$t('error.500.h1')") beforeEach(() => {
expect(wrapper.find('p').text()).toEqual("$t('error.500.text')") wrapper = WrapperFalse()
}) })
})
describe('is404 property is true', () => { it('renders error 500', () => {
beforeEach(async () => { expect(wrapper.find('h1').text()).toEqual("$t('error.500.h1')")
await wrapper.setProps({ expect(wrapper.find('p').text()).toEqual("$t('error.500.text')")
is404: true,
}) })
}) })
})
it('renders error 400', () => { describe('404 Error', () => {
expect(wrapper.find('h1').text()).toEqual("$t('error.404.h1')") const Wrapper = () => {
expect(wrapper.find('p').text()).toEqual("$t('error.404.text')") return mount(VApp, {
slots: {
default: h(ErrorPage as Component, {
is404: true,
}),
},
})
}
let wrapper: ReturnType<typeof Wrapper>
beforeEach(() => {
wrapper = Wrapper()
})
describe('is404 property is true', () => {
it('renders error 400', () => {
expect(wrapper.find('h1').text()).toEqual("$t('error.404.h1')")
expect(wrapper.find('p').text()).toEqual("$t('error.404.text')")
})
}) })
}) })
}) })

View File

@ -0,0 +1 @@
export const title = 'IT4C | About'

View File

@ -4,6 +4,7 @@ import { Component, h } from 'vue'
import { VApp } from 'vuetify/components' import { VApp } from 'vuetify/components'
import AboutPage from './+Page.vue' import AboutPage from './+Page.vue'
import { title } from './+title'
describe('AboutPage', () => { describe('AboutPage', () => {
const wrapper = mount(VApp, { const wrapper = mount(VApp, {
@ -12,6 +13,10 @@ describe('AboutPage', () => {
}, },
}) })
it('title returns correct title', () => {
expect(title).toBe('DreamMall')
})
it('renders', () => { it('renders', () => {
expect(wrapper.element).toMatchSnapshot() expect(wrapper.element).toMatchSnapshot()
}) })

View File

@ -1,6 +1,5 @@
import { resolveRoute } from 'vike/routing' import { resolveRoute } from 'vike/routing'
import { PageContext } from 'vike/types'
import { PageContext } from '#types/PageContext'
export default (pageContext: PageContext) => { export default (pageContext: PageContext) => {
{ {

1
src/pages/app/+title.ts Normal file
View File

@ -0,0 +1 @@
export const title = 'IT4C | App'

View File

@ -4,6 +4,7 @@ import { Component, h } from 'vue'
import { VApp } from 'vuetify/components' import { VApp } from 'vuetify/components'
import AppPage from './+Page.vue' import AppPage from './+Page.vue'
import { title } from './+title'
describe('AppPage', () => { describe('AppPage', () => {
const wrapper = mount(VApp, { const wrapper = mount(VApp, {
@ -12,6 +13,10 @@ describe('AppPage', () => {
}, },
}) })
it('title returns correct title', () => {
expect(title).toBe('DreamMall')
})
it('renders', () => { it('renders', () => {
expect(wrapper.element).toMatchSnapshot() expect(wrapper.element).toMatchSnapshot()
}) })

View File

@ -0,0 +1,3 @@
import { META } from '#src/env'
export const title = META.DEFAULT_TITLE

View File

@ -4,14 +4,19 @@ import { Component, h } from 'vue'
import { VApp } from 'vuetify/components' import { VApp } from 'vuetify/components'
import IndexPage from './+Page.vue' import IndexPage from './+Page.vue'
import { title } from './+title'
describe('DataPrivacyPage', () => { describe('IndexPage', () => {
const wrapper = mount(VApp, { const wrapper = mount(VApp, {
slots: { slots: {
default: h(IndexPage as Component), default: h(IndexPage as Component),
}, },
}) })
it('title returns default title', () => {
expect(title).toBe('DreamMall')
})
it('renders', () => { it('renders', () => {
expect(wrapper.element).toMatchSnapshot() expect(wrapper.element).toMatchSnapshot()
}) })

16
types/PageContext.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
import { Page } from '#types/Page'
import { PageProps } from '#types/PageProps'
declare global {
namespace Vike {
interface PageContext {
urlPathname: string
config: {
title: string | ((pageContext: PageContext) => string) | undefined
description: string | ((pageContext: PageContext) => string) | undefined
}
Page: Page
pageProps?: PageProps
}
}
}

View File

@ -1,33 +0,0 @@
import { Page } from '#types/Page'
import { PageProps } from '#types/PageProps'
export type {
PageContextServer,
// When using Client Routing https://vike.dev/clientRouting
PageContextClient,
PageContext as VikePageContext,
PageContextBuiltInClientWithClientRouting as PageContextBuiltInClient,
// When using Server Routing
/*
PageContextClientWithServerRouting as PageContextClient,
PageContextWithServerRouting as PageContext,
*/
//* /
} from 'vike/types'
export type PageContext = {
Page: Page
pageProps?: PageProps
urlPathname: string
exports: {
documentProps?: {
title?: string
description?: string
}
}
documentProps?: {
title: string
description?: string
}
}