├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .nuxtrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bin └── nuxt-drupal-ce-init.cjs ├── eslint.config.mjs ├── nuxt.config.ts ├── package-lock.json ├── package.json ├── playground ├── app.vue ├── components │ ├── Drupal │ │ ├── DrupalTabs.vue │ │ └── DrupalViewsPagination.vue │ ├── Navigation │ │ ├── NavigationAccount.vue │ │ └── NavigationMain.vue │ ├── Site │ │ ├── SiteBreadcrumbs.vue │ │ ├── SiteLanguageSwitcher.vue │ │ ├── SiteMessage.vue │ │ └── SiteMessages.vue │ └── global │ │ ├── drupal-form--default.vue │ │ ├── drupal-layout.vue │ │ ├── drupal-markup.vue │ │ ├── drupal-view--default.vue │ │ ├── field--default.vue │ │ └── node--default.vue ├── layouts │ ├── clear.vue │ └── default.vue ├── nuxt.config.ts ├── nuxt.config4test.ts ├── package.json ├── pages │ ├── [...slug].vue │ ├── [entity] │ │ └── [id] │ │ │ └── layout-preview.client.vue │ └── node │ │ └── preview │ │ └── [...slug].client.vue └── server │ └── routes │ └── ce-api │ ├── [...].ts │ ├── api │ └── menu_items │ │ ├── account.ts │ │ └── main.ts │ ├── de │ ├── api │ │ └── menu_items │ │ │ ├── account.ts │ │ │ └── main.ts │ ├── error404.ts │ ├── error500.ts │ ├── index.ts │ ├── node │ │ ├── 1.ts │ │ ├── 3.ts │ │ ├── 4.ts │ │ └── 404.ts │ └── redirect.ts │ ├── error404.ts │ ├── error500.ts │ ├── form │ ├── custom.get.ts │ ├── custom.post.ts │ ├── custom2.get.ts │ └── custom2.post.ts │ ├── index.ts │ ├── node │ ├── 5 │ │ └── layout-preview.ts │ ├── 1.ts │ ├── 3.ts │ ├── 4.ts │ ├── 404.ts │ ├── 5.ts │ ├── layout-builder.ts │ └── preview │ │ └── node.ts │ ├── redirect.ts │ └── user │ ├── login.get.ts │ └── login.post.ts ├── renovate.json ├── src ├── module.ts ├── runtime │ ├── composables │ │ └── useDrupalCe │ │ │ ├── index.ts │ │ │ └── server.ts │ └── server │ │ ├── api │ │ ├── drupalCe.ts │ │ └── menu.ts │ │ ├── middleware │ │ └── drupalFormHandler.ts │ │ └── plugins │ │ └── errorLogger.ts └── types.d.ts ├── test ├── addRequestContentFormat │ ├── json.test.ts │ ├── markup.test.ts │ └── unset.test.ts ├── addRequestFormat │ ├── set.test.ts │ └── unset.test.ts ├── basic.test.ts ├── basicWithoutProxy.test.ts ├── drupalFormHandler.test.ts ├── errorhandling.test.ts ├── fixtures │ └── debug │ │ ├── app.vue │ │ ├── components │ │ └── global │ │ │ └── node-article-demo.vue │ │ ├── pages │ │ ├── [...slug].vue │ │ └── node │ │ │ └── 4.vue │ │ └── server │ │ └── routes │ │ └── ce-api │ │ ├── index.ts │ │ └── node │ │ └── 4.ts ├── i18n.test.ts ├── i18nWithoutProxy.test.ts ├── layout.test.ts ├── redirect.test.ts ├── unit │ ├── ceApiEndpoint.test.ts │ ├── components │ │ ├── drupal-form--default.test.ts │ │ ├── drupal-markup.test.ts │ │ ├── field--default.test.ts │ │ ├── node--default.test.ts │ │ └── preview.test.ts │ ├── fetchPageAndMenu.test.ts │ ├── renderCustomElements-defaults.test.ts │ └── renderCustomElements.test.ts └── userLoginForm.test.ts ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 2.x 7 | push: 8 | branches: 9 | - 2.x 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [22.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: "npm" 26 | - run: npm ci 27 | - run: npx playwright install --with-deps 28 | - run: npm run dev:prepare 29 | - run: npm run test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .vercel_build_output 23 | .build-* 24 | .env 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | -------------------------------------------------------------------------------- /.nuxtrc: -------------------------------------------------------------------------------- 1 | typescript.includeWorkspace=true 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "eslint.useFlatConfig": true, 4 | "volar.validation.template": false, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "always", 7 | "source.fixAll.vetur": "always", 8 | "source.fixAll.vscode": "always" 9 | }, 10 | "css.lint.unknownAtRules": "ignore", 11 | "eslint.validate": [ 12 | "javascript", 13 | "typescript", 14 | "vue", 15 | "vue-html" 16 | ], 17 | "prettier.enable": false 18 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Wolfgang Ziegler // fago 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nuxtjs-drupal-ce - Nuxt Drupal Custom Elements Connector 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![ci](https://github.com/drunomics/nuxtjs-drupal-ce/actions/workflows/ci.yml/badge.svg?branch=2.x)](https://github.com/drunomics/nuxtjs-drupal-ce/actions/workflows/ci.yml) 6 | [![codecov][codecov-src]][codecov-href] 7 | [![License][license-src]][license-href] 8 | 9 | > Connects Nuxt v3 with Drupal via the [Lupus Custom Elements Renderer](https://www.drupal.org/project/lupus_ce_renderer) 10 | 11 | Please refer to https://www.drupal.org/project/lupus_decoupled for more info. 12 | 13 | The 2.x version of the module is compatible with Nuxt 3. For a Nuxt 2 compatible version, please checkout the [1.x version](https://github.com/drunomics/nuxtjs-drupal-ce/tree/1.x) 14 | 15 | 16 | ## Pre-requisites 17 | 18 | * A [Drupal](https://drupal.org) backend with the 19 | [Lupus Custom Elements Renderer](https://www.drupal.org/project/lupus_ce_renderer) 20 | module or [Lupus Decoupled Drupal](https://www.drupal.org/project/lupus_decoupled) installed. 21 | 22 | ## Setup 23 | 24 | 1. Go to your Nuxt project. If necessary, start a new one: 25 | 26 | ```bash 27 | npx nuxi@latest init 28 | ``` 29 | 30 | 2. Add the `nuxtjs-drupal-ce` module to your Nuxt project 31 | 32 | ```bash 33 | npx nuxi@latest module add drupal-ce 34 | ``` 35 | 36 | 3. Configure `nuxtjs-drupal-ce` in your `nuxt.config.js` 37 | 38 | ```js 39 | export default defineNuxtConfig({ 40 | modules: [ 41 | 'nuxtjs-drupal-ce', 42 | ], 43 | drupalCe: { 44 | drupalBaseUrl: 'https://your-drupal.example.com', 45 | // more options... 46 | } 47 | }) 48 | ``` 49 | The module defaults work well with [Lupus Decoupled Drupal](https://drupal.org/project/lupus_decoupled) - in that case setting the 50 | `drupalBaseUrl` is enough to get started. 51 | 52 | 4. Scaffold initial files. After scaffolding, edit them as suiting. 53 | ```bash 54 | rm -f app.vue && npx nuxt-drupal-ce-init 55 | ``` 56 | 57 | 58 | ## Features 59 | 60 | * Fetches pages via the custom elements API provided by the [Lupus Custom Elements Renderer](https://www.drupal.org/project/lupus_ce_renderer) 61 | * Provides a Nuxt-wildcard route, so all Drupal pages can be rendered via Nuxt.js and vue-router. 62 | * Integrates page metadata and the page title with Nuxt. 63 | * Supports breadcrumbs and local tasks ("Tabs") 64 | * Supports authenticated requests. Cookies are passed through to Drupal by default. 65 | * Supports display of Drupal messages in the frontend. 66 | * Provides unstyled skeleton components for getting started quickly. 67 | * Supports fetching and display of Drupal menus via the [Rest menu items](https://www.drupal.org/project/rest_menu_items) module. 68 | 69 | 70 | ## Options 71 | 72 | - `drupalBaseUrl`: The Drupal base URL, e.g. `https://example.com:8080`. Required. 73 | 74 | - `serverDrupalBaseUrl`: Optionally, an alternative drupal base URL to apply in server context. 75 | 76 | - `ceApiEndpoint`: The custom elements API endpoint. Defaults to `/ce-api`. 77 | 78 | - `fetchOptions`: The default [fetchOptions](https://nuxt.com/docs/api/composables/use-fetch#params) 79 | to apply when fetching from the Drupal. Defaults to `{ credentials: 'include' }`. 80 | 81 | - `fetchProxyHeaders`: The HTTP request headers to pass through to Drupal, via [useRequestHeaders](https://nuxt.com/docs/api/composables/use-request-headers#userequestheaders). Defaults to `['cookie']`. 82 | 83 | - `menuEndpoint`: The menu endpoint pattern used for fetching menus. Defaults to 'api/menu_items/$$$NAME$$$' as fitting 84 | to the API provided by the [Rest menu items](https://www.drupal.org/project/rest_menu_items) Drupal module. 85 | `$$$NAME$$$` is replaced by the menu name being fetched. 86 | 87 | - `menuBaseUrl`: The menu base URL. Defaults to drupalBaseUrl + ceApiEndpoint. 88 | 89 | - `addRequestContentFormat`: If specified, the given value is added as `_content_format` 90 | URL parameter to requests. Disabled by default. 91 | 92 | - `addRequestFormat`: If set to `true`, the `_format=custom_elements` URL parameter 93 | is added automatically to requests. Defaults to `false`. 94 | 95 | - `customErrorPages`: By default, error pages provided by Drupal (e.g. 403, 404 page) are shown, 96 | while keeping the right status code. By enabling customErrorPages, the regular Nuxt error 97 | pages are shown instead, such that the pages can be customized with Nuxt. Defaults to `false`. 98 | 99 | - `useLocalizedMenuEndpoint`: If enabled, the menu endpoint will use a language prefix as configured by [nuxtjs/i18n](https://v8.i18n.nuxtjs.org) module. Defaults to `true`. 100 | 101 | - `serverApiProxy`: If enabled, the module will create a Nitro server handler that proxies API requests to Drupal backend. Defaults to `true` for SSR (it's disabled for SSG). 102 | 103 | - `passThroughHeaders`: Response headers to pass through from Drupal to the client. Defaults to ['cache-control', 'content-language', 'set-cookie', 'x-drupal-cache', 'x-drupal-dynamic-cache']. Note: This is only available in SSR mode. 104 | 105 | - `serverLogLevel`: The log level to use for server-side logging. Defaults to 'info'. Options: 106 | - false: The server plugin will not be loaded, keeps the default Nuxt error logging. 107 | - 'info': Log all server requests and errors. 108 | - 'error': Log only errors. 109 | 110 | - `disableFormHandler`: If set to `true`, the form handler middleware will be disabled. Defaults to `false`. 111 | 112 | ## Overriding options with environment variables 113 | 114 | Runtime config values can be overridden with environment variables via `NUXT_PUBLIC_` prefix. Supported runtime overrides: 115 | 116 | - `drupalBaseUrl` -> `NUXT_PUBLIC_DRUPAL_CE_DRUPAL_BASE_URL` 117 | - `serverDrupalBaseUrl` -> `NUXT_PUBLIC_DRUPAL_CE_SERVER_DRUPAL_BASE_URL` 118 | - `menuBaseUrl` -> `NUXT_PUBLIC_DRUPAL_CE_MENU_BASE_URL` 119 | - `ceApiEndpoint` -> `NUXT_PUBLIC_DRUPAL_CE_CE_API_ENDPOINT` 120 | 121 | ## Rendering custom elements 122 | 123 | Generally, custom elements are rendered as [dynamic components](https://nuxt.com/docs/guide/directory-structure/components#dynamic-components) and simply need to be registered as global components. 124 | 125 | The components should be placed in `~/components/global`, refer to the `/playground` directory for an example. 126 | For example, for the custom element `node-article-teaser` a global component `node-article-teaser.vue` would be 127 | picked up for rendering. 128 | 129 | ### Naming recommendation 130 | 131 | We recommend to name the components lowercase using kebap-case, such that there is a clear 1:1 mapping between 132 | custom element names used in the API response and the frontend components. For 133 | example use `custom-element-name.vue` instead of `CustomElementName.vue`. Both variants work though. 134 | 135 | ### Default components (JSON only) 136 | 137 | When using JSON-based rendering of custom elements, the module offers fallback component support. If a custom element lacks a corresponding Vue component, the module attempts to find a suitable default component. 138 | 139 | #### How it works: 140 | 141 | 1. The module removes the last `-`-separated prefix from the element name. 142 | 2. It then appends a `--default` suffix. 143 | 3. If this modified component exists, it's used for rendering. 144 | 4. If the component is not exiting, the process is repeated. 145 | 146 | This approach allows for generic default components like `drupal-form--default.vue` to be used for specific elements such as `drupal-form-user-login-form.vue`. For customization, developers can simply copy and modify the default component as needed. 147 | 148 | #### Example lookup process 149 | 150 | When a specific component isn't found, the module searches for a default component by progressively removing segments from the custom element name. For example when rendering the custom element `node-custom-view` it looks for components in the following order: 151 | 152 | ``` 153 | x node-custom-view.vue 154 | x node-custom-view--default.vue 155 | x node-custom--default.vue 156 | ✓ node--default.vue 157 | ``` 158 | 159 | ## Form handler middleware 160 | 161 | The form handler middleware is used to process Drupal form submissions by forwarding form-POST 162 | requests to Drupal and rendering the response as usual. This option allows you to bypass this 163 | middleware for certain routes or to disable it globally. 164 | 165 | ### Route level 166 | 167 | To bypass the form handler middleware for certain routes, you can use the `disableFormHandler` option with an array of routes: 168 | 169 | ```js 170 | export default defineNuxtConfig({ 171 | drupalCe: { 172 | disableFormHandler: ['/custom-form'], 173 | }, 174 | }) 175 | ``` 176 | 177 | ### Global level 178 | 179 | To disable the form handler middleware globally, you can use the `disableFormHandler` option with `true`: 180 | 181 | ```js 182 | export default defineNuxtConfig({ 183 | drupalCe: { 184 | disableFormHandler: true, 185 | }, 186 | }) 187 | ``` 188 | 189 | ## Deprecated options 190 | 191 | The following options are deprecated and only there for improved backwards compatibility. 192 | 193 | - `baseURL`: The base URL of the Drupal /ce-api endpoint, e.g. http://localhost:8888/ce-api. 194 | If set, `drupalBaseUrl` is set with the origin of the provided URL. 195 | 196 | 197 | ## Error handling 198 | 199 | The module provides a default error handler for the `fetchPage` and `fetchMenu` methods: 200 | 201 | - `fetchPage`: Throws an error with the status code and message provided by Drupal. 202 | - `fetchMenu`: Logs an error message to the console and displays a message in the frontend. 203 | 204 | ## Customizing error handling 205 | 206 | You have the option to override the default error handlers by using a parameter when calling the `fetch` methods. 207 | 208 | - `fetchPage`: 209 | ```javascript 210 | 218 | ``` 219 | 220 | - `fetchMenu`: 221 | ```javascript 222 | 235 | ``` 236 | 237 | Note: The `error` parameter is optional and can be omitted. 238 | 239 | ## Previous options not supported in 2.x version 240 | 241 | The following options were support in 1.x but got dropped: 242 | 243 | - `addVueCompiler`: This is necessary if you want to render custom elements markup on runtime; 244 | i.e. use the 'markup' content format. Instead, the vue runtime compiler can be enabled in via 245 | Nuxt config, see [here](https://github.com/nuxt/framework/pull/4762). 246 | 247 | - `axios`: Options to pass-through to the `drupal-ce` 248 | [axios](https://github.com/nuxt-community/axios-module) instance. Use `fetchOptions` instead. 249 | 250 | 251 | ## Development 252 | 253 | 1. Clone this repository. 254 | 2. Install dependencies using `npm install`. 255 | 3. Run `npm run dev:prepare` to generate type stubs. 256 | 4. Use `npm run dev` to start [playground](./playground) in development mode. 257 | 5. Update baseURL setting in Nuxt config with [Lupus Decoupled Drupal](https://www.drupal.org/project/lupus_decoupled) instance URL and append the API-prefix /ce-api, e.g. `https://8080-shaal-drupalpod-8m3z0ms7mb6.ws-eu67.gitpod.io/ce-api` 258 | 259 | ### Run on StackBlitz 260 | 261 | 1. [Launch it on StackBlitz](https://stackblitz.com/fork/github/drunomics/nuxtjs-drupal-ce/tree/2.x?startScript=dev:prepare,dev&file=playground/nuxt.config.ts) 262 | 2. Update baseURL setting in Nuxt config with [Lupus Decoupled Drupal](https://www.drupal.org/project/lupus_decoupled) instance URL and append the API-prefix /ce-api, e.g. `https://8080-shaal-drupalpod-8m3z0ms7mb6.ws-eu67.gitpod.io/ce-api` 263 | 264 | 265 | ## License 266 | 267 | [MIT License](./LICENSE) 268 | 269 | ## Credits 270 | 271 | Development sponsored by [drunomics](https://drunomics.com) 272 | 273 | 274 | [npm-version-src]: https://img.shields.io/npm/v/nuxtjs-drupal-ce/latest.svg 275 | [npm-version-href]: https://npmjs.com/package/nuxtjs-drupal-ce 276 | 277 | [npm-downloads-src]: https://img.shields.io/npm/dt/nuxtjs-drupal-ce.svg 278 | [npm-downloads-href]: https://npmjs.com/package/nuxtjs-drupal-ce 279 | 280 | [codecov-src]: https://codecov.io/gh/drunomics/nuxt-module-drupal-ce/branch/1.x/graph/badge.svg?token=vX3zknQWZv 281 | [codecov-href]: https://codecov.io/gh/drunomics/nuxt-module-drupal-ce 282 | 283 | [license-src]: https://img.shields.io/npm/l/nuxtjs-drupal-ce.svg 284 | [license-href]: https://npmjs.com/package/nuxtjs-drupal-ce 285 | -------------------------------------------------------------------------------- /bin/nuxt-drupal-ce-init.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('node:fs') 4 | const path = require('node:path') 5 | 6 | const scaffoldDir = path.join(__dirname, '/../playground') 7 | const target = '.' 8 | 9 | function copyFile(source, target) { 10 | const targetFile = target + '/' + path.basename(source) 11 | 12 | // Create the target directory if it doesn't exist 13 | const targetDir = path.dirname(targetFile) 14 | if (!fs.existsSync(targetDir)) { 15 | fs.mkdirSync(targetDir, { recursive: true }) 16 | console.log(targetDir + ' - Directory created.') 17 | } 18 | 19 | // If target is a file that doesn't exist, create it 20 | if (!fs.existsSync(targetFile)) { 21 | console.log(targetFile + ' - Created.') 22 | fs.readFileSync(source) 23 | fs.writeFileSync(targetFile, fs.readFileSync(source)) 24 | } 25 | else { 26 | console.log(targetFile + ' - Existing, skipped.') 27 | } 28 | } 29 | 30 | function syncDir(directory) { 31 | fs.readdirSync(scaffoldDir + '/' + directory).forEach(function (item) { 32 | if (fs.lstatSync(scaffoldDir + '/' + directory + '/' + item).isDirectory()) { 33 | syncDir(directory + '/' + item) 34 | } 35 | else { 36 | copyFile(scaffoldDir + '/' + directory + '/' + item, target + '/' + directory) 37 | } 38 | }) 39 | } 40 | 41 | // Here we want to make sure our directories exist. 42 | fs.mkdirSync('./components/global', { recursive: true }) 43 | fs.mkdirSync('./pages', { recursive: true }) 44 | fs.mkdirSync('./layouts', { recursive: true }) 45 | 46 | syncDir('pages') 47 | syncDir('layouts') 48 | syncDir('components') 49 | copyFile(scaffoldDir + '/app.vue', target) 50 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import withNuxt from './.nuxt/eslint.config.mjs' 2 | 3 | export default withNuxt([ 4 | { 5 | rules: { 6 | 'vue/no-v-html': 'off', 7 | 'no-useless-escape': 'off', 8 | 'vue/multi-word-component-names': 'off', 9 | '@typescript-eslint/no-explicit-any': 'off', 10 | '@typescript-eslint/no-unused-expressions': 'off', 11 | }, 12 | ignores: ['playground/server'], 13 | } 14 | ]) 15 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | modules: ['@nuxt/eslint'], 4 | compatibilityDate: '2024-12-16' 5 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxtjs-drupal-ce", 3 | "version": "2.2.2", 4 | "license": "MIT", 5 | "bin": { 6 | "nuxt-drupal-ce-init": "bin/nuxt-drupal-ce-init.cjs" 7 | }, 8 | "type": "module", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/module.mjs" 12 | } 13 | }, 14 | "main": "./dist/module.mjs", 15 | "files": [ 16 | "dist", 17 | "playground/components", 18 | "playground/pages", 19 | "playground/app.vue", 20 | "playground/layouts" 21 | ], 22 | "scripts": { 23 | "prepack": "nuxt-module-build build", 24 | "dev": "nuxi dev playground", 25 | "dev:build": "nuxi build playground", 26 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground", 27 | "test": "vitest run", 28 | "lint": "eslint .", 29 | "lint-fix": "eslint . --fix" 30 | }, 31 | "dependencies": { 32 | "defu": "^6.1.4" 33 | }, 34 | "devDependencies": { 35 | "@nuxt/eslint": "^1.4.1", 36 | "@nuxt/eslint-config": "^1.4.1", 37 | "@nuxt/kit": "^3.17.5", 38 | "@nuxt/module-builder": "^1.0.1", 39 | "@nuxt/schema": "^3.17.5", 40 | "@nuxt/test-utils": "^3.19.1", 41 | "@nuxtjs/i18n": "^9.5.5", 42 | "@playwright/test": "^1.52.0", 43 | "@vue/test-utils": "^2.4.6", 44 | "eslint": "^9.28.0", 45 | "happy-dom": "^15.11.7", 46 | "nuxt": "^3.17.5", 47 | "playwright-core": "^1.52.0", 48 | "typescript": "^5.8.3", 49 | "vitest": "^3.2.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/components/Drupal/DrupalTabs.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | 24 | 63 | -------------------------------------------------------------------------------- /playground/components/Drupal/DrupalViewsPagination.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 65 | -------------------------------------------------------------------------------- /playground/components/Navigation/NavigationAccount.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 20 | 21 | 33 | -------------------------------------------------------------------------------- /playground/components/Navigation/NavigationMain.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 28 | 29 | 45 | -------------------------------------------------------------------------------- /playground/components/Site/SiteBreadcrumbs.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 29 | 30 | 45 | -------------------------------------------------------------------------------- /playground/components/Site/SiteLanguageSwitcher.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 42 | 43 | 65 | -------------------------------------------------------------------------------- /playground/components/Site/SiteMessage.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 25 | 26 | 44 | -------------------------------------------------------------------------------- /playground/components/Site/SiteMessages.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 27 | 28 | 47 | -------------------------------------------------------------------------------- /playground/components/global/drupal-form--default.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 25 | 26 | 42 | -------------------------------------------------------------------------------- /playground/components/global/drupal-layout.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 36 | -------------------------------------------------------------------------------- /playground/components/global/drupal-markup.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /playground/components/global/drupal-view--default.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | -------------------------------------------------------------------------------- /playground/components/global/field--default.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /playground/components/global/node--default.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 29 | -------------------------------------------------------------------------------- /playground/layouts/clear.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /playground/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtConfig } from 'nuxt/config' 2 | import DrupalCe from '..' 3 | 4 | export default defineNuxtConfig({ 5 | modules: [ 6 | DrupalCe, 7 | ], 8 | compatibilityDate: '2024-12-16', 9 | nitro: { 10 | compressPublicAssets: true, 11 | }, 12 | drupalCe: { 13 | drupalBaseUrl: 'http://127.0.0.1:3000', 14 | ceApiEndpoint: '/ce-api', 15 | }, 16 | i18n: { 17 | locales: ['en', 'de'], 18 | defaultLocale: 'en', 19 | detectBrowserLanguage: false, 20 | }, 21 | }) -------------------------------------------------------------------------------- /playground/nuxt.config4test.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtConfig } from 'nuxt/config' 2 | import DrupalCe from '..' 3 | 4 | export default defineNuxtConfig({ 5 | modules: [ 6 | DrupalCe, 7 | ], 8 | drupalCe: { 9 | drupalBaseUrl: 'http://127.0.0.1:3001', 10 | ceApiEndpoint: '/ce-api', 11 | }, 12 | }) -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "nuxtjs-drupal-ce-playground" 4 | } 5 | -------------------------------------------------------------------------------- /playground/pages/[...slug].vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /playground/pages/[entity]/[id]/layout-preview.client.vue: -------------------------------------------------------------------------------- 1 | 31 | -------------------------------------------------------------------------------- /playground/pages/node/preview/[...slug].client.vue: -------------------------------------------------------------------------------- 1 | 31 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/[...].ts: -------------------------------------------------------------------------------- 1 | import { eventHandler, getRequestURL, setResponseStatus } from 'h3' 2 | // Catch-all handler for /ce-api/* paths - avoids 404 endless loops. 3 | 4 | export default eventHandler((event) => { 5 | const url = getRequestURL(event) 6 | setResponseStatus(event, 404) 7 | return { 8 | title: 'Not found', 9 | messages: [], 10 | breadcrumbs: [], 11 | metatags: { 12 | meta: [ 13 | { name: 'title', content: 'Not found' } 14 | ], 15 | link: [] 16 | }, 17 | content_format: 'json', 18 | content: { 19 | element: 'drupal-markup', 20 | content: `API endpoint ${url.pathname} not found.` 21 | }, 22 | page_layout: 'default', 23 | local_tasks: [] 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/api/menu_items/account.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => []) 2 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/api/menu_items/main.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => [ 2 | { 3 | key: '06c57f70-be51-4fa4-a944-a71704a616df', 4 | title: 'Another page', 5 | description: null, 6 | uri: 'node\/3', 7 | alias: 'node\/3', 8 | external: false, 9 | absolute: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/3', 10 | relative: '\/node\/3', 11 | existing: true, 12 | weight: '0', 13 | expanded: false, 14 | enabled: true, 15 | uuid: 'b49a069f-1d05-4a54-a201-c7d06605f7c5', 16 | options: [] 17 | }, 18 | { 19 | key: '819c0178-ee38-4778-b489-2eedc4c20249', 20 | title: 'Test page', 21 | description: null, 22 | uri: 'node\/1', 23 | alias: 'node\/1', 24 | external: false, 25 | absolute: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/1', 26 | relative: '\/node\/1', 27 | existing: true, 28 | weight: '0', 29 | expanded: false, 30 | enabled: true, 31 | uuid: '98f6edd6-42d1-44ac-a154-3e23895dcfc9', 32 | options: [] 33 | }, 34 | { 35 | key: 'layout-builder-demo', 36 | title: 'Layout', 37 | description: null, 38 | uri: 'node\/layout-builder', 39 | alias: 'node\/layout-builder', 40 | external: false, 41 | absolute: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/layout-builder', 42 | relative: '\/node\/layout-builder', 43 | existing: true, 44 | weight: '0', 45 | expanded: false, 46 | enabled: true, 47 | uuid: 'layout-builder-demo', 48 | options: [] 49 | } 50 | ]) -------------------------------------------------------------------------------- /playground/server/routes/ce-api/de/api/menu_items/account.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => []) 2 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/de/api/menu_items/main.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => [ 2 | { 3 | key: '06c57f70-be51-4fa4-a944-a71704a616df', 4 | title: 'Eine andere Seite', 5 | description: null, 6 | uri: 'de\/node\/3', 7 | alias: 'de\/node\/3', 8 | external: false, 9 | absolute: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/de\/node\/3', 10 | relative: '\/de\/\/node\/3', 11 | existing: true, 12 | weight: '0', 13 | expanded: false, 14 | enabled: true, 15 | uuid: 'b49a069f-1d05-4a54-a201-c7d06605f7c5', 16 | options: [] 17 | }, 18 | { 19 | key: '819c0178-ee38-4778-b489-2eedc4c20249', 20 | title: 'Test page DE', 21 | description: null, 22 | uri: 'de\/node\/1', 23 | alias: 'de\/node\/1', 24 | external: false, 25 | absolute: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/de\/node\/1', 26 | relative: '\/de\/node\/1', 27 | existing: true, 28 | weight: '0', 29 | expanded: false, 30 | enabled: true, 31 | uuid: '98f6edd6-42d1-44ac-a154-3e23895dcfc9', 32 | options: [] 33 | }, 34 | { 35 | key: 'layout-builder-demo', 36 | title: 'Layout DE', 37 | description: null, 38 | uri: 'de\/node\/layout-builder', 39 | alias: 'de\/node\/layout-builder', 40 | external: false, 41 | absolute: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/de\/node\/layout-builder', 42 | relative: '\/de\/node\/layout-builder', 43 | existing: true, 44 | weight: '0', 45 | expanded: false, 46 | enabled: true, 47 | uuid: 'layout-builder-demo', 48 | options: [] 49 | } 50 | ]) -------------------------------------------------------------------------------- /playground/server/routes/ce-api/de/error404.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | event.node.res.statusCode = 404 3 | event.node.res.end() 4 | }) 5 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/de/error500.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | event.node.res.statusCode = 500 3 | event.node.res.end() 4 | }) 5 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/de/index.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => ({ title: 'Willkommen', messages: [], breadcrumbs: [], metatags: { meta: [{ name: 'title', content: 'Willkommen | lupus decoupled' }], link: [{ rel: 'canonical', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/de' }, { rel: 'shortlink', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/' }, { rel: 'alternate', hreflang: 'de', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/de' }, { rel: 'alternate', hreflang: 'en', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/' }] }, content_format: 'json', content: { element: 'drupal-markup', content: 'Willkommen auf Ihrer Drupal-Website mit benutzerdefinierten Elementen!' }, page_layout: 'default', local_tasks: [], 2 | })) 3 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/de/node/1.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => ({ title: 'DE Test page', messages: [], breadcrumbs: [], metatags: { meta: [{ name: 'title', content: 'DE Test page | lupus decoupled' }, { name: 'description', content: 'DE Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Id neque aliquam vestibulum morbi blandit cursus risus. Cursus sit amet dictum sit amet. In pellentesque massa placerat duis ultricies lacus sed turpis tincidunt. Quis hendrerit dolor magna eget est lorem ipsum dolor. Viverra tellus in hac habitasse platea dictumst vestibulum rhoncus. Tellus in hac habitasse platea. Mattis enim ut tellus elementum sagittis. Sit amet nisl suscipit adipiscing. Donec enim diam vulputate ut pharetra sit amet.' }], link: [{ rel: 'canonical', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/de\/node\/1' }, { rel: 'alternate', hreflang: 'de', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/de\/node\/1' }, { rel: 'alternate', hreflang: 'en', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/node\/1' }] }, content_format: 'json', content: { element: 'node', type: 'page', title: 'Test page DE', created: '1674823460', body: ['\u003Cp\u003EDE Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Id neque aliquam vestibulum morbi blandit cursus risus. Cursus sit amet dictum sit amet. In pellentesque massa placerat duis ultricies lacus sed turpis tincidunt. Quis hendrerit dolor magna eget est lorem ipsum dolor. Viverra tellus in hac habitasse platea dictumst vestibulum rhoncus. Tellus in hac habitasse platea. Mattis enim ut tellus elementum sagittis. Sit amet nisl suscipit adipiscing. Donec enim diam vulputate ut pharetra sit amet. Aliquet nibh praesent tristique magna sit amet. Phasellus faucibus scelerisque eleifend donec pretium vulputate sapien nec. Nibh nisl condimentum id venenatis a condimentum vitae. A scelerisque purus semper eget duis at tellus. Amet volutpat consequat mauris nunc congue nisi vitae suscipit. Consectetur adipiscing elit duis tristique sollicitudin nibh. Massa massa ultricies mi quis hendrerit dolor magna eget est. Dolor magna eget est lorem. Senectus et netus et malesuada. Enim lobortis scelerisque fermentum dui faucibus in ornare quam.\u003C\/p\u003E'], uid: { element: 'field-entity-reference', targetId: '1', entity: { element: 'a', href: '\/user\/1', type: 'user', content: 'admin' } } }, page_layout: 'default', local_tasks: { primary: [{ url: '\/node\/1', label: 'View', active: true }, { url: '\/node\/1\/edit', label: 'Edit', active: false }, { url: '\/node\/1\/delete', label: 'Delete', active: false }, { url: '\/node\/1\/revisions', label: 'Revisions', active: false }], secondary: [] }, 2 | })) 3 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/de/node/3.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => ({ title: 'Eine andere Seite', messages: [], breadcrumbs: [], metatags: { meta: [{ name: 'title', content: 'Another page | lupus decoupled' }, { name: 'description', content: 'DE Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Arcu dictum varius duis at consectetur. Aliquam purus sit amet luctus. Varius morbi enim nunc faucibus a pellentesque sit. Vel facilisis volutpat est velit egestas dui id ornare arcu. Ut pharetra sit amet aliquam id diam maecenas. Quis commodo odio aenean sed adipiscing diam donec. Velit scelerisque in dictum non consectetur. Ultrices eros in cursus turpis massa. Amet volutpat consequat mauris nunc congue nisi vitae.' }], link: [{ rel: 'canonical', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/de\/node\/3' }, { rel: 'alternate', hreflang: 'de', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/de\/node\/3' }, { rel: 'alternate', hreflang: 'en', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/3' }] }, content_format: 'json', content: { element: 'node', type: 'page', uid: '1', title: 'Eine andere Seite', created: '1674823558', body: ['\u003Cp\u003EDE Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Arcu dictum varius duis at consectetur. Aliquam purus sit amet luctus. Varius morbi enim nunc faucibus a pellentesque sit. Vel facilisis volutpat est velit egestas dui id ornare arcu. Ut pharetra sit amet aliquam id diam maecenas. Quis commodo odio aenean sed adipiscing diam donec. Velit scelerisque in dictum non consectetur. Ultrices eros in cursus turpis massa. Amet volutpat consequat mauris nunc congue nisi vitae. Turpis nunc eget lorem dolor sed viverra ipsum nunc aliquet. Tempus egestas sed sed risus pretium quam. Odio eu feugiat pretium nibh ipsum. Posuere sollicitudin aliquam ultrices sagittis orci. Convallis posuere morbi leo urna molestie. Porttitor rhoncus dolor purus non enim praesent. Mi tempus imperdiet nulla malesuada.\u003C\/p\u003E'] }, page_layout: 'clear', local_tasks: [], 2 | })) 3 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/de/node/4.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => ({ title: 'Node 4', messages: [], breadcrumbs: [], metatags: { meta: [{ name: 'title', content: 'Node 4 | lupus decoupled' }, { name: 'description', content: 'Dieser Node testet die Fallbacks für benutzerdefinierte Elemente. node-article-demo --> node--default.' }], link: [{ rel: 'canonical', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/de\/node\/4' }, { rel: 'alternate', hreflang: 'en', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/4' }, { rel: 'alternate', hreflang: 'en', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/4' }] }, content_format: 'json', content: { element: 'node-article-demo', type: 'page', uid: '1', title: 'Node 4', created: '1674823558', body: ['\u003Cp\u003EDieser Node testet die Fallbacks für benutzerdefinierte Elemente. node-article-demo --> node--default.\u003C\/p\u003E'] }, page_layout: 'default', local_tasks: [], 2 | })) 3 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/de/node/404.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | event.node.res.statusCode = 404 3 | return { title: 'Seite nicht gefunden', messages: [], breadcrumbs: [], metatags: { meta: [{ name: 'title', content: 'Seite nicht gefunden | lupus decoupled' }], link: [{ rel: 'canonical', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/de\/' }, { rel: 'shortlink', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/de\/' }] }, content_format: 'json', content: { element: 'drupal-markup', content: 'Die angeforderte Seite konnte nicht gefunden werden.' }, page_layout: 'default', local_tasks: [] } 4 | }) 5 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/de/redirect.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => { 2 | return { 3 | redirect: { 4 | external: false, 5 | statusCode: 302, 6 | url: '/de/node/1', 7 | }, 8 | messages: [], 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/error404.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | event.node.res.statusCode = 404 3 | event.node.res.end() 4 | }) 5 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/error500.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | event.node.res.statusCode = 500 3 | event.node.res.end() 4 | }) 5 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/form/custom.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async _ => ({ 2 | title: 'Custom form', 3 | messages: [], 4 | breadcrumbs: [{frontpage: true, url: '\/', label: 'Home'}], 5 | metatags: {meta: [], link: []}, 6 | content_format: 'json', 7 | content: { 8 | element: 'drupal-form', 9 | formId: 'custom_form', 10 | attributes: {class: ['custom-form'], dataDrupalSelector: 'custom-form'}, 11 | method: 'post', 12 | content: '
\n \n \n\n
\n\n', 13 | }, 14 | page_layout: 'default', 15 | local_tasks: [], 16 | })) 17 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/form/custom.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async _ => ({ 2 | title: 'Form response', 3 | messages: [], 4 | breadcrumbs: [{frontpage: true, url: '\/', label: 'Home'}], 5 | metatags: {meta: [], link: []}, 6 | content_format: 'json', 7 | content: { 8 | element: 'node', 9 | content: '

