diff --git a/README.md b/README.md index b612266..487b0f8 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,18 @@ alongside the solver. When you scaffold a new SolverForge project with | `SF.showError(title, detail)` | `void` | Danger toast shorthand | | `SF.showTab(tabId, root?)` | `void` | Activate matching tab panels in every tab container, or only within `root` when provided | +### Unsafe HTML APIs (opt-in) + +Default content is always text-rendered. Use these fields only with trusted HTML: + +| Factory | Unsafe HTML field | +|---------|-------------------| +| `SF.el(tag, attrs, ...)` | `unsafeHtml` | +| `SF.createModal(config)` | `unsafeBody` | +| `SF.createTabs(config)` | `tabs[].content.unsafeHtml` | +| `SF.createTable(config)` | `cells[].unsafeHtml` | +| `SF.gantt.create(config)` | `unsafePopupHtml`, `columns[].render(task).unsafeHtml` | + ### Timeline Rail | Factory | Returns | Description | @@ -228,7 +240,9 @@ var gantt = SF.gantt.create({ { key: 'start', label: 'Start' }, { key: 'end', label: 'End' }, { key: 'priority', label: 'P', render: function (t) { - return 'P' + t.priority + ''; + return { + unsafeHtml: 'P' + t.priority + '', + }; }}, ], onTaskClick: function (task) { console.log('clicked', task.id); }, diff --git a/js-src/00-core.js b/js-src/00-core.js index 043d3d0..e774602 100644 --- a/js-src/00-core.js +++ b/js-src/00-core.js @@ -30,7 +30,8 @@ const SF = (function () { } else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]); else if (key === 'dataset') Object.assign(el.dataset, attrs[key]); - else if (key === 'html') el.innerHTML = attrs[key]; + else if (key === 'html') el.textContent = attrs[key]; + else if (key === 'unsafeHtml') el.innerHTML = attrs[key]; else el.setAttribute(key, attrs[key]); }); } diff --git a/js-src/06-modal.js b/js-src/06-modal.js index 9082102..9698d0c 100644 --- a/js-src/06-modal.js +++ b/js-src/06-modal.js @@ -8,6 +8,7 @@ sf.createModal = function (config) { var overlay = sf.el('div', { className: 'sf-modal-overlay' }); var dialog = sf.el('div', { className: 'sf-modal' }); + var body = sf.el('div', { className: 'sf-modal-body' }); // Header var header = sf.el('div', { className: 'sf-modal-header' }); @@ -15,21 +16,14 @@ var closeBtn = sf.el('button', { className: 'sf-modal-close', - html: '×', onClick: function () { api.close(); }, - }); + }, '×'); header.appendChild(closeBtn); + dialog.appendChild(header); // Body - var body = sf.el('div', { className: 'sf-modal-body' }); - if (config.body) { - if (typeof config.body === 'string') { - body.innerHTML = config.body; - } else if (config.body instanceof Node) { - body.appendChild(config.body); - } - } + setBodyContent(body, config.body, config.unsafeBody); dialog.appendChild(body); // Footer @@ -69,12 +63,7 @@ }; api.setBody = function (content) { - body.innerHTML = ''; - if (typeof content === 'string') { - body.innerHTML = content; - } else if (content instanceof Node) { - body.appendChild(content); - } + setBodyContent(body, content); }; if (config.width) { @@ -84,4 +73,19 @@ return api; }; + function setBodyContent(target, content, explicitUnsafeHtml) { + target.textContent = ''; + if (explicitUnsafeHtml != null) { + target.innerHTML = explicitUnsafeHtml; + } else if (typeof content === 'string') { + target.textContent = content; + } else if (content && content.unsafeBody) { + target.innerHTML = content.unsafeBody; + } else if (content && content.unsafeHtml) { + target.innerHTML = content.unsafeHtml; + } else if (content instanceof Node) { + target.appendChild(content); + } + } + })(SF); diff --git a/js-src/07-tabs.js b/js-src/07-tabs.js index 73c7318..1048dae 100644 --- a/js-src/07-tabs.js +++ b/js-src/07-tabs.js @@ -27,7 +27,8 @@ dataset: { tabId: tab.id }, }); if (tab.content) { - if (typeof tab.content === 'string') panel.innerHTML = tab.content; + if (typeof tab.content === 'string') panel.textContent = tab.content; + else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml; else if (tab.content instanceof Node) panel.appendChild(tab.content); } container.appendChild(panel); diff --git a/js-src/08-table.js b/js-src/08-table.js index 84727db..430ab89 100644 --- a/js-src/08-table.js +++ b/js-src/08-table.js @@ -34,8 +34,8 @@ td.textContent = cell; } else if (cell instanceof Node) { td.appendChild(cell); - } else if (cell && cell.html) { - td.innerHTML = cell.html; + } else if (cell && cell.unsafeHtml) { + td.innerHTML = cell.unsafeHtml; } var col = config.columns && config.columns[colIdx]; if (col && col.align) td.style.textAlign = col.align; diff --git a/js-src/09-toast.js b/js-src/09-toast.js index 9e0dc5d..d7e85ac 100644 --- a/js-src/09-toast.js +++ b/js-src/09-toast.js @@ -36,9 +36,8 @@ var closeBtn = sf.el('button', { className: 'sf-toast-close', - html: '×', onClick: function () { dismiss(); }, - }); + }, '×'); toast.appendChild(closeBtn); container.appendChild(toast); diff --git a/js-src/14-gantt.js b/js-src/14-gantt.js index d28fa83..e5108b5 100644 --- a/js-src/14-gantt.js +++ b/js-src/14-gantt.js @@ -165,17 +165,27 @@ var frappeTasks = tasksToFrappe(taskList); if (frappeTasks.length === 0) { - chartContainer.innerHTML = '
Pinned
' : '') + ''; } + + function createSvgRoot(id) { + if (document.createElementNS) { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.id = id; + return svg; + } + return sf.el('svg', { id: id }); + } }; })(SF); diff --git a/static/sf/sf.js b/static/sf/sf.js index bd47530..d4c3ccb 100644 --- a/static/sf/sf.js +++ b/static/sf/sf.js @@ -30,7 +30,8 @@ const SF = (function () { } else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]); else if (key === 'dataset') Object.assign(el.dataset, attrs[key]); - else if (key === 'html') el.innerHTML = attrs[key]; + else if (key === 'html') el.textContent = attrs[key]; + else if (key === 'unsafeHtml') el.innerHTML = attrs[key]; else el.setAttribute(key, attrs[key]); }); } @@ -484,6 +485,7 @@ const SF = (function () { sf.createModal = function (config) { var overlay = sf.el('div', { className: 'sf-modal-overlay' }); var dialog = sf.el('div', { className: 'sf-modal' }); + var body = sf.el('div', { className: 'sf-modal-body' }); // Header var header = sf.el('div', { className: 'sf-modal-header' }); @@ -491,21 +493,14 @@ const SF = (function () { var closeBtn = sf.el('button', { className: 'sf-modal-close', - html: '×', onClick: function () { api.close(); }, - }); + }, '×'); header.appendChild(closeBtn); + dialog.appendChild(header); // Body - var body = sf.el('div', { className: 'sf-modal-body' }); - if (config.body) { - if (typeof config.body === 'string') { - body.innerHTML = config.body; - } else if (config.body instanceof Node) { - body.appendChild(config.body); - } - } + setBodyContent(body, config.body, config.unsafeBody); dialog.appendChild(body); // Footer @@ -545,12 +540,7 @@ const SF = (function () { }; api.setBody = function (content) { - body.innerHTML = ''; - if (typeof content === 'string') { - body.innerHTML = content; - } else if (content instanceof Node) { - body.appendChild(content); - } + setBodyContent(body, content); }; if (config.width) { @@ -560,6 +550,21 @@ const SF = (function () { return api; }; + function setBodyContent(target, content, explicitUnsafeHtml) { + target.textContent = ''; + if (explicitUnsafeHtml != null) { + target.innerHTML = explicitUnsafeHtml; + } else if (typeof content === 'string') { + target.textContent = content; + } else if (content && content.unsafeBody) { + target.innerHTML = content.unsafeBody; + } else if (content && content.unsafeHtml) { + target.innerHTML = content.unsafeHtml; + } else if (content instanceof Node) { + target.appendChild(content); + } + } + })(SF); /* ============================================================================ SolverForge UI — Tab Switching @@ -590,7 +595,8 @@ const SF = (function () { dataset: { tabId: tab.id }, }); if (tab.content) { - if (typeof tab.content === 'string') panel.innerHTML = tab.content; + if (typeof tab.content === 'string') panel.textContent = tab.content; + else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml; else if (tab.content instanceof Node) panel.appendChild(tab.content); } container.appendChild(panel); @@ -650,8 +656,8 @@ const SF = (function () { td.textContent = cell; } else if (cell instanceof Node) { td.appendChild(cell); - } else if (cell && cell.html) { - td.innerHTML = cell.html; + } else if (cell && cell.unsafeHtml) { + td.innerHTML = cell.unsafeHtml; } var col = config.columns && config.columns[colIdx]; if (col && col.align) td.style.textAlign = col.align; @@ -710,9 +716,8 @@ const SF = (function () { var closeBtn = sf.el('button', { className: 'sf-toast-close', - html: '×', onClick: function () { dismiss(); }, - }); + }, '×'); toast.appendChild(closeBtn); container.appendChild(toast); @@ -1343,17 +1348,27 @@ const SF = (function () { var frappeTasks = tasksToFrappe(taskList); if (frappeTasks.length === 0) { - chartContainer.innerHTML = 'Pinned
' : '') + ''; } + + function createSvgRoot(id) { + if (document.createElementNS) { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.id = id; + return svg; + } + return sf.el('svg', { id: id }); + } }; })(SF); diff --git a/tests/safe-html-rendering.test.js b/tests/safe-html-rendering.test.js new file mode 100644 index 0000000..0425ec6 --- /dev/null +++ b/tests/safe-html-rendering.test.js @@ -0,0 +1,73 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const { createDom } = require('./support/fake-dom'); + +const ROOT = path.resolve(__dirname, '..'); + +function loadSf(files, overrides = {}) { + const { document, window, Node } = createDom(); + const context = vm.createContext({ + console, + document, + window, + Node, + setTimeout, + clearTimeout, + ...overrides, + }); + + files.forEach((file) => { + const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + vm.runInContext(source, context, { filename: file }); + }); + + return { SF: context.window.SF, document }; +} + +test('createModal renders unsafeBody as raw HTML and preserves text mode by default', () => { + const { SF } = loadSf(['js-src/00-core.js', 'js-src/06-modal.js']); + + const safeModal = SF.createModal({ title: 'Safe', body: 'safe' }); + assert.equal(safeModal.body.textContent, 'safe'); + assert.equal(safeModal.body.innerHTML, ''); + + const unsafeModal = SF.createModal({ title: 'Unsafe', unsafeBody: 'unsafe' }); + assert.equal(unsafeModal.body.innerHTML, 'unsafe'); + + unsafeModal.setBody({ unsafeBody: 'updated' }); + assert.equal(unsafeModal.body.innerHTML, 'updated'); +}); + +test('gantt creates the chart root as a namespaced SVG element', () => { + let seenNamespace = null; + let seenTag = null; + + const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { + Gantt: function () { + return { + change_view_mode() {}, + refresh() {}, + }; + }, + }); + + const originalCreateElementNS = document.createElementNS.bind(document); + document.createElementNS = function (namespaceURI, tagName) { + seenNamespace = namespaceURI; + seenTag = tagName; + return originalCreateElementNS(namespaceURI, tagName); + }; + + const gantt = SF.gantt.create({}); + gantt.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]); + const chartRoot = gantt.el.querySelector('svg'); + + assert.equal(seenNamespace, 'http://www.w3.org/2000/svg'); + assert.equal(seenTag, 'svg'); + assert.equal(chartRoot.namespaceURI, 'http://www.w3.org/2000/svg'); + assert.equal(chartRoot.tagName, 'SVG'); +}); diff --git a/tests/support/fake-dom.js b/tests/support/fake-dom.js index 5c3a609..5ade7ea 100644 --- a/tests/support/fake-dom.js +++ b/tests/support/fake-dom.js @@ -64,9 +64,10 @@ class FakeClassList { } class FakeElement extends FakeNode { - constructor(tagName) { + constructor(tagName, namespaceURI) { super(); this.tagName = String(tagName).toUpperCase(); + this.namespaceURI = namespaceURI || null; this.childNodes = []; this.parentNode = null; this.ownerDocument = null; @@ -186,6 +187,12 @@ class FakeDocument { return element; } + createElementNS(namespaceURI, tagName) { + var element = new FakeElement(tagName, namespaceURI); + element.ownerDocument = this; + return element; + } + createTextNode(text) { return new FakeTextNode(text); }