Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ jobs:
runs-on: ubuntu-latest
env:
VITE_TENOR_API_KEY: ${{ secrets.TENOR_API_KEY }}
VITE_KLIPY_API_KEY: ${{ secrets.KLIPY_API_KEY }}

steps:
- name: Checkout repo
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ Packages have complex interdependencies. Key relationships:
## Special Configuration

### Environment Variables
- `VITE_TENOR_API_KEY`: Required for Gif card functionality
- `VITE_KLIPY_API_KEY`: Required for Gif card functionality (preferred provider)
- `VITE_TENOR_API_KEY`: Required for Gif card functionality (fallback when Klipy key is not set)
Comment thread
renatoworks marked this conversation as resolved.
- `VITE_SENTRY_*`: Error tracking configuration

### External Dependencies
Expand Down
6 changes: 4 additions & 2 deletions packages/koenig-lexical/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ Now, if you navigate to Ghost Admin at http://localhost:2368/ghost and open a po

#### Gif card

To see this card locally, you need to create `.env.local` file in `koenig-lexical` root package with the next data:
To see this card locally, create a `.env.local` file in the `koenig-lexical` root package with a GIF provider key:
```
VITE_KLIPY_API_KEY=xxx
# or, for the legacy Tenor provider:
VITE_TENOR_API_KEY=xxx
```
Comment thread
renatoworks marked this conversation as resolved.

How to get the tenor key is described here https://ghost.org/docs/config/#tenor
The card resolves to Klipy when `VITE_KLIPY_API_KEY` is set, otherwise Tenor. Get a Klipy key at https://partner.klipy.com; the Tenor key is described at https://ghost.org/docs/config/#tenor

#### Bookmark & Embed cards

Expand Down
3 changes: 2 additions & 1 deletion packages/koenig-lexical/demo/DemoApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig';
import {fetchEmbed} from './utils/fetchEmbed';
import {fileTypes, useFileUpload} from './utils/useFileUpload';
import {tenorConfig} from './utils/tenorConfig';
import {klipyConfig, tenorConfig} from './utils/gifConfig';
import {useLocation, useSearchParams} from 'react-router-dom';
import {useSnippets} from './utils/useSnippets';

