diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index aced2b2..94febe0 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -336,13 +336,13 @@ These are **required on every crawlable page** (home, listing, product/blog/port Business admins configure this under **Website → Tags & Badges**. 1. `GET /tenants/{domain}/website/business-info` -2. For each item in `metaTags` (array of `{ name, content }`), emit once in the root layout ``: +2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout ``. ```html - +{html} ``` -In Next.js App Router, map them into `generateMetadata()` → `other: { [name]: content }`. Verification metas (eNamad, Google, etc.) belong here as custom name/value rows. +Example `html` value: `` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `` element, or inject the sanitized string into ``. Do **not** wrap it again as `name`/`content` — `html` is already the whole tag. The dashboard “Name” field is an admin label only and is **not** in this public payload. 3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content. diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 78687eb..218907f 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -570,14 +570,16 @@ }, "metaTags": { "type": "array", - "description": "Enabled custom meta tags for the site (name/content). Configured under Website → Tags & Badges.", + "description": "Custom HTML snippets for the site . Each item is { html } with a full tag. Configured under Website → Tags & Badges (name there is an admin label only).", "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "content": { "type": "string" } + "html": { + "type": "string", + "description": "Full sanitized HTML tag to inject in ." + } }, - "required": ["name", "content"] + "required": ["html"] } }, "trustBadges": { diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 5a677ec..816a48a 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -131,7 +131,10 @@ export type WebsiteSettings = { tagsAndBadges: WebsiteTagsAndBadgesSettings; }; -/** Custom head meta tag (name + content). Present = published. */ +/** + * Custom head meta tag. + * `name` = admin label only; `content` = full `` HTML to inject. + */ export type WebsiteMetaTag = { id: string; name: string; diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts index 1b74506..16b578e 100644 --- a/src/business-settings/dto/update-business-settings.dto.ts +++ b/src/business-settings/dto/update-business-settings.dto.ts @@ -8,7 +8,6 @@ import { IsNumber, IsOptional, IsString, - Matches, MaxLength, Min, MinLength, @@ -270,13 +269,16 @@ class WebsiteMetaTagDto { @MaxLength(64) id?: string; - @IsString() - @Matches(/^[a-zA-Z0-9_.:-]{1,80}$/) - name!: string; - + /** Admin label only (not necessarily the HTML name= attribute). */ @IsString() @MinLength(1) - @MaxLength(2000) + @MaxLength(80) + name!: string; + + /** Full `` HTML snippet. */ + @IsString() + @MinLength(1) + @MaxLength(4000) content!: string; } diff --git a/src/business-settings/website-tags-badges.util.ts b/src/business-settings/website-tags-badges.util.ts index 9561768..c65dff4 100644 --- a/src/business-settings/website-tags-badges.util.ts +++ b/src/business-settings/website-tags-badges.util.ts @@ -7,10 +7,9 @@ import { } from './business-settings.types'; const MAX_META_TAGS = 40; -const MAX_NAME_LEN = 80; -const MAX_CONTENT_LEN = 2000; +const MAX_LABEL_LEN = 80; +const MAX_META_HTML_LEN = 4000; const MAX_EMBED_LEN = 10000; -const META_NAME_RE = /^[a-zA-Z0-9_.:-]{1,80}$/; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -31,6 +30,38 @@ export function sanitizeTrustEmbedHtml(raw: string): string { return html.trim(); } +/** + * Keep a single tag. Reject anything else. + * Legacy plain content + attr-name is wrapped into a meta tag. + */ +export function sanitizeMetaTagHtml( + rawHtml: string, + legacyAttrName?: string, +): string | null { + let html = rawHtml.trim().slice(0, MAX_META_HTML_LEN); + if (!html) return null; + + html = html.replace(/]*>[\s\S]*?<\/script>/gi, ''); + html = html.replace(/]*\/?>/gi, ''); + html = html.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, ''); + html = html.replace(/javascript\s*:/gi, ''); + html = html.trim(); + + if (!/^]/.test(html)) { + return null; + } + const escaped = html.replace(/&/g, '&').replace(/"/g, '"'); + return ``; + } + + const match = html.match(/]*>/i); + if (!match) return null; + return match[0].trim(); +} + function normalizeTrustBadge(raw: unknown): WebsiteTrustBadgeSettings { const source = isRecord(raw) ? raw : {}; return { @@ -44,19 +75,31 @@ function normalizeTrustBadge(raw: unknown): WebsiteTrustBadgeSettings { function normalizeMetaTag(raw: unknown): WebsiteMetaTag | null { if (!isRecord(raw)) return null; - const name = readString(raw.name, MAX_NAME_LEN); - const content = readString(raw.content, MAX_CONTENT_LEN); - if (!name || !META_NAME_RE.test(name) || !content) return null; - // Legacy rows with enabled:false are ignored (treated as deleted). if (raw.enabled === false) return null; + + const label = readString(raw.name, MAX_LABEL_LEN); + const rawContent = + typeof raw.content === 'string' ? raw.content.trim() : ''; + if (!label || !rawContent) return null; + + // If content is already a full , label is display-only. + // If not, treat legacy attr-name + content-value (use label as attr name only when it looks like one). + const looksLikeHtml = /^ 0 ? raw.id.trim().slice(0, 64) : randomUUID(); + return { id, - name, - content, + name: label, + content: html, }; } @@ -81,14 +124,14 @@ export function normalizeWebsiteTagsAndBadges( }; } -export type PublicWebsiteMetaTag = { name: string; content: string }; +/** Full HTML snippets for storefront . */ +export type PublicWebsiteMetaTag = { html: string }; export type PublicWebsiteTrustBadge = { kind: 'enamad' | 'samandehi'; embedHtml: string; }; -/** Custom meta tags present in settings → emit in storefront . */ export function toPublicWebsiteMetaTags( settings: WebsiteTagsAndBadgesSettings, ): PublicWebsiteMetaTag[] { @@ -96,17 +139,17 @@ export function toPublicWebsiteMetaTags( const seen = new Set(); for (const tag of settings.metaTags) { - if (!tag.name || !tag.content) continue; - const key = tag.name.toLowerCase(); + const html = tag.content?.trim(); + if (!html) continue; + const key = html.toLowerCase(); if (seen.has(key)) continue; seen.add(key); - out.push({ name: tag.name, content: tag.content }); + out.push({ html }); } return out; } -/** Trust badges with non-empty HTML → render in the footer. */ export function toPublicWebsiteTrustBadges( settings: WebsiteTagsAndBadgesSettings, ): PublicWebsiteTrustBadge[] { diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 23a6ad4..065d42f 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -365,13 +365,13 @@ These are **required on every crawlable page** (home, listing, product/blog/port Business admins configure this under **Website → Tags & Badges**. 1. `GET /tenants/{domain}/website/business-info` -2. For each item in `metaTags` (array of `{ name, content }`), emit once in the root layout ``: +2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout ``. ```html - +{html} ``` -In Next.js App Router, map them into `generateMetadata()` → `other: { [name]: content }`. Verification metas (eNamad, Google, etc.) belong here as custom name/value rows. +Example `html` value: `` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `` element, or inject the sanitized string into ``. Do **not** wrap it again as `name`/`content` — `html` is already the whole tag. The dashboard “Name” field is an admin label only and is **not** in this public payload. 3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content. diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 6de13d7..e93e2c6 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -574,14 +574,16 @@ }, "metaTags": { "type": "array", - "description": "Enabled custom meta tags for the site (name/content). Configured under Website → Tags & Badges.", + "description": "Custom HTML snippets for the site . Each item is { html } with a full tag. Configured under Website → Tags & Badges (name there is an admin label only).", "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "content": { "type": "string" } + "html": { + "type": "string", + "description": "Full sanitized HTML tag to inject in ." + } }, - "required": ["name", "content"] + "required": ["html"] } }, "trustBadges": {