Skip to content

✨ Added plugin card system for custom editor cards#1989

Closed
danielperez9430 wants to merge 4 commits into
TryGhost:mainfrom
danielperez9430:main
Closed

✨ Added plugin card system for custom editor cards#1989
danielperez9430 wants to merge 4 commits into
TryGhost:mainfrom
danielperez9430:main

Conversation

@danielperez9430

Copy link
Copy Markdown

Adds a plugin card system that lets Ghost admins install custom editor cards via ZIP upload. Cards define their own template (Handlebars syntax), CSS, and form fields — enabling review cards, info boxes, pricing tables, and more without touching Koenig's source code.

⚠️ Dependencies

This PR must be merged and published to npm before the companion Ghost PR, because Ghost's plugin-renderer.js imports renderTemplate and createPreprocessor from @tryghost/kg-default-nodes.

Companion PR: TryGhost/Ghost#28642

New Packages/Files

kg-default-nodes — Base node & rendering

  • PluginCardNode.ts — Lexical node for plugin cards with pluginName, cardName, payload, template, css, and preprocess properties
  • plugin-card-template.ts — Lightweight Handlebars-compatible template engine ({{var}}, {{#if}}, {{#each}}, {{#unless}})
  • plugin-card-renderer.ts — Client-side exportDOM renderer using the template engine
  • plugin-card-parser.tsimportDOM parser that extracts data-ghost-* attributes for re-import
  • plugin-card-css.tsscopeCss() utility that namespaces selectors to prevent style collisions
  • plugin-card-preprocess.ts — Sandboxed createPreprocessor() that runs plugin-defined data transformations with frozen wrapper objects to prevent prototype pollution

koenig-lexical — Editor integration

  • PluginCardNode.jsx — Editor node subclass with icon, indicator, and KoenigCardWrapper
  • PluginCardNodeComponent.jsx — React component with:
    • Dynamic edit mode (form fields generated from cardDef.fields)
    • Template-based view mode via dangerouslySetInnerHTML
    • CSS preflight reset to counter Tailwind in the editor
    • not-kg-prose wrapper for Koenig typography isolation
    • Preprocess hook integration
  • PluginCardPlugin.jsx — Lexical plugin that registers the INSERT_PLUGIN_CARD_COMMAND
  • PluginCardContext.jsx — React context that fetches plugin cards from the API and populates the slash/plus menu
  • pluginCardCommands.js — Lexical command definition

Modified files

  • kg-default-nodes.ts — Re-exports renderTemplate, scopeCss, createPreprocessor
  • DefaultNodes.js — Registers PluginCardNode
  • AllDefaultPlugins.jsx — Registers PluginCardPlugin
  • buildCardMenu.js — Builds menu items from plugin card config
  • SlashCardMenuPlugin.jsx / PlusCardMenuPlugin.jsx — Pass plugin cards to menu builder
  • PluginCardNode.ts — Property list updated with template and preprocess

Features

  • Template rendering: Plugin authors write template.html using Handlebars syntax ({{var}}, {{#if}}, {{#each}}, {{#unless}}, {{else}}). Renders identically in editor and published page.
  • Dynamic edit forms: Form fields are generated from plugin.json field definitions (text, number, textarea)
  • CSS isolation: scopeCss() adds .plugin-{name} prefix to all selectors. Editor view mode applies a preflight reset to neutralise Tailwind overrides.
  • Card menu: Slash and plus menus automatically show all installed plugin cards
  • Re-import: importDOM extracts data-ghost-plugin, data-card-name, data-ghost-payload from HTML
  • Data preprocessing: Plugins can define a preprocess.js that transforms raw form data before rendering (e.g., parsing "Label=9" into structured rating objects). Runs in a sandboxed environment.
  • Sandbox security: Preprocess functions receive frozen wrapper objects, not raw constructors, preventing prototype pollution.

Testing

  1. Install a plugin ZIP via Settings → Labs → Card Plugins
  2. Open the editor, type / or click + → plugin card appears in menu
  3. Insert card, fill form, save → view mode renders with template
  4. Publish → card HTML includes scoped CSS and data attributes
  5. Download plugin → valid ZIP with all plugin files
  6. Delete plugin → card nodes converted to static HTML preserving content

Screenshots

image

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR introduces a plugin-card node type across two packages. In kg-default-nodes, it adds the PluginCardNode decorator class with seven properties, a DOM parser that extracts content between <!--kg-card-begin: plugin-card--> markers, a renderer that wraps output in a <textarea>, a CSS namespace-scoping utility, a lightweight Handlebars-compatible template renderer, and a sandboxed preprocessor factory using new Function with restricted globals. In koenig-lexical, it adds a PluginCardContext that fetches card definitions from /ghost/api/admin/plugins/cards/, a PluginCardNodeComponent React component with edit/view modes, a PluginCardPlugin Lexical command handler, and integrates the node and plugin cards into DefaultNodes, AllDefaultPlugins, and both PlusCardMenuPlugin/SlashCardMenuPlugin via buildCardMenu.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Suggested reviewers

  • kevinansfield
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.30% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: addition of a plugin card system for custom editor cards. It directly reflects the PR's primary objective and is specific enough to be meaningful.
Description check ✅ Passed The description comprehensively covers the plugin card system implementation, including architecture, features, testing steps, and dependencies. It relates directly to the changeset and provides clear context for the work.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (1)
packages/kg-default-nodes/src/nodes/plugin-card/PluginCardNode.ts (1)

9-9: ⚡ Quick win

Duplicated default values may diverge.

The payload default '{}' is defined both in pluginCardProperties (line 9) and again in defaults (line 50). If one changes without the other, import behavior could differ from node creation defaults.

Consider extracting defaults from pluginCardProperties to ensure consistency:

♻️ Suggested refactor to derive defaults from properties
         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: ''};
+        const defaults = Object.fromEntries(
+            pluginCardProperties.map(p => [p.name, p.default])
+        );
         propNames.forEach((name) => {

Also applies to: 50-50

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/kg-default-nodes/src/nodes/plugin-card/PluginCardNode.ts` at line 9,
The `payload` default value `'{}'` is duplicated in both `pluginCardProperties`
(line 9) and the `defaults` object (line 50), creating a maintenance risk where
changes in one location could be forgotten in the other. Extract the default
values from `pluginCardProperties` by deriving the defaults object from that
property definition instead of maintaining separate hardcoded defaults.
Specifically, remove the duplicate `{name: 'payload', default: '{}'}` entry from
the `defaults` object at line 50 and instead programmatically derive the
defaults by mapping over the `pluginCardProperties` to extract each property's
default value, ensuring a single source of truth for all default values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-css.ts`:
- Around line 52-59: The scopeInnerRules function (lines 141–146) is currently a
no-op and does not actually scope selectors within `@media` blocks, allowing CSS
rules to leak globally instead of being namespaced. Either implement the
scopeInnerRules function to properly parse and prefix selectors inside `@media`
rules with the namespace, or add clear documentation explaining that `@media`
block scoping is not supported and selectors within them will not be isolated.
The current comment claiming selectors already match outer scoped rules is
misleading and should be corrected or removed if you choose the documentation
approach.

In `@packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-preprocess.ts`:
- Around line 26-27: The String, Number, Boolean, and Date constructors are
passed directly to the sandbox without protection, allowing malicious preprocess
code to modify their prototypes and cause prototype pollution. Wrap these
constructors with frozen or protected versions similar to how the other globals
are already wrapped in the sandbox initialization to prevent prototype
modifications. If the preprocess code needs to create Date instances, provide a
factory function wrapper instead of exposing the direct Date constructor.

In `@packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-renderer.ts`:
- Around line 41-57: The addDataAttributes function creates a data-ghost-payload
attribute using single-quote delimiters, but the payloadStr variable only
escapes double quotes via JSON.stringify. If the payload contains single quotes
(like {"name": "it's"}), the attribute syntax breaks. Fix this by either: (1)
escaping single quotes in payloadStr before constructing the data-ghost-payload
attribute string, or (2) switching the data-ghost-payload attribute to use
double-quote delimiters instead of single quotes (which already has proper
escaping in place from the JSON.stringify call). Choose the approach that best
fits your project's conventions.

In `@packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-template.ts`:
- Around line 45-51: The escapeHtml function is missing the escape sequence for
single quotes, which can cause rendering issues or allow attribute injection
when single-quoted attributes contain interpolated values. Add a replace call to
the escapeHtml function chain to escape single quotes (') to &`#39`; using the
same pattern as the existing replace calls for other special characters like &,
<, >, and ".

In `@packages/koenig-lexical/src/context/PluginCardContext.jsx`:
- Around line 18-23: The catch block in the plugin cards fetch flow sets
_pluginCardsLoaded to true and returns an empty array on failure, but fails to
reset _pluginCardsPromise, causing subsequent calls to return the cached empty
result instead of retrying. In the catch block where _pluginCardsLoaded is set
to true, also reset _pluginCardsPromise to null so that the next call to
getPluginCards will create a new promise and attempt to fetch again rather than
returning the permanently failed result.

In `@packages/koenig-lexical/src/nodes/PluginCardNodeComponent.jsx`:
- Around line 73-89: The conditional checks for updating node.css and
node.template properties use truthy checks instead of nullish checks, which
prevents clearing stale values when the found properties are falsy but defined
(like empty strings). In the PluginCardNodeComponent.jsx file where the
editor.update block syncs template, CSS and preprocess from the plugin loader,
change the conditions for node.css and node.template updates from truthy checks
(if (found.css) and if (found.template)) to nullish checks using the same
pattern as the existing node.preprocess check (if (found.css !== undefined) and
if (found.template !== undefined)). This ensures that all property updates,
including falsy values, will overwrite stale node metadata.
- Around line 65-96: The useEffect in PluginCardNodeComponent is making
independent fetch requests to '/ghost/api/admin/plugins/cards/' for each card
instance, which bypasses the PluginCardContext's single-flight cache and causes
duplicate requests when multiple plugin cards are rendered. Replace the
individual fetch call with consumption of PluginCardContext to retrieve the
shared cached plugin data instead. This will eliminate the redundant network
requests by allowing all card instances to use a single fetched set of plugin
definitions from the context.
- Around line 33-45: The issue is that all card instances of the same plugin
share a single stylesheet identified by styleId, but the cleanup function
unconditionally removes that shared style element whenever any instance
unmounts, causing other mounted cards of the same plugin to lose their styles.
Implement reference counting for the shared style element: create a mechanism to
track how many component instances are currently using each stylesheet (keyed by
styleId), increment the count when the component mounts, and only remove the
style element from the DOM in the cleanup function when the reference count
reaches zero after decrementing. This ensures the shared stylesheet persists as
long as at least one card instance for that plugin remains mounted.

In `@packages/koenig-lexical/src/plugins/PluginCardPlugin.jsx`:
- Around line 21-26: Remove the async keyword from the handler function that
accepts the dataset parameter in the PluginCardPlugin.jsx file. The handler must
return a boolean synchronously to properly control command propagation in
Lexical's command system; currently it returns a Promise which Lexical does not
use for determining if the command was handled. Change the handler to
synchronously execute the dispatchCommand call and return its result directly.

In `@packages/koenig-lexical/src/plugins/PlusCardMenuPlugin.jsx`:
- Line 20: The usePluginCards() hook in PlusCardMenuPlugin cannot access the
PluginCardProvider context because the provider is only wrapping
PluginCardPlugin in AllDefaultPlugins.jsx, not CardMenuPlugin which renders
PlusCardMenuPlugin. To fix this, restructure the component tree in
AllDefaultPlugins.jsx so that PluginCardProvider wraps both CardMenuPlugin (line
39) and PluginCardPlugin (line 61) together, ensuring the provider is a common
ancestor to all components that need to call the usePluginCards hook.
Alternatively, move PluginCardProvider higher in the component hierarchy to be
an ancestor of both components.

In `@packages/koenig-lexical/src/utils/buildCardMenu.js`:
- Around line 125-131: The PluginCardIcon function directly injects
pluginCard.icon into dangerouslySetInnerHTML without sanitization, allowing
malicious plugins to execute scripts or event handlers in admin context.
Sanitize the pluginCard.icon value before using it in the
dangerouslySetInnerHTML attribute. Apply a sanitization function or library
(such as DOMPurify) to strip out potentially dangerous content like script tags
and event handlers from the SVG string before rendering it.
- Around line 121-123: The buildCardMenu function assumes plugin cards have a
valid shape with required properties, but does not validate them before use. Add
guard clauses to validate the pluginCard structure before accessing its
properties. Specifically, before line 121 where pluginCard.label is accessed
with toLowerCase(), verify that the label property exists and is a string.
Similarly, before line 145 where fields.reduce() is called, verify that the
fields property exists and is an array. These guards should either skip
malformed cards or provide safe defaults, preventing one invalid plugin card
from breaking the entire card menu build process.
- Around line 13-16: Remove the debug logging statements from the buildCardMenu
function that execute during frequent menu rebuilds. Delete the console.log
statement (lines 13-16) that logs plugin cards from the config, and also remove
the debug logging statement at lines 74-75. These statements add unnecessary
console noise and create performance overhead during common operations like
slash command typing.

---

Nitpick comments:
In `@packages/kg-default-nodes/src/nodes/plugin-card/PluginCardNode.ts`:
- Line 9: The `payload` default value `'{}'` is duplicated in both
`pluginCardProperties` (line 9) and the `defaults` object (line 50), creating a
maintenance risk where changes in one location could be forgotten in the other.
Extract the default values from `pluginCardProperties` by deriving the defaults
object from that property definition instead of maintaining separate hardcoded
defaults. Specifically, remove the duplicate `{name: 'payload', default: '{}'}`
entry from the `defaults` object at line 50 and instead programmatically derive
the defaults by mapping over the `pluginCardProperties` to extract each
property's default value, ensuring a single source of truth for all default
values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7201a894-5daa-4f3e-b611-caf03cf5f8c4

📥 Commits

Reviewing files that changed from the base of the PR and between bc8a553 and ded45ea.

📒 Files selected for processing (19)
  • packages/kg-default-nodes/src/kg-default-nodes.ts
  • packages/kg-default-nodes/src/nodes/plugin-card/PluginCardNode.ts
  • packages/kg-default-nodes/src/nodes/plugin-card/index.ts
  • packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-css.ts
  • packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-parser.ts
  • packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-preprocess.ts
  • packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-renderer.ts
  • packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-template.ts
  • packages/koenig-lexical/src/commands/pluginCardCommands.js
  • packages/koenig-lexical/src/context/PluginCardContext.jsx
  • packages/koenig-lexical/src/index.js
  • packages/koenig-lexical/src/nodes/DefaultNodes.js
  • packages/koenig-lexical/src/nodes/PluginCardNode.jsx
  • packages/koenig-lexical/src/nodes/PluginCardNodeComponent.jsx
  • packages/koenig-lexical/src/plugins/AllDefaultPlugins.jsx
  • packages/koenig-lexical/src/plugins/PluginCardPlugin.jsx
  • packages/koenig-lexical/src/plugins/PlusCardMenuPlugin.jsx
  • packages/koenig-lexical/src/plugins/SlashCardMenuPlugin.jsx
  • packages/koenig-lexical/src/utils/buildCardMenu.js

Comment thread packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-css.ts
Comment thread packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-preprocess.ts Outdated
Comment thread packages/koenig-lexical/src/context/PluginCardContext.jsx
Comment thread packages/koenig-lexical/src/plugins/PluginCardPlugin.jsx Outdated
Comment thread packages/koenig-lexical/src/plugins/PlusCardMenuPlugin.jsx
Comment thread packages/koenig-lexical/src/utils/buildCardMenu.js Outdated
Comment thread packages/koenig-lexical/src/utils/buildCardMenu.js
Comment thread packages/koenig-lexical/src/utils/buildCardMenu.js

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-renderer.ts`:
- Around line 70-71: The deep copy operation using
JSON.parse(JSON.stringify(rawPayload)) on line 71 can throw an error when
rawPayload contains non-JSON-serializable objects such as circular references.
Wrap this operation in a try-catch block to guard against serialization failures
before preprocess runs. In the catch block, handle the error appropriately by
either logging the error and using the original rawPayload, or throwing a
descriptive error message to prevent silent failures during export.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0bfeb8fa-db6f-45a3-90fa-c32b63d93256

📥 Commits

Reviewing files that changed from the base of the PR and between 526d08c and 5122dd4.

📒 Files selected for processing (1)
  • packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-renderer.ts

Comment thread packages/kg-default-nodes/src/nodes/plugin-card/plugin-card-renderer.ts Outdated
@danielperez9430 danielperez9430 force-pushed the main branch 2 times, most recently from 27d7b70 to 7e9e0e2 Compare June 16, 2026 23:37
refs TryGhost#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
@9larsons

Copy link
Copy Markdown
Collaborator

Closing - see TryGhost/Ghost#28642 for details.

@9larsons 9larsons closed this Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants