diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index dae7a43aa3..7fe56db63d 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 1b87f8bcba..9d339cd2dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) - `VITE_SENTRY_*`: Error tracking configuration ### External Dependencies diff --git a/packages/koenig-lexical/README.md b/packages/koenig-lexical/README.md index 8cd5523a74..ea9ae8c98d 100644 --- a/packages/koenig-lexical/README.md +++ b/packages/koenig-lexical/README.md @@ -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 ``` -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 diff --git a/packages/koenig-lexical/demo/DemoApp.jsx b/packages/koenig-lexical/demo/DemoApp.jsx index 97376bcf74..eb86e4502b 100644 --- a/packages/koenig-lexical/demo/DemoApp.jsx +++ b/packages/koenig-lexical/demo/DemoApp.jsx @@ -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'; @@ -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'} diff --git a/packages/koenig-lexical/demo/HtmlOutputDemo.jsx b/packages/koenig-lexical/demo/HtmlOutputDemo.jsx index 547f9998e3..bb65f9e9da 100644 --- a/packages/koenig-lexical/demo/HtmlOutputDemo.jsx +++ b/packages/koenig-lexical/demo/HtmlOutputDemo.jsx @@ -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() { diff --git a/packages/koenig-lexical/demo/RestrictedContentDemo.jsx b/packages/koenig-lexical/demo/RestrictedContentDemo.jsx index 6002645a4c..1c4b25cd9b 100644 --- a/packages/koenig-lexical/demo/RestrictedContentDemo.jsx +++ b/packages/koenig-lexical/demo/RestrictedContentDemo.jsx @@ -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() { diff --git a/packages/koenig-lexical/demo/utils/gifConfig.js b/packages/koenig-lexical/demo/utils/gifConfig.js new file mode 100644 index 0000000000..9991063a1c --- /dev/null +++ b/packages/koenig-lexical/demo/utils/gifConfig.js @@ -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; +} diff --git a/packages/koenig-lexical/demo/utils/tenorConfig.js b/packages/koenig-lexical/demo/utils/tenorConfig.js deleted file mode 100644 index f5b653b8a8..0000000000 --- a/packages/koenig-lexical/demo/utils/tenorConfig.js +++ /dev/null @@ -1,15 +0,0 @@ -import {isTestEnv} from '../../test/utils/isTestEnv'; - -export const tenorConfig = isTestEnv ? {googleApiKey: 'xxx'} : getTenorConfig(); - -function getTenorConfig() { - let config = null; - - if (import.meta.env.VITE_TENOR_API_KEY) { - config = { - googleApiKey: import.meta.env.VITE_TENOR_API_KEY - }; - } - - return config; -} diff --git a/packages/koenig-lexical/src/components/ui/TenorPlugin.jsx b/packages/koenig-lexical/src/components/ui/GifPlugin.jsx similarity index 68% rename from packages/koenig-lexical/src/components/ui/TenorPlugin.jsx rename to packages/koenig-lexical/src/components/ui/GifPlugin.jsx index d99033451b..0763ff3f27 100644 --- a/packages/koenig-lexical/src/components/ui/TenorPlugin.jsx +++ b/packages/koenig-lexical/src/components/ui/GifPlugin.jsx @@ -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(() => { @@ -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 ( - ); }; -export default TenorPlugin; +export default GifPlugin; diff --git a/packages/koenig-lexical/src/components/ui/TenorSelector.jsx b/packages/koenig-lexical/src/components/ui/GifSelector.jsx similarity index 92% rename from packages/koenig-lexical/src/components/ui/TenorSelector.jsx rename to packages/koenig-lexical/src/components/ui/GifSelector.jsx index 5a796ce3f9..ca24c08d11 100644 --- a/packages/koenig-lexical/src/components/ui/TenorSelector.jsx +++ b/packages/koenig-lexical/src/components/ui/GifSelector.jsx @@ -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); @@ -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; @@ -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; } @@ -172,7 +172,7 @@ const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLo } if (foundGifElem) { - setHighlightedGif(gifs[foundGifElem.dataset.tenorIndex]); + setHighlightedGif(gifs[foundGifElem.dataset.gifIndex]); } } @@ -294,7 +294,7 @@ const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLo
e.stopPropagation()} > @@ -304,7 +304,7 @@ const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLo @@ -330,11 +330,11 @@ const TenorSelector = ({onGifInsert, onClickOutside, updateSearch, columns, isLo {!!isLoading && !error && } - {!!error &&
} + {!!error &&
}
); }; -export default TenorSelector; +export default GifSelector; diff --git a/packages/koenig-lexical/src/components/ui/TenorSelector.stories.jsx b/packages/koenig-lexical/src/components/ui/GifSelector.stories.jsx similarity index 72% rename from packages/koenig-lexical/src/components/ui/TenorSelector.stories.jsx rename to packages/koenig-lexical/src/components/ui/GifSelector.stories.jsx index b9b35d67d4..d122d4a61b 100644 --- a/packages/koenig-lexical/src/components/ui/TenorSelector.stories.jsx +++ b/packages/koenig-lexical/src/components/ui/GifSelector.stories.jsx @@ -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' @@ -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 ( - + ); }; diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Error.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Error.jsx new file mode 100644 index 0000000000..b77144b271 --- /dev/null +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Error.jsx @@ -0,0 +1,23 @@ +import {ERROR_TYPE} from '../../../../utils/services/gif.js'; + +export function Error({error}) { + if (error === ERROR_TYPE.COMMON) { + return ( +

