(function () { const root = document.getElementById("policyBlockEditor"); const form = document.getElementById("policyBlockEditorForm"); const pageJsonInput = document.getElementById("pageJson"); const intentInput = document.getElementById("editorIntent"); const blockList = document.getElementById("blockList"); const previewContent = document.getElementById("previewContent"); const blockTypeSelect = document.getElementById("blockTypeSelect"); const addBlockButton = document.getElementById("addBlockButton"); const toggleFullscreenButton = document.getElementById("toggleFullscreenButton"); const validationBanner = document.getElementById("validationBanner"); const validationSummary = document.getElementById("validationSummary"); const previewDevice = document.getElementById("previewDevice"); const dropIndicator = document.getElementById("dropIndicator"); const floatingToolbar = document.getElementById("floatingToolbar"); const linkPopover = document.getElementById("linkPopover"); const slashMenu = document.getElementById("slashMenu"); const linkUrlInput = document.getElementById("linkUrlInput"); const linkPolicySelect = document.getElementById("linkPolicySelect"); const saveLinkButton = document.getElementById("saveLinkButton"); const removeLinkButton = document.getElementById("removeLinkButton"); const data = readJsonScript("policyBlockEditorDataPayload"); const config = readJsonScript("policyBlockEditorConfigData"); if (!root || !form || !pageJsonInput || !blockList || !previewContent || !data || !config) { return; } const defaultContent = { content: { blocks: [] }, }; const state = JSON.parse(JSON.stringify(data.content ? data : defaultContent)); state.policy = data.policy; state.content = state.content || defaultContent.content; const ui = { previewMode: "desktop", selectedBlockId: null, targetedPreviewBlockId: null, hoveredPreviewBlockId: null, pendingLinkRange: null, slashContext: null, toolbarTarget: null, sortable: null, previewTargetTimer: null, isFullscreen: false, }; const stylePalette = [ { value: "info", label: "Info" }, { value: "warning", label: "Warning" }, { value: "success", label: "Success" }, ]; const iconOptions = Array.isArray(config.iconOptions) ? config.iconOptions : []; bindStaticEvents(); render(); window.policyBlockEditorDebug = { getState: function () { return JSON.parse(JSON.stringify(state)); }, moveBlockById: function (language, blockId, newIndex) { const blocks = getBlocks(); const oldIndex = blocks.findIndex((block) => block.id === blockId); if (oldIndex === -1) { return false; } moveBlock(oldIndex, newIndex); return true; }, }; function bindStaticEvents() { root.querySelectorAll("[data-preview-mode]").forEach((button) => { button.addEventListener("click", function () { ui.previewMode = this.dataset.previewMode; updatePreviewModeButtons(); }); }); addBlockButton.addEventListener("click", function () { insertBlock(blockTypeSelect.value || "paragraph"); }); if (toggleFullscreenButton) { toggleFullscreenButton.addEventListener("click", function () { setEditorFullscreen(!ui.isFullscreen); }); } form.querySelectorAll("[data-intent]").forEach((button) => { button.addEventListener("click", function () { intentInput.value = this.dataset.intent || "save"; }); }); form.addEventListener("submit", function (event) { const validation = validateState(); if (validation.errors.length > 0) { event.preventDefault(); renderValidation(validation); showToast("Validation", "Fix the highlighted content before saving.", "danger"); return; } pageJsonInput.value = JSON.stringify({ content: state.content, }); }); document.addEventListener("selectionchange", handleSelectionChange); root.addEventListener( "wheel", function (event) { if (!ui.isFullscreen) { return; } const interactiveTarget = event.target.closest( ".icon-combobox-panel, .icon-combobox-options", ); if (interactiveTarget) { return; } root.scrollTop += event.deltaY; event.preventDefault(); }, { passive: false }, ); document.addEventListener("click", handleGlobalClick); document.addEventListener("keydown", function (event) { if (event.key === "Escape") { if (ui.isFullscreen) { setEditorFullscreen(false); } hideFloatingToolbar(); hideLinkPopover(); hideSlashMenu(); } }); floatingToolbar.addEventListener("mousedown", function (event) { event.preventDefault(); }); floatingToolbar.addEventListener("click", function (event) { const target = event.target.closest("[data-command]"); if (!target) { return; } const command = target.dataset.command; if (command === "openLink") { openLinkPopover(); return; } if (target.matches('input[type="color"]')) { return; } applyCommand(command); }); floatingToolbar.querySelectorAll('input[type="color"]').forEach((input) => { input.addEventListener("input", function () { applyCommand(this.dataset.command, this.value); }); }); saveLinkButton.addEventListener("click", applyLinkFromPopover); removeLinkButton.addEventListener("click", removeCurrentLink); slashMenu.querySelectorAll("[data-slash-type]").forEach((button) => { button.addEventListener("click", function () { insertSlashCommand(this.dataset.slashType); }); }); } function getBlocks() { return state.content.blocks; } function setBlockSelection(blockId) { ui.selectedBlockId = blockId; renderBlockSelection(); renderPreviewSelection(); } function render() { hideFloatingToolbar(); hideLinkPopover(); hideSlashMenu(); updatePreviewModeButtons(); updateFullscreenToggle(); renderBlocks(); renderPreview(); renderValidation(validateState()); } function setEditorFullscreen(nextValue) { ui.isFullscreen = Boolean(nextValue); root.classList.toggle("is-editor-fullscreen", ui.isFullscreen); document.body.classList.toggle("policy-editor-fullscreen", ui.isFullscreen); updateFullscreenToggle(); } function updateFullscreenToggle() { if (!toggleFullscreenButton) { return; } const label = toggleFullscreenButton.querySelector('[data-role="fullscreen-label"]'); const icon = toggleFullscreenButton.querySelector('[data-role="fullscreen-icon"]'); toggleFullscreenButton.setAttribute("aria-pressed", ui.isFullscreen ? "true" : "false"); toggleFullscreenButton.classList.toggle("btn-outline-secondary", !ui.isFullscreen); toggleFullscreenButton.classList.toggle("btn-outline-primary", ui.isFullscreen); if (label) { label.textContent = ui.isFullscreen ? "Exit full screen" : "Full screen edit"; } if (icon) { icon.className = `fas ${ui.isFullscreen ? "fa-compress" : "fa-expand"} me-2`; } } function updatePreviewModeButtons() { root.querySelectorAll("[data-preview-mode]").forEach((button) => { button.classList.toggle("is-active", button.dataset.previewMode === ui.previewMode); }); previewDevice.dataset.mode = ui.previewMode; } function createBlock(type) { const id = `block-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; if (type === "heading") { return { id, type, level: 2, html: "", }; } if (type === "list") { return { id, type, style: "unordered", items: [ { id: `${id}-item-1`, html: "" }, { id: `${id}-item-2`, html: "" }, ], }; } if (type === "quote") { return { id, type, html: "", caption: "", }; } if (type === "divider") { return { id, type }; } if (type === "callout") { return { id, type, tone: "info", title: "", icon: "fa-circle-info", html: "", }; } return { id, type: "paragraph", html: "", }; } function insertBlock(type, index) { const blocks = getBlocks(); const nextBlock = createBlock(type); const insertIndex = typeof index === "number" ? index : blocks.length; blocks.splice(insertIndex, 0, nextBlock); setBlockSelection(nextBlock.id); render(); } function duplicateBlock(blockId) { const blocks = getBlocks(); const index = blocks.findIndex((block) => block.id === blockId); if (index === -1) { return; } const source = JSON.parse(JSON.stringify(blocks[index])); source.id = `block-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; if (source.items) { source.items = source.items.map((item, itemIndex) => ({ ...item, id: `${source.id}-item-${itemIndex + 1}`, })); } blocks.splice(index + 1, 0, source); setBlockSelection(source.id); render(); } function deleteBlock(blockId) { const blocks = getBlocks(); const index = blocks.findIndex((block) => block.id === blockId); if (index === -1) { return; } blocks.splice(index, 1); if (ui.selectedBlockId === blockId) { ui.selectedBlockId = null; } render(); } function moveBlock(oldIndex, newIndex) { const blocks = getBlocks(); if ( oldIndex === newIndex || oldIndex < 0 || newIndex < 0 || oldIndex >= blocks.length || newIndex >= blocks.length ) { return; } const [moved] = blocks.splice(oldIndex, 1); blocks.splice(newIndex, 0, moved); render(); } function renderBlocks() { const blocks = getBlocks(); blockList.innerHTML = ""; if (blocks.length === 0) { const empty = document.createElement("div"); empty.className = "text-muted border rounded-4 p-4 text-center"; empty.innerHTML = 'No blocks yet. Add your first block to start editing.'; blockList.appendChild(empty); return; } const validation = validateState(); blocks.forEach((block, index) => { const card = document.createElement("article"); card.className = "block-card"; card.dataset.blockId = block.id; card.classList.toggle( "is-invalid", validation.blockIssues[block.id]?.length > 0, ); card.classList.toggle("is-selected", ui.selectedBlockId === block.id); card.innerHTML = `
${index + 1}
${block.type}
${describeBlock(block)}
`; const body = card.querySelector(".block-body"); renderBlockBody(block, body, index); card.addEventListener("click", function () { setBlockSelection(block.id); }); card.querySelector('[data-action="duplicate"]').addEventListener("click", function (event) { event.stopPropagation(); duplicateBlock(block.id); }); card.querySelector('[data-action="target-preview"]').addEventListener("click", function (event) { event.stopPropagation(); focusPreviewBlock(block.id); }); card.querySelector('[data-action="delete"]').addEventListener("click", function (event) { event.stopPropagation(); deleteBlock(block.id); }); card.querySelector('[data-action="collapse"]').addEventListener("click", function (event) { event.stopPropagation(); card.classList.toggle("is-collapsed"); }); blockList.appendChild(card); }); initializeSortable(); renderBlockSelection(); } function initializeSortable() { if (ui.sortable) { ui.sortable.destroy(); ui.sortable = null; } if (!window.Sortable || blockList.children.length < 2) { return; } ui.sortable = window.Sortable.create(blockList, { animation: 180, handle: ".drag-handle", ghostClass: "sortable-ghost", chosenClass: "sortable-chosen", onStart: function () { dropIndicator.classList.remove("is-visible"); }, onEnd: function (event) { if ( typeof event.oldIndex !== "number" || typeof event.newIndex !== "number" || event.oldIndex === event.newIndex ) { return; } moveBlock(event.oldIndex, event.newIndex); }, }); } function renderBlockBody(block, body, index) { if (block.type === "heading") { body.innerHTML = `
`; const editor = createEditable(block.html, "Heading text"); editor.dataset.blockId = block.id; editor.dataset.field = "html"; body.appendChild(editor); bindEditable(editor, function (value) { block.html = value; }); body.querySelector('[data-field="level"]').addEventListener("change", function () { block.level = Number(this.value); render(); }); return; } if (block.type === "paragraph") { const editor = createEditable(block.html, "Write a paragraph. Type / for commands."); editor.dataset.blockId = block.id; editor.dataset.field = "html"; body.appendChild(editor); bindEditable(editor, function (value) { block.html = value; }, { slashEnabled: true, blockIndex: index }); return; } if (block.type === "quote") { body.innerHTML = `
`; const editor = createEditable(block.html, "Quote content"); editor.dataset.blockId = block.id; editor.dataset.field = "html"; body.appendChild(editor); bindEditable(editor, function (value) { block.html = value; }); body.querySelector('[data-field="caption"]').addEventListener("input", function () { block.caption = this.value; renderPreview(); renderValidation(validateState()); }); return; } if (block.type === "divider") { body.innerHTML = `
Divider blocks render as a semantic separator in the preview. They are useful between long content groups.
`; return; } if (block.type === "callout") { body.innerHTML = `
${block.icon ? `` : ""}
`; const editor = createEditable(block.html, "Callout body"); editor.dataset.blockId = block.id; editor.dataset.field = "html"; body.appendChild(editor); bindEditable(editor, function (value) { block.html = value; }); initializeIconPicker(body.querySelector('[data-role="icon-picker-group"]'), block); body.querySelectorAll("input[data-field], select[data-field], textarea[data-field]").forEach((input) => { const syncField = function () { block[this.dataset.field] = this.value; renderPreview(); renderValidation(validateState()); }; input.addEventListener("input", syncField); input.addEventListener("change", syncField); }); return; } body.innerHTML = `
`; body.querySelector('[data-field="style"]').addEventListener("change", function () { block.style = this.value; renderPreview(); renderValidation(validateState()); }); const listItems = body.querySelector(".list-items"); (block.items || []).forEach((item, itemIndex) => { const row = document.createElement("div"); row.className = "list-item"; row.innerHTML = `
${itemIndex + 1}
`; const content = row.querySelector(".list-item-content"); content.innerHTML = item.html || ""; content.dataset.blockId = block.id; content.dataset.itemId = item.id; bindEditable(content, function (value) { item.html = value; }, { slashEnabled: true, blockIndex: index }); row.querySelector("button").addEventListener("click", function () { block.items.splice(itemIndex, 1); render(); }); listItems.appendChild(row); }); body.querySelector('[data-action="add-list-item"]').addEventListener("click", function () { block.items.push({ id: `${block.id}-item-${Date.now()}`, html: "", }); render(); }); } function createEditable(html, placeholder) { const editor = document.createElement("div"); editor.className = "block-content"; editor.contentEditable = "true"; editor.spellcheck = true; editor.dataset.placeholder = placeholder || ""; editor.innerHTML = html || ""; return editor; } function initializeIconPicker(wrapper, block) { if (!wrapper) { return; } const input = wrapper.querySelector('input[data-field="icon"]'); const button = wrapper.querySelector('[data-action="pick-icon"]'); if (!input || !button) { return; } const syncIconValue = function (value) { const nextValue = String(value || "").trim(); input.value = nextValue; input.dataset.iconPickerPreviewPrefix = resolveCalloutIconPreviewPrefix(nextValue); block.icon = nextValue; syncCalloutIconPreview(input); renderPreview(); renderValidation(validateState()); }; input.addEventListener("click", function () { openSharedIconPicker(input, syncIconValue); }); button.addEventListener("click", function () { openSharedIconPicker(input, syncIconValue); }); syncIconValue(block.icon || ""); } function openSharedIconPicker(input, onPicked) { if (!window.IconPicker || typeof window.IconPicker.open !== "function") { return; } patchSharedIconPicker(); window.__policyBlockIconPickerInput = input; window.__policyBlockIconPickerOnPicked = onPicked; window.IconPicker.open(input); } function patchSharedIconPicker() { if (!window.IconPicker || window.IconPicker.__policyBlockPatched) { return; } const originalPick = typeof window.IconPicker.pick === "function" ? window.IconPicker.pick.bind(window.IconPicker) : null; if (!originalPick) { return; } const patchedPick = function (value) { originalPick(value); const activeInput = window.__policyBlockIconPickerInput; const onPicked = window.__policyBlockIconPickerOnPicked; if (!activeInput || typeof onPicked !== "function") { return; } activeInput.dataset.iconPickerPreviewPrefix = extractIconStyle(value) || resolveCalloutIconPreviewPrefix(activeInput.value || value); onPicked(extractIconName(value)); activeInput.dispatchEvent(new Event("input", { bubbles: true })); activeInput.dispatchEvent(new Event("change", { bubbles: true })); window.__policyBlockIconPickerInput = null; window.__policyBlockIconPickerOnPicked = null; }; window.IconPicker.pick = patchedPick; window.IconPickerPick = patchedPick; window.IconPicker.__policyBlockPatched = true; } function syncCalloutIconPreview(input) { if (!input) { return; } const previewCell = input.closest(".input-group")?.querySelector(".icon-preview-cell"); if (!previewCell) { return; } const previewClass = resolveCalloutIconPreviewClass(input.value, input); previewCell.innerHTML = previewClass ? `` : ""; loadIconStyleLookup().then(function () { const nextPrefix = resolveCalloutIconPreviewPrefix(input.value, input); if (nextPrefix !== input.dataset.iconPickerPreviewPrefix) { input.dataset.iconPickerPreviewPrefix = nextPrefix; const refreshedPreviewClass = resolveCalloutIconPreviewClass(input.value, input); previewCell.innerHTML = refreshedPreviewClass ? `` : ""; } }); } function resolveCalloutIconPreviewClass(iconName, input) { const normalizedValue = String(iconName || "").trim(); if (!normalizedValue) { return ""; } return `${resolveCalloutIconPreviewPrefix(normalizedValue, input)} ${normalizedValue}`.trim(); } function resolveCalloutIconPreviewPrefix(iconName, input) { const explicitPrefix = String(input?.dataset?.iconPickerPreviewPrefix || "").trim(); if (explicitPrefix && explicitPrefix !== "fa-solid") { return explicitPrefix; } const normalizedIconName = extractIconName(iconName); const knownStyles = window.__policyBlockIconStyleLookup?.[normalizedIconName] || []; if (knownStyles.includes("fa-brands")) return "fa-brands"; if (knownStyles.includes("fa-regular")) return "fa-regular"; if (knownStyles.includes("fa-solid")) return "fa-solid"; return explicitPrefix || "fa-solid"; } function extractIconName(value) { return String(value || "") .trim() .split(/\s+/) .find( (token) => /^fa-[a-z0-9-]+$/i.test(token) && !/^fa-(solid|regular|brands)$/i.test(token), ) || ""; } function extractIconStyle(value) { return String(value || "") .trim() .split(/\s+/) .find((token) => /^fa-(solid|regular|brands)$/i.test(token)) || ""; } function loadIconStyleLookup() { if (window.__policyBlockIconStyleLookupPromise) { return window.__policyBlockIconStyleLookupPromise; } window.__policyBlockIconStyleLookupPromise = fetch("/js/fa-icons.json") .then((response) => { if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return response.json(); }) .then((json) => { const lookup = {}; Object.entries(json || {}).forEach(([name, meta]) => { lookup[`fa-${name}`] = (meta.styles || []) .filter((style) => ["solid", "regular", "brands"].includes(style)) .map((style) => `fa-${style}`); }); window.__policyBlockIconStyleLookup = lookup; return lookup; }) .catch(() => { window.__policyBlockIconStyleLookup = window.__policyBlockIconStyleLookup || {}; return window.__policyBlockIconStyleLookup; }); return window.__policyBlockIconStyleLookupPromise; } function bindEditable(element, onChange, options) { element.addEventListener("input", function () { normalizeEditorHtml(element); onChange(element.innerHTML); renderPreview(); renderValidation(validateState()); }); element.addEventListener("focus", function () { ui.toolbarTarget = element; setBlockSelection(element.dataset.blockId); }); element.addEventListener("keydown", function (event) { if (event.key === "Tab" && element.closest(".list-item-content")) { event.preventDefault(); document.execCommand("insertHTML", false, "    "); return; } if (options?.slashEnabled && event.key === "/") { ui.slashContext = { blockIndex: options.blockIndex, blockId: element.dataset.blockId, target: element, }; const range = window.getSelection()?.getRangeAt(0); if (range) { window.setTimeout(function () { showSlashMenu(range); }, 0); } } }); } function normalizeEditorHtml(element) { if (!element.innerHTML.trim()) { element.innerHTML = ""; } } function handleSelectionChange() { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { hideFloatingToolbar(); return; } const anchorNode = selection.anchorNode?.parentElement; const editable = anchorNode?.closest(".block-content, .list-item-content"); if (!editable) { hideFloatingToolbar(); return; } ui.toolbarTarget = editable; const range = selection.getRangeAt(0); const rect = range.getBoundingClientRect(); if (!rect.width && !rect.height) { hideFloatingToolbar(); return; } floatingToolbar.style.top = `${Math.max(rect.top - 56, 12)}px`; floatingToolbar.style.left = `${Math.max(rect.left + rect.width / 2 - 160, 12)}px`; floatingToolbar.classList.add("is-visible"); } function handleGlobalClick(event) { if (!floatingToolbar.contains(event.target) && !event.target.closest(".block-content, .list-item-content")) { hideFloatingToolbar(); } if (!linkPopover.contains(event.target) && !event.target.closest('[data-command="openLink"]')) { hideLinkPopover(); } if (!slashMenu.contains(event.target) && !event.target.closest(".block-content, .list-item-content")) { hideSlashMenu(); } } function hideFloatingToolbar() { floatingToolbar.classList.remove("is-visible"); } function showSlashMenu(range) { const rect = range.getBoundingClientRect(); slashMenu.style.top = `${rect.bottom + 8}px`; slashMenu.style.left = `${Math.max(rect.left, 12)}px`; slashMenu.classList.add("is-visible"); } function hideSlashMenu() { slashMenu.classList.remove("is-visible"); ui.slashContext = null; } function insertSlashCommand(type) { hideSlashMenu(); if (type === "link") { openLinkPopover(); return; } if (!ui.slashContext) { insertBlock(type); return; } const blocks = getBlocks(); const index = blocks.findIndex((block) => block.id === ui.slashContext.blockId); insertBlock(type, index + 1); } function applyCommand(command, value) { if (!ui.toolbarTarget) { return; } ui.toolbarTarget.focus(); if (command === "toggleCode") { document.execCommand("insertHTML", false, `${getSelectedText()}`); } else if (command === "toggleHighlight") { document.execCommand("hiliteColor", false, "#fef08a"); } else { document.execCommand(command, false, value); } ui.toolbarTarget.dispatchEvent(new Event("input", { bubbles: true })); } function getSelectedText() { return window.getSelection ? window.getSelection().toString() : ""; } function openLinkPopover() { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { return; } ui.pendingLinkRange = selection.getRangeAt(0).cloneRange(); const rect = ui.pendingLinkRange.getBoundingClientRect(); linkPopover.style.top = `${rect.bottom + 12}px`; linkPopover.style.left = `${Math.max(rect.left - 60, 12)}px`; linkPopover.classList.add("is-visible"); const anchor = selection.anchorNode?.parentElement?.closest("a"); linkUrlInput.value = anchor?.getAttribute("href") || ""; linkPolicySelect.value = anchor?.getAttribute("data-policy-id") || ""; } function hideLinkPopover() { linkPopover.classList.remove("is-visible"); } function applyLinkFromPopover() { if (!ui.pendingLinkRange) { return; } const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(ui.pendingLinkRange); const internalPolicyId = linkPolicySelect.value; const href = linkUrlInput.value.trim(); const selectedText = getSelectedText() || href || internalPolicyId || "link"; if (internalPolicyId) { document.execCommand( "insertHTML", false, `${selectedText}`, ); } else if (href) { document.execCommand("createLink", false, href); } if (ui.toolbarTarget) { ui.toolbarTarget.dispatchEvent(new Event("input", { bubbles: true })); } hideLinkPopover(); } function removeCurrentLink() { if (!ui.toolbarTarget) { return; } ui.toolbarTarget.focus(); document.execCommand("unlink", false); ui.toolbarTarget.dispatchEvent(new Event("input", { bubbles: true })); hideLinkPopover(); } function describeBlock(block) { if (block.type === "heading") { return `H${block.level || 2}`; } if (block.type === "list") { return `${block.style === "ordered" ? "Numbered" : "Bullet"} list`; } if (block.type === "callout") { return `${block.tone || "info"} callout`; } return "Rich text"; } function validateState() { const errors = []; const warnings = []; const blockIssues = {}; const validPolicyIds = [state.policy.id].concat((config.policyOptions || []).map((item) => item.value)); let h1Count = 0; getBlocks().forEach((block) => { const issues = []; if (block.type === "heading" && Number(block.level) === 1) { h1Count += 1; } if (block.type === "divider") { blockIssues[block.id] = issues; return; } if (block.type === "list") { if (!block.items?.length) { issues.push("List needs at least one item."); } block.items?.forEach((item, index) => { if (!stripHtml(item.html)) { issues.push(`List item ${index + 1} is empty.`); } issues.push(...collectLinkIssues(item.html, validPolicyIds)); }); } else if (block.type === "callout") { if (!stripHtml(block.html) && !stripHtml(block.title)) { issues.push("Callout is empty."); } issues.push(...collectLinkIssues(block.html, validPolicyIds)); } else if (!stripHtml(block.html)) { issues.push("Block is empty."); } else { issues.push(...collectLinkIssues(block.html, validPolicyIds)); } warnings.push(...issues); blockIssues[block.id] = issues; }); if (h1Count > 1) { errors.push("Content has more than one H1."); } if (getBlocks().length === 0) { warnings.push("Content is empty."); } return { errors, warnings, blockIssues, }; } function collectLinkIssues(html, validPolicyIds) { const issues = []; const anchorRegex = /]*)>/gi; let match = anchorRegex.exec(String(html || "")); while (match) { const attrs = {}; String(match[1]).replace(/([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)')/g, function (_, key, __, a, b) { attrs[key] = a || b || ""; return _; }); const href = String(attrs.href || "").trim(); const policyId = String(attrs["data-policy-id"] || "").trim(); const kind = String(attrs["data-link-kind"] || ""); if (kind === "internal") { if (!policyId || !validPolicyIds.includes(policyId)) { issues.push("Internal link target is invalid."); } } else if (!href || !/^(https?:\/\/|mailto:|tel:|\/|#)/i.test(href)) { issues.push("Link URL is invalid."); } match = anchorRegex.exec(String(html || "")); } return issues; } function renderValidation(validation) { const hasMessages = validation.errors.length > 0 || validation.warnings.length > 0; validationBanner.classList.toggle("is-visible", hasMessages); validationSummary.innerHTML = hasMessages ? validation.errors.concat(validation.warnings).slice(0, 8).map((item) => `
${escapeHtml(item)}
`).join("") : ""; blockList.querySelectorAll(".block-card").forEach((card) => { const issues = validation.blockIssues[card.dataset.blockId] || []; card.classList.toggle("is-invalid", issues.length > 0); }); } function renderPreview() { const blocks = getBlocks(); previewContent.innerHTML = ""; if (blocks.length === 0) { previewContent.innerHTML = '
No content yet.
'; return; } blocks.forEach((block) => { const node = document.createElement("section"); node.className = "preview-block"; node.dataset.previewBlockId = block.id; node.addEventListener("mouseenter", function () { ui.hoveredPreviewBlockId = block.id; renderBlockSelection(); }); node.addEventListener("mouseleave", function () { ui.hoveredPreviewBlockId = null; renderBlockSelection(); }); node.addEventListener("click", function () { setBlockSelection(block.id); document.querySelector(`[data-block-id="${block.id}"]`)?.scrollIntoView({ behavior: "smooth", block: "center", }); }); if (block.type === "heading") { const tagName = `h${block.level || 2}`; const heading = document.createElement(tagName); heading.className = block.level === 1 ? "display-6 fw-bold" : block.level === 2 ? "h3 fw-bold" : "h5 fw-semibold"; heading.innerHTML = block.html || ""; node.appendChild(heading); } else if (block.type === "paragraph") { const wrapper = document.createElement("div"); wrapper.innerHTML = block.html || ""; node.appendChild(wrapper); } else if (block.type === "quote") { const blockquote = document.createElement("blockquote"); blockquote.className = "border-start border-4 ps-3 my-2"; blockquote.innerHTML = block.html || ""; node.appendChild(blockquote); if (block.caption) { const cite = document.createElement("div"); cite.className = "small text-muted"; cite.textContent = block.caption; node.appendChild(cite); } } else if (block.type === "divider") { const divider = document.createElement("hr"); node.appendChild(divider); } else if (block.type === "list") { const list = document.createElement(block.style === "ordered" ? "ol" : "ul"); list.className = "ps-4"; (block.items || []).forEach((item) => { const li = document.createElement("li"); li.innerHTML = item.html || ""; list.appendChild(li); }); node.appendChild(list); } else if (block.type === "callout") { const callout = document.createElement("div"); callout.className = "callout-block"; callout.dataset.tone = block.tone || "info"; callout.innerHTML = ` ${block.title ? `
${escapeHtml(block.title)}
` : ""}
${block.html || ""}
`; node.appendChild(callout); } previewContent.appendChild(node); }); renderPreviewSelection(); bindPreviewInternalLinks(); } function bindPreviewInternalLinks() { previewContent.querySelectorAll("a[data-policy-id]").forEach((link) => { link.addEventListener("click", function (event) { event.preventDefault(); showToast("Internal link", `Targets policy "${link.dataset.policyId}" on the public page.`, "info"); }); }); } function renderBlockSelection() { blockList.querySelectorAll(".block-card").forEach((card) => { const isSelected = card.dataset.blockId === ui.selectedBlockId; const isPreviewHovered = card.dataset.blockId === ui.hoveredPreviewBlockId; card.classList.toggle("is-selected", isSelected || isPreviewHovered); }); } function renderPreviewSelection() { previewContent.querySelectorAll(".preview-block").forEach((node) => { const blockId = node.dataset.previewBlockId; node.classList.toggle( "is-highlighted", blockId === ui.selectedBlockId || blockId === ui.hoveredPreviewBlockId, ); node.classList.toggle("is-targeted", blockId === ui.targetedPreviewBlockId); }); } function focusPreviewBlock(blockId) { setBlockSelection(blockId); const node = previewContent.querySelector(`[data-preview-block-id="${blockId}"]`); if (!node) { return; } if (ui.previewTargetTimer) { window.clearTimeout(ui.previewTargetTimer); } ui.targetedPreviewBlockId = blockId; renderPreviewSelection(); node.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest", }); ui.previewTargetTimer = window.setTimeout(function () { ui.targetedPreviewBlockId = null; renderPreviewSelection(); ui.previewTargetTimer = null; }, 1600); } function escapeHtml(value) { return String(value || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function escapeAttribute(value) { return escapeHtml(value).replace(/`/g, "`"); } function stripHtml(value) { return String(value || "") .replace(//gi, " ") .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") .replace(/\s+/g, " ") .trim(); } function showToast(title, message, type) { if (window.bootstrap && typeof bootstrap.Toast === "function") { const container = document.querySelector(".toast-container") || createToastContainer(); const toast = document.createElement("div"); toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`; toast.setAttribute("role", "alert"); toast.innerHTML = `
${escapeHtml(title)}: ${escapeHtml(message)}
`; container.appendChild(toast); new bootstrap.Toast(toast, { autohide: true, delay: 2500 }).show(); toast.addEventListener("hidden.bs.toast", function () { toast.remove(); }); return; } window.alert(`${title}: ${message}`); } function createToastContainer() { const container = document.createElement("div"); container.className = "toast-container position-fixed top-0 end-0 p-3"; document.body.appendChild(container); return container; } function readJsonScript(id) { const node = document.getElementById(id); if (!node) { return null; } try { return JSON.parse(node.textContent || "null"); } catch (error) { console.error(`Failed to parse JSON script "${id}"`, error); return null; } } })();