@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.
Files changed (260) hide show
  1. package/README.md +86 -0
  2. package/__tests__/anchors.spec.ts +43 -0
  3. package/__tests__/contract/blokkliComponents.spec.ts +59 -0
  4. package/__tests__/contract/componentFragments.spec.ts +24 -0
  5. package/__tests__/contract/languages.spec.ts +63 -0
  6. package/__tests__/contract/project.ts +144 -0
  7. package/__tests__/hreflang.spec.ts +39 -0
  8. package/{tests → __tests__}/pages.spec.ts +14 -5
  9. package/app.config.ts +15 -0
  10. package/app.vue +3 -21
  11. package/assets/css/backend.css +56 -0
  12. package/assets/css/base/base.css +2 -2
  13. package/assets/css/base/typography.css +2 -2
  14. package/assets/css/ckeditor.css +3 -4
  15. package/assets/css/components/decorations.css +1 -1
  16. package/assets/css/components/editor.css +1 -1
  17. package/assets/css/components/grid-container.css +19 -19
  18. package/assets/css/fe-fixes.css +6 -12
  19. package/assets/css/theme/colors/color-primary.css +33 -11
  20. package/assets/css/theme/colors/color-secondary.css +33 -11
  21. package/assets/css/utilities/utilities.css +1 -1
  22. package/assets/symbols/anchor.svg +3 -0
  23. package/blokkli.editAdapter.ts +56 -0
  24. package/bundles.ts +6 -6
  25. package/cache/__tests__/pageCache.spec.ts +540 -0
  26. package/cache/cacheTagRegistry.ts +124 -0
  27. package/cache/enabledForRequest.ts +21 -0
  28. package/cache/multiCache.serverOptions.ts +58 -0
  29. package/cache/pageCache.ts +360 -0
  30. package/components/Blokkli/Anchor/icon.svg +3 -0
  31. package/components/Blokkli/Anchor/index.vue +13 -0
  32. package/components/Blokkli/AnchorNavigation/icon.svg +8 -0
  33. package/components/Blokkli/AnchorNavigation/index.vue +9 -0
  34. package/components/Blokkli/Card/Element/index.vue +0 -1
  35. package/components/Blokkli/Collapsible/Element/index.vue +5 -0
  36. package/components/Blokkli/FAQ/Element/index.vue +0 -1
  37. package/components/Blokkli/Fragment/Separator/icon.svg +4 -0
  38. package/components/Blokkli/Fragment/Separator/index.vue +87 -0
  39. package/components/Blokkli/Gallery/index.vue +1 -3
  40. package/components/Blokkli/IconText/Element/index.vue +0 -1
  41. package/components/Blokkli/Introduction/index.vue +0 -1
  42. package/components/Blokkli/Listing/icon.svg +9 -0
  43. package/components/Blokkli/Listing/index.vue +24 -0
  44. package/components/Blokkli/Logo/Element/index.vue +0 -1
  45. package/components/Blokkli/Table/index.vue +0 -1
  46. package/components/Blokkli/Team/Element/index.vue +0 -1
  47. package/components/Blokkli/Team/Layout/index.vue +0 -1
  48. package/components/Blokkli/Testimonial/Element/index.vue +0 -1
  49. package/components/Blokkli/Text/index.vue +0 -1
  50. package/components/DesignSystem/Atoms/Button.vue +3 -0
  51. package/components/DesignSystem/Atoms/GridRow.vue +3 -1
  52. package/components/DesignSystem/Molecules/ButtonGroup.vue +5 -4
  53. package/components/DesignSystem/Molecules/IconText.vue +6 -6
  54. package/components/DesignSystem/Molecules/MediaLightbox.vue +8 -8
  55. package/components/DesignSystem/Molecules/MediaSlider.vue +9 -11
  56. package/components/DesignSystem/Molecules/Partnership.vue +7 -4
  57. package/components/DesignSystem/Molecules/Table.vue +35 -19
  58. package/components/DesignSystem/Molecules/TitleComposition.vue +46 -15
  59. package/components/DesignSystem/Molecules/iFrame.vue +2 -2
  60. package/components/DesignSystem/Organisms/CallToAction.vue +8 -7
  61. package/components/DesignSystem/Organisms/CallToActionFeature.vue +8 -7
  62. package/components/DesignSystem/Organisms/CallToActionFullwidth.vue +9 -8
  63. package/components/DesignSystem/Organisms/ContentBlock.vue +26 -16
  64. package/components/DesignSystem/Organisms/FacetedListing.vue +38 -17
  65. package/components/DesignSystem/Organisms/FactCard.vue +6 -7
  66. package/components/DesignSystem/Organisms/FeatureCard.vue +14 -14
  67. package/components/DesignSystem/Organisms/FlipTeaser.vue +19 -19
  68. package/components/DesignSystem/Organisms/HeaderHero.vue +11 -11
  69. package/components/DesignSystem/Organisms/HeaderTextImage.vue +10 -10
  70. package/components/DesignSystem/Organisms/LinkList.vue +20 -12
  71. package/components/DesignSystem/Organisms/LogoElement.vue +7 -4
  72. package/components/DesignSystem/Organisms/Map.vue +1 -1
  73. package/components/DesignSystem/Organisms/SearchResults.vue +15 -6
  74. package/components/DesignSystem/Organisms/SpotlightCard.vue +18 -18
  75. package/components/DesignSystem/Organisms/TeamElement.vue +19 -18
  76. package/components/DesignSystem/Organisms/TeaserCard.vue +16 -17
  77. package/components/DesignSystem/Organisms/TeaserSimple.vue +4 -31
  78. package/components/DesignSystem/Organisms/Testimonial.vue +13 -13
  79. package/components/DesignSystem/Organisms/TextCard.vue +10 -10
  80. package/components/DesignSystem/Organisms/TimelineElement.vue +20 -19
  81. package/components/DesignSystem/Organisms/TwoColumnTextElement.vue +5 -5
  82. package/components/DesignSystem/README.md +59 -40
  83. package/components/Drupal/Breadcrumb/index.vue +25 -12
  84. package/components/Drupal/Messages/index.vue +9 -4
  85. package/components/Drupal/Webform/index.vue +271 -51
  86. package/components/Drupal/Webform/mutation.graphql +17 -1
  87. package/components/Form/HiddenInput.vue +1 -4
  88. package/components/Form/SelectInput.vue +1 -1
  89. package/components/Form/SubmitButton.vue +2 -2
  90. package/components/Form/TextAreaInput.vue +3 -3
  91. package/components/Form/TextEmailInput.vue +1 -1
  92. package/components/Form/TextInput.vue +1 -1
  93. package/components/Form/TextPhoneInput.vue +1 -1
  94. package/components/Form/WebformMarkup.vue +1 -1
  95. package/components/Form/composables/useFormDefaultValue.ts +9 -13
  96. package/components/Form/composables/useFormElement.ts +5 -3
  97. package/components/Form/utils/types.ts +3 -3
  98. package/components/Media/Image/fragment.graphql +4 -0
  99. package/components/Media/Image/index.vue +27 -17
  100. package/components/Media/RemoteVideo/index.vue +3 -3
  101. package/components/Media/Video/index.vue +1 -1
  102. package/components/Node/Icms/Page/SearchResult/fragment.graphql +1 -1
  103. package/components/Node/Icms/Page/SearchResult/index.vue +2 -2
  104. package/components/Node/Icms/Page/Teaser/index.vue +1 -1
  105. package/components/Node/Icms/Page/index.vue +8 -2
  106. package/components/Paragraph/Anchor/fragment.graphql +4 -0
  107. package/components/Paragraph/Anchor/index.vue +48 -0
  108. package/components/Paragraph/AnchorNavigation/fragment.graphql +3 -0
  109. package/components/Paragraph/AnchorNavigation/index.vue +154 -0
  110. package/components/Paragraph/BlokkliFragment/fragment.graphql +5 -0
  111. package/components/Paragraph/Button/Element/fragment.graphql +1 -1
  112. package/components/Paragraph/Button/Element/index.vue +13 -8
  113. package/components/Paragraph/Card/Element/index.vue +1 -1
  114. package/components/Paragraph/Card/Layout/index.vue +9 -9
  115. package/components/Paragraph/Collapsible/Element/index.vue +2 -2
  116. package/components/Paragraph/Collapsible/Layout/index.vue +19 -18
  117. package/components/Paragraph/Contact/index.vue +17 -17
  118. package/components/Paragraph/Downloads/fragment.graphql +1 -1
  119. package/components/Paragraph/Downloads/index.vue +13 -13
  120. package/components/Paragraph/FAQ/Element/index.vue +4 -3
  121. package/components/Paragraph/FAQ/Layout/index.vue +42 -21
  122. package/components/Paragraph/FromLibrary/fragment.graphql +17 -0
  123. package/components/Paragraph/Gallery/index.vue +14 -14
  124. package/components/Paragraph/HeroImage/index.vue +26 -19
  125. package/components/Paragraph/IconText/Element/index.vue +4 -4
  126. package/components/Paragraph/IconText/Layout/index.vue +12 -12
  127. package/components/Paragraph/Introduction/index.vue +17 -17
  128. package/components/Paragraph/LinkLists/Element/fragment.graphql +1 -1
  129. package/components/Paragraph/LinkLists/Element/index.vue +5 -5
  130. package/components/Paragraph/LinkLists/Layout/index.vue +9 -9
  131. package/components/Paragraph/Listing/fragment.graphql +14 -0
  132. package/components/Paragraph/Listing/index.vue +91 -0
  133. package/components/Paragraph/Logo/Element/fragment.graphql +1 -1
  134. package/components/Paragraph/Logo/Element/index.vue +7 -4
  135. package/components/Paragraph/Media/index.vue +23 -21
  136. package/components/Paragraph/Partnerships/Element/fragment.graphql +1 -1
  137. package/components/Paragraph/Partnerships/Element/index.vue +10 -7
  138. package/components/Paragraph/Partnerships/Layout/index.vue +13 -13
  139. package/components/Paragraph/SingleTestimonial/index.vue +13 -13
  140. package/components/Paragraph/Table/index.vue +12 -12
  141. package/components/Paragraph/Team/Element/index.vue +16 -14
  142. package/components/Paragraph/Team/Layout/index.vue +9 -9
  143. package/components/Paragraph/Teasers/index.vue +14 -17
  144. package/components/Paragraph/Testimonial/Element/index.vue +19 -18
  145. package/components/Paragraph/Testimonial/Layout/index.vue +24 -18
  146. package/components/Paragraph/Text/fragment.graphql +1 -1
  147. package/components/Paragraph/Text/index.vue +8 -8
  148. package/components/Paragraph/TextMedia/index.vue +22 -22
  149. package/components/Paragraph/Timeline/Element/index.vue +19 -19
  150. package/components/Paragraph/Timeline/Layout/index.vue +51 -20
  151. package/components/Paragraph/TwoColumnText/Element/index.vue +13 -13
  152. package/components/Paragraph/TwoColumnText/Layout/index.vue +1 -1
  153. package/components/Paragraph/iFrame/index.vue +8 -8
  154. package/components/Search/Facets/FacetDropdownUI.vue +25 -31
  155. package/components/Search/Facets/FacetedSearchUI.vue +72 -48
  156. package/components/Search/Facets/fragment.graphql +20 -15
  157. package/components/Section/Footer/index.vue +55 -32
  158. package/components/Section/Header/Buttons.vue +4 -5
  159. package/components/Section/Header/DesktopMainMenu.vue +5 -4
  160. package/components/Section/Header/DesktopMainMenuDropdown.vue +5 -5
  161. package/components/Section/Header/LanguageDropdown.vue +2 -3
  162. package/components/Section/Header/Logo.vue +3 -3
  163. package/components/Section/Header/MainButtons.vue +4 -5
  164. package/components/Section/Header/MetaButtons.vue +5 -6
  165. package/components/Section/Header/MetaMenu.vue +1 -1
  166. package/components/Section/Header/MobileMainMenu.vue +1 -1
  167. package/components/Section/Header/MobileMainMenuItem.vue +2 -2
  168. package/components/Section/Header/index.vue +6 -13
  169. package/composables/__tests__/useImageSources.spec.ts +91 -0
  170. package/composables/breadcrumb.ts +12 -7
  171. package/composables/useAccordionScroll.ts +12 -4
  172. package/composables/useAllowedFragments.ts +14 -0
  173. package/composables/useAnchors.ts +48 -0
  174. package/composables/useChildTitleWeight.ts +1 -1
  175. package/composables/useCurrentLangcodeEnum.ts +1 -1
  176. package/composables/useCurrentNodeId.ts +8 -0
  177. package/composables/useDrupalUrl.ts +15 -0
  178. package/composables/useEnrichFacetLabels.ts +8 -6
  179. package/composables/useEntityRegistry.ts +3 -3
  180. package/composables/useGlobalConfig.ts +1 -2
  181. package/composables/useGraphqlCacheability.ts +12 -1
  182. package/composables/useHreflangLinks.ts +54 -0
  183. package/composables/useImageSources.ts +70 -28
  184. package/composables/useInitData.ts +1 -2
  185. package/composables/useIsHomepage.ts +1 -1
  186. package/composables/useLinkUrl.ts +30 -0
  187. package/composables/useMediaLightbox.ts +62 -50
  188. package/composables/useMenu.ts +1 -3
  189. package/composables/useMessageBus.ts +14 -7
  190. package/composables/useMobileMenuOverlayBus.ts +1 -1
  191. package/composables/useNavigationOffset.ts +49 -6
  192. package/composables/usePageCache.ts +33 -0
  193. package/composables/useSiteShell.ts +11 -0
  194. package/composables/useTheme.ts +17 -13
  195. package/config/__tests__/stubs/nuxt-multi-cache-config.ts +2 -0
  196. package/config/blokkli.ts +7 -13
  197. package/config/easyTexts.ts +1 -1
  198. package/config/graphqlMiddleware.ts +11 -4
  199. package/config/index.ts +5 -2
  200. package/config/languageNegotiation.ts +1 -1
  201. package/config/multiCache.ts +6 -1
  202. package/config/postcss-drupal/postcss.config.cjs +2 -2
  203. package/config/runtimeConfig.ts +7 -2
  204. package/config/svgIconSprite.ts +1 -2
  205. package/config/vuepal.ts +16 -18
  206. package/error.vue +1 -22
  207. package/layouts/default.vue +2 -6
  208. package/layouts/error.vue +7 -15
  209. package/modules/blokkliFragments.ts +76 -0
  210. package/modules/dynamicEntityComponentResolver.ts +47 -47
  211. package/modules/dynamicParagraphResolver.ts +43 -8
  212. package/modules/pageCache.ts +106 -0
  213. package/nuxt.config.ts +62 -33
  214. package/package.json +23 -22
  215. package/pages/[...slug]/index.vue +25 -4
  216. package/pages/index.vue +3 -3
  217. package/pages/node/[nid]/index.vue +2 -3
  218. package/patches/@blokkli+editor+2.0.0-alpha.13.patch +15 -1
  219. package/plugins/currentNode.ts +17 -0
  220. package/plugins/graphqlMiddleware.ts +10 -2
  221. package/plugins/initData.ts +15 -7
  222. package/plugins/registerNodeIcmsPageComponents.ts +6 -2
  223. package/plugins/siteShell.client.ts +94 -0
  224. package/queries/facetTermLabels.graphql +1 -5
  225. package/queries/fragment.linkUrl.graphql +12 -0
  226. package/queries/query.contentSearch.graphql +17 -2
  227. package/queries/query.initData.graphql +1 -1
  228. package/scripts/create-shade.ts +13 -4
  229. package/scripts/update-icms.ts +3 -1
  230. package/server/__tests__/graphqlMiddleware.serverOptions.spec.ts +2 -2
  231. package/server/__tests__/turnstile.spec.ts +119 -0
  232. package/server/api/icms/blokkli/last-changed.get.ts +24 -0
  233. package/server/api/icon/[...params].ts +3 -9
  234. package/server/graphqlMiddleware.serverOptions.ts +9 -8
  235. package/server/helpers/index.ts +0 -1
  236. package/server/middleware/turnstile.ts +51 -0
  237. package/server/plugins/frameAncestors.ts +20 -0
  238. package/server/plugins/pageCache.ts +59 -0
  239. package/server/utils/__tests__/accessDeniedHandover.spec.ts +49 -0
  240. package/server/utils/__tests__/backendRequestHeaders.spec.ts +115 -0
  241. package/server/utils/accessDeniedHandover.ts +48 -0
  242. package/server/utils/backendRequestHeaders.ts +51 -0
  243. package/server/utils/cacheability.ts +4 -4
  244. package/shared/__tests__/blokkliState.spec.ts +54 -0
  245. package/shared/__tests__/csp.spec.ts +53 -0
  246. package/shared/__tests__/drupalUrl.spec.ts +50 -0
  247. package/shared/blokkliState.ts +40 -0
  248. package/shared/csp.ts +47 -0
  249. package/shared/drupalUrl.ts +30 -0
  250. package/tailwind.config.ts +0 -5
  251. package/types/breadcrumb.ts +22 -0
  252. package/types/global.ts +15 -14
  253. package/types/langcode.ts +16 -0
  254. package/types/layerConfig.ts +84 -0
  255. package/utils/pageCache.ts +95 -0
  256. package/utils/turnstile.ts +35 -0
  257. package/composables/useWebformEnrichment.ts +0 -68
  258. package/server/__tests__/graphqlMiddleware.ts +0 -41
  259. package/server/multiCache.serverOptions.ts +0 -32
  260. package/vitest.config.mts +0 -28
