@iqual/nuxt-icms 1.6.1 → 1.6.2-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/__tests__/anchors.spec.ts +43 -0
- package/__tests__/contract/blokkliComponents.spec.ts +59 -0
- package/__tests__/contract/componentFragments.spec.ts +24 -0
- package/__tests__/contract/languages.spec.ts +63 -0
- package/__tests__/contract/project.ts +144 -0
- package/__tests__/hreflang.spec.ts +39 -0
- package/{tests → __tests__}/pages.spec.ts +14 -5
- package/app.config.ts +15 -0
- package/app.vue +3 -21
- package/assets/css/backend.css +56 -0
- package/assets/css/base/base.css +2 -2
- package/assets/css/base/typography.css +2 -2
- package/assets/css/ckeditor.css +3 -4
- package/assets/css/components/decorations.css +1 -1
- package/assets/css/components/editor.css +1 -1
- package/assets/css/components/grid-container.css +19 -19
- package/assets/css/fe-fixes.css +6 -12
- package/assets/css/theme/colors/color-primary.css +33 -11
- package/assets/css/theme/colors/color-secondary.css +33 -11
- package/assets/css/utilities/utilities.css +1 -1
- package/assets/symbols/anchor.svg +3 -0
- package/blokkli.editAdapter.ts +56 -0
- package/bundles.ts +6 -6
- package/cache/__tests__/pageCache.spec.ts +540 -0
- package/cache/cacheTagRegistry.ts +124 -0
- package/cache/enabledForRequest.ts +21 -0
- package/cache/multiCache.serverOptions.ts +58 -0
- package/cache/pageCache.ts +360 -0
- package/components/Blokkli/Anchor/icon.svg +3 -0
- package/components/Blokkli/Anchor/index.vue +13 -0
- package/components/Blokkli/AnchorNavigation/icon.svg +8 -0
- package/components/Blokkli/AnchorNavigation/index.vue +9 -0
- package/components/Blokkli/Card/Element/index.vue +0 -1
- package/components/Blokkli/Collapsible/Element/index.vue +5 -0
- package/components/Blokkli/FAQ/Element/index.vue +0 -1
- package/components/Blokkli/Fragment/Separator/icon.svg +4 -0
- package/components/Blokkli/Fragment/Separator/index.vue +87 -0
- package/components/Blokkli/Gallery/index.vue +1 -3
- package/components/Blokkli/IconText/Element/index.vue +0 -1
- package/components/Blokkli/Introduction/index.vue +0 -1
- package/components/Blokkli/Listing/icon.svg +9 -0
- package/components/Blokkli/Listing/index.vue +24 -0
- package/components/Blokkli/Logo/Element/index.vue +0 -1
- package/components/Blokkli/Table/index.vue +0 -1
- package/components/Blokkli/Team/Element/index.vue +0 -1
- package/components/Blokkli/Team/Layout/index.vue +0 -1
- package/components/Blokkli/Testimonial/Element/index.vue +0 -1
- package/components/Blokkli/Text/index.vue +0 -1
- package/components/DesignSystem/Atoms/Button.vue +3 -0
- package/components/DesignSystem/Atoms/GridRow.vue +3 -1
- package/components/DesignSystem/Molecules/ButtonGroup.vue +5 -4
- package/components/DesignSystem/Molecules/IconText.vue +6 -6
- package/components/DesignSystem/Molecules/MediaLightbox.vue +8 -8
- package/components/DesignSystem/Molecules/MediaSlider.vue +9 -11
- package/components/DesignSystem/Molecules/Partnership.vue +7 -4
- package/components/DesignSystem/Molecules/Table.vue +35 -19
- package/components/DesignSystem/Molecules/TitleComposition.vue +46 -15
- package/components/DesignSystem/Molecules/iFrame.vue +2 -2
- package/components/DesignSystem/Organisms/CallToAction.vue +8 -7
- package/components/DesignSystem/Organisms/CallToActionFeature.vue +8 -7
- package/components/DesignSystem/Organisms/CallToActionFullwidth.vue +9 -8
- package/components/DesignSystem/Organisms/ContentBlock.vue +26 -16
- package/components/DesignSystem/Organisms/FacetedListing.vue +38 -17
- package/components/DesignSystem/Organisms/FactCard.vue +6 -7
- package/components/DesignSystem/Organisms/FeatureCard.vue +14 -14
- package/components/DesignSystem/Organisms/FlipTeaser.vue +19 -19
- package/components/DesignSystem/Organisms/HeaderHero.vue +11 -11
- package/components/DesignSystem/Organisms/HeaderTextImage.vue +10 -10
- package/components/DesignSystem/Organisms/LinkList.vue +20 -12
- package/components/DesignSystem/Organisms/LogoElement.vue +7 -4
- package/components/DesignSystem/Organisms/Map.vue +1 -1
- package/components/DesignSystem/Organisms/SearchResults.vue +15 -6
- package/components/DesignSystem/Organisms/SpotlightCard.vue +18 -18
- package/components/DesignSystem/Organisms/TeamElement.vue +19 -18
- package/components/DesignSystem/Organisms/TeaserCard.vue +16 -17
- package/components/DesignSystem/Organisms/TeaserSimple.vue +4 -31
- package/components/DesignSystem/Organisms/Testimonial.vue +13 -13
- package/components/DesignSystem/Organisms/TextCard.vue +10 -10
- package/components/DesignSystem/Organisms/TimelineElement.vue +20 -19
- package/components/DesignSystem/Organisms/TwoColumnTextElement.vue +5 -5
- package/components/DesignSystem/README.md +59 -40
- package/components/Drupal/Breadcrumb/index.vue +25 -12
- package/components/Drupal/Messages/index.vue +9 -4
- package/components/Drupal/Webform/index.vue +271 -51
- package/components/Drupal/Webform/mutation.graphql +17 -1
- package/components/Form/HiddenInput.vue +1 -4
- package/components/Form/SelectInput.vue +1 -1
- package/components/Form/SubmitButton.vue +2 -2
- package/components/Form/TextAreaInput.vue +3 -3
- package/components/Form/TextEmailInput.vue +1 -1
- package/components/Form/TextInput.vue +1 -1
- package/components/Form/TextPhoneInput.vue +1 -1
- package/components/Form/WebformMarkup.vue +1 -1
- package/components/Form/composables/useFormDefaultValue.ts +9 -13
- package/components/Form/composables/useFormElement.ts +5 -3
- package/components/Form/utils/types.ts +3 -3
- package/components/Media/Image/fragment.graphql +4 -0
- package/components/Media/Image/index.vue +27 -17
- package/components/Media/RemoteVideo/index.vue +3 -3
- package/components/Media/Video/index.vue +1 -1
- package/components/Node/Icms/Page/SearchResult/fragment.graphql +1 -1
- package/components/Node/Icms/Page/SearchResult/index.vue +2 -2
- package/components/Node/Icms/Page/Teaser/index.vue +1 -1
- package/components/Node/Icms/Page/index.vue +8 -2
- package/components/Paragraph/Anchor/fragment.graphql +4 -0
- package/components/Paragraph/Anchor/index.vue +48 -0
- package/components/Paragraph/AnchorNavigation/fragment.graphql +3 -0
- package/components/Paragraph/AnchorNavigation/index.vue +154 -0
- package/components/Paragraph/BlokkliFragment/fragment.graphql +5 -0
- package/components/Paragraph/Button/Element/fragment.graphql +1 -1
- package/components/Paragraph/Button/Element/index.vue +13 -8
- package/components/Paragraph/Card/Element/index.vue +1 -1
- package/components/Paragraph/Card/Layout/index.vue +9 -9
- package/components/Paragraph/Collapsible/Element/index.vue +2 -2
- package/components/Paragraph/Collapsible/Layout/index.vue +19 -18
- package/components/Paragraph/Contact/index.vue +17 -17
- package/components/Paragraph/Downloads/fragment.graphql +1 -1
- package/components/Paragraph/Downloads/index.vue +13 -13
- package/components/Paragraph/FAQ/Element/index.vue +4 -3
- package/components/Paragraph/FAQ/Layout/index.vue +42 -21
- package/components/Paragraph/FromLibrary/fragment.graphql +17 -0
- package/components/Paragraph/Gallery/index.vue +14 -14
- package/components/Paragraph/HeroImage/index.vue +26 -19
- package/components/Paragraph/IconText/Element/index.vue +4 -4
- package/components/Paragraph/IconText/Layout/index.vue +12 -12
- package/components/Paragraph/Introduction/index.vue +17 -17
- package/components/Paragraph/LinkLists/Element/fragment.graphql +1 -1
- package/components/Paragraph/LinkLists/Element/index.vue +5 -5
- package/components/Paragraph/LinkLists/Layout/index.vue +9 -9
- package/components/Paragraph/Listing/fragment.graphql +14 -0
- package/components/Paragraph/Listing/index.vue +91 -0
- package/components/Paragraph/Logo/Element/fragment.graphql +1 -1
- package/components/Paragraph/Logo/Element/index.vue +7 -4
- package/components/Paragraph/Media/index.vue +23 -21
- package/components/Paragraph/Partnerships/Element/fragment.graphql +1 -1
- package/components/Paragraph/Partnerships/Element/index.vue +10 -7
- package/components/Paragraph/Partnerships/Layout/index.vue +13 -13
- package/components/Paragraph/SingleTestimonial/index.vue +13 -13
- package/components/Paragraph/Table/index.vue +12 -12
- package/components/Paragraph/Team/Element/index.vue +16 -14
- package/components/Paragraph/Team/Layout/index.vue +9 -9
- package/components/Paragraph/Teasers/index.vue +14 -17
- package/components/Paragraph/Testimonial/Element/index.vue +19 -18
- package/components/Paragraph/Testimonial/Layout/index.vue +24 -18
- package/components/Paragraph/Text/fragment.graphql +1 -1
- package/components/Paragraph/Text/index.vue +8 -8
- package/components/Paragraph/TextMedia/index.vue +22 -22
- package/components/Paragraph/Timeline/Element/index.vue +19 -19
- package/components/Paragraph/Timeline/Layout/index.vue +51 -20
- package/components/Paragraph/TwoColumnText/Element/index.vue +13 -13
- package/components/Paragraph/TwoColumnText/Layout/index.vue +1 -1
- package/components/Paragraph/iFrame/index.vue +8 -8
- package/components/Search/Facets/FacetDropdownUI.vue +25 -31
- package/components/Search/Facets/FacetedSearchUI.vue +72 -48
- package/components/Search/Facets/fragment.graphql +20 -15
- package/components/Section/Footer/index.vue +55 -32
- package/components/Section/Header/Buttons.vue +4 -5
- package/components/Section/Header/DesktopMainMenu.vue +5 -4
- package/components/Section/Header/DesktopMainMenuDropdown.vue +5 -5
- package/components/Section/Header/LanguageDropdown.vue +2 -3
- package/components/Section/Header/Logo.vue +3 -3
- package/components/Section/Header/MainButtons.vue +4 -5
- package/components/Section/Header/MetaButtons.vue +5 -6
- package/components/Section/Header/MetaMenu.vue +1 -1
- package/components/Section/Header/MobileMainMenu.vue +1 -1
- package/components/Section/Header/MobileMainMenuItem.vue +2 -2
- package/components/Section/Header/index.vue +6 -13
- package/composables/__tests__/useImageSources.spec.ts +91 -0
- package/composables/breadcrumb.ts +12 -7
- package/composables/useAccordionScroll.ts +12 -4
- package/composables/useAllowedFragments.ts +14 -0
- package/composables/useAnchors.ts +48 -0
- package/composables/useChildTitleWeight.ts +1 -1
- package/composables/useCurrentLangcodeEnum.ts +1 -1
- package/composables/useCurrentNodeId.ts +8 -0
- package/composables/useDrupalUrl.ts +15 -0
- package/composables/useEnrichFacetLabels.ts +8 -6
- package/composables/useEntityRegistry.ts +3 -3
- package/composables/useGlobalConfig.ts +1 -2
- package/composables/useGraphqlCacheability.ts +12 -1
- package/composables/useHreflangLinks.ts +54 -0
- package/composables/useImageSources.ts +70 -28
- package/composables/useInitData.ts +1 -2
- package/composables/useIsHomepage.ts +1 -1
- package/composables/useLinkUrl.ts +30 -0
- package/composables/useMediaLightbox.ts +62 -50
- package/composables/useMenu.ts +1 -3
- package/composables/useMessageBus.ts +14 -7
- package/composables/useMobileMenuOverlayBus.ts +1 -1
- package/composables/useNavigationOffset.ts +49 -6
- package/composables/usePageCache.ts +33 -0
- package/composables/useSiteShell.ts +11 -0
- package/composables/useTheme.ts +17 -13
- package/config/__tests__/stubs/nuxt-multi-cache-config.ts +2 -0
- package/config/blokkli.ts +7 -13
- package/config/easyTexts.ts +1 -1
- package/config/graphqlMiddleware.ts +11 -4
- package/config/index.ts +5 -2
- package/config/languageNegotiation.ts +1 -1
- package/config/multiCache.ts +6 -1
- package/config/postcss-drupal/postcss.config.cjs +2 -2
- package/config/runtimeConfig.ts +7 -2
- package/config/svgIconSprite.ts +1 -2
- package/config/vuepal.ts +16 -18
- package/error.vue +1 -22
- package/layouts/default.vue +2 -6
- package/layouts/error.vue +7 -15
- package/modules/blokkliFragments.ts +76 -0
- package/modules/dynamicEntityComponentResolver.ts +47 -47
- package/modules/dynamicParagraphResolver.ts +43 -8
- package/modules/pageCache.ts +106 -0
- package/nuxt.config.ts +62 -33
- package/package.json +23 -22
- package/pages/[...slug]/index.vue +25 -4
- package/pages/index.vue +3 -3
- package/pages/node/[nid]/index.vue +2 -3
- package/patches/@blokkli+editor+2.0.0-alpha.13.patch +15 -1
- package/plugins/currentNode.ts +17 -0
- package/plugins/graphqlMiddleware.ts +10 -2
- package/plugins/initData.ts +15 -7
- package/plugins/registerNodeIcmsPageComponents.ts +6 -2
- package/plugins/siteShell.client.ts +94 -0
- package/queries/facetTermLabels.graphql +1 -5
- package/queries/fragment.linkUrl.graphql +12 -0
- package/queries/query.contentSearch.graphql +17 -2
- package/queries/query.initData.graphql +1 -1
- package/scripts/create-shade.ts +13 -4
- package/scripts/update-icms.ts +3 -1
- package/server/__tests__/graphqlMiddleware.serverOptions.spec.ts +2 -2
- package/server/__tests__/turnstile.spec.ts +119 -0
- package/server/api/icms/blokkli/last-changed.get.ts +24 -0
- package/server/api/icon/[...params].ts +3 -9
- package/server/graphqlMiddleware.serverOptions.ts +9 -8
- package/server/helpers/index.ts +0 -1
- package/server/middleware/turnstile.ts +51 -0
- package/server/plugins/frameAncestors.ts +20 -0
- package/server/plugins/pageCache.ts +59 -0
- package/server/utils/__tests__/accessDeniedHandover.spec.ts +49 -0
- package/server/utils/__tests__/backendRequestHeaders.spec.ts +115 -0
- package/server/utils/accessDeniedHandover.ts +48 -0
- package/server/utils/backendRequestHeaders.ts +51 -0
- package/server/utils/cacheability.ts +4 -4
- package/shared/__tests__/blokkliState.spec.ts +54 -0
- package/shared/__tests__/csp.spec.ts +53 -0
- package/shared/__tests__/drupalUrl.spec.ts +50 -0
- package/shared/blokkliState.ts +40 -0
- package/shared/csp.ts +47 -0
- package/shared/drupalUrl.ts +30 -0
- package/tailwind.config.ts +0 -5
- package/types/breadcrumb.ts +22 -0
- package/types/global.ts +15 -14
- package/types/langcode.ts +16 -0
- package/types/layerConfig.ts +84 -0
- package/utils/pageCache.ts +95 -0
- package/utils/turnstile.ts +35 -0
- package/composables/useWebformEnrichment.ts +0 -68
- package/server/__tests__/graphqlMiddleware.ts +0 -41
- package/server/multiCache.serverOptions.ts +0 -32
- package/vitest.config.mts +0 -28
|
@@ -3,21 +3,21 @@
|
|
|
3
3
|
<div
|
|
4
4
|
v-if="showLanguageLinks"
|
|
5
5
|
v-click-away="closeDropdown"
|
|
6
|
-
@keydown.esc="closeDropdown"
|
|
7
6
|
:class="themedClass('containerClass', 'relative py-2')"
|
|
7
|
+
@keydown.esc="closeDropdown"
|
|
8
8
|
>
|
|
9
9
|
<button
|
|
10
10
|
type="button"
|
|
11
11
|
aria-haspopup="menu"
|
|
12
12
|
:aria-expanded="isLangDropdownOpen"
|
|
13
13
|
aria-controls="language-dropdown"
|
|
14
|
-
@click="toggleDropdown"
|
|
15
14
|
:class="
|
|
16
15
|
themedClass(
|
|
17
16
|
'currentLanguageLinkClass',
|
|
18
17
|
'flex cursor-pointer flex-nowrap items-center gap-1',
|
|
19
18
|
)
|
|
20
19
|
"
|
|
20
|
+
@click="toggleDropdown"
|
|
21
21
|
>
|
|
22
22
|
{{ currentLanguage.toUpperCase() }}
|
|
23
23
|
<SpriteSymbol
|
|
@@ -104,7 +104,6 @@
|
|
|
104
104
|
</template>
|
|
105
105
|
|
|
106
106
|
<script lang="ts" setup>
|
|
107
|
-
|
|
108
107
|
const themedClass = useTheme('SectionHeaderLanguageDropdown')
|
|
109
108
|
const links = useLanguageLinks()
|
|
110
109
|
const currentLanguage = useCurrentLanguage()
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
<Media
|
|
9
9
|
v-if="companyLogo"
|
|
10
10
|
:media="companyLogo"
|
|
11
|
-
:
|
|
12
|
-
:
|
|
13
|
-
:
|
|
11
|
+
:media-class="themedClass('mediaClass', 'h-8 2xl:h-10')"
|
|
12
|
+
:figcaption-class="themedClass('figcaptionClass')"
|
|
13
|
+
:element-class="themedClass('elementClass', 'h-8 2xl:h-10 w-auto')"
|
|
14
14
|
/>
|
|
15
15
|
</NuxtLink>
|
|
16
16
|
</template>
|
|
@@ -11,17 +11,16 @@
|
|
|
11
11
|
:link="link?.link?.uri?.path"
|
|
12
12
|
:params="link?.params"
|
|
13
13
|
:target-blank="link?.targetBlank"
|
|
14
|
-
:
|
|
15
|
-
:
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
:button-class="themedClass('buttonClass', 'xs:w-fit')"
|
|
15
|
+
:label-class="themedClass('labelClass')"
|
|
16
|
+
color-scheme="none"
|
|
17
|
+
button-style="focus"
|
|
18
18
|
@click="close()"
|
|
19
19
|
/>
|
|
20
20
|
</div>
|
|
21
21
|
</template>
|
|
22
22
|
|
|
23
23
|
<script lang="ts" setup>
|
|
24
|
-
|
|
25
24
|
const { contactButtons } = await useGlobalConfig()
|
|
26
25
|
const themedClass = useTheme('SectionHeaderMainButtons')
|
|
27
26
|
const { close } = useMobileMenuOverlayBus()
|
|
@@ -10,18 +10,17 @@
|
|
|
10
10
|
:title="link?.title"
|
|
11
11
|
:link="link?.link?.uri?.path"
|
|
12
12
|
:params="link?.params"
|
|
13
|
-
:
|
|
14
|
-
:
|
|
15
|
-
:
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
:target-blank="link?.targetBlank"
|
|
14
|
+
:button-class="themedClass('buttonClass', 'xs:w-fit')"
|
|
15
|
+
:label-class="themedClass('labelClass')"
|
|
16
|
+
color-scheme="none"
|
|
17
|
+
button-style="focus"
|
|
18
18
|
@click="close()"
|
|
19
19
|
/>
|
|
20
20
|
</div>
|
|
21
21
|
</template>
|
|
22
22
|
|
|
23
23
|
<script lang="ts" setup>
|
|
24
|
-
|
|
25
24
|
const { metaLinks } = await useGlobalConfig()
|
|
26
25
|
const themedClass = useTheme('SectionHeaderMetaButtons')
|
|
27
26
|
const { close } = useMobileMenuOverlayBus()
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
"
|
|
34
34
|
:target="menuItem?.target"
|
|
35
35
|
:rel="menuItem?.rel"
|
|
36
|
-
@click="close()"
|
|
37
36
|
:exact-active-class="themedClass('linkActiveClass', 'text-primary')"
|
|
38
37
|
:class="
|
|
39
38
|
cn(
|
|
@@ -44,6 +43,7 @@
|
|
|
44
43
|
menuItem?.additionalClass,
|
|
45
44
|
)
|
|
46
45
|
"
|
|
46
|
+
@click="close()"
|
|
47
47
|
>
|
|
48
48
|
{{ menuItem.label }}
|
|
49
49
|
</NuxtLink>
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
:aria-controls="submenuId"
|
|
26
26
|
role="button"
|
|
27
27
|
tabindex="0"
|
|
28
|
-
@click="isOpen = !isOpen"
|
|
29
28
|
:class="[
|
|
30
29
|
themedClass(
|
|
31
30
|
'submenuToggleClass',
|
|
@@ -33,6 +32,7 @@
|
|
|
33
32
|
),
|
|
34
33
|
{ 'motion-safe:rotate-180': isOpen },
|
|
35
34
|
]"
|
|
35
|
+
@click="isOpen = !isOpen"
|
|
36
36
|
/>
|
|
37
37
|
</div>
|
|
38
38
|
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
v-for="item in item.children"
|
|
85
85
|
:key="`submenu_${item.id}`"
|
|
86
86
|
:item="item"
|
|
87
|
-
:
|
|
87
|
+
:menu-item-active="menuItemActive"
|
|
88
88
|
/>
|
|
89
89
|
</ul>
|
|
90
90
|
</Transition>
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
)
|
|
26
26
|
"
|
|
27
27
|
>
|
|
28
|
-
<div
|
|
28
|
+
<div data-navigation-meta :class="themedClass('metaRefContainerClass')">
|
|
29
29
|
<DesignSystemAtomsGridRow
|
|
30
30
|
:class="
|
|
31
31
|
themedClass(
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
|
|
53
53
|
<!-- Main Container -->
|
|
54
54
|
<DesignSystemAtomsContainer
|
|
55
|
+
data-navigation-main
|
|
55
56
|
:spacing="{ top: 'none', bottom: 'none' }"
|
|
56
57
|
:class="
|
|
57
58
|
themedClass('mainContainerClass', 'border-b border-gray-100 bg-white')
|
|
@@ -90,8 +91,6 @@
|
|
|
90
91
|
<!-- Mobile Menu Overlay -->
|
|
91
92
|
<Teleport to="body">
|
|
92
93
|
<Transition
|
|
93
|
-
@after-enter="clickAwayArmed = true"
|
|
94
|
-
@after-leave="clickAwayArmed = false"
|
|
95
94
|
:enter-active-class="
|
|
96
95
|
themedClass(
|
|
97
96
|
'transitionEnterActiveClass',
|
|
@@ -128,11 +127,13 @@
|
|
|
128
127
|
'opacity-0 md:opacity-100 md:translate-x-full',
|
|
129
128
|
)
|
|
130
129
|
"
|
|
130
|
+
@after-enter="clickAwayArmed = true"
|
|
131
|
+
@after-leave="clickAwayArmed = false"
|
|
131
132
|
>
|
|
132
133
|
<DesignSystemAtomsContainer
|
|
133
134
|
v-show="isMobileMenuOpen"
|
|
134
|
-
:spacing="{ top: 'none', bottom: 'none' }"
|
|
135
135
|
v-click-away="clickAwayArmed && close"
|
|
136
|
+
:spacing="{ top: 'none', bottom: 'none' }"
|
|
136
137
|
:class="
|
|
137
138
|
themedClass(
|
|
138
139
|
'mobileMenuContainerClass',
|
|
@@ -174,7 +175,6 @@
|
|
|
174
175
|
</template>
|
|
175
176
|
|
|
176
177
|
<script lang="ts" setup>
|
|
177
|
-
|
|
178
178
|
const { metaLinks, contactButtons } = await useGlobalConfig()
|
|
179
179
|
const { menuItems: metaMenuItems } = await useMenu('metaMenu')
|
|
180
180
|
const languageLinks = useLanguageLinks()
|
|
@@ -185,8 +185,7 @@ const viewport = useViewport()
|
|
|
185
185
|
const isMobileMenuOpen = ref(false)
|
|
186
186
|
const clickAwayArmed = ref(false)
|
|
187
187
|
|
|
188
|
-
const
|
|
189
|
-
const metaHeight = ref(0)
|
|
188
|
+
const { metaHeight } = useNavigationOffset()
|
|
190
189
|
|
|
191
190
|
onOpen(() => {
|
|
192
191
|
isMobileMenuOpen.value = true
|
|
@@ -203,10 +202,4 @@ watch(viewport.breakpoint, (newValue: string) => {
|
|
|
203
202
|
isMobileMenuOpen.value = false
|
|
204
203
|
}
|
|
205
204
|
})
|
|
206
|
-
|
|
207
|
-
onMounted(() => {
|
|
208
|
-
requestAnimationFrame(() => {
|
|
209
|
-
metaHeight.value = metaContainer.value?.offsetHeight ?? 0
|
|
210
|
-
})
|
|
211
|
-
})
|
|
212
205
|
</script>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
|
2
|
+
import { computed, toRef } from 'vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The image component renders width/height attributes from the sources the
|
|
6
|
+
* composable builds, so the browser reserves the box before the image has
|
|
7
|
+
* loaded (no layout shift, stable screenshots). Art-directed images carry
|
|
8
|
+
* one pair per <source>; images without an aspect ratio use the original
|
|
9
|
+
* file's dimensions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Stand-in for @nuxt/image: one candidate per requested width, screens as
|
|
13
|
+
// configured in nuxt.config.ts of the layer.
|
|
14
|
+
const screens = { xs: 320, sm: 640, md: 768, lg: 1024, xl: 1280, xxl: 1536 }
|
|
15
|
+
const img = Object.assign(
|
|
16
|
+
(src: string, modifiers: Record<string, unknown>) =>
|
|
17
|
+
`${src}?w=${modifiers.width}&h=${modifiers.height}`,
|
|
18
|
+
{
|
|
19
|
+
options: { screens },
|
|
20
|
+
getSizes: (src: string) => ({ sizes: '100vw', srcset: `${src} 1x`, src }),
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
type UseImageSources = typeof import('../useImageSources').useImageSources
|
|
25
|
+
let useImageSources: UseImageSources
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
// The composable relies on Nuxt auto-imports; the unit suite is Nuxt-free.
|
|
29
|
+
vi.stubGlobal('useImage', () => img)
|
|
30
|
+
vi.stubGlobal('useRuntimeConfig', () => ({
|
|
31
|
+
public: { imageBaseUrl: 'https://backend.example' },
|
|
32
|
+
}))
|
|
33
|
+
vi.stubGlobal('toRef', toRef)
|
|
34
|
+
vi.stubGlobal('computed', computed)
|
|
35
|
+
useImageSources = (await import('../useImageSources')).useImageSources
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const media = (dimensions: { width: number; height: number } | null) =>
|
|
39
|
+
({
|
|
40
|
+
sourceUrl: { path: '/sites/default/files/image.jpg' },
|
|
41
|
+
dimensions,
|
|
42
|
+
}) as unknown as Parameters<UseImageSources>[0]['media']
|
|
43
|
+
|
|
44
|
+
describe('useImageSources dimensions', () => {
|
|
45
|
+
it('reserves the largest candidate of a single aspect ratio', () => {
|
|
46
|
+
const { imageSources } = useImageSources({
|
|
47
|
+
media: media({ width: 4000, height: 3000 }),
|
|
48
|
+
imageAspectRatios: '16/9',
|
|
49
|
+
imageSizes: 'xs:100vw md:50vw lg:600px',
|
|
50
|
+
})
|
|
51
|
+
expect(imageSources.value).toHaveLength(1)
|
|
52
|
+
expect(imageSources.value[0]).toMatchObject({ width: 600, height: 338 })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('reserves one box per art-directed source', () => {
|
|
56
|
+
const { imageSources } = useImageSources({
|
|
57
|
+
media: media({ width: 4000, height: 3000 }),
|
|
58
|
+
imageAspectRatios: 'xs:1/1 md:16/9',
|
|
59
|
+
imageSizes: 'xs:100vw md:50vw lg:600px',
|
|
60
|
+
})
|
|
61
|
+
expect(imageSources.value).toHaveLength(2)
|
|
62
|
+
// Square up to the md breakpoint: 50vw of 768px.
|
|
63
|
+
expect(imageSources.value[0]).toMatchObject({
|
|
64
|
+
media: '(max-width: 768px)',
|
|
65
|
+
width: 384,
|
|
66
|
+
height: 384,
|
|
67
|
+
})
|
|
68
|
+
// Landscape from md on; the last source has no media query and is the
|
|
69
|
+
// fallback the <img> uses.
|
|
70
|
+
expect(imageSources.value[1].media).toBeUndefined()
|
|
71
|
+
expect(imageSources.value[1]).toMatchObject({ width: 600, height: 338 })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('uses the original dimensions when the natural ratio is kept', () => {
|
|
75
|
+
const { imageSources } = useImageSources({
|
|
76
|
+
media: media({ width: 4000, height: 3000 }),
|
|
77
|
+
imageAspectRatios: '',
|
|
78
|
+
})
|
|
79
|
+
expect(imageSources.value).toHaveLength(1)
|
|
80
|
+
expect(imageSources.value[0]).toMatchObject({ width: 4000, height: 3000 })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('leaves the attributes out when the file dimensions are unknown', () => {
|
|
84
|
+
const { imageSources } = useImageSources({
|
|
85
|
+
media: media(null),
|
|
86
|
+
imageAspectRatios: '',
|
|
87
|
+
})
|
|
88
|
+
expect(imageSources.value[0].width).toBeUndefined()
|
|
89
|
+
expect(imageSources.value[0].height).toBeUndefined()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
BreadcrumbLink,
|
|
3
|
+
RouteQueryWithBreadcrumb,
|
|
4
|
+
} from '@iqual/nuxt-icms/types/breadcrumb'
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Extract the breadcrumb from a route query.
|
|
@@ -6,10 +9,12 @@ import type { BreadcrumbFragment, RouteQuery } from '#graphql-operations'
|
|
|
6
9
|
* @see ~/pages/[language]/[...slug]/query.route.graphql
|
|
7
10
|
*/
|
|
8
11
|
function getBreadcrumbFromRouteQuery(
|
|
9
|
-
routeQuery:
|
|
10
|
-
):
|
|
12
|
+
routeQuery: RouteQueryWithBreadcrumb | undefined | null,
|
|
13
|
+
): BreadcrumbLink[] {
|
|
11
14
|
if (routeQuery && routeQuery.route && 'breadcrumb' in routeQuery.route) {
|
|
12
|
-
return routeQuery.route.breadcrumb || []
|
|
15
|
+
return (routeQuery.route.breadcrumb || []).filter(
|
|
16
|
+
(link): link is BreadcrumbLink => !!link,
|
|
17
|
+
)
|
|
13
18
|
}
|
|
14
19
|
return []
|
|
15
20
|
}
|
|
@@ -18,14 +23,14 @@ function getBreadcrumbFromRouteQuery(
|
|
|
18
23
|
* Returns the current breadcrumb links.
|
|
19
24
|
*/
|
|
20
25
|
export function useBreadcrumbLinks() {
|
|
21
|
-
return useState<
|
|
26
|
+
return useState<BreadcrumbLink[]>('breadcrumbLinks', () => [])
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* Set the breadcrumb links for the current page.
|
|
26
31
|
*/
|
|
27
32
|
export function setBreadcrumbLinksFromRoute(
|
|
28
|
-
routeQuery:
|
|
33
|
+
routeQuery: RouteQueryWithBreadcrumb | undefined | null,
|
|
29
34
|
) {
|
|
30
35
|
const links = useBreadcrumbLinks()
|
|
31
36
|
links.value = getBreadcrumbFromRouteQuery(routeQuery)
|
|
@@ -35,7 +40,7 @@ export function setBreadcrumbLinksFromRoute(
|
|
|
35
40
|
* Set the breadcrumb links directly.
|
|
36
41
|
*/
|
|
37
42
|
export function setBreadcrumbLinks(
|
|
38
|
-
newLinks?:
|
|
43
|
+
newLinks?: BreadcrumbLink[] | null | undefined,
|
|
39
44
|
) {
|
|
40
45
|
const links = useBreadcrumbLinks()
|
|
41
46
|
links.value = newLinks || []
|
|
@@ -3,21 +3,29 @@ import { useNavigationOffset } from '@iqual/nuxt-icms/composables/useNavigationO
|
|
|
3
3
|
export function useAccordionScroll() {
|
|
4
4
|
const { navigationOffset } = useNavigationOffset()
|
|
5
5
|
|
|
6
|
-
const scrollToTrigger = (
|
|
6
|
+
const scrollToTrigger = (
|
|
7
|
+
accordionEl: HTMLElement | undefined,
|
|
8
|
+
index: number,
|
|
9
|
+
): void => {
|
|
7
10
|
if (!accordionEl) return
|
|
8
11
|
|
|
9
|
-
const triggers = accordionEl.querySelectorAll<HTMLElement>(
|
|
12
|
+
const triggers = accordionEl.querySelectorAll<HTMLElement>(
|
|
13
|
+
'[data-slot="trigger"]',
|
|
14
|
+
)
|
|
10
15
|
const trigger = triggers[index]
|
|
11
16
|
|
|
12
17
|
if (!trigger) return
|
|
13
18
|
|
|
14
19
|
nextTick(() => {
|
|
15
20
|
setTimeout(() => {
|
|
16
|
-
const top =
|
|
21
|
+
const top =
|
|
22
|
+
window.scrollY +
|
|
23
|
+
trigger.getBoundingClientRect().top -
|
|
24
|
+
navigationOffset.value
|
|
17
25
|
window.scrollTo({ top, behavior: 'smooth' })
|
|
18
26
|
}, 400)
|
|
19
27
|
})
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
return { scrollToTrigger }
|
|
23
|
-
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BlokkliFragmentName } from '#blokkli-build/definitions'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The fragments editors may place in a page's paragraphs field.
|
|
5
|
+
*
|
|
6
|
+
* Passed as `allowed-fragments` to the page-level `<BlokkliField>`. The list
|
|
7
|
+
* holds every fragment blökkli discovered in the layer stack (collected at
|
|
8
|
+
* build time by the `blokkliFragments` module); it is empty when a project
|
|
9
|
+
* sets `icms.blokkliFragments: false`, in which case the editor's fragments
|
|
10
|
+
* feature is disabled altogether.
|
|
11
|
+
*/
|
|
12
|
+
export default function useAllowedFragments(): BlokkliFragmentName[] {
|
|
13
|
+
return (useAppConfig().icms?.allowedFragments ?? []) as BlokkliFragmentName[]
|
|
14
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useNavigationOffset } from '@iqual/nuxt-icms/composables/useNavigationOffset'
|
|
2
|
+
|
|
3
|
+
export type Anchor = { id: string; label: string }
|
|
4
|
+
|
|
5
|
+
const anchorNavigationHeight = 52
|
|
6
|
+
|
|
7
|
+
// Strips everything that is not safe in a URL fragment, e.g.
|
|
8
|
+
// "Öffnungszeiten & Preise" -> "Offnungszeiten-Preise".
|
|
9
|
+
const fragment = (text: string): string =>
|
|
10
|
+
text
|
|
11
|
+
.normalize('NFKD')
|
|
12
|
+
.replace(/[̀-ͯ]/g, '')
|
|
13
|
+
.trim()
|
|
14
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
15
|
+
.replace(/^-+|-+$/g, '')
|
|
16
|
+
|
|
17
|
+
// Generates an id from a label. Lowercase on top, so a link typed by hand
|
|
18
|
+
// ("#kontakt") hits the anchor no matter how the label is capitalised.
|
|
19
|
+
export function anchorId(text: string): string {
|
|
20
|
+
return fragment(text.toLowerCase())
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The id an anchor block scrolls to: the anchor field when the editor filled
|
|
24
|
+
// it in, kept as written apart from invalid characters, otherwise generated
|
|
25
|
+
// from the label.
|
|
26
|
+
export function resolveAnchorId(
|
|
27
|
+
label?: string | null,
|
|
28
|
+
anchor?: string | null,
|
|
29
|
+
): string {
|
|
30
|
+
const explicit = anchor?.trim()
|
|
31
|
+
|
|
32
|
+
return explicit ? fragment(explicit) : anchorId(label ?? '')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Both measured from the top of the viewport:
|
|
36
|
+
// headerOffset sits below the header, where the anchor navigation sticks.
|
|
37
|
+
// anchorOffset sits below the anchor navigation, where a section scrolls to.
|
|
38
|
+
export function useAnchorOffset(): {
|
|
39
|
+
headerOffset: ComputedRef<number>
|
|
40
|
+
anchorOffset: ComputedRef<number>
|
|
41
|
+
} {
|
|
42
|
+
const { headerHeight } = useNavigationOffset()
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
headerOffset: headerHeight,
|
|
46
|
+
anchorOffset: computed(() => headerHeight.value + anchorNavigationHeight),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The node ID of the node the current Drupal route resolves to.
|
|
3
|
+
*
|
|
4
|
+
* Set by the current-node plugin via the vuepal:drupal-route hook and
|
|
5
|
+
* null on routes that don't resolve to a node.
|
|
6
|
+
*/
|
|
7
|
+
export const useCurrentNodeId = () =>
|
|
8
|
+
useState<string | null>('currentNodeId', () => null)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { drupalOrigin, toDrupalUrl } from '../shared/drupalUrl'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a function that turns a Drupal path into a browser-facing URL.
|
|
5
|
+
*
|
|
6
|
+
* The Drupal backend runs on the "backend." subdomain of the domain the
|
|
7
|
+
* frontend was requested on (see docs/routing.md), so the origin is derived
|
|
8
|
+
* per request and is right on every domain a site serves. Use it for every
|
|
9
|
+
* link or iframe pointing at Drupal (login, admin, entity forms) — it is the
|
|
10
|
+
* only place the backend URL is read from.
|
|
11
|
+
*/
|
|
12
|
+
export default function useDrupalUrl() {
|
|
13
|
+
const origin = drupalOrigin(useRequestURL())
|
|
14
|
+
return (path: string): string => toDrupalUrl(path, origin)
|
|
15
|
+
}
|
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export async function useEnrichFacetLabels(
|
|
12
12
|
facets: any[],
|
|
13
|
-
fieldsToEnrich: string[] = ['topics']
|
|
13
|
+
fieldsToEnrich: string[] = ['topics'],
|
|
14
14
|
): Promise<any[]> {
|
|
15
15
|
if (!facets || facets.length === 0) {
|
|
16
16
|
return facets
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// Find facets that need enrichment
|
|
20
|
-
const facetsToEnrich = facets.filter(f => fieldsToEnrich.includes(f.field))
|
|
20
|
+
const facetsToEnrich = facets.filter((f) => fieldsToEnrich.includes(f.field))
|
|
21
21
|
|
|
22
22
|
if (facetsToEnrich.length === 0) {
|
|
23
23
|
return facets
|
|
@@ -37,7 +37,9 @@ export async function useEnrichFacetLabels(
|
|
|
37
37
|
|
|
38
38
|
// Fetch term labels via GraphQL
|
|
39
39
|
try {
|
|
40
|
-
const response: any = await useGraphqlQuery('facetTermLabels' as any, {
|
|
40
|
+
const response: any = await useGraphqlQuery('facetTermLabels' as any, {
|
|
41
|
+
ids: termIds,
|
|
42
|
+
})
|
|
41
43
|
const terms = response?.data?.terms?.items || []
|
|
42
44
|
|
|
43
45
|
if (terms.length === 0) {
|
|
@@ -53,7 +55,7 @@ export async function useEnrichFacetLabels(
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
// Update facet filter labels
|
|
56
|
-
return facets.map(facet => {
|
|
58
|
+
return facets.map((facet) => {
|
|
57
59
|
if (fieldsToEnrich.includes(facet.field) && facet.filter?.id) {
|
|
58
60
|
const label = termLabelMap.get(facet.filter.id)
|
|
59
61
|
if (label) {
|
|
@@ -61,8 +63,8 @@ export async function useEnrichFacetLabels(
|
|
|
61
63
|
...facet,
|
|
62
64
|
filter: {
|
|
63
65
|
...facet.filter,
|
|
64
|
-
label
|
|
65
|
-
}
|
|
66
|
+
label,
|
|
67
|
+
},
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const nodeComponentRegistry = new Map<string, Component>()
|
|
2
2
|
|
|
3
|
-
export const useEntityRegistry
|
|
3
|
+
export const useEntityRegistry = () => {
|
|
4
4
|
const register = (type: string, component: Component) => {
|
|
5
5
|
nodeComponentRegistry.set(type, component)
|
|
6
6
|
}
|
|
@@ -16,6 +16,6 @@ export const useEntityRegistry = () => {
|
|
|
16
16
|
return {
|
|
17
17
|
register,
|
|
18
18
|
get,
|
|
19
|
-
getAll
|
|
19
|
+
getAll,
|
|
20
20
|
}
|
|
21
|
-
}
|
|
21
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
export async function useGlobalConfig() {
|
|
3
2
|
const initData = useInitData()
|
|
4
3
|
|
|
@@ -44,7 +43,7 @@ export async function useGlobalConfig() {
|
|
|
44
43
|
|
|
45
44
|
return {
|
|
46
45
|
companyLogo,
|
|
47
|
-
companyLogoAlt,
|
|
46
|
+
companyLogoAlt,
|
|
48
47
|
companyName,
|
|
49
48
|
companyDescription,
|
|
50
49
|
address,
|
|
@@ -6,8 +6,9 @@ export function useGraphqlCacheability(
|
|
|
6
6
|
response?: GraphqlResponseTyped,
|
|
7
7
|
) {
|
|
8
8
|
if (import.meta.server) {
|
|
9
|
+
const cacheability = response?.extensions.cacheability
|
|
10
|
+
|
|
9
11
|
useCDNHeaders((cdn) => {
|
|
10
|
-
const cacheability = response?.extensions.cacheability
|
|
11
12
|
if (!cacheability?.isCacheable) {
|
|
12
13
|
cdn.private()
|
|
13
14
|
return
|
|
@@ -18,5 +19,15 @@ export function useGraphqlCacheability(
|
|
|
18
19
|
.setNumeric('sharedMaxAge', cacheability.sharedMaxAge)
|
|
19
20
|
.addTags(cacheability.tagsCdn)
|
|
20
21
|
}, event)
|
|
22
|
+
|
|
23
|
+
// The route cache is purged by Drupal with the encoded tags.
|
|
24
|
+
useRouteCache((route) => {
|
|
25
|
+
if (!cacheability?.isCacheable) {
|
|
26
|
+
route.setUncacheable()
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
route.setMaxAge(cacheability.sharedMaxAge).addTags(cacheability.tagsNuxt)
|
|
31
|
+
}, event)
|
|
21
32
|
}
|
|
22
33
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface HreflangAlternate {
|
|
2
|
+
code: string
|
|
3
|
+
path: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type HreflangLink = {
|
|
7
|
+
rel: 'alternate'
|
|
8
|
+
hreflang: string
|
|
9
|
+
href: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds the `<link rel="alternate" hreflang>` tags of a single page.
|
|
14
|
+
*
|
|
15
|
+
* Without them search engines rank the language versions of a page against
|
|
16
|
+
* each other as near-duplicates. No `x-default`: it would name the fallback
|
|
17
|
+
* of the `/` redirect, which lives in the router config (`routing.languages`)
|
|
18
|
+
* and is not visible here.
|
|
19
|
+
*/
|
|
20
|
+
export function buildHreflangLinks(
|
|
21
|
+
alternates: HreflangAlternate[],
|
|
22
|
+
origin: string,
|
|
23
|
+
): HreflangLink[] {
|
|
24
|
+
if (!origin || alternates.length < 2) {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return alternates.map((alternate) => ({
|
|
29
|
+
rel: 'alternate',
|
|
30
|
+
hreflang: alternate.code,
|
|
31
|
+
href: origin + alternate.path,
|
|
32
|
+
}))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The hreflang link tags for the current route, for `useHead()`.
|
|
37
|
+
*
|
|
38
|
+
* Translations come from the route query's `languageSwitchLinks`, the same
|
|
39
|
+
* source as the language switcher.
|
|
40
|
+
*/
|
|
41
|
+
export function useHreflangLinks() {
|
|
42
|
+
const languageLinks = useLanguageLinks()
|
|
43
|
+
const requestUrl = useRequestURL({ xForwardedHost: true })
|
|
44
|
+
|
|
45
|
+
return computed<HreflangLink[]>(() => {
|
|
46
|
+
const alternates = (languageLinks.value ?? []).flatMap((link) =>
|
|
47
|
+
link.enabled && typeof link.to === 'string'
|
|
48
|
+
? [{ code: link.code, path: link.to }]
|
|
49
|
+
: [],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return buildHreflangLinks(alternates, requestUrl.origin)
|
|
53
|
+
})
|
|
54
|
+
}
|