/**
* HTML sanitization pre-pass. The ProseMirror schema is the real whitelist —
* anything it can't parse is dropped — but this pass removes actively
* dangerous content (scripts, event handlers, javascript: URLs) before the
* string ever touches a live DOM, and strips style declarations down to the
* set the schema understands.
*/
import { isAllowedEmbedSrc, EMBED_SANDBOX, EMBED_REFERRER_POLICY } from './plugins/embeds';
import { isExtensionValueValid, NormalizedAllowedStyles } from './email-css';
/** Elements removed together with their content. */
const DROP_WITH_CONTENT = new Set([
'script', 'frame', 'frameset', 'object', 'embed',
'applet', 'form', 'input', 'textarea', 'select', 'button', 'meta', 'link',
'base', 'title', 'noscript', 'template', 'svg', 'math', 'canvas', 'audio',
'video', 'dialog', 'slot',
]);
/** Elements kept as-is (with filtered attributes). Extended in later phases. */
const ALLOWED_TAGS = new Set([
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'ul', 'ol', 'li',
'br', 'hr', 'a', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del',
'span', 'code', 'sub', 'sup', 'div', 'font',
'pre', 'figure', 'figcaption', 'img', 'iframe',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'colgroup', 'col',
]);
/** Attributes allowed per tag (on top of GLOBAL_ATTRS). */
const TAG_ATTRS: Record> = {
a: new Set(['href', 'title', 'target', 'rel', 'id', 'name', 'data-bookmark']),
table: new Set(['role', 'data-layout']),
ol: new Set(['start', 'type', 'data-list-style']),
ul: new Set(['data-task-list']),
figure: new Set(['data-align']),
li: new Set(['data-task-item', 'data-checked']),
span: new Set([
'data-mention-trigger', 'data-mention-key', 'data-mention-label',
'data-merge-field', 'data-merge-label', 'data-math', 'data-math-display',
]),
pre: new Set(['data-language']),
img: new Set(['src', 'alt', 'title', 'width', 'height']),
td: new Set(['colspan', 'rowspan', 'data-colwidth']),
th: new Set(['colspan', 'rowspan', 'data-colwidth']),
col: new Set(['span', 'width']),
div: new Set(['data-embed-provider', 'data-width', 'data-align']),
iframe: new Set([
'src', 'title', 'frameborder', 'allowfullscreen', 'allow', 'sandbox', 'referrerpolicy',
]),
};
const GLOBAL_ATTRS = new Set(['style', 'align', 'class', 'id']);
/** Author class/id names starting with these are the editor's own; drop them so
* content can't impersonate or restyle the chrome. */
const RESERVED_NAME = /^(?:nile-wysiwyg|ProseMirror)/;
/** Filtered `class`: reserved-prefixed tokens removed. Empty → drop attribute. */
function cleanClassAttr(value: string): string {
return value
.split(/\s+/)
.filter(token => token && !RESERVED_NAME.test(token))
.join(' ');
}
/** Style declarations the schema can represent. Everything else is noise. */
const ALLOWED_STYLES = new Set([
'color', 'background-color', 'font-family', 'font-size', 'font-style',
'font-weight', 'text-align', 'text-decoration', 'text-decoration-line',
'vertical-align', 'margin-left', 'margin-inline-start', 'list-style-type',
'line-height',
]);
const SAFE_URL_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:', 'ftp:'];
export function isSafeUrl(url: string, allowDataImage = false): boolean {
const trimmed = url.trim();
if (trimmed === '') return false;
// Relative URLs and fragments have no protocol — safe.
if (/^(#|\/|\.\/|\.\.\/)/.test(trimmed)) return true;
const match = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
if (!match) return true;
const protocol = `${match[1].toLowerCase()}:`;
if (SAFE_URL_PROTOCOLS.includes(protocol)) return true;
if (allowDataImage && protocol === 'data:') {
// Raster images only — data:image/svg+xml can carry scripts.
return /^data:image\/(png|gif|jpe?g|webp|avif|bmp);/i.test(trimmed);
}
return false;
}
/** Size styles allowed only where they are part of the document model. */
const SIZED_TAGS: Record> = {
img: new Set(['width', 'height']),
figure: new Set(['width', 'margin-right']),
div: new Set(['width', 'max-width']),
};
function filterStyle(value: string, tag: string): string {
const extra = SIZED_TAGS[tag];
return value
.split(';')
.map(decl => decl.trim())
.filter(decl => {
const name = decl.slice(0, decl.indexOf(':')).trim().toLowerCase();
if (!ALLOWED_STYLES.has(name) && !(extra && extra.has(name))) return false;
// Defense in depth: CSS url()/expression() smuggling.
return !/url\s*\(|expression\s*\(/i.test(decl);
})
.join('; ');
}
/* --- email-fidelity: container styles become node attrs, not per-run marks (flag-gated) --- */
/** Containers whose inline styles must not become per-run marks. */
const CONTAINER_TAGS = new Set([
'table', 'td', 'th', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'div', 'li', 'blockquote', 'tr', 'tbody',
]);
/** Mark-triggering declarations dropped on any container. */
const CONTAINER_DROP = new Set([
'color', 'background-color', 'font-family', 'font-size', 'font-weight',
'font-style', 'text-decoration', 'text-decoration-line',
'text-decoration-style', 'text-decoration-color',
]);
/** Layout declarations allowed on cells with fidelity. */
const CELL_LAYOUT = new Set([
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'width', 'vertical-align', 'border', 'border-top', 'border-right',
'border-bottom', 'border-left', 'border-radius',
]);
/** `border-width`, `border-top-color`, … — resolved per side through CSSOM instead. */
const CELL_BORDER_LONGHAND = /^border-(?:(?:top|right|bottom|left)-)?(?:width|style|color)$/;
/** Layout declarations allowed on tables with fidelity. */
const TABLE_LAYOUT = new Set(['width', 'max-width', 'border', 'margin-left', 'margin-right']);
/** Block margins allowed on paragraphs/headings with fidelity. */
const BLOCK_LAYOUT = new Set(['margin', 'margin-top', 'margin-bottom']);
const BORDER_STYLES = new Set(['solid', 'dashed', 'dotted', 'double', 'none']);
const VALIGN_KEYWORDS = new Set([
'top', 'middle', 'bottom', 'baseline', 'sub', 'super', 'text-top', 'text-bottom',
]);
const LENGTH_TOKEN = /^-?(?:\d+\.?\d*|\.\d+)(?:px|%|em|rem|pt)$/;
function isAscii(value: string): boolean {
// eslint-disable-next-line no-control-regex
return !/[^\x00-\x7f]/.test(value);
}
/** Forbidden function-ish / escape tokens shared by the length & border grammars. */
function hasUnsafeToken(value: string): boolean {
return /[\\/(]|url|var|calc|expression/i.test(value);
}
function isLengthToken(token: string): boolean {
return token === '0' || token === 'auto' || LENGTH_TOKEN.test(token);
}
/** 1–4 whitespace-separated length tokens (padding/margin/width/radius). */
function isLengthList(value: string): boolean {
if (!isAscii(value) || hasUnsafeToken(value)) return false;
const tokens = value.trim().split(/\s+/);
return tokens.length >= 1 && tokens.length <= 4 && tokens.every(isLengthToken);
}
function isColor(value: string): boolean {
if (!isAscii(value)) return false;
const v = value.trim();
if (/^[a-z]+$/i.test(v)) return true;
if (/^#[0-9a-f]{3,8}$/i.test(v)) return true;
// rgb()/rgba() with numeric (optionally %) arguments only.
if (/^rgba?\(\s*[\d.]+%?(?:\s*,\s*[\d.]+%?){2,3}\s*\)$/i.test(v)) return true;
// hsl()/hsla() with numeric (optionally %) arguments only — numeric-only, so a
// smuggled `hsl(var(--x))` still fails (it has letters inside the parens).
return /^hsla?\(\s*[\d.]+%?(?:\s*,\s*[\d.]+%?){2,3}\s*\)$/i.test(v);
}
/** Line-height for tables: a unitless number or a single length token. */
function isLineHeight(value: string): boolean {
if (!isAscii(value) || hasUnsafeToken(value)) return false;
const v = value.trim();
if (/^\d+(?:\.\d+)?$/.test(v)) return true;
return isLengthToken(v);
}
/** Cell `text-align` keyword set (also the legacy `align` conversion source). */
const TEXT_ALIGN_KEYWORDS = new Set(['left', 'center', 'right', 'justify', 'start', 'end']);
const LEGACY_ALIGN_KEYWORDS = new Set(['left', 'center', 'right', 'justify']);
function isBorder(value: string): boolean {
if (!isAscii(value)) return false;
if (/[\\/]|url|var|calc|expression/i.test(value)) return false;
const tokens = value.trim().match(/rgba?\([^)]*\)|\S+/gi) ?? [];
if (!tokens.length) return false;
let hasStyle = false;
for (const token of tokens) {
if (BORDER_STYLES.has(token.toLowerCase())) {
hasStyle = true;
continue;
}
if (isLengthToken(token) || isColor(token)) continue;
return false;
}
return hasStyle;
}
function isValign(value: string): boolean {
const v = value.trim().toLowerCase();
return VALIGN_KEYWORDS.has(v) || isLengthToken(v);
}
function isLayoutValid(name: string, value: string): boolean {
if (name === 'vertical-align') return isValign(value);
// `border-radius` is a length list (a corner rounding), not the width/style/
// colour grammar the `border*` shorthands use — validate it as such.
if (name === 'border-radius') return isLengthList(value);
if (name.startsWith('border')) return isBorder(value);
return isLengthList(value);
}
/** Validates a cell `color`/`font-*` declaration kept as part of `textStyle`. */
function isCellTextValid(name: string, value: string): boolean {
if (!isAscii(value) || /url|expression|var|calc/i.test(value)) return false;
if (name === 'color') return isColor(value);
if (name === 'font-size') return isLengthToken(value);
if (name === 'font-weight') {
return /^(normal|bold|bolder|lighter|[1-9]00)$/i.test(value.trim());
}
// font-family: names, commas and quotes only — no function-ish characters.
return !/[(){}<>;\\]/.test(value);
}
/** A container's kept style under fidelity: validated layout, carried background/font, legacy attrs. */
function computeContainerStyle(el: Element, tag: string): string {
const isCell = tag === 'td' || tag === 'th';
const isTable = tag === 'table';
const isBlock = /^(?:p|h[1-6])$/.test(tag);
const extra = SIZED_TAGS[tag];
const kept: string[] = [];
for (const decl of (el.getAttribute('style') ?? '').split(';')) {
const idx = decl.indexOf(':');
if (idx === -1) continue;
const name = decl.slice(0, idx).trim().toLowerCase();
const value = decl.slice(idx + 1).trim();
if (!value || /url\s*\(|expression\s*\(/i.test(decl)) continue;
if (name === 'background-color') {
if ((isCell || isTable) && isColor(value)) kept.push(`${name}: ${value}`);
continue;
}
if (name === 'color' || name === 'font-family' || name === 'font-size' || name === 'font-weight') {
// Blocks and tables keep their own text style; cells inherit the table's.
if ((isCell || isBlock || isTable) && isCellTextValid(name, value)) {
kept.push(`${name}: ${value}`);
}
continue;
}
if (name === 'text-align') {
// Cells keep a validated text-align (lifted to data-cell-align); blocks let
// it fall through to the align attr via the ALLOWED_STYLES branch below.
if (isCell && TEXT_ALIGN_KEYWORDS.has(value.toLowerCase())) {
kept.push(`text-align: ${value.toLowerCase()}`);
continue;
}
}
if (name === 'line-height' && isTable) {
if (isLineHeight(value)) kept.push(`line-height: ${value}`);
continue;
}
if (name === 'margin' && isTable) {
// `margin: 0 auto` centring: keep only the horizontal parts as longhands so
// the table parseDOM's `margin-left/right: auto` check recognises it.
if (isLengthList(value)) {
const tokens = value.trim().split(/\s+/);
const [, right] = tokens.length === 1
? [tokens[0], tokens[0]]
: [tokens[0], tokens[1]];
const left = tokens.length >= 4 ? tokens[3] : right;
kept.push(`margin-left: ${left}`, `margin-right: ${right}`);
}
continue;
}
if (CONTAINER_DROP.has(name)) continue;
// Border longhand groups (`border-width/-style/-color`, per side or not) are
// how the browser re-serializes four equal sides; recombined per side below.
if (isCell && CELL_BORDER_LONGHAND.test(name)) continue;
if (isCell && CELL_LAYOUT.has(name) && isLayoutValid(name, value)) {
kept.push(`${name}: ${value}`);
} else if (isTable && TABLE_LAYOUT.has(name) && isLayoutValid(name, value)) {
kept.push(`${name}: ${value}`);
} else if (isBlock && BLOCK_LAYOUT.has(name) && isLengthList(value)) {
kept.push(`${name}: ${value}`);
} else if (ALLOWED_STYLES.has(name)) {
kept.push(`${name}: ${value}`);
} else if (extra && extra.has(name)) {
kept.push(`${name}: ${value}`);
}
}
// A cell border written as longhand groups: read each side back through CSSOM,
// which resolves any mix of shorthands and longhands to `width style color`.
if (isCell && !kept.some(d => d.startsWith('border:'))) {
const style = (el as HTMLElement).style;
for (const side of ['top', 'right', 'bottom', 'left']) {
const prop = `border-${side}`;
if (kept.some(d => d.startsWith(`${prop}:`))) continue;
const value = style?.getPropertyValue(prop);
if (value && isBorder(value)) kept.push(`${prop}: ${value}`);
}
}
// Legacy presentational attributes → validated styles (raw attr removed by the
// standard attribute pass, which doesn't list them).
if (isCell || isTable) {
const bgcolor = el.getAttribute('bgcolor');
if (bgcolor && isColor(bgcolor) && !kept.some(d => d.startsWith('background-color'))) {
kept.push(`background-color: ${bgcolor}`);
}
const widthAttr = el.getAttribute('width');
if (widthAttr && !kept.some(d => d.startsWith('width'))) {
const width = /^\d+$/.test(widthAttr)
? `${widthAttr}px`
: /^\d+%$/.test(widthAttr)
? widthAttr
: null;
if (width) kept.push(`width: ${width}`);
}
const valign = el.getAttribute('valign');
if (isCell && valign && isValign(valign) && !kept.some(d => d.startsWith('vertical-align'))) {
kept.push(`vertical-align: ${valign}`);
}
// Legacy cell align → text-align; the attr stays for table centring.
const alignAttr = el.getAttribute('align');
if (
isCell &&
alignAttr &&
LEGACY_ALIGN_KEYWORDS.has(alignAttr.toLowerCase()) &&
!kept.some(d => d.startsWith('text-align'))
) {
kept.push(`text-align: ${alignAttr.toLowerCase()}`);
}
}
return kept.join('; ');
}
/** Cell text declarations lifted onto `data-cell-text-style` before PM parse. */
const CELL_TEXT_PROPS = ['color', 'font-family', 'font-size', 'font-weight'];
/** Table text declarations lifted onto `data-table-text-style` before PM parse. */
const TABLE_TEXT_PROPS = ['color', 'font-family', 'font-size', 'font-weight', 'line-height'];
/** Moves carried background/font declarations onto data-attrs so they never become marks. */
function relocateElement(el: HTMLElement, tag: string): void {
if (tag === 'td' || tag === 'th') {
const bg = el.style.backgroundColor;
if (bg) {
el.setAttribute('data-cell-bg', bg);
el.style.removeProperty('background-color');
}
const parts: string[] = [];
for (const prop of CELL_TEXT_PROPS) {
const value = el.style.getPropertyValue(prop);
if (value) {
parts.push(`${prop}: ${value}`);
el.style.removeProperty(prop);
}
}
if (parts.length) el.setAttribute('data-cell-text-style', parts.join('; '));
// Cell text-align → data-cell-align (so it stays a node attr, never a mark).
const align = el.style.textAlign;
if (align) {
el.setAttribute('data-cell-align', align);
el.style.removeProperty('text-align');
}
if (!el.getAttribute('style')) el.removeAttribute('style');
} else if (tag === 'table') {
const bg = el.style.backgroundColor;
if (bg) {
el.setAttribute('data-table-bg', bg);
el.style.removeProperty('background-color');
}
const textParts: string[] = [];
for (const prop of TABLE_TEXT_PROPS) {
const value = el.style.getPropertyValue(prop);
if (value) {
textParts.push(`${prop}: ${value}`);
el.style.removeProperty(prop);
}
}
if (textParts.length) el.setAttribute('data-table-text-style', textParts.join('; '));
// Email containers are centred with `align="center"` on the table or on
// the cell that holds it.
const holder = el.parentElement?.closest('td, th');
if (
el.getAttribute('align')?.toLowerCase() === 'center' ||
holder?.getAttribute('align')?.toLowerCase() === 'center'
) {
el.setAttribute('data-table-align', 'center');
}
if (!el.getAttribute('style')) el.removeAttribute('style');
}
}
function relocateWalk(node: Element): void {
for (const child of Array.from(node.children)) {
relocateElement(child as HTMLElement, child.tagName.toLowerCase());
relocateWalk(child);
}
}
/** A `` width (style width or `width` attr) normalized to a px/% token. */
function colWidth(col: Element): string | null {
const styleWidth = /width\s*:\s*([^;]+)/i.exec(col.getAttribute('style') ?? '')?.[1]?.trim();
const v = (styleWidth || col.getAttribute('width') || '').trim();
if (!v) return null;
if (/^\d+$/.test(v)) return `${v}px`;
if (LENGTH_TOKEN.test(v) && /(?:px|%)$/.test(v)) return v;
return null;
}
/** Per-column widths of a table's own colgroup, expanding `span`. */
function tableColWidths(table: Element): (string | null)[] {
const widths: (string | null)[] = [];
for (const col of Array.from(table.querySelectorAll(':scope > colgroup > col, :scope > col'))) {
const span = parseInt(col.getAttribute('span') ?? '1', 10);
const n = Number.isFinite(span) && span > 0 ? span : 1;
const w = colWidth(col);
for (let i = 0; i < n; i += 1) widths.push(w);
}
return widths;
}
/** A table's own rows (not nested tables' rows). */
function directRows(table: Element): Element[] {
const rows: Element[] = [];
for (const child of Array.from(table.children)) {
const t = child.tagName.toLowerCase();
if (t === 'tr') rows.push(child);
else if (t === 'thead' || t === 'tbody' || t === 'tfoot') {
for (const row of Array.from(child.children)) {
if (row.tagName.toLowerCase() === 'tr') rows.push(row);
}
}
}
return rows;
}
/** Copies colgroup widths onto unsized cells (rows with a colspan are skipped). */
function applyColgroupWidths(table: Element): void {
const widths = tableColWidths(table);
if (!widths.some(Boolean)) return;
for (const row of directRows(table)) {
const cells = Array.from(row.children).filter(c => /^t[dh]$/.test(c.tagName.toLowerCase()));
if (cells.some(c => parseInt(c.getAttribute('colspan') ?? '1', 10) > 1)) continue;
cells.forEach((cell, idx) => {
const w = widths[idx];
if (!w) return;
const hasWidth =
/(?:^|;)\s*width\s*:/i.test(cell.getAttribute('style') ?? '') || cell.hasAttribute('width');
if (hasWidth) return;
const style = cell.getAttribute('style') ?? '';
cell.setAttribute('style', style ? `${style}; width: ${w}` : `width: ${w}`);
});
}
}
/** Fidelity-only pass run right before parsing; see `relocateElement`. */
export function relocateEmailContainers(html: string): string {
const doc = new DOMParser().parseFromString(`${html}`, 'text/html');
relocateWalk(doc.body);
return doc.body.innerHTML;
}
/**
* Sanitizes the CSS inside a {
const url = String(raw).trim();
if (/^#/.test(url)) return full;
if (/^data:image\/(png|gif|jpe?g|webp|avif|bmp);/i.test(url)) return full;
return 'url(#)';
})
.trim();
}
/** Container tags an app may widen with `allowedStyles`. */
const EXTENSION_TAGS = new Set(['td', 'th', 'table', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
/** The allowlisted, grammar-valid extension declarations for a container, or ''. */
function computeExtraStyle(el: Element, allowed: Set | undefined): string {
if (!allowed) return '';
const kept: string[] = [];
for (const decl of (el.getAttribute('style') ?? '').split(';')) {
const idx = decl.indexOf(':');
if (idx === -1) continue;
const name = decl.slice(0, idx).trim().toLowerCase();
const value = decl.slice(idx + 1).trim();
if (allowed.has(name) && isExtensionValueValid(value)) kept.push(`${name}: ${value}`);
}
return kept.join('; ');
}
/** Re-validates a stored cell/table text style on write-back. */
export function sanitizeTableTextStyleForWrite(s: string): string {
const kept: string[] = [];
for (const decl of String(s ?? '').split(';')) {
const idx = decl.indexOf(':');
if (idx === -1) continue;
const name = decl.slice(0, idx).trim().toLowerCase();
const value = decl.slice(idx + 1).trim();
if (!value) continue;
const ok =
name === 'line-height'
? isLineHeight(value)
: TABLE_TEXT_PROPS.includes(name) && isCellTextValid(name, value);
if (ok) kept.push(`${name}: ${value}`);
}
return kept.join('; ');
}
function sanitizeElement(
el: Element,
emailFidelity = false,
allowedStyles?: NormalizedAllowedStyles
): void {
const tag = el.tagName.toLowerCase();
// With fidelity, a table pushes its colgroup column widths down onto its own
// cells before they are sanitized (the widths are stripped otherwise).
if (emailFidelity && tag === 'table') applyColgroupWidths(el);
// Fidelity recomputes the container style, written back after the attribute pass.
const email =
emailFidelity && CONTAINER_TAGS.has(tag) ? computeContainerStyle(el, tag) : null;
// Extension declarations come from the original style and move to data-extra-style.
const extra =
emailFidelity && EXTENSION_TAGS.has(tag)
? computeExtraStyle(el, allowedStyles?.get(tag))
: '';
const allowed = TAG_ATTRS[tag];
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const keep =
(GLOBAL_ATTRS.has(name) || (allowed !== undefined && allowed.has(name))) &&
!name.startsWith('on');
if (!keep) {
el.removeAttribute(attr.name);
continue;
}
if (name === 'style') {
const filtered = filterStyle(attr.value, tag);
if (filtered) el.setAttribute('style', filtered);
else el.removeAttribute('style');
} else if (name === 'class') {
const cleaned = cleanClassAttr(attr.value);
if (cleaned) el.setAttribute('class', cleaned);
else el.removeAttribute('class');
} else if (name === 'id') {
if (RESERVED_NAME.test(attr.value)) el.removeAttribute('id');
} else if (name === 'href' || name === 'src') {
// Raster data: URLs are allowed on images only.
if (!isSafeUrl(attr.value, tag === 'img')) el.removeAttribute(attr.name);
} else if (name === 'target') {
if (attr.value === '_blank') el.setAttribute('rel', 'noopener noreferrer');
else el.removeAttribute('target');
}
}
if (email !== null) {
if (email) el.setAttribute('style', email);
else el.removeAttribute('style');
}
if (extra) el.setAttribute('data-extra-style', extra);
}
function walk(
node: Element,
emailFidelity = false,
allowedStyles?: NormalizedAllowedStyles
): void {
for (const child of Array.from(node.children)) {
const tag = child.tagName.toLowerCase();
if (DROP_WITH_CONTENT.has(tag)) {
child.remove();
continue;
}
// Email preheaders: elements hidden with display:none / mso-hide are dropped
// together with their content so they don't surface as 1px text.
if (emailFidelity) {
const style = child.getAttribute('style') ?? '';
if (/display\s*:\s*none/i.test(style) || /mso-hide\s*:\s*all/i.test(style)) {
child.remove();
continue;
}
}
// iframes are allowed for any https src, but always force-sandboxed so an
// untrusted embed can never navigate the top window or run unrestricted.
if (tag === 'iframe') {
if (!isAllowedEmbedSrc(child.getAttribute('src') ?? '')) {
child.remove();
continue;
}
child.setAttribute('sandbox', EMBED_SANDBOX);
child.setAttribute('referrerpolicy', EMBED_REFERRER_POLICY);
}
//