Form response received, submit was successful!

', 10 | }, 11 | page_layout: 'default', 12 | local_tasks: [], 13 | })) 14 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/form/custom2.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async _ => ({ 2 | title: 'Custom form', 3 | messages: [], 4 | breadcrumbs: [{frontpage: true, url: '\/', label: 'Home'}], 5 | metatags: {meta: [], link: []}, 6 | content_format: 'json', 7 | content: { 8 | element: 'drupal-form', 9 | formId: 'custom_form', 10 | attributes: {class: ['custom-form'], dataDrupalSelector: 'custom-form'}, 11 | method: 'post', 12 | content: '
\n \n \n\n
\n\n', 13 | }, 14 | page_layout: 'default', 15 | local_tasks: [], 16 | })) 17 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/form/custom2.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async _ => ({ 2 | title: 'Form response', 3 | messages: [], 4 | breadcrumbs: [{frontpage: true, url: '\/', label: 'Home'}], 5 | metatags: {meta: [], link: []}, 6 | content_format: 'json', 7 | content: { 8 | element: 'node', 9 | content: '

Form response received, submit was successful!

', 10 | }, 11 | page_layout: 'default', 12 | local_tasks: [], 13 | })) 14 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/index.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => ({ title: 'Welcome', messages: [], breadcrumbs: [], metatags: { meta: [{ name: 'title', content: 'Welcome | lupus decoupled' }], link: [{ rel: 'canonical', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/' }, { rel: 'shortlink', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/' }, { rel: 'alternate', hreflang: 'de', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/de' }, { rel: 'alternate', hreflang: 'en', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/' }] }, content_format: 'json', content: { element: 'drupal-markup', content: 'Welcome to your custom-elements enabled Drupal site!' }, page_layout: 'default', local_tasks: [], 2 | })) 3 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/node/1.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => ({ 2 | title: 'Test page', 3 | messages: [], 4 | breadcrumbs: [], 5 | metatags: { 6 | meta: [{name: 'title', content: 'Test page | lupus decoupled'}, { 7 | name: 'description', 8 | content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Id neque aliquam vestibulum morbi blandit cursus risus. Cursus sit amet dictum sit amet. In pellentesque massa placerat duis ultricies lacus sed turpis tincidunt. Quis hendrerit dolor magna eget est lorem ipsum dolor. Viverra tellus in hac habitasse platea dictumst vestibulum rhoncus. Tellus in hac habitasse platea. Mattis enim ut tellus elementum sagittis. Sit amet nisl suscipit adipiscing. Donec enim diam vulputate ut pharetra sit amet.' 9 | }], 10 | link: [{ 11 | rel: 'canonical', 12 | href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/node\/1' 13 | }, { 14 | rel: 'alternate', 15 | hreflang: 'de', 16 | href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/de\/node\/1' 17 | }, { 18 | rel: 'alternate', 19 | hreflang: 'en', 20 | href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/node\/1' 21 | }], 22 | jsonld: { 23 | "@context": "https://schema.org", 24 | "@graph": [ 25 | { 26 | "@type": "WebPage", 27 | "@id": "https://example.com/testing/metadata", 28 | "headline": "Test Page", 29 | "description": "Test page description for metadata verification", 30 | "datePublished": "2024-01-14T08:49:16+0200", 31 | "image": { 32 | "@type": "ImageObject", 33 | "url": "https://example.com/image.jpg", 34 | "width": "1200", 35 | "height": "630" 36 | } 37 | } 38 | ] 39 | } 40 | }, 41 | content_format: 'json', 42 | content: { 43 | element: 'node', 44 | type: 'page', 45 | title: 'Test page', 46 | created: '1674823460', 47 | body: ['\u003Cp\u003ELorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Id neque aliquam vestibulum morbi blandit cursus risus. Cursus sit amet dictum sit amet. In pellentesque massa placerat duis ultricies lacus sed turpis tincidunt. Quis hendrerit dolor magna eget est lorem ipsum dolor. Viverra tellus in hac habitasse platea dictumst vestibulum rhoncus. Tellus in hac habitasse platea. Mattis enim ut tellus elementum sagittis. Sit amet nisl suscipit adipiscing. Donec enim diam vulputate ut pharetra sit amet. Aliquet nibh praesent tristique magna sit amet. Phasellus faucibus scelerisque eleifend donec pretium vulputate sapien nec. Nibh nisl condimentum id venenatis a condimentum vitae. A scelerisque purus semper eget duis at tellus. Amet volutpat consequat mauris nunc congue nisi vitae suscipit. Consectetur adipiscing elit duis tristique sollicitudin nibh. Massa massa ultricies mi quis hendrerit dolor magna eget est. Dolor magna eget est lorem. Senectus et netus et malesuada. Enim lobortis scelerisque fermentum dui faucibus in ornare quam.\u003C\/p\u003E'], 48 | uid: { 49 | element: 'field-entity-reference', 50 | targetId: '1', 51 | entity: {element: 'a', href: '\/user\/1', type: 'user', content: 'admin'} 52 | } 53 | }, 54 | page_layout: 'default', 55 | local_tasks: { 56 | primary: [{url: '\/node\/1', label: 'View', active: true}, { 57 | url: '\/node\/1\/edit', 58 | label: 'Edit', 59 | active: false 60 | }, {url: '\/node\/1\/delete', label: 'Delete', active: false}, { 61 | url: '\/node\/1\/revisions', 62 | label: 'Revisions', 63 | active: false 64 | }], secondary: [] 65 | }, 66 | })) 67 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/node/3.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => ({ 2 | title: 'Another page', 3 | messages: [], 4 | breadcrumbs: [], 5 | metatags: { 6 | meta: [{name: 'title', content: 'Another page | lupus decoupled'}, { 7 | name: 'description', 8 | content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Arcu dictum varius duis at consectetur. Aliquam purus sit amet luctus. Varius morbi enim nunc faucibus a pellentesque sit. Vel facilisis volutpat est velit egestas dui id ornare arcu. Ut pharetra sit amet aliquam id diam maecenas. Quis commodo odio aenean sed adipiscing diam donec. Velit scelerisque in dictum non consectetur. Ultrices eros in cursus turpis massa. Amet volutpat consequat mauris nunc congue nisi vitae.' 9 | }], 10 | link: [{ 11 | rel: 'canonical', 12 | href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/3' 13 | }, { 14 | rel: 'alternate', 15 | hreflang: 'de', 16 | href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/de\/node\/3' 17 | }, { 18 | rel: 'alternate', 19 | hreflang: 'en', 20 | href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/3' 21 | }] 22 | }, 23 | content_format: 'json', 24 | content: { 25 | element: 'node', 26 | type: 'page', 27 | uid: '1', 28 | title: 'Another page', 29 | created: '1674823558', 30 | body: '\u003Cp\u003ELorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Arcu dictum varius duis at consectetur. Aliquam purus sit amet luctus. Varius morbi enim nunc faucibus a pellentesque sit. Vel facilisis volutpat est velit egestas dui id ornare arcu. Ut pharetra sit amet aliquam id diam maecenas. Quis commodo odio aenean sed adipiscing diam donec. Velit scelerisque in dictum non consectetur. Ultrices eros in cursus turpis massa. Amet volutpat consequat mauris nunc congue nisi vitae. Turpis nunc eget lorem dolor sed viverra ipsum nunc aliquet. Tempus egestas sed sed risus pretium quam. Odio eu feugiat pretium nibh ipsum. Posuere sollicitudin aliquam ultrices sagittis orci. Convallis posuere morbi leo urna molestie. Porttitor rhoncus dolor purus non enim praesent. Mi tempus imperdiet nulla malesuada.\u003C\/p\u003E' 31 | }, 32 | page_layout: 'clear', 33 | local_tasks: [], 34 | })) 35 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/node/4.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => ({ title: 'Node 4', messages: [], breadcrumbs: [], metatags: { meta: [{ name: 'title', content: 'Node 4 | lupus decoupled' }, { name: 'description', content: 'This node is testing custom elements fallback. node-article-demo --> node--default.' }], link: [{ rel: 'canonical', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/4' }, { rel: 'alternate', hreflang: 'de', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/de\/node\/4' }, { rel: 'alternate', hreflang: 'en', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/4' }] }, content_format: 'json', content: { element: 'node-article-demo', type: 'page', uid: '1', title: 'Node 4', created: '1674823558', body: ['\u003Cp\u003EThis node is testing custom elements fallback. node-article-demo --> node--default.\u003C\/p\u003E'] }, page_layout: 'default', local_tasks: [], 2 | })) 3 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/node/404.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | event.node.res.statusCode = 404 3 | return { title: 'Page not found', messages: [], breadcrumbs: [], metatags: { meta: [{ name: 'title', content: 'Page not found | lupus decoupled' }], link: [{ rel: 'canonical', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/' }, { rel: 'shortlink', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu86.gitpod.io\/' }] }, content_format: 'json', content: { element: 'drupal-markup', content: 'The requested page could not be found.' }, page_layout: 'default', local_tasks: [] } 4 | }) 5 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/node/5.ts: -------------------------------------------------------------------------------- 1 | // This file is only there for manual testing and NOT used in actual test cases. 2 | export default defineEventHandler(() => { 3 | return { 4 | breadcrumbs: [ 5 | { 6 | frontpage: true, 7 | url: '/', 8 | label: 'Home' 9 | } 10 | ], 11 | content: { 12 | element: 'node-article', 13 | created: '1734839102', 14 | title: 'Test article', 15 | body: '

