diff --git a/components/organisms/VDefaultPage/VDefaultPage.vue b/components/organisms/VDefaultPage/VDefaultPage.vue new file mode 100644 index 0000000..26fca7f --- /dev/null +++ b/components/organisms/VDefaultPage/VDefaultPage.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/composables/use-alternate-links.ts b/composables/use-alternate-links.ts new file mode 100644 index 0000000..729386f --- /dev/null +++ b/composables/use-alternate-links.ts @@ -0,0 +1,26 @@ +import type { RoadizAlternateLink } from '@roadiz/types' +import type { LocaleObject } from '@nuxtjs/i18n' + +export function useAlternateLinks(links?: RoadizAlternateLink[]) { + const alternateLinks = useState('alternateLinks', () => []) + + if (links) alternateLinks.value = links + + const { $i18n } = useNuxtApp() + + const availableAlternateLinks = computed(() => { + const locales = + ($i18n.locales.value?.some((locale: unknown) => typeof locale === 'string') + ? ($i18n.locales.value as unknown as string[]) + : ($i18n.locales.value as LocaleObject[]).map((locale) => locale.code)) || [] + + return alternateLinks.value.sort((a: RoadizAlternateLink, b: RoadizAlternateLink) => { + const indexA = locales.includes(a.locale) ? locales.indexOf(a.locale) : 9999 + const indexB = locales.includes(b.locale) ? locales.indexOf(b.locale) : 9999 + + return indexA - indexB + }) + }) + + return { alternateLinks, availableAlternateLinks } +} diff --git a/composables/use-common-content.ts b/composables/use-common-content.ts new file mode 100644 index 0000000..17e2545 --- /dev/null +++ b/composables/use-common-content.ts @@ -0,0 +1,25 @@ +import type { CommonContent, CommonContentMenuKey } from '~/types/api' +import type { ValueOf } from '~/utils/types' + +export const COMMON_CONTENT_KEY = 'commonContent' + +export function useCommonContent() { + const commonContent = useNuxtData(COMMON_CONTENT_KEY).data + const homeItem = computed(() => commonContent.value?.home) + const head = computed(() => commonContent.value?.head) + const errorWalker = computed(() => commonContent.value?.errorPage) + + function getMenu(key: CommonContentMenuKey): ValueOf | undefined { + return commonContent.value?.menus?.[key] + } + + const mainMenuWalker = computed(() => getMenu('mainMenuWalker')) + + return { + commonContent, + head, + homeItem, + mainMenuWalker, + errorWalker, + } +} diff --git a/composables/use-current-page.ts b/composables/use-current-page.ts new file mode 100644 index 0000000..05d7198 --- /dev/null +++ b/composables/use-current-page.ts @@ -0,0 +1,5 @@ +import type { Page } from '~/composables/use-page' + +export function useCurrentPage() { + return useState('currentPage', () => ({})) +} diff --git a/pages/[...slug].vue b/pages/[...slug].vue index 29c011a..5ed8f02 100644 --- a/pages/[...slug].vue +++ b/pages/[...slug].vue @@ -4,22 +4,30 @@ // }) import type { RoadizNodesSources } from '@roadiz/types' +import { getBlockCollection } from '~/utils/roadiz/block' +import { isPageEntity } from '~/utils/roadiz/entity' -const { item, error } = await useRoadizWebResponse() +const { webResponse, item, error } = await useRoadizWebResponse() if (error) { showError(error) } - const route = useRoute() + // Force redirect when web response URL is not matching current route path if (item?.url && item.url !== route.path) { await navigateTo({ path: item?.url }, { redirectCode: 301 }) } + +// Get blocks from web response +const blocks = computed(() => (webResponse?.blocks && getBlockCollection(webResponse.blocks)) || []) + +// Get default page entity +const defaultPageEntity = computed(() => item && isPageEntity(item) && item) diff --git a/plugins/01.init.ts b/plugins/01.init.ts new file mode 100644 index 0000000..09b4cf4 --- /dev/null +++ b/plugins/01.init.ts @@ -0,0 +1,141 @@ +import { joinURL } from 'ufo' +import type { RoadizAlternateLink, RoadizNodesSources, RoadizWebResponse } from '@roadiz/types' +import type { Link, Script } from '@unhead/schema' +import { COMMON_CONTENT_KEY, useCommonContent } from '~/composables/use-common-content' +import { createGoogleTagManagerScript } from '~/utils/tracking/google-tag-manager' +import { createMatomoTagManagerScript } from '~/utils/tracking/matomo-tag-manager' + +async function initI18n(locale?: string) { + const nuxtApp = useNuxtApp() + + if (locale) { + const { $i18n } = nuxtApp + await $i18n.setLocale(locale) + } else { + // get the locale from the route (prefix) or cookie ? + } +} + +async function initCommonContent() { + await useRoadizFetch('/common_content', { + key: COMMON_CONTENT_KEY, + }) +} + +function initHead(webResponse?: RoadizWebResponse, alternateLinks?: RoadizAlternateLink[]) { + const nuxtApp = useNuxtApp() + const route = useRoute() + const runtimeConfig = useRuntimeConfig() + const { $i18n } = nuxtApp + const script: (Script<['script']> | string)[] = [] + const link: Link[] = [ + { + rel: 'canonical', + href: joinURL(runtimeConfig.public.site.url, webResponse?.item?.url || route.path), + }, + ] + + // ALTERNATE LINKS + const alternateLinksHead = alternateLinks?.map((alternateLink: RoadizAlternateLink) => { + return { + hid: `alternate-${alternateLink.locale}`, + rel: 'alternate', + hreflang: alternateLink.locale, + href: joinURL(runtimeConfig.public.site.url, alternateLink.url), + } + }) + if (alternateLinksHead) link.push(...alternateLinksHead) + + // GOOGLE TAG MANAGER + // Google Tag Manager must not be loaded by tarteaucitron, it must configure tarteaucitron itself. + // Notice: by using GTM you must comply with GDPR and cookie consent or just use + // tarteaucitron with GA4, Matomo or Plausible + const googleTagManager = runtimeConfig.public.googleTagManager + if (googleTagManager && googleTagManager !== '') { + script.push(createGoogleTagManagerScript(googleTagManager)) + } + + // MATOMO + const matomoURL = runtimeConfig.public.matomo?.url + const matomoContainerID = runtimeConfig.public.matomo?.containerID + if (matomoURL && matomoContainerID && matomoURL !== '' && matomoContainerID !== '') { + script.push(createMatomoTagManagerScript(matomoContainerID, matomoURL)) + } + + useHead({ + htmlAttrs: { + lang: $i18n.locale.value, + }, + script, + link, + meta: [ + // app version + { name: 'version', content: runtimeConfig.public.version }, + ], + }) +} + +function initSeoMeta(webResponse?: RoadizWebResponse) { + const nuxtApp = useNuxtApp() + const { commonContent } = useCommonContent() + const runtimeConfig = useRuntimeConfig() + const head = webResponse?.head + const description = webResponse?.head?.metaDescription || commonContent.value?.head?.metaDescription + const title = webResponse?.head?.metaTitle || commonContent.value?.head?.metaTitle + const siteName = commonContent.value?.head?.siteName || (nuxtApp.$config.siteName as string) || '' + const { isActive: previewIsActive } = useRoadizPreview() + const img = useImage() + const image = () => { + const image = + head?.shareImage?.relativePath || + // @ts-ignore not sure the `images` property exists, but generally it does + head?.images?.[0]?.relativePath || + // @ts-ignore not sure the `image` property exists, but generally it does + head?.image?.[0]?.relativePath || + commonContent.value?.head?.shareImage?.relativePath + + if (image) { + return img(image, { + width: 1200, + quality: 70, + }) + } else { + return joinURL(runtimeConfig.public.site.url, '/images/share.jpg') + } + } + + useServerSeoMeta({ + description, + ogTitle: title, + ogSiteName: siteName, + ogDescription: description, + ogImage: image(), + twitterCard: 'summary', + twitterTitle: title, + twitterDescription: description, + robots: { + noindex: (webResponse?.item as RoadizNodesSources)?.noIndex || previewIsActive.value, + }, + }) +} + +export default defineNuxtPlugin(async () => { + const route = useRoute() + const isWildCardRoute = route.name === 'slug' + const data = isWildCardRoute ? await useRoadizWebResponse() : undefined + const locale = data && ((data.item as RoadizNodesSources)?.translation?.locale || undefined) + + if (locale) { + // Set currentPage data accessible outside pages + useCurrentPage().value = { + webResponse: data.webResponse, + alternateLinks: data.alternateLinks, + } + + await initI18n(locale) + useAlternateLinks(data?.alternateLinks) + } + await initCommonContent() + initHead(data?.webResponse, data?.alternateLinks) + initSeoMeta(data?.webResponse) +}) diff --git a/types/api.d.ts b/types/api.d.ts new file mode 100644 index 0000000..a72946c --- /dev/null +++ b/types/api.d.ts @@ -0,0 +1,47 @@ +import type { RoadizAlternateLink, RoadizNodesSources, RoadizNodesSourcesHead, RoadizWebResponse } from '@roadiz/types' +import type { MenuNodeType } from '~/types/app' +import type { NSFooter, NSMenu, NSPage } from '~/types/roadiz' +import type { RoadizWalkerKnown } from '~/utils/types' + +interface HydraError { + '@context': string + '@type': string + 'hydra:title': string + 'hydra:description': string + message?: string + trace: Array +} + +interface HydraErrorTraceItem { + file: string + line: number + function: string + class: string + type: string + args: Array +} + +interface PageResponse { + webResponse: RoadizWebResponse | undefined + alternateLinks?: RoadizAlternateLink[] + locale?: string +} + +type CommonContentMenuKey = 'mainMenuWalker' | 'footerMenuWalker' | 'headerMenuWalker' | string + +interface CommonContent { + home?: RoadizNodesSources + head?: RoadizNodesSourcesHead + menus?: Record> + errorPage?: RoadizWalkerKnown +} + +interface CustomForm extends JsonLdObject { + slug?: boolean + name?: boolean + open?: boolean + definitionUrl?: string | null + postUrl?: string | null + description?: string | null + color?: string | null +} diff --git a/utils/roadiz/block.ts b/utils/roadiz/block.ts new file mode 100644 index 0000000..09c8f55 --- /dev/null +++ b/utils/roadiz/block.ts @@ -0,0 +1,8 @@ +import type { RoadizWalker, RoadizWebResponseBlocks } from '@roadiz/types' +import { getArrayFromCollection } from '~/utils/roadiz/get-array-from-collection' + +// we have to get a hydra collection of RoadizWalker (JSON LD format), +// because if the API has been requested as JSON format then we won't be able to parse the result (lack of '@type' properties) +export function getBlockCollection(blocks: RoadizWebResponseBlocks): RoadizWalker[] { + return getArrayFromCollection(blocks) +} diff --git a/utils/roadiz/entity.ts b/utils/roadiz/entity.ts new file mode 100644 index 0000000..87c230f --- /dev/null +++ b/utils/roadiz/entity.ts @@ -0,0 +1,35 @@ +import type { JsonLdObject } from '@roadiz/types' + +export function isNodeType(entity: unknown): entity is JsonLdObject { + return !!(entity && typeof entity === 'object' && '@id' in entity && '@type' in entity) +} + +export function isEntityType(entity: JsonLdObject, type: string): boolean { + const regex = new RegExp('^(NS)?' + type + '$', 'gi') + const matches = entity['@type']?.match(regex) + return matches !== null && matches.length > 0 +} + +export function isSchemaOrgType(entity: JsonLdObject, type: string): boolean { + const regex = new RegExp('^(?:https?:\\/\\/schema\\.org\\/)?' + type + '$', 'gi') + const matches = entity['@type']?.match(regex) + return matches !== null && matches.length > 0 +} + +export function isPageEntity(entity: JsonLdObject): boolean { + return isEntityType(entity, 'Page') +} + +export function isBlogPostEntity(entity: JsonLdObject): boolean { + return isEntityType(entity, 'BlogPost') +} + +export function isBlogListingEntity(entity: JsonLdObject): boolean { + return isEntityType(entity, 'BlogPostContainer') +} + +// BLOCKS + +export function isContentBlock(entity: JsonLdObject): boolean { + return isEntityType(entity, 'ContentBlock') +} diff --git a/utils/roadiz/get-array-from-collection.ts b/utils/roadiz/get-array-from-collection.ts new file mode 100644 index 0000000..7743906 --- /dev/null +++ b/utils/roadiz/get-array-from-collection.ts @@ -0,0 +1,9 @@ +import type { HydraCollection, JsonLdObject } from '@roadiz/types' + +export function getArrayFromCollection( + collection: HydraCollection | Array> | unknown[], +): T[] { + if (Array.isArray(collection)) return collection as T[] + + return 'hydra:member' in collection && Array.isArray(collection['hydra:member']) ? collection['hydra:member'] : [] +} diff --git a/utils/tracking/google-analytics-4.ts b/utils/tracking/google-analytics-4.ts new file mode 100644 index 0000000..f6f7f33 --- /dev/null +++ b/utils/tracking/google-analytics-4.ts @@ -0,0 +1,13 @@ +/** + * @deprecated Use tarteaucitron for better GDPR integration + * @see https://developers.google.com/analytics/devguides/collection/gtagjs + * @param id + */ +export function createGoogleAnalytics4Script(id: string): string { + return ` + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + gtag('js', new Date()); + gtag('config', '${id}'); + ` +} diff --git a/utils/tracking/google-tag-manager.ts b/utils/tracking/google-tag-manager.ts new file mode 100644 index 0000000..99c6ac8 --- /dev/null +++ b/utils/tracking/google-tag-manager.ts @@ -0,0 +1,13 @@ +/** + * @see https://developers.google.com/tag-platform/tag-manager/web + * @param id + */ +export function createGoogleTagManagerScript(id: string): string { + return ` + (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': + new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], + j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= + 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); + })(window,document,'script','dataLayer','${id}'); + ` +} diff --git a/utils/tracking/matomo-tag-manager.ts b/utils/tracking/matomo-tag-manager.ts new file mode 100644 index 0000000..bae70dd --- /dev/null +++ b/utils/tracking/matomo-tag-manager.ts @@ -0,0 +1,13 @@ +/** + * @see https://developer.matomo.org/guides/tagmanager/embedding + * @param {string} id + * @param {string} matomoTagManagerUrl + */ +export function createMatomoTagManagerScript(id: string, matomoTagManagerUrl: string): string { + return ` + var _mtm = window._mtm = window._mtm || []; + _mtm.push({'mtm.startTime': (new Date().getTime()), 'event': 'mtm.Start'}); + var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; + g.async=true; g.src='${matomoTagManagerUrl}/js/container_${id}.js'; s.parentNode.insertBefore(g,s); + ` +} diff --git a/utils/tracking/matomo.ts b/utils/tracking/matomo.ts new file mode 100644 index 0000000..3ff9613 --- /dev/null +++ b/utils/tracking/matomo.ts @@ -0,0 +1,20 @@ +/** + * @deprecated Use tarteaucitron for better GDPR integration + * @see https://developer.matomo.org/guides/tracking-javascript-guide + * @param {string} id + * @param {string} matomoUrl + */ +export function createMatomoScript(id: string, matomoUrl: string): string { + return ` + var _paq = window._paq = window._paq || []; + _paq.push(['trackPageView']); + _paq.push(['enableLinkTracking']); + (function() { + var u="//${matomoUrl}/"; + _paq.push(['setTrackerUrl', u+'matomo.php']); + _paq.push(['setSiteId', '${id}']); + var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; + g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); + })(); + ` +} diff --git a/utils/tracking/tarteaucitron.ts b/utils/tracking/tarteaucitron.ts new file mode 100644 index 0000000..aba177d --- /dev/null +++ b/utils/tracking/tarteaucitron.ts @@ -0,0 +1,78 @@ +export interface TarteaucitronConfigOptions { + policyUrl?: string + googleAnalytics?: string + matomoSiteId?: string + matomoUrl?: string +} + +/* + * This method initialize TAC directly on your website. If you need to use + * Google Tag Manager, you should move this configuration to an external GTM tag. + * + * Override this method if you need to adjust configuration: such as multi-domains cookie + * or exotic trackers, etc + */ +export function createTarteaucitronConfig(options: TarteaucitronConfigOptions): string { + let script = ` + tarteaucitron.init({ + "privacyUrl": "${options.policyUrl}", + "bodyPosition": "bottom", + "hashtag": "#cookie_consent", + "cookieName": "cookie_consent", + "orientation": "bottom", + "groupServices": false, + "serviceDefaultState": "wait", + "showAlertSmall": false, + "cookieslist": false, + "closePopup": false, + "showIcon": false, + "iconPosition": "BottomRight", + "adblocker": false, + "DenyAllCta" : true, + "AcceptAllCta" : true, + "highPrivacy": true, + "handleBrowserDNTRequest": false, + "removeCredit": true, + "moreInfoLink": false, + "useExternalCss": true, /* Use _tarteaucitron.scss */ + "useExternalJs": false, + "readmoreLink": "", + "mandatory": true, + "mandatoryCta": true + }); + ` + + /* + * Google Analytics (universal) + */ + if (options.googleAnalytics && options.googleAnalytics.startsWith('UA-')) { + script += ` + + tarteaucitron.user.analyticsUa = '${options.googleAnalytics}'; + tarteaucitron.user.analyticsAnonymizeIp = true; + (tarteaucitron.job = tarteaucitron.job || []).push('analytics'); + ` + } + + /* + * Google Analytics (GA4) + */ + if (options.googleAnalytics && options.googleAnalytics.startsWith('G-')) { + script += ` + + tarteaucitron.user.gtagUa = '${options.googleAnalytics}'; + (tarteaucitron.job = tarteaucitron.job || []).push('gtag'); + ` + } + + if (options.matomoSiteId && options.matomoUrl) { + script += ` + + tarteaucitron.user.matomoId = '${options.matomoSiteId}'; + tarteaucitron.user.matomoHost = '${options.matomoUrl}'; + (tarteaucitron.job = tarteaucitron.job || []).push('matomo'); + ` + } + + return script +} diff --git a/utils/types.ts b/utils/types.ts new file mode 100644 index 0000000..be8780e --- /dev/null +++ b/utils/types.ts @@ -0,0 +1,12 @@ +// ROADIZ +import type { RoadizNodesSources, JsonLdObject } from '@roadiz/types' + +export interface RoadizWalkerKnown extends JsonLdObject { + item: T + children: RoadizWalkerKnown[] +} + +// COMMONS +export type Writeable = { -readonly [P in keyof T]: T[P] } +export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never +export type ValueOf = T extends any[] ? T[number] : T[keyof T]