Skip to content

feat: Add Klipy as a GIF provider alongside Tenor#1932

Merged
renatoworks merged 5 commits into
mainfrom
tenor-klipy-migration
Jun 2, 2026
Merged

feat: Add Klipy as a GIF provider alongside Tenor#1932
renatoworks merged 5 commits into
mainfrom
tenor-klipy-migration

Conversation

@renatoworks
Copy link
Copy Markdown
Contributor

@renatoworks renatoworks commented May 25, 2026

Describe the problem you are solving

This PR adds Klipy as a GIF provider in the editor's GIF card, alongside the existing Tenor provider. Google is shutting down the Tenor API on 2026-06-30, so a replacement is needed.

Klipy exposes a Tenor-compatible API: same /v2/search and /v2/featured endpoints and the same media_formats/results/next response shape, so one client serves either provider. The error response shape differs (Tenor returns {error:{message}}, Klipy returns {result:false, errors:{message:[...]}}), and that is handled in the error parser.

No breaking changes. Provider resolution prefers Klipy when a Klipy key is configured, otherwise it falls back to Tenor exactly as today. Deployments without a Klipy key behave identically to before. Tenor stays in place; removing it entirely is a separate later phase after the API shutdown.

This PR is paired with the Ghost-side config plumbing (TryGhost/Ghost#28109), but each PR is independently safe to merge: the two are backward-compatible in every direction. The Ghost catalog pin bump that lets Ghost actually consume this version is a separate, small dependency PR (Renovate's auto-PR, or a tiny manual PR), not coupled to either of these.

Changelog for devs

  • Added Klipy as a GIF provider, resolved via a new exported getGifProviderConfig(cardConfig) (Klipy preferred when configured, Tenor as fallback, null when neither is set)
  • Added exported extractErrorMessage and isInvalidKeyError pure functions so error parsing and invalid-key classification are unit-testable across both providers' verified wording
  • Changed media_filter from 'minimal' to 'tinygif,gif'
  • Renamed Tenor-specific files, components, commands and the data-tenor-index DOM attribute to Gif* / data-gif-index so the card is provider-agnostic
  • Kept Tenor-as-a-provider references (config.tenor, provider: 'tenor', Tenor-specific tests, the "Search Tenor for GIFs" placeholder) intentionally
  • Removed the hardcoded API_URL; the base URL and API key now come from card config
  • Updated the selector placeholder and error / docs-link copy to be provider-neutral
  • Fixed a pre-existing search-pagination bug: loadNextPage was passing the searchTerm useRef object instead of searchTerm.current, so page 2+ of a search sent q=[object Object] and returned junk results. Pre-existing in tenor.js, found during the rename.
  • Added unit tests for provider resolution, error parsing, and invalid-key detection
  • Added e2e coverage for the Klipy insert path
  • Documented VITE_KLIPY_API_KEY in the README
  • Wired VITE_KLIPY_API_KEY into the demo deployment workflow (.github/workflows/pages.yml) from a new KLIPY_API_KEY repo secret so the editor demo at koenig.ghost.org runs against Klipy
  • Documented both env vars (Klipy preferred, Tenor fallback) in CLAUDE.md

Changelog for customers

  • Added Klipy as a GIF provider for the editor's GIF card
  • Fixed GIF search loading unrelated results when scrolling for more

Notes

  • Future cleanup: once Tenor's API is dead (after the June 30 shutdown), a separate small PR will remove the Tenor branch in getGifProviderConfig, the Tenor-specific tests, the "Search Tenor for GIFs" placeholder, and the config.tenor gating. Until then, Tenor stays as the fallback so anyone without a Klipy key keeps working.
  • The pagination fix was a pre-existing bug in tenor.js, silently broken in production for anyone scrolling past the first page of GIF search results. It is included here as a small drive-by because the file was being renamed and touched anyway; worth highlighting since it would otherwise be invisible inside the bigger feature commit when this PR is squash-merged.
  • Klipy's invalid-key behaviour was verified against the live API (HTTP 404 + {"result":false,"errors":{"message":["The provided API key is invalid: ..."]}}); the regex in isInvalidKeyError matches that wording, so a misconfigured Klipy key surfaces the correct INVALID_API_KEY UI rather than a generic "connection" error.
  • The cross-repo end-to-end flow (Ghost config -> Koenig editor) was rehearsed locally via a pnpm.overrides link: setup. All four scenarios were verified manually in the local Ghost editor: Klipy provider, Tenor provider, Tenor fallback when no Klipy key is set, and no GIF card when neither is configured.
  • Reviewer can reproduce locally by setting VITE_KLIPY_API_KEY in packages/koenig-lexical/.env.local and running yarn dev.

Technical debt

  • checkStatus reads response.headers.map['content-type'] rather than the standard response.headers.get('content-type'). This relies on whatwg-fetch polyfill internals and would throw on a native Response. Pre-existing, not introduced by this PR, kept for a 1:1 migration; worth addressing separately.

PR Scope (mark all that apply)

  • Improves the user experience
  • Enhances the readability of our code
  • Simplifies the maintenance of our software
  • Fixes a bug
  • Provide a core layer to allow one of the points above

Checklist before requesting a review (mark all that apply)

  • I have performed a self-review of my code
  • It's simple enough
  • The PR is not extensive
  • It includes documentation
  • I have added thorough tests.
  • Do we need to implement analytics?

Merge instructions

  • Requires approval from Renato
  • Use squash and merge

ref https://linear.app/ghost/project/replace-tenor-with-klipy-for-gif-cards-c48db5d2a3d5/overview

- Google is shutting down the Tenor API on 2026-06-30, so the GIF card needs a replacement provider
- makes the GIF client provider-aware: host and API key now come from card config via getGifProviderConfig(), resolving Klipy when configured and falling back to Tenor
- Klipy exposes a Tenor-compatible search API, so the success path is unchanged; extractErrorMessage() handles Klipy's different error shape
- fixes media_filter to request tinygif,gif explicitly
- updates the selector placeholder and error copy to be provider-neutral
- adds unit tests and e2e coverage for the Klipy path
ref https://linear.app/ghost/project/replace-tenor-with-klipy-for-gif-cards-c48db5d2a3d5/overview

- the editor GIF card was named after Tenor throughout, but it now supports multiple providers (Tenor and Klipy)
- renamed the files, components, commands and DOM attribute from Tenor* to Gif*: tenor.js -> gif.js, TenorPlugin -> GifPlugin, TenorSelector -> GifSelector, useTenor -> useGif, the OPEN/INSERT_FROM_TENOR commands -> _GIF, data-tenor-index -> data-gif-index
- pure rename: Tenor-the-provider references (config.tenor, provider: 'tenor', the Tenor-specific tests) are unchanged
ref https://linear.app/ghost/project/replace-tenor-with-klipy-for-gif-cards-c48db5d2a3d5/overview

- loadNextPage passed the searchTerm useRef object instead of searchTerm.current, so page 2+ of a GIF search sent q=[object Object] and returned junk results
- pre-existing bug carried over from the original tenor.js, surfaced during PR review
- first-page search and trending pagination were unaffected
ref https://linear.app/ghost/project/replace-tenor-with-klipy-for-gif-cards-c48db5d2a3d5/overview

- extracted invalid-key detection into an exported isInvalidKeyError() function, consistent with the already-extracted extractErrorMessage(), so the classification is unit-testable
- added tests for invalid-key detection across both providers' verified wording, a non-array Klipy error message, and provider fallback when the Klipy config has no key
- no behaviour change; the classification logic is identical
Injects VITE_KLIPY_API_KEY into the demo build from the new
KLIPY_API_KEY repo secret so the deployed editor at koenig.ghost.org
uses Klipy as the GIF provider, with Tenor as the fallback.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 25, 2026

Review Change Stack

Walkthrough

This PR extends the Koenig editor's GIF card to support a new Klipy GIF provider alongside the existing Tenor provider. The refactoring consolidates provider-specific logic into reusable utilities, updates the GIF service layer to dynamically select between Klipy and Tenor based on configuration, renames and extends the selector component to be provider-agnostic, updates plugin commands and menu integration to reference the new GIF abstractions, and adds comprehensive test coverage for both providers. The component hierarchy remains unchanged; only the underlying provider selection and API integration have been generalized.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% 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 'feat: Add Klipy as a GIF provider alongside Tenor' accurately and clearly summarizes the main change: adding Klipy as a new GIF provider while maintaining Tenor support.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the problem (Tenor API shutdown), solution (adding Klipy), implementation details, testing, and deployment changes.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tenor-klipy-migration

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@CLAUDE.md`:
- Around line 83-84: Update the environment variable descriptions for
VITE_KLIPY_API_KEY and VITE_TENOR_API_KEY to use the acronym “GIF” (uppercase)
instead of “Gif”; locate the lines containing the text for `VITE_KLIPY_API_KEY`
and `VITE_TENOR_API_KEY` in the CLAUDE.md content and replace “Gif card
functionality” with “GIF card functionality” (and similarly update the
parenthetical notes such as “fallback when Klipy key is not set”) to maintain
consistent uppercase acronym usage.

In `@packages/koenig-lexical/README.md`:
- Around line 36-40: The fenced code block containing the environment variables
(the block with VITE_KLIPY_API_KEY and VITE_TENOR_API_KEY) is missing a language
tag which triggers markdownlint MD040; update that fenced block to include a
language tag (for example "bash") right after the opening backticks so the block
becomes ```bash ... ``` to satisfy the linter.

In `@packages/koenig-lexical/src/nodes/ImageNode.jsx`:
- 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.

In `@packages/koenig-lexical/src/utils/services/gif.js`:
- Around line 214-216: In checkStatus, don't access response.headers.map
(Headers is a Headers object) — instead read the content type via
response.headers.get('content-type') (guarding for null/undefined and
normalizing case) and use that value for checks; e.g., set const contentType =
(response.headers.get('content-type') || '').toLowerCase() and replace uses of
response.headers.map['content-type'] with contentType, then call
.startsWith('application/json') or compare to 'text/xml' as before so error
parsing (response.json(), response.text(), etc.) works reliably.
🪄 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: 93f1dcf7-4249-4621-9ddb-2e16ca0fc6df

📥 Commits

Reviewing files that changed from the base of the PR and between 3b9200e and e2e016c.

📒 Files selected for processing (22)
  • .github/workflows/pages.yml
  • CLAUDE.md
  • packages/koenig-lexical/README.md
  • packages/koenig-lexical/demo/DemoApp.jsx
  • packages/koenig-lexical/demo/HtmlOutputDemo.jsx
  • packages/koenig-lexical/demo/RestrictedContentDemo.jsx
  • packages/koenig-lexical/demo/utils/gifConfig.js
  • packages/koenig-lexical/demo/utils/tenorConfig.js
  • packages/koenig-lexical/src/components/ui/GifPlugin.jsx
  • packages/koenig-lexical/src/components/ui/GifSelector.jsx
  • packages/koenig-lexical/src/components/ui/GifSelector.stories.jsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Gif/Error.jsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Gif/Gif.jsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Gif/Loader.jsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Tenor/Error.jsx
  • packages/koenig-lexical/src/nodes/ImageNode.jsx
  • packages/koenig-lexical/src/plugins/AllDefaultPlugins.jsx
  • packages/koenig-lexical/src/plugins/KoenigSelectorPlugin.jsx
  • packages/koenig-lexical/src/utils/buildCardMenu.js
  • packages/koenig-lexical/src/utils/services/gif.js
  • packages/koenig-lexical/test/e2e/cards/image-card.test.js
  • packages/koenig-lexical/test/unit/utils/gif-provider.test.js
💤 Files with no reviewable changes (2)
  • packages/koenig-lexical/src/components/ui/file-selectors/Tenor/Error.jsx
  • packages/koenig-lexical/demo/utils/tenorConfig.js

Comment thread CLAUDE.md
Comment thread packages/koenig-lexical/README.md
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.

Comment on lines 214 to 216
if (response.headers.map['content-type'].startsWith('application/json')) {
responseText = await response.json().then(json => json.error.message || json.error);
responseText = await response.json().then(json => extractErrorMessage(json));
} else if (response.headers.map['content-type'] === 'text/xml') {
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify content-type header access style in GIF service.
# Expected after fix:
# 1) No matches for `headers.map[...]`
# 2) At least one match for `headers.get('content-type')`

rg -n "response\.headers\.map\['content-type'\]" packages/koenig-lexical/src/utils/services/gif.js
rg -n "response\.headers\.get\('content-type'\)" packages/koenig-lexical/src/utils/services/gif.js

Repository: TryGhost/Koenig

Length of output: 224


Fix Fetch Headers access in checkStatus

In packages/koenig-lexical/src/utils/services/gif.js, checkStatus reads response.headers.map['content-type'] (lines 214-216). Fetch response.headers is a Headers object and doesn’t expose .map; this can throw during non-2xx handling and prevent provider error parsing/classification.

Proposed fix
-        if (response.headers.map['content-type'].startsWith('application/json')) {
+        const contentType = response.headers.get('content-type') || '';
+        if (contentType.startsWith('application/json')) {
             responseText = await response.json().then(json => extractErrorMessage(json));
-        } else if (response.headers.map['content-type'] === 'text/xml') {
+        } else if (contentType.includes('text/xml')) {
             responseText = await response.text();
+        } else {
+            responseText = response.statusText || 'Request failed';
         }
📝 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
if (response.headers.map['content-type'].startsWith('application/json')) {
responseText = await response.json().then(json => json.error.message || json.error);
responseText = await response.json().then(json => extractErrorMessage(json));
} else if (response.headers.map['content-type'] === 'text/xml') {
const contentType = response.headers.get('content-type') || '';
if (contentType.startsWith('application/json')) {
responseText = await response.json().then(json => extractErrorMessage(json));
} else if (contentType.includes('text/xml')) {
responseText = await response.text();
} else {
responseText = response.statusText || 'Request failed';
}
🤖 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/utils/services/gif.js` around lines 214 - 216, In
checkStatus, don't access response.headers.map (Headers is a Headers object) —
instead read the content type via response.headers.get('content-type') (guarding
for null/undefined and normalizing case) and use that value for checks; e.g.,
set const contentType = (response.headers.get('content-type') ||
'').toLowerCase() and replace uses of response.headers.map['content-type'] with
contentType, then call .startsWith('application/json') or compare to 'text/xml'
as before so error parsing (response.json(), response.text(), etc.) works
reliably.

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.

Acknowledged. This is pre-existing tech debt called out in the PR description's "Technical debt" section, carried over verbatim from the old tenor.js. It works today because Ghost loads the whatwg-fetch polyfill (whose Headers implementation exposes .map), and has been working in production this way for years.

On a native Response the access would throw, but the catch in makeRequest would still surface an error to the user (just a generic "trouble reaching the service" rather than the specific API error). Not in scope for this 1:1 migration; flagging as something to revisit if Ghost's polyfill strategy changes.

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, understood — the context around the whatwg-fetch polyfill clarifies why this has been stable in production. The catch in makeRequest does provide a safe fallback either way.

Would you like me to open a follow-up GitHub issue to track this so it doesn't get lost when Ghost's polyfill strategy is eventually revisited?


✏️ Learnings added
Learnt from: renatoworks
Repo: TryGhost/Koenig PR: 1932
File: packages/koenig-lexical/src/utils/services/gif.js:214-216
Timestamp: 2026-05-25T18:49:00.766Z
Learning: In `packages/koenig-lexical/src/utils/services/gif.js`, the `checkStatus` function accesses `response.headers.map['content-type']` rather than `response.headers.get('content-type')`. This is pre-existing tech debt carried over verbatim from the old `tenor.js`. It works in the Ghost environment because Ghost loads the whatwg-fetch polyfill, whose `Headers` implementation exposes `.map`. On a native `Response` (Fetch API) this would throw, but the `catch` in `makeRequest` still surfaces a generic error to the user. This was intentionally left as-is in the Tenor→Klipy migration PR `#1932` and flagged for future cleanup if Ghost's polyfill strategy changes.

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.

Comment thread packages/koenig-lexical/src/utils/services/gif.js
@renatoworks renatoworks merged commit 62c29d2 into main Jun 2, 2026
3 checks passed
@renatoworks renatoworks deleted the tenor-klipy-migration branch June 2, 2026 12:49
renatoworks added a commit to TryGhost/Ghost that referenced this pull request Jun 2, 2026
ref https://linear.app/ghost/project/replace-tenor-with-klipy-for-gif-cards-c48db5d2a3d5/overview

Ghost-side config plumbing for Klipy as a GIF provider, ahead of the Tenor API shutdown on 2026-06-30.

Non-breaking: without a Klipy key configured, the editor's GIF card behaves identically to today (Tenor as before). Klipy is used once the `koenig-lexical` dependency is bumped to a version that supports it (a separate small follow-up, Renovate's auto-PR or a tiny manual bump).

* exposes a `klipy` config block (apiKey, contentFilter) through defaults, public-config and the config serializer, mirroring `tenor`
* forwards `klipy` into the editor card config in both the Ember post editor and the admin-x welcome-email editor
* adds the optional `klipy` field to the admin-x-framework `Config` type and the test fixture
* removes the orphaned ghost/admin/app/services/tenor.js (pre-Lexical GIF client, zero injection sites)
* regenerates the config API e2e snapshot to include the new `klipy` block

Paired with TryGhost/Koenig#1932.

Google has confirmed that media.tenor.com will continue serving existing asset URLs to third-party requests after the API shutdown, so existing embedded GIFs in published content remain safe; no Ghost(Pro) CDN re-host is required.
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