forked from UKSOURCE/cms.lams
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
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
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(/ /gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.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 `<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,
|
||||
};
|
||||
Reference in New Issue
Block a user