diff --git a/packages/kg-default-nodes/src/kg-default-nodes.ts b/packages/kg-default-nodes/src/kg-default-nodes.ts index 56e393566c..5560cf9333 100644 --- a/packages/kg-default-nodes/src/kg-default-nodes.ts +++ b/packages/kg-default-nodes/src/kg-default-nodes.ts @@ -23,6 +23,7 @@ import * as gallery from './nodes/gallery/GalleryNode.js'; import * as emailCta from './nodes/email-cta/EmailCtaNode.js'; import * as signup from './nodes/signup/SignupNode.js'; import * as transistor from './nodes/transistor/TransistorNode.js'; +import * as pluginCard from './nodes/plugin-card/PluginCardNode.js'; import * as textnode from './nodes/ExtendedTextNode.js'; import * as headingnode from './nodes/ExtendedHeadingNode.js'; import * as quotenode from './nodes/ExtendedQuoteNode.js'; @@ -57,6 +58,11 @@ export * from './nodes/gallery/GalleryNode.js'; export * from './nodes/email-cta/EmailCtaNode.js'; export * from './nodes/signup/SignupNode.js'; export * from './nodes/transistor/TransistorNode.js'; +export * from './nodes/plugin-card/PluginCardNode.js'; +export {renderTemplate} from './nodes/plugin-card/plugin-card-template.js'; +export type {TemplateData} from './nodes/plugin-card/plugin-card-template.js'; +export {scopeCss} from './nodes/plugin-card/plugin-card-css.js'; +export {createPreprocessor} from './nodes/plugin-card/plugin-card-preprocess.js'; export * from './nodes/call-to-action/CallToActionNode.js'; export * from './nodes/ExtendedTextNode.js'; export * from './nodes/ExtendedHeadingNode.js'; @@ -122,6 +128,7 @@ export const DEFAULT_NODES = [ emailCta.EmailCtaNode, signup.SignupNode, transistor.TransistorNode, + pluginCard.PluginCardNode, tk.TKNode, atLink.AtLinkNode, atLink.AtLinkSearchNode, diff --git a/packages/kg-default-nodes/src/nodes/plugin-card/PluginCardNode.ts b/packages/kg-default-nodes/src/nodes/plugin-card/PluginCardNode.ts new file mode 100644 index 0000000000..fb7ef534da --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/plugin-card/PluginCardNode.ts @@ -0,0 +1,70 @@ +import {generateDecoratorNode, type DecoratorNodeData, type DecoratorNodeProperty, type DecoratorNodeValueMap} from '../../generate-decorator-node.js'; +import {renderPluginCardNode} from './plugin-card-renderer.js'; +import {parsePluginCardNode} from './plugin-card-parser.js'; + +const pluginCardProperties = [ + {name: 'html', default: '', urlType: 'html', wordCount: true}, + {name: 'pluginName', default: ''}, + {name: 'cardName', default: ''}, + {name: 'payload', default: '{}'}, + {name: 'css', default: ''}, + {name: 'template', default: ''}, + {name: 'preprocess', default: ''} +] as const satisfies readonly DecoratorNodeProperty[]; + +export type PluginCardData = DecoratorNodeData; + +export interface PluginCardNode extends DecoratorNodeValueMap {} + +export class PluginCardNode extends generateDecoratorNode({ + nodeType: 'plugin-card', + properties: pluginCardProperties, + defaultRenderFn: renderPluginCardNode +}) { + static importDOM() { + return parsePluginCardNode(this); + } + + static importJSON(serializedNode: Record) { + // Parse payload: handles objects, JSON strings, and double-encoded strings + let payload = serializedNode.payload; + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + // Not valid JSON, keep as-is + } + // If still a string, it might be double-encoded — try one more parse + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + // Keep the string value + } + } + } + serializedNode = {...serializedNode, payload}; + + const data: Record = {}; + const propNames = ['html', 'pluginName', 'cardName', 'payload', 'css', 'template', 'preprocess']; + const defaults: Record = {html: '', pluginName: '', cardName: '', payload: '{}', css: '', template: '', preprocess: ''}; + propNames.forEach((name) => { + data[name] = serializedNode[name] ?? defaults[name]; + }); + // Use `new this(data)` so the editor subclass (which has decorate()) + // is instantiated, not the bare base class from kg-default-nodes + return new (this as any)(data); + } + + isEmpty() { + return false; + } +} + +export function $createPluginCardNode(dataset: PluginCardData = {}) { + return new PluginCardNode(dataset); +} + +export function $isPluginCardNode(node: unknown): node is PluginCardNode { + return node instanceof PluginCardNode; +} diff --git a/packages/kg-default-nodes/src/nodes/plugin-card/index.ts b/packages/kg-default-nodes/src/nodes/plugin-card/index.ts new file mode 100644 index 0000000000..51c6a4c05c --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/plugin-card/index.ts @@ -0,0 +1,5 @@ +export {PluginCardNode, $createPluginCardNode, $isPluginCardNode} from './PluginCardNode.js'; +export type {PluginCardData} from './PluginCardNode.js'; +export {renderTemplate} from './plugin-card-template.js'; +export type {TemplateData} from './plugin-card-template.js'; +export {createPreprocessor} from './plugin-card-preprocess.js'; diff --git a/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-css.ts b/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-css.ts new file mode 100644 index 0000000000..355fd4996d --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-css.ts @@ -0,0 +1,148 @@ +/** + * Prefixes all CSS selectors with a namespace scope to prevent collisions + * between plugins that use the same class names. + * + * Example: scopeCss('.card { color: red; }', 'plugin-review') + * → '.plugin-review .card { color: red; }' + * + * Handles: + * - Simple selectors: .class, #id, tag + * - Multiple selectors: .a, .b { ... } + * - Nested selectors: no changes needed (they inherit the prefix) + * - @-rules: @media, @keyframes pass through unchanged + */ + +export function scopeCss(css: string, namespace: string): string { + if (!css || !namespace) return css; + + let result = ''; + + // We process the CSS rule-by-rule using a simple state machine. + // A "rule" is: selectors { declarations } + // An @-rule is: @something ... { possibly-nested-rules } + + let i = 0; + const len = css.length; + + while (i < len) { + // Skip whitespace and comments + const ch = css[i]; + if (ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r') { + result += ch; + i++; + continue; + } + + // Skip comments + if (ch === '/' && css[i + 1] === '*') { + const end = css.indexOf('*/', i + 2); + if (end === -1) { + result += css.slice(i); + break; + } + result += css.slice(i, end + 2); + i = end + 2; + continue; + } + + // @-rule: preserve as-is (don't scope the @-rule itself) + if (ch === '@') { + const ruleEnd = findRuleEnd(css, i); + const atRule = css.slice(i, ruleEnd); + // For @media, scope the inner rules + if (/^@media\b/.test(atRule)) { + result += scopeInnerRules(atRule, namespace); + } else { + result += atRule; + } + i = ruleEnd; + continue; + } + + // Regular rule: scope the selectors + const selectorsEnd = css.indexOf('{', i); + if (selectorsEnd === -1) { + result += css.slice(i); + break; + } + + const selectors = css.slice(i, selectorsEnd); + + // Don't scope if selector already starts with the namespace + if (selectors.includes(`.${namespace} `) || selectors.trim() === `.${namespace}`) { + const ruleEnd = findRuleEnd(css, i); + result += css.slice(i, ruleEnd); + i = ruleEnd; + continue; + } + + // Find the matching closing brace + const ruleEnd = findMatchingBrace(css, selectorsEnd); + + // Prefix each selector in the comma-separated list + const scopedSelectors = selectors.split(',').map(s => { + const trimmed = s.trim(); + if (!trimmed) return s; + // Skip pseudo-element/class selectors that start with : + if (trimmed.startsWith(':') && !trimmed.startsWith('::')) { + return s.replace(trimmed, `.${namespace}${trimmed}`); + } + return `.${namespace} ${trimmed}`; + }).join(',\n'); + + const innerContent = css.slice(selectorsEnd + 1, ruleEnd - 1); + + result += scopedSelectors + ' {\n'; + // Handle nested @-rules inside + result += scopeInnerRules(innerContent, namespace); + result += '}\n'; + + i = ruleEnd; + } + + return result; +} + +/** + * Find the end of a CSS rule starting at `start` (at the opening `{` or similar). + * Handles nested braces (not common in CSS but handled for safety). + */ +function findMatchingBrace(css: string, openBracePos: number): number { + let depth = 0; + for (let i = openBracePos; i < css.length; i++) { + if (css[i] === '{') depth++; + else if (css[i] === '}') { + depth--; + if (depth === 0) return i + 1; + } + } + return css.length; +} + +/** + * Find the end of a CSS rule or @-rule starting at `start`. + */ +function findRuleEnd(css: string, start: number): number { + // Check if it's an @-rule + if (css[start] === '@') { + const bracePos = css.indexOf('{', start); + if (bracePos === -1) return css.length; + return findMatchingBrace(css, bracePos); + } + const bracePos = css.indexOf('{', start); + if (bracePos === -1) return css.length; + return findMatchingBrace(css, bracePos); +} + +/** + * Process inner CSS content (inside @media blocks or regular rules), + * scoping any regular rules found within. + */ +function scopeInnerRules(css: string, _namespace: string): string { + // @media block selectors are NOT scoped. Recursive scoping of inner + // rules would cause infinite loops (scopeCss → scopeInnerRules → + // scopeCss → ...). This means plugin CSS inside @media queries is + // not namespace-isolated — plugin authors should use explicit + // selector specificity within @media blocks instead. + return css; +} diff --git a/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-parser.ts b/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-parser.ts new file mode 100644 index 0000000000..5efc1a3203 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-parser.ts @@ -0,0 +1,77 @@ +import type {PluginCardNode} from './PluginCardNode.js'; + +export function parsePluginCardNode(NodeType: typeof PluginCardNode) { + return { + '#comment': (nodeElem: Comment) => { + // Match + if (nodeElem.nodeType === 8 && nodeElem.nodeValue?.trim().match(/^kg-card-begin:\s?plugin-card$/)) { + return { + conversion(domNode: Comment) { + const html: string[] = []; + let nextNode = domNode.nextSibling; + let hasEndComment = false; + + // First pass: check for end comment + let checkNode = domNode.nextSibling; + while (checkNode) { + if (checkNode.nodeType === 8 && checkNode.nodeValue?.trim().match(/^kg-card-end:\s?plugin-card$/)) { + hasEndComment = true; + break; + } + checkNode = checkNode.nextSibling; + } + + nextNode = domNode.nextSibling; + + if (hasEndComment) { + // Collect HTML between markers + while (nextNode && !(nextNode.nodeType === 8 && nextNode.nodeValue?.trim().match(/^kg-card-end:\s?plugin-card$/))) { + const currentNode = nextNode; + nextNode = currentNode.nextSibling; + if (currentNode.nodeType === 1) { + html.push((currentNode as Element).outerHTML); + } else if (currentNode.nodeType === 3 && currentNode.textContent) { + html.push(currentNode.textContent); + } + currentNode.remove(); + } + // Remove end comment + if (nextNode) { + nextNode.remove(); + } + } + + const fullHtml = html.join('\n').trim(); + + // Extract plugin metadata from data attributes + let pluginName = ''; + let cardName = ''; + let payload = '{}'; + + // Parse the HTML to find data attributes + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = fullHtml; + const firstChild = tempDiv.firstElementChild; + if (firstChild) { + pluginName = firstChild.getAttribute('data-ghost-plugin') || ''; + cardName = firstChild.getAttribute('data-card-name') || ''; + payload = firstChild.getAttribute('data-ghost-payload') || '{}'; + } + + const nodePayload = { + html: fullHtml, + pluginName, + cardName, + payload + }; + + const node = new NodeType(nodePayload); + return { node }; + }, + priority: 0 + }; + } + return null; + } + }; +} diff --git a/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-preprocess.ts b/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-preprocess.ts new file mode 100644 index 0000000000..67b999b53c --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-preprocess.ts @@ -0,0 +1,75 @@ +/** + * Sandboxed preprocess function executor for plugin cards. + * + * The preprocess function receives the raw payload (form data) and returns + * a transformed version ready for template rendering. It runs in a restricted + * sandbox with no access to DOM, network, storage, or Node.js APIs — only + * JavaScript built-ins for data transformation. + * + * Safety: constructors are passed as frozen wrapper objects so a plugin's + * preprocess code cannot mutate prototypes globally. + */ + +const $ = Object.freeze({ + JSON: Object.freeze({ + parse: JSON.parse.bind(JSON), + stringify: JSON.stringify.bind(JSON) + }), + Math: Object.freeze({ + min: Math.min.bind(Math), + max: Math.max.bind(Math), + round: Math.round.bind(Math), + ceil: Math.ceil.bind(Math), + floor: Math.floor.bind(Math), + abs: Math.abs.bind(Math) + }), + // Wrapped as coercion functions to prevent prototype pollution + String: Object.freeze(function(v: unknown) { return String(v); }), + Number: Object.freeze(function(v: unknown) { return Number(v); }), + Boolean: Object.freeze(function(v: unknown) { return Boolean(v); }), + Object: Object.freeze({ + keys: Object.keys.bind(Object), + values: Object.values.bind(Object), + assign: Object.assign.bind(Object) + }), + Date: Object.freeze(function DateFactory() { return new Date(); }), + parseInt: parseInt.bind(undefined), + parseFloat: parseFloat.bind(undefined), + isNaN: isNaN.bind(undefined), + isFinite: isFinite.bind(undefined) +}); + +const SANDBOX_NAMES = ['JSON', 'Math', 'String', 'Number', 'Boolean', 'Object', 'Date', 'parseInt', 'parseFloat', 'isNaN', 'isFinite']; +const SANDBOX_VALUES: unknown[] = SANDBOX_NAMES.map(k => ($ as unknown as Record)[k]); + +/** + * Creates a preprocessor function from a source string. + * The source should define a `function preprocess(payload) { ... }` + * that returns a plain object. + * + * Returns a function (payload: object) => object, or null if source is empty. + */ +export function createPreprocessor(source: string | undefined): ((payload: Record) => Record) | null { + if (!source || !source.trim()) return null; + + const fn = new Function( + ...SANDBOX_NAMES, + 'payload', + `"use strict";\n${source}\nreturn preprocess(payload);` + ); + + return (payload: Record) => { + const result = fn(...SANDBOX_VALUES, payload); + + if (typeof result !== 'object' || result === null || Array.isArray(result)) { + throw new Error('preprocess must return a plain object'); + } + + // Copy own enumerable properties only — prevents prototype pollution + // from leaking through the return value + return Object.keys(result).reduce((obj, key) => { + obj[key] = result[key]; + return obj; + }, {} as Record); + }; +} diff --git a/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-renderer.ts b/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-renderer.ts new file mode 100644 index 0000000000..bf42ce7ca1 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-renderer.ts @@ -0,0 +1,125 @@ +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions, ExportDOMOutput} from '../../export-dom.js'; +import {renderTemplate} from './plugin-card-template.js'; +import {scopeCss} from './plugin-card-css.js'; +import {createPreprocessor} from './plugin-card-preprocess.js'; + +interface PluginCardNodeData { + html?: string; + pluginName?: string; + cardName?: string; + payload?: string; + css?: string; + template?: string; + preprocess?: string; +} + +interface RenderOptions extends ExportDOMOptions {} + +function parsePayload(raw: any): Record { + if (typeof raw === 'object' && raw !== null) { + return raw as Record; + } + if (typeof raw === 'string') { + try { + return JSON.parse(raw || '{}'); + } catch { + try { + return JSON.parse((raw || '{}').replace(/\\"/g, '"')); + } catch { + return {}; + } + } + } + return {}; +} + +/** + * Adds data-ghost-* attributes to the root element of rendered HTML + * so that the plugin-card-parser can re-create PluginCardNodes on re-import. + */ +function addDataAttributes(html: string, pluginName: string, cardName: string, payload: Record): string { + const payloadStr = JSON.stringify(payload) + .replace(/"/g, '"') + .replace(/'/g, '''); + + // Find the first HTML element and add attributes + const tagMatch = html.match(/<(\w+)([^>]*)>/); + if (tagMatch) { + const tagName = tagMatch[1]; + const existingAttrs = tagMatch[2]; + const dataAttrs = ` data-ghost-plugin="${pluginName}" data-card-name="${cardName}" data-ghost-payload='${payloadStr}'`; + return html.replace( + new RegExp(`<${tagName}([^>]*)>`), + `<${tagName}${existingAttrs}${dataAttrs}>` + ); + } + + return html; +} + +export function renderPluginCardNode(node: PluginCardNodeData, options: RenderOptions = {}): ExportDOMOutput<'value'> { + addCreateDocumentOption(options); + const document = options.createDocument!(); + + // Keep raw payload for re-import via data attributes (parser restores + // the original form data, not the preprocessed version) + const rawPayload = parsePayload(node.payload || '{}'); + + // Apply plugin-specific data transformations before rendering. + // Deep copy so nested mutations in preprocess don't corrupt rawPayload. + let renderPayload; + try { + renderPayload = JSON.parse(JSON.stringify(rawPayload)); + } catch { + renderPayload = {...rawPayload}; // Fallback to shallow copy + } + if (node.preprocess) { + try { + const preprocess = createPreprocessor(node.preprocess); + if (preprocess) { + renderPayload = preprocess(renderPayload); + } + } catch (err) { + console.warn('[PluginCard] preprocess failed:', err); + } + } + + // Use the plugin's Handlebars template if available, fallback to + // pre-rendered html or an empty placeholder + let renderedHtml: string; + if (node.template) { + try { + renderedHtml = renderTemplate(node.template, renderPayload); + } catch { + renderedHtml = node.html || '
Plugin card (render error)
'; + } + } else if (node.html) { + renderedHtml = node.html; + } else { + renderedHtml = '
Plugin card (no template)
'; + } + + // Wrap in namespace container so scoped CSS applies + const namespace = `plugin-${node.pluginName || 'unknown'}`; + renderedHtml = `
\n${renderedHtml}\n
`; + + // Add data attributes for re-import via plugin-card-parser. + // Store the RAW payload so the editor form gets the original data. + renderedHtml = addDataAttributes( + renderedHtml, + node.pluginName || '', + node.cardName || '', + rawPayload + ); + + // Include CSS scoped to the plugin namespace + const css = scopeCss(node.css || '', namespace); + const styleTag = css ? `` : ''; + + const wrappedHtml = `\n\n${styleTag}${renderedHtml}\n\n`; + + const textarea = document.createElement('textarea'); + textarea.value = wrappedHtml; + return {element: textarea, type: 'value' as const}; +} diff --git a/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-template.ts b/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-template.ts new file mode 100644 index 0000000000..6eb1fbeca4 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-template.ts @@ -0,0 +1,225 @@ +/** + * Lightweight Handlebars-compatible template renderer. + * + * Supports the subset used in plugin card templates: + * - {{var}} simple interpolation + * - {{this}} current context in #each loops + * - {{nested.key.path}} dot-separated path access + * - {{#if var}}...{{else}}...{{/if}} + * - {{#unless var}}...{{/unless}} + * - {{#each list}}...{{/each}} + * + * Does NOT support: helpers, subexpressions, block params, partials. + */ + +export interface TemplateData { + [key: string]: unknown; +} + +type RenderContext = TemplateData | unknown; + +function getPath(data: RenderContext, path: string): unknown { + if (!path || path === 'this') { + return data; + } + const parts = path.split('.'); + let value: unknown = data; + for (const part of parts) { + if (value && typeof value === 'object') { + value = (value as TemplateData)[part]; + } else { + return undefined; + } + } + return value; +} + +function isTruthy(value: unknown): boolean { + if (value === null || value === undefined || value === false) return false; + if (value === 0) return false; + if (value === '') return false; + if (Array.isArray(value) && value.length === 0) return false; + return true; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +interface BlockToken { + type: 'if' | 'unless' | 'each'; + condition: string; + content: string; + elseContent: string; +} + +/** + * Renders a Handlebars-like template string with the given data context. + * Optimized for the simple syntax used in Ghost plugin card templates. + */ +export function renderTemplate(template: string, data: TemplateData): string { + let result = template; + + // --- Phase 1: Handle block helpers (#if, #unless, #each) --- + // Match opening tag, content, optional {{else}}, closing tag. + // Handles nested blocks by matching balanced open/close. + const blockRegex = /\{\{#(if|unless|each)\s+([^}]+)\}\}/g; + + function processBlocks(input: string, context: RenderContext): string { + let output = input; + let match: RegExpExecArray | null; + + // Find the first opening block with a balanced closing tag + const openRe = /\{\{#(if|unless|each)\s+([^}]+)\}\}/; + + while (true) { + const openMatch = openRe.exec(output); + if (!openMatch) break; + + const blockType = openMatch[1]; + const condition = openMatch[2].trim(); + const openIndex = openMatch.index; + const openLength = openMatch[0].length; + + // Find matching close tag by counting nesting depth + const openTag = `{{#${blockType} ${condition}}}`; + const closeTag = `{{/${blockType}}}`; + let depth = 1; + let searchFrom = openIndex + openLength; + let closeIndex = -1; + let elseIndex = -1; + let elseLength = 0; + + const maybeElse = /\{\{else\}\}/g; + const maybeClose = new RegExp( + `\\{\\{#${blockType}\\s+([^}]+)\\}\\}|\\{\\{/${blockType}\\}\\}`, + 'g' + ); + + maybeClose.lastIndex = searchFrom; + while (depth > 0) { + const next = maybeClose.exec(output); + if (!next) break; + + const matched = next[0]; + if (matched.startsWith('{{/')) { + depth--; + if (depth === 0) { + closeIndex = next.index; + } + } else { + depth++; + } + } + + if (closeIndex === -1) break; // No matching close tag + + // Find {{else}} between open and close + maybeElse.lastIndex = openIndex + openLength; + while (true) { + const elseMatch = maybeElse.exec(output); + if (!elseMatch || elseMatch.index >= closeIndex) break; + // Make sure it's not inside a nested block + let nestedDepth = 0; + const checkRe = /\{\{#|\{\{\//g; + checkRe.lastIndex = openIndex + openLength; + while (true) { + const chk = checkRe.exec(output); + if (!chk || chk.index >= elseMatch.index) break; + if (chk[0] === '{{#') nestedDepth++; + else nestedDepth--; + } + if (nestedDepth === 0) { + elseIndex = elseMatch.index; + elseLength = elseMatch[0].length; + break; + } + maybeElse.lastIndex = elseMatch.index + 1; + } + + const before = output.slice(0, openIndex); + const endClose = closeIndex + closeTag.length; + + let innerContent: string; + let elseContent = ''; + + if (elseIndex !== -1) { + innerContent = output.slice(openIndex + openLength, elseIndex); + elseContent = output.slice(elseIndex + elseLength, closeIndex); + } else { + innerContent = output.slice(openIndex + openLength, closeIndex); + } + + // Recursively process nested blocks inside content + innerContent = processBlocks(innerContent, context); + elseContent = processBlocks(elseContent, context); + + // Render based on block type + let replacement = ''; + const value = getPath(context, condition); + + if (blockType === 'if') { + const useContent = isTruthy(value) ? innerContent : elseContent; + replacement = useContent.replace(/\{\{this\}\}/g, () => escapeHtml(String(value ?? ''))); + // Replace simple {{var}} interpolations in the processed content + replacement = interpolateVars(replacement, context); + } else if (blockType === 'unless') { + const useContent = !isTruthy(value) ? innerContent : elseContent; + replacement = useContent.replace(/\{\{this\}\}/g, () => escapeHtml(String(value ?? ''))); + replacement = interpolateVars(replacement, context); + } else if (blockType === 'each') { + // Coerce strings to arrays (split by newline — plugin + // form textareas store multi-value data this way) + const items = typeof value === 'string' + ? value.split('\n').filter((l: string) => l.trim() !== '') + : value; + if (Array.isArray(items)) { + replacement = (items as unknown[]).map((item: unknown) => { + let itemContent = innerContent; + // {{this}} gets the current item + itemContent = itemContent.replace(/\{\{this\}\}/g, () => { + return escapeHtml(String(item ?? '')); + }); + // {{field}} gets item.field + itemContent = interpolateVars(itemContent, item as TemplateData); + return itemContent; + }).join(''); + } else { + replacement = elseContent; + } + } + + output = before + replacement + output.slice(endClose); + } + + return output; + } + + // Process all blocks recursively + result = processBlocks(result, data); + + // --- Phase 2: Simple variable interpolation --- + result = interpolateVars(result, data); + + return result; +} + +/** + * Replace {{var}} and {{nested.path}} patterns in text with data values. + * Does not handle block helpers (they're already processed). + */ +function interpolateVars(input: string, data: RenderContext): string { + return input.replace(/\{\{([^#/][^}]*?)\}\}/g, (_match, key: string) => { + const trimmed = key.trim(); + const value = getPath(data, trimmed); + if (value === null || value === undefined) { + return ''; + } + return escapeHtml(String(value)); + }); +} diff --git a/packages/koenig-lexical/src/commands/pluginCardCommands.js b/packages/koenig-lexical/src/commands/pluginCardCommands.js new file mode 100644 index 0000000000..3e3afbf797 --- /dev/null +++ b/packages/koenig-lexical/src/commands/pluginCardCommands.js @@ -0,0 +1,3 @@ +import {createCommand} from 'lexical'; + +export const INSERT_PLUGIN_CARD_COMMAND = createCommand('INSERT_PLUGIN_CARD_COMMAND'); diff --git a/packages/koenig-lexical/src/context/PluginCardContext.jsx b/packages/koenig-lexical/src/context/PluginCardContext.jsx new file mode 100644 index 0000000000..9b09f5c203 --- /dev/null +++ b/packages/koenig-lexical/src/context/PluginCardContext.jsx @@ -0,0 +1,48 @@ +import React from 'react'; + +const PluginCardContext = React.createContext({pluginCards: []}); + +let _pluginCardsCache = []; +let _pluginCardsLoaded = false; +let _pluginCardsPromise = null; + +function fetchPluginCards() { + if (!_pluginCardsPromise) { + _pluginCardsPromise = fetch('/ghost/api/admin/plugins/cards/', {credentials: 'include'}) + .then(r => r.json()) + .then(data => { + _pluginCardsCache = data.plugins?.flat() || []; + _pluginCardsLoaded = true; + return _pluginCardsCache; + }) + .catch(() => { + _pluginCardsLoaded = true; + _pluginCardsPromise = null; + return []; + }); + } + return _pluginCardsPromise.then(() => _pluginCardsCache); +} + +export function PluginCardProvider({children}) { + const [pluginCards, setPluginCards] = React.useState(_pluginCardsCache); + + React.useEffect(() => { + fetchPluginCards().then(setPluginCards); + }, []); + + return ( + + {children} + + ); +} + +export function usePluginCards() { + return React.useContext(PluginCardContext); +} + +// Also expose synchronously-cached cards (for menu builds before fetch completes) +export function getCachedPluginCards() { + return _pluginCardsCache; +} diff --git a/packages/koenig-lexical/src/index.js b/packages/koenig-lexical/src/index.js index 798dbd61ff..c972a6c2bb 100644 --- a/packages/koenig-lexical/src/index.js +++ b/packages/koenig-lexical/src/index.js @@ -36,6 +36,7 @@ import KoenigSnippetPlugin from './plugins/KoenigSnippetPlugin'; import MarkdownPlugin from './plugins/MarkdownPlugin'; import MarkdownShortcutPlugin from './plugins/MarkdownShortcutPlugin'; import PlusCardMenuPlugin from './plugins/PlusCardMenuPlugin'; +import PluginCardPlugin from './plugins/PluginCardPlugin'; import ProductPlugin from './plugins/ProductPlugin'; import ReplacementStringsPlugin from './plugins/ReplacementStringsPlugin'; import RestrictContentPlugin from './plugins/RestrictContentPlugin'; @@ -109,6 +110,7 @@ export { MarkdownPlugin, MarkdownShortcutPlugin, PlusCardMenuPlugin, + PluginCardPlugin, ProductPlugin, ReplacementStringsPlugin, RestrictContentPlugin, diff --git a/packages/koenig-lexical/src/nodes/DefaultNodes.js b/packages/koenig-lexical/src/nodes/DefaultNodes.js index 2726c0b7d8..14e0c6bffb 100644 --- a/packages/koenig-lexical/src/nodes/DefaultNodes.js +++ b/packages/koenig-lexical/src/nodes/DefaultNodes.js @@ -32,6 +32,7 @@ import {LinkNode} from '@lexical/link'; import {ListItemNode, ListNode} from '@lexical/list'; import {MarkdownNode} from './MarkdownNode'; import {PaywallNode} from './PaywallNode'; +import {PluginCardNode} from './PluginCardNode'; import {ProductNode} from './ProductNode'; import {SignupNode} from './SignupNode'; import {ToggleNode} from './ToggleNode'; @@ -66,6 +67,7 @@ const DEFAULT_NODES = [ HeaderNode, BookmarkNode, PaywallNode, + PluginCardNode, ProductNode, EmailNode, EmailCtaNode, diff --git a/packages/koenig-lexical/src/nodes/PluginCardNode.jsx b/packages/koenig-lexical/src/nodes/PluginCardNode.jsx new file mode 100644 index 0000000000..272aaba51d --- /dev/null +++ b/packages/koenig-lexical/src/nodes/PluginCardNode.jsx @@ -0,0 +1,44 @@ +import PluginCardIcon from '../assets/icons/kg-card-type-html.svg?react'; +import PluginCardIndicatorIcon from '../assets/icons/kg-indicator-html.svg?react'; +import KoenigCardWrapper from '../components/KoenigCardWrapper'; +import {PluginCardNode as BasePluginCardNode} from '@tryghost/kg-default-nodes'; +import {PluginCardNodeComponent} from './PluginCardNodeComponent'; +import {INSERT_PLUGIN_CARD_COMMAND} from '../commands/pluginCardCommands'; + +export class PluginCardNode extends BasePluginCardNode { + static kgMenu = []; + + getIcon() { + return PluginCardIcon; + } + + constructor(dataset = {}, key) { + super(dataset, key); + } + + decorate() { + return ( + + + + ); + } +} + +export function $createPluginCardNode(dataset) { + return new PluginCardNode(dataset); +} + +export function $isPluginCardNode(node) { + return node instanceof PluginCardNode; +} diff --git a/packages/koenig-lexical/src/nodes/PluginCardNodeComponent.jsx b/packages/koenig-lexical/src/nodes/PluginCardNodeComponent.jsx new file mode 100644 index 0000000000..4f61ceda0b --- /dev/null +++ b/packages/koenig-lexical/src/nodes/PluginCardNodeComponent.jsx @@ -0,0 +1,210 @@ +import React from 'react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$getNodeByKey} from 'lexical'; +import {createPreprocessor, renderTemplate, scopeCss} from '@tryghost/kg-default-nodes'; + +// CSS reset to neutralise Tailwind preflight inside plugin cards. +// Tailwind's *, ::before, ::after { box-sizing: border-box } and +// block-level margin resets affect plugin-rendered HTML but not +// the published page. This reset restores browser defaults so the +// card renders identically in editor and preview. +const PREFLIGHT_RESET = ` + .{ns}, .{ns} *, .{ns} ::before, .{ns} ::after { box-sizing: content-box; white-space: normal; word-break: normal; overflow-wrap: normal; } + .{ns} img, .{ns} svg, .{ns} video, .{ns} canvas, .{ns} audio, .{ns} iframe, .{ns} embed, .{ns} object { display: inline; vertical-align: baseline; } + .{ns} blockquote, .{ns} dl, .{ns} dd, .{ns} h1, .{ns} h2, .{ns} h3, .{ns} h4, .{ns} h5, .{ns} h6, .{ns} hr, .{ns} figure, .{ns} p, .{ns} pre { margin: revert; } + .{ns} ol, .{ns} ul { list-style: revert; margin: revert; padding: revert; } + .{ns} a { color: revert; text-decoration: revert; } + .{ns} table { border-collapse: separate; border-spacing: 0; } + .{ns} th, .{ns} td { text-align: revert; font-weight: revert; } + .{ns} strong, .{ns} b { font-weight: revert; } + .{ns} em, .{ns} i { font-style: revert; } + .{ns} button, .{ns} input, .{ns} select, .{ns} textarea { font-family: revert; font-size: revert; line-height: revert; } + .{ns} ::placeholder { color: revert; opacity: revert; } + .{ns} ::before, .{ns} ::after { border-width: 0; border-style: solid; border-color: currentColor; } +`; + +// Inject CSS into so the editor applies custom class styles +// Reference counting for shared stylesheets: multiple card instances +// of the same plugin share one