Skip to content
This repository was archived by the owner on Jul 13, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/kg-default-nodes/src/kg-default-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -122,6 +128,7 @@ export const DEFAULT_NODES = [
emailCta.EmailCtaNode,
signup.SignupNode,
transistor.TransistorNode,
pluginCard.PluginCardNode,
tk.TKNode,
atLink.AtLinkNode,
atLink.AtLinkSearchNode,
Expand Down
70 changes: 70 additions & 0 deletions packages/kg-default-nodes/src/nodes/plugin-card/PluginCardNode.ts
Original file line number Diff line number Diff line change
@@ -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<typeof pluginCardProperties, true>;

export interface PluginCardNode extends DecoratorNodeValueMap<typeof pluginCardProperties, true> {}

export class PluginCardNode extends generateDecoratorNode({
nodeType: 'plugin-card',
properties: pluginCardProperties,
defaultRenderFn: renderPluginCardNode
}) {
static importDOM() {
return parsePluginCardNode(this);
}

static importJSON(serializedNode: Record<string, unknown>) {
// 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<string, unknown> = {};
const propNames = ['html', 'pluginName', 'cardName', 'payload', 'css', 'template', 'preprocess'];
const defaults: Record<string, unknown> = {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;
}
5 changes: 5 additions & 0 deletions packages/kg-default-nodes/src/nodes/plugin-card/index.ts
Original file line number Diff line number Diff line change
@@ -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';
148 changes: 148 additions & 0 deletions packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-css.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
danielperez9430 marked this conversation as resolved.
}

// 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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type {PluginCardNode} from './PluginCardNode.js';

export function parsePluginCardNode(NodeType: typeof PluginCardNode) {
return {
'#comment': (nodeElem: Comment) => {
// Match <!--kg-card-begin: plugin-card-->
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;
}
};
}
Loading