Treat website meta tags as label + full HTML snippets.
Public business-info now exposes metaTags as { html }, matching how admins paste complete <meta> tags in Tags & Badges.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
63ae9b22b9
commit
e0cd2327eb
@@ -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 `<head>`:
|
||||
2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout `<head>`.
|
||||
|
||||
```html
|
||||
<meta name="{name}" content="{content}">
|
||||
{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: `<meta name="google-site-verification" content="…">` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `<meta />` element, or inject the sanitized string into `<head>`. 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.
|
||||
|
||||
|
||||
@@ -570,14 +570,16 @@
|
||||
},
|
||||
"metaTags": {
|
||||
"type": "array",
|
||||
"description": "Enabled custom meta tags for the site <head> (name/content). Configured under Website → Tags & Badges.",
|
||||
"description": "Custom <meta> HTML snippets for the site <head>. Each item is { html } with a full <meta …> 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 <meta …> HTML tag to inject in <head>."
|
||||
}
|
||||
},
|
||||
"required": ["name", "content"]
|
||||
"required": ["html"]
|
||||
}
|
||||
},
|
||||
"trustBadges": {
|
||||
|
||||
@@ -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 `<meta …>` HTML to inject.
|
||||
*/
|
||||
export type WebsiteMetaTag = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -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 `<meta …>` HTML snippet. */
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
content!: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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 <meta …> 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(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
html = html.replace(/<script\b[^>]*\/?>/gi, '');
|
||||
html = html.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
||||
html = html.replace(/javascript\s*:/gi, '');
|
||||
html = html.trim();
|
||||
|
||||
if (!/^<meta\b/i.test(html)) {
|
||||
// Legacy: name was HTML meta name, content was only the content value.
|
||||
const attr = (legacyAttrName ?? '').trim();
|
||||
if (!attr || !/^[a-zA-Z0-9_.:-]{1,80}$/.test(attr) || /[<>]/.test(html)) {
|
||||
return null;
|
||||
}
|
||||
const escaped = html.replace(/&/g, '&').replace(/"/g, '"');
|
||||
return `<meta name="${attr}" content="${escaped}">`;
|
||||
}
|
||||
|
||||
const match = html.match(/<meta\b[^>]*>/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 <meta>, 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 = /^<meta\b/i.test(rawContent);
|
||||
const html = sanitizeMetaTagHtml(
|
||||
rawContent,
|
||||
looksLikeHtml ? undefined : label,
|
||||
);
|
||||
if (!html) return null;
|
||||
|
||||
const id =
|
||||
typeof raw.id === 'string' && raw.id.trim().length > 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 <meta> HTML snippets for storefront <head>. */
|
||||
export type PublicWebsiteMetaTag = { html: string };
|
||||
|
||||
export type PublicWebsiteTrustBadge = {
|
||||
kind: 'enamad' | 'samandehi';
|
||||
embedHtml: string;
|
||||
};
|
||||
|
||||
/** Custom meta tags present in settings → emit in storefront <head>. */
|
||||
export function toPublicWebsiteMetaTags(
|
||||
settings: WebsiteTagsAndBadgesSettings,
|
||||
): PublicWebsiteMetaTag[] {
|
||||
@@ -96,17 +139,17 @@ export function toPublicWebsiteMetaTags(
|
||||
const seen = new Set<string>();
|
||||
|
||||
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[] {
|
||||
|
||||
@@ -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 `<head>`:
|
||||
2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout `<head>`.
|
||||
|
||||
```html
|
||||
<meta name="{name}" content="{content}">
|
||||
{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: `<meta name="google-site-verification" content="…">` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `<meta />` element, or inject the sanitized string into `<head>`. 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.
|
||||
|
||||
|
||||
@@ -574,14 +574,16 @@
|
||||
},
|
||||
"metaTags": {
|
||||
"type": "array",
|
||||
"description": "Enabled custom meta tags for the site <head> (name/content). Configured under Website → Tags & Badges.",
|
||||
"description": "Custom <meta> HTML snippets for the site <head>. Each item is { html } with a full <meta …> 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 <meta …> HTML tag to inject in <head>."
|
||||
}
|
||||
},
|
||||
"required": ["name", "content"]
|
||||
"required": ["html"]
|
||||
}
|
||||
},
|
||||
"trustBadges": {
|
||||
|
||||
Reference in New Issue
Block a user