@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
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @iqual/nuxt-icms
2
+
3
+ The Nuxt layer of ICMS: design system, blökkli components and the GraphQL
4
+ integration with the Drupal backend. Client projects get it through
5
+ `@iqual/create-nuxt-icms`.
6
+
7
+ ## Page cache
8
+
9
+ Anonymous HTML is cached in the memory of the Nuxt process (nuxt-multi-cache's
10
+ route cache) and purged by Drupal cache tags through the `nuxt_multi_cache`
11
+ purger, with a 5 s batching delay. Code: `cache/`, `utils/pageCache.ts`,
12
+ `modules/pageCache.ts`, `server/plugins/pageCache.ts`.
13
+
14
+ - **Stored:** pages that opt in with `usePageCache()` (the catch-all page and
15
+ the homepage do), rendered with status 200, from cacheable GraphQL responses
16
+ only, without setting a cookie. Requests with a Drupal session
17
+ (`SSESS*`/`SESS*`) and blökkli editor/preview requests bypass it.
18
+ - **Never stored:** redirects, 4xx and 5xx. There is no grace: a purged page is
19
+ re-rendered on the next request, or fails if Drupal is down.
20
+ - **Key:** scheme, host, path and the sorted query, without cookies and
21
+ analytics/click IDs (`utm_*`, `gclid`, `fbclid`, …).
22
+ - **Budget:** LRU by bytes, pages stored gzipped. `routeMaxSize: 'auto'` uses 3%
23
+ of the container memory (8–64 MiB; 24 MiB if unknown; off in `nuxi dev`,
24
+ `NUXT_PAGE_CACHE_ROUTE_MAX_SIZE=auto` turns it on). The data cache has 8 MiB.
25
+ Under heavy churn the process grows by several times the budget (8 MiB: +40–50
26
+ MB, 24 MiB: +60–90 MB RSS).
27
+ - **Headers:** cacheable anonymous responses get
28
+ `Cache-Control: public, max-age=300` without `s-maxage`/`Cache-Tag`; sessions
29
+ and 5xx get `private, no-store`. `X-Icms-Page-Cache: HIT|MISS` shows the
30
+ outcome.
31
+ - **`varnish` mode:** behind Varnish (`routing.cache: varnish`) the responses
32
+ keep `s-maxage` and `Cache-Tag` and the route cache is off.
33
+ `NUXT_PAGE_CACHE_MODE=varnish` is set by `frontend/.environment`; an
34
+ explicitly set variable wins.
35
+
36
+ Configure it in `frontend/nuxt.config.ts` or with the environment variables:
37
+
38
+ ```ts
39
+ runtimeConfig: {
40
+ pageCache: {
41
+ mode: 'varnish', // NUXT_PAGE_CACHE_MODE
42
+ browserMaxAge: 300, // NUXT_PAGE_CACHE_BROWSER_MAX_AGE
43
+ routeMaxSize: 32 * 1024 * 1024, // NUXT_PAGE_CACHE_ROUTE_MAX_SIZE, 'auto', 0 = off
44
+ routeCompression: true, // NUXT_PAGE_CACHE_ROUTE_COMPRESSION
45
+ routeMaxAge: 604_800, // NUXT_PAGE_CACHE_ROUTE_MAX_AGE (7 days)
46
+ dataMaxSize: 8 * 1024 * 1024, // NUXT_PAGE_CACHE_DATA_MAX_SIZE
47
+ },
48
+ },
49
+ ```
50
+
51
+ Projects need no `server/multiCache.serverOptions.ts`. One that has its own file
52
+ must extend the ICMS options, or the page cache stays off (the server logs
53
+ `[icms] Page cache disabled`):
54
+
55
+ ```ts
56
+ // frontend/server/multiCache.serverOptions.ts
57
+ import defineIcmsOptions from '@iqual/nuxt-icms/cache/multiCache.serverOptions'
58
+ import { defineMultiCacheOptions } from 'nuxt-multi-cache/server-options'
59
+
60
+ export default defineMultiCacheOptions(() => {
61
+ const options = defineIcmsOptions()
62
+ return { ...options /* , project overrides */ }
63
+ })
64
+ ```
65
+
66
+ ## Webform spam protection (Turnstile)
67
+
68
+ Webforms are submitted through GraphQL, which bypasses Drupal's Form API, so
69
+ Drupal spam modules (`honeypot`, `captcha`, `antibot`, Webform's honeypot) never
70
+ run on them. Use Cloudflare Turnstile instead: the widget renders above the
71
+ submit button, and a Nitro middleware verifies its token before the
72
+ `submitWebform` mutation reaches Drupal.
73
+
74
+ It is off until both keys are set on the **frontend** application (Drupal needs
75
+ neither):
76
+
77
+ ```
78
+ NUXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAA… # public
79
+ NUXT_TURNSTILE_SECRET_KEY=0x4AAAAAAA… # sensitive
80
+ ```
81
+
82
+ Cloudflare's test keys for staging: `1x00000000000000000000AA` /
83
+ `1x0000000000000000000000000000000AA` always pass, `2x00000000000000000000AB` /
84
+ `2x0000000000000000000000000000000AA` always fail. The rejection message is the
85
+ text `webform.spam_protection_error`; the widget is themed via `turnstileClass`
86
+ of `DrupalWebform`.
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { anchorId, resolveAnchorId } from '../composables/useAnchors'
3
+
4
+ /**
5
+ * The anchor block and the anchor navigation derive the same id from the same
6
+ * fields — one on the block that carries the id, one on the links pointing at
7
+ * it. A drift between the two leaves links pointing nowhere, so the
8
+ * derivation is pinned here.
9
+ */
10
+ describe('anchorId', () => {
11
+ it('lowercases, strips accents and special characters, dashes the spaces', () => {
12
+ expect(anchorId('Öffnungszeiten & Kontakt')).toBe('offnungszeiten-kontakt')
13
+ expect(anchorId(' Über uns ')).toBe('uber-uns')
14
+ expect(anchorId('FAQ (2026)')).toBe('faq-2026')
15
+ })
16
+
17
+ it('is empty when nothing usable is left', () => {
18
+ expect(anchorId('')).toBe('')
19
+ expect(anchorId('***')).toBe('')
20
+ })
21
+ })
22
+
23
+ describe('resolveAnchorId', () => {
24
+ it('generates the id from the label when the anchor is empty', () => {
25
+ expect(resolveAnchorId('Unsere Standorte', null)).toBe('unsere-standorte')
26
+ expect(resolveAnchorId('Unsere Standorte', ' ')).toBe('unsere-standorte')
27
+ })
28
+
29
+ it('prefers the anchor field and keeps its casing', () => {
30
+ expect(resolveAnchorId('Unsere Standorte', 'standorte')).toBe('standorte')
31
+ expect(resolveAnchorId('Unsere Standorte', 'Standorte')).toBe('Standorte')
32
+ })
33
+
34
+ it('still strips what is not valid in a fragment from the anchor field', () => {
35
+ expect(resolveAnchorId('Unsere Standorte', '#Standorte')).toBe('Standorte')
36
+ expect(resolveAnchorId('Kontakt', 'Büro Zürich')).toBe('Buro-Zurich')
37
+ })
38
+
39
+ it('handles missing values', () => {
40
+ expect(resolveAnchorId(undefined, undefined)).toBe('')
41
+ expect(resolveAnchorId(null, 'kontakt')).toBe('kontakt')
42
+ })
43
+ })
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ blokkliBundles,
4
+ hasSchema,
5
+ pascalCase,
6
+ readSchema,
7
+ resolveProject,
8
+ schemaTypeNames,
9
+ } from './project'
10
+
11
+ /**
12
+ * Guards the contract between the blökkli components and the Drupal schema.
13
+ *
14
+ * Every `defineBlokkli({ bundle })` component — in the base layer, the
15
+ * installed bundle layers and the project's own layers — renders a paragraph
16
+ * type; the editor lists it and the frontend resolves it through the
17
+ * `Paragraph<Bundle>` GraphQL type. A component whose paragraph type no longer
18
+ * exists in the schema is a dead block (editor error, unresolvable fragment);
19
+ * a layout paragraph type without a component renders nothing. Both sides are
20
+ * discovered, so project blocks and bundles are covered wherever they are
21
+ * installed.
22
+ */
23
+ describe.skipIf(!hasSchema())(
24
+ 'blökkli components and the GraphQL schema',
25
+ () => {
26
+ it('every blökkli component maps to a paragraph type in the schema', async () => {
27
+ const { layerDirs } = await resolveProject()
28
+ const types = schemaTypeNames(readSchema())
29
+ const bundles = blokkliBundles(layerDirs)
30
+ expect(bundles.size).toBeGreaterThan(0)
31
+
32
+ const missing = [...bundles]
33
+ .filter(([bundle]) => !types.has(`Paragraph${pascalCase(bundle)}`))
34
+ .map(([bundle, file]) => `${bundle} (${file})`)
35
+ expect(
36
+ missing,
37
+ 'components without a paragraph type in schema.graphql',
38
+ ).toEqual([])
39
+ })
40
+
41
+ it('every ICMS layout paragraph type in the schema has a blökkli component', async () => {
42
+ const { layerDirs } = await resolveProject()
43
+ const bundles = blokkliBundles(layerDirs)
44
+ const componentTypes = new Set(
45
+ [...bundles.keys()].map((bundle) => `Paragraph${pascalCase(bundle)}`),
46
+ )
47
+ // Layout paragraphs are always top-level blocks; element paragraphs may
48
+ // be nested-only or data-only (e.g. event occurrences), so only layouts
49
+ // are required to have a component.
50
+ const layoutTypes = [...schemaTypeNames(readSchema())].filter((type) =>
51
+ /^ParagraphIcmsLayout[A-Z0-9]/.test(type),
52
+ )
53
+ expect(layoutTypes.length).toBeGreaterThan(0)
54
+
55
+ const missing = layoutTypes.filter((type) => !componentTypes.has(type))
56
+ expect(missing, 'layout paragraph types without a component').toEqual([])
57
+ })
58
+ },
59
+ )
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { definedFragments, resolveProject } from './project'
3
+
4
+ /**
5
+ * Guards the fragment composition the dynamic paragraph resolver relies on.
6
+ *
7
+ * Every layer registers the fragments of its blökkli components in
8
+ * `icms.components.fragments` (and `wrapperFragments` for paragraphs that
9
+ * embed another one); `dynamicParagraphResolver` spreads them into the
10
+ * generated `paragraph` fragment. A registered name without a definition
11
+ * — a renamed fragment file, a bundle layer installed without its documents,
12
+ * a project layer that forgot the file — only fails at `nuxt prepare`, with a
13
+ * generic GraphQL error far from its cause.
14
+ */
15
+ describe('icms.components.fragments', () => {
16
+ it('every registered fragment name is defined in a GraphQL document', async () => {
17
+ const { layerDirs, componentFragments } = await resolveProject()
18
+ expect(componentFragments.length).toBeGreaterThan(0)
19
+
20
+ const defined = definedFragments(layerDirs)
21
+ const missing = componentFragments.filter((name) => !defined.has(name))
22
+ expect(missing, 'registered fragments without a definition').toEqual([])
23
+ })
24
+ })
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { describe, it, expect } from 'vitest'
4
+ import {
5
+ hasSchema,
6
+ projectRoot,
7
+ readSchema,
8
+ resolveProject,
9
+ schemaEnumValues,
10
+ } from './project'
11
+
12
+ /**
13
+ * Guards the language contract between the frontend, Drupal and the routing.
14
+ *
15
+ * The frontend serves exactly `languageNegotiation.languages`, asking Drupal
16
+ * for each via `/{lang}/graphql`; Drupal exposes its languages as the
17
+ * `Langcode` enum; the nginx/VCL split (scaffolded from `routing.languages`
18
+ * in drupal/composer.json) decides which prefixes reach the frontend at all.
19
+ * A frontend language Drupal doesn't know renders empty; one the routing
20
+ * doesn't know is unreachable. Both regress silently on config changes.
21
+ */
22
+ describe.skipIf(!hasSchema())('site languages', () => {
23
+ it('every frontend language exists in Drupal (Langcode enum)', async () => {
24
+ const { languages } = await resolveProject()
25
+ expect(languages.length).toBeGreaterThan(0)
26
+
27
+ const drupal = schemaEnumValues(readSchema(), 'Langcode')
28
+ expect(drupal.length).toBeGreaterThan(0)
29
+
30
+ const unknown = languages.filter(
31
+ (lang) => !drupal.includes(lang.toUpperCase()),
32
+ )
33
+ expect(unknown, 'frontend languages missing in the Drupal schema').toEqual(
34
+ [],
35
+ )
36
+ })
37
+
38
+ it('every frontend language is routed', async () => {
39
+ const composerPath = resolve(projectRoot, '../drupal/composer.json')
40
+ const routing = existsSync(composerPath)
41
+ ? (
42
+ JSON.parse(readFileSync(composerPath, 'utf8')) as {
43
+ extra?: {
44
+ 'project-scaffold'?: { routing?: { languages?: string[] } }
45
+ }
46
+ }
47
+ ).extra?.['project-scaffold']?.routing?.languages
48
+ : undefined
49
+ if (!routing?.length) {
50
+ // Not scaffolded (yet): the routing falls back to the platform default.
51
+ return
52
+ }
53
+
54
+ const { languages } = await resolveProject()
55
+ // The first routing entry is the fallback target of the root redirect,
56
+ // not necessarily the frontend's default language — only membership is
57
+ // the contract.
58
+ const unrouted = languages.filter((lang) => !routing.includes(lang))
59
+ expect(unrouted, 'frontend languages missing in routing.languages').toEqual(
60
+ [],
61
+ )
62
+ })
63
+ })
@@ -0,0 +1,144 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { loadNuxtConfig } from '@nuxt/kit'
4
+
5
+ /**
6
+ * Reads the project the specs run in — the monorepo's frontend or a client's
7
+ * — from the working directory: its committed schema.graphql, the resolved
8
+ * Nuxt config with every layer (base, bundles, project layers) and the files
9
+ * of all of them. Nothing here assumes the starterkit's set of languages,
10
+ * bundles or components; the specs compare what the project declares with
11
+ * what its Drupal schema exposes.
12
+ */
13
+
14
+ export const projectRoot = process.cwd()
15
+ export const schemaPath = resolve(projectRoot, 'schema.graphql')
16
+
17
+ /** Skip guard: the committed schema is the contract these specs read. */
18
+ export const hasSchema = () => existsSync(schemaPath)
19
+
20
+ export function readSchema(): string {
21
+ return readFileSync(schemaPath, 'utf8')
22
+ }
23
+
24
+ /** All object type names of the schema (`type Foo ...`). */
25
+ export function schemaTypeNames(schema: string): Set<string> {
26
+ return new Set(
27
+ [...schema.matchAll(/^type\s+([A-Za-z0-9_]+)/gm)].map((m) => m[1]!),
28
+ )
29
+ }
30
+
31
+ /** The values of a schema enum, e.g. `Langcode` → `['DE', 'EN']`. */
32
+ export function schemaEnumValues(schema: string, name: string): string[] {
33
+ const match = schema.match(
34
+ new RegExp(`^enum\\s+${name}\\s*\\{([^}]*)\\}`, 'm'),
35
+ )
36
+ if (!match) return []
37
+ return match[1]!
38
+ .split('\n')
39
+ .map((line) => line.trim())
40
+ .filter((line) => /^[A-Z][A-Z0-9_]*$/.test(line))
41
+ }
42
+
43
+ /**
44
+ * `icms_layout_text` → `IcmsLayoutText`, the way graphql_core_schema names
45
+ * the GraphQL type of a bundle (`ParagraphIcmsLayoutText`).
46
+ */
47
+ export function pascalCase(machineName: string): string {
48
+ return machineName
49
+ .split('_')
50
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
51
+ .join('')
52
+ }
53
+
54
+ export interface ResolvedProject {
55
+ /** Layer directories, base layer first, the project's own last. */
56
+ layerDirs: string[]
57
+ languages: string[]
58
+ componentFragments: string[]
59
+ }
60
+
61
+ let resolved: Promise<ResolvedProject> | undefined
62
+
63
+ /** Resolves the Nuxt config (all layers merged) once per run. */
64
+ export function resolveProject(): Promise<ResolvedProject> {
65
+ resolved ??= loadNuxtConfig({ cwd: projectRoot }).then((config) => {
66
+ const layerDirs = [...(config._layers ?? [])]
67
+ .map((layer) => layer.cwd)
68
+ .filter((dir): dir is string => typeof dir === 'string')
69
+ .reverse()
70
+ const icms = (
71
+ config as {
72
+ icms?: {
73
+ components?: { fragments?: string[]; wrapperFragments?: string[] }
74
+ }
75
+ }
76
+ ).icms
77
+ return {
78
+ layerDirs,
79
+ languages: [
80
+ ...((config.languageNegotiation as { languages?: string[] })
81
+ ?.languages ?? []),
82
+ ],
83
+ componentFragments: [
84
+ ...new Set([
85
+ ...(icms?.components?.fragments ?? []),
86
+ ...(icms?.components?.wrapperFragments ?? []),
87
+ ]),
88
+ ],
89
+ }
90
+ })
91
+ return resolved
92
+ }
93
+
94
+ /** Recursively lists files under a directory (skipping node_modules). */
95
+ export function listFiles(dir: string, extension: string): string[] {
96
+ if (!existsSync(dir)) return []
97
+ const files: string[] = []
98
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
99
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
100
+ const path = join(dir, entry.name)
101
+ if (entry.isDirectory()) files.push(...listFiles(path, extension))
102
+ else if (entry.name.endsWith(extension)) files.push(path)
103
+ }
104
+ return files
105
+ }
106
+
107
+ /**
108
+ * Directories a layer keeps its source in: the layer root and, for the Nuxt 4
109
+ * app structure, its `app/` directory.
110
+ */
111
+ export function layerSourceDirs(layerDir: string): string[] {
112
+ return [layerDir, join(layerDir, 'app')].filter((dir) => existsSync(dir))
113
+ }
114
+
115
+ /** The paragraph bundles of all `defineBlokkli({ bundle })` components. */
116
+ export function blokkliBundles(layerDirs: string[]): Map<string, string> {
117
+ const bundles = new Map<string, string>()
118
+ for (const layerDir of layerDirs) {
119
+ for (const sourceDir of layerSourceDirs(layerDir)) {
120
+ for (const file of listFiles(join(sourceDir, 'components'), '.vue')) {
121
+ const match = readFileSync(file, 'utf8').match(
122
+ /defineBlokkli\(\s*\{[\s\S]*?\bbundle:\s*['"]([a-z0-9_]+)['"]/,
123
+ )
124
+ if (match) bundles.set(match[1]!, file)
125
+ }
126
+ }
127
+ }
128
+ return bundles
129
+ }
130
+
131
+ /** Names of all fragments defined in the project's and layers' documents. */
132
+ export function definedFragments(layerDirs: string[]): Set<string> {
133
+ const names = new Set<string>()
134
+ for (const dir of new Set([projectRoot, ...layerDirs])) {
135
+ for (const file of listFiles(dir, '.graphql')) {
136
+ for (const match of readFileSync(file, 'utf8').matchAll(
137
+ /^\s*fragment\s+([A-Za-z0-9_]+)\s+on\b/gm,
138
+ )) {
139
+ names.add(match[1]!)
140
+ }
141
+ }
142
+ }
143
+ return names
144
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildHreflangLinks } from '../composables/useHreflangLinks'
3
+
4
+ const ORIGIN = 'https://www.example.com'
5
+
6
+ describe('buildHreflangLinks', () => {
7
+ it('lists every translation, including the current one', () => {
8
+ expect(
9
+ buildHreflangLinks(
10
+ [
11
+ { code: 'de', path: '/de/ueber-uns' },
12
+ { code: 'fr', path: '/fr/a-propos' },
13
+ ],
14
+ ORIGIN,
15
+ ),
16
+ ).toEqual([
17
+ { rel: 'alternate', hreflang: 'de', href: `${ORIGIN}/de/ueber-uns` },
18
+ { rel: 'alternate', hreflang: 'fr', href: `${ORIGIN}/fr/a-propos` },
19
+ ])
20
+ })
21
+
22
+ it('emits nothing for a page that exists in one language only', () => {
23
+ expect(
24
+ buildHreflangLinks([{ code: 'de', path: '/de/ueber-uns' }], ORIGIN),
25
+ ).toEqual([])
26
+ })
27
+
28
+ it('emits nothing without a known origin, rather than relative hrefs', () => {
29
+ expect(
30
+ buildHreflangLinks(
31
+ [
32
+ { code: 'de', path: '/de/ueber-uns' },
33
+ { code: 'fr', path: '/fr/a-propos' },
34
+ ],
35
+ '',
36
+ ),
37
+ ).toEqual([])
38
+ })
39
+ })
@@ -8,6 +8,7 @@ import { parse as parseSfc } from '@vue/compiler-sfc'
8
8
  async function findVueFiles(
9
9
  dir: string,
10
10
  ignorePatterns: string[] = [],
11
+ rootDir: string = dir,
11
12
  ): Promise<string[]> {
12
13
  const entries = await fsp.readdir(dir, { withFileTypes: true })
13
14
  const files: string[] = []
@@ -15,13 +16,16 @@ async function findVueFiles(
15
16
  for (const entry of entries) {
16
17
  const fullPath = path.join(dir, entry.name)
17
18
 
18
- // Skip if path matches any ignore pattern
19
- if (ignorePatterns.some((pattern) => fullPath.includes(pattern))) {
19
+ // Skip if the path below the root matches any ignore pattern. Matching the
20
+ // absolute path would also match the checkout directory, which on CI is
21
+ // named after the repository — and that name contains "blokkli".
22
+ const relativePath = path.relative(rootDir, fullPath)
23
+ if (ignorePatterns.some((pattern) => relativePath.includes(pattern))) {
20
24
  continue
21
25
  }
22
26
 
23
27
  if (entry.isDirectory()) {
24
- const subFiles = await findVueFiles(fullPath, ignorePatterns)
28
+ const subFiles = await findVueFiles(fullPath, ignorePatterns, rootDir)
25
29
  files.push(...subFiles)
26
30
  } else if (entry.name.endsWith('.vue')) {
27
31
  files.push(fullPath)
@@ -69,11 +73,10 @@ function functionIsCalledAtLeastOnce(
69
73
  }
70
74
 
71
75
  describe('The Nuxt page component', async () => {
72
- const rootDir = path.resolve(__dirname, './../app/pages')
76
+ const rootDir = path.resolve(__dirname, './../pages')
73
77
  const ignorePatterns = ['blokkli']
74
78
 
75
79
  const pageFilePaths = await findVueFiles(rootDir, ignorePatterns)
76
- console.log(pageFilePaths)
77
80
 
78
81
  const pageComponentFiles = await Promise.all(
79
82
  pageFilePaths.map((filePath) => {
@@ -86,6 +89,12 @@ describe('The Nuxt page component', async () => {
86
89
  }),
87
90
  )
88
91
 
92
+ // Without this the suite would silently pass as empty if the discovery ever
93
+ // stops finding the page components.
94
+ it('is discovered by the test suite', () => {
95
+ expect(pageComponentFiles.length).toBeGreaterThan(0)
96
+ })
97
+
89
98
  pageComponentFiles.forEach(({ filePath, content }) => {
90
99
  it(`"${filePath}" should call useDrupalRouteQuery()`, () => {
91
100
  expect(
package/app.config.ts CHANGED
@@ -1,2 +1,17 @@
1
1
  export default defineAppConfig({
2
+ ui: {
3
+ button: {
4
+ // The design system styles buttons entirely through the `ui.base` slot,
5
+ // so it opts out of @nuxt/ui's own colour and variant classes by
6
+ // selecting these empty ones.
7
+ variants: {
8
+ color: {
9
+ none: '',
10
+ },
11
+ variant: {
12
+ none: '',
13
+ },
14
+ },
15
+ },
16
+ },
2
17
  })
package/app.vue CHANGED
@@ -1,19 +1,7 @@
1
1
  <template>
2
2
  <UApp>
3
+ <NuxtLoadingIndicator color="var(--primary-500)" :height="3" />
3
4
  <div class="bk-main-canvas relative">
4
- <ClientOnly>
5
- <div
6
- v-if="drupalUser.accessToolbar && !isEditing"
7
- class="hidden border-b border-b-gray-100 bg-white 2xl:block"
8
- >
9
- <VuepalAdminToolbar :key="language" />
10
- <div class="flex">
11
- <div class="mx-auto w-auto bg-white py-8 xl:min-w-[1174px]">
12
- <VuepalLocalTasks />
13
- </div>
14
- </div>
15
- </div>
16
- </ClientOnly>
17
5
  <NuxtLayout>
18
6
  <NuxtPage />
19
7
  </NuxtLayout>
@@ -22,16 +10,10 @@
22
10
  </template>
23
11
 
24
12
  <script setup lang="ts">
25
- const route = useRoute()
26
- const drupalUser = useDrupalUser()
13
+ // The admin UI (toolbar, local tasks) is Drupal's: editors browse the
14
+ // frontend inside the backend's site shell (see docs/routing.md).
27
15
  const language = useCurrentLanguage()
28
16
 
29
- const isEditing = computed(
30
- () =>
31
- !!(route.query.blokkliEditing || route.query.blokkliPreview) &&
32
- drupalUser.value.accessToolbar,
33
- )
34
-
35
17
  useHead({
36
18
  htmlAttrs: {
37
19
  lang: language.value,
@@ -9,6 +9,7 @@
9
9
  text-transform: uppercase;
10
10
  list-style-type: none !important;
11
11
  margin: 0 !important;
12
+
12
13
  a,
13
14
  div {
14
15
  display: block;
@@ -26,10 +27,12 @@
26
27
  color: #aaa;
27
28
  font-weight: 500;
28
29
  }
30
+
29
31
  &.is-source {
30
32
  border-color: #a7b3ce;
31
33
  }
32
34
  }
35
+
33
36
  a {
34
37
  &:hover {
35
38
  background: #dbe1ea;
@@ -46,9 +49,14 @@
46
49
  position: relative;
47
50
  display: inline-block;
48
51
  margin-right: 0.25em;
52
+
49
53
  &.is-published {
50
54
  background: var(--gin-color-green);
51
55
  }
56
+
57
+ &.is-draft {
58
+ background: var(--gin-color-warning);
59
+ }
52
60
  }
53
61
 
54
62
  table.permissions thead tr {
@@ -80,3 +88,51 @@ table.permissions thead tr {
80
88
  bottom: 0;
81
89
  z-index: 20000;
82
90
  }
91
+
92
+ .meta-sidebar__trigger {
93
+ &.trigger {
94
+ cursor: pointer;
95
+ background: transparent;
96
+ display: block;
97
+ height: 32px;
98
+ width: 32px;
99
+ margin-inline: calc(var(--gin-spacing-xxs) * -1);
100
+ border-radius: var(--gin-border-m);
101
+
102
+ &:hover {
103
+ background-color: var(--gin-color-primary-light);
104
+
105
+ &::before {
106
+ background-color: var(--gin-color-primary);
107
+ }
108
+ }
109
+
110
+ &::before {
111
+ display: inline-block;
112
+ width: 100%;
113
+ height: 100%;
114
+ content: '';
115
+ -webkit-mask-repeat: no-repeat;
116
+ mask-repeat: no-repeat;
117
+ -webkit-mask-position: center center;
118
+ mask-position: center center;
119
+ background-color: var(--gin-icon-color);
120
+ -webkit-mask-image: url('/themes/contrib/gin/dist/media/sprite.svg#sidebar-view');
121
+ mask-image: url('/themes/contrib/gin/dist/media/sprite.svg#sidebar-view');
122
+ -webkit-mask-size: var(--gin-icon-size-toolbar-secondary)
123
+ var(--gin-icon-size-toolbar-secondary);
124
+ mask-size: var(--gin-icon-size-toolbar-secondary)
125
+ var(--gin-icon-size-toolbar-secondary);
126
+ border-radius: 50%;
127
+ }
128
+
129
+ &.is-active,
130
+ &.is-active:hover {
131
+ background-color: var(--gin-bg-item-hover);
132
+
133
+ &::before {
134
+ background-color: var(--gin-color-primary);
135
+ }
136
+ }
137
+ }
138
+ }
@@ -1,3 +1,3 @@
1
1
  html {
2
- @apply scroll-smooth hyphens-auto bg-white font-sans text-black;
3
- }
2
+ @apply scroll-smooth bg-white font-sans hyphens-auto text-black;
3
+ }
@@ -10,7 +10,7 @@ h5,
10
10
  .h5,
11
11
  h6,
12
12
  .h6 {
13
- @apply hyphens-auto font-bold;
13
+ @apply font-bold hyphens-auto;
14
14
  }
15
15
 
16
16
  h1,
@@ -41,4 +41,4 @@ h5,
41
41
  h6,
42
42
  .h6 {
43
43
  @apply mb-2 text-base lg:text-lg;
44
- }
44
+ }