Some example body.

', 16 | comment: {}, 17 | image: ' test\n\n\n' 18 | }, 19 | content_format: 'json', 20 | local_tasks: {}, 21 | messages: [], 22 | metatags: { 23 | meta: [ 24 | { 25 | name: 'title', 26 | content: 'tes adfa fdfdsf | Drush Site-Install' 27 | }, 28 | { 29 | name: 'description', 30 | content: 'asdf asdfasdf' 31 | } 32 | ], 33 | link: [ 34 | { 35 | rel: 'canonical', 36 | href: 'https://lupus-nuxt.ddev.site/custom-error' 37 | } 38 | ] 39 | }, 40 | page_layout: 'default', 41 | title: 'tes adfa fdfdsf' 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/node/5/layout-preview.ts: -------------------------------------------------------------------------------- 1 | // This file is only there for manual testing AND used in e2e test cases. 2 | // This is for testing the /preview/* route via /preview/node. 3 | export default defineEventHandler(async (event) => { 4 | setHeaders(event, { 5 | 'Access-Control-Allow-Origin': 'http://localhost:3000', 6 | 'Access-Control-Allow-Credentials': true, 7 | }) 8 | return { 9 | breadcrumbs: [ 10 | { 11 | frontpage: true, 12 | url: '/', 13 | label: 'Home' 14 | } 15 | ], 16 | content: { 17 | element: 'node-article', 18 | created: '1734839102', 19 | title: 'Test article', 20 | body: '

