const { ensureUniqueIds, slugifyContentId } = require("./contentEditorIds");
const BLOCK_TYPES = new Set([
"heading",
"paragraph",
"list",
"quote",
"divider",
"callout",
]);
function createBlockId(prefix, value, index) {
return slugifyContentId(value, `${prefix}-${index + 1}`);
}
function stripHtml(html = "") {
return String(html || "")
.replace(/
/gi, " ")
.replace(/<\/p>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/ /gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function escapeHtml(value = "") {
return String(value)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function ensureParagraphHtml(value = "") {
const raw = String(value || "").trim();
if (!raw) {
return "";
}
if (/<[a-z][\s\S]*>/i.test(raw)) {
return raw;
}
return `
${escapeHtml(raw)}
`; } function createInternalAnchor(policyId, label) { return `${escapeHtml(label)}`; } function createExternalAnchor(href, label) { return `${escapeHtml(label)}`; } function paragraphToHtml(paragraph = {}) { const text = String(paragraph.text || ""); const links = Array.isArray(paragraph.links) ? paragraph.links : []; let html = escapeHtml(text); links.forEach((link, index) => { const label = String(link.label || "").trim(); if (!label) { return; } const anchor = link.tabId ? createInternalAnchor(link.tabId, label) : link.href ? createExternalAnchor(link.href, label) : escapeHtml(label); if (html.includes(escapeHtml(label))) { html = html.replace(escapeHtml(label), anchor); return; } const suffix = index === 0 ? "" : " "; html = `${html}${suffix}${anchor}`; }); return `${html}
`; } function legacySectionToBlocks(section = {}, sectionIndex = 0) { const blocks = []; const heading = String(section.heading || section.title || "").trim(); if (heading) { blocks.push({ id: createBlockId("heading", heading, sectionIndex), type: "heading", level: 2, html: `${escapeHtml(heading)}`, }); } if (section.type === "text") { const paragraphs = Array.isArray(section.paragraphs) ? section.paragraphs : []; paragraphs.forEach((paragraph, paragraphIndex) => { blocks.push({ id: createBlockId( "paragraph", paragraph.text || `${heading || "paragraph"}-${paragraphIndex + 1}`, paragraphIndex, ), type: "paragraph", html: paragraphToHtml(paragraph), }); }); return blocks; } if (section.type === "list") { if (section.intro) { blocks.push({ id: createBlockId("paragraph", `${heading || "list"}-intro`, sectionIndex), type: "paragraph", html: ensureParagraphHtml(section.intro), }); } blocks.push({ id: createBlockId("list", heading || `list-${sectionIndex + 1}`, sectionIndex), type: "list", style: "unordered", items: (Array.isArray(section.items) ? section.items : []).map((item, itemIndex) => ({ id: createBlockId("item", item || `item-${itemIndex + 1}`, itemIndex), html: ensureParagraphHtml(item), })), }); return blocks; } if (section.type === "cards") { const cards = Array.isArray(section.cards) ? section.cards : []; cards.forEach((card, cardIndex) => { const description = String(card.description || ""); const linkHtml = card.link?.tabId ? `${createInternalAnchor(card.link.tabId, card.link.label || "Open")}
` : card.link?.href ? `${createExternalAnchor(card.link.href, card.link.label || card.link.href)}
` : ""; blocks.push({ id: createBlockId("callout", card.title || `card-${cardIndex + 1}`, cardIndex), type: "callout", tone: "info", title: String(card.title || ""), icon: String(card.icon || ""), html: `${ensureParagraphHtml(description)}${linkHtml}`, }); }); } return blocks; } function legacySectionsToBlocks(sections = []) { return ensureUniqueIds( (Array.isArray(sections) ? sections : []).flatMap(legacySectionToBlocks), (block) => block.id, (block, index) => stripHtml(block.html) || `${block.type}-${index + 1}`, "block", ); } function normalizeListItems(items = []) { return ensureUniqueIds( (Array.isArray(items) ? items : []) .map((item, index) => { if (typeof item === "string") { return { id: createBlockId("item", item, index), html: ensureParagraphHtml(item), }; } return { id: item.id || createBlockId("item", stripHtml(item.html), index), html: ensureParagraphHtml(item.html), }; }) .filter((item) => item.html), (item) => item.id, (item, index) => stripHtml(item.html) || `item-${index + 1}`, "item", ); } function normalizeBlock(block = {}, index = 0) { const type = BLOCK_TYPES.has(block.type) ? block.type : "paragraph"; const normalized = { id: block.id || createBlockId(type, block.title || stripHtml(block.html) || type, index), type, }; if (type === "heading") { normalized.level = [1, 2, 3].includes(Number(block.level)) ? Number(block.level) : 2; normalized.html = ensureParagraphHtml(block.html || block.content || block.title || ""); return normalized; } if (type === "paragraph" || type === "quote") { normalized.html = ensureParagraphHtml(block.html || block.content || ""); if (type === "quote") { normalized.caption = String(block.caption || ""); } return normalized; } if (type === "list") { normalized.style = block.style === "ordered" ? "ordered" : "unordered"; normalized.items = normalizeListItems(block.items); return normalized; } if (type === "divider") { return normalized; } normalized.tone = ["info", "warning", "success"].includes(block.tone) ? block.tone : "info"; normalized.title = String(block.title || ""); normalized.icon = String(block.icon || ""); normalized.html = ensureParagraphHtml(block.html || block.content || ""); return normalized; } function normalizeContent(content = {}) { const blocks = ensureUniqueIds( (Array.isArray(content.blocks) ? content.blocks : []).map(normalizeBlock), (block) => block.id, (block, index) => stripHtml(block.title || block.html) || `${block.type}-${index + 1}`, "block", ); return { blocks, }; } function createEmptyContent() { return { blocks: [], }; } function normalizePolicyContent(policy = {}) { if (policy.content && typeof policy.content === "object") { return normalizeContent(policy.content); } if (policy.contentByLanguage && typeof policy.contentByLanguage === "object") { const englishContent = policy.contentByLanguage.en || policy.contentByLanguage.vi; return normalizeContent(englishContent || createEmptyContent()); } const migratedBlocks = legacySectionsToBlocks(policy.sections); return normalizeContent({ blocks: migratedBlocks }); } function normalizePolicy(policy = {}) { const normalized = { ...policy, content: normalizePolicyContent(policy), }; delete normalized.sections; delete normalized.contentByLanguage; return normalized; } function normalizePoliciesDocument(data = {}) { return { ...data, policies: (Array.isArray(data.policies) ? data.policies : []).map(normalizePolicy), }; } function parseAnchorAttributes(source = "") { const attributes = {}; const regex = /([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)')/g; let match = regex.exec(source); while (match) { attributes[match[1]] = match[3] || match[4] || ""; match = regex.exec(source); } return attributes; } function collectLinkErrors(html = "", validPolicyIds = []) { const errors = []; const anchorRegex = /]*)>/gi; let match = anchorRegex.exec(String(html || "")); while (match) { const attrs = parseAnchorAttributes(match[1]); const href = String(attrs.href || "").trim(); const policyId = String(attrs["data-policy-id"] || "").trim(); const isInternal = String(attrs["data-link-kind"] || "") === "internal"; if (isInternal) { if (!policyId || !validPolicyIds.includes(policyId)) { errors.push("Internal link points to an invalid policy."); } } else if ( href && !/^(https?:\/\/|mailto:|tel:|\/|#)/i.test(href) ) { errors.push(`Invalid link URL "${href}".`); } if (!isInternal && !href) { errors.push("Link is missing a URL."); } match = anchorRegex.exec(String(html || "")); } return errors; } function validateBlocks(blocks = [], validPolicyIds = []) { const errors = []; const warnings = []; let h1Count = 0; blocks.forEach((block, index) => { const label = `Block ${index + 1}`; if (block.type === "heading" && Number(block.level) === 1) { h1Count += 1; } if (block.type === "divider") { return; } if (block.type === "list") { if (!Array.isArray(block.items) || block.items.length === 0) { warnings.push(`${label} has no list items.`); } block.items.forEach((item, itemIndex) => { if (!stripHtml(item.html)) { warnings.push(`${label} item ${itemIndex + 1} is empty.`); } }); errors.push(...collectLinkErrors(JSON.stringify(block.items), validPolicyIds)); return; } if (block.type === "callout" && !stripHtml(block.html) && !stripHtml(block.title)) { warnings.push(`${label} is empty.`); } else if (!stripHtml(block.html) && block.type !== "divider") { warnings.push(`${label} is empty.`); } errors.push(...collectLinkErrors(block.html, validPolicyIds)); }); if (h1Count > 1) { errors.push( `Content has ${h1Count} H1 blocks. Only one H1 is allowed.`, ); } return { errors, warnings }; } function validateContent(content = {}, policyIds = []) { const normalized = normalizeContent(content || createEmptyContent()); const { errors, warnings } = validateBlocks(normalized.blocks, policyIds); if (normalized.blocks.length === 0) { warnings.push("Content is empty."); } return { content: normalized, errors: Array.from(new Set(errors)), warnings: Array.from(new Set(warnings)), }; } module.exports = { createEmptyContent, normalizePolicy, normalizePoliciesDocument, normalizePolicyContent, validateContent, stripHtml, };