diff --git a/README.md b/README.md index 6f71f9bd..b42964af 100755 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ is added automatically to requests. Defaults to `false`. - `'legacy'`: Legacy format with props and slots flattened at the same level. Explicitly configure this for improved compatibility with older backends. Defaults to `'explicit'`. +- `jsonRender`: Enables rendering custom_elements' [json-render](https://github.com/vercel-labs/json-render) output format — a flat element map (`{root, elements}`) instead of a nested custom element tree. Requires installing the optional dependencies: `npm install @json-render/vue zod`. When a page's content is a json-render spec, it renders via `@json-render/vue`; element types resolve through the same component resolution as the other formats, named slots and `drupal-markup` inline-HTML elements included. Defaults to `false`. + - `customErrorPages`: By default, error pages provided by Drupal (e.g. 403, 404 page) are shown, while keeping the right status code. By enabling customErrorPages, the regular Nuxt error pages are shown instead, such that the pages can be customized with Nuxt. Defaults to `false`. diff --git a/package-lock.json b/package-lock.json index cf23a732..f47a3775 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nuxtjs-drupal-ce", - "version": "2.8.0", + "version": "2.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nuxtjs-drupal-ce", - "version": "2.8.0", + "version": "2.9.0", "license": "MIT", "dependencies": { "defu": "^6.1.7", @@ -17,6 +17,7 @@ "nuxt-drupal-ce-init": "bin/nuxt-drupal-ce-init.cjs" }, "devDependencies": { + "@json-render/vue": "^0.20.0", "@nuxt/eslint": "^1.17.0", "@nuxt/eslint-config": "^1.17.0", "@nuxt/kit": "^4.5.2", @@ -33,7 +34,20 @@ "playwright-core": "^1.62.1", "typescript": "^5.9.3", "vitest": "^4.1.11", - "vue": "^3.5.41" + "vue": "^3.5.41", + "zod": "^4.0.0" + }, + "peerDependencies": { + "@json-render/vue": ">=0.20.0", + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "@json-render/vue": { + "optional": true + }, + "zod": { + "optional": true + } } }, "node_modules/@antfu/install-pkg": { @@ -2704,6 +2718,33 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@json-render/core": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@json-render/core/-/core-0.20.0.tgz", + "integrity": "sha512-cXAXn7h3QaylefQgh85AEGPuVq4C8SJ3k9JqigWLJhaBSozF1Fmsrm43DP16RmkqNiVgj5pBRdmB48lEO5qlow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.3.6" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@json-render/vue": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@json-render/vue/-/vue-0.20.0.tgz", + "integrity": "sha512-vASy4ygzrv/h2yxwiEUkX/HomYcJ30T3oYL1LMknvAdNu4bwljDHEkx0tLxvYiogmwDQfT9nfFI5UzpbpHtVEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@json-render/core": "0.20.0" + }, + "peerDependencies": { + "vue": "^3.5.0", + "zod": "^4.0.0" + } + }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", @@ -19244,6 +19285,16 @@ "engines": { "node": ">= 14" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index c571f5b0..57a3aad6 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,20 @@ "nuxt-component-preview": "^1.1.1", "ufo": "^1.6.4" }, + "peerDependencies": { + "@json-render/vue": ">=0.20.0", + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "@json-render/vue": { + "optional": true + }, + "zod": { + "optional": true + } + }, "devDependencies": { + "@json-render/vue": "^0.20.0", "@nuxt/eslint": "^1.17.0", "@nuxt/eslint-config": "^1.17.0", "@nuxt/kit": "^4.5.2", @@ -55,6 +68,7 @@ "playwright-core": "^1.62.1", "typescript": "^5.9.3", "vitest": "^4.1.11", - "vue": "^3.5.41" + "vue": "^3.5.41", + "zod": "^4.0.0" } } diff --git a/src/module.ts b/src/module.ts index 6d29ccc2..cd745ff9 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,4 +1,4 @@ -import { defineNuxtModule, addPlugin, addServerPlugin, createResolver, addImportsDir, addServerHandler, addImports, installModule } from '@nuxt/kit' +import { defineNuxtModule, addPlugin, addServerPlugin, createResolver, addImportsDir, addServerHandler, addImports, addComponent, installModule } from '@nuxt/kit' import { defu } from 'defu' import type { NuxtOptionsWithDrupalCe } from './runtime/types' @@ -63,6 +63,11 @@ export interface ModuleOptions { enableComponentPreview?: boolean /** Extra Drupal JS URLs (substring match) the library loader must not load. */ skipLibraryScripts?: string[] + /** + * Render custom_elements' json-render output format. Requires the optional + * `@json-render/vue` dependency (plus its `zod` peer) to be installed. + */ + jsonRender?: boolean } export default defineNuxtModule({ @@ -91,6 +96,7 @@ export default defineNuxtModule({ disableFormHandler: false, enableComponentPreview: true, skipLibraryScripts: [], + jsonRender: false, }, async setup(options, nuxt) { const nuxtOptions = nuxt.options as NuxtOptionsWithDrupalCe @@ -114,6 +120,17 @@ export default defineNuxtModule({ addPlugin(resolve(runtimeDir, 'plugins/payloadPath.client')) addPlugin(resolve(runtimeDir, 'plugins/drupalMarkup')) + // json-render support is opt-in: the component statically imports the + // optional @json-render/vue dependency, so it must stay unregistered (and + // unbundled) unless enabled. + if (options.jsonRender) { + addComponent({ + name: 'DrupalCeJsonRender', + filePath: resolve(runtimeDir, 'components/DrupalCeJsonRender'), + global: true, + }) + } + // Add form handler middleware if not disabled (via boolean) if (!(options.disableFormHandler === true)) { addServerHandler({ diff --git a/src/runtime/components/DrupalCeJsonRender.ts b/src/runtime/components/DrupalCeJsonRender.ts new file mode 100644 index 00000000..c5c9c326 --- /dev/null +++ b/src/runtime/components/DrupalCeJsonRender.ts @@ -0,0 +1,79 @@ +import type { PropType, VNode } from 'vue' +import { defineComponent, computed, h } from 'vue' +import { JSONUIProvider, Renderer } from '@json-render/vue' +import { useDrupalCe } from '../composables/useDrupalCe' +import type { JsonRenderSpec, JsonRenderElement } from '../types' + +type JsonRenderRegistry = Record + +/** + * Renders a custom_elements json-render spec via `@json-render/vue`. + * + * Registered globally only when the `jsonRender` module option is enabled, so + * the optional `@json-render/vue` dependency stays out of the bundle + * otherwise. + * + * - Element types resolve through the module's regular custom-element + * component resolution, so the same components serve markup, JSON and + * json-render rendering. + * - `drupal-markup` elements carry inline HTML in `props.markup` and render + * through the app's `drupal-markup` component (`content` prop). + * - json-render walks `children` only; the named `slots` of an element are + * bridged by rendering each slot entry as a sub-spec rooted at it. + */ +export default defineComponent({ + name: 'DrupalCeJsonRender', + props: { + spec: { + type: Object as PropType, + required: true, + }, + }, + setup(props) { + const { resolveCustomElement } = useDrupalCe() + + const makeRegistryComponent = (component: unknown, type: string, spec: JsonRenderSpec, registry: JsonRenderRegistry) => { + const wrapper = (jsonRenderProps: { element: JsonRenderElement }, context: { slots: Record VNode[]) | undefined> }) => { + const element = jsonRenderProps.element + if (type === 'drupal-markup') { + return h(component as object, { content: element.props?.markup ?? '' }) + } + const slotFunctions: Record VNode | VNode[] | undefined> = {} + if (context.slots.default) { + slotFunctions.default = context.slots.default + } + Object.entries(element.slots ?? {}).forEach(([slotName, elementKeys]) => { + slotFunctions[slotName] = () => elementKeys.map(elementKey => h(Renderer, { + key: elementKey, + spec: { ...spec, root: elementKey }, + registry, + })) + }) + return h(component as object, element.props ?? {}, slotFunctions) + } + wrapper.props = ['element', 'emit', 'on', 'bindings', 'loading'] + return wrapper + } + + const registry = computed(() => { + const spec = props.spec + const registry: JsonRenderRegistry = {} + Object.values(spec.elements).forEach((element) => { + if (registry[element.type]) { + return + } + const component = resolveCustomElement(element.type) + if (component) { + registry[element.type] = makeRegistryComponent(component, element.type, spec, registry) + } + }) + return registry + }) + + // The renderer requires its provider contexts; JSONUIProvider bundles + // them all with defaults. + return () => h(JSONUIProvider, { registry: registry.value }, { + default: () => h(Renderer, { spec: props.spec, registry: registry.value }), + }) + }, +}) diff --git a/src/runtime/composables/useDrupalCe/index.ts b/src/runtime/composables/useDrupalCe/index.ts index 910b5ea5..2b394023 100644 --- a/src/runtime/composables/useDrupalCe/index.ts +++ b/src/runtime/composables/useDrupalCe/index.ts @@ -7,7 +7,17 @@ import type { DrupalResolvedLibrary } from './drupalLibraryLoader' import type { UseFetchOptions, AsyncData } from '#app' import { callWithNuxt } from '#app' import { useRuntimeConfig, useState, useFetch, navigateTo, createError, h, resolveComponent, setResponseStatus, useNuxtApp, useRequestHeaders, ref, watch, useRequestEvent, computed, useHead, toRef, useRoute, useRouter, useSlots } from '#imports' -import type { DrupalCePage, DrupalCeApiResponse } from '../../types' +import type { DrupalCePage, DrupalCeApiResponse, JsonRenderSpec } from '../../types' + +/** + * Whether the given custom elements content is a json-render spec — the flat + * element-map format custom_elements can emit (issue #3580092) — rather than + * a nested explicit/legacy custom element object. + */ +export const isJsonRenderSpec = (content: unknown): content is JsonRenderSpec => + typeof content === 'object' && content !== null && !Array.isArray(content) + && typeof (content as JsonRenderSpec).root === 'string' + && typeof (content as JsonRenderSpec).elements === 'object' && (content as JsonRenderSpec).elements !== null // Cache the dynamic import of the library loader in a single module-level // promise. All loadLibrary() callers await the *same* promise, so their @@ -537,6 +547,19 @@ export const useDrupalCe = () => { return customElements.map(element => renderCustomElementsToVNodes(element)) } + // Handle the json-render format: a flat element map plus a root reference. + if (isJsonRenderSpec(customElements)) { + // Resolved directly by name: the component only exists when the + // `jsonRender` module option registered it, and the custom-element + // fallback resolution must not kick in for this internal component. + const component = useNuxtApp().vueApp.component('DrupalCeJsonRender') + if (component) { + return h(component, { spec: customElements }) + } + console.error('[nuxtjs-drupal-ce] Received a json-render spec, but json-render support is not enabled. Set the `jsonRender` module option and install the optional `@json-render/vue` dependency (plus its `zod` peer).') + return null + } + // Handle single custom element object based on configured format if (config.customElementJsonFormat === 'explicit') { // Verify format is explicit: check for keys that are NOT element/props/slots diff --git a/src/runtime/types.d.ts b/src/runtime/types.d.ts index e81f177c..da1b057b 100644 --- a/src/runtime/types.d.ts +++ b/src/runtime/types.d.ts @@ -40,8 +40,32 @@ export type CustomElementContent = | undefined | string | CustomElementContentObject + | JsonRenderSpec | Array +/** + * One element of a json-render spec. + * + * `children` and slot entries reference other elements of the spec by key. + * A `drupal-markup` element carries inline HTML in `props.markup`. + */ +export interface JsonRenderElement { + type: string + props?: Record + children?: string[] + slots?: Record +} + +/** + * json-render format custom elements content: a flat element map plus the key + * of the root element, as emitted by custom_elements' json-render output + * format (drupal.org/project/custom_elements, issue #3580092). + */ +export interface JsonRenderSpec { + root: string + elements: Record +} + /** * Metatags structure */ diff --git a/test/nuxt/drupalCeJsonRender.test.ts b/test/nuxt/drupalCeJsonRender.test.ts new file mode 100644 index 00000000..548a8579 --- /dev/null +++ b/test/nuxt/drupalCeJsonRender.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment nuxt +import { describe, it, expect, beforeAll } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { defineComponent, h } from 'vue' +import { useDrupalCe, isJsonRenderSpec } from '../../src/runtime/composables/useDrupalCe' +import DrupalCeJsonRender from '../../src/runtime/components/DrupalCeJsonRender' +import { useNuxtApp } from '#imports' + +/** + * The json-render spec shape custom_elements emits (issue #3580092): a flat + * element map with children/slots referencing other elements by key, inline + * HTML wrapped in drupal-markup elements carrying props.markup. + */ +const SPEC = { + root: 'el-0', + elements: { + 'el-0': { + type: 'article-teaser', + props: { title: 'Hello', href: '/article/1' }, + children: ['el-1'], + slots: { media: ['el-2'] }, + }, + 'el-1': { + type: 'drupal-markup', + props: { markup: '

Body text

' }, + children: [], + }, + 'el-2': { + type: 'teaser-image', + props: { src: '/img.jpg' }, + children: [], + }, + }, +} + +describe('DrupalCeJsonRender', () => { + beforeAll(() => { + const vueApp = useNuxtApp().vueApp + vueApp.component('ArticleTeaser', defineComponent({ + name: 'ArticleTeaser', + props: { title: String, href: String }, + template: '

{{ title }}

', + })) + vueApp.component('TeaserImage', defineComponent({ + name: 'TeaserImage', + props: { src: String }, + template: '', + })) + vueApp.component('DrupalMarkup', defineComponent({ + name: 'DrupalMarkup', + props: { content: String }, + setup: props => () => h('div', { class: 'markup', innerHTML: props.content }), + })) + }) + + it('detects a json-render spec', () => { + expect(isJsonRenderSpec(SPEC)).toBe(true) + expect(isJsonRenderSpec({ element: 'node-article', props: {} })).toBe(false) + expect(isJsonRenderSpec('

markup

')).toBe(false) + expect(isJsonRenderSpec(null)).toBe(false) + expect(isJsonRenderSpec([SPEC])).toBe(false) + }) + + it('renders a spec: props, children, named slots and drupal-markup', async () => { + const wrapper = await mountSuspended(defineComponent({ + components: { DrupalCeJsonRender }, + setup: () => ({ spec: SPEC }), + template: '', + })) + + const article = wrapper.find('article') + expect(article.exists()).toBe(true) + expect(article.attributes('data-href')).toBe('/article/1') + expect(article.find('h2').text()).toBe('Hello') + // drupal-markup child renders through the app's markup component. + expect(article.find('.markup p').text()).toBe('Body text') + // The named slot bridges to the slot element of the spec. + expect(article.find('.media img').attributes('src')).toBe('/img.jpg') + }) + + it('routes a json-render page content through renderCustomElements when enabled', async () => { + useNuxtApp().vueApp.component('DrupalCeJsonRender', DrupalCeJsonRender) + const { renderCustomElements } = useDrupalCe() + const wrapper = await mountSuspended(defineComponent({ + setup: () => () => renderCustomElements(SPEC), + })) + expect(wrapper.find('article h2').text()).toBe('Hello') + expect(wrapper.find('.media img').attributes('src')).toBe('/img.jpg') + }) +})