Some example body.

', 21 | comment: {}, 22 | image: ' test\n\n\n' 23 | }, 24 | content_format: 'json', 25 | local_tasks: {}, 26 | messages: [], 27 | metatags: { 28 | meta: [ 29 | { 30 | name: 'title', 31 | content: 'tes adfa fdfdsf | Drush Site-Install' 32 | }, 33 | { 34 | name: 'description', 35 | content: 'asdf asdfasdf' 36 | } 37 | ], 38 | link: [ 39 | { 40 | rel: 'canonical', 41 | href: 'https://lupus-nuxt.ddev.site/custom-error' 42 | } 43 | ] 44 | }, 45 | page_layout: 'default', 46 | title: 'tes adfa fdfdsf' 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/node/layout-builder.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => ({ 2 | title: 'Layout Builder Test', 3 | messages: [], 4 | breadcrumbs: [ 5 | { 6 | frontpage: true, 7 | url: '/', 8 | label: 'Home' 9 | } 10 | ], 11 | content: { 12 | element: 'node-layout-full', 13 | type: 'page', 14 | title: 'Layout Builder Test', 15 | created: '1734839102', 16 | sections: [{ 17 | element: 'drupal-layout', 18 | layout: 'twocol', 19 | settings: { 20 | label: 'Two column layout', 21 | column_widths: '50-50' 22 | }, 23 | first: { 24 | element: 'drupal-markup', 25 | content: '

First Column

This is content in the first column of our layout builder test.

' 26 | }, 27 | second: { 28 | element: 'drupal-markup', 29 | content: '

Second Column

This is content in the second column of our layout builder test.

' 30 | } 31 | }], 32 | }, 33 | content_format: 'json', 34 | local_tasks: { 35 | primary: [ 36 | { 37 | url: '/node/layout-builder', 38 | label: 'View', 39 | active: true 40 | } 41 | ] 42 | }, 43 | metatags: { 44 | meta: [ 45 | { 46 | name: 'title', 47 | content: 'Layout Builder Test | Drupal CE' 48 | }, 49 | { 50 | name: 'description', 51 | content: 'A test page demonstrating layout builder functionality' 52 | } 53 | ], 54 | link: [ 55 | { 56 | rel: 'canonical', 57 | href: 'http://localhost:3000/node/layout-builder' 58 | } 59 | ] 60 | }, 61 | page_layout: 'default' 62 | })) -------------------------------------------------------------------------------- /playground/server/routes/ce-api/node/preview/node.ts: -------------------------------------------------------------------------------- 1 | // This file is only there for manual testing AND used in e2e test cases. 2 | // This is for testing the /preview/* route via /preview/node. 3 | export default defineEventHandler(async (event) => { 4 | setHeaders(event, { 5 | 'Access-Control-Allow-Origin': 'http://localhost:3000', 6 | 'Access-Control-Allow-Credentials': true, 7 | }) 8 | return { 9 | breadcrumbs: [ 10 | { 11 | frontpage: true, 12 | url: '/', 13 | label: 'Home' 14 | } 15 | ], 16 | content: { 17 | element: 'node-article', 18 | created: '1734839102', 19 | title: 'Test article', 20 | body: '

Some example body.

