@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
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createEvent } from 'h3'
3
+ import { IncomingMessage, ServerResponse } from 'node-mock-http'
4
+
5
+ const metaMock = vi.hoisted(() => ({ importMetaClient: false }))
6
+ vi.mock('#shared/importMeta', () => metaMock)
7
+
8
+ // The app-level helper the layer builds on (frontend/shared/request.ts).
9
+ vi.mock('#shared/request', async () => {
10
+ const { getRequestHeaders } = await import('h3')
11
+ const allowed = new Set([
12
+ 'x-forwarded-host',
13
+ 'x-forwarded-proto',
14
+ 'x-forwarded-for',
15
+ 'x-forwarded-port',
16
+ 'x-client-ip',
17
+ 'cookie',
18
+ ])
19
+ return {
20
+ buildProxyRequestHeaders: (
21
+ event: Parameters<typeof getRequestHeaders>[0],
22
+ ) => {
23
+ const out: Record<string, string> = {}
24
+ for (const [name, value] of Object.entries(getRequestHeaders(event))) {
25
+ if (allowed.has(name) && value) out[name] = value
26
+ }
27
+ return out
28
+ },
29
+ }
30
+ })
31
+
32
+ vi.stubGlobal('useRuntimeConfig', () => ({
33
+ drupalGraphqlToken: 'runtime-token',
34
+ }))
35
+
36
+ const { buildBackendRequestHeaders, GRAPHQL_TOKEN_HEADER } =
37
+ await import('../backendRequestHeaders')
38
+
39
+ function eventWithHeaders(headers: Record<string, string>) {
40
+ const request = new IncomingMessage()
41
+ request.headers = headers
42
+ return createEvent(request, new ServerResponse(request))
43
+ }
44
+
45
+ describe('buildBackendRequestHeaders', () => {
46
+ it('sets the forwarded host and scheme from the request', () => {
47
+ expect(
48
+ buildBackendRequestHeaders(
49
+ eventWithHeaders({ host: 'www.example.com', cookie: 'SSESSabc=1' }),
50
+ 'token',
51
+ ),
52
+ ).toEqual({
53
+ cookie: 'SSESSabc=1',
54
+ 'x-forwarded-host': 'www.example.com',
55
+ 'x-forwarded-proto': 'https',
56
+ [GRAPHQL_TOKEN_HEADER]: 'token',
57
+ })
58
+ })
59
+
60
+ it('overwrites forwarded headers sent by the client', () => {
61
+ expect(
62
+ buildBackendRequestHeaders(
63
+ eventWithHeaders({
64
+ host: 'www.example.com',
65
+ 'x-forwarded-host': 'evil.example',
66
+ 'x-forwarded-proto': 'http',
67
+ }),
68
+ 'token',
69
+ ),
70
+ ).toMatchObject({
71
+ 'x-forwarded-host': 'www.example.com',
72
+ 'x-forwarded-proto': 'https',
73
+ })
74
+ })
75
+
76
+ it('drops forwarded headers Drupal does not trust', () => {
77
+ const headers = buildBackendRequestHeaders(
78
+ eventWithHeaders({
79
+ host: 'www.example.com',
80
+ 'x-forwarded-for': '203.0.113.1',
81
+ 'x-forwarded-port': '8443',
82
+ 'x-client-ip': '198.51.100.7',
83
+ }),
84
+ 'token',
85
+ )
86
+ expect(headers).not.toHaveProperty('x-forwarded-for')
87
+ expect(headers).not.toHaveProperty('x-forwarded-port')
88
+ // The router-set client IP is what Drupal uses as REMOTE_ADDR.
89
+ expect(headers['x-client-ip']).toBe('198.51.100.7')
90
+ })
91
+
92
+ it('drops a client-sent forwarded host without a Host header', () => {
93
+ expect(
94
+ buildBackendRequestHeaders(
95
+ eventWithHeaders({ 'x-forwarded-host': 'evil.example' }),
96
+ 'token',
97
+ ),
98
+ ).not.toHaveProperty('x-forwarded-host')
99
+ })
100
+
101
+ it('sends the GraphQL token from the runtime config', () => {
102
+ expect(
103
+ buildBackendRequestHeaders(eventWithHeaders({ host: 'www.example.com' })),
104
+ ).toMatchObject({ [GRAPHQL_TOKEN_HEADER]: 'runtime-token' })
105
+ })
106
+
107
+ it('omits the token header when no token is configured', () => {
108
+ expect(
109
+ buildBackendRequestHeaders(
110
+ eventWithHeaders({ host: 'www.example.com' }),
111
+ '',
112
+ ),
113
+ ).not.toHaveProperty(GRAPHQL_TOKEN_HEADER)
114
+ })
115
+ })
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The part of a route query response this module cares about.
3
+ */
4
+ interface RouteQueryDataLike {
5
+ route?: {
6
+ __typename?: string
7
+ path?: string | null
8
+ redirect?: { statusCode?: number | null } | null
9
+ } | null
10
+ }
11
+
12
+ /**
13
+ * Hands a Drupal route the visitor may not access over to the backend.
14
+ *
15
+ * For a Drupal route that exists but is not accessible (e.g. a revision page
16
+ * for an anonymous visitor), the GraphQL route resolver answers with a
17
+ * redirect to Drupal's login form, with the path as ?destination. The login
18
+ * form lives on the backend domain — so does the page the visitor wanted —
19
+ * and following the redirect on the frontend would only get there through
20
+ * the router's /user redirect. Send the visitor to the page on the backend
21
+ * directly instead: Drupal then decides between its 403 and its login form.
22
+ */
23
+ export function handoverAccessDeniedRoute<T>(
24
+ data: T,
25
+ backendOrigin: string,
26
+ ): T {
27
+ const route = (data as RouteQueryDataLike | null | undefined)?.route
28
+ if (
29
+ !route ||
30
+ route.redirect?.statusCode !== 403 ||
31
+ typeof route.path !== 'string' ||
32
+ !/^\/(?:[a-z]{2}\/)?user\/login(?:[/?]|$)/.test(route.path)
33
+ ) {
34
+ return data
35
+ }
36
+ const destination =
37
+ new URL(route.path, 'http://localhost').searchParams.get('destination') ||
38
+ ''
39
+ const path = /^\/(?!\/)/.test(destination) ? destination : route.path
40
+ return {
41
+ ...data,
42
+ route: {
43
+ ...route,
44
+ path: backendOrigin + path,
45
+ redirect: { ...route.redirect, statusCode: 302 },
46
+ },
47
+ } as T
48
+ }
@@ -0,0 +1,51 @@
1
+ import type { H3Event } from 'h3'
2
+ import { getRequestHeader } from 'h3'
3
+ import { buildProxyRequestHeaders } from '#shared/request'
4
+
5
+ /**
6
+ * Carries the token Drupal and the frontend share (DRUPAL_GRAPHQL_TOKEN): it
7
+ * grants access to GraphQL, and Drupal only trusts X-Forwarded-Host and
8
+ * X-Forwarded-Proto on requests carrying it (see settings.php).
9
+ */
10
+ export const GRAPHQL_TOKEN_HEADER = 'x-drupal-graphql-token'
11
+
12
+ /**
13
+ * Forwarded headers not passed on: the host is set from the request below,
14
+ * and Drupal never trusts the others, which clients can set.
15
+ */
16
+ const DROPPED_HEADERS = new Set([
17
+ 'x-forwarded-host',
18
+ 'x-forwarded-for',
19
+ 'x-forwarded-port',
20
+ 'x-forwarded-server',
21
+ 'forwarded',
22
+ ])
23
+
24
+ /**
25
+ * Headers for a server-side request to the Drupal backend.
26
+ *
27
+ * Extends the proxied request headers with the public host and scheme and the
28
+ * GraphQL token: Drupal derives the URLs it generates and the session cookie
29
+ * from X-Forwarded-Host. The host always comes from the request's Host header
30
+ * (the router only routes known domains), never from a client-sent
31
+ * X-Forwarded-Host.
32
+ */
33
+ export function buildBackendRequestHeaders(
34
+ event: H3Event,
35
+ token: string = useRuntimeConfig().drupalGraphqlToken,
36
+ ): Record<string, string> {
37
+ const headers = Object.fromEntries(
38
+ Object.entries(buildProxyRequestHeaders(event)).filter(
39
+ ([name]) => !DROPPED_HEADERS.has(name),
40
+ ),
41
+ )
42
+ const host = getRequestHeader(event, 'host')
43
+ if (host) {
44
+ headers['x-forwarded-host'] = host
45
+ }
46
+ headers['x-forwarded-proto'] = 'https'
47
+ if (token) {
48
+ headers[GRAPHQL_TOKEN_HEADER] = token
49
+ }
50
+ return headers
51
+ }
@@ -2,7 +2,7 @@ import type { FetchResponse } from 'ofetch'
2
2
  import type { H3Event } from 'h3'
