diff --git a/packages/markdown/src/plugins/checkbox.ts b/packages/markdown/src/plugins/checkbox.ts index d9a16b96..e1e7dddd 100644 --- a/packages/markdown/src/plugins/checkbox.ts +++ b/packages/markdown/src/plugins/checkbox.ts @@ -5,6 +5,7 @@ import { Batch, definePlugin, IInstance, Plugin } from '../factory.js'; import { sanitizedHTML } from '../sanitize.js'; +import { getJsonScriptTag } from './util.js'; interface CheckboxInstance { id: string; @@ -22,17 +23,17 @@ export const checkboxPlugin: Plugin = { name: 'checkbox', initializePlugin: (md) => definePlugin(md, 'checkbox'), fence: (token, idx) => { - const CheckboxId = `Checkbox-${idx}`; - return sanitizedHTML('div', { id: CheckboxId, class: 'checkbox' }, token.content.trim()); + return sanitizedHTML('div', { class: 'checkbox' }, token.content.trim(), true); }, hydrateComponent: async (renderer, errorHandler) => { const checkboxInstances: CheckboxInstance[] = []; const containers = renderer.element.querySelectorAll('.checkbox'); for (const [index, container] of Array.from(containers).entries()) { - if (!container.textContent) continue; + const scriptTag = getJsonScriptTag(container); + if (!scriptTag) continue; try { - const spec: CheckboxSpec = JSON.parse(container.textContent); + const spec: CheckboxSpec = JSON.parse(scriptTag.textContent); const html = `
@@ -45,7 +46,7 @@ export const checkboxPlugin: Plugin = { container.innerHTML = html; const element = container.querySelector('input[type="checkbox"]') as HTMLInputElement; - const checkboxInstance: CheckboxInstance = { id: container.id, spec, element }; + const checkboxInstance: CheckboxInstance = { id: `checkbox-${index}`, spec, element }; checkboxInstances.push(checkboxInstance); } catch (e) { container.innerHTML = `
${e.toString()}
`; diff --git a/packages/markdown/src/plugins/css.ts b/packages/markdown/src/plugins/css.ts index 660a8f2b..37cbc457 100644 --- a/packages/markdown/src/plugins/css.ts +++ b/packages/markdown/src/plugins/css.ts @@ -6,6 +6,7 @@ import { definePlugin, IInstance, Plugin } from '../factory.js'; import { sanitizedHTML } from '../sanitize.js'; import * as Csstree from 'css-tree'; +import { getJsonScriptTag } from './util.js'; // CSS Tree is expected to be available as a global variable declare const csstree: typeof Csstree; @@ -345,13 +346,12 @@ export const cssPlugin: Plugin = { const info = token.info.trim(); if (info === 'css') { - const cssId = `css-${idx}`; const cssContent = token.content.trim(); // Parse and categorize CSS content const categorizedCss = categorizeCss(cssContent); - return sanitizedHTML('div', { id: cssId, class: 'css-component' }, JSON.stringify(categorizedCss)); + return sanitizedHTML('div', { class: 'css-component' }, JSON.stringify(categorizedCss), true); } // Fallback to original fence renderer @@ -367,10 +367,11 @@ export const cssPlugin: Plugin = { const containers = renderer.element.querySelectorAll('.css-component'); for (const [index, container] of Array.from(containers).entries()) { - if (!container.textContent) continue; + const scriptTag = getJsonScriptTag(container); + if (!scriptTag) continue; try { - const categorizedCss: CategorizedCss = JSON.parse(container.textContent); + const categorizedCss: CategorizedCss = JSON.parse(scriptTag.textContent); const comments: string[] = []; // Log security issues found @@ -385,7 +386,7 @@ export const cssPlugin: Plugin = { if (safeCss.trim().length > 0) { const styleElement = document.createElement('style'); styleElement.type = 'text/css'; - styleElement.id = `idocs-css-${container.id}`; + styleElement.id = `idocs-css-${index}`; styleElement.textContent = safeCss; // Apply to shadow DOM if available, otherwise document @@ -395,7 +396,7 @@ export const cssPlugin: Plugin = { comments.push(``); cssInstances.push({ - id: container.id, + id: `css-${index}`, element: styleElement }); } else { diff --git a/packages/markdown/src/plugins/dropdown.ts b/packages/markdown/src/plugins/dropdown.ts index a268347d..51cf6e56 100644 --- a/packages/markdown/src/plugins/dropdown.ts +++ b/packages/markdown/src/plugins/dropdown.ts @@ -5,6 +5,7 @@ import { Batch, definePlugin, IInstance, Plugin } from '../factory.js'; import { sanitizedHTML } from '../sanitize.js'; +import { getJsonScriptTag } from './util.js'; interface DropdownInstance { id: string; @@ -32,17 +33,17 @@ export const dropdownPlugin: Plugin = { name: 'dropdown', initializePlugin: (md) => definePlugin(md, 'dropdown'), fence: (token, idx) => { - const DropdownId = `Dropdown-${idx}`; - return sanitizedHTML('div', { id: DropdownId, class: 'dropdown' }, token.content.trim()); + return sanitizedHTML('div', { class: 'dropdown' }, token.content.trim(), true); }, hydrateComponent: async (renderer, errorHandler) => { const dropdownInstances: DropdownInstance[] = []; const containers = renderer.element.querySelectorAll('.dropdown'); for (const [index, container] of Array.from(containers).entries()) { - if (!container.textContent) continue; + const scriptTag = getJsonScriptTag(container); + if (!scriptTag) continue; try { - const spec: DropdownSpec = JSON.parse(container.textContent); + const spec: DropdownSpec = JSON.parse(scriptTag.textContent); const html = `
@@ -59,7 +60,7 @@ export const dropdownPlugin: Plugin = { // Safely set the initial options setSelectOptions(element, spec.multiple ?? false, spec.options ?? [], spec.value ?? (spec.multiple ? [] : "")); - const dropdownInstance: DropdownInstance = { id: container.id, spec, element }; + const dropdownInstance: DropdownInstance = { id: `dropdown-${index}`, spec, element }; dropdownInstances.push(dropdownInstance); } catch (e) { container.innerHTML = `
${e.toString()}
`; diff --git a/packages/markdown/src/plugins/image.ts b/packages/markdown/src/plugins/image.ts index 50ed26ec..2dd677e0 100644 --- a/packages/markdown/src/plugins/image.ts +++ b/packages/markdown/src/plugins/image.ts @@ -5,6 +5,7 @@ import { definePlugin, IInstance, Plugin } from '../factory.js'; import { sanitizedHTML } from '../sanitize.js'; +import { getJsonScriptTag } from './util.js'; export interface ImageSpec { srcSignalName: string; @@ -30,16 +31,16 @@ export const imagePlugin: Plugin = { name: 'image', initializePlugin: (md) => definePlugin(md, 'image'), fence: (token, idx) => { - const ImageId = `Image-${idx}`; - return sanitizedHTML('div', { id: ImageId, class: 'image' }, token.content.trim()); + return sanitizedHTML('div', { class: 'image' }, token.content.trim(), true); }, hydrateComponent: async (renderer, errorHandler) => { const imageInstances: ImageInstance[] = []; const containers = renderer.element.querySelectorAll('.image'); for (const [index, container] of Array.from(containers).entries()) { - if (!container.textContent) continue; + const scriptTag = getJsonScriptTag(container); + if (!scriptTag) continue; try { - const spec: ImageSpec = JSON.parse(container.textContent); + const spec: ImageSpec = JSON.parse(scriptTag.textContent); const element = document.createElement('img'); const spinner = document.createElement('div'); spinner.innerHTML = ` @@ -68,7 +69,7 @@ export const imagePlugin: Plugin = { container.appendChild(spinner); container.appendChild(element); - const imageInstance: ImageInstance = { id: container.id, spec, element, spinner }; + const imageInstance: ImageInstance = { id: `image-${index}`, spec, element, spinner }; imageInstances.push(imageInstance); } catch (e) { container.innerHTML = `
${e.toString()}
`; diff --git a/packages/markdown/src/plugins/presets.ts b/packages/markdown/src/plugins/presets.ts index dbbf6668..25fa1522 100644 --- a/packages/markdown/src/plugins/presets.ts +++ b/packages/markdown/src/plugins/presets.ts @@ -5,6 +5,7 @@ import { Batch, definePlugin, IInstance, Plugin, PrioritizedSignal } from '../factory.js'; import { sanitizedHTML } from '../sanitize.js'; +import { getJsonScriptTag } from './util.js'; interface Preset { name: string; @@ -25,19 +26,19 @@ export const presetsPlugin: Plugin = { initializePlugin: (md) => definePlugin(md, 'presets'), fence: (token, idx) => { const spec = JSON.parse(token.content.trim()); - const pluginId = `preset-${idx}`; - return sanitizedHTML('div', { id: pluginId, class: 'presets' }, JSON.stringify(spec)); + return sanitizedHTML('div', { class: 'presets' }, JSON.stringify(spec), true); }, hydrateComponent: async (renderer, errorHandler) => { const presetsInstances: PresetsInstance[] = []; const containers = renderer.element.querySelectorAll('.presets'); for (const [index, container] of Array.from(containers).entries()) { - if (!container.textContent) continue; + const scriptTag = getJsonScriptTag(container); + if (!scriptTag) continue; - const id = `presets${index}`; + const id = `presets-${index}`; let presets: Preset[]; try { - presets = JSON.parse(container.textContent) as Preset[]; + presets = JSON.parse(scriptTag.textContent) as Preset[]; } catch (e) { container.innerHTML = `
${e.toString()}
`; errorHandler(e, 'presets', index, 'parse', container); diff --git a/packages/markdown/src/plugins/tabulator.ts b/packages/markdown/src/plugins/tabulator.ts index 69972b06..9f633991 100644 --- a/packages/markdown/src/plugins/tabulator.ts +++ b/packages/markdown/src/plugins/tabulator.ts @@ -7,6 +7,7 @@ import { defaultCommonOptions } from 'common'; import { Batch, definePlugin, IInstance, Plugin } from '../factory.js'; import { sanitizedHTML } from '../sanitize.js'; import { Tabulator as TabulatorType, Options as TabulatorOptions } from 'tabulator-tables'; +import { getJsonScriptTag } from './util.js'; interface TabulatorInstance { id: string; @@ -26,21 +27,21 @@ export const tabulatorPlugin: Plugin = { name: 'tabulator', initializePlugin: (md) => definePlugin(md, 'tabulator'), fence: (token, idx) => { - const tabulatorId = `tabulator-${idx}`; - return sanitizedHTML('div', { id: tabulatorId, class: 'tabulator', style: 'box-sizing: border-box;' }, token.content.trim()); + return sanitizedHTML('div', { class: 'tabulator', style: 'box-sizing: border-box;' }, token.content.trim(), true); }, hydrateComponent: async (renderer, errorHandler) => { const tabulatorInstances: TabulatorInstance[] = []; const containers = renderer.element.querySelectorAll('.tabulator'); for (const [index, container] of Array.from(containers).entries()) { - if (!container.textContent) continue; + const scriptTag = getJsonScriptTag(container); + if (!scriptTag) continue; if (!Tabulator) { errorHandler(new Error('Tabulator not found'), 'tabulator', index, 'init', container); continue; } try { - const spec: TabulatorSpec = JSON.parse(container.textContent); + const spec: TabulatorSpec = JSON.parse(scriptTag.textContent); let options: TabulatorOptions = { autoColumns: true, @@ -54,7 +55,7 @@ export const tabulatorPlugin: Plugin = { } const table = new Tabulator(container as HTMLElement, options); - const tabulatorInstance: TabulatorInstance = { id: container.id, spec, table, built: false }; + const tabulatorInstance: TabulatorInstance = { id: `tabulator-${index}`, spec, table, built: false }; table.on('tableBuilt', () => { table.off('tableBuilt'); tabulatorInstance.built = true; diff --git a/packages/markdown/src/plugins/util.ts b/packages/markdown/src/plugins/util.ts index a67ac240..31e2c537 100644 --- a/packages/markdown/src/plugins/util.ts +++ b/packages/markdown/src/plugins/util.ts @@ -6,3 +6,14 @@ export function urlParam(urlParamName: string, value: any) { return `${urlParamName}=${encodeURIComponent(value)}`; } } + +export function getJsonScriptTag(container: Element): HTMLScriptElement | null { + const scriptTag = container.previousElementSibling; + if (scriptTag?.tagName !== 'SCRIPT' || scriptTag.getAttribute('type') !== 'application/json') { + return null; + } + if (!scriptTag.textContent) { + return null; + } + return scriptTag as HTMLScriptElement; +} diff --git a/packages/markdown/src/sanitize.ts b/packages/markdown/src/sanitize.ts index 6499a926..42097c7a 100644 --- a/packages/markdown/src/sanitize.ts +++ b/packages/markdown/src/sanitize.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -export function sanitizedHTML(tagName: string, attributes: { [key: string]: string }, content: string) { +export function sanitizedHTML(tagName: string, attributes: { [key: string]: string }, content: string, precedeWithScriptTag?: boolean) { // Create a temp element with the specified tag name const element = document.createElement(tagName); @@ -13,8 +13,20 @@ export function sanitizedHTML(tagName: string, attributes: { [key: string]: stri element.setAttribute(key, attributes[key]); }); - // Set the textContent to automatically escape the content - element.textContent = content; + if (precedeWithScriptTag) { + // Create a script tag that precedes the main element + const scriptElement = document.createElement('script'); + scriptElement.setAttribute('type', 'application/json'); + // Only escape the dangerous sequence that could break out of script tag + const safeContent = content.replace(/<\/script>/gi, '<\\/script>'); + scriptElement.innerHTML = safeContent; + + // Return script tag followed by empty element + return scriptElement.outerHTML + element.outerHTML; + } else { + // Set the textContent to automatically escape the content + element.textContent = content; + } // Return the outer HTML of the element return element.outerHTML;