@@ -1,4 +1,8 @@
1
- import { NodeIcmsPage, NodeIcmsPageTeaser, NodeIcmsPageSearchResult } from '#components'
1
+ import {
2
+ NodeIcmsPage,
3
+ NodeIcmsPageTeaser,
4
+ NodeIcmsPageSearchResult,
5
+ } from '#components'
2
6
 
3
7
  export default defineNuxtPlugin(() => {
4
8
  const { register } = useEntityRegistry()
@@ -6,4 +10,4 @@ export default defineNuxtPlugin(() => {
6
10
  register('NodeIcmsPage', NodeIcmsPage)
7
11
  register('NodeIcmsPageTeaser', NodeIcmsPageTeaser)
8
12
  register('NodeIcmsPageSearchResult', NodeIcmsPageSearchResult)
9
- })
13
+ })
@@ -0,0 +1,94 @@
1
+ import { defineNuxtPlugin } from '#app'
2
+ import type { RouteLocationNormalizedLoaded } from 'vue-router'
3
+ import useDrupalUser from '../composables/useDrupalUser'
4
+ import useSiteShell from '../composables/useSiteShell'
5
+ import { drupalOrigin } from '../shared/drupalUrl'
6
+
7
+ /**
8
+ * Bridge to the Drupal backend's site shell.
9
+ *
10
+ * Framed by the backend: tell it about route changes (it mirrors the path in
11
+ * its address bar and refreshes the top bar). The blökkli editor and preview
12
+ * need the whole window, so they leave the shell (the top window navigates to
13
+ * the frontend). Not framed: editors are taken into the shell whenever they
14
+ * are not editing, so browsing always happens in one UI. `?noshell` keeps the
15
+ * bare frontend for the browser session.
16
+ */
17
+ export default defineNuxtPlugin({
18
+ name: 'icms-site-shell',
19
+ dependsOn: ['nuxt:router', 'drupal-user'],
20
+ setup(nuxtApp) {
21
+ // The backend of the domain the frontend runs on (docs/routing.md).
22
+ const backendOrigin = drupalOrigin(window.location)
23
+ const shell = useSiteShell()
24
+ const router = useRouter()
25
+
26
+ const framedByBackend = (): boolean => {
27
+ if (window.parent === window) {
28
+ return false
29
+ }
30
+ const ancestors = window.location.ancestorOrigins
31
+ if (ancestors && ancestors.length) {
32
+ return ancestors[0] === backendOrigin
33
+ }
34
+ return document.referrer.startsWith(backendOrigin)
35
+ }
36
+
37
+ const isEditing = (route: RouteLocationNormalizedLoaded) =>
38
+ !!(route.query.blokkliEditing || route.query.blokkliPreview)
39
+
40
+ if (framedByBackend()) {
41
+ shell.value.framed = true
42
+ const post = (type: string, data: Record<string, unknown>) =>
43
+ window.parent.postMessage({ type, ...data }, backendOrigin)
44
+ const announce = () =>
45
+ post('icms:navigate', {
46
+ path: router.currentRoute.value.fullPath,
47
+ title: document.title,
48
+ })
49
+ const leaveShell = () => {
50
+ window.top?.location.replace(window.location.href)
51
+ }
52
+ // The title is updated by useHead once the page has finished loading;
53
+ // query-only changes don't finish a page.
54
+ nuxtApp.hook('page:finish', () => {
55
+ setTimeout(announce, 100)
56
+ })
57
+ router.afterEach((to) => {
58
+ if (isEditing(to)) {
59
+ leaveShell()
60
+ return
61
+ }
62
+ setTimeout(announce, 100)
63
+ })
64
+ if (isEditing(router.currentRoute.value)) {
65
+ leaveShell()
66
+ }
67
+ return
68
+ }
69
+
70
+ const NO_SHELL_KEY = 'icms:noshell'
71
+ const noShell = (): boolean => {
72
+ try {
73
+ if ('noshell' in router.currentRoute.value.query) {
74
+ sessionStorage.setItem(NO_SHELL_KEY, '1')
75
+ }
76
+ return sessionStorage.getItem(NO_SHELL_KEY) === '1'
77
+ } catch {
78
+ return 'noshell' in router.currentRoute.value.query
79
+ }
80
+ }
81
+ const enterShell = (route: RouteLocationNormalizedLoaded) => {
82
+ if (
83
+ useDrupalUser().value.accessToolbar &&
84
+ !isEditing(route) &&
85
+ !noShell()
86
+ ) {
87
+ window.location.replace(backendOrigin + route.fullPath)
88
+ }
89
+ }
90
+ enterShell(router.currentRoute.value)
91
+ // Closing the editor navigates back to the page: return to the shell.
92
+ router.afterEach((to) => enterShell(to))
93
+ },
94
+ })
@@ -1,11 +1,7 @@
1
1
  query facetTermLabels($ids: [String!]!) {
2
2
  terms: entityQuery(
3
3
  entityType: TAXONOMY_TERM
4
- filter: {
5
- conditions: [
6
- { field: "tid", value: $ids, operator: IN }
7
- ]
8
- }
4
+ filter: { conditions: [{ field: "tid", value: $ids, operator: IN }] }
9
5
  ) {
10
6
  items {
11
7
  ... on TaxonomyTerm {
@@ -0,0 +1,12 @@
1
+ fragment linkUrl on Url {
2
+ path
3
+ ... on EntityCanonicalUrl {
4
+ entity {
5
+ # Only media select an id here: it marks a link whose target (the file
6
+ # behind /media/{id}) is served by the backend, not by the Nuxt app.
7
+ ... on Media {
8
+ id
9
+ }
10
+ }
11
+ }
12
+ }
@@ -1,5 +1,20 @@
1
- query contentSearch($keys: String!, $language: String, $facets: [String!], $filter: SearchApiQueryFilterInput, $limit: Int, $offset: Int) {
2
- searchApiQuery(indexId: "content_search", keys: $keys, language: $language, facets: $facets, filter: $filter, limit: $limit, offset: $offset) {
1
+ query contentSearch(
2
+ $keys: String!
3
+ $language: String
4
+ $facets: [String!]
5
+ $filter: SearchApiQueryFilterInput
6
+ $limit: Int
7
+ $offset: Int
8
+ ) {
9
+ searchApiQuery(
10
+ indexId: "content_search"
11
+ keys: $keys
12
+ language: $language
13
+ facets: $facets
14
+ filter: $filter
15
+ limit: $limit
16
+ offset: $offset
17
+ ) {
3
18
  count
4
19
  facets {
5
20
  field
@@ -92,7 +92,7 @@ fragment globalConfig on TranslatableConfigPagesGlobal {
92
92
  }
93
93
  socialMediaLinks: fieldIcmsSocialMediaLinks {
94
94
  uri {
95
- path
95
+ ...linkUrl
96
96
  }
97
97
  }
98
98
  }
@@ -98,7 +98,12 @@ async function main() {
98
98
  }
99
99
 
100
100
  const oklch = toOklch(parsed)
101
- if (!oklch || oklch.l === undefined || oklch.c === undefined || oklch.h === undefined) {
101
+ if (
102
+ !oklch ||
103
+ oklch.l === undefined ||
104
+ oklch.c === undefined ||
105
+ oklch.h === undefined
106
+ ) {
102
107
  fail(`Could not convert color to OKLCH: ${rawColor}`)
103
108
  }
104
109
 
@@ -142,7 +147,9 @@ async function main() {
142
147
  ].join('\n')
143
148
  }).join('\n')
144
149
 
145
- const themeShadeLines = SHADES.map((shade) => ` --color-${name}-${shade}: var(--${name}-${shade});`).join('\n')
150
+ const themeShadeLines = SHADES.map(
151
+ (shade) => ` --color-${name}-${shade}: var(--${name}-${shade});`,
152
+ ).join('\n')
146
153
 
147
154
  const fileContent = `:root {
148
155
  /* ${displayName}: ${hex} */
@@ -162,7 +169,9 @@ ${themeShadeLines}
162
169
  await writeFile(filePath, fileContent)
163
170
  await ensureColorsImport(colorsPath, fileName)
164
171
 
165
- console.log(`Created ${fileName} from ${hex} with anchor shade ${anchorShade}`)
172
+ console.log(
173
+ `Created ${fileName} from ${hex} with anchor shade ${anchorShade}`,
174
+ )
166
175
  }
167
176
 
168
- await main()
177
+ await main()
@@ -50,7 +50,9 @@ const pick = (dependencies?: Record<string, string>) =>
50
50
  const add = (packages: string[], flags: string[] = []) => {
51
51
  if (packages.length === 0) return
52
52
  console.log(`Updating ${packages.join(', ')}`)
53
- const result = spawnSync('bun', ['add', ...flags, ...packages], { stdio: 'inherit' })
53
+ const result = spawnSync('bun', ['add', ...flags, ...packages], {
54
+ stdio: 'inherit',
55
+ })
54
56
  if (result.status !== 0) {
55
57
  fail(`Failed to update ${packages.join(', ')}`)
56
58
  }
@@ -15,8 +15,8 @@ vi.mock('#nuxt-language-negotiation/config', () => ({
15
15
  langcodes: ['de', 'fr', 'it', 'en'],
16
16
  }))
17
17
 
18
- vi.mock('#shared/request', () => ({
19
- buildProxyRequestHeaders: () => ({}),
18
+ vi.mock('../utils/backendRequestHeaders', () => ({
19
+ buildBackendRequestHeaders: () => ({}),
20
20
  }))
21
21
 
22
22
  // Nitro auto-imports in the module under test, provided as globals: the h3
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { createEvent, createError, defineEventHandler, getHeader } from 'h3'
3
+ import type { H3Event } from 'h3'
4
+ import { IncomingMessage, ServerResponse } from 'node-mock-http'
5
+ import {
6
+ TURNSTILE_ERROR_CODE,
7
+ TURNSTILE_TOKEN_HEADER,
8
+ isWebformSubmissionRequest,
9
+ } from '../../utils/turnstile'
10
+
11
+ // Nitro auto-imports in the module under test, provided as globals. The h3
12
+ // helpers are the real ones; the runtime config and the Cloudflare call are
13
+ // the two things a unit test has to control.
14
+ const runtimeConfig = { turnstile: { secretKey: '' } }
15
+ const verifyTurnstileToken = vi.fn()
16
+
17
+ vi.stubGlobal('defineEventHandler', defineEventHandler)
18
+ vi.stubGlobal('getHeader', getHeader)
19
+ vi.stubGlobal('createError', createError)
20
+ vi.stubGlobal('useRuntimeConfig', () => runtimeConfig)
21
+ vi.stubGlobal('verifyTurnstileToken', verifyTurnstileToken)
22
+
23
+ const middleware = (await import('../middleware/turnstile')).default
24
+
25
+ function submissionEvent(headers: Record<string, string> = {}): H3Event {
26
+ const request = new IncomingMessage()
27
+ request.url = '/api/graphql/mutation/submitWebform'
28
+ request.method = 'POST'
29
+ Object.assign(request.headers, headers)
30
+
31
+ return createEvent(request, new ServerResponse(request))
32
+ }
33
+
34
+ function run(event: H3Event) {
35
+ return (middleware as unknown as (event: H3Event) => Promise<void>)(event)
36
+ }
37
+
38
+ describe('isWebformSubmissionRequest', () => {
39
+ it.each([
40
+ '/api/graphql/mutation/submitWebform',
41
+ '/api/graphql/mutation/submitWebform?__client=1',
42
+ // A project may change graphqlMiddleware.serverApiPrefix.
43
+ '/custom-prefix/mutation/submitWebform',
44
+ ])('matches %s', (path) => {
45
+ expect(isWebformSubmissionRequest(path)).toBe(true)
46
+ })
47
+
48
+ it.each([
49
+ '/api/graphql/query/route',
50
+ '/api/graphql/mutation/submitWebformSomethingElse',
51
+ '/de/veranstaltungen',
52
+ '',
53
+ undefined,
54
+ ])('does not match %s', (path) => {
55
+ expect(isWebformSubmissionRequest(path)).toBe(false)
56
+ })
57
+ })
58
+
59
+ describe('turnstile middleware', () => {
60
+ beforeEach(() => {
61
+ runtimeConfig.turnstile.secretKey = ''
62
+ verifyTurnstileToken.mockReset()
63
+ })
64
+
65
+ it('lets every request through while no secret key is configured', async () => {
66
+ await expect(run(submissionEvent())).resolves.toBeUndefined()
67
+ expect(verifyTurnstileToken).not.toHaveBeenCalled()
68
+ })
69
+
70
+ it('ignores requests that are not a webform submission', async () => {
71
+ runtimeConfig.turnstile.secretKey = 'secret'
72
+
73
+ const request = new IncomingMessage()
74
+ request.url = '/api/graphql/query/route'
75
+ const event = createEvent(request, new ServerResponse(request))
76
+
77
+ await expect(run(event)).resolves.toBeUndefined()
78
+ expect(verifyTurnstileToken).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it('rejects a submission without a token', async () => {
82
+ runtimeConfig.turnstile.secretKey = 'secret'
83
+
84
+ await expect(run(submissionEvent())).rejects.toMatchObject({
85
+ statusCode: 403,
86
+ data: { code: TURNSTILE_ERROR_CODE },
87
+ })
88
+ expect(verifyTurnstileToken).not.toHaveBeenCalled()
89
+ })
90
+
91
+ it('rejects a submission Cloudflare does not verify', async () => {
92
+ runtimeConfig.turnstile.secretKey = 'secret'
93
+ verifyTurnstileToken.mockResolvedValue({
94
+ success: false,
95
+ 'error-codes': ['invalid-input-response'],
96
+ })
97
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
98
+
99
+ await expect(
100
+ run(submissionEvent({ [TURNSTILE_TOKEN_HEADER]: 'a-token' })),
101
+ ).rejects.toMatchObject({
102
+ statusCode: 403,
103
+ data: { code: TURNSTILE_ERROR_CODE },
104
+ })
105
+ expect(verifyTurnstileToken).toHaveBeenCalledWith(
106
+ 'a-token',
107
+ expect.anything(),
108
+ )
109
+ })
110
+
111
+ it('passes a verified submission through', async () => {
112
+ runtimeConfig.turnstile.secretKey = 'secret'
113
+ verifyTurnstileToken.mockResolvedValue({ success: true })
114
+
115
+ await expect(
116
+ run(submissionEvent({ [TURNSTILE_TOKEN_HEADER]: 'a-token' })),
117
+ ).resolves.toBeUndefined()
118
+ })
119
+ })
@@ -0,0 +1,24 @@
1
+ import { buildBackendRequestHeaders } from '../../../utils/backendRequestHeaders'
2
+
3
+ /**
4
+ * Proxies paragraphs_blokkli's "last changed" poll to the Drupal backend.
5
+ *
6
+ * The blökkli preview polls this endpoint; the Drupal route is not reachable
7
+ * on the frontend domain when the backend runs on its own domain, and calling
8
+ * it there directly would be a cross-origin request.
9
+ */
10
+ export default defineEventHandler(async (event) => {
11
+ const query = getQuery(event)
12
+ const entityType = String(query.entityType || '')
13
+ const entityUuid = String(query.entityUuid || '')
14
+ if (!/^[a-z_]+$/.test(entityType) || !/^[0-9a-f-]{36}$/i.test(entityUuid)) {
15
+ throw createError({ statusCode: 400, statusMessage: 'Invalid entity.' })
16
+ }
17
+
18
+ setResponseHeader(event, 'Cache-Control', 'private, no-store')
19
+ const config = useRuntimeConfig()
20
+ return $fetch<{ changed: number }>(
21
+ `${config.backendUrl}/paragraphs_blokkli/${entityType}/${entityUuid}/last_changed`,
22
+ { headers: buildBackendRequestHeaders(event) },
23
+ )
24
+ })
@@ -2,6 +2,7 @@ import { getRouterParams, defaultContentType, defineEventHandler } from 'h3'
2
2
  import type { H3Event } from 'h3'
3
3
  import { optimize } from 'svgo'
4
4
  import { MAX_AGE } from '../../helpers'
5
+ import { buildBackendRequestHeaders } from '../../utils/backendRequestHeaders'
5
6
 
6
7
  const config = useRuntimeConfig()
7
8
 
@@ -117,17 +118,10 @@ async function getIcon(
117
118
  return value
118
119
  }
119
120
 
120
- const requestHeaders = getHeaders(event)
121
- const host = requestHeaders.host || ''
122
-
123
121
  // Fetch the SVG markup.
124
122
  const url = `${config.backendUrl}/de/media/${id}/icon`
125
123
  const response = await $fetch.raw<Blob>(url, {
126
- headers: {
127
- host,
128
- cookie: requestHeaders.cookie || '',
129
- referer: requestHeaders.referer || '',
130
- },
124
+ headers: buildBackendRequestHeaders(event),
131
125
  })
132
126
 
133
127
  const cacheability = extractCacheability(response, event)
@@ -197,4 +191,4 @@ export default defineEventHandler(async (event) => {
197
191
  // We don't want to return a message here, only an empty response.
198
192
  return EMPTY_RESPONSE
199
193
  }
200
- })
194
+ })
@@ -1,8 +1,11 @@
1
1
  import { defineGraphqlServerOptions } from 'nuxt-graphql-middleware/server-options'
2
+ import { getRequestURL } from 'h3'
2
3
  import { extractCacheability } from '../server/utils/cacheability'
3
4
  import { defaultLangcode, langcodes } from '#nuxt-language-negotiation/config'
4
5
  import { MAX_AGE, type GraphqlCacheability } from '../server/helpers'
5
- import { buildProxyRequestHeaders } from '#shared/request'
6
+ import { buildBackendRequestHeaders } from '../server/utils/backendRequestHeaders'
7
+ import { handoverAccessDeniedRoute } from '../server/utils/accessDeniedHandover'
8
+ import { drupalOrigin } from '../shared/drupalUrl'
6
9
 
7
10
  export default defineGraphqlServerOptions<{
8
11
  extensions: {
@@ -22,13 +25,8 @@ export default defineGraphqlServerOptions<{
22
25
  return `${config.backendUrl}/${defaultLangcode}/graphql`
23
26
  },
24
27
  serverFetchOptions(event) {
25
- const config = useRuntimeConfig()
26
-
27
28
  return {
28
- headers: {
29
- ...buildProxyRequestHeaders(event),
30
- 'x-drupal-graphql-token': config.drupalGraphqlToken,
31
- },
29
+ headers: buildBackendRequestHeaders(event),
32
30
  }
33
31
  },
34
32
  onServerResponse(event, graphqlResponse, operation, operationName, context) {
@@ -83,7 +81,10 @@ export default defineGraphqlServerOptions<{
83
81
 
84
82
  // Return the GraphQL response.
85
83
  return {
86
- data: graphqlResponse._data!.data,
84
+ data: handoverAccessDeniedRoute(
85
+ graphqlResponse._data!.data,
86
+ drupalOrigin(getRequestURL(event)),
87
+ ),
87
88
  errors: graphqlResponse._data!.errors,
88
89
  extensions: {
89
90
  cacheability: addCacheability ? cacheability : null,
@@ -3,7 +3,6 @@ export type GraphqlCacheability = {
3
3
  tagsNuxt: string[]
4
4
  tagsCdn: string[]
5
5
  tagsDrupal: string[]
6
- maxAge: number
7
6
  sharedMaxAge: number
8
7
  }
9
8
 
@@ -0,0 +1,51 @@
1
+ import {
2
+ TURNSTILE_ERROR_CODE,
3
+ TURNSTILE_TOKEN_HEADER,
4
+ isWebformSubmissionRequest,
5
+ } from '../../utils/turnstile'
6
+
7
+ /**
8
+ * Verifies the Turnstile token before a webform submission reaches Drupal.
9
+ *
10
+ * `graphql_webform` submits through Webform's programmatic API, which bypasses
11
+ * the Form API — Drupal-side spam modules (honeypot, captcha, antibot) hook
12
+ * into `hook_form_alter()` and therefore never run on a decoupled submission.
13
+ * The check has to happen where the submission crosses the boundary, and it has
14
+ * to happen on the server: verifying in the component only would be skipped by
15
+ * anyone calling the mutation endpoint directly.
16
+ *
17
+ * Disabled, and completely transparent, while no secret key is configured.
18
+ */
19
+ export default defineEventHandler(async (event) => {
20
+ if (!isWebformSubmissionRequest(event.path)) {
21
+ return
22
+ }
23
+
24
+ const secretKey = useRuntimeConfig(event).turnstile?.secretKey
25
+ if (!secretKey) {
26
+ return
27
+ }
28
+
29
+ const token = getHeader(event, TURNSTILE_TOKEN_HEADER)
30
+ if (!token) {
31
+ throw createError({
32
+ statusCode: 403,
33
+ statusMessage: 'Missing spam protection token.',
34
+ data: { code: TURNSTILE_ERROR_CODE },
35
+ })
36
+ }
37
+
38
+ const result = await verifyTurnstileToken(token, event)
39
+ if (!result?.success) {
40
+ console.warn(
41
+ '[turnstile] Rejected a webform submission:',
42
+ result?.['error-codes'] ?? 'no error codes returned',
43
+ )
44
+
45
+ throw createError({
46
+ statusCode: 403,
47
+ statusMessage: 'Spam protection check failed.',
48
+ data: { code: TURNSTILE_ERROR_CODE },
49
+ })
50
+ }
51
+ })
@@ -0,0 +1,20 @@
1
+ import { drupalOrigin } from '../../shared/drupalUrl'
2
+ import { mergeFrameAncestors } from '../../shared/csp'
3
+
4
+ /**
5
+ * Lets the Drupal backend frame the frontend (site shell).
6
+ *
7
+ * The backend is the "backend." subdomain of the domain the page was
8
+ * requested on (see docs/routing.md). The directive is merged into whatever
9
+ * Content-Security-Policy the rendered response already carries — a project's
10
+ * own policy keeps its other directives and sources.
11
+ */
12
+ export default defineNitroPlugin((nitroApp) => {
13
+ nitroApp.hooks.hook('render:response', (response, { event }) => {
14
+ const origin = drupalOrigin(getRequestURL(event))
15
+ response.headers['content-security-policy'] = mergeFrameAncestors(
16
+ response.headers['content-security-policy'],
17
+ ["'self'", origin],
18
+ ).join(', ')
19
+ })
20
+ })
@@ -0,0 +1,59 @@
1
+ import { removeResponseHeader } from 'h3'
2
+ import { serverOptions } from '#nuxt-multi-cache/server-options'
3
+ import {
4
+ applyCacheControlHeaders,
5
+ finalizeRouteCache,
6
+ resolvePageCacheConfig,
7
+ setRouteCacheStatusHeader,
8
+ } from '../../cache/pageCache'
9
+
10
+ /**
11
+ * Applies the page cache mode to every response (ICMS-268), see
12
+ * cache/pageCache.ts.
13
+ */
14
+ export default defineNitroPlugin((nitroApp) => {
15
+ const config = resolvePageCacheConfig(useRuntimeConfig().pageCache)
16
+
17
+ // Without a bounded route storage nuxt-multi-cache falls back to an
18
+ // unbounded in-memory one, so pages are never stored then. This happens
19
+ // when a project's own server/multiCache.serverOptions.ts replaces the
20
+ // ICMS server options (modules/pageCache.ts) without one.
21
+ const routeDriver = serverOptions.route?.storage?.driver
22
+ const hasBoundedRouteStorage = !!routeDriver
23
+ if (hasBoundedRouteStorage && config.mode !== 'varnish') {
24
+ const maxSize = Number(routeDriver.options?.maxSize) || 0
25
+ console.info(
26
+ config.routeMaxSize === 0
27
+ ? '[icms] Page cache: off (routeMaxSize 0, the default in development)'
28
+ : `[icms] Page cache: ${(maxSize / 1024 / 1024).toFixed(1)} MiB` +
29
+ (config.routeMaxSize === 'auto' ? ' (auto)' : '') +
30
+ (config.routeCompression ? ', gzip' : ''),
31
+ )
32
+ }
33
+ if (!hasBoundedRouteStorage) {
34
+ console.error(
35
+ '[icms] Page cache disabled: the multi-cache server options define ' +
36
+ 'no route storage. Extend the ICMS server options in ' +
37
+ 'server/multiCache.serverOptions.ts (see the @iqual/nuxt-icms README).',
38
+ )
39
+ }
40
+
41
+ nitroApp.hooks.hook('beforeResponse', (event) => {
42
+ if (!hasBoundedRouteStorage) {
43
+ event.context.multiCache?.route?.setUncacheable()
44
+ }
45
+ finalizeRouteCache(event, config)
46
+ applyCacheControlHeaders(event, config)
47
+ setRouteCacheStatusHeader(event)
48
+ })
49
+
50
+ // Error responses (e.g. a 404 thrown during the render) are sent by the
51
+ // error handler without the beforeResponse hook. The headers of the error
52
+ // page render are applied on top, but a Cache-Tag the failed render already
53
+ // set would stay.
54
+ nitroApp.hooks.hook('error', (_error, { event }) => {
55
+ if (event && config.mode !== 'varnish') {
56
+ removeResponseHeader(event, 'cache-tag')
57
+ }
58
+ })
59
+ })
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { handoverAccessDeniedRoute } from '../accessDeniedHandover'
3
+
4
+ const origin = 'https://backend.www.example.com'
5
+
6
+ describe('handoverAccessDeniedRoute', () => {
7
+ it('sends an access-denied route to the backend page directly', () => {
8
+ const data = {
9
+ route: {
10
+ __typename: 'RedirectUrl',
11
+ path: '/user/login?destination=/de/node/1/latest',
12
+ redirect: { statusCode: 403 },
13
+ },
14
+ }
15
+ expect(handoverAccessDeniedRoute(data, origin)).toEqual({
16
+ route: {
17
+ __typename: 'RedirectUrl',
18
+ path: 'https://backend.www.example.com/de/node/1/latest',
19
+ redirect: { statusCode: 302 },
20
+ },
21
+ })
22
+ // The response is not mutated.
23
+ expect(data.route.redirect.statusCode).toBe(403)
24
+ })
25
+
26
+ it('falls back to the login form without a usable destination', () => {
27
+ for (const path of ['/de/user/login', '/user/login?destination=//evil']) {
28
+ expect(
29
+ handoverAccessDeniedRoute(
30
+ { route: { path, redirect: { statusCode: 403 } } },
31
+ origin,
32
+ ).route?.path,
33
+ ).toBe(origin + path)
34
+ }
35
+ })
36
+
37
+ it('leaves other responses alone', () => {
38
+ const cases = [
39
+ { route: { path: '/de/neu', redirect: { statusCode: 301 } } },
40
+ { route: { path: '/user/login?destination=/x', redirect: null } },
41
+ { route: { __typename: 'EntityCanonicalUrl', path: '/de/x' } },
42
+ { route: null },
43
+ {},
44
+ ]
45
+ for (const data of cases) {
46
+ expect(handoverAccessDeniedRoute(data, origin)).toBe(data)
47
+ }
48
+ })
49
+ })