From 0f73011ac364400303516f3602d55630dcc24445 Mon Sep 17 00:00:00 2001 From: danielperez9430 Date: Wed, 17 Jun 2026 01:29:08 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Added=20plugin=20card=20system?= =?UTF-8?q?=20for=20custom=20editor=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refs TryGhost/Koenig#1980 Ghost admins can now install custom editor cards via ZIP upload. Cards define their own Handlebars template, CSS, form fields, and optional preprocess hook for data transformation. The system includes: - PluginCardNode with generic template rendering (renderTemplate engine) - Sandboxed preprocess hook (createPreprocessor with frozen constructors) - Dynamic card menu population via PluginCardProvider context - CSS namespace isolation (scopeCss) and editor preflight reset - ZIP Slip and path traversal protections in plugin loader and extraction - HTML attribute escaping and DOMPurify sanitization for plugin icons - Reference counting for shared plugin stylesheets --- .../kg-default-nodes/src/kg-default-nodes.ts | 7 + .../src/nodes/plugin-card/PluginCardNode.ts | 70 ++++++ .../src/nodes/plugin-card/index.ts | 5 + .../src/nodes/plugin-card/plugin-card-css.ts | 148 ++++++++++++ .../nodes/plugin-card/plugin-card-parser.ts | 77 ++++++ .../plugin-card/plugin-card-preprocess.ts | 75 ++++++ .../nodes/plugin-card/plugin-card-renderer.ts | 125 ++++++++++ .../nodes/plugin-card/plugin-card-template.ts | 225 ++++++++++++++++++ .../src/commands/pluginCardCommands.js | 3 + .../src/context/PluginCardContext.jsx | 48 ++++ packages/koenig-lexical/src/index.js | 2 + .../koenig-lexical/src/nodes/DefaultNodes.js | 2 + .../src/nodes/PluginCardNode.jsx | 44 ++++ .../src/nodes/PluginCardNodeComponent.jsx | 201 ++++++++++++++++ .../src/plugins/AllDefaultPlugins.jsx | 5 + .../src/plugins/PluginCardPlugin.jsx | 36 +++ .../src/plugins/PlusCardMenuPlugin.jsx | 6 +- .../src/plugins/SlashCardMenuPlugin.jsx | 6 +- .../koenig-lexical/src/utils/buildCardMenu.js | 59 ++++- 19 files changed, 1139 insertions(+), 5 deletions(-) create mode 100644 packages/kg-default-nodes/src/nodes/plugin-card/PluginCardNode.ts create mode 100644 packages/kg-default-nodes/src/nodes/plugin-card/index.ts create mode 100644 packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-css.ts create mode 100644 packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-parser.ts create mode 100644 packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-preprocess.ts create mode 100644 packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-renderer.ts create mode 100644 packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-template.ts create mode 100644 packages/koenig-lexical/src/commands/pluginCardCommands.js create mode 100644 packages/koenig-lexical/src/context/PluginCardContext.jsx create mode 100644 packages/koenig-lexical/src/nodes/PluginCardNode.jsx create mode 100644 packages/koenig-lexical/src/nodes/PluginCardNodeComponent.jsx create mode 100644 packages/koenig-lexical/src/plugins/PluginCardPlugin.jsx 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..946aba3e6e --- /dev/null +++ b/packages/koenig-lexical/src/nodes/PluginCardNodeComponent.jsx @@ -0,0 +1,201 @@ +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