The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
DraftComponent Helpers

HTML Content

Render rich-text HTML from a CWA resource with CwaLink replacing anchor tags so internal links stay client-side.

Rich-text components (e.g. a WYSIWYG or markdown-rendered field) output raw HTML containing <a> tags. Those tags cause full-page reloads for internal links. useHtmlContent solves this by replacing every <a> tag in the container with a <CwaLink> Vue component after mount.

It also returns vCwaHtml, a directive you use in place of v-html. It renders the same HTML without redrawing it during hydration.

Usage

<!-- app/cwa/components/RichText/RichText.vue -->
<template>
    <div ref="container" v-cwa-html="content" class="prose" /></template>

<script setup lang="ts">
import { computed, ref, toRef } from 'vue'
import type { IriProp } from '#cwa/composables/cwa-resource'
import { useCwaResource, useHtmlContent } from '#imports'

const props = defineProps<IriProp>()

const { getResource, exposeMeta } = useCwaResource(toRef(props, 'iri'))
const resource = getResource()

const content = computed(() => resource.value?.data?.content ?? '')

const container = ref<HTMLElement | null>(null)
const { vCwaHtml } = useHtmlContent(container, content) 
defineExpose(exposeMeta)
</script>

Pass container, a template ref to the element that holds the HTML, and content, the HTML source itself. useHtmlContent walks the container's DOM, finds <a> elements, and mounts <CwaLink> components in their place. It runs again whenever the element or the HTML changes.

The directive is not registered globally. Destructure vCwaHtml in <script setup>, and Vue makes it available as v-cwa-html.

Pass the second argument. It is optional for backwards compatibility, but with only the container ref the conversion tracks the element rather than the HTML — so content that changes while the same element stays mounted keeps its raw <a> tags, and those internal links then cause full page loads. This bites whenever a field can change in place: an inline editor, or a resource updated over Mercure.
Requires @cwa/nuxt-edge 0.0.0-29836337.fbe401d or later. Older builds return nothing from useHtmlContent. On those, keep using v-html. Calling useHtmlContent without using its return value still works.

Why Not v-html

Since Vue 3.5.39, hydration sets innerHTML again for every v-html, even when the server HTML is identical. The paragraph the browser has already painted is thrown away and parsed again.

That matters when body text is the page's largest element. Its Largest Contentful Paint (LCP) moves from first paint to after the scripts have run. In the template's production Lighthouse runs, this added 0.45–0.68s to LCP in 7 of 8 loads.

v-cwa-html avoids the redraw:

  • On the server, it renders the HTML as innerHTML, like v-html.
  • On mount, it sets innerHTML only if the DOM differs from the value.
  • On update, it replaces the HTML only when the value has changed.

It patches in beforeUpdate, not updated. So it never replaces the HTML after useHtmlContent has converted the links.

Loading an Editor Lazily

A rich-text component often shows an editor to admins, such as TipTap. Import the editor with defineAsyncComponent, not statically. A static import puts the editor into the preloads of every page with body text, for anonymous visitors too. In the template, that was about 424 KB of TipTap and ProseMirror (135 KB gzipped).

<template>
  <article>
    <TipTapHtmlEditor v-if="$cwa.admin.isEditing" v-model="model" />
    <div v-else ref="container" v-cwa-html="content" class="prose" />
  </article>
</template>

<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

// Fetched only when an admin starts editing.
const TipTapHtmlEditor = defineAsyncComponent(() => import('~/components/TipTapHtmlEditor.vue')) // ...
</script>

Anywhere else that only needs the editor's type, use import type. A value import keeps the editor in the dev bundle.

import type TipTapHtmlEditor from '~/components/TipTapHtmlEditor.vue'

const editorComponent = ref<typeof TipTapHtmlEditor | undefined>()

Nuxt still adds a prefetch hint for the async chunk, so browsers may download it in the background. The template removes that hint with a build:manifest hook in nuxt.config.ts. It filters the editor out of each chunk's dynamicImports. The editor still loads on demand when it is first shown.

// nuxt.config.ts
const isEditorSource = (id: string) => id.endsWith('components/TipTapHtmlEditor.vue')

export default defineNuxtConfig({
  modules: [
    (_options, nuxt) => {
      nuxt.hook('build:manifest', (manifest) => {
        for (const chunk of Object.values(manifest)) {
          chunk.dynamicImports = chunk.dynamicImports?.filter(id => !isEditorSource(id))
        }
      })
    },
  ],
})

The module already keeps its own admin UI out of visitors' prefetch hints. It can't see your editor, because the editor is app code, so keep this hook.

Choosing the Editor's Buttons

The template's app/components/TipTapHtmlEditor.vue shows a menu of formatting buttons: H1, H2, Bold, Italic, Underline, Link and Bullet List. Every button shows by default. Pass the config prop to hide some of them for one field.

For example, a short description that allows only bold, italic and links:

<TipTapHtmlEditor
  v-if="$cwa.admin.isEditing"
  v-model="model"
  :config="{ h1: false, h2: false, underline: false, bulletList: false }"
/>

The keys are h1, h2, bold, italic, underline, link and bulletList. Set a key to false to hide that button. The prop is typed, so a misspelt key fails the type check. The config prop and the Underline button came in template commit 57d2009. A project made before that can copy the file from the template.

H1, H2 and Bullet List also appear in the menu shown on an empty line. That menu is hidden when all three are off.

Hiding a button doesn't stop the style. The editor still has every extension, so pasted content and keyboard shortcuts (such as Ctrl+U, or Cmd+U on a Mac, for underline) still apply it. config only changes the menu. To forbid a style, remove its extension from the editor, or clean the HTML on the API.
Image placeholder: the editor's selection menu for a field with config set to bold, italic and link only, next to the full menu with all seven buttons.

What It Does

  • Finds every <a href="..."> inside the container
  • Creates a <CwaLink> Vue component instance for each
  • Mounts it as a replacement — internal paths get <NuxtLink> behaviour, external URLs open in a new tab
  • Unmounts the replacement link apps before re-converting, and again when the parent component unmounts

When to Use It

Use useHtmlContent for any component that renders HTML strings from the API into the page — rich-text editors, markdown output, or any field where the content author controls anchor tags. Without it, every internal link causes a hard navigation.