Files
cms.techvanguard.vn/utils/policiesBlockContent.js
Tống Thành Đạt cdaddddfeb refactor(policies): migrate to block-based content editor
Replace the legacy section-based policy editor with a flexible block-based system. This transition enables real-time previews, a wider variety of content types (headings, quotes, callouts), and improved data normalization.
- Implement `policies-block-editor.js` for dynamic content management
- Add `normalizePoliciesDocument` and `validateContent` utilities to handle data migration and integrity
- Update `policiesController.js` to support the new block structure and validation logic
- Migrate `policies.json` from `sections` array to a `content.blocks` structure
- Remove obsolete section editor views and partials
- Update `policiesConfig.js` to reflect the new editor capabilities
2026-04-22 10:14:54 +07:00

405 lines
11 KiB
JavaScript

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(/<br\s*\/?>/gi, " ")
.replace(/<\/p>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function escapeHtml(value = "") {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function ensureParagraphHtml(value = "") {
const raw = String(value || "").trim();
if (!raw) {
return "";
}
if (/<[a-z][\s\S]*>/i.test(raw)) {
return raw;
}
return `<p>${escapeHtml(raw)}</p>`;
}
function createInternalAnchor(policyId, label) {
return `<a href="#${escapeHtml(policyId)}" data-policy-id="${escapeHtml(
policyId,
)}" data-link-kind="internal">${escapeHtml(label)}</a>`;
}
function createExternalAnchor(href, label) {
return `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
}
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 `<p>${html}</p>`;
}
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: `<span>${escapeHtml(heading)}</span>`,
});
}
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
? `<p>${createInternalAnchor(card.link.tabId, card.link.label || "Open")}</p>`
: card.link?.href
? `<p>${createExternalAnchor(card.link.href, card.link.label || card.link.href)}</p>`
: "";
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 = /<a\b([^>]*)>/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,
};