Expand All @@ -47,6 +47,7 @@ const defaultCardConfig = {
unsplash: defaultUnsplashHeaders,
fetchEmbed: fetchEmbed,
tenor: tenorConfig,
klipy: klipyConfig,
fetchAutocompleteLinks: () => Promise.resolve([
{label: 'Homepage', value: window.location.origin + '/'},
{label: 'Free signup', value: window.location.origin + '/#/portal/signup/free'}
Expand Down
5 changes: 3 additions & 2 deletions packages/koenig-lexical/demo/HtmlOutputDemo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import {$getRoot, $isDecoratorNode} from 'lexical';
import {HtmlOutputPlugin, KoenigComposableEditor, KoenigComposer} from '../src';
import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig';
import {fileTypes, useFileUpload} from './utils/useFileUpload';
import {tenorConfig} from './utils/tenorConfig';
import {klipyConfig, tenorConfig} from './utils/gifConfig';
import {useSnippets} from './utils/useSnippets';
import {useState} from 'react';

const cardConfig = {
unsplash: {defaultHeaders: defaultUnsplashHeaders},
tenor: tenorConfig
tenor: tenorConfig,
klipy: klipyConfig
};

function HtmlOutputDemo() {
Expand Down
5 changes: 3 additions & 2 deletions packages/koenig-lexical/demo/RestrictedContentDemo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import {$getRoot, $isDecoratorNode} from 'lexical';
import {KoenigComposableEditor, KoenigComposer, RestrictContentPlugin} from '../src';
import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig';
import {fileTypes, useFileUpload} from './utils/useFileUpload';
import {tenorConfig} from './utils/tenorConfig';
import {klipyConfig, tenorConfig} from './utils/gifConfig';
import {useLocation} from 'react-router-dom';
import {useSnippets} from './utils/useSnippets';
import {useState} from 'react';

const cardConfig = {
unsplash: {defaultHeaders: defaultUnsplashHeaders},
tenor: tenorConfig
tenor: tenorConfig,
klipy: klipyConfig
};

function useQuery() {
Expand Down
36 changes: 36 additions & 0 deletions packages/koenig-lexical/demo/utils/gifConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {isTestEnv} from '../../test/utils/isTestEnv';

export const tenorConfig = isTestEnv ? {googleApiKey: 'xxx'} : getTenorConfig();

// In tests the GIF provider defaults to Tenor; the ?gifProvider=klipy query
// param opts a specific test into the Klipy path.
export const klipyConfig = isTestEnv ? getTestKlipyConfig() : getKlipyConfig();

function getTenorConfig() {
let config = null;

if (import.meta.env.VITE_TENOR_API_KEY) {
config = {
googleApiKey: import.meta.env.VITE_TENOR_API_KEY
};
}

return config;
}

function getKlipyConfig() {
let config = null;

if (import.meta.env.VITE_KLIPY_API_KEY) {
config = {
apiKey: import.meta.env.VITE_KLIPY_API_KEY
};
}

return config;
}

function getTestKlipyConfig() {
const provider = new URLSearchParams(window.location.search).get('gifProvider');
return provider === 'klipy' ? {apiKey: 'xxx'} : null;
}
15 changes: 0 additions & 15 deletions packages/koenig-lexical/demo/utils/tenorConfig.js

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import GifSelector from './GifSelector';
import KoenigComposerContext from '../../context/KoenigComposerContext.jsx';
import React from 'react';
import TenorSelector from './TenorSelector';
import {DELETE_CARD_COMMAND} from '../../plugins/KoenigBehaviourPlugin.jsx';
import {INSERT_FROM_TENOR_COMMAND} from '../../plugins/KoenigSelectorPlugin.jsx';
import {INSERT_FROM_GIF_COMMAND} from '../../plugins/KoenigSelectorPlugin.jsx';
import {getGifProviderConfig, useGif} from '../../utils/services/gif.js';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useTenor} from '../../utils/services/tenor.js';

const TenorPlugin = ({nodeKey}) => {
const GifPlugin = ({nodeKey}) => {
const {cardConfig} = React.useContext(KoenigComposerContext);
const tenorHook = useTenor({config: cardConfig.tenor});
const providerConfig = getGifProviderConfig(cardConfig);
const gifHook = useGif({config: providerConfig});
const [editor] = useLexicalComposerContext();

React.useEffect(() => {
Expand All @@ -33,16 +34,17 @@ const TenorPlugin = ({nodeKey}) => {
};

const insertImageToNode = async (image) => {
editor.dispatchCommand(INSERT_FROM_TENOR_COMMAND, image);
editor.dispatchCommand(INSERT_FROM_GIF_COMMAND, image);
};

return (
<TenorSelector
<GifSelector
provider={providerConfig?.provider}
onClickOutside={onClickOutside}
onGifInsert={insertImageToNode}
{...tenorHook}
{...gifHook}
/>
);
};

export default TenorPlugin;
export default GifPlugin;
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React, {useEffect, useRef, useState} from 'react';
import SearchIcon from '../../assets/icons/kg-search.svg?react';
import {Error} from './file-selectors/Tenor/Error';
import {Gif} from './file-selectors/Tenor/Gif';
import {Loader} from './file-selectors/Tenor/Loader';
import {Error} from './file-selectors/Gif/Error';
import {Gif} from './file-selectors/Gif/Gif';
import {Loader} from './file-selectors/Gif/Loader';

// number of columns based on selector container width
const TWO_COLUMN_WIDTH = 540;
const THREE_COLUMN_WIDTH = 940;

const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLoading, isLazyLoading, error, changeColumnCount, loadNextPage, gifs}) => {
const GifSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLoading, isLazyLoading, error, changeColumnCount, loadNextPage, gifs, provider}) => {
const selectorRef = useRef(null);
const searchRef = useRef(null);
const [highlightedGif, setHighlightedGif] = useState(undefined);
Expand Down Expand Up @@ -138,7 +138,7 @@ const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLo
}

function moveToNextHorizontalGif(direction) {
const highlightedElem = document.querySelector(`[data-tenor-index="${highlightedGif.index}"]`);
const highlightedElem = document.querySelector(`[data-gif-index="${highlightedGif.index}"]`);
const highlightedElemRect = highlightedElem.getBoundingClientRect();

let x;
Expand All @@ -155,9 +155,9 @@ const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLo

// we might hit spacing between gifs, keep moving up 5 px until we get a match
while (!foundGifElem) {
let possibleMatch = document.elementFromPoint(x, y)?.closest('[data-tenor-index]');
let possibleMatch = document.elementFromPoint(x, y)?.closest('[data-gif-index]');

if (possibleMatch?.dataset.tenorIndex !== undefined) {
if (possibleMatch?.dataset.gifIndex !== undefined) {
foundGifElem = possibleMatch;
break;
}
Expand All @@ -172,7 +172,7 @@ const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLo
}

if (foundGifElem) {
setHighlightedGif(gifs[foundGifElem.dataset.tenorIndex]);
setHighlightedGif(gifs[foundGifElem.dataset.gifIndex]);
}
}

Expand Down Expand Up @@ -294,7 +294,7 @@ const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLo
<div
ref={selectorRef}
className="flex h-[540px] flex-col rounded border border-grey-200 bg-grey-50 dark:border-none dark:bg-grey-900"
data-testid="tenor-selector"
data-testid="gif-selector"
// prevent click handle in the editor while selector is active
onClick={e => e.stopPropagation()}
>
Expand All @@ -304,7 +304,7 @@ const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLo
<input
ref={searchRef}
className="h-10 w-full rounded-full border border-grey-300 pl-10 pr-8 font-sans text-md font-normal text-black focus:border-green focus:shadow-insetgreen dark:border-grey-800 dark:bg-grey-950 dark:text-white dark:placeholder:text-grey-800 dark:focus:border-green"
placeholder="Search Tenor for GIFs"
placeholder={provider === 'klipy' ? 'Search KLIPY' : 'Search Tenor for GIFs'}
autoFocus
onChange={handleSearch}
/>
Expand All @@ -330,11 +330,11 @@ const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLo

{!!isLoading && !error && <Loader isLazyLoading={isLazyLoading} />}

{!!error && <div data-testid="tenor-selector-error"><Error error={error} /></div>}
{!!error && <div data-testid="gif-selector-error"><Error error={error} /></div>}
</div>
</div>
</div>
);
};

export default TenorSelector;
export default GifSelector;
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import TenorSelector from './TenorSelector';
import {tenorConfig} from '../../../demo/utils/tenorConfig';
import {useTenor} from '../../utils/services/tenor.js';
import GifSelector from './GifSelector';
import {getGifProviderConfig, useGif} from '../../utils/services/gif.js';
import {tenorConfig} from '../../../demo/utils/gifConfig';

const story = {
title: 'File Selectors/Tenor',
component: TenorSelector,
title: 'File Selectors/Gif',
component: GifSelector,
parameters: {
status: {
type: 'Functional'
Expand All @@ -14,10 +14,10 @@ const story = {
export default story;

const Template = (args) => {
const tenorHook = useTenor({config: tenorConfig});
const gifHook = useGif({config: getGifProviderConfig({tenor: tenorConfig})});

return (
<TenorSelector {...tenorHook} {...args} />
<GifSelector {...gifHook} {...args} />
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {ERROR_TYPE} from '../../../../utils/services/gif.js';

export function Error({error}) {
if (error === ERROR_TYPE.COMMON) {
return (
<p>
Uh-oh! Trouble reaching the GIF service, please check your connection
</p>
);
}

if (error === ERROR_TYPE.INVALID_API_KEY) {
return (
<p>
The GIF API key is not valid. Please check your configuration by following our
<a href="https://ghost.org/docs/config/" rel="noopener noreferrer" target="_blank"> documentation here</a>.
</p>
);
}
return (
<p>{error}</p>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function Gif({gif, onClick, highlightedGif = {}}) {
<button
ref={gifRef}
className="cursor-pointer border-2 border-transparent focus:border-green-600"
data-tenor-index={gif.index}
data-gif-index={gif.index}
type="button"
onClick={handleClick}
>
Expand Down

This file was deleted.

8 changes: 4 additions & 4 deletions packages/koenig-lexical/src/nodes/ImageNode.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {$generateHtmlFromNodes} from '@lexical/html';
import {ImageNode as BaseImageNode} from '@tryghost/kg-default-nodes';
import {ImageNodeComponent} from './ImageNodeComponent';
import {KoenigCardWrapper, MINIMAL_NODES} from '../index.js';
import {OPEN_TENOR_SELECTOR_COMMAND, OPEN_UNSPLASH_SELECTOR_COMMAND} from '../plugins/KoenigSelectorPlugin.jsx';
import {OPEN_GIF_SELECTOR_COMMAND, OPEN_UNSPLASH_SELECTOR_COMMAND} from '../plugins/KoenigSelectorPlugin.jsx';
import {cleanBasicHtml} from '@tryghost/kg-clean-basic-html';
import {createCommand} from 'lexical';
import {populateNestedEditor, setupNestedEditor} from '../utils/nested-editors';
Expand Down Expand Up @@ -51,14 +51,14 @@ export class ImageNode extends BaseImageNode {
label: 'GIF',
desc: 'Search and embed gifs',
Icon: GIFIcon,
insertCommand: OPEN_TENOR_SELECTOR_COMMAND,
insertCommand: OPEN_GIF_SELECTOR_COMMAND,
insertParams: {
triggerFileDialog: false
},
matches: ['gif', 'giphy', 'tenor'],
matches: ['gif', 'giphy', 'tenor', 'klipy'],
priority: 17,
queryParams: ['src'],
isHidden: ({config}) => !config?.tenor,
isHidden: ({config}) => !config?.tenor && !config?.klipy,
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 25, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Hide GIF menu item when no valid provider key is configured.

Line 61 gates visibility by provider object presence, but provider resolution requires config.klipy.apiKey or config.tenor.googleApiKey. This can expose a GIF menu entry that opens with no usable provider config.

Suggested fix
-        isHidden: ({config}) => !config?.tenor && !config?.klipy,
+        isHidden: ({config}) => !(config?.klipy?.apiKey || config?.tenor?.googleApiKey),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
isHidden: ({config}) => !config?.tenor && !config?.klipy,
isHidden: ({config}) => !(config?.klipy?.apiKey || config?.tenor?.googleApiKey),
🤖 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/koenig-lexical/src/nodes/ImageNode.jsx` at line 61, Update the
visibility predicate for the GIF menu item so it checks actual provider API keys
instead of just provider objects: replace the isHidden function used in
ImageNode.jsx (isHidden: ({config}) => ...) to return true when neither
config.klipy.apiKey nor config.tenor.googleApiKey are present (i.e., hide when
both keys are missing) so the GIF menu is only shown when a usable provider key
exists.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a pre-existing pattern — the previous line isHidden: ({config}) => !config?.tenor already gated on object presence, not key presence, and this PR extends the same pattern to klipy.

In the real Ghost ↔ Koenig contract, the consumer nullifies the provider object when no key is set:

  • ghost/admin/app/components/koenig-lexical-editor.js: klipy: this.config.klipy?.apiKey ? this.config.klipy : null
  • apps/admin-x-settings/.../member-email-editor.tsx: const klipyConfig = config.klipy?.apiKey ? config.klipy : null;

(Tenor follows the same pattern, long-standing.) So in practice the object-presence check is equivalent to the key-presence check. The defensive-by-apiKey variant is a reasonable tightening for callers that don't follow the contract, but it's a behaviour change beyond the scope of this migration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@renatoworks, that's a clear and well-reasoned explanation — the contract-level nullification makes the two checks equivalent in practice, and my suggestion would have introduced an unnecessary scope creep in this migration PR. I'll withdraw it.


✏️ Learnings added
Learnt from: renatoworks
Repo: TryGhost/Koenig PR: 1932
File: packages/koenig-lexical/src/nodes/ImageNode.jsx:61-61
Timestamp: 2026-05-25T18:46:42.200Z
Learning: In the Ghost ↔ Koenig integration, GIF provider config objects (`config.tenor`, `config.klipy`) are nullified by consumers when no API key is set (e.g., `klipy: this.config.klipy?.apiKey ? this.config.klipy : null`). Therefore, gating on object presence (e.g., `!config?.tenor && !config?.klipy`) is equivalent to gating on key presence. This is a long-standing pattern and checking for specific key fields (e.g., `config?.klipy?.apiKey`) would be an unnecessary tightening that goes beyond the established contract. See `packages/koenig-lexical/src/nodes/ImageNode.jsx` `isHidden` predicate for the GIF menu item.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

shortcut: '/gif'
}];

Expand Down
2 changes: 1 addition & 1 deletion packages/koenig-lexical/src/plugins/AllDefaultPlugins.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const AllDefaultPlugins = () => {
{/* Koenig Plugins */}
<CardMenuPlugin />
<KoenigSnippetPlugin />
<KoenigSelectorPlugin /> {/* Tenor/Unsplash selectors */}
<KoenigSelectorPlugin /> {/* Gif/Unsplash selectors */}
<EmojiPickerPlugin />
<AtLinkPlugin />

Expand Down
Loading
Loading