', 21 | comment: {}, 22 | image: ' test\n\n\n' 23 | }, 24 | content_format: 'json', 25 | local_tasks: {}, 26 | messages: [], 27 | metatags: { 28 | meta: [ 29 | { 30 | name: 'title', 31 | content: 'tes adfa fdfdsf | Drush Site-Install' 32 | }, 33 | { 34 | name: 'description', 35 | content: 'asdf asdfasdf' 36 | } 37 | ], 38 | link: [ 39 | { 40 | rel: 'canonical', 41 | href: 'https://lupus-nuxt.ddev.site/custom-error' 42 | } 43 | ] 44 | }, 45 | page_layout: 'default', 46 | title: 'tes adfa fdfdsf' 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/redirect.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => { 2 | return { 3 | redirect: { 4 | external: false, 5 | statusCode: 302, 6 | url: '/node/1', 7 | }, 8 | messages: [], 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/user/login.get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async _ => ({ 2 | title: 'Log in', 3 | messages: [], 4 | breadcrumbs: [{frontpage: true, url: '\/', label: 'Home'}], 5 | metatags: {meta: [], link: []}, 6 | content_format: 'json', 7 | content: { 8 | element: 'drupal-form', 9 | formId: 'user_login_form', 10 | attributes: {class: ['user-login-form'], dataDrupalSelector: 'user-login-form'}, 11 | method: 'post', 12 | content: '
\n \n \n\n
\n
\n \n \n\n
\n\n\n
\n
\n', 13 | }, 14 | page_layout: 'default', 15 | local_tasks: [], 16 | })) 17 | -------------------------------------------------------------------------------- /playground/server/routes/ce-api/user/login.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const formData = await readFormData(event) 3 | const username = formData.get('name') 4 | const password = formData.get('pass') 5 | 6 | if (username !== 'admin' || password !== 'drupal123') { 7 | return { 8 | title: 'Log in', messages: { error: ['Unrecognized username or password. \u003Ca href=\u0022\/user\/password\u0022\u003EForgot your password?\u003C\/a\u003E'] }, breadcrumbs: [{ frontpage: true, url: '\/', label: 'Home' }], metatags: { meta: [], link: [] }, content_format: 'json', content: { element: 'drupal-form', formId: 'user_login_form', attributes: { class: ['user-login-form'], dataDrupalSelector: 'user-login-form' }, method: 'post', content: '\u003Cdiv class=\u0022js-form-item form-item js-form-type-textfield form-item-name js-form-item-name\u0022\u003E\n \u003Clabel for=\u0022edit-name\u0022 class=\u0022form-item__label js-form-required form-required\u0022\u003EUsername\u003C\/label\u003E\n \u003Cinput autocorrect=\u0022none\u0022 autocapitalize=\u0022none\u0022 spellcheck=\u0022false\u0022 autofocus=\u0022autofocus\u0022 autocomplete=\u0022username\u0022 data-drupal-selector=\u0022edit-name\u0022 type=\u0022text\u0022 id=\u0022edit-name\u0022 name=\u0022name\u0022 value=\u0022admin123\u0022 size=\u002260\u0022 maxlength=\u002260\u0022 class=\u0022form-text required error form-element form-element--type-text form-element--api-textfield\u0022 required=\u0022required\u0022 aria-required=\u0022true\u0022 aria-invalid=\u0022true\u0022 \/\u003E\n\n \u003C\/div\u003E\n\u003Cdiv class=\u0022js-form-item form-item js-form-type-password form-item-pass js-form-item-pass\u0022\u003E\n \u003Clabel for=\u0022edit-pass\u0022 class=\u0022form-item__label js-form-required form-required\u0022\u003EPassword\u003C\/label\u003E\n \u003Cinput autocomplete=\u0022current-password\u0022 data-drupal-selector=\u0022edit-pass\u0022 type=\u0022password\u0022 id=\u0022edit-pass\u0022 name=\u0022pass\u0022 size=\u002260\u0022 maxlength=\u0022128\u0022 class=\u0022form-text required form-element form-element--type-password form-element--api-password\u0022 required=\u0022required\u0022 aria-required=\u0022true\u0022 \/\u003E\n\n \u003C\/div\u003E\n\u003Cinput data-drupal-selector=\u0022form-y0dcq5st-2releayznqf-qaeu2krrwig77u-4x69m1u\u0022 type=\u0022hidden\u0022 name=\u0022form_build_id\u0022 value=\u0022form-y0DCq5St-2rElEaYZnqF-QAeU2KrrWiG77u-4x69M1U\u0022 \/\u003E\n\u003Cinput data-drupal-selector=\u0022edit-user-login-form\u0022 type=\u0022hidden\u0022 name=\u0022form_id\u0022 value=\u0022user_login_form\u0022 \/\u003E\n\u003Cdiv data-drupal-selector=\u0022edit-actions\u0022 class=\u0022form-actions js-form-wrapper form-wrapper\u0022 id=\u0022edit-actions\u0022\u003E\u003Cinput class=\u0022button--primary button js-form-submit form-submit\u0022 data-drupal-selector=\u0022edit-submit\u0022 type=\u0022submit\u0022 id=\u0022edit-submit\u0022 name=\u0022op\u0022 value=\u0022Log in\u0022 \/\u003E\n\u003C\/div\u003E\n' }, page_layout: 'default', local_tasks: [], 9 | } 10 | } 11 | // Login success 12 | setHeader(event, 'Set-Cookie', 'SSESSf9f2dc90f4drupal=k8WpMQCcQ-test; Expires=Fri, 13-Sep-2024 18:47:37 GMT; Max-Age=2000000; Path=/; Domain=127.0.0.1; Secure; HttpOnly; SameSite=Lax') 13 | return { redirect: { external: false, url: '/node/1', statusCode: 301 }, messages: [] } 14 | }) 15 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule, addServerPlugin, createResolver, addImportsDir, addServerHandler } from '@nuxt/kit' 2 | import { defu } from 'defu' 3 | import type { NuxtOptionsWithDrupalCe } from './types' 4 | 5 | export interface ModuleOptions { 6 | drupalBaseUrl: string 7 | serverDrupalBaseUrl?: string 8 | ceApiEndpoint: string 9 | menuEndpoint: string 10 | menuBaseUrl?: string 11 | addRequestContentFormat?: string 12 | addRequestFormat: boolean 13 | customErrorPages: boolean 14 | fetchOptions: object 15 | fetchProxyHeaders: string[] 16 | useLocalizedMenuEndpoint: boolean 17 | serverApiProxy: boolean 18 | passThroughHeaders?: string[] 19 | exposeAPIRouteRules?: boolean 20 | serverLogLevel?: boolean | 'info' | 'error' 21 | disableFormHandler?: boolean | string[] 22 | } 23 | 24 | export default defineNuxtModule({ 25 | meta: { 26 | name: 'nuxtjs-drupal-ce', 27 | configKey: 'drupalCe', 28 | compatibility: { 29 | nuxt: '>=3.7.0', 30 | }, 31 | }, 32 | defaults: { 33 | drupalBaseUrl: '', 34 | ceApiEndpoint: '/ce-api', 35 | menuEndpoint: 'api/menu_items/$$$NAME$$$', 36 | customErrorPages: false, 37 | fetchOptions: { 38 | credentials: 'include', 39 | }, 40 | fetchProxyHeaders: ['cookie'], 41 | useLocalizedMenuEndpoint: true, 42 | addRequestFormat: false, 43 | serverApiProxy: true, 44 | passThroughHeaders: ['cache-control', 'content-language', 'set-cookie', 'x-drupal-cache', 'x-drupal-dynamic-cache'], 45 | serverLogLevel: 'info', 46 | disableFormHandler: false, 47 | }, 48 | setup(options, nuxt) { 49 | const nuxtOptions = nuxt.options as NuxtOptionsWithDrupalCe 50 | // Keep backwards compatibility for exposeAPIRouteRules(deprecated). 51 | if (!nuxtOptions.drupalCe?.serverApiProxy && options.exposeAPIRouteRules !== undefined) { 52 | options.serverApiProxy = options.exposeAPIRouteRules 53 | } 54 | 55 | // Disable the server routes for static sites. 56 | if (nuxt.options._generate) { 57 | options.serverApiProxy = false 58 | } 59 | 60 | const { resolve } = createResolver(import.meta.url) 61 | const runtimeDir = resolve('./runtime') 62 | nuxt.options.build.transpile.push(runtimeDir) 63 | if (options.serverLogLevel) { 64 | addServerPlugin(resolve(runtimeDir, 'server/plugins/errorLogger')) 65 | } 66 | addImportsDir(resolve(runtimeDir, 'composables/useDrupalCe')) 67 | 68 | // Add form handler middleware if not disabled (via boolean) 69 | if (!(options.disableFormHandler === true)) { 70 | addServerHandler({ 71 | handler: resolve(runtimeDir, 'server/middleware/drupalFormHandler'), 72 | }) 73 | } 74 | 75 | const publicOptions = { ...options } 76 | // Server options are not needed in the client bundle. 77 | delete publicOptions.serverLogLevel 78 | delete publicOptions.passThroughHeaders 79 | delete publicOptions.exposeAPIRouteRules 80 | delete publicOptions.disableFormHandler 81 | 82 | nuxt.options.runtimeConfig.public.drupalCe = defu(nuxt.options.runtimeConfig.public.drupalCe ?? {}, publicOptions) 83 | 84 | nuxt.options.runtimeConfig.drupalCe = defu(nuxt.options.runtimeConfig.drupalCe ?? {}, { 85 | serverLogLevel: options.serverLogLevel as string, 86 | passThroughHeaders: options.passThroughHeaders, 87 | disableFormHandler: options.disableFormHandler, 88 | }) 89 | 90 | if (options.serverApiProxy === true) { 91 | addServerHandler({ 92 | route: '/api/drupal-ce', 93 | handler: resolve(runtimeDir, 'server/api/drupalCe'), 94 | }) 95 | addServerHandler({ 96 | route: '/api/drupal-ce/**', 97 | handler: resolve(runtimeDir, 'server/api/drupalCe'), 98 | }) 99 | addServerHandler({ 100 | route: '/api/menu/**', 101 | handler: resolve(runtimeDir, 'server/api/menu'), 102 | }) 103 | } 104 | }, 105 | }) 106 | -------------------------------------------------------------------------------- /src/runtime/composables/useDrupalCe/index.ts: -------------------------------------------------------------------------------- 1 | import { defu } from 'defu' 2 | import { appendResponseHeader } from 'h3' 3 | import type { $Fetch, NitroFetchRequest } from 'nitropack' 4 | import type { Ref, ComputedRef, Component } from 'vue' 5 | import { getDrupalBaseUrl, getMenuBaseUrl } from './server' 6 | import type { UseFetchOptions } from '#app' 7 | import { callWithNuxt } from '#app' 8 | import { useRuntimeConfig, useState, useFetch, navigateTo, createError, h, resolveComponent, setResponseStatus, useNuxtApp, useRequestHeaders, ref, watch, useRequestEvent, computed, useHead, defineComponent } from '#imports' 9 | 10 | export const useDrupalCe = () => { 11 | const config = useRuntimeConfig().public.drupalCe 12 | const privateConfig = useRuntimeConfig().drupalCe 13 | 14 | /** 15 | * Processes the given fetchOptions to apply module defaults 16 | * @param fetchOptions Optional Nuxt useFetch options 17 | * @param skipDrupalCeApiProxy Force skip the Drupal CE API proxy. Defaults to false. 18 | * The proxy might still be skipped if serverApiProxy is set to false globally. 19 | * @returns UseFetchOptions 20 | */ 21 | const processFetchOptions = (fetchOptions: UseFetchOptions = {}, skipDrupalCeApiProxy: boolean = false) => { 22 | if (config.serverApiProxy && !skipDrupalCeApiProxy) { 23 | fetchOptions.baseURL = '/api/drupal-ce' 24 | } 25 | else { 26 | fetchOptions.baseURL = fetchOptions.baseURL ?? getDrupalBaseUrl() + config.ceApiEndpoint 27 | } 28 | fetchOptions = defu(fetchOptions, config.fetchOptions) 29 | 30 | // Apply the request headers of current request, if configured. 31 | if (config.fetchProxyHeaders) { 32 | fetchOptions.headers = defu(fetchOptions.headers ?? {}, useRequestHeaders(config.fetchProxyHeaders)) 33 | } 34 | 35 | // If fetchOptions.query._content_format is undefined, use config.addRequestContentFormat. 36 | // If fetchOptions.query._content_format is false, keep that. 37 | fetchOptions.query = fetchOptions.query ?? {} 38 | 39 | fetchOptions.query._content_format = fetchOptions.query._content_format ?? config.addRequestContentFormat 40 | if (!fetchOptions.query._content_format) { 41 | // Remove _content_format if set to a falsy value (e.g. fetchOptions.query._content_format was set to false) 42 | delete fetchOptions.query._content_format 43 | } 44 | 45 | if (config.addRequestFormat) { 46 | fetchOptions.query._format = 'custom_elements' 47 | } 48 | return fetchOptions 49 | } 50 | 51 | /** 52 | * Custom $fetch instance 53 | * @param fetchOptions UseFetchOptions 54 | * @param skipDrupalCeApiProxy Force skip the Drupal CE API proxy. Defaults to false. 55 | */ 56 | const $ceApi = (fetchOptions: UseFetchOptions = {}, skipDrupalCeApiProxy: boolean = false): $Fetch => { 57 | const useFetchOptions = processFetchOptions(fetchOptions, skipDrupalCeApiProxy) 58 | 59 | return $fetch.create({ 60 | ...useFetchOptions, 61 | }) 62 | } 63 | 64 | /** 65 | * Fetch data from Drupal ce-API endpoint using $ceApi 66 | * @param path Path of the Drupal ce-API endpoint to fetch 67 | * @param fetchOptions UseFetchOptions 68 | * @param doPassThroughHeaders Whether to pass through headers from Drupal to the client 69 | * @param skipDrupalCeApiProxy Force skip the Drupal CE API proxy. Defaults to false. 70 | */ 71 | const useCeApi = (path: string | Ref, fetchOptions: UseFetchOptions = {}, doPassThroughHeaders?: boolean, skipDrupalCeApiProxy: boolean = false): Promise => { 72 | const nuxtApp = useNuxtApp() 73 | fetchOptions.onResponse = (context) => { 74 | if (doPassThroughHeaders && import.meta.server && privateConfig?.passThroughHeaders) { 75 | const headersObject = Object.fromEntries([...context.response.headers.entries()]) 76 | passThroughHeaders(nuxtApp, headersObject) 77 | } 78 | } 79 | 80 | return useFetch(path, { 81 | ...fetchOptions, 82 | $fetch: $ceApi(fetchOptions, skipDrupalCeApiProxy), 83 | }) 84 | } 85 | 86 | /** 87 | * Returns the API endpoint with localization (if available) 88 | */ 89 | const getCeApiEndpoint = (localize: boolean = true) => { 90 | const nuxtApp = useNuxtApp() 91 | if (localize && nuxtApp.$i18n?.locale?.value && nuxtApp.$i18n.locale.value !== nuxtApp.$i18n.defaultLocale) { 92 | return `${config.ceApiEndpoint}/${nuxtApp.$i18n.locale.value}` 93 | } 94 | return config.ceApiEndpoint 95 | } 96 | 97 | /** 98 | * Fetches page data from Drupal, handles redirects, errors and messages 99 | * @param path Path of the Drupal page to fetch 100 | * @param useFetchOptions Optional Nuxt useFetch options 101 | * @param overrideErrorHandler Optional error handler 102 | * @param skipDrupalCeApiProxy Force skip the Drupal CE API proxy. Defaults to false. 103 | * The proxy might still be skipped if serverApiProxy is set to false globally. 104 | */ 105 | const fetchPage = async (path: string, useFetchOptions: UseFetchOptions = {}, overrideErrorHandler?: (error?: any) => void, skipDrupalCeApiProxy: boolean = false) => { 106 | const nuxtApp = useNuxtApp() 107 | 108 | // Workaround for issue - useState is not available after async call (Nuxt instance unavailable) 109 | // Initialize state with default values 110 | const pageState = useState('drupal-ce-page-data', () => ({ 111 | breadcrumbs: [], 112 | content: {}, 113 | content_format: 'json', 114 | local_tasks: { 115 | primary: [], 116 | secondary: [], 117 | }, 118 | settings: {}, 119 | messages: [], 120 | metatags: { 121 | meta: [], 122 | link: [], 123 | jsonld: [], 124 | }, 125 | page_layout: 'default', 126 | title: '', 127 | })) 128 | const serverResponse = useState('server-response', () => null) 129 | // Remove trailing slash from path key as it might cause issues in SSG. 130 | const sanitizedPathKey = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path 131 | useFetchOptions.key = `page-${sanitizedPathKey}${skipDrupalCeApiProxy ? '-direct' : '-proxy'}` 132 | let page = null 133 | const pageError = ref(null) 134 | 135 | if (import.meta.server) { 136 | serverResponse.value = useRequestEvent(nuxtApp).context.drupalCeCustomPageResponse 137 | } 138 | 139 | // Check if the page data is already provided, e.g. by a form response. 140 | if (serverResponse.value) { 141 | if (serverResponse.value._data) { 142 | page = ref(serverResponse.value._data) 143 | passThroughHeaders(nuxtApp, serverResponse.value.headers) 144 | } 145 | else if (serverResponse.value.error) { 146 | pageError.value = serverResponse.value.error 147 | } 148 | // Clear the server response state after it was sent to the client. 149 | if (import.meta.client) { 150 | serverResponse.value = null 151 | } 152 | } 153 | else { 154 | const { data, error } = await useCeApi(path, useFetchOptions, true, skipDrupalCeApiProxy) 155 | page = data 156 | pageError.value = error.value 157 | } 158 | 159 | if (page.value?.messages) { 160 | pushMessagesToState(page.value.messages) 161 | } 162 | 163 | if (page?.value?.redirect) { 164 | await callWithNuxt(nuxtApp, navigateTo, [ 165 | page.value.redirect.url, 166 | { external: page.value.redirect.external, redirectCode: page.value.redirect.statusCode, replace: true }, 167 | ]) 168 | return pageState 169 | } 170 | 171 | if (pageError.value) { 172 | overrideErrorHandler ? overrideErrorHandler(pageError) : pageErrorHandler(pageError, { config, nuxtApp }) 173 | page.value = pageError.value?.data 174 | } 175 | 176 | pageState.value = page 177 | return page 178 | } 179 | 180 | /** 181 | * Fetches menu data from Drupal (configured by menuEndpoint option), handles errors 182 | * @param name Menu name being fetched 183 | * @param useFetchOptions Optional Nuxt useFetch options 184 | * @param overrideErrorHandler Optional error handler 185 | * @param skipDrupalCeApiProxy Force skip the Drupal CE API proxy. Defaults to false. 186 | * The proxy might still be skipped if serverApiProxy is set to false globally. 187 | */ 188 | const fetchMenu = async (name: string, useFetchOptions: UseFetchOptions = {}, overrideErrorHandler?: (error?: any) => void, skipDrupalCeApiProxy: boolean = false) => { 189 | const nuxtApp = useNuxtApp() 190 | useFetchOptions = processFetchOptions(useFetchOptions) 191 | useFetchOptions.key = useFetchOptions.key || `menu-${name}` 192 | useFetchOptions.getCachedData = (key) => { 193 | if (nuxtApp.payload.data[key]) { 194 | return nuxtApp.payload.data[key] 195 | } 196 | } 197 | 198 | const baseMenuPath = config.menuEndpoint.replace('$$$NAME$$$', name) 199 | const menuPath = ref(baseMenuPath) 200 | 201 | // Ensure menuPath has no leading slash 202 | const sanitizeMenuPath = (path: string) => path.startsWith('/') ? path.substring(1) : path 203 | 204 | if (config.useLocalizedMenuEndpoint && nuxtApp.$i18n) { 205 | // API path with localization 206 | menuPath.value = sanitizeMenuPath(nuxtApp.$localePath('/' + baseMenuPath)) 207 | watch(nuxtApp.$i18n.locale, () => { 208 | menuPath.value = sanitizeMenuPath(nuxtApp.$localePath('/' + baseMenuPath)) 209 | }) 210 | } 211 | else { 212 | menuPath.value = sanitizeMenuPath(menuPath.value) 213 | } 214 | 215 | // Override baseURL specifically for menu endpoints 216 | if (config.serverApiProxy && !skipDrupalCeApiProxy) { 217 | useFetchOptions.baseURL = '/api/menu' 218 | } 219 | else { 220 | useFetchOptions.baseURL = getDrupalBaseUrl() + getCeApiEndpoint(false) 221 | } 222 | 223 | const { data: menu, error } = await useFetch(menuPath, useFetchOptions) 224 | 225 | if (error.value) { 226 | overrideErrorHandler ? overrideErrorHandler(error) : menuErrorHandler(error) 227 | } 228 | return menu 229 | } 230 | 231 | /** 232 | * Use messages state 233 | */ 234 | const getMessages = (): Ref => useState('drupal-ce-messages', () => []) 235 | 236 | /** 237 | * Use page data 238 | */ 239 | const getPage = (): Ref => useState('drupal-ce-page-data', () => ({})) 240 | 241 | /** 242 | * Resolve a custom element into a Vue component 243 | * @param element The custom element name to resolve 244 | */ 245 | const resolveCustomElement = (element: string) => { 246 | const nuxtApp = useNuxtApp() 247 | const formatName = (name: string) => name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('') 248 | 249 | // Try resolving the full component name. 250 | const component = nuxtApp.vueApp.component(formatName(element)) 251 | if (typeof component === 'object' && component.name) { 252 | return component 253 | } 254 | 255 | // Progressively remove segments from the custom element name to find a matching default component. 256 | const regex = /-?[a-z]+$/ 257 | let componentName = element 258 | while (componentName) { 259 | // Try resolving by adding 'Default' suffix. 260 | const fallbackComponent = nuxtApp.vueApp.component(formatName(componentName) + 'Default') 261 | if (typeof fallbackComponent === 'object' && fallbackComponent.name) { 262 | return fallbackComponent 263 | } 264 | componentName = componentName.replace(regex, '') 265 | } 266 | 267 | // If not found, try with resolveComponent. This provides a warning if the component is not found. 268 | return typeof resolveComponent(element) === 'object' ? resolveComponent(element) : null 269 | } 270 | 271 | /** 272 | * Renders Vue components from JSON-serialized custom element data. 273 | * 274 | * @param customElements - Custom element data that can be: 275 | * - null/undefined (returns null, skipping render) 276 | * - string (rendered inside a wrapping div element) 277 | * - single custom element object with {element: string, ...props} 278 | * - array of strings or custom element objects (rendered inside a wrapping div element) 279 | * @returns Component | null - A Vue component that can be used with . 280 | * Returns null for skipped render, otherwise returns a Vue component 281 | * (either a custom element component or a wrapping div component for strings/arrays). 282 | */ 283 | const renderCustomElements = ( 284 | customElements: null | undefined | string | Record | Array, 285 | ): Component | null => { 286 | // Handle null/undefined case 287 | if (customElements == null) { 288 | return null 289 | } 290 | 291 | // Handle string case by creating a component that renders HTML content 292 | if (typeof customElements === 'string') { 293 | const component = resolveCustomElement('drupal-markup') 294 | if (component) { 295 | return h(component, {content: customElements}) 296 | } 297 | // Else fallback to a simple wrapping div. 298 | return h('div', customElements) 299 | } 300 | 301 | // Handle empty object case 302 | if (Object.keys(customElements).length === 0) { 303 | return null 304 | } 305 | 306 | // Handle multiple elements without creating a wrapping div 307 | if (Array.isArray(customElements)) { 308 | return defineComponent({ 309 | setup() { 310 | return () => customElements.map(element => { 311 | const rendered = renderCustomElements(element) 312 | return rendered ? h(rendered) : null 313 | }) 314 | } 315 | }) 316 | } 317 | 318 | // Handle single custom element object 319 | const resolvedElement = resolveCustomElement(customElements.element) 320 | return resolvedElement ? h(resolvedElement, customElements) : null 321 | } 322 | 323 | /** 324 | * Pass through headers from Drupal to the client 325 | * @param nuxtApp The Nuxt app instance 326 | * @param pageHeaders The headers from the Drupal response 327 | */ 328 | const passThroughHeaders = (nuxtApp, pageHeaders) => { 329 | // Only run when SSR context is available. 330 | if (!nuxtApp.ssrContext) { 331 | return 332 | } 333 | const event = nuxtApp.ssrContext.event 334 | if (pageHeaders) { 335 | Object.keys(pageHeaders).forEach((key) => { 336 | if (privateConfig?.passThroughHeaders.includes(key)) { 337 | appendResponseHeader(event, key, pageHeaders[key]) 338 | } 339 | }) 340 | } 341 | } 342 | 343 | /** 344 | * Sets page head metadata from Drupal page data 345 | * @param page Ref containing the Drupal page data 346 | * @param include Optional array of parts to include: 'title', 'meta', 'link', 'jsonld' 347 | */ 348 | const usePageHead = (page: Ref, include?: Array<'title' | 'meta' | 'link' | 'jsonld'>) => { 349 | const parts = include || ['title', 'meta', 'link', 'jsonld'] 350 | useHead({ 351 | ...(parts.includes('title') && { title: page.value.title }), 352 | ...(parts.includes('meta') && { meta: page.value.metatags.meta }), 353 | ...(parts.includes('link') && { link: page.value.metatags.link }), 354 | ...(parts.includes('jsonld') && { script: [{ 355 | type: 'application/ld+json', 356 | children: JSON.stringify(page.value.metatags.jsonld || [], null, ''), 357 | }] }), 358 | }) 359 | } 360 | 361 | /** 362 | * Gets the current page layout. 363 | * @param page Optional Ref containing the Drupal page data. If not provided, gets data from global state. 364 | * @returns ComputedRef resolving to the current layout name 365 | */ 366 | const getPageLayout = (page?: Ref): ComputedRef => { 367 | const pageData = page || getPage() 368 | return computed(() => pageData.value?.page_layout || 'default') 369 | } 370 | 371 | return { 372 | $ceApi, 373 | useCeApi, 374 | fetchPage, 375 | fetchMenu, 376 | getMessages, 377 | getPage, 378 | renderCustomElements, 379 | passThroughHeaders, 380 | getCeApiEndpoint, 381 | getDrupalBaseUrl, 382 | getMenuBaseUrl, 383 | getPageLayout, 384 | usePageHead, 385 | } 386 | } 387 | 388 | const pushMessagesToState = (messages) => { 389 | messages = Object.assign({ success: [], error: [] }, messages) 390 | const messagesArray = [ 391 | ...messages.error.map(message => ({ type: 'error', message })), 392 | ...messages.success.map(message => ({ type: 'success', message })), 393 | ] 394 | if (!messagesArray.length) { 395 | return 396 | } 397 | import.meta.client && useDrupalCe().getMessages().value.push(...messagesArray) 398 | } 399 | 400 | const menuErrorHandler = (error: Record) => { 401 | console.error({ statusCode: error.value.statusCode, statusMessage: error.value.message, data: error.value.data }) 402 | import.meta.client && useDrupalCe().getMessages().value.push({ 403 | type: 'error', 404 | message: `Menu error: ${error.value.message}.`, 405 | }) 406 | } 407 | 408 | const pageErrorHandler = (error: Record, context?: Record) => { 409 | const errorData = error.value.data 410 | if (error.value && (!errorData?.content || context?.config.customErrorPages)) { 411 | // At the moment, Nuxt API proxy does not provide a nice error when the backend is not reachable. Handle it better. 412 | // See https://github.com/nuxt/nuxt/issues/22645 413 | if (error.value.statusCode === 500 && errorData?.message === 'fetch failed' && !errorData.statusMessage) { 414 | throw createError({ 415 | statusCode: 503, 416 | statusMessage: 'Unable to reach backend.', 417 | data: errorData, 418 | fatal: true, 419 | }) 420 | } 421 | throw createError({ 422 | statusCode: error.value.statusCode, 423 | statusMessage: error.value?.message, 424 | data: error.value.data, 425 | fatal: true, 426 | }) 427 | } 428 | if (context) { 429 | callWithNuxt(context.nuxtApp, setResponseStatus, [error.value.statusCode]) 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /src/runtime/composables/useDrupalCe/server.ts: -------------------------------------------------------------------------------- 1 | import { useRuntimeConfig } from '#imports' 2 | 3 | /** 4 | * Returns the drupalBaseUrl. 5 | * On server it returns the serverDrupalBaseUrl if set, otherwise it returns the drupalBaseUrl. 6 | * 7 | * @returns {string} Returns the Drupal base URL. 8 | */ 9 | export const getDrupalBaseUrl = () => { 10 | const config = useRuntimeConfig().public.drupalCe 11 | return import.meta.server && config.serverDrupalBaseUrl ? config.serverDrupalBaseUrl : config.drupalBaseUrl 12 | } 13 | 14 | /** 15 | * Returns the menuBaseUrl if set, otherwise it returns the drupalBaseUrl + ceApiEndpoint. 16 | * 17 | * @returns {string} Returns the menu base URL. 18 | */ 19 | export const getMenuBaseUrl = () => { 20 | const config = useRuntimeConfig().public.drupalCe 21 | return config.menuBaseUrl ? config.menuBaseUrl : getDrupalBaseUrl() + config.ceApiEndpoint 22 | } 23 | -------------------------------------------------------------------------------- /src/runtime/server/api/drupalCe.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, proxyRequest, getRouterParams, getQuery } from 'h3' 2 | import { getDrupalBaseUrl } from '../../composables/useDrupalCe/server' 3 | import { useRuntimeConfig } from '#imports' 4 | 5 | export default defineEventHandler(async (event) => { 6 | const params = getRouterParams(event)._ 7 | const path = params ? '/' + params : '' 8 | const query = getQuery(event) ? '?' + new URLSearchParams(getQuery(event)) : '' 9 | const { ceApiEndpoint } = useRuntimeConfig().public.drupalCe 10 | // Remove x-forwarded-proto header as it causes issues with the request. 11 | delete event.req.headers['x-forwarded-proto'] 12 | return await proxyRequest(event, getDrupalBaseUrl() + ceApiEndpoint + path + query) 13 | }) 14 | -------------------------------------------------------------------------------- /src/runtime/server/api/menu.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, proxyRequest, getRouterParams } from 'h3' 2 | import { getMenuBaseUrl } from '../../composables/useDrupalCe/server' 3 | 4 | export default defineEventHandler(async (event) => { 5 | // Get the menu path along with the localization prefix. 6 | const menuPath = getRouterParams(event)._ 7 | return await proxyRequest(event, `${getMenuBaseUrl()}/${menuPath}`, { 8 | headers: { 9 | 'Cache-Control': 'max-age=300', 10 | }, 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /src/runtime/server/middleware/drupalFormHandler.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, readFormData } from 'h3' 2 | import { getDrupalBaseUrl } from '../../composables/useDrupalCe/server' 3 | import { useRuntimeConfig } from '#imports' 4 | 5 | export default defineEventHandler(async (event) => { 6 | const { disableFormHandler } = useRuntimeConfig().drupalCe 7 | const { ceApiEndpoint } = useRuntimeConfig().public.drupalCe 8 | 9 | if (event.node.req.method === 'POST') { 10 | const routesToBypass = Array.isArray(disableFormHandler) ? disableFormHandler : [] 11 | 12 | if (routesToBypass.length) { 13 | // Remove query parameters from the URL. 14 | const currentPath = event.node.req.url?.split('?')[0] || ''; 15 | const shouldBypass = routesToBypass.some(route => { 16 | const routeFormats = [ 17 | route, 18 | '/api/drupal-ce' + route, 19 | ceApiEndpoint + route 20 | ]; 21 | return routeFormats.some(format => format === currentPath); 22 | }); 23 | 24 | if (shouldBypass) { 25 | return; 26 | } 27 | } 28 | 29 | const contentType = event.node.req.headers['content-type'] || '' 30 | if (!contentType.includes('multipart/form-data') && !contentType.includes('application/x-www-form-urlencoded') || event.node.req.headers['x-form-processed']) { 31 | return 32 | } 33 | 34 | const formData = await readFormData(event) 35 | 36 | if (formData) { 37 | const targetUrl = event.node.req.url 38 | const response = await $fetch.raw(getDrupalBaseUrl() + ceApiEndpoint + targetUrl, { 39 | method: 'POST', 40 | body: formData, 41 | headers: { 42 | 'x-form-processed': 'true', 43 | }, 44 | }).catch((error) => { 45 | event.context.drupalCeCustomPageResponse = { 46 | error: { 47 | data: error, 48 | statusCode: error.statusCode || 400, 49 | message: error.message || 'Error when POSTing form data (drupalFormHandler).', 50 | }, 51 | } 52 | }) 53 | 54 | if (response) { 55 | event.context.drupalCeCustomPageResponse = { 56 | _data: response._data, 57 | headers: Object.fromEntries(response.headers.entries()), 58 | } 59 | } 60 | } 61 | else { 62 | throw createError({ 63 | statusCode: 400, 64 | statusMessage: 'Bad Request', 65 | message: 'POST requests without form data are not supported (drupalFormHandler).', 66 | }) 67 | } 68 | } 69 | }) 70 | -------------------------------------------------------------------------------- /src/runtime/server/plugins/errorLogger.ts: -------------------------------------------------------------------------------- 1 | import { getRequestURL } from 'h3' 2 | import { useRuntimeConfig, defineNitroPlugin } from '#imports' 3 | 4 | export default defineNitroPlugin((nitro: any) => { 5 | const { serverLogLevel } = useRuntimeConfig().drupalCe 6 | 7 | if (serverLogLevel === 'error' || serverLogLevel === 'info') { 8 | nitro.hooks.hook('error', (error: any, { event }: any) => { 9 | const url = getRequestURL(event) 10 | const fullUrl = url.origin + url.pathname + url.search 11 | console.error(`[${event?.node.req.method}] ${fullUrl} - ${error}`) 12 | }) 13 | } 14 | 15 | // Log every request when serverLogLevel is set to info. 16 | if (serverLogLevel === 'info') { 17 | nitro.hooks.hook('request', (event: any) => { 18 | const origin = getRequestURL(event).origin 19 | console.log(`[${event.node.req.method}] ${origin + event.node.req.url}`) 20 | }) 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleOptions } from './module' 2 | 3 | // Define the type for the runtime-config,. 4 | // see https://nuxt.com/docs/guide/going-further/runtime-config#manually-typing-runtime-config 5 | declare module '@nuxt/schema' { 6 | interface PublicRuntimeConfig { 7 | drupalCe: ModuleOptions 8 | } 9 | } 10 | 11 | export interface NuxtOptionsWithDrupalCe extends NuxtOptions { 12 | drupalCe?: ModuleOptions 13 | } 14 | -------------------------------------------------------------------------------- /test/addRequestContentFormat/json.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import DrupalCe from '../..' 5 | import { join } from 'node:path' 6 | 7 | describe('Module addRequestContentFormat option set to json', async () => { 8 | await setup({ 9 | rootDir: join(fileURLToPath(import.meta.url), '../../fixtures/debug'), 10 | nuxtConfig: { 11 | modules: [ 12 | DrupalCe, 13 | ], 14 | drupalCe: { 15 | drupalBaseUrl: 'http://127.0.0.1:3100', 16 | ceApiEndpoint: '/ce-api', 17 | addRequestContentFormat: 'json', 18 | }, 19 | }, 20 | port: 3100, 21 | }) 22 | it('is correctly set in query', async () => { 23 | const html = await $fetch('/') 24 | expect(html).toContain('_content_format=json') 25 | expect(html).not.toContain('_content_format=markup') 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/addRequestContentFormat/markup.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import DrupalCe from '../..' 5 | import { join } from 'node:path' 6 | 7 | describe('Module addRequestContentFormat set to markup', async () => { 8 | await setup({ 9 | rootDir: join(fileURLToPath(import.meta.url), '../../fixtures/debug'), 10 | nuxtConfig: { 11 | modules: [ 12 | DrupalCe, 13 | ], 14 | drupalCe: { 15 | drupalBaseUrl: 'http://127.0.0.1:3101', 16 | ceApiEndpoint: '/ce-api', 17 | addRequestContentFormat: 'markup', 18 | }, 19 | }, 20 | port: 3101, 21 | }) 22 | it('is correctly set in query', async () => { 23 | const html = await $fetch('/') 24 | expect(html).toContain('_content_format=markup') 25 | expect(html).not.toContain('_content_format=json') 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/addRequestContentFormat/unset.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import DrupalCe from '../..' 5 | import { join } from 'node:path' 6 | 7 | describe('Module addRequestContentFormat not set', async () => { 8 | await setup({ 9 | rootDir: join(fileURLToPath(import.meta.url), '../../fixtures/debug'), 10 | nuxtConfig: { 11 | modules: [ 12 | DrupalCe, 13 | ], 14 | drupalCe: { 15 | drupalBaseUrl: 'http://127.0.0.1:3102', 16 | ceApiEndpoint: '/ce-api', 17 | }, 18 | }, 19 | port: 3102, 20 | }) 21 | it('is correctly missing in query', async () => { 22 | const html = await $fetch('/') 23 | expect(html).not.toContain('_content_format=') 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/addRequestFormat/set.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import DrupalCe from '../..' 5 | import { join } from 'node:path' 6 | 7 | describe('Module addRequestFormat option set to true', async () => { 8 | await setup({ 9 | rootDir: join(fileURLToPath(import.meta.url), '../../fixtures/debug'), 10 | nuxtConfig: { 11 | modules: [ 12 | DrupalCe, 13 | ], 14 | drupalCe: { 15 | drupalBaseUrl: 'http://127.0.0.1:3200', 16 | ceApiEndpoint: '/ce-api', 17 | addRequestFormat: true, 18 | }, 19 | }, 20 | port: 3200, 21 | }) 22 | it('is correctly set in query', async () => { 23 | const html = await $fetch('/') 24 | expect(html).toContain('_format=custom_elements') 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/addRequestFormat/unset.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import DrupalCe from '../..' 5 | import { join } from 'node:path' 6 | 7 | describe('Module addRequestFormat not set', async () => { 8 | await setup({ 9 | rootDir: join(fileURLToPath(import.meta.url), '../../fixtures/debug'), 10 | nuxtConfig: { 11 | modules: [ 12 | DrupalCe, 13 | ], 14 | drupalCe: { 15 | drupalBaseUrl: 'http://127.0.0.1:3201', 16 | ceApiEndpoint: '/ce-api', 17 | }, 18 | }, 19 | port: 3201, 20 | }) 21 | it('is correctly missing in query', async () => { 22 | const html = await $fetch('/') 23 | expect(html).not.toContain('_format=custom_elements') 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils/e2e' 4 | import { join } from 'node:path' 5 | 6 | describe('Module renders pages', async () => { 7 | await setup({ 8 | rootDir: join(fileURLToPath(import.meta.url), '../../playground'), 9 | configFile: 'nuxt.config4test', 10 | port: 3001, 11 | }) 12 | 13 | it('renders homepage', async () => { 14 | const html = await $fetch('/') 15 | expect(html).toContain('Welcome to your custom-elements enabled Drupal site') 16 | }) 17 | 18 | it('renders menu', async () => { 19 | const html = await $fetch('/') 20 | expect(html).toContain('Another page') 21 | expect(html).toContain('Test page') 22 | }) 23 | 24 | it('renders test page with metadata', async () => { 25 | const html = await $fetch('/node/1') 26 | 27 | // Content 28 | expect(html).toContain('Node: Test page') 29 | 30 | // Meta tags 31 | expect(html).toContain('') 32 | expect(html).toContain('') 36 | expect(html).toContain('') 37 | expect(html).toContain('') 38 | 39 | // Local tasks 40 | expect(html).toContain('href="/node/1/edit"') 41 | expect(html).toContain('href="/node/1/delete"') 42 | expect(html).toContain('href="/node/1/revisions"') 43 | 44 | // JSON-LD check 45 | const jsonLdMatch = html.match(/ 13 | -------------------------------------------------------------------------------- /test/fixtures/debug/pages/node/4.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 9 | -------------------------------------------------------------------------------- /test/fixtures/debug/server/routes/ce-api/index.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | return { debug: { url: event.node.req.url } } 3 | }) 4 | -------------------------------------------------------------------------------- /test/fixtures/debug/server/routes/ce-api/node/4.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(() => ({ title: 'Node 4', messages: [], breadcrumbs: [], metatags: { meta: [{ name: 'title', content: 'Node 4 | lupus decoupled' }, { name: 'description', content: 'This node is testing custom elements fallback. node-article-demo --> node--default.' }], link: [{ rel: 'canonical', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/4' }, { rel: 'alternate', hreflang: 'de', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/de\/node\/4' }, { rel: 'alternate', hreflang: 'en', href: 'https:\/\/8080-drunomics-lupusdecouple-fd0ilwlpax7.ws-eu84.gitpod.io\/node\/4' }] }, content_format: 'json', content: { element: 'node-article-demo', type: 'page', uid: '1', title: 'Node 4', created: '1674823558', body: ['\u003Cp\u003EThis node is testing custom elements fallback. node-article-demo --> node--default.\u003C\/p\u003E'] }, page_layout: 'default', local_tasks: [], 2 | })) 3 | -------------------------------------------------------------------------------- /test/i18n.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import DrupalCe from '../' 5 | import { join } from 'node:path' 6 | 7 | describe('Module @nuxtjs/i18n integration works', async () => { 8 | await setup({ 9 | rootDir: join(fileURLToPath(import.meta.url), '../../playground'), 10 | nuxtConfig: { 11 | modules: [ 12 | DrupalCe, 13 | '@nuxtjs/i18n', 14 | ], 15 | drupalCe: { 16 | drupalBaseUrl: 'http://127.0.0.1:3004', 17 | ceApiEndpoint: '/ce-api', 18 | serverApiProxy: true, 19 | }, 20 | i18n: { 21 | locales: ['en', 'de'], 22 | defaultLocale: 'en', 23 | detectBrowserLanguage: false, 24 | }, 25 | }, 26 | port: 3004, 27 | }) 28 | it('language switcher renders', async () => { 29 | const html = await $fetch('/') 30 | expect(html).toContain('language-switcher') 31 | }) 32 | it('switching language works', async () => { 33 | let html = await $fetch('/') 34 | expect(html).toContain('Welcome to your custom-elements enabled Drupal site') 35 | html = await $fetch('/de') 36 | expect(html).toContain('Willkommen auf Ihrer Drupal-Website mit benutzerdefinierten Elementen') 37 | }) 38 | it('correct menu is rendered', async () => { 39 | let html = await $fetch('/') 40 | expect(html).toContain('Another page') 41 | html = await $fetch('/de') 42 | expect(html).toContain('Eine andere Seite') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/i18nWithoutProxy.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import DrupalCe from '../' 5 | import { join } from 'node:path' 6 | 7 | describe('Module @nuxtjs/i18n integration works', async () => { 8 | await setup({ 9 | rootDir: join(fileURLToPath(import.meta.url), '../../playground'), 10 | nuxtConfig: { 11 | modules: [ 12 | DrupalCe, 13 | '@nuxtjs/i18n', 14 | ], 15 | drupalCe: { 16 | drupalBaseUrl: 'http://127.0.0.1:3003', 17 | ceApiEndpoint: '/ce-api', 18 | serverApiProxy: false, 19 | }, 20 | i18n: { 21 | locales: ['en', 'de'], 22 | defaultLocale: 'en', 23 | detectBrowserLanguage: false, 24 | }, 25 | }, 26 | port: 3003, 27 | }) 28 | it('language switcher renders', async () => { 29 | const html = await $fetch('/') 30 | expect(html).toContain('language-switcher') 31 | }) 32 | it('switching language works', async () => { 33 | let html = await $fetch('/') 34 | expect(html).toContain('Welcome to your custom-elements enabled Drupal site') 35 | html = await $fetch('/de') 36 | expect(html).toContain('Willkommen auf Ihrer Drupal-Website mit benutzerdefinierten Elementen') 37 | }) 38 | it('correct menu is rendered', async () => { 39 | let html = await $fetch('/') 40 | expect(html).toContain('Another page') 41 | html = await $fetch('/de') 42 | expect(html).toContain('Eine andere Seite') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/layout.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import { join } from 'node:path' 5 | 6 | describe('Custom layouts work', async () => { 7 | await setup({ 8 | rootDir: join(fileURLToPath(import.meta.url), '../../playground'), 9 | configFile: 'nuxt.config4test', 10 | port: 3001, 11 | }) 12 | it('renders a page with a custom layout', async () => { 13 | const html = await $fetch('/node/3') 14 | expect(html).toContain('id="main-clear"') 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /test/redirect.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, $fetch } from '@nuxt/test-utils' 4 | import { join } from 'node:path' 5 | 6 | describe('Module redirects work', async () => { 7 | await setup({ 8 | rootDir: join(fileURLToPath(import.meta.url), '../../playground'), 9 | configFile: 'nuxt.config4test', 10 | port: 3001, 11 | }) 12 | it('redirect to /node/1 works', async () => { 13 | const html = await $fetch('/redirect') 14 | await new Promise(resolve => setTimeout(resolve, 3000)) 15 | expect(html).toContain('Node: Test page') 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/unit/ceApiEndpoint.test.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment nuxt 2 | import { describe, it, expect, beforeEach, vi } from 'vitest' 3 | import { mockNuxtImport } from '@nuxt/test-utils/runtime' 4 | import { useDrupalCe } from '../../src/runtime/composables/useDrupalCe' 5 | 6 | const { useRuntimeConfigMock, useNuxtAppMock } = vi.hoisted(() => ({ 7 | useRuntimeConfigMock: vi.fn().mockReturnValue({ 8 | public: { drupalCe: { ceApiEndpoint: '/ce-api' } } 9 | }), 10 | useNuxtAppMock: vi.fn() 11 | })) 12 | 13 | describe('useDrupalCe() - getCeApiEndpoint() - handling of i18n localization in API endpoint paths', () => { 14 | beforeEach(() => { 15 | mockNuxtImport('useRuntimeConfig', () => useRuntimeConfigMock) 16 | mockNuxtImport('useNuxtApp', () => useNuxtAppMock) 17 | }) 18 | 19 | it('returns base endpoint without i18n', () => { 20 | useNuxtAppMock.mockReturnValue({ $i18n: undefined }) 21 | const { getCeApiEndpoint } = useDrupalCe() 22 | expect(getCeApiEndpoint()).toBe('/ce-api') 23 | }) 24 | 25 | it('returns base endpoint for default locale', () => { 26 | useNuxtAppMock.mockReturnValue({ 27 | $i18n: { locale: { value: 'en' }, defaultLocale: 'en' } 28 | }) 29 | const { getCeApiEndpoint } = useDrupalCe() 30 | expect(getCeApiEndpoint()).toBe('/ce-api') 31 | }) 32 | 33 | it('returns localized endpoint for non-default locale', () => { 34 | useNuxtAppMock.mockReturnValue({ 35 | $i18n: { locale: { value: 'de' }, defaultLocale: 'en' } 36 | }) 37 | const { getCeApiEndpoint } = useDrupalCe() 38 | expect(getCeApiEndpoint()).toBe('/ce-api/de') 39 | }) 40 | 41 | it('returns base endpoint when localize=false', () => { 42 | useNuxtAppMock.mockReturnValue({ 43 | $i18n: { locale: { value: 'de' }, defaultLocale: 'en' } 44 | }) 45 | const { getCeApiEndpoint } = useDrupalCe() 46 | expect(getCeApiEndpoint(false)).toBe('/ce-api') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/unit/components/drupal-form--default.test.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment nuxt 2 | import { describe, it, expect } from 'vitest' 3 | import { mountSuspended } from '@nuxt/test-utils/runtime' 4 | import { defineComponent } from 'vue' 5 | import { useDrupalCe } from '../../../src/runtime/composables/useDrupalCe' 6 | import DrupalFormDefault from '../../../playground/components/global/drupal-form--default.vue' 7 | 8 | describe('drupal-form--default custom element', () => { 9 | const formData = { 10 | element: 'drupal-form', 11 | formId: 'user_login_form', 12 | attributes: { 13 | class: ['user-login-form'], 14 | dataDrupalSelector: 'user-login-form' 15 | }, 16 | method: 'post', 17 | content: '
Some form content html.
' 18 | } 19 | 20 | const createFormComponent = (data = formData) => defineComponent({ 21 | components: { 'drupal-form': DrupalFormDefault }, 22 | setup() { 23 | const { renderCustomElements } = useDrupalCe() 24 | return { component: renderCustomElements(data) } 25 | }, 26 | template: '' 27 | }) 28 | 29 | it('renders form correctly', async () => { 30 | const wrapper = await mountSuspended(createFormComponent()) 31 | expect(wrapper.find('form').attributes('formid')).toBe('user_login_form') 32 | expect(wrapper.find('form').attributes('method')).toBe('post') 33 | expect(wrapper.find('form').classes()).toContain('user-login-form') 34 | expect(wrapper.find('form').attributes('datadrupalselector')).toBe('user-login-form') 35 | expect(wrapper.html()).toContain('
Some form content html.
') 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /test/unit/components/drupal-markup.test.ts: -------------------------------------------------------------------------------- 1 | // test/unit/components/drupal-markup.test.ts 2 | // @vitest-environment nuxt 3 | import { describe, it, expect } from 'vitest' 4 | import { mountSuspended } from '@nuxt/test-utils/runtime' 5 | import { defineComponent } from 'vue' 6 | import { useDrupalCe } from '../../../src/runtime/composables/useDrupalCe' 7 | 8 | describe('drupal-markup custom element', () => { 9 | const { renderCustomElements } = useDrupalCe() 10 | const addWrappingDiv = (children: string): string => '
\n ' + children + '\n
' 11 | 12 | it('renders markup content via custom element json', async () => { 13 | const component = defineComponent({ 14 | setup() { 15 | return { component: renderCustomElements({ 16 | element: 'drupal-markup', 17 | content: '

Some formatted content.

' 18 | }) } 19 | }, 20 | template: '' 21 | }) 22 | const wrapper = await mountSuspended(component) 23 | expect(wrapper.html()).toContain('

Some formatted content.

') 24 | // drupal-markup should only have the wrapping-div for the slot. 25 | expect(wrapper.html()).toEqual(addWrappingDiv('

Some formatted content.

')) 26 | }) 27 | 28 | it('renders markup content given via attribute ', async () => { 29 | const TestComponent = defineComponent({ 30 | template: '' 31 | }) 32 | const wrapper = await mountSuspended(TestComponent) 33 | expect(wrapper.html()).toEqual(addWrappingDiv('

Slotted content

')) 34 | }) 35 | 36 | it('renders markup content via custom element markup ', async () => { 37 | const TestComponent = defineComponent({ 38 | template: '

Slotted content

' 39 | }) 40 | const wrapper = await mountSuspended(TestComponent) 41 | expect(wrapper.html()).toContain('

Slotted content

') 42 | // drupal-markup should not add a wrapping element when used 43 | // via a vue slot. 44 | expect(wrapper.html()).toEqual('

Slotted content

') 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /test/unit/components/field--default.test.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment nuxt 2 | import { describe, it, expect } from 'vitest' 3 | import { mountSuspended } from '@nuxt/test-utils/runtime' 4 | import { defineComponent } from 'vue' 5 | import { useDrupalCe } from '../../../src/runtime/composables/useDrupalCe' 6 | import FieldDefault from '../../../playground/components/global/field--default.vue' 7 | 8 | describe('field--default custom element', () => { 9 | const createFieldComponent = content => defineComponent({ 10 | components: { 'field-image': FieldDefault }, 11 | setup() { 12 | const { renderCustomElements } = useDrupalCe() 13 | return { component: renderCustomElements({ 14 | element: 'field-image', 15 | content, 16 | }) } 17 | }, 18 | template: '', 19 | }) 20 | 21 | it('renders field content via content attribute', async () => { 22 | const wrapper = await mountSuspended(createFieldComponent( 23 | 'test image', 24 | )) 25 | expect(wrapper.html()).toContain(' { 9 | const nodeData = { 10 | element: 'node', 11 | title: 'Test article', 12 | body: '

Some example body.

', 13 | image: 'test' 14 | } 15 | 16 | const createNodeComponent = (data = nodeData) => defineComponent({ 17 | components: { 'node': NodeDefault }, 18 | setup() { 19 | const { renderCustomElements } = useDrupalCe() 20 | return { component: renderCustomElements(data) } 21 | }, 22 | template: '' 23 | }) 24 | 25 | it('renders node with all attributes', async () => { 26 | const wrapper = await mountSuspended(createNodeComponent()) 27 | expect(wrapper.find('h2').text()).toBe('Node: Test article') 28 | expect(wrapper.html()).toContain('Some example body') 29 | expect(wrapper.html()).toContain(' { 33 | const wrapper = await mountSuspended(createNodeComponent({ 34 | ...nodeData, 35 | title: undefined 36 | })) 37 | expect(wrapper.find('h2').exists()).toBe(false) 38 | expect(wrapper.html()).toContain('Some example body') 39 | expect(wrapper.html()).toContain(' { 43 | const wrapper = await mountSuspended(createNodeComponent({ 44 | element: 'node', 45 | title: 'Just a title', 46 | })) 47 | expect(wrapper.find('h2').text()).toBe('Node: Just a title') 48 | expect(wrapper.find('img').exists()).toBe(false) 49 | expect(wrapper.find('p').exists()).toBe(false) 50 | }) 51 | 52 | it('renders node with layout builder sections', async () => { 53 | const wrapper = await mountSuspended(createNodeComponent({ 54 | element: 'node', 55 | sections: { 56 | element: 'drupal-layout', 57 | layout: 'twocol', 58 | settings: { 59 | label: 'Two column layout', 60 | column_widths: '50-50' 61 | }, 62 | first: { 63 | element: 'drupal-markup', 64 | content: '

First Column

First column content

' 65 | }, 66 | second: { 67 | element: 'drupal-markup', 68 | content: '

Second Column

Second column content

' 69 | } 70 | } 71 | })) 72 | expect(wrapper.html()).toContain('

First Column

') 73 | expect(wrapper.html()).toContain('

First column content

') 74 | expect(wrapper.html()).toContain('

Second Column

') 75 | expect(wrapper.html()).toContain('

Second column content

') 76 | }) 77 | }) -------------------------------------------------------------------------------- /test/unit/components/preview.test.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment nuxt 2 | import { describe, it, expect } from 'vitest' 3 | import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' 4 | import App from '~/app.vue' 5 | 6 | describe('Preview routes', () => { 7 | registerEndpoint('http://127.0.0.1:3001/ce-api/node/preview/node', () => ({ 8 | content: { 9 | element: 'node-article', 10 | body: '

Some example body.

' 11 | }, 12 | local_tasks: {}, 13 | messages: [], 14 | metatags: { meta: [], link: [], jsonld: [] } 15 | })) 16 | 17 | registerEndpoint('http://127.0.0.1:3001/ce-api/node/5/layout-preview', () => ({ 18 | content: { 19 | element: 'node-article', 20 | body: '

Some example body.

' 21 | }, 22 | local_tasks: {}, 23 | messages: [], 24 | metatags: { meta: [], link: [], jsonld: [] } 25 | })) 26 | 27 | registerEndpoint('/api/menu/api/menu_items/main', () => ([ 28 | { title: 'Test page', relative: '/node/1' } 29 | ])) 30 | 31 | it('renders preview/node with direct backend request, skipping drupal-ce-proxy', async () => { 32 | const component = await mountSuspended(App, { 33 | route: '/node/preview/node' 34 | }) 35 | expect(component.html()).toContain('

Some example body.

') 36 | }) 37 | 38 | it('renders node/5/layout-preview with direct backend request, skipping drupal-ce-proxy', async () => { 39 | const component = await mountSuspended(App, { 40 | route: '/node/5/layout-preview' 41 | }) 42 | expect(component.html()).toContain('

Some example body.

') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/unit/fetchPageAndMenu.test.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment nuxt 2 | import { describe, it, expect, beforeEach } from 'vitest' 3 | import { registerEndpoint } from '@nuxt/test-utils/runtime' 4 | import { useDrupalCe } from '../../src/runtime/composables/useDrupalCe' 5 | import { useNuxtApp } from '#imports' 6 | import { ref } from 'vue' 7 | 8 | describe('fetchPage and fetchMenu use the right API endpoints', () => { 9 | beforeEach(() => { 10 | const nuxtApp = useNuxtApp() 11 | nuxtApp.$i18n = undefined 12 | nuxtApp.$localePath = undefined 13 | 14 | // Register endpoints 15 | registerEndpoint('/api/drupal-ce/test-page', () => ({ 16 | content: { title: 'Via Proxy' } 17 | })) 18 | registerEndpoint('http://127.0.0.1:3001/ce-api/test-page', () => ({ 19 | content: { title: 'Direct API' } 20 | })) 21 | registerEndpoint('/api/menu/api/menu_items/main', () => ({ 22 | items: [{ title: 'Via Proxy Menu' }] 23 | })) 24 | registerEndpoint('http://127.0.0.1:3001/ce-api/api/menu_items/main', () => ({ 25 | items: [{ title: 'Direct API Menu' }] 26 | })) 27 | }) 28 | 29 | describe('fetchPage', () => { 30 | it('uses proxy by default', async () => { 31 | const { fetchPage } = useDrupalCe() 32 | const result = await fetchPage('/test-page') 33 | expect(result.value?.content?.title).toBe('Via Proxy') 34 | }) 35 | 36 | it('skips proxy', async () => { 37 | const { fetchPage } = useDrupalCe() 38 | const result = await fetchPage('/test-page', {}, undefined, true) 39 | expect(result.value?.content?.title).toBe('Direct API') 40 | }) 41 | }) 42 | 43 | describe('fetchMenu', () => { 44 | describe('without i18n', () => { 45 | it('uses proxy by default', async () => { 46 | const { fetchMenu } = useDrupalCe() 47 | const result = await fetchMenu('main') 48 | expect(result.value?.items?.[0]?.title).toBe('Via Proxy Menu') 49 | }) 50 | 51 | it('skips proxy', async () => { 52 | const { fetchMenu } = useDrupalCe() 53 | const result = await fetchMenu('main', { key: 'main-direct' }, undefined, true) 54 | expect(result.value?.items?.[0]?.title).toBe('Direct API Menu') 55 | }) 56 | }) 57 | 58 | describe('with i18n', () => { 59 | beforeEach(() => { 60 | const nuxtApp = useNuxtApp() 61 | // Mock i18n 62 | nuxtApp.$i18n = { 63 | locale: ref('fr'), 64 | defaultLocale: 'en' 65 | } 66 | nuxtApp.$localePath = (path: string) => `/fr${path}` 67 | 68 | // Register localized endpoints 69 | registerEndpoint('/api/menu/fr/api/menu_items/main', () => ({ 70 | items: [{ title: 'Via Proxy Menu FR' }] 71 | })) 72 | registerEndpoint('http://127.0.0.1:3001/ce-api/fr/api/menu_items/main', () => ({ 73 | items: [{ title: 'Direct API Menu FR' }] 74 | })) 75 | }) 76 | 77 | it('uses proxy with localized path', async () => { 78 | const { fetchMenu } = useDrupalCe() 79 | const result = await fetchMenu('main', { key: 'main-localized-proxy' }) 80 | expect(result.value?.items?.[0]?.title).toBe('Via Proxy Menu FR') 81 | }) 82 | 83 | it('skips proxy with localized path', async () => { 84 | const { fetchMenu } = useDrupalCe() 85 | const result = await fetchMenu('main', { key: 'main-localized-direct' }, undefined, true) 86 | expect(result.value?.items?.[0]?.title).toBe('Direct API Menu FR') 87 | }) 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /test/unit/renderCustomElements-defaults.test.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment nuxt 2 | import { describe, it, expect } from 'vitest' 3 | import { mountSuspended } from '@nuxt/test-utils/runtime' 4 | import { defineComponent, useNuxtApp } from '#imports' 5 | import { useDrupalCe } from '../../src/runtime/composables/useDrupalCe' 6 | 7 | describe('Custom Element Fallback Tests', () => { 8 | const NodeArticleDemo = defineComponent({ 9 | name: 'NodeArticleDemo', 10 | props: { content: String }, 11 | template: `

Article Demo Component

{{ content }}
` 12 | }) 13 | 14 | const NodeArticleDefault = defineComponent({ 15 | name: 'NodeArticleDefault', 16 | props: { content: String }, 17 | template: `

Article Default Component

{{ content }}
` 18 | }) 19 | 20 | const NodeDefault = defineComponent({ 21 | name: 'NodeDefault', 22 | props: { content: String }, 23 | template: `

Node Default Component

{{ content }}
` 24 | }) 25 | 26 | const app = useNuxtApp() 27 | app.vueApp.component('NodeArticleDemo', NodeArticleDemo) 28 | app.vueApp.component('NodeArticleDefault', NodeArticleDefault) 29 | app.vueApp.component('NodeDefault', NodeDefault) 30 | 31 | const createTestComponent = (elementData) => defineComponent({ 32 | setup() { 33 | const { renderCustomElements } = useDrupalCe() 34 | return { component: renderCustomElements(elementData) } 35 | }, 36 | template: '' 37 | }) 38 | 39 | it('renders specific component when available', async () => { 40 | const elementData = { 41 | element: 'node-article-demo', 42 | content: 'Article demo' 43 | } 44 | const wrapper = await mountSuspended(createTestComponent(elementData)) 45 | expect(wrapper.html()).toContain('Article demo') 46 | expect(wrapper.findComponent(NodeArticleDemo).exists()).toBe(true) 47 | }) 48 | 49 | it('falls back to node-article--default when specific component not found', async () => { 50 | const elementData = { 51 | element: 'node-article-custom', 52 | content: 'Testing intermediate fallback' 53 | } 54 | const wrapper = await mountSuspended(createTestComponent(elementData)) 55 | expect(wrapper.html()).toContain('Testing intermediate fallback') 56 | expect(wrapper.findComponent(NodeArticleDefault).exists()).toBe(true) 57 | }) 58 | 59 | it('falls back to node--default when no intermediate default found', async () => { 60 | const elementData = { 61 | element: 'node-special-custom', 62 | content: 'Testing final fallback' 63 | } 64 | const wrapper = await mountSuspended(createTestComponent(elementData)) 65 | expect(wrapper.html()).toContain('Testing final fallback') 66 | expect(wrapper.findComponent(NodeDefault).exists()).toBe(true) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /test/unit/renderCustomElements.test.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment nuxt 2 | import { describe, it, expect } from 'vitest' 3 | import { mountSuspended } from '@nuxt/test-utils/runtime' 4 | import { defineComponent } from 'vue' 5 | import { useDrupalCe } from '../../src/runtime/composables/useDrupalCe' 6 | import {useNuxtApp} from "#imports"; 7 | 8 | describe('renderCustomElements', () => { 9 | const { renderCustomElements } = useDrupalCe() 10 | 11 | // Define reusable test components 12 | const TestComponent = defineComponent({ 13 | name: 'TestComponent', 14 | props: { 15 | foo: String 16 | }, 17 | inheritAttrs: false, 18 | template: '
Test Component: {{ foo }}
' 19 | }) 20 | 21 | const AnotherComponent = defineComponent({ 22 | name: 'AnotherComponent', 23 | props: { 24 | bar: String 25 | }, 26 | inheritAttrs: false, 27 | template: '
Another Component: {{ bar }}
' 28 | }) 29 | const app = useNuxtApp() 30 | app.vueApp.component('TestComponent', TestComponent) 31 | app.vueApp.component('AnotherComponent', AnotherComponent) 32 | 33 | describe('basic input handling', () => { 34 | it('should return null for empty inputs', () => { 35 | expect(renderCustomElements(null)).toBe(null) 36 | expect(renderCustomElements(undefined)).toBe(null) 37 | expect(renderCustomElements({})).toBe(null) 38 | }) 39 | 40 | it('should render nothing when component is null', async () => { 41 | const wrapper = await mountSuspended(defineComponent({ 42 | setup() { 43 | return { component: renderCustomElements(null) } 44 | }, 45 | template: '' 46 | })) 47 | expect(wrapper.html()).toBe('') 48 | }) 49 | }) 50 | 51 | describe('string rendering', () => { 52 | it('should render plain text', async () => { 53 | const wrapper = await mountSuspended(defineComponent({ 54 | setup() { 55 | return { component: renderCustomElements('Hello World') } 56 | }, 57 | template: '' 58 | })) 59 | expect(wrapper.text()).toBe('Hello World') 60 | }) 61 | 62 | it('should render HTML string preserving markup', async () => { 63 | const htmlString = '

Hello World

' 64 | const wrapper = await mountSuspended(defineComponent({ 65 | setup() { 66 | return { component: renderCustomElements(htmlString) } 67 | }, 68 | template: '' 69 | })) 70 | expect(wrapper.html()).toContain(htmlString) 71 | expect(wrapper.html()).toEqual('
\n ' + htmlString + '\n
') 72 | expect(wrapper.text()).toBe('Hello World') 73 | 74 | // Ensure bogus html and html with multiple root nodes works. 75 | const bogusHtmlString = '

second element

'; 76 | const component = await mountSuspended(defineComponent({ 77 | setup() { 78 | return { component: renderCustomElements(bogusHtmlString) } 79 | }, 80 | template: '' 81 | })) 82 | // The bogus html string has to be cleaned and the 2nd element kept. 83 | expect(component.html()).toEqual('
\n' + 84 | '
\n' + 85 | '

second element

\n' + 86 | '
') 87 | }) 88 | }) 89 | 90 | describe('custom element rendering', () => { 91 | it('should render a single custom element', async () => { 92 | const wrapper = await mountSuspended(defineComponent({ 93 | setup() { 94 | return { component: renderCustomElements({ 95 | element: 'test-component', 96 | foo: 'bar' 97 | })} 98 | }, 99 | template: '' 100 | })) 101 | expect(wrapper.text()).toBe('Test Component: bar') 102 | expect(wrapper.html()).toEqual('
Test Component: bar
') 103 | }) 104 | 105 | it('rendering known html tags is not supported', async () => { 106 | // Note: This produces a [Vue warn] also. 107 | const wrapper = await mountSuspended(defineComponent({ 108 | setup() { 109 | return { component: renderCustomElements({ 110 | element: 'span', 111 | title: 'test', 112 | content: 'Text content' 113 | })} 114 | }, 115 | template: '' 116 | })) 117 | expect(wrapper.html()).toEqual('') 118 | }) 119 | }) 120 | 121 | describe('array handling', () => { 122 | it('should render array of markup strings', async () => { 123 | const wrapper = await mountSuspended(defineComponent({ 124 | setup() { 125 | return { component: renderCustomElements(['Content 1', '

Text 2

']) } 126 | }, 127 | template: '' 128 | })) 129 | // Content will be wrapped in divs but should be there. 130 | expect(wrapper.text()).toContain('Content 1') 131 | expect(wrapper.html()).toContain('

Text 2

') 132 | }) 133 | 134 | it('should render array of custom elements without a wrapper div', async () => { 135 | const wrapper = await mountSuspended(defineComponent({ 136 | setup() { 137 | return { component: renderCustomElements([ 138 | { element: 'test-component', foo: 'one' }, 139 | { element: 'another-component', bar: 'two' } 140 | ]) } 141 | }, 142 | template: '' 143 | })) 144 | expect(wrapper.html()).not.toContain('
') 145 | expect(wrapper.text()).toContain('Test Component: one') 146 | expect(wrapper.text()).toContain('Another Component: two') 147 | expect(wrapper.html()).toEqual("
Test Component: one
\n" + 148 | "
Another Component: two
") 149 | }) 150 | }) 151 | 152 | describe('edge cases', () => { 153 | it('should handle malformed element objects', async () => { 154 | const wrapper = await mountSuspended(defineComponent({ 155 | setup() { 156 | return { component: renderCustomElements({ element: 'test-component' })} 157 | }, 158 | template: '' 159 | })) 160 | expect(wrapper.text()).toBe('Test Component:') 161 | }) 162 | 163 | it('should handle nonexistent components', async () => { 164 | const wrapper = await mountSuspended(defineComponent({ 165 | setup() { 166 | return { component: renderCustomElements({ 167 | element: 'nonexistent-component', 168 | foo: 'bar' 169 | })} 170 | }, 171 | template: '' 172 | })) 173 | expect(wrapper.html()).toBe('') 174 | }) 175 | 176 | it('should handle empty arrays', async () => { 177 | const wrapper = await mountSuspended(defineComponent({ 178 | setup() { 179 | return { component: renderCustomElements([]) } 180 | }, 181 | template: '' 182 | })) 183 | expect(wrapper.html()).toBe('') 184 | }) 185 | }) 186 | }) 187 | -------------------------------------------------------------------------------- /test/userLoginForm.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { describe, it, expect } from 'vitest' 3 | import { setup, createPage } from '@nuxt/test-utils/e2e' 4 | import DrupalCe from '../' 5 | import { join } from 'node:path' 6 | 7 | describe('User login form', async () => { 8 | await setup({ 9 | rootDir: join(fileURLToPath(import.meta.url), '../../playground'), 10 | nuxtConfig: { 11 | modules: [ 12 | DrupalCe, 13 | ], 14 | drupalCe: { 15 | drupalBaseUrl: 'http://127.0.0.1:3011', 16 | ceApiEndpoint: '/ce-api', 17 | }, 18 | }, 19 | port: 3011, 20 | }) 21 | 22 | it('catches wrong credentials message', async () => { 23 | const page = await createPage('/user/login') 24 | 25 | const name = page.locator('input[name="name"]') 26 | const pwd = page.locator('input[name="pass"]') 27 | const submit = page.locator('input[type="submit"]') 28 | 29 | expect(await name.isVisible()).toBe(true) 30 | 31 | await name.fill('admin') 32 | await pwd.fill('wrongpwd') 33 | 34 | await submit.click() 35 | 36 | await page.waitForSelector('text=Unrecognized username or password', { timeout: 5000 }) 37 | expect(await page.getByText('Unrecognized username or password').isVisible()).toBe(true) 38 | }) 39 | 40 | it('correctly logs-in user', async () => { 41 | const page = await createPage('/user/login') 42 | 43 | const name = page.locator('input[name="name"]') 44 | const pwd = page.locator('input[name="pass"]') 45 | const submit = page.locator('input[type="submit"]') 46 | 47 | expect(await name.isVisible()).toBe(true) 48 | 49 | await name.fill('admin') 50 | await pwd.fill('drupal123') 51 | 52 | await submit.click() 53 | // Wait for login success redirect 54 | await page.waitForURL('**/node/1') 55 | 56 | // Check for session cookie 57 | const cookies = await page.context().cookies() 58 | expect(cookies.some(cookie => cookie.name === 'SSESSf9f2dc90f4drupal')).toBe(true) 59 | // Cleanup 60 | await page.context().clearCookies() 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./playground/.nuxt/tsconfig.json" 3 | } -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // vitest.config.ts 2 | import { fileURLToPath } from 'node:url' 3 | import { defineVitestConfig } from '@nuxt/test-utils/config' 4 | 5 | export default defineVitestConfig({ 6 | test: { 7 | environmentOptions: { 8 | nuxt: { 9 | rootDir: fileURLToPath(new URL('./playground', import.meta.url)), 10 | port: 3001, 11 | overrides: { 12 | modules: ['../dist/module.mjs'], 13 | drupalCe: { 14 | drupalBaseUrl: 'http://127.0.0.1:3001', 15 | ceApiEndpoint: '/ce-api', 16 | } 17 | } 18 | } 19 | }, 20 | testTimeout: 10000, 21 | include: ['test/**/*.test.ts'], 22 | }, 23 | }) 24 | --------------------------------------------------------------------------------