HTML Content
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.
<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.@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, likev-html. - On mount, it sets
innerHTMLonly 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.
config only changes the menu. To forbid a style, remove its extension from the editor, or clean the HTML on the API.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.