+ Uh-oh! Trouble reaching the GIF service, please check your connection +

+ ); + } + + if (error === ERROR_TYPE.INVALID_API_KEY) { + return ( +

+ The GIF API key is not valid. Please check your configuration by following our + documentation here. +

+ ); + } + return ( +

{error}

+ ); +} diff --git a/packages/koenig-lexical/src/components/ui/file-selectors/Tenor/Gif.jsx b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Gif.jsx similarity index 95% rename from packages/koenig-lexical/src/components/ui/file-selectors/Tenor/Gif.jsx rename to packages/koenig-lexical/src/components/ui/file-selectors/Gif/Gif.jsx index 2acf6ab4f4..19a3c8a814 100644 --- a/packages/koenig-lexical/src/components/ui/file-selectors/Tenor/Gif.jsx +++ b/packages/koenig-lexical/src/components/ui/file-selectors/Gif/Gif.jsx @@ -20,7 +20,7 @@ export function Gif({gif, onClick, highlightedGif = {}}) { + + +
+ + +


+ `, {ignoreCardToolbarContents: true}); + }); +}); diff --git a/packages/koenig-lexical/test/unit/utils/gif-provider.test.js b/packages/koenig-lexical/test/unit/utils/gif-provider.test.js new file mode 100644 index 0000000000..e8995ca5e9 --- /dev/null +++ b/packages/koenig-lexical/test/unit/utils/gif-provider.test.js @@ -0,0 +1,93 @@ +import {describe, expect, test} from 'vitest'; +import {extractErrorMessage, getGifProviderConfig, isInvalidKeyError} from '../../../src/utils/services/gif.js'; + +describe('Utils: getGifProviderConfig', () => { + test('returns null when neither provider is configured', () => { + expect(getGifProviderConfig(undefined)).toBeNull(); + expect(getGifProviderConfig({})).toBeNull(); + expect(getGifProviderConfig({tenor: null, klipy: null})).toBeNull(); + }); + + test('resolves Tenor when only Tenor is configured', () => { + const config = getGifProviderConfig({tenor: {googleApiKey: 'tenor-key'}}); + + expect(config).toEqual({ + provider: 'tenor', + apiUrl: 'https://tenor.googleapis.com', + apiKey: 'tenor-key', + contentFilter: 'off' + }); + }); + + test('resolves Klipy when only Klipy is configured', () => { + const config = getGifProviderConfig({klipy: {apiKey: 'klipy-key'}}); + + expect(config).toEqual({ + provider: 'klipy', + apiUrl: 'https://api.klipy.com', + apiKey: 'klipy-key', + contentFilter: 'off' + }); + }); + + test('prefers Klipy when both providers are configured', () => { + const config = getGifProviderConfig({ + tenor: {googleApiKey: 'tenor-key'}, + klipy: {apiKey: 'klipy-key'} + }); + + expect(config.provider).toEqual('klipy'); + expect(config.apiKey).toEqual('klipy-key'); + }); + + test('passes through a configured content filter', () => { + expect(getGifProviderConfig({tenor: {googleApiKey: 'k', contentFilter: 'high'}}).contentFilter).toEqual('high'); + expect(getGifProviderConfig({klipy: {apiKey: 'k', contentFilter: 'low'}}).contentFilter).toEqual('low'); + }); + + test('falls back to Tenor when the Klipy config is present but has no key', () => { + expect(getGifProviderConfig({klipy: {}, tenor: {googleApiKey: 'tenor-key'}}).provider).toEqual('tenor'); + expect(getGifProviderConfig({klipy: {apiKey: ''}, tenor: {googleApiKey: 'tenor-key'}}).provider).toEqual('tenor'); + }); +}); + +describe('Utils: extractErrorMessage', () => { + test('reads the Tenor error shape', () => { + expect(extractErrorMessage({error: {message: 'API key not valid'}})).toEqual('API key not valid'); + }); + + test('reads a Tenor string error', () => { + expect(extractErrorMessage({error: 'Something went wrong'})).toEqual('Something went wrong'); + }); + + test('reads the Klipy error shape', () => { + expect(extractErrorMessage({result: false, errors: {message: ['The provided API key is invalid']}})).toEqual('The provided API key is invalid'); + }); + + test('reads a Klipy error message that is not wrapped in an array', () => { + expect(extractErrorMessage({result: false, errors: {message: 'Rate limit exceeded'}})).toEqual('Rate limit exceeded'); + }); + + test('falls back to a generic message when the shape is unknown', () => { + expect(extractErrorMessage({})).toEqual('Unknown error'); + }); +}); + +describe('Utils: isInvalidKeyError', () => { + test('detects the Tenor invalid-key wording', () => { + expect(isInvalidKeyError('API key not valid')).toBe(true); + }); + + test('detects the Klipy invalid-key wording', () => { + expect(isInvalidKeyError('The provided API key is invalid: [xxx]')).toBe(true); + }); + + test('returns false for a non-key error', () => { + expect(isInvalidKeyError('Trouble reaching the GIF service')).toBe(false); + }); + + test('returns false for an empty or missing message', () => { + expect(isInvalidKeyError('')).toBe(false); + expect(isInvalidKeyError(undefined)).toBe(false); + }); +});