feat(chat): 购物推荐结果渲染为可点击商品卡片

- 聊天 markdown 渲染器解析 product-cards 围栏为卡片网格(图片/价格/平台/去购买按钮),整卡可点跳购买页
- DOMPurify afterSanitizeAttributes 钩子补回被 ALLOWED_URI_REGEXP 剥掉的 target/referrerpolicy,确保防盗链图片加载与新标签打开
- 后端在购物推荐工具结果尾部追加卡片渲染指令,保证模型稳定输出卡片而非表格
- 技能:购物意图优先调用参考价工具并约定 product-cards 输出格式
This commit is contained in:
matevip 2026-06-08 15:11:56 +08:00
parent be7bbd9644
commit 3c87efdb15
6 changed files with 474 additions and 39 deletions

View File

@ -184,6 +184,44 @@ public class ToolExecutionExecutor {
return StructuredTruncator.truncate(result, headLen, tailLen, marker);
}
/**
* Appended to the cross-platform shopping recommendation tool's result so the
* model reliably renders products as chat cards instead of a markdown table.
* The tool's MCP schema is globally callable, so a model can invoke it without
* ever loading the skill's instructions this puts the rendering contract
* where the model always sees it: in the tool result itself.
*/
private static final String PRODUCT_CARD_RENDER_DIRECTIVE =
"\n\n[Rendering directive] Render these recommendations for the user as a"
+ " fenced code block whose language tag is exactly `product-cards`, containing a"
+ " JSON array. Each element: {name, url, imageUrl, price, originalPrice, lowestPrice,"
+ " platformLabel, shopName, purchaseAdvice}. Copy `url` and `imageUrl` verbatim from"
+ " the result above (never invent or alter them). The chat UI turns this block into"
+ " clickable product cards with a buy button. Do NOT use a markdown table or inline"
+ " image markdown for these products. You may add a short intro sentence and purchase"
+ " tips around the block.";
/**
* Returns {@code true} when the tool is the cross-platform shopping
* recommendation tool and its result actually carries product records
* (so timeouts / empty results fall through to the model's own fallback).
*/
static boolean shouldAppendProductCardDirective(String toolName, String result) {
if (toolName == null || result == null) return false;
if (!toolName.contains("ckjia_shopping_recom")) return false;
return result.contains("recommendations")
|| result.contains("imageUrl")
|| result.contains("priceTag")
|| result.contains("markdownLink");
}
/** Appends {@link #PRODUCT_CARD_RENDER_DIRECTIVE} when applicable, else returns the result unchanged. */
static String withProductCardDirective(String toolName, String result) {
return shouldAppendProductCardDirective(toolName, result)
? result + PRODUCT_CARD_RENDER_DIRECTIVE
: result;
}
private final Map<String, ToolCallback> toolCallbackMap;
/**
* Maps a normalized tool name (lowercase snake_case, with `_tool`/`_function`
@ -675,8 +713,10 @@ public class ToolExecutionExecutor {
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
// Append the card-rendering directive to the LLM-facing response only,
// leaving the broadcast tool-result panel unchanged.
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, result != null ? result : "");
toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
} catch (Exception e) {
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
String safeError = isReturnDirect(callback)
@ -905,8 +945,10 @@ public class ToolExecutionExecutor {
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true).data());
streamTracker.updateRunningTool(pc.conversationId, null);
}
// Append the card-rendering directive to the LLM-facing response only,
// leaving the broadcast tool-result panel unchanged.
return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), toolName, result != null ? result : "");
pc.toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
} catch (Exception e) {
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
// RFC-052: for returnDirect tools, even the error message is

View File

@ -34,60 +34,61 @@ tags:
当用户询问"X 多少钱 / 哪里便宜 / 帮我推荐 X / 这个值不值买 / 拍照认一下这是什么"时使用本技能。
## 优先级(最高优先级规则)
只要用户的意图属于**购物 / 比价 / 选购 / 报价**(关键词:买、多少钱、价格、参考价、推荐、性价比、哪款好、值不值、京东/淘宝/天猫/拼多多……),**必须先调用 `ckjia_shopping_recommend` 拿到结构化商品数据**,再组织回答。
- ✅ 先 `ckjia_shopping_recommend` → 拿到带图片和价格的真实商品
- ❌ 不要直接用网页搜索 / 凭记忆报价 / 编造型号和价格来回答购物类问题
- 仅当 `ckjia_shopping_recommend` 多次超时或返回为空时,才退回到网页搜索,并明确告诉用户"参考价数据暂不可用,以下为网络估算"
## 决策树
1. **"推荐 / 帮我挑 / 性价比 / 想买 X"** → `ckjia_shopping_recommend(query, top_n=5)`
1. **"推荐 / 帮我挑 / 性价比 / 想买 X / X 多少钱"** → `ckjia_shopping_recommend(query, top_n=5)`
- 想要 ckjia 顺便给出意图理解(用于澄清后续问句)→ `include_intent=true`
2. **附带图片 / 拍照识物**`ckjia_image_recognize(image_url)` → 拿到 `suggested_query` 后再 `ckjia_shopping_recommend(suggested_query)`
3. **transport 健康自检**`ckjia_ping("hello")`,验证 MCP 链路通
## 输出格式(强制规则,零容忍)
每个 `ProductCard` 已经预渲染了两个开箱即用字段,**直接复制粘贴这两个字符串到回复**,不要自己拼装:
- `markdownLink` —— 已经是 `[商品名](购买URL)` 格式,照抄即可
- `priceTag` —— 已经是 `¥4099 ~~¥4499~~ (9% off)` 格式,照抄即可
聊天界面能把商品渲染成**带图片和价格的可点击卡片**。要触发卡片,必须把推荐结果放进一个语言标记为 `product-cards` 的代码围栏里,围栏内是一个 JSON 数组,**每个对象的字段值直接从工具返回的 ProductCard 原样复制**(尤其 `url` / `imageUrl` 必须照抄,不能改写、不能编造)。
### 必须遵守的输出模板
```markdown
1. {{markdownLink}}
- 💰 {{priceTag}}
- 🛒 {{platformLabel}} · {{shopName}}
- 📊 评分 {{rating}} · 销量 {{salesCount}}
- 💡 历史最低 ¥{{lowestPrice}}
- {{purchaseAdvice}}
先用一两句话给出整体结论(预算区间、推荐方向),然后紧跟卡片围栏,最后补充选购提醒:
````markdown
🎯 你的预算内我挑了这几款,优先看 1.5 匹 / 新一级能效:
```product-cards
[
{
"name": "格力空调 云佳pro 1.5匹 新一级能效",
"url": "https://union-click.jd.com/jdc?e=...",
"imageUrl": "https://img14.360buyimg.com/.../xxx.jpg",
"price": 3057,
"originalPrice": 3299,
"lowestPrice": 2999,
"platformLabel": "京东",
"shopName": "格力京东自营官方旗舰店",
"purchaseAdvice": "卧室够用,关注是否含基础安装"
}
]
```
把双花括号 `{{xxx}}` 替换成 ProductCard 对应字段的值。**`markdownLink` 一定要原样输出**,不要把它拆开后用其它方式重组。
提醒:空调到手价会受安装费 / 高空费 / 国补影响,下单前确认基础安装是否免费。
````
### 真实示例
工具返回:
```json
{
"name": "格力空调 云佳pro 1.5匹...",
"url": "https://union-click.jd.com/jdc?e=...",
"markdownLink": "[格力空调 云佳pro 1.5匹...](https://union-click.jd.com/jdc?e=...)",
"priceTag": "¥3057",
"platformLabel": "京东",
...
}
```
正确输出:
```markdown
1. [格力空调 云佳pro 1.5匹...](https://union-click.jd.com/jdc?e=...)
- 💰 ¥3057
- 🛒 京东
```
每个对象建议带的字段(缺失就省略,不要填占位符):`name`、`url`、`imageUrl`、`price`、`originalPrice`、`lowestPrice`、`platformLabel`、`shopName`、`purchaseAdvice`。
### 严禁的错误(出现任何一条都算回复失败)
- ❌ 不输出 `markdownLink` —— 用户没法点击购买
- ❌ 把 markdownLink 拆开只取商品名 —— 等于丢弃链接
- ❌ 编造任何不在 ProductCard 字段里的 URL
- ❌ 输出 `[商品名](url)` 但中间填占位符或省略号
- ❌ 不输出 `product-cards` 围栏 —— 用户看不到卡片,也点不进购买页
- ❌ 改写或编造 `url` / `imageUrl` —— 卡片会点开错误页面或图片裂开
- ❌ 在围栏里填占位符、省略号或不完整 JSON —— 卡片会渲染失败
- ❌ 把价格写进字符串而丢掉数字 —— 卡片无法对齐展示价格
> 兼容性:纯文本渠道(部分 IM无法渲染卡片围栏会退化成代码块。若当前对话明显是这类渠道再退回到 `1. [商品名](url) — 价格` 的普通列表,并照抄 `markdownLink`。Web 聊天页一律用 `product-cards` 围栏。
## 其它字段处理

View File

@ -0,0 +1,54 @@
package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* The cross-platform shopping recommendation tool is globally callable, so a
* model can invoke it without ever loading the skill's instructions. The
* executor appends a card-rendering directive to that tool's result so products
* render as chat cards rather than a markdown table but only when the result
* actually carries product records (timeouts / empty results must fall through
* to the model's own fallback).
*/
class ToolExecutionExecutorProductCardDirectiveTest {
private static final String SHOPPING_TOOL = "mcp_1000000903_ckjia_shopping_recom_w2ekrl";
@Test
@DisplayName("appends the directive when the shopping tool returns recommendations")
void appendsForShoppingResults() {
String result = "[{\"text\":\"{\\\"recommendations\\\":[{\\\"name\\\":\\\"X\\\",\\\"imageUrl\\\":\\\"https://i\\\"}]}\"}]";
assertTrue(ToolExecutionExecutor.shouldAppendProductCardDirective(SHOPPING_TOOL, result));
String decorated = ToolExecutionExecutor.withProductCardDirective(SHOPPING_TOOL, result);
assertTrue(decorated.startsWith(result), "original payload must be preserved verbatim");
assertTrue(decorated.contains("product-cards"), "directive names the fence language");
assertTrue(decorated.contains("imageUrl"), "directive lists the card fields");
}
@Test
@DisplayName("does not append on a timeout / error result from the shopping tool")
void skipsForTimeout() {
String timeout = "Tool execution failed: java.util.concurrent.TimeoutException: Did not observe any item";
assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective(SHOPPING_TOOL, timeout));
assertEquals(timeout, ToolExecutionExecutor.withProductCardDirective(SHOPPING_TOOL, timeout));
}
@Test
@DisplayName("does not append for unrelated tools even when the body looks product-ish")
void skipsForOtherTools() {
String body = "{\"recommendations\":[{\"imageUrl\":\"https://i\"}]}";
assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective("web_search", body));
assertEquals(body, ToolExecutionExecutor.withProductCardDirective("web_search", body));
}
@Test
@DisplayName("null-safe")
void nullSafe() {
assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective(null, "x"));
assertFalse(ToolExecutionExecutor.shouldAppendProductCardDirective(SHOPPING_TOOL, null));
}
}