3
3
  import crypto from 'crypto'
4
4
  import { INTERVALS, MAX_AGE } from '../helpers'
5
- import type { ValidInterval } from '../helpers'
5
+ import type { GraphqlCacheability, ValidInterval } from '../helpers'
6
6
 
7
7
  function extractCacheTags(
8
8
  response: FetchResponse<unknown>,
@@ -45,7 +45,7 @@ function calculateMaxAge(expires: string | null): number {
45
45
  export function extractCacheability(
46
46
  response: FetchResponse<unknown>,
47
47
  event: H3Event,
48
- ) {
48
+ ): GraphqlCacheability {
49
49
  const hasSessionCookie = (event.node.req.headers.cookie || '').includes(
50
50
  'SSESS',
51
51
  )
@@ -53,7 +53,7 @@ export function extractCacheability(
53
53
  // cache the initData in the cache of nuxt_multi_cache in the frontend.
54
54
  const tagsNuxt = extractCacheTags(response, 'x-nuxt-cache-tags')
55
55
 
56
- // These tags are provided by the generic http purge module and
56
+ // These tags are provided by the generic http purge module and
57
57
  // used to invalidate SSR full page caches on varnish.
58
58
  const tagsCdn = extractCacheTags(response, 'purge-cache-tags')
59
59
 
@@ -136,4 +136,4 @@ export function buildFastlyCacheTags(input: string | string[]): string[] {
136
136
  .digest('base64')
137
137
  .substring(0, 6)
138
138
  })
139
- }
139
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { withoutTranslationEditUrls } from '../blokkliState'
3
+
4
+ describe('withoutTranslationEditUrls', () => {
5
+ it('removes the editUrl from every translation', () => {
6
+ const state = {
7
+ currentIndex: 2,
8
+ translationState: {
9
+ isTranslatable: true,
10
+ sourceLanguage: 'de',
11
+ translations: [
12
+ {
13
+ id: 'de',
14
+ url: '/de/seite',
15
+ editUrl: '/de/node/1/edit',
16
+ exists: true,
17
+ status: true,
18
+ },
19
+ {
20
+ id: 'fr',
21
+ url: '/fr/node/1',
22
+ editUrl:
23
+ '/fr/node/1/translations/add/de/fr?destination=/paragraphs_blokkli/redirect',
24
+ exists: false,
25
+ status: false,
26
+ },
27
+ ],
28
+ },
29
+ }
30
+
31
+ const result = withoutTranslationEditUrls(state)
32
+
33
+ expect(result.translationState.translations).toEqual([
34
+ { id: 'de', url: '/de/seite', exists: true, status: true },
35
+ { id: 'fr', url: '/fr/node/1', exists: false, status: false },
36
+ ])
37
+ expect(result.translationState.isTranslatable).toBe(true)
38
+ expect(result.translationState.sourceLanguage).toBe('de')
39
+ expect(result.currentIndex).toBe(2)
40
+ // The adapter's state is not mutated.
41
+ expect(state.translationState.translations[1].editUrl).toContain(
42
+ 'translations/add',
43
+ )
44
+ })
45
+
46
+ it('returns the state as is without translations', () => {
47
+ const untranslatable = {
48
+ translationState: { isTranslatable: false, translations: [] },
49
+ }
50
+ expect(withoutTranslationEditUrls(untranslatable)).toBe(untranslatable)
51
+ const noState = { currentIndex: 0 }
52
+ expect(withoutTranslationEditUrls(noState)).toBe(noState)
53
+ })
54
+ })
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mergeFrameAncestors } from '../csp'
3
+
4
+ const sources = ["'self'", 'https://backend.www.example.com']
5
+
6
+ describe('mergeFrameAncestors', () => {
7
+ it('creates the policy when there is none', () => {
8
+ expect(mergeFrameAncestors(undefined, sources)).toEqual([
9
+ "frame-ancestors 'self' https://backend.www.example.com",
10
+ ])
11
+ expect(mergeFrameAncestors('', sources)).toEqual([
12
+ "frame-ancestors 'self' https://backend.www.example.com",
13
+ ])
14
+ })
15
+
16
+ it('appends the directive to a policy without one', () => {
17
+ expect(mergeFrameAncestors("default-src 'self'; ", sources)).toEqual([
18
+ "default-src 'self'; frame-ancestors 'self' https://backend.www.example.com",
19
+ ])
20
+ })
21
+
22
+ it('keeps existing sources and replaces none', () => {
23
+ expect(
24
+ mergeFrameAncestors(
25
+ "default-src 'self'; frame-ancestors 'self' https://other.example.org; img-src *",
26
+ sources,
27
+ ),
28
+ ).toEqual([
29
+ "default-src 'self'; frame-ancestors 'self' https://other.example.org https://backend.www.example.com; img-src *",
30
+ ])
31
+ expect(mergeFrameAncestors("frame-ancestors 'none'", sources)).toEqual([
32
+ "frame-ancestors 'self' https://backend.www.example.com",
33
+ ])
34
+ expect(
35
+ mergeFrameAncestors(
36
+ "frame-ancestors https://backend.www.example.com 'self'",
37
+ sources,
38
+ ),
39
+ ).toEqual(["frame-ancestors https://backend.www.example.com 'self'"])
40
+ })
41
+
42
+ it('merges into the right one of several header values', () => {
43
+ expect(
44
+ mergeFrameAncestors(
45
+ ["default-src 'self'", "frame-ancestors 'self'"],
46
+ sources,
47
+ ),
48
+ ).toEqual([
49
+ "default-src 'self'",
50
+ "frame-ancestors 'self' https://backend.www.example.com",
51
+ ])
52
+ })
53
+ })
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { drupalOrigin, toDrupalUrl } from '../drupalUrl'
3
+
4
+ describe('drupalOrigin', () => {
5
+ it('prefixes the request host with the backend subdomain', () => {
6
+ expect(drupalOrigin(new URL('https://www.example.com/de/about?x=1'))).toBe(
7
+ 'https://backend.www.example.com',
8
+ )
9
+ expect(drupalOrigin(new URL('https://example.com/'))).toBe(
10
+ 'https://backend.example.com',
11
+ )
12
+ })
13
+
14
+ it('keeps scheme and port', () => {
15
+ expect(drupalOrigin(new URL('http://starterkit.ddev.site:8080/'))).toBe(
16
+ 'http://backend.starterkit.ddev.site:8080',
17
+ )
18
+ })
19
+ })
20
+
21
+ describe('toDrupalUrl', () => {
22
+ it('returns the path unchanged without a backend origin', () => {
23
+ expect(toDrupalUrl('/de/user/login')).toBe('/de/user/login')
24
+ expect(toDrupalUrl('/de/user/login', '')).toBe('/de/user/login')
25
+ expect(toDrupalUrl('/de/user/login', null)).toBe('/de/user/login')
26
+ })
27
+
28
+ it('prefixes the path with the backend origin', () => {
29
+ expect(
30
+ toDrupalUrl('/de/user/login', 'https://backend.www.example.com'),
31
+ ).toBe('https://backend.www.example.com/de/user/login')
32
+ expect(
33
+ toDrupalUrl(
34
+ '/paragraphs_blokkli/x?paragraphsBlokkli=true',
35
+ 'https://backend.example.com/',
36
+ ),
37
+ ).toBe(
38
+ 'https://backend.example.com/paragraphs_blokkli/x?paragraphsBlokkli=true',
39
+ )
40
+ expect(toDrupalUrl('de/user', 'https://backend.example.com')).toBe(
41
+ 'https://backend.example.com/de/user',
42
+ )
43
+ })
44
+
45
+ it('leaves absolute URLs alone', () => {
46
+ expect(
47
+ toDrupalUrl('https://other.example.com/x', 'https://backend.example.com'),
48
+ ).toBe('https://other.example.com/x')
49
+ })
50
+ })
@@ -0,0 +1,40 @@
1
+ /**
2
+ * blökkli's mapped edit state, as far as this module cares.
3
+ */
4
+ interface TranslationStateLike {
5
+ translationState?: {
6
+ translations?: Array<{ editUrl?: string | null }>
7
+ } | null
8
+ }
9
+
10
+ /**
11
+ * Drops the Drupal edit URLs from the translations of blökkli's state.
12
+ *
13
+ * paragraphs_blokkli sends an `editUrl` per language (Drupal's
14
+ * content-translation add or edit form with `?destination`), and blökkli's
15
+ * edit-form feature loads that URL directly when an editor creates a
16
+ * translation. It is a relative URL without the `paragraphsBlokkli` marker,
17
+ * so the iframe shows the form with Drupal's whole navigation and toolbar, and
18
+ * on a split domain it first lands on the frontend, which hands it over to the
19
+ * backend. Without the URL blökkli asks the adapter's form frame builder
20
+ * ("entity:translate") instead, whose paragraphs_blokkli route redirects to
21
+ * the same form with the marker and on the backend domain: the way the page
22
+ * settings ("entity:edit") open.
23
+ */
24
+ export function withoutTranslationEditUrls<T extends TranslationStateLike>(
25
+ state: T,
26
+ ): T {
27
+ const translations = state.translationState?.translations
28
+ if (!translations?.length) {
29
+ return state
30
+ }
31
+ return {
32
+ ...state,
33
+ translationState: {
34
+ ...state.translationState,
35
+ translations: translations.map(
36
+ ({ editUrl: _editUrl, ...translation }) => translation,
37
+ ),
38
+ },
39
+ } as T
40
+ }
package/shared/csp.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Adds sources to the frame-ancestors directive of a Content-Security-Policy.
3
+ *
4
+ * A policy without the directive gets it appended, a policy that has one keeps
5
+ * its sources and gains the missing ones ('none' cannot be combined and is
6
+ * dropped); without any policy one is created. Several header values are
7
+ * merged into the first one that has the directive, or the first one.
8
+ */
9
+ export function mergeFrameAncestors(
10
+ policy: string | string[] | number | undefined | null,
11
+ sources: string[],
12
+ ): string[] {
13
+ const policies = (Array.isArray(policy) ? policy : [policy])
14
+ .filter((p): p is string => typeof p === 'string' && p.trim() !== '')
15
+ .map((p) => p.trim())
16
+ if (!policies.length) {
17
+ return [`frame-ancestors ${sources.join(' ')}`]
18
+ }
19
+ let merged = false
20
+ const result = policies.map((p) => {
21
+ const directives = p
22
+ .split(';')
23
+ .map((d) => d.trim())
24
+ .filter(Boolean)
25
+ .map((directive) => {
26
+ const match = directive.match(/^frame-ancestors(?:\s+(.*))?$/i)
27
+ if (!match) {
28
+ return directive
29
+ }
30
+ const existing = (match[1] || '')
31
+ .split(/\s+/)
32
+ .filter((s) => s && s.toLowerCase() !== "'none'")
33
+ for (const source of sources) {
34
+ if (!existing.includes(source)) {
35
+ existing.push(source)
36
+ }
37
+ }
38
+ merged = true
39
+ return `frame-ancestors ${existing.join(' ')}`
40
+ })
41
+ return directives.join('; ')
42
+ })
43
+ if (!merged) {
44
+ result[0] = `${result[0]}; frame-ancestors ${sources.join(' ')}`
45
+ }
46
+ return result
47
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The subdomain label of the Drupal backend, see docs/routing.md.
3
+ */
4
+ export const DRUPAL_SUBDOMAIN = 'backend'
5
+
6
+ /**
7
+ * The browser-facing origin of the Drupal backend for a frontend URL.
8
+ *
9
+ * The frontend serves the public site on a domain of its own and Drupal is
10
+ * reachable on the "backend." subdomain of that same domain, so the backend
11
+ * origin follows from the request: https://www.example.com →
12
+ * https://backend.www.example.com, with scheme and port kept.
13
+ */
14
+ export function drupalOrigin(frontendUrl: URL | Location): string {
15
+ return `${frontendUrl.protocol}//${DRUPAL_SUBDOMAIN}.${frontendUrl.host}`
16
+ }
17
+
18
+ /**
19
+ * Builds the browser-facing URL of a Drupal path.
20
+ *
21
+ * A Drupal path such as /de/user/login lives on the backend origin; without
22
+ * one the path is returned unchanged. Absolute URLs are never touched.
23
+ */
24
+ export function toDrupalUrl(path: string, drupalUrl?: string | null): string {
25
+ if (!drupalUrl || /^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
26
+ return path
27
+ }
28
+ const base = drupalUrl.replace(/\/+$/, '')
29
+ return path.startsWith('/') ? base + path : `${base}/${path}`
30
+ }
@@ -2,16 +2,11 @@ import type { Config } from 'tailwindcss'
2
2
 
3
3
  import plugin from 'tailwindcss/plugin'
4
4
 
5
- import { fileURLToPath } from 'url'
6
- import { dirname, join } from 'path'
7
- const currentDir = dirname(fileURLToPath(import.meta.url))
8
-
9
5
  const config: Config = {
10
6
  // Added as part of https://support-iqual.atlassian.net/browse/ICMS-312
11
7
  safelist: [{ pattern: /bg-gray-\d+/ }],
12
8
 
13
9
  plugins: [
14
-
15
10
  /**
16
11
  * Various additional variants
17
12
  */
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A single breadcrumb link, matching the `breadcrumb` GraphQL fragment in
3
+ * `components/Drupal/Breadcrumb/fragment.breadcrumb.graphql`.
4
+ *
5
+ * The fragment is not part of any operation in the base layer, so codegen does
6
+ * not emit a `BreadcrumbFragment` type for it. Layers that add a `breadcrumb`
7
+ * field to their route query produce the same shape.
8
+ */
9
+ export type BreadcrumbLink = {
10
+ title: string
11
+ url?: {
12
+ path?: string
13
+ }
14
+ }
15
+
16
+ /**
17
+ * The part of a route query the breadcrumb is read from. Only layers that
18
+ * select the field have it, hence the optional, widened shape.
19
+ */
20
+ export type RouteQueryWithBreadcrumb = {
21
+ route?: { breadcrumb?: (BreadcrumbLink | null)[] } | object | null
22
+ }
package/types/global.ts CHANGED
@@ -2,23 +2,24 @@ import type { RuntimeBlockOptions } from '#blokkli-build/runtime-options'
2
2
 
3
3
  export type ImageFormat = 'landscape' | 'portrait' | 'auto'
4
4
 
5
- export type ButtonStyle = RuntimeBlockOptions['icms_button_element']['buttonStyle']
5
+ export type ButtonStyle =
6
+ RuntimeBlockOptions['icms_button_element']['buttonStyle']
6
7
  export type ButtonColor =
7
- | 'error'
8
- | 'primary'
9
- | 'secondary'
10
- | 'success'
11
- | 'info'
12
- | 'warning'
13
- | 'neutral'
14
- export type ButtonVariant = 'solid' | 'outline' | 'soft' | 'subtle' | 'ghost' | 'link'
8
+ 'error' | 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'neutral'
9
+ export type ButtonVariant =
10
+ 'solid' | 'outline' | 'soft' | 'subtle' | 'ghost' | 'link'
15
11
 
16
- export type CardVariant = RuntimeBlockOptions['icms_layout_cards']['cardVariant'] | undefined
17
- export type CollapsibleVariant = RuntimeBlockOptions['icms_layout_collapsible']['collapsibleVariant'] | undefined
18
- export type CallToActionVariant = RuntimeBlockOptions['icms_layout_call_to_action']['callToActionVariant']
19
- export type TeaserVariant = RuntimeBlockOptions['icms_layout_teasers']['teaserVariant']
12
+ export type CardVariant =
13
+ RuntimeBlockOptions['icms_layout_cards']['cardVariant'] | undefined
14
+ export type CollapsibleVariant =
15
+ | RuntimeBlockOptions['icms_layout_collapsible']['collapsibleVariant']
16
+ | undefined
17
+ export type CallToActionVariant =
18
+ RuntimeBlockOptions['icms_layout_call_to_action']['callToActionVariant']
19
+ export type TeaserVariant =
20
+ RuntimeBlockOptions['icms_layout_teasers']['teaserVariant']
20
21
 
21
22
  export type ColorScheme = 'none' | 'dark' | 'light' | 'accent' | undefined
22
23
  export type TeaserStyle = 'none' | 'highlight'
23
24
  export type AlignHorizontalFull = 'left' | 'center' | 'right' | undefined
24
- export type AlignHorizontalReduced = 'left' | 'right' | undefined
25
+ export type AlignHorizontalReduced = 'left' | 'right' | undefined
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The `Langcode` enum of the Drupal GraphQL schema.
3
+ *
4
+ * Codegen only emits schema enums that an operation actually references, so
5
+ * `#graphql-operations` does not export `Langcode` unless a query declares a
6
+ * variable of that type. The shape below mirrors what codegen would generate,
7
+ * so the values stay assignable to the generated type where it does exist.
8
+ */
9
+ export const Langcode = Object.freeze({
10
+ DE: 'DE',
11
+ EN: 'EN',
12
+ FR: 'FR',
13
+ IT: 'IT',
14
+ } as const)
15
+
16
+ export type Langcode = (typeof Langcode)[keyof typeof Langcode]