Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/build-js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ jobs:
fetch-depth: 0
- uses: pnpm/action-setup@v2
with:
version: 9.0.6
version: 11.0.0
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
cache-dependency-path: "pnpm-lock.yaml"

Expand All @@ -41,10 +41,10 @@ jobs:
fetch-depth: 0
- uses: pnpm/action-setup@v2
with:
version: 9.0.6
version: 11.0.0
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
cache-dependency-path: "pnpm-lock.yaml"

Expand All @@ -53,3 +53,6 @@ jobs:

- name: Build examples
run: pnpm --filter "./examples/**" --workspace-concurrency=1 run build

- name: Test vue-chat
run: cd examples/vue-chat && pnpm nuxt prepare && pnpm exec vitest run
18 changes: 15 additions & 3 deletions examples/vue-chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A chat application built with [Nuxt 3](https://nuxt.com), [Vercel AI SDK](https:

### Prerequisites

- Node.js 18+
- Node.js 22+
- [pnpm](https://pnpm.io/)
- An OpenAI API key

Expand Down Expand Up @@ -48,11 +48,22 @@ The generated prompt lives at `generated/system-prompt.txt` and is checked in, s
### Run

```bash
pnpm --filter vue-chat prepare
pnpm --filter vue-chat dev
```

Open [http://localhost:3000](http://localhost:3000).

### Test

```bash
pnpm --filter vue-chat test
```

### Known issues

- **Nuxt IPC socket error with `ssr: false`**: Nuxt 3.21.x has a bug where the dev server fails with `Vite Node IPC socket path not configured` in SPA mode. A workaround is included in `nuxt.config.ts` — see the comment there for details. This will be removed once the app upgrades to Nuxt 3.21.9+ where the bug is fixed upstream ([nuxt/nuxt#34957](https://github.com/nuxt/nuxt/issues/34957)).

## Project structure

```
Expand All @@ -70,9 +81,10 @@ components/
└── openui/ # Vue component renderers for openui-lang output
├── Stack.vue
├── Card.vue
├── TextContent.vue
├── TextContent.vue # Renders text with markdown support
├── Button.vue
└── Chart.vue
├── Chart.vue
└── __tests__/ # Component unit tests
lib/
├── library.ts # OpenUI component definitions (Stack, Card, TextContent, Button, Chart)
└── tools.ts # AI tool definitions (weather, stocks, math, search)
Expand Down
123 changes: 121 additions & 2 deletions examples/vue-chat/components/openui/TextContent.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,126 @@
<script setup lang="ts">
defineProps<{ props: { text?: string } }>();
import DOMPurify from "dompurify";
import { marked, Renderer } from "marked";
import { computed } from "vue";

const { props } = defineProps<{ props: { text?: string } }>();

const renderer = new Renderer();
renderer.html = ({ text }: { text: string }) => {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
};

const html = computed(() => {
const raw = marked.parse(props.text ?? "", { renderer, async: false });
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS: [
"p", "br", "strong", "em", "code", "pre", "a", "ul", "ol", "li",
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "hr",
"table", "thead", "tbody", "tr", "th", "td",
],
ALLOWED_ATTR: ["href", "target", "rel"],
});
});
</script>

<template>
<p class="text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">{{ props.text ?? "" }}</p>
<div class="markdown-content text-sm leading-relaxed text-zinc-700 dark:text-zinc-300" v-html="html"></div>
</template>

<style scoped>
.markdown-content :deep(h1) {
font-size: 1.5em;
font-weight: 700;
margin: 0.67em 0;
line-height: 1.2;
}
.markdown-content :deep(h2) {
font-size: 1.25em;
font-weight: 600;
margin: 0.75em 0;
line-height: 1.3;
}
.markdown-content :deep(h3) {
font-size: 1.1em;
font-weight: 600;
margin: 0.83em 0;
line-height: 1.4;
}
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
font-size: 1em;
font-weight: 600;
margin: 1em 0;
line-height: 1.4;
}
.markdown-content :deep(p) {
margin: 0.5em 0;
}
.markdown-content :deep(strong) {
font-weight: 600;
}
.markdown-content :deep(em) {
font-style: italic;
}
.markdown-content :deep(code) {
font-size: 0.875em;
padding: 0.15em 0.4em;
border-radius: 0.25rem;
background: rgb(228 228 231 / 1);
}
.markdown-content :deep(pre) {
overflow-x: auto;
padding: 0.75rem;
border-radius: 0.5rem;
background: rgb(24 24 27 / 1);
color: rgb(212 212 216 / 1);
margin: 0.75em 0;
}
.markdown-content :deep(pre code) {
padding: 0;
background: transparent;
border-radius: 0;
}
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
padding-left: 1.5em;
margin: 0.5em 0;
}
.markdown-content :deep(ul) {
list-style-type: disc;
}
.markdown-content :deep(ol) {
list-style-type: decimal;
}
.markdown-content :deep(li) {
margin: 0.25em 0;
}
.markdown-content :deep(a) {
color: rgb(99 102 241 / 1);
text-decoration: underline;
}
.markdown-content :deep(blockquote) {
border-left: 3px solid rgb(212 212 216 / 0.4);
padding-left: 0.75em;
margin: 0.75em 0;
color: rgb(161 161 170 / 1);
}
.markdown-content :deep(hr) {
border: none;
border-top: 1px solid rgb(228 228 231 / 1);
margin: 1em 0;
}
.markdown-content :deep(table) {
border-collapse: collapse;
margin: 0.75em 0;
}
.markdown-content :deep(th),
.markdown-content :deep(td) {
border: 1px solid rgb(228 228 231 / 1);
padding: 0.4em 0.75em;
}
.markdown-content :deep(th) {
font-weight: 600;
}
</style>
38 changes: 38 additions & 0 deletions examples/vue-chat/components/openui/__tests__/TextContent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { nextTick } from "vue";
import TextContent from "../TextContent.vue";

const mountText = (text: string) =>
mount(TextContent, {
props: { props: { text } },
});

describe("TextContent", () => {
it("renders plain text", async () => {
const wrapper = mountText("Hello world");
await nextTick();
expect(wrapper.text()).toBe("Hello world");
});

it("renders bold markdown", async () => {
const wrapper = mountText("**bold**");
await nextTick();
expect(wrapper.find("strong").exists()).toBe(true);
expect(wrapper.find("strong").text()).toBe("bold");
});

it("renders inline code", async () => {
const wrapper = mountText("`code`");
await nextTick();
expect(wrapper.find("code").exists()).toBe(true);
expect(wrapper.find("code").text()).toBe("code");
});

it("escapes raw HTML", async () => {
const wrapper = mountText('<script>alert(1)</script>');
await nextTick();
expect(wrapper.html()).not.toContain("<script>");
expect(wrapper.html()).toContain("&lt;script&gt;");
});
});
38 changes: 34 additions & 4 deletions examples/vue-chat/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { useNitro } from "@nuxt/kit";

// WORKAROUND: Nuxt 3.21.x has a bug where dev server fails with
// "Vite Node IPC socket path not configured" when ssr: false.
// This workaround bypasses the vite-node manifest fetch by providing
// a direct file reference. See: https://github.com/nuxt/nuxt/issues/34957
// This can be removed once Nuxt is upgraded to 3.21.9+ where the bug is fixed.

export default defineNuxtConfig({
compatibilityDate: "2025-03-01",
ssr: false,
modules: [],
modules: [
function spaDevManifestWorkaround(_options, nuxt) {
nuxt.hook("vite:extendConfig", (_config, context) => {
if (!nuxt.options.dev || nuxt.options.ssr || !context.isClient) {
return;
}

const nitro = useNitro();
const clientManifestPath = pathToFileURL(
resolve(nuxt.options.buildDir, "dist/server/client.manifest.mjs"),
).href;

nitro.options.virtual ||= {};
nitro.options._config.virtual ||= {};

for (const virtual of [nitro.options.virtual, nitro.options._config.virtual]) {
virtual["#build/dist/server/server.mjs"] = "export default () => {}";
virtual["#build/dist/server/client.manifest.mjs"] =
`export { default } from ${JSON.stringify(clientManifestPath)}`;
}
});
},
],
css: ["~/assets/app.css"],
nitro: {
// lang-core uses bundler-style extensionless imports in dist/
// so Nitro must bundle (not externalize) it for Node ESM compat
externals: {
inline: ["@openuidev/lang-core", "@openuidev/vue-lang"],
},
Expand All @@ -20,4 +50,4 @@ export default defineNuxtConfig({
"@tailwindcss/postcss": {},
},
},
});
});
7 changes: 7 additions & 0 deletions examples/vue-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"test": "nuxt prepare && vitest run",
"generate:prompt": "node scripts/generate-prompt.mjs"
},
"dependencies": {
Expand All @@ -15,14 +16,20 @@
"@openuidev/vue-lang": "workspace:*",
"ai": "^6.0.116",
"chart.js": "^4.5.1",
"dompurify": "^3.4.5",
"marked": "^17.0.5",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/test-utils": "^2.4.0",
"jiti": "^2.0.0",
"jsdom": "catalog:",
"nuxt": "^3.17.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.0",
"vue": "^3.5.0",
"vue-router": "^4.5.0"
}
Expand Down
10 changes: 10 additions & 0 deletions examples/vue-chat/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vitest/config";

export default defineConfig({
plugins: [vue()],
test: {
include: ["components/**/*.test.ts"],
environment: "jsdom",
},
});
13 changes: 12 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@
"cookie@<0.7.0": ">=0.7.0",
"prismjs@<1.30.0": ">=1.30.0",
"@ai-sdk/provider-utils@<=3.0.97": "^4.0.27"
}
},
"onlyBuiltDependencies": [
"@google/genai",
"@parcel/watcher",
"@scarf/scarf",
"core-js",
"esbuild",
"protobufjs",
"sharp",
"sqlite3",
"unrs-resolver"
]
}
}
Loading
Loading