View File

@ -616,6 +616,145 @@ html.dark body::before {
font-size: 13px;
}
/* ================================================================
Product cards (from ```product-cards fenced blocks shopping /
price-comparison results rendered as a clickable card grid)
================================================================ */
.markdown-body .product-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 12px;
margin: 14px 0;
}
.markdown-body .product-card {
display: flex;
flex-direction: column;
border: 1px solid var(--mc-border-light);
border-radius: 12px;
overflow: hidden;
background: var(--mc-bg-elevated);
text-decoration: none;
color: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.markdown-body a.product-card:hover {
border-color: var(--mc-primary, #4f46e5);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
/* The whole card is an <a>, so the global `.markdown-body a:hover` underline
would streak across every line of card text. Suppress it hover feedback
comes from the lift/shadow and the name turning primary instead. */
.markdown-body a.product-card,
.markdown-body a.product-card:hover,
.markdown-body a.product-card:hover * {
text-decoration: none;
}
.markdown-body a.product-card:hover .product-card__name {
color: var(--mc-primary, #d96d46);
}
.markdown-body .product-card__media {
width: 100%;
aspect-ratio: 1 / 1;
background: var(--mc-bg-subtle, #f1f5f9);
overflow: hidden;
}
.markdown-body .product-card__media img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.markdown-body .product-card__body {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px 12px;
}
.markdown-body .product-card__name {
font-size: 13px;
line-height: 1.4;
color: var(--mc-text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.markdown-body .product-card__price {
display: flex;
align-items: baseline;
gap: 6px;
margin-top: 2px;
}
.markdown-body .product-card__price-now {
font-size: 17px;
font-weight: 700;
color: var(--mc-danger, #e11d48);
}
.markdown-body .product-card__price-was {
font-size: 12px;
color: var(--mc-text-tertiary);
text-decoration: line-through;
}
.markdown-body .product-card__meta {
font-size: 12px;
color: var(--mc-text-secondary);
}
.markdown-body .product-card__low {
font-size: 11px;
color: var(--mc-text-tertiary);
}
.markdown-body .product-card__advice {
font-size: 12px;
line-height: 1.45;
color: var(--mc-text-secondary);
margin-top: 2px;
}
.markdown-body .product-card__buy {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 8px;
padding: 7px 12px;
border-radius: 8px;
background: var(--mc-primary, #d96d46);
color: #fff;
font-size: 13px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
transition: background 0.15s ease;
}
.markdown-body a.product-card:hover .product-card__buy {
background: var(--mc-primary-hover, #bb4f27);
}
.markdown-body .product-card__buy-arrow {
transition: transform 0.15s ease;
}
.markdown-body a.product-card:hover .product-card__buy-arrow {
transform: translateX(3px);
}
/* Streaming placeholder while the product-cards JSON is still incomplete. */
.markdown-body .product-cards--loading {
display: flex;
gap: 6px;
padding: 16px 4px;
}
.markdown-body .product-cards__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mc-border, #cbd5e1);
animation: mc-product-pulse 1.4s ease-in-out infinite;
}
.markdown-body .product-cards__dot:nth-child(2) { animation-delay: 0.2s; }
.markdown-body .product-cards__dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes mc-product-pulse {
0%, 80%, 100% { opacity: 0.3; }
40% { opacity: 1; }
}
/* ================================================================
Shared page shell
================================================================ */

View File

@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { useMarkdownRenderer } from '../useMarkdownRenderer'
const { renderMarkdown } = useMarkdownRenderer()
// A representative ckjia_shopping_recommend payload, trimmed to the fields the
// SKILL.md contract asks the model to emit inside a ```product-cards fence.
const SAMPLE = `\`\`\`product-cards
[
{
"name": "华为畅享 70X 尊享版 256GB 曜金黑",
"url": "https://union-click.jd.com/jdc?e=abc",
"imageUrl": "https://img14.360buyimg.com/pop/jfs/t1/xxx.jpg",
"price": 1699,
"originalPrice": 1899,
"lowestPrice": 1619,
"platformLabel": "京东",
"shopName": "华为HUAWEI",
"purchaseAdvice": "长续航,预算内首选"
}
]
\`\`\``
describe('product-cards rendering', () => {
it('renders a fenced product-cards block as a clickable card grid', () => {
const html = renderMarkdown(SAMPLE)
expect(html).toContain('class="product-cards"')
// Whole card is an anchor to the buy URL.
expect(html).toContain('<a class="product-card"')
expect(html).toContain('href="https://union-click.jd.com/jdc?e=abc"')
expect(html).toContain('target="_blank"')
// Image survives DOMPurify with lazy-load + no-referrer.
expect(html).toContain('src="https://img14.360buyimg.com/pop/jfs/t1/xxx.jpg"')
expect(html).toContain('referrerpolicy="no-referrer"')
// Price is formatted; original price shows struck-through because it is higher.
expect(html).toContain('¥1,699')
expect(html).toContain('product-card__price-was')
expect(html).toContain('华为畅享 70X 尊享版')
})
it('accepts an object wrapping a recommendations array', () => {
const wrapped = '```product-cards\n{"recommendations":[{"name":"X","url":"https://x.test/p","price":10}]}\n```'
const html = renderMarkdown(wrapped)
expect(html).toContain('class="product-cards"')
expect(html).toContain('¥10')
})
it('shows a loading placeholder for incomplete (streaming) JSON', () => {
const partial = '```product-cards\n[{"name":"half'
const html = renderMarkdown(partial)
expect(html).toContain('product-cards--loading')
})
it('drops a javascript: url to a non-clickable card', () => {
const evil = '```product-cards\n[{"name":"bad","url":"javascript:alert(1)"}]\n```'
const html = renderMarkdown(evil)
expect(html).toContain('product-card--nolink')
expect(html).not.toContain('javascript:alert')
})
})

View File

@ -168,6 +168,106 @@ function preprocessLatex(text: string): string {
return out
}
// ---------------------------------------------------------------------------
// Product cards
// ---------------------------------------------------------------------------
/** Shape the model is asked to emit inside a ```product-cards fence. */
interface ProductCard {
name?: string
url?: string
imageUrl?: string
price?: number | string
originalPrice?: number | string
lowestPrice?: number | string
platformLabel?: string
shopName?: string
purchaseAdvice?: string
}
/** Format a numeric/string amount as `¥1,234` (drops a trailing `.0`). */
function formatPrice(v: number | string | undefined): string {
if (v === undefined || v === null || v === '') return ''
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^\d.]/g, ''))
if (!Number.isFinite(n)) return ''
const s = Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.0+$/, '')
return '¥' + s.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
/**
* Render a ```product-cards fenced JSON block into a clickable card grid.
*
* Accepts a bare array or an object wrapping the array under
* `recommendations` / `products` / `items`. While streaming, the JSON is
* frequently incomplete we swallow the parse error and show a lightweight
* loading placeholder rather than dumping half a JSON blob into the bubble.
*/
function renderProductCards(rawCode: string): string {
let items: ProductCard[] = []
try {
const parsed = JSON.parse(rawCode)
if (Array.isArray(parsed)) items = parsed
else if (parsed && typeof parsed === 'object') {
items = parsed.recommendations || parsed.products || parsed.items || []
}
} catch {
return '<div class="product-cards product-cards--loading">'
+ '<span class="product-cards__dot"></span>'
+ '<span class="product-cards__dot"></span>'
+ '<span class="product-cards__dot"></span>'
+ '</div>'
}
if (!Array.isArray(items) || items.length === 0) return ''
const cards = items.map((it) => {
const href = typeof it.url === 'string' && SAFE_LINK_RE.test(it.url) ? it.url : ''
const name = escapeHtml(String(it.name ?? '').trim()) || '商品'
const img = typeof it.imageUrl === 'string' && /^https?:/i.test(it.imageUrl) ? it.imageUrl : ''
const now = formatPrice(it.price)
const wasNum = typeof it.originalPrice === 'number' ? it.originalPrice : Number(it.originalPrice)
const nowNum = typeof it.price === 'number' ? it.price : Number(it.price)
const showWas = Number.isFinite(wasNum) && Number.isFinite(nowNum) && wasNum > nowNum
const was = showWas ? formatPrice(it.originalPrice) : ''
const low = formatPrice(it.lowestPrice)
const platform = escapeHtml(String(it.platformLabel ?? '').trim())
const shop = escapeHtml(String(it.shopName ?? '').trim())
const advice = escapeHtml(String(it.purchaseAdvice ?? '').trim())
// target/rel (anchor) and referrerpolicy/loading (img) are re-applied by the
// afterSanitizeAttributes hook — DOMPurify strips them here regardless.
const media = img
? `<div class="product-card__media"><img src="${escapeHtml(img)}" alt="${name}"></div>`
: `<div class="product-card__media product-card__media--empty"></div>`
const meta = [platform, shop].filter(Boolean).join(' · ')
const priceLine = now
? `<div class="product-card__price"><span class="product-card__price-now">${now}</span>`
+ (was ? `<span class="product-card__price-was">${was}</span>` : '')
+ `</div>`
: ''
// The whole card is the anchor, but a visible CTA makes the "tap to buy"
// affordance explicit (an `<a>` can't legally wrap a `<button>`, so this is
// a styled span). Only shown when there's a real buy URL.
const platformWord = platform || '商家'
const buyCta = href
? `<span class="product-card__buy">去${platformWord}购买<span class="product-card__buy-arrow">→</span></span>`
: ''
const body = `<div class="product-card__body">`
+ `<div class="product-card__name">${name}</div>`
+ priceLine
+ (meta ? `<div class="product-card__meta">${meta}</div>` : '')
+ (low ? `<div class="product-card__low">历史最低 ${low}</div>` : '')
+ (advice ? `<div class="product-card__advice">${advice}</div>` : '')
+ buyCta
+ `</div>`
if (href) {
return `<a class="product-card" href="${escapeHtml(href)}">${media}${body}</a>`
}
return `<div class="product-card product-card--nolink">${media}${body}</div>`
}).join('')
return `<div class="product-cards">${cards}</div>`
}
// ---------------------------------------------------------------------------
// Custom renderer (marked v15 requires a plain object — class instances are
// NOT dispatched).
@ -209,6 +309,15 @@ const customRenderer = {
return `<div class="echarts-block" data-echarts-option="${encodeURIComponent(rawCode)}"></div>`
}
// Product cards: a ```product-cards fenced block carries a JSON array (or an
// object wrapping `recommendations` / `products` / `items`) of shopping
// recommendations. We render it inline as a clickable card grid — image,
// name, price, platform — so price-comparison results show up as real cards
// in the chat instead of a markdown list. Pure HTML, no post-mount step.
if (infoStr === 'product-cards') {
return renderProductCards(rawCode)
}
const detectedLang = extractLang(infoStr)
const hasLanguage = !!detectedLang && !!hljs.getLanguage(detectedLang)
@ -334,6 +443,35 @@ const purifyConfig = {
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#|\/|\.\/|\.\.\/)/i,
}
// The custom ALLOWED_URI_REGEXP above also vets non-URI attribute *values*, so
// DOMPurify strips `target="_blank"`, `rel="noopener"`, `referrerpolicy="..."`
// etc. (their values don't match the URL whitelist). For product cards we need
// those back: the buy link must open in a new tab instead of navigating away
// from the chat, and marketplace CDN thumbnails (e.g. 360buyimg) are hotlink-
// protected and only load with `referrer-policy: no-referrer`. An
// afterSanitizeAttributes hook re-applies them with fixed, safe values —
// attributes set inside this hook are NOT re-validated, so this is the
// canonical DOMPurify pattern. Scoped strictly to product-card nodes so no
// other rendered markdown changes behaviour.
let productCardHookRegistered = false
function ensureProductCardHook(): void {
if (productCardHookRegistered) return
productCardHookRegistered = true
DOMPurify.addHook('afterSanitizeAttributes', (node: Element) => {
if (!node || typeof node.tagName !== 'string') return
const tag = node.tagName.toLowerCase()
if (tag === 'a' && node.classList?.contains('product-card')) {
node.setAttribute('target', '_blank')
node.setAttribute('rel', 'noopener noreferrer')
} else if (tag === 'img' && typeof node.closest === 'function' && node.closest('.product-cards')) {
node.setAttribute('referrerpolicy', 'no-referrer')
node.setAttribute('loading', 'lazy')
node.setAttribute('decoding', 'async')
}
})
}
ensureProductCardHook()
// ---------------------------------------------------------------------------
// LRU render cache
// ---------------------------------------------------------------------------