forked from UKSOURCE/cms.lams
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
405 lines
11 KiB
JavaScript
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(/ /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,
|
|
};
|