diff --git a/packages/kg-default-nodes/eslint.config.mjs b/packages/kg-default-nodes/eslint.config.mjs index 9a583ec14c..e7a814e84d 100644 --- a/packages/kg-default-nodes/eslint.config.mjs +++ b/packages/kg-default-nodes/eslint.config.mjs @@ -1,45 +1,41 @@ -import {fixupPluginRules} from '@eslint/compat'; import eslint from '@eslint/js'; +import {defineConfig} from 'eslint/config'; import ghostPlugin from 'eslint-plugin-ghost'; -import globals from 'globals'; +import tseslint from 'typescript-eslint'; -const ghost = fixupPluginRules(ghostPlugin); - -export default [ - {ignores: ['build/**', 'cjs/**', 'es/**']}, - eslint.configs.recommended, - { - files: ['**/*.{js,mjs}'], - plugins: {ghost}, - languageOptions: { - globals: { - ...globals.node, - ...globals.browser - } - }, - rules: { - ...ghostPlugin.configs.node.rules, - // match ESLint 8 behavior for catch clause variables - 'no-unused-vars': ['error', {caughtErrors: 'none'}], - // disable rules incompatible with ESLint 9 flat config - 'ghost/filenames/match-exported-class': 'off', - 'ghost/filenames/match-exported': 'off', - 'ghost/filenames/match-regex': 'off' - } +export default defineConfig([ + { ignores: ['build/**'] }, + { + files: ['**/*.ts'], + extends: [ + eslint.configs.recommended, + tseslint.configs.recommended, + ], + languageOptions: { + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, + }, + plugins: { ghost: ghostPlugin }, + rules: { + ...ghostPlugin.configs.ts.rules, + '@typescript-eslint/no-explicit-any': 'error', + }, + }, + { + files: ['src/**/*.ts'], + rules: { + '@typescript-eslint/no-unsafe-declaration-merging': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + }, + }, + { + files: ['test/**/*.ts'], + rules: { + ...ghostPlugin.configs['ts-test'].rules, + 'ghost/mocha/no-global-tests': 'off', + 'ghost/mocha/handle-done-callback': 'off', + 'ghost/mocha/no-mocha-arrows': 'off', + 'ghost/mocha/max-top-level-suites': 'off', + 'ghost/mocha/no-setup-in-describe': 'off', }, - { - files: ['test/**/*.{js,mjs}'], - plugins: {ghost}, - languageOptions: { - globals: { - ...globals.node, - ...globals.mocha, - should: true, - sinon: true - } - }, - rules: { - ...ghostPlugin.configs.test.rules - } - } -]; + }, +]); diff --git a/packages/kg-default-nodes/index.js b/packages/kg-default-nodes/index.js deleted file mode 100644 index de6039568e..0000000000 --- a/packages/kg-default-nodes/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './lib/kg-default-nodes'; diff --git a/packages/kg-default-nodes/lib/kg-default-nodes.js b/packages/kg-default-nodes/lib/kg-default-nodes.js deleted file mode 100644 index b503a98310..0000000000 --- a/packages/kg-default-nodes/lib/kg-default-nodes.js +++ /dev/null @@ -1,127 +0,0 @@ -import * as image from './nodes/image/ImageNode'; -import * as codeblock from './nodes/codeblock/CodeBlockNode'; -import * as markdown from './nodes/markdown/MarkdownNode'; -import * as video from './nodes/video/VideoNode'; -import * as audio from './nodes/audio/AudioNode'; -import * as callout from './nodes/callout/CalloutNode'; -import * as callToAction from './nodes/call-to-action/CallToActionNode'; -import * as aside from './nodes/aside/AsideNode'; -import * as horizontalrule from './nodes/horizontalrule/HorizontalRuleNode'; -import * as html from './nodes/html/HtmlNode'; -import * as toggle from './nodes/toggle/ToggleNode'; -import * as button from './nodes/button/ButtonNode'; -import * as bookmark from './nodes/bookmark/BookmarkNode'; -import * as file from './nodes/file/FileNode'; -import * as header from './nodes/header/HeaderNode'; -import * as paywall from './nodes/paywall/PaywallNode'; -import * as product from './nodes/product/ProductNode'; -import * as embed from './nodes/embed/EmbedNode'; -import * as email from './nodes/email/EmailNode'; -import * as gallery from './nodes/gallery/GalleryNode'; -import * as emailCta from './nodes/email-cta/EmailCtaNode'; -import * as signup from './nodes/signup/SignupNode'; -import * as transistor from './nodes/transistor/TransistorNode'; -import * as textnode from './nodes/ExtendedTextNode'; -import * as headingnode from './nodes/ExtendedHeadingNode'; -import * as quotenode from './nodes/ExtendedQuoteNode'; -import * as tk from './nodes/TKNode'; -import * as atLink from './nodes/at-link/index.js'; -import * as zwnj from './nodes/zwnj/ZWNJNode.js'; - -import linebreakSerializers from './serializers/linebreak'; -import paragraphSerializers from './serializers/paragraph'; - -// re-export everything for easier importing -export * from './KoenigDecoratorNode'; -export * from './nodes/image/ImageNode'; -export * from './nodes/codeblock/CodeBlockNode'; -export * from './nodes/markdown/MarkdownNode'; -export * from './nodes/video/VideoNode'; -export * from './nodes/audio/AudioNode'; -export * from './nodes/callout/CalloutNode'; -export * from './nodes/aside/AsideNode'; -export * from './nodes/horizontalrule/HorizontalRuleNode'; -export * from './nodes/html/HtmlNode'; -export * from './nodes/toggle/ToggleNode'; -export * from './nodes/button/ButtonNode'; -export * from './nodes/bookmark/BookmarkNode'; -export * from './nodes/file/FileNode'; -export * from './nodes/header/HeaderNode'; -export * from './nodes/paywall/PaywallNode'; -export * from './nodes/product/ProductNode'; -export * from './nodes/embed/EmbedNode'; -export * from './nodes/email/EmailNode'; -export * from './nodes/gallery/GalleryNode'; -export * from './nodes/email-cta/EmailCtaNode'; -export * from './nodes/signup/SignupNode'; -export * from './nodes/transistor/TransistorNode'; -export * from './nodes/call-to-action/CallToActionNode'; -export * from './nodes/ExtendedTextNode'; -export * from './nodes/ExtendedHeadingNode'; -export * from './nodes/ExtendedQuoteNode'; -export * from './nodes/TKNode'; -export * from './nodes/at-link/index.js'; -export * from './nodes/zwnj/ZWNJNode'; - -// export utility functions that are useful in other packages or tests -import * as visibilityUtils from './utils/visibility'; -import * as taggedTemplateFns from './utils/tagged-template-fns.mjs'; -import {generateDecoratorNode} from './generate-decorator-node.js'; -import {rgbToHex} from './utils/rgb-to-hex.js'; -export const utils = { - generateDecoratorNode, - visibility: visibilityUtils, - rgbToHex, - taggedTemplateFns -}; - -export const serializers = { - linebreak: linebreakSerializers, - paragraph: paragraphSerializers -}; - -export const DEFAULT_CONFIG = { - html: { - import: { - ...serializers.linebreak.import, - ...serializers.paragraph.import - } - } -}; - -// export convenience objects for use elsewhere -export const DEFAULT_NODES = [ - textnode.ExtendedTextNode, - textnode.extendedTextNodeReplacement, - headingnode.ExtendedHeadingNode, - headingnode.extendedHeadingNodeReplacement, - quotenode.ExtendedQuoteNode, - quotenode.extendedQuoteNodeReplacement, - codeblock.CodeBlockNode, - image.ImageNode, - markdown.MarkdownNode, - video.VideoNode, - audio.AudioNode, - callout.CalloutNode, - callToAction.CallToActionNode, - aside.AsideNode, - horizontalrule.HorizontalRuleNode, - html.HtmlNode, - file.FileNode, - toggle.ToggleNode, - button.ButtonNode, - header.HeaderNode, - bookmark.BookmarkNode, - paywall.PaywallNode, - product.ProductNode, - embed.EmbedNode, - email.EmailNode, - gallery.GalleryNode, - emailCta.EmailCtaNode, - signup.SignupNode, - transistor.TransistorNode, - tk.TKNode, - atLink.AtLinkNode, - atLink.AtLinkSearchNode, - zwnj.ZWNJNode -]; diff --git a/packages/kg-default-nodes/lib/nodes/at-link/index.js b/packages/kg-default-nodes/lib/nodes/at-link/index.js deleted file mode 100644 index e369eae5c5..0000000000 --- a/packages/kg-default-nodes/lib/nodes/at-link/index.js +++ /dev/null @@ -1,4 +0,0 @@ -/* c8 ignore start */ -export * from './AtLinkNode'; -export * from './AtLinkSearchNode'; -/* c8 ignore end */ diff --git a/packages/kg-default-nodes/lib/nodes/audio/AudioNode.js b/packages/kg-default-nodes/lib/nodes/audio/AudioNode.js deleted file mode 100644 index 94c6cd2a0a..0000000000 --- a/packages/kg-default-nodes/lib/nodes/audio/AudioNode.js +++ /dev/null @@ -1,27 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {parseAudioNode} from './audio-parser'; -import {renderAudioNode} from './audio-renderer'; - -export class AudioNode extends generateDecoratorNode({ - nodeType: 'audio', - properties: [ - {name: 'duration', default: 0}, - {name: 'mimeType', default: ''}, - {name: 'src', default: '', urlType: 'url'}, - {name: 'title', default: ''}, - {name: 'thumbnailSrc', default: ''} - ], - defaultRenderFn: renderAudioNode -}) { - static importDOM() { - return parseAudioNode(this); - } -} - -export const $createAudioNode = (dataset) => { - return new AudioNode(dataset); -}; - -export function $isAudioNode(node) { - return node instanceof AudioNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/bookmark/BookmarkNode.js b/packages/kg-default-nodes/lib/nodes/bookmark/BookmarkNode.js deleted file mode 100644 index 122aed6a09..0000000000 --- a/packages/kg-default-nodes/lib/nodes/bookmark/BookmarkNode.js +++ /dev/null @@ -1,94 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {parseBookmarkNode} from './bookmark-parser'; -import {renderBookmarkNode} from './bookmark-renderer'; - -export class BookmarkNode extends generateDecoratorNode({ - nodeType: 'bookmark', - properties: [ - {name: 'title', default: '', wordCount: true}, - {name: 'description', default: '', wordCount: true}, - {name: 'url', default: '', urlType: 'url', wordCount: true}, - {name: 'caption', default: '', wordCount: true}, - {name: 'author', default: ''}, - {name: 'publisher', default: ''}, - {name: 'icon', urlPath: 'metadata.icon', default: '', urlType: 'url'}, - {name: 'thumbnail', urlPath: 'metadata.thumbnail', default: '', urlType: 'url'} - ], - defaultRenderFn: renderBookmarkNode -}) { - static importDOM() { - return parseBookmarkNode(this); - } - - /* override */ - constructor({url, metadata, caption} = {}, key) { - super(key); - this.__url = url || ''; - this.__icon = metadata?.icon || ''; - this.__title = metadata?.title || ''; - this.__description = metadata?.description || ''; - this.__author = metadata?.author || ''; - this.__publisher = metadata?.publisher || ''; - this.__thumbnail = metadata?.thumbnail || ''; - this.__caption = caption || ''; - } - - /* @override */ - getDataset() { - const self = this.getLatest(); - return { - url: self.__url, - metadata: { - icon: self.__icon, - title: self.__title, - description: self.__description, - author: self.__author, - publisher: self.__publisher, - thumbnail: self.__thumbnail - }, - caption: self.__caption - }; - } - - /* @override */ - static importJSON(serializedNode) { - const {url, metadata, caption} = serializedNode; - const node = new this({ - url, - metadata, - caption - }); - return node; - } - - /* @override */ - exportJSON() { - const dataset = { - type: 'bookmark', - version: 1, - url: this.url, - metadata: { - icon: this.icon, - title: this.title, - description: this.description, - author: this.author, - publisher: this.publisher, - thumbnail: this.thumbnail - }, - caption: this.caption - }; - return dataset; - } - - isEmpty() { - return !this.url; - } -} - -export const $createBookmarkNode = (dataset) => { - return new BookmarkNode(dataset); -}; - -export function $isBookmarkNode(node) { - return node instanceof BookmarkNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/button/ButtonNode.js b/packages/kg-default-nodes/lib/nodes/button/ButtonNode.js deleted file mode 100644 index 5b1c3adb2d..0000000000 --- a/packages/kg-default-nodes/lib/nodes/button/ButtonNode.js +++ /dev/null @@ -1,25 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {parseButtonNode} from './button-parser'; -import {renderButtonNode} from './button-renderer'; - -export class ButtonNode extends generateDecoratorNode({ - nodeType: 'button', - properties: [ - {name: 'buttonText', default: ''}, - {name: 'alignment', default: 'center'}, - {name: 'buttonUrl', default: '', urlType: 'url'} - ], - defaultRenderFn: renderButtonNode -}) { - static importDOM() { - return parseButtonNode(this); - } -} - -export const $createButtonNode = (dataset) => { - return new ButtonNode(dataset); -}; - -export function $isButtonNode(node) { - return node instanceof ButtonNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/call-to-action/CallToActionNode.js b/packages/kg-default-nodes/lib/nodes/call-to-action/CallToActionNode.js deleted file mode 100644 index 335527d0b6..0000000000 --- a/packages/kg-default-nodes/lib/nodes/call-to-action/CallToActionNode.js +++ /dev/null @@ -1,39 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {renderCallToActionNode} from './calltoaction-renderer'; -import {parseCallToActionNode} from './calltoaction-parser'; - -export class CallToActionNode extends generateDecoratorNode({ - nodeType: 'call-to-action', - hasVisibility: true, - properties: [ - {name: 'layout', default: 'minimal'}, - {name: 'alignment', default: 'left'}, - {name: 'textValue', default: '', wordCount: true}, - {name: 'showButton', default: true}, - {name: 'showDividers', default: true}, - {name: 'buttonText', default: 'Learn more'}, - {name: 'buttonUrl', default: ''}, - {name: 'buttonColor', default: '#000000'}, // Where colour is customisable, we should use hex values - {name: 'buttonTextColor', default: '#ffffff'}, - {name: 'hasSponsorLabel', default: true}, - {name: 'sponsorLabel', default: '

SPONSORED

'}, - {name: 'backgroundColor', default: 'grey'}, // Since this is one of a few fixed options, we stick to colour names. - {name: 'linkColor', default: 'text'}, - {name: 'imageUrl', default: ''}, - {name: 'imageWidth', default: null}, - {name: 'imageHeight', default: null} - ], - defaultRenderFn: renderCallToActionNode -}) { - static importDOM() { - return parseCallToActionNode(this); - } -} - -export const $createCallToActionNode = (dataset) => { - return new CallToActionNode(dataset); -}; - -export const $isCallToActionNode = (node) => { - return node instanceof CallToActionNode; -}; diff --git a/packages/kg-default-nodes/lib/nodes/callout/CalloutNode.js b/packages/kg-default-nodes/lib/nodes/callout/CalloutNode.js deleted file mode 100644 index 304329f9a1..0000000000 --- a/packages/kg-default-nodes/lib/nodes/callout/CalloutNode.js +++ /dev/null @@ -1,33 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {renderCalloutNode} from './callout-renderer'; -import {parseCalloutNode} from './callout-parser'; - -export class CalloutNode extends generateDecoratorNode({ - nodeType: 'callout', - properties: [ - {name: 'calloutText', default: '', wordCount: true}, - {name: 'calloutEmoji', default: '💡'}, - {name: 'backgroundColor', default: 'blue'} - ], - defaultRenderFn: renderCalloutNode -}) { - /* override */ - constructor({calloutText, calloutEmoji, backgroundColor} = {}, key) { - super(key); - this.__calloutText = calloutText || ''; - this.__calloutEmoji = calloutEmoji !== undefined ? calloutEmoji : '💡'; - this.__backgroundColor = backgroundColor || 'blue'; - } - - static importDOM() { - return parseCalloutNode(this); - } -} - -export function $isCalloutNode(node) { - return node instanceof CalloutNode; -} - -export const $createCalloutNode = (dataset) => { - return new CalloutNode(dataset); -}; diff --git a/packages/kg-default-nodes/lib/nodes/codeblock/CodeBlockNode.js b/packages/kg-default-nodes/lib/nodes/codeblock/CodeBlockNode.js deleted file mode 100644 index 650175af3f..0000000000 --- a/packages/kg-default-nodes/lib/nodes/codeblock/CodeBlockNode.js +++ /dev/null @@ -1,29 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {parseCodeBlockNode} from './codeblock-parser'; -import {renderCodeBlockNode} from './codeblock-renderer'; - -export class CodeBlockNode extends generateDecoratorNode({ - nodeType: 'codeblock', - properties: [ - {name: 'code', default: '', wordCount: true}, - {name: 'language', default: ''}, - {name: 'caption', default: '', urlType: 'html', wordCount: true} - ], - defaultRenderFn: renderCodeBlockNode -}) { - static importDOM() { - return parseCodeBlockNode(this); - } - - isEmpty() { - return !this.__code; - } -} - -export function $createCodeBlockNode(dataset) { - return new CodeBlockNode(dataset); -} - -export function $isCodeBlockNode(node) { - return node instanceof CodeBlockNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/email-cta/EmailCtaNode.js b/packages/kg-default-nodes/lib/nodes/email-cta/EmailCtaNode.js deleted file mode 100644 index 6fd156c93d..0000000000 --- a/packages/kg-default-nodes/lib/nodes/email-cta/EmailCtaNode.js +++ /dev/null @@ -1,25 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {renderEmailCtaNode} from './email-cta-renderer'; - -export class EmailCtaNode extends generateDecoratorNode({ - nodeType: 'email-cta', - properties: [ - {name: 'alignment', default: 'left'}, - {name: 'buttonText', default: ''}, - {name: 'buttonUrl', default: '', urlType: 'url'}, - {name: 'html', default: '', urlType: 'html'}, - {name: 'segment', default: 'status:free'}, - {name: 'showButton', default: false}, - {name: 'showDividers', default: true} - ], - defaultRenderFn: renderEmailCtaNode -}) { -} - -export const $createEmailCtaNode = (dataset) => { - return new EmailCtaNode(dataset); -}; - -export function $isEmailCtaNode(node) { - return node instanceof EmailCtaNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/email/EmailNode.js b/packages/kg-default-nodes/lib/nodes/email/EmailNode.js deleted file mode 100644 index 550b67d28d..0000000000 --- a/packages/kg-default-nodes/lib/nodes/email/EmailNode.js +++ /dev/null @@ -1,19 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {renderEmailNode} from './email-renderer'; - -export class EmailNode extends generateDecoratorNode({ - nodeType: 'email', - properties: [ - {name: 'html', default: '', urlType: 'html'} - ], - defaultRenderFn: renderEmailNode -}) { -} - -export const $createEmailNode = (dataset) => { - return new EmailNode(dataset); -}; - -export function $isEmailNode(node) { - return node instanceof EmailNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/embed/EmbedNode.js b/packages/kg-default-nodes/lib/nodes/embed/EmbedNode.js deleted file mode 100644 index 87b96f1b66..0000000000 --- a/packages/kg-default-nodes/lib/nodes/embed/EmbedNode.js +++ /dev/null @@ -1,31 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {parseEmbedNode} from './embed-parser'; -import {renderEmbedNode} from './embed-renderer'; - -export class EmbedNode extends generateDecoratorNode({ - nodeType: 'embed', - properties: [ - {name: 'url', default: '', urlType: 'url'}, - {name: 'embedType', default: ''}, - {name: 'html', default: ''}, - {name: 'metadata', default: {}}, - {name: 'caption', default: '', wordCount: true} - ], - defaultRenderFn: renderEmbedNode -}) { - static importDOM() { - return parseEmbedNode(this); - } - - isEmpty() { - return !this.__url && !this.__html; - } -} - -export const $createEmbedNode = (dataset) => { - return new EmbedNode(dataset); -}; - -export function $isEmbedNode(node) { - return node instanceof EmbedNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/file/FileNode.js b/packages/kg-default-nodes/lib/nodes/file/FileNode.js deleted file mode 100644 index a0a9800c2b..0000000000 --- a/packages/kg-default-nodes/lib/nodes/file/FileNode.js +++ /dev/null @@ -1,47 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {renderFileNode} from './file-renderer'; -import {parseFileNode} from './file-parser'; -import {bytesToSize} from '../../utils/size-byte-converter'; - -export class FileNode extends generateDecoratorNode({ - nodeType: 'file', - properties: [ - {name: 'src', default: '', urlType: 'url'}, - {name: 'fileTitle', default: '', wordCount: true}, - {name: 'fileCaption', default: '', wordCount: true}, - {name: 'fileName', default: ''}, - {name: 'fileSize', default: ''} - ], - defaultRenderFn: renderFileNode -}) { - /* @override */ - exportJSON() { - const {src, fileTitle, fileCaption, fileName, fileSize} = this; - const isBlob = src && src.startsWith('data:'); - - return { - type: 'file', - src: isBlob ? '' : src, - fileTitle, - fileCaption, - fileName, - fileSize - }; - } - - static importDOM() { - return parseFileNode(this); - } - - get formattedFileSize() { - return bytesToSize(this.fileSize); - } -} - -export function $isFileNode(node) { - return node instanceof FileNode; -} - -export const $createFileNode = (dataset) => { - return new FileNode(dataset); -}; diff --git a/packages/kg-default-nodes/lib/nodes/gallery/GalleryNode.js b/packages/kg-default-nodes/lib/nodes/gallery/GalleryNode.js deleted file mode 100644 index cfef4fa4fe..0000000000 --- a/packages/kg-default-nodes/lib/nodes/gallery/GalleryNode.js +++ /dev/null @@ -1,38 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {parseGalleryNode} from './gallery-parser'; -import {renderGalleryNode} from './gallery-renderer'; -export class GalleryNode extends generateDecoratorNode({ - nodeType: 'gallery', - properties: [ - {name: 'images', default: []}, - {name: 'caption', default: '', wordCount: true} - ], - defaultRenderFn: renderGalleryNode -}) { - /* override */ - static get urlTransformMap() { - return { - caption: 'html', - images: { - src: 'url', - caption: 'html' - } - }; - } - - static importDOM() { - return parseGalleryNode(this); - } - - hasEditMode() { - return false; - } -} - -export const $createGalleryNode = (dataset) => { - return new GalleryNode(dataset); -}; - -export function $isGalleryNode(node) { - return node instanceof GalleryNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/header/HeaderNode.js b/packages/kg-default-nodes/lib/nodes/header/HeaderNode.js deleted file mode 100644 index 69cd10e29a..0000000000 --- a/packages/kg-default-nodes/lib/nodes/header/HeaderNode.js +++ /dev/null @@ -1,52 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {renderHeaderNodeV1} from './renderers/v1/header-renderer'; -import {parseHeaderNode} from './parsers/header-parser'; -// V2 imports below -import {renderHeaderNodeV2} from './renderers/v2/header-renderer'; - -// This is our first node that has a custom version property -export class HeaderNode extends generateDecoratorNode({ - nodeType: 'header', - properties: [ - {name: 'size', default: 'small'}, - {name: 'style', default: 'dark'}, - {name: 'buttonEnabled', default: false}, - {name: 'buttonUrl', default: '', urlType: 'url'}, - {name: 'buttonText', default: ''}, - {name: 'header', default: '', urlType: 'html', wordCount: true}, - {name: 'subheader', default: '', urlType: 'html', wordCount: true}, - {name: 'backgroundImageSrc', default: '', urlType: 'url'}, - // we need to initialize a new version property here so that we can separate v1 and v2 - // we should never remove old properties, only add new ones, as this could break & corrupt existing content - // ref https://lexical.dev/docs/concepts/serialization#versioning--breaking-changes - {name: 'version', default: 1}, - {name: 'accentColor', default: '#FF1A75'}, // this is used to have the accent color hex for email - // v2 properties - {name: 'alignment', default: 'center'}, - {name: 'backgroundColor', default: '#000000'}, - {name: 'backgroundImageWidth', default: null}, - {name: 'backgroundImageHeight', default: null}, - {name: 'backgroundSize', default: 'cover'}, - {name: 'textColor', default: '#FFFFFF'}, - {name: 'buttonColor', default: '#ffffff'}, - {name: 'buttonTextColor', default: '#000000'}, - {name: 'layout', default: 'full'}, // replaces size - {name: 'swapped', default: false} - ], - defaultRenderFn: { - 1: renderHeaderNodeV1, - 2: renderHeaderNodeV2 - } -}) { - static importDOM() { - return parseHeaderNode(this); - } -} - -export const $createHeaderNode = (dataset) => { - return new HeaderNode(dataset); -}; - -export function $isHeaderNode(node) { - return node instanceof HeaderNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/horizontalrule/horizontalrule-parser.js b/packages/kg-default-nodes/lib/nodes/horizontalrule/horizontalrule-parser.js deleted file mode 100644 index f5151bca69..0000000000 --- a/packages/kg-default-nodes/lib/nodes/horizontalrule/horizontalrule-parser.js +++ /dev/null @@ -1,11 +0,0 @@ -export function parseHorizontalRuleNode(HorizontalRuleNode) { - return { - hr: () => ({ - conversion() { - const node = new HorizontalRuleNode(); - return {node}; - }, - priority: 0 - }) - }; -} diff --git a/packages/kg-default-nodes/lib/nodes/horizontalrule/horizontalrule-renderer.js b/packages/kg-default-nodes/lib/nodes/horizontalrule/horizontalrule-renderer.js deleted file mode 100644 index 90ab2fcb53..0000000000 --- a/packages/kg-default-nodes/lib/nodes/horizontalrule/horizontalrule-renderer.js +++ /dev/null @@ -1,9 +0,0 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; - -export function renderHorizontalRuleNode(_, options = {}) { - addCreateDocumentOption(options); - const document = options.createDocument(); - - const element = document.createElement('hr'); - return {element}; -} \ No newline at end of file diff --git a/packages/kg-default-nodes/lib/nodes/html/HtmlNode.js b/packages/kg-default-nodes/lib/nodes/html/HtmlNode.js deleted file mode 100644 index e618160b48..0000000000 --- a/packages/kg-default-nodes/lib/nodes/html/HtmlNode.js +++ /dev/null @@ -1,28 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {renderHtmlNode} from './html-renderer'; -import {parseHtmlNode} from './html-parser'; - -export class HtmlNode extends generateDecoratorNode({ - nodeType: 'html', - hasVisibility: true, - properties: [ - {name: 'html', default: '', urlType: 'html', wordCount: true} - ], - defaultRenderFn: renderHtmlNode -}) { - static importDOM() { - return parseHtmlNode(this); - } - - isEmpty() { - return !this.__html; - } -} - -export function $createHtmlNode(dataset) { - return new HtmlNode(dataset); -} - -export function $isHtmlNode(node) { - return node instanceof HtmlNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/html/html-parser.js b/packages/kg-default-nodes/lib/nodes/html/html-parser.js deleted file mode 100644 index 03895400f7..0000000000 --- a/packages/kg-default-nodes/lib/nodes/html/html-parser.js +++ /dev/null @@ -1,47 +0,0 @@ -export function parseHtmlNode(HtmlNode) { - return { - '#comment': (nodeElem) => { - if (nodeElem.nodeType === 8 && nodeElem.nodeValue.trim().match(/^kg-card-begin:\s?html$/)) { - return { - conversion(domNode) { - let html = []; - let nextNode = domNode.nextSibling; - - while (nextNode && !isHtmlEndComment(nextNode)) { - let currentNode = nextNode; - html.push(currentNode.outerHTML); - nextNode = currentNode.nextSibling; - // remove nodes as we go so that they don't go through the parser - currentNode.remove(); - } - - let payload = {html: html.join('\n').trim()}; - const node = new HtmlNode(payload); - return {node}; - }, - priority: 0 - }; - } - - return null; - }, - table: (nodeElem) => { - if (nodeElem.nodeType === 1 && nodeElem.tagName === 'TABLE' && nodeElem.parentNode.tagName !== 'TABLE') { - return { - conversion(domNode) { - const payload = {html: domNode.outerHTML}; - const node = new HtmlNode(payload); - return {node}; - }, - priority: 0 - }; - } - - return null; - } - }; -} - -function isHtmlEndComment(node) { - return node && node.nodeType === 8 && node.nodeValue.trim().match(/^kg-card-end:\s?html$/); -} diff --git a/packages/kg-default-nodes/lib/nodes/html/html-renderer.js b/packages/kg-default-nodes/lib/nodes/html/html-renderer.js deleted file mode 100644 index cf507bf307..0000000000 --- a/packages/kg-default-nodes/lib/nodes/html/html-renderer.js +++ /dev/null @@ -1,27 +0,0 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {renderEmptyContainer} from '../../utils/render-empty-container'; -import {renderWithVisibility} from '../../utils/visibility'; - -export function renderHtmlNode(node, options = {}) { - addCreateDocumentOption(options); - const document = options.createDocument(); - - const html = node.html; - - if (!html) { - return renderEmptyContainer(document); - } - - const wrappedHtml = `\n\n${html}\n\n`; - - const textarea = document.createElement('textarea'); - textarea.value = wrappedHtml; - - if (node.visibility) { - const renderOutput = {element: textarea, type: 'value'}; - return renderWithVisibility(renderOutput, node.visibility, options); - } - - // `type: 'value'` will render the value of the textarea element - return {element: textarea, type: 'value'}; -} diff --git a/packages/kg-default-nodes/lib/nodes/image/ImageNode.js b/packages/kg-default-nodes/lib/nodes/image/ImageNode.js deleted file mode 100644 index 78dacba20a..0000000000 --- a/packages/kg-default-nodes/lib/nodes/image/ImageNode.js +++ /dev/null @@ -1,54 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {parseImageNode} from './image-parser'; -import {renderImageNode} from './image-renderer'; -export class ImageNode extends generateDecoratorNode({ - nodeType: 'image', - properties: [ - {name: 'src', default: '', urlType: 'url'}, - {name: 'caption', default: '', urlType: 'html', wordCount: true}, - {name: 'title', default: ''}, - {name: 'alt', default: ''}, - {name: 'cardWidth', default: 'regular'}, - {name: 'width', default: null}, - {name: 'height', default: null}, - {name: 'href', default: '', urlType: 'url'} - ], - defaultRenderFn: renderImageNode -}) { - /* @override */ - exportJSON() { - // checks if src is a data string - const {src, width, height, title, alt, caption, cardWidth, href} = this; - const isBlob = src && src.startsWith('data:'); - - const dataset = { - type: 'image', - version: 1, - src: isBlob ? '' : src, - width, - height, - title, - alt, - caption, - cardWidth, - href - }; - return dataset; - } - - static importDOM() { - return parseImageNode(this); - } - - hasEditMode() { - return false; - } -} - -export const $createImageNode = (dataset) => { - return new ImageNode(dataset); -}; - -export function $isImageNode(node) { - return node instanceof ImageNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/markdown/MarkdownNode.js b/packages/kg-default-nodes/lib/nodes/markdown/MarkdownNode.js deleted file mode 100644 index 1cbf460e72..0000000000 --- a/packages/kg-default-nodes/lib/nodes/markdown/MarkdownNode.js +++ /dev/null @@ -1,22 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {renderMarkdownNode} from './markdown-renderer'; - -export class MarkdownNode extends generateDecoratorNode({ - nodeType: 'markdown', - properties: [ - {name: 'markdown', default: '', urlType: 'markdown', wordCount: true} - ], - defaultRenderFn: renderMarkdownNode -}) { - isEmpty() { - return !this.__markdown; - } -} - -export function $createMarkdownNode(dataset) { - return new MarkdownNode(dataset); -} - -export function $isMarkdownNode(node) { - return node instanceof MarkdownNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/markdown/markdown-renderer.js b/packages/kg-default-nodes/lib/nodes/markdown/markdown-renderer.js deleted file mode 100644 index 3012d01b23..0000000000 --- a/packages/kg-default-nodes/lib/nodes/markdown/markdown-renderer.js +++ /dev/null @@ -1,16 +0,0 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {render} from '@tryghost/kg-markdown-html-renderer'; - -export function renderMarkdownNode(node, options = {}) { - addCreateDocumentOption(options); - const document = options.createDocument(); - - const html = render(node.markdown || '', options); - - const element = document.createElement('div'); - element.innerHTML = html; - - // `type: 'inner'` will render only the innerHTML of the element - // @see @tryghost/kg-lexical-html-renderer package - return {element, type: 'inner'}; -} diff --git a/packages/kg-default-nodes/lib/nodes/paywall/PaywallNode.js b/packages/kg-default-nodes/lib/nodes/paywall/PaywallNode.js deleted file mode 100644 index 9c61fd0af4..0000000000 --- a/packages/kg-default-nodes/lib/nodes/paywall/PaywallNode.js +++ /dev/null @@ -1,20 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {parsePaywallNode} from './paywall-parser'; -import {renderPaywallNode} from './paywall-renderer'; - -export class PaywallNode extends generateDecoratorNode({ - nodeType: 'paywall', - defaultRenderFn: renderPaywallNode -}) { - static importDOM() { - return parsePaywallNode(this); - } -} - -export const $createPaywallNode = (dataset) => { - return new PaywallNode(dataset); -}; - -export function $isPaywallNode(node) { - return node instanceof PaywallNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/paywall/paywall-parser.js b/packages/kg-default-nodes/lib/nodes/paywall/paywall-parser.js deleted file mode 100644 index 7b546fc258..0000000000 --- a/packages/kg-default-nodes/lib/nodes/paywall/paywall-parser.js +++ /dev/null @@ -1,16 +0,0 @@ -export function parsePaywallNode(PaywallNode) { - return { - '#comment': (nodeElem) => { - if (nodeElem.nodeType === 8 && nodeElem.nodeValue.trim() === 'members-only') { - return { - conversion() { - const node = new PaywallNode(); - return {node}; - }, - priority: 0 - }; - } - return null; - } - }; -} diff --git a/packages/kg-default-nodes/lib/nodes/paywall/paywall-renderer.js b/packages/kg-default-nodes/lib/nodes/paywall/paywall-renderer.js deleted file mode 100644 index 60f5d32e0d..0000000000 --- a/packages/kg-default-nodes/lib/nodes/paywall/paywall-renderer.js +++ /dev/null @@ -1,13 +0,0 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; - -export function renderPaywallNode(_, options = {}) { - addCreateDocumentOption(options); - const document = options.createDocument(); - const element = document.createElement('div'); - - element.innerHTML = ''; - - // `type: 'inner'` will render only the innerHTML of the element - // @see @tryghost/kg-lexical-html-renderer package - return {element, type: 'inner'}; -} diff --git a/packages/kg-default-nodes/lib/nodes/signup/SignupNode.js b/packages/kg-default-nodes/lib/nodes/signup/SignupNode.js deleted file mode 100644 index 011e479aa4..0000000000 --- a/packages/kg-default-nodes/lib/nodes/signup/SignupNode.js +++ /dev/null @@ -1,78 +0,0 @@ -import {signupParser} from './signup-parser'; -import {renderSignupCardToDOM} from './signup-renderer'; -import {generateDecoratorNode} from '../../generate-decorator-node'; - -export class SignupNode extends generateDecoratorNode({ - nodeType: 'signup', - properties: [ - {name: 'alignment', default: 'left'}, - {name: 'backgroundColor', default: '#F0F0F0'}, - {name: 'backgroundImageSrc', default: ''}, - {name: 'backgroundSize', default: 'cover'}, - {name: 'textColor', default: ''}, - {name: 'buttonColor', default: 'accent'}, - {name: 'buttonTextColor', default: '#FFFFFF'}, - {name: 'buttonText', default: 'Subscribe'}, - {name: 'disclaimer', default: '', wordCount: true}, - {name: 'header', default: '', wordCount: true}, - {name: 'labels', default: []}, - {name: 'layout', default: 'wide'}, - {name: 'subheader', default: '', wordCount: true}, - {name: 'successMessage', default: 'Email sent! Check your inbox to complete your signup.'}, - {name: 'swapped', default: false} - ], - defaultRenderFn: renderSignupCardToDOM -}) { - /* override */ - constructor({alignment, backgroundColor, backgroundImageSrc, backgroundSize, textColor, buttonColor, buttonTextColor, buttonText, disclaimer, header, labels, layout, subheader, successMessage, swapped} = {}, key) { - super(key); - this.__alignment = alignment || 'left'; - this.__backgroundColor = backgroundColor || '#F0F0F0'; - this.__backgroundImageSrc = backgroundImageSrc || ''; - this.__backgroundSize = backgroundSize || 'cover'; - this.__textColor = (backgroundColor === 'transparent' && (layout === 'split' || !backgroundImageSrc)) ? '' : textColor || '#000000'; // text color should inherit with a transparent bg color unless we're using an image for the background (which supercedes the bg color) - this.__buttonColor = buttonColor || 'accent'; - this.__buttonTextColor = buttonTextColor || '#FFFFFF'; - this.__buttonText = buttonText || 'Subscribe'; - this.__disclaimer = disclaimer || ''; - this.__header = header || ''; - this.__labels = labels || []; - this.__layout = layout || 'wide'; - this.__subheader = subheader || ''; - this.__successMessage = successMessage || 'Email sent! Check your inbox to complete your signup.'; - this.__swapped = swapped || false; - } - - static importDOM() { - return signupParser(this); - } - - // keeping some custom methods for labels as it requires some special handling - - setLabels(labels) { - if (!Array.isArray(labels) || !labels.every(item => typeof item === 'string')) { - throw new Error('Invalid argument: Expected an array of strings.'); // eslint-disable-line - } - - const writable = this.getWritable(); - writable.__labels = labels; - } - - addLabel(label) { - const writable = this.getWritable(); - writable.__labels.push(label); - } - - removeLabel(label) { - const writable = this.getWritable(); - writable.__labels = writable.__labels.filter(l => l !== label); - } -} - -export const $createSignupNode = (dataset) => { - return new SignupNode(dataset); -}; - -export function $isSignupNode(node) { - return node instanceof SignupNode; -} diff --git a/packages/kg-default-nodes/lib/nodes/toggle/ToggleNode.js b/packages/kg-default-nodes/lib/nodes/toggle/ToggleNode.js deleted file mode 100644 index 74236d361a..0000000000 --- a/packages/kg-default-nodes/lib/nodes/toggle/ToggleNode.js +++ /dev/null @@ -1,24 +0,0 @@ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {parseToggleNode} from './toggle-parser'; -import {renderToggleNode} from './toggle-renderer'; - -export class ToggleNode extends generateDecoratorNode({ - nodeType: 'toggle', - properties: [ - {name: 'heading', default: '', urlType: 'html', wordCount: true}, - {name: 'content', default: '', urlType: 'html', wordCount: true} - ], - defaultRenderFn: renderToggleNode -}) { - static importDOM() { - return parseToggleNode(this); - } -} - -export const $createToggleNode = (dataset) => { - return new ToggleNode(dataset); -}; - -export function $isToggleNode(node) { - return node instanceof ToggleNode; -} diff --git a/packages/kg-default-nodes/lib/utils/is-unsplash-image.js b/packages/kg-default-nodes/lib/utils/is-unsplash-image.js deleted file mode 100644 index 1f6e590e82..0000000000 --- a/packages/kg-default-nodes/lib/utils/is-unsplash-image.js +++ /dev/null @@ -1,3 +0,0 @@ -export const isUnsplashImage = function (url) { - return /images\.unsplash\.com/.test(url); -}; diff --git a/packages/kg-default-nodes/package.json b/packages/kg-default-nodes/package.json index 074460866e..0a3ef8244a 100644 --- a/packages/kg-default-nodes/package.json +++ b/packages/kg-default-nodes/package.json @@ -4,45 +4,65 @@ "repository": "https://github.com/TryGhost/Koenig/tree/main/packages/kg-default-nodes", "author": "Ghost Foundation", "license": "MIT", - "main": "cjs/kg-default-nodes.js", - "module": "es/kg-default-nodes.js", - "source": "lib/kg-default-nodes.js", + "engines": { + "node": "^22.13.1 || ^24.0.0" + }, + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/esm/index.d.ts", + "exports": { + ".": { + "types": "./build/esm/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" + }, + "./visibility": { + "types": "./build/esm/visibility.d.ts", + "import": "./build/esm/visibility.js", + "require": "./build/cjs/visibility.js" + } + }, "scripts": { - "dev": "rollup -c -w", - "build": "rollup -c", - "prepare": "NODE_ENV=production yarn build", - "pretest": "yarn build", - "test:unit": "NODE_ENV=testing c8 --src lib --all --check-coverage --reporter text --reporter cobertura mocha './test/**/*.test.*js'", + "dev": "tsc --watch --preserveWatchOutput", + "clean": "rm -rf build", + "build": "yarn clean && tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json", + "prepare": "yarn clean && tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json", + "pretest": "yarn clean && tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json && tsc -p tsconfig.test.json", + "test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --reporter text --reporter cobertura mocha --require tsx './test/**/*.test.ts'", "test": "yarn test:unit", - "test:no-coverage": "yarn pretest && mocha './test/**/*.test.*js'", - "lint": "eslint . --cache" + "lint:code": "eslint src/ --cache", + "lint": "yarn lint:code && yarn lint:test", + "lint:test": "eslint test/ --cache" }, "files": [ "LICENSE", "README.md", - "cjs/", - "es/", - "lib/" + "build" ], "publishConfig": { "access": "public" }, "devDependencies": { - "@babel/eslint-parser": "7.28.6", - "@babel/plugin-syntax-import-assertions": "7.28.6", - "@babel/preset-env": "7.29.2", + "@eslint/js": "9.39.4", "@lexical/headless": "0.13.1", "@lexical/html": "0.13.1", "@prettier/sync": "0.6.1", - "@rollup/plugin-babel": "7.0.0", + "@types/jsdom": "^21.1.7", + "@types/lodash": "^4.17.24", + "@types/luxon": "^3.6.2", + "@types/mocha": "10.0.10", + "@types/node": "24.12.0", + "@types/should": "^13.0.0", + "@types/sinon": "21.0.0", "c8": "11.0.0", "html-minifier": "4.0.0", "mocha": "11.7.5", "prettier": "3.8.2", - "rollup": "4.60.1", - "rollup-plugin-svg": "2.0.0", "should": "13.2.3", - "sinon": "21.1.2" + "sinon": "21.1.2", + "tsx": "4.21.0", + "typescript": "5.8.3", + "typescript-eslint": "8.57.0" }, "dependencies": { "@lexical/clipboard": "0.13.1", diff --git a/packages/kg-default-nodes/rollup.config.mjs b/packages/kg-default-nodes/rollup.config.mjs deleted file mode 100644 index 38ef3247b1..0000000000 --- a/packages/kg-default-nodes/rollup.config.mjs +++ /dev/null @@ -1,50 +0,0 @@ -/* eslint-env node */ -import pkg from './package.json' with { type: 'json' }; -import babel from '@rollup/plugin-babel'; -import svg from 'rollup-plugin-svg'; - -const dependencies = Object.keys(pkg.dependencies); - -export default [ - // Node build. - // No transpilation or bundling other than conversion from es modules to cjs - { - input: pkg.source, - output: { - file: pkg.main, - format: 'cjs', - sourcemap: true - }, - plugins: [svg()], - external: id => /lodash/.test(id) || dependencies.includes(id) - }, - - // ES module build - // Transpiles to target browser support for use in client apps - { - input: pkg.source, - output: { - file: pkg.module, - format: 'es', - sourcemap: true - }, - plugins: [ - svg(), - babel({ - babelHelpers: 'bundled', - presets: [ - ['@babel/preset-env', { - modules: false, - targets: [ - 'last 2 Chrome versions', - 'last 2 Firefox versions', - 'last 2 Safari versions' - ].join(', ') - }] - ], - exclude: ['node_modules/**', '../../node_modules/**'] - }) - ], - external: id => /lodash/.test(id) || dependencies.includes(id) - } -]; diff --git a/packages/kg-default-nodes/src/KoenigDecoratorNode.ts b/packages/kg-default-nodes/src/KoenigDecoratorNode.ts new file mode 100644 index 0000000000..3b42f6878b --- /dev/null +++ b/packages/kg-default-nodes/src/KoenigDecoratorNode.ts @@ -0,0 +1,17 @@ +/* c8 ignore start */ +import {DecoratorNode} from 'lexical'; + +export class KoenigDecoratorNode extends DecoratorNode { + static transform() { + return null; + } + + decorate(): unknown { + return null; + } +} + +export function $isKoenigCard(node: unknown): node is KoenigDecoratorNode { + return node instanceof KoenigDecoratorNode; +} +/* c8 ignore end */ diff --git a/packages/kg-default-nodes/src/export-dom.ts b/packages/kg-default-nodes/src/export-dom.ts new file mode 100644 index 0000000000..1785e22711 --- /dev/null +++ b/packages/kg-default-nodes/src/export-dom.ts @@ -0,0 +1,40 @@ +export type ExportDOMOutputType = 'inner' | 'outer' | 'value' | 'html'; + +export interface ExportDOMOutput { + element: TElement; + type: TType; +} + +export interface ExportDOMFeatureOptions { + emailCustomization?: boolean; + emailCustomizationAlpha?: boolean; + [key: string]: unknown; +} + +export interface ExportDOMDom { + window: {document: Document}; +} + +export interface ExportDOMOptionsBase { + createDocument?: () => Document; + dom?: ExportDOMDom; + target?: string; + postUrl?: string; + siteUrl?: string; + siteUuid?: string; + canTransformImage?: (src: string) => boolean; + imageOptimization?: Record; + feature?: ExportDOMFeatureOptions; + design?: Record; + [key: string]: unknown; +} + +export type ExportDOMRenderer = (node: TNode, options: TOptions) => TOutput; + +export type VersionedExportDOMRenderer = Record>; + +export type ExportDOMNodeRenderers = Record | VersionedExportDOMRenderer>; + +export interface ExportDOMOptions extends ExportDOMOptionsBase { + nodeRenderers?: ExportDOMNodeRenderers; +} diff --git a/packages/kg-default-nodes/lib/generate-decorator-node.js b/packages/kg-default-nodes/src/generate-decorator-node.ts similarity index 55% rename from packages/kg-default-nodes/lib/generate-decorator-node.js rename to packages/kg-default-nodes/src/generate-decorator-node.ts index fa9192953b..309e364bee 100644 --- a/packages/kg-default-nodes/lib/generate-decorator-node.js +++ b/packages/kg-default-nodes/src/generate-decorator-node.ts @@ -1,27 +1,37 @@ -import {KoenigDecoratorNode} from './KoenigDecoratorNode'; -import readTextContent from './utils/read-text-content'; -import {buildDefaultVisibility, isVisibilityRestricted, migrateOldVisibilityFormat} from './utils/visibility'; +import {KoenigDecoratorNode} from './KoenigDecoratorNode.js'; +import type {ExportDOMOptions, ExportDOMOutput} from './export-dom.js'; +import readTextContent from './utils/read-text-content.js'; +import {buildDefaultVisibility, isVisibilityRestricted, migrateOldVisibilityFormat} from './utils/visibility.js'; +import type {Visibility} from './utils/visibility.js'; + +type RenderFn = (node: any, options: ExportDOMOptions) => TOutput; +type VersionedRenderFn = Record>; +type WidenLiteral = + T extends string ? string : + T extends number ? number : + T extends boolean ? boolean : + T extends readonly (infer U)[] ? U[] : + T; /** * Validates the required arguments passed to `generateDecoratorNode` */ -function validateArguments(nodeType, properties) { - /* eslint-disable ghost/ghost-custom/no-native-error */ +function validateArguments(nodeType: string, properties: readonly DecoratorNodeProperty[]) { /* c8 ignore start */ if (!nodeType) { - throw new Error({message: '[generateDecoratorNode] A unique "nodeType" should be provided'}); + throw new Error('[generateDecoratorNode] A unique "nodeType" should be provided'); } - properties.forEach((prop) => { + properties.forEach((prop: DecoratorNodeProperty) => { if (!('name' in prop) || !('default' in prop)){ - throw new Error({message: '[generateDecoratorNode] Properties should have both "name" and "default" attributes.'}); + throw new Error('[generateDecoratorNode] Properties should have both "name" and "default" attributes.'); } if (prop.urlType && !['url', 'html', 'markdown'].includes(prop.urlType)) { - throw new Error({message: '[generateDecoratorNode] "urlType" should be either "url", "html" or "markdown"'}); + throw new Error('[generateDecoratorNode] "urlType" should be either "url", "html" or "markdown"'); } if ('wordCount' in prop && typeof prop.wordCount !== 'boolean') { - throw new Error({message: '[generateDecoratorNode] "wordCount" should be of boolean type.'}); + throw new Error('[generateDecoratorNode] "wordCount" should be of boolean type.'); } }); /* c8 ignore stop */ @@ -40,19 +50,114 @@ function validateArguments(nodeType, properties) { * @param {Function} defaultRenderFn - A function that returns a @tryghost/kg-lexical-html-renderer compatible object, e.g. {element: Div, type: 'inner} * @returns {Object} - The generated class. */ -export function generateDecoratorNode({nodeType, properties = [], defaultRenderFn, version = 1, hasVisibility = false}) { +export interface DecoratorNodeProperty { + name: Name; + default: Default; + urlType?: string; + urlPath?: string; + wordCount?: boolean; + privateName?: string; +} + +export type DecoratorNodeValueMap = { + [Prop in Props[number] as Prop['name']]: WidenLiteral; +} & (HasVisibility extends true ? {visibility: Visibility} : {}); + +export type DecoratorNodeData = Partial>; + +type GeneratedDecoratorNodeInstance, TOutput extends ExportDOMOutput = ExportDOMOutput> = GeneratedDecoratorNodeBase & TDataset & { + exportDOM(options?: ExportDOMOptions): TOutput; +}; + +export interface GeneratedDecoratorNodeClass, TOutput extends ExportDOMOutput = ExportDOMOutput> { + new (data?: Partial, key?: string): GeneratedDecoratorNodeInstance; + prototype: GeneratedDecoratorNodeInstance; + getType(): string; + clone(node: GeneratedDecoratorNodeInstance): GeneratedDecoratorNodeInstance; + transform(): null; + getPropertyDefaults(): TDataset; + readonly urlTransformMap: Record>; + importJSON(serializedNode: Record): GeneratedDecoratorNodeInstance; +} + +// Type-only base class used as the return type of generateDecoratorNode. +// This ensures TypeScript recognizes generated nodes as LexicalNode subclasses +// while preserving the dynamic property index signature. +export class GeneratedDecoratorNodeBase = Record> extends KoenigDecoratorNode { + [key: string]: unknown; + + constructor(data?: Partial, key?: string) { + super(key); + } + + getDataset(): Record { + return {}; + } + + exportJSON(): {type: string; version: number; [key: string]: unknown} { + return {type: '', version: 1}; + } + + static getPropertyDefaults(): Record { + return {}; + } + + static get urlTransformMap(): Record> { + return {}; + } + + static importJSON(_serializedNode: Record): GeneratedDecoratorNodeBase> { + return new GeneratedDecoratorNodeBase(); + } + + static transform() { + return null; + } + + hasDynamicData(): boolean { + return false; + } + + hasEditMode(): boolean { + return true; + } + + getIsVisibilityActive(): boolean { + return false; + } +} + +export function generateDecoratorNode< + Props extends readonly DecoratorNodeProperty[] = readonly [], + HasVisibility extends boolean = false, + TOutput extends ExportDOMOutput = ExportDOMOutput +>({nodeType, properties = [] as unknown as Props, defaultRenderFn, version = 1, hasVisibility = false as HasVisibility}: { + nodeType: string; + properties?: Props; + defaultRenderFn?: RenderFn | VersionedRenderFn; + version?: number; + hasVisibility?: HasVisibility; +}): GeneratedDecoratorNodeClass, TOutput> { validateArguments(nodeType, properties); // Adds a `privateName` field to the properties for convenience (e.g. `__name`): // properties: [{name: 'name', privateName: '__name', type: 'string', default: 'hello'}, {...}] - properties = properties.map((prop) => { - return {...prop, privateName: `__${prop.name}`}; + const internalProps = properties.map((prop) => { + return Object.defineProperties({}, { + ...Object.getOwnPropertyDescriptors(prop), + privateName: { + configurable: true, + enumerable: true, + value: `__${prop.name}`, + writable: true + } + }) as DecoratorNodeProperty & {privateName: string}; }); // Adds `visibility` property to the properties array if `hasVisibility` is true // uses a getter for `default` to avoid problems with mutation of nested objects if (hasVisibility) { - properties.push({ + internalProps.push({ name: 'visibility', get default() { return buildDefaultVisibility(); @@ -62,13 +167,16 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF } class GeneratedDecoratorNode extends KoenigDecoratorNode { - constructor(data = {}, key) { + [key: string]: unknown; + + constructor(data: Partial> = {}, key?: string) { super(key); - properties.forEach((prop) => { + const dataset = data as Record; + internalProps.forEach((prop) => { if (typeof prop.default === 'boolean') { - this[prop.privateName] = data[prop.name] ?? prop.default; + this[prop.privateName] = dataset[prop.name] ?? prop.default; } else { - this[prop.privateName] = data[prop.name] || prop.default; + this[prop.privateName] = dataset[prop.name] || prop.default; } }); } @@ -88,8 +196,8 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF * @extends DecoratorNode * @see https://lexical.dev/docs/concepts/nodes#extending-decoratornode */ - static clone(node) { - return new this(node.getDataset(), node.__key); + static clone(node: GeneratedDecoratorNodeInstance, TOutput>) { + return new this(node.getDataset() as Partial>, node.__key); } /** @@ -97,10 +205,10 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF * to detect when a property has been changed */ static getPropertyDefaults() { - return properties.reduce((obj, prop) => { + return internalProps.reduce((obj: Record, prop) => { obj[prop.name] = prop.default; return obj; - }, {}); + }, {}) as DecoratorNodeValueMap; } /** @@ -109,9 +217,9 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF * @see https://github.com/TryGhost/SDK/tree/main/packages/url-utils */ static get urlTransformMap() { - let map = {}; + const map: Record = {}; - properties.forEach((prop) => { + internalProps.forEach((prop) => { if (prop.urlType) { if (prop.urlPath) { map[prop.urlPath] = prop.urlType; @@ -131,8 +239,8 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF getDataset() { const self = this.getLatest(); - let dataset = {}; - properties.forEach((prop) => { + const dataset: Record = {}; + internalProps.forEach((prop) => { dataset[prop.name] = self[prop.privateName]; }); @@ -145,17 +253,17 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF * @extends DecoratorNode * @param {Object} serializedNode - Lexical's representation of the node, in JSON format */ - static importJSON(serializedNode) { - const data = {}; + static importJSON(serializedNode: Record) { + const data: Record = {}; // migrate older nodes that were saved with an earlier version of the visibility format - serializedNode.visibility = migrateOldVisibilityFormat(serializedNode.visibility); + serializedNode.visibility = migrateOldVisibilityFormat(serializedNode.visibility as Visibility); - properties.forEach((prop) => { + internalProps.forEach((prop) => { data[prop.name] = serializedNode[prop.name]; }); - return new this(data); + return new this(data as Partial>); } /** @@ -163,11 +271,12 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF * @extends DecoratorNode * @see https://lexical.dev/docs/concepts/serialization#lexicalnodeexportjson */ + // @ts-expect-error -- strict mode migration exportJSON() { - const dataset = { + const dataset: Record = { type: nodeType, version: version, - ...properties.reduce((obj, prop) => { + ...internalProps.reduce((obj: Record, prop) => { obj[prop.name] = this[prop.name]; return obj; }, {}) @@ -175,27 +284,29 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF return dataset; } - exportDOM(options = {}) { + // @ts-expect-error - custom exportDOM signature for Ghost rendering + exportDOM(options: ExportDOMOptions = {}): TOutput { // this.__version is used when a node has a version property which // means it's set from the serialized version data at runtime const nodeVersion = this.__version || version; - if (options.nodeRenderers?.[nodeType]) { - const render = options.nodeRenderers[nodeType]; + const nodeRenderers = options.nodeRenderers as Record | VersionedRenderFn> | undefined; + if (nodeRenderers?.[nodeType]) { + const render = nodeRenderers[nodeType]; if (typeof render === 'object') { - const versionRenderer = render[nodeVersion]; + const versionRenderer = (render as VersionedRenderFn)[nodeVersion as number]; if (!versionRenderer) { throw new Error(`[generateDecoratorNode] ${nodeType}: options.nodeRenderers['${nodeType}'] for version ${nodeVersion} is required`); } return versionRenderer(this, options); } else { - return render(this, options); + return (render as RenderFn)(this, options); } } if (typeof defaultRenderFn === 'object') { - const render = defaultRenderFn[nodeVersion]; + const render = (defaultRenderFn as VersionedRenderFn)[nodeVersion as number]; if (!render) { throw new Error(`[generateDecoratorNode] ${nodeType}: "defaultRenderFn" for version ${nodeVersion} is required`); } @@ -206,7 +317,7 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF throw new Error(`[generateDecoratorNode] ${nodeType}: "defaultRenderFn" is required`); } - const render = defaultRenderFn; + const render = defaultRenderFn as RenderFn; return render(this, options); } @@ -276,14 +387,14 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF * @returns {boolean} */ getIsVisibilityActive() { - if (!properties.some(prop => prop.name === 'visibility')) { + if (!internalProps.some(prop => prop.name === 'visibility')) { return false; } const self = this.getLatest(); const visibility = self.__visibility; - return isVisibilityRestricted(visibility); + return isVisibilityRestricted(visibility as Visibility); } } @@ -305,7 +416,7 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF * * They can be used as `node.content` (getter) and `node.content = 'new value'` (setter) */ - properties.forEach((prop) => { + internalProps.forEach((prop) => { Object.defineProperty(GeneratedDecoratorNode.prototype, prop.name, { get: function () { const self = this.getLatest(); @@ -318,5 +429,5 @@ export function generateDecoratorNode({nodeType, properties = [], defaultRenderF }); }); - return GeneratedDecoratorNode; + return GeneratedDecoratorNode as unknown as GeneratedDecoratorNodeClass, TOutput>; } diff --git a/packages/kg-default-nodes/src/index.ts b/packages/kg-default-nodes/src/index.ts new file mode 100644 index 0000000000..1c910a46ae --- /dev/null +++ b/packages/kg-default-nodes/src/index.ts @@ -0,0 +1 @@ +export * from './kg-default-nodes.js'; diff --git a/packages/kg-default-nodes/src/kg-default-nodes.ts b/packages/kg-default-nodes/src/kg-default-nodes.ts new file mode 100644 index 0000000000..56e393566c --- /dev/null +++ b/packages/kg-default-nodes/src/kg-default-nodes.ts @@ -0,0 +1,129 @@ +export {GeneratedDecoratorNodeBase} from './generate-decorator-node.js'; +export * from './export-dom.js'; +import * as image from './nodes/image/ImageNode.js'; +import * as codeblock from './nodes/codeblock/CodeBlockNode.js'; +import * as markdown from './nodes/markdown/MarkdownNode.js'; +import * as video from './nodes/video/VideoNode.js'; +import * as audio from './nodes/audio/AudioNode.js'; +import * as callout from './nodes/callout/CalloutNode.js'; +import * as callToAction from './nodes/call-to-action/CallToActionNode.js'; +import * as aside from './nodes/aside/AsideNode.js'; +import * as horizontalrule from './nodes/horizontalrule/HorizontalRuleNode.js'; +import * as html from './nodes/html/HtmlNode.js'; +import * as toggle from './nodes/toggle/ToggleNode.js'; +import * as button from './nodes/button/ButtonNode.js'; +import * as bookmark from './nodes/bookmark/BookmarkNode.js'; +import * as file from './nodes/file/FileNode.js'; +import * as header from './nodes/header/HeaderNode.js'; +import * as paywall from './nodes/paywall/PaywallNode.js'; +import * as product from './nodes/product/ProductNode.js'; +import * as embed from './nodes/embed/EmbedNode.js'; +import * as email from './nodes/email/EmailNode.js'; +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 textnode from './nodes/ExtendedTextNode.js'; +import * as headingnode from './nodes/ExtendedHeadingNode.js'; +import * as quotenode from './nodes/ExtendedQuoteNode.js'; +import * as tk from './nodes/TKNode.js'; +import * as atLink from './nodes/at-link/index.js'; +import * as zwnj from './nodes/zwnj/ZWNJNode.js'; + +import linebreakSerializers from './serializers/linebreak.js'; +import paragraphSerializers from './serializers/paragraph.js'; + +// re-export everything for easier importing +export * from './KoenigDecoratorNode.js'; +export * from './nodes/image/ImageNode.js'; +export * from './nodes/codeblock/CodeBlockNode.js'; +export * from './nodes/markdown/MarkdownNode.js'; +export * from './nodes/video/VideoNode.js'; +export * from './nodes/audio/AudioNode.js'; +export * from './nodes/callout/CalloutNode.js'; +export * from './nodes/aside/AsideNode.js'; +export * from './nodes/horizontalrule/HorizontalRuleNode.js'; +export * from './nodes/html/HtmlNode.js'; +export * from './nodes/toggle/ToggleNode.js'; +export * from './nodes/button/ButtonNode.js'; +export * from './nodes/bookmark/BookmarkNode.js'; +export * from './nodes/file/FileNode.js'; +export * from './nodes/header/HeaderNode.js'; +export * from './nodes/paywall/PaywallNode.js'; +export * from './nodes/product/ProductNode.js'; +export * from './nodes/embed/EmbedNode.js'; +export * from './nodes/email/EmailNode.js'; +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/call-to-action/CallToActionNode.js'; +export * from './nodes/ExtendedTextNode.js'; +export * from './nodes/ExtendedHeadingNode.js'; +export * from './nodes/ExtendedQuoteNode.js'; +export * from './nodes/TKNode.js'; +export * from './nodes/at-link/index.js'; +export * from './nodes/zwnj/ZWNJNode.js'; + +// export utility functions that are useful in other packages or tests +import * as visibilityUtils from './utils/visibility.js'; +import * as taggedTemplateFns from './utils/tagged-template-fns.js'; +import {generateDecoratorNode} from './generate-decorator-node.js'; +import {rgbToHex} from './utils/rgb-to-hex.js'; +export const utils = { + generateDecoratorNode, + visibility: visibilityUtils, + rgbToHex, + taggedTemplateFns +}; + +export const serializers = { + linebreak: linebreakSerializers, + paragraph: paragraphSerializers +}; + +export const DEFAULT_CONFIG = { + html: { + import: { + ...serializers.linebreak.import, + ...serializers.paragraph.import + } + } +}; + +// export convenience objects for use elsewhere +export const DEFAULT_NODES = [ + textnode.ExtendedTextNode, + textnode.extendedTextNodeReplacement, + headingnode.ExtendedHeadingNode, + headingnode.extendedHeadingNodeReplacement, + quotenode.ExtendedQuoteNode, + quotenode.extendedQuoteNodeReplacement, + codeblock.CodeBlockNode, + image.ImageNode, + markdown.MarkdownNode, + video.VideoNode, + audio.AudioNode, + callout.CalloutNode, + callToAction.CallToActionNode, + aside.AsideNode, + horizontalrule.HorizontalRuleNode, + html.HtmlNode, + file.FileNode, + toggle.ToggleNode, + button.ButtonNode, + header.HeaderNode, + bookmark.BookmarkNode, + paywall.PaywallNode, + product.ProductNode, + embed.EmbedNode, + email.EmailNode, + gallery.GalleryNode, + emailCta.EmailCtaNode, + signup.SignupNode, + transistor.TransistorNode, + tk.TKNode, + atLink.AtLinkNode, + atLink.AtLinkSearchNode, + zwnj.ZWNJNode +]; diff --git a/packages/kg-default-nodes/lib/nodes/ExtendedHeadingNode.js b/packages/kg-default-nodes/src/nodes/ExtendedHeadingNode.ts similarity index 70% rename from packages/kg-default-nodes/lib/nodes/ExtendedHeadingNode.js rename to packages/kg-default-nodes/src/nodes/ExtendedHeadingNode.ts index 63b3883d50..9dbe10ca3d 100644 --- a/packages/kg-default-nodes/lib/nodes/ExtendedHeadingNode.js +++ b/packages/kg-default-nodes/src/nodes/ExtendedHeadingNode.ts @@ -1,4 +1,6 @@ import {HeadingNode} from '@lexical/rich-text'; +import type {HeadingTagType, SerializedHeadingNode} from '@lexical/rich-text'; +import type {DOMConversion} from 'lexical'; // Since the HeadingNode is foundational to Lexical rich-text, only using a // custom HeadingNode is undesirable as it means every package would need to @@ -8,10 +10,10 @@ import {HeadingNode} from '@lexical/rich-text'; // // https://lexical.dev/docs/concepts/serialization#handling-extended-html-styling -export const extendedHeadingNodeReplacement = {replace: HeadingNode, with: node => new ExtendedHeadingNode(node.__tag)}; +export const extendedHeadingNodeReplacement = {replace: HeadingNode, with: (node: HeadingNode) => new ExtendedHeadingNode(node.__tag)}; export class ExtendedHeadingNode extends HeadingNode { - constructor(tag, key) { + constructor(tag: HeadingTagType, key?: string) { super(tag, key); } @@ -19,7 +21,7 @@ export class ExtendedHeadingNode extends HeadingNode { return 'extended-heading'; } - static clone(node) { + static clone(node: ExtendedHeadingNode) { return new ExtendedHeadingNode(node.__tag, node.__key); } @@ -31,8 +33,12 @@ export class ExtendedHeadingNode extends HeadingNode { }; } - static importJSON(serializedNode) { - return HeadingNode.importJSON(serializedNode); + static importJSON(serializedNode: SerializedHeadingNode): ExtendedHeadingNode { + const node = new ExtendedHeadingNode(serializedNode.tag); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; } exportJSON() { @@ -42,8 +48,10 @@ export class ExtendedHeadingNode extends HeadingNode { } } -function patchParagraphConversion(originalDOMConverter) { - return (node) => { +type DOMConverterFn = ((node: HTMLElement) => DOMConversion | null) | undefined; + +function patchParagraphConversion(originalDOMConverter: DOMConverterFn) { + return (node: HTMLElement) => { // Original matches Google Docs p node to a null conversion so it's // child span is parsed as a heading. Don't prevent that here const original = originalDOMConverter?.(node); @@ -64,10 +72,10 @@ function patchParagraphConversion(originalDOMConverter) { return { conversion: () => { return { - node: new ExtendedHeadingNode(`h${level}`) + node: new ExtendedHeadingNode(`h${level}` as HeadingTagType) }; }, - priority: 1 + priority: 1 as const }; } } diff --git a/packages/kg-default-nodes/lib/nodes/ExtendedQuoteNode.js b/packages/kg-default-nodes/src/nodes/ExtendedQuoteNode.ts similarity index 84% rename from packages/kg-default-nodes/lib/nodes/ExtendedQuoteNode.js rename to packages/kg-default-nodes/src/nodes/ExtendedQuoteNode.ts index 7d2f14be90..c61869d13f 100644 --- a/packages/kg-default-nodes/lib/nodes/ExtendedQuoteNode.js +++ b/packages/kg-default-nodes/src/nodes/ExtendedQuoteNode.ts @@ -1,5 +1,7 @@ import {QuoteNode} from '@lexical/rich-text'; +import type {SerializedQuoteNode} from '@lexical/rich-text'; import {$createLineBreakNode, $isParagraphNode} from 'lexical'; +import type {LexicalNode} from 'lexical'; // Since the QuoteNode is foundational to Lexical rich-text, only using a // custom QuoteNode is undesirable as it means every package would need to @@ -12,7 +14,7 @@ import {$createLineBreakNode, $isParagraphNode} from 'lexical'; export const extendedQuoteNodeReplacement = {replace: QuoteNode, with: () => new ExtendedQuoteNode()}; export class ExtendedQuoteNode extends QuoteNode { - constructor(key) { + constructor(key?: string) { super(key); } @@ -20,7 +22,7 @@ export class ExtendedQuoteNode extends QuoteNode { return 'extended-quote'; } - static clone(node) { + static clone(node: ExtendedQuoteNode) { return new ExtendedQuoteNode(node.__key); } @@ -32,7 +34,7 @@ export class ExtendedQuoteNode extends QuoteNode { }; } - static importJSON(serializedNode) { + static importJSON(serializedNode: SerializedQuoteNode) { return QuoteNode.importJSON(serializedNode); } @@ -55,14 +57,14 @@ function convertBlockquoteElement() { const node = new ExtendedQuoteNode(); return { node, - after: (childNodes) => { + after: (childNodes: LexicalNode[]) => { // Blockquotes can have nested paragraphs. In our original mobiledoc // editor we parsed all of the nested paragraphs into a single blockquote // separating each paragraph with two line breaks. We replicate that // here so we don't have a breaking change in conversion behaviour. - const newChildNodes = []; + const newChildNodes: LexicalNode[] = []; - childNodes.forEach((child) => { + childNodes.forEach((child: LexicalNode) => { if ($isParagraphNode(child)) { if (newChildNodes.length > 0) { newChildNodes.push($createLineBreakNode()); @@ -79,6 +81,6 @@ function convertBlockquoteElement() { } }; }, - priority: 1 + priority: 1 as const }; } diff --git a/packages/kg-default-nodes/lib/nodes/ExtendedTextNode.js b/packages/kg-default-nodes/src/nodes/ExtendedTextNode.ts similarity index 74% rename from packages/kg-default-nodes/lib/nodes/ExtendedTextNode.js rename to packages/kg-default-nodes/src/nodes/ExtendedTextNode.ts index c2144dc0ee..85b5ab95a3 100644 --- a/packages/kg-default-nodes/lib/nodes/ExtendedTextNode.js +++ b/packages/kg-default-nodes/src/nodes/ExtendedTextNode.ts @@ -1,4 +1,5 @@ import {$isTextNode, TextNode} from 'lexical'; +import type {DOMConversion, DOMConversionOutput, LexicalNode, SerializedTextNode} from 'lexical'; // Since the TextNode is foundational to all Lexical packages, including the // plain text use case. Handling any rich text logic is undesirable. This creates @@ -7,10 +8,10 @@ import {$isTextNode, TextNode} from 'lexical'; // // https://lexical.dev/docs/concepts/serialization#handling-extended-html-styling -export const extendedTextNodeReplacement = {replace: TextNode, with: node => new ExtendedTextNode(node.__text)}; +export const extendedTextNodeReplacement = {replace: TextNode, with: (node: TextNode) => new ExtendedTextNode(node.__text)}; export class ExtendedTextNode extends TextNode { - constructor(text, key) { + constructor(text: string, key?: string) { super(text, key); } @@ -18,7 +19,7 @@ export class ExtendedTextNode extends TextNode { return 'extended-text'; } - static clone(node) { + static clone(node: ExtendedTextNode) { return new ExtendedTextNode(node.__text, node.__key); } @@ -28,13 +29,18 @@ export class ExtendedTextNode extends TextNode { ...importers, span: () => ({ conversion: patchConversion(importers?.span, convertSpanElement), - priority: 1 + priority: 1 as const }) }; } - static importJSON(serializedNode) { - return TextNode.importJSON(serializedNode); + static importJSON(serializedNode: SerializedTextNode): ExtendedTextNode { + const node = new ExtendedTextNode(serializedNode.text); + node.setFormat(serializedNode.format); + node.setDetail(serializedNode.detail); + node.setMode(serializedNode.mode); + node.setStyle(serializedNode.style); + return node; } exportJSON() { @@ -43,7 +49,7 @@ export class ExtendedTextNode extends TextNode { return json; } - isSimpleText() { + isSimpleText(): boolean { return ( (this.__type === 'text' || this.__type === 'extended-text') && this.__mode === 0 @@ -55,13 +61,15 @@ export class ExtendedTextNode extends TextNode { } } -function patchConversion(originalDOMConverter, convertFn) { - return (node) => { +type DOMConverterFn = ((node: HTMLElement) => DOMConversion | null) | undefined; + +function patchConversion(originalDOMConverter: DOMConverterFn, convertFn: (lexicalNode: TextNode, domNode: HTMLElement) => TextNode) { + return (node: HTMLElement) => { const original = originalDOMConverter?.(node); if (!original) { return null; } - const originalOutput = original.conversion(node); + const originalOutput = original.conversion(node) as DOMConversionOutput; if (!originalOutput) { return originalOutput; @@ -69,8 +77,8 @@ function patchConversion(originalDOMConverter, convertFn) { return { ...originalOutput, - forChild: (lexicalNode, parent) => { - const originalForChild = originalOutput?.forChild ?? (x => x); + forChild: (lexicalNode: LexicalNode, parent: LexicalNode | null | undefined) => { + const originalForChild = originalOutput?.forChild ?? ((x: LexicalNode) => x); const result = originalForChild(lexicalNode, parent); if ($isTextNode(result)) { return convertFn(result, node); @@ -81,7 +89,7 @@ function patchConversion(originalDOMConverter, convertFn) { }; } -function convertSpanElement(lexicalNode, domNode) { +function convertSpanElement(lexicalNode: TextNode, domNode: HTMLElement) { const span = domNode; // Word uses span tags + font-weight for bold text diff --git a/packages/kg-default-nodes/lib/nodes/TKNode.js b/packages/kg-default-nodes/src/nodes/TKNode.ts similarity index 64% rename from packages/kg-default-nodes/lib/nodes/TKNode.js rename to packages/kg-default-nodes/src/nodes/TKNode.ts index 27f7a841e3..3ce9f39db4 100644 --- a/packages/kg-default-nodes/lib/nodes/TKNode.js +++ b/packages/kg-default-nodes/src/nodes/TKNode.ts @@ -1,31 +1,32 @@ import {$applyNodeReplacement, TextNode} from 'lexical'; +import type {EditorConfig, SerializedTextNode, TextModeType} from 'lexical'; export class TKNode extends TextNode { static getType() { return 'tk'; } - static clone(node) { + static clone(node: TKNode) { return new TKNode(node.__text, node.__key); } - constructor(text, key) { + constructor(text: string, key?: string) { super(text, key); } - createDOM(config) { + createDOM(config: EditorConfig) { const element = super.createDOM(config); const classes = config.theme.tk?.split(' ') || []; element.classList.add(...classes); - element.dataset.kgTk = true; + element.dataset.kgTk = 'true'; return element; } - static importJSON(serializedNode) { - const node = $createTKNode(serializedNode.text); - node.setFormat(serializedNode.format); - node.setDetail(serializedNode.detail); - node.setMode(serializedNode.mode); + static importJSON(serializedNode: SerializedTextNode): TKNode { + const node = new TKNode(serializedNode.text); + node.setFormat(serializedNode.format as number); + node.setDetail(serializedNode.detail as number); + node.setMode(serializedNode.mode as TextModeType); node.setStyle(serializedNode.style); return node; } @@ -51,7 +52,7 @@ export class TKNode extends TextNode { * @param text - The text used inside the TKNode. * @returns - The TKNode with the embedded text. */ -export function $createTKNode(text) { +export function $createTKNode(text: string) { return $applyNodeReplacement(new TKNode(text)); } @@ -60,6 +61,6 @@ export function $createTKNode(text) { * @param node - The node to be checked. * @returns true if node is a TKNode, false otherwise. */ -export function $isTKNode(node) { +export function $isTKNode(node: unknown): node is TKNode { return node instanceof TKNode; } diff --git a/packages/kg-default-nodes/lib/nodes/aside/AsideNode.js b/packages/kg-default-nodes/src/nodes/aside/AsideNode.ts similarity index 74% rename from packages/kg-default-nodes/lib/nodes/aside/AsideNode.js rename to packages/kg-default-nodes/src/nodes/aside/AsideNode.ts index 7f65528319..94ec517be9 100644 --- a/packages/kg-default-nodes/lib/nodes/aside/AsideNode.js +++ b/packages/kg-default-nodes/src/nodes/aside/AsideNode.ts @@ -1,12 +1,13 @@ import {ElementNode} from 'lexical'; -import {AsideParser} from './AsideParser'; +import type {EditorConfig, LexicalEditor, SerializedElementNode} from 'lexical'; +import {AsideParser} from './AsideParser.js'; export class AsideNode extends ElementNode { static getType() { return 'aside'; } - static clone(node) { + static clone(node: AsideNode) { return new this( node.__key ); @@ -16,11 +17,11 @@ export class AsideNode extends ElementNode { return {}; } - constructor(key) { + constructor(key?: string) { super(key); } - static importJSON(serializedNode) { + static importJSON(serializedNode: SerializedElementNode) { const node = new this(); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); @@ -43,7 +44,7 @@ export class AsideNode extends ElementNode { } /* c8 ignore start */ - createDOM() { + createDOM(_config?: EditorConfig, _editor?: LexicalEditor): HTMLElement { return document.createElement('div'); } @@ -65,6 +66,6 @@ export function $createAsideNode() { return new AsideNode(); } -export function $isAsideNode(node) { +export function $isAsideNode(node: unknown): node is AsideNode { return node instanceof AsideNode; } diff --git a/packages/kg-default-nodes/lib/nodes/aside/AsideParser.js b/packages/kg-default-nodes/src/nodes/aside/AsideParser.ts similarity index 61% rename from packages/kg-default-nodes/lib/nodes/aside/AsideParser.js rename to packages/kg-default-nodes/src/nodes/aside/AsideParser.ts index 662cf5234d..c1218ef6e3 100644 --- a/packages/kg-default-nodes/lib/nodes/aside/AsideParser.js +++ b/packages/kg-default-nodes/src/nodes/aside/AsideParser.ts @@ -1,23 +1,25 @@ +import type {LexicalNode} from 'lexical'; + export class AsideParser { - constructor(NodeClass) { + NodeClass: {new (): LexicalNode}; + + constructor(NodeClass: {new (): LexicalNode}) { this.NodeClass = NodeClass; } get DOMConversionMap() { - const self = this; - return { blockquote: () => ({ - conversion(domNode) { + conversion: (domNode: HTMLElement) => { const isBigQuote = domNode.classList?.contains('kg-blockquote-alt'); if (domNode.tagName === 'BLOCKQUOTE' && isBigQuote) { - const node = new self.NodeClass(); + const node = new this.NodeClass(); return {node}; } return null; }, - priority: 0 + priority: 0 as const }) }; } diff --git a/packages/kg-default-nodes/lib/nodes/at-link/AtLinkNode.js b/packages/kg-default-nodes/src/nodes/at-link/AtLinkNode.ts similarity index 61% rename from packages/kg-default-nodes/lib/nodes/at-link/AtLinkNode.js rename to packages/kg-default-nodes/src/nodes/at-link/AtLinkNode.ts index 0b98d9d024..44c21b681c 100644 --- a/packages/kg-default-nodes/lib/nodes/at-link/AtLinkNode.js +++ b/packages/kg-default-nodes/src/nodes/at-link/AtLinkNode.ts @@ -1,30 +1,31 @@ import {$applyNodeReplacement, ElementNode} from 'lexical'; -import linkSVG from './kg-link.svg'; +import type {EditorConfig} from 'lexical'; +const linkSVG = ' '; // Container element for a link search query. Temporary node used only inside // the editor that will be replaced with a LinkNode when the search is complete. export class AtLinkNode extends ElementNode { // We keep track of the format that was applied to the original '@' character // so we can re-apply that when converting to a LinkNode - __linkFormat = null; + __linkFormat: number | null = null; static getType() { return 'at-link'; } - constructor(linkFormat, key) { + constructor(linkFormat: number | null, key?: string) { super(key); this.__linkFormat = linkFormat; } - static clone(node) { + static clone(node: AtLinkNode) { return new AtLinkNode(node.__linkFormat, node.__key); } // This is a temporary node, it should never be serialized but we need // to implement just in case and to match expected types. The AtLinkPlugin // should take care of replacing this node with it's children when needed. - static importJSON({linkFormat}) { - return $createAtLinkNode(linkFormat); + static importJSON(serializedNode: ReturnType) { + return $createAtLinkNode(serializedNode.linkFormat); } exportJSON() { @@ -36,7 +37,7 @@ export class AtLinkNode extends ElementNode { }; } - createDOM(config) { + createDOM(config: EditorConfig) { const span = document.createElement('span'); const atLinkClasses = (config.theme.atLink || '').split(' ').filter(Boolean); const atLinkIconClasses = (config.theme.atLinkIcon || '').split(' ').filter(Boolean); @@ -55,9 +56,12 @@ export class AtLinkNode extends ElementNode { return false; } - // should not render anything - this is a placeholder node + // This is an editor-only placeholder node. Return an empty element and + // `type: 'inner'` so downstream serializers emit no HTML while still + // receiving a non-null DOM element. exportDOM() { - return null; + const span = document.createElement('span'); + return {element: span, type: 'inner' as const}; } /* c8 ignore next 3 */ @@ -77,7 +81,7 @@ export class AtLinkNode extends ElementNode { return false; } - setLinkFormat(linkFormat) { + setLinkFormat(linkFormat: number | null) { const self = this.getWritable(); self.__linkFormat = linkFormat; } @@ -88,10 +92,10 @@ export class AtLinkNode extends ElementNode { } } -export function $createAtLinkNode(linkFormat) { +export function $createAtLinkNode(linkFormat: number | null = null): AtLinkNode { return $applyNodeReplacement(new AtLinkNode(linkFormat)); } -export function $isAtLinkNode(node) { +export function $isAtLinkNode(node: unknown): node is AtLinkNode { return node instanceof AtLinkNode; } diff --git a/packages/kg-default-nodes/lib/nodes/at-link/AtLinkSearchNode.js b/packages/kg-default-nodes/src/nodes/at-link/AtLinkSearchNode.ts similarity index 64% rename from packages/kg-default-nodes/lib/nodes/at-link/AtLinkSearchNode.js rename to packages/kg-default-nodes/src/nodes/at-link/AtLinkSearchNode.ts index e7f93c1a67..2d7b6ad5d6 100644 --- a/packages/kg-default-nodes/lib/nodes/at-link/AtLinkSearchNode.js +++ b/packages/kg-default-nodes/src/nodes/at-link/AtLinkSearchNode.ts @@ -1,9 +1,10 @@ import {$applyNodeReplacement, TextNode} from 'lexical'; +import type {EditorConfig} from 'lexical'; // Represents the search query string inside an AtLinkNode. Used in place of a // regular TextNode to allow for :after styling to be applied to work as a placeholder export class AtLinkSearchNode extends TextNode { - __placeholder = null; + __placeholder: string | null = null; defaultPlaceholder = 'Find a post, tag or author'; @@ -11,12 +12,12 @@ export class AtLinkSearchNode extends TextNode { return 'at-link-search'; } - constructor(text, placeholder, key) { + constructor(text: string, placeholder: string | null, key?: string) { super(text, key); this.__placeholder = placeholder; } - static clone(node) { + static clone(node: AtLinkSearchNode) { return new AtLinkSearchNode( node.__text, node.__placeholder, @@ -27,8 +28,8 @@ export class AtLinkSearchNode extends TextNode { // This is a temporary node, it should never be serialized but we need // to implement just in case and to match expected types. The AtLinkPlugin // should take care of replacing this node when needed. - static importJSON({text, placeholder}) { - return $createAtLinkSearchNode(text, placeholder); + static importJSON(serializedNode: ReturnType) { + return $createAtLinkSearchNode(serializedNode.text, serializedNode.placeholder); } exportJSON() { @@ -40,7 +41,7 @@ export class AtLinkSearchNode extends TextNode { }; } - createDOM(config) { + createDOM(config: EditorConfig) { const span = super.createDOM(config); span.dataset.placeholder = ''; if (!this.__text) { @@ -54,17 +55,22 @@ export class AtLinkSearchNode extends TextNode { return span; } - updateDOM(prevNode, dom) { - if (this.__text) { - dom.dataset.placeholder = this.__placeholder ?? ''; - } + updateDOM(prevNode: AtLinkSearchNode, dom: HTMLElement, config: EditorConfig) { + dom.dataset.placeholder = this.__placeholder !== null + ? this.__placeholder + : this.__text + ? '' + : this.defaultPlaceholder; - return super.updateDOM(...arguments); + return super.updateDOM(prevNode, dom, config); } - // should not render anything - this is a placeholder node + // This is an editor-only placeholder node. Return an empty element and + // `type: 'inner'` so downstream serializers emit no HTML while still + // receiving a non-null DOM element. exportDOM() { - return null; + const span = document.createElement('span'); + return {element: span, type: 'inner' as const}; } /* c8 ignore next 3 */ @@ -76,7 +82,7 @@ export class AtLinkSearchNode extends TextNode { return false; } - setPlaceholder(text) { + setPlaceholder(text: string | null) { const self = this.getWritable(); self.__placeholder = text; } @@ -99,10 +105,10 @@ export class AtLinkSearchNode extends TextNode { } } -export function $createAtLinkSearchNode(text = '', placeholder = null) { +export function $createAtLinkSearchNode(text = '', placeholder: string | null = null): AtLinkSearchNode { return $applyNodeReplacement(new AtLinkSearchNode(text, placeholder)); } -export function $isAtLinkSearchNode(node) { +export function $isAtLinkSearchNode(node: unknown): node is AtLinkSearchNode { return node instanceof AtLinkSearchNode; } diff --git a/packages/kg-default-nodes/src/nodes/at-link/index.ts b/packages/kg-default-nodes/src/nodes/at-link/index.ts new file mode 100644 index 0000000000..9ae2921d68 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/at-link/index.ts @@ -0,0 +1,4 @@ +/* c8 ignore start */ +export * from './AtLinkNode.js'; +export * from './AtLinkSearchNode.js'; +/* c8 ignore end */ diff --git a/packages/kg-default-nodes/lib/nodes/at-link/kg-link.svg b/packages/kg-default-nodes/src/nodes/at-link/kg-link.svg similarity index 100% rename from packages/kg-default-nodes/lib/nodes/at-link/kg-link.svg rename to packages/kg-default-nodes/src/nodes/at-link/kg-link.svg diff --git a/packages/kg-default-nodes/src/nodes/audio/AudioNode.ts b/packages/kg-default-nodes/src/nodes/audio/AudioNode.ts new file mode 100644 index 0000000000..f8a0f2fe69 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/audio/AudioNode.ts @@ -0,0 +1,33 @@ +import {generateDecoratorNode, type DecoratorNodeData, type DecoratorNodeProperty, type DecoratorNodeValueMap} from '../../generate-decorator-node.js'; +import {parseAudioNode} from './audio-parser.js'; +import {renderAudioNode} from './audio-renderer.js'; + +const audioProperties = [ + {name: 'duration', default: 0}, + {name: 'mimeType', default: ''}, + {name: 'src', default: '', urlType: 'url'}, + {name: 'title', default: ''}, + {name: 'thumbnailSrc', default: ''} +] as const satisfies readonly DecoratorNodeProperty[]; + +export type AudioData = DecoratorNodeData; + +export interface AudioNode extends DecoratorNodeValueMap {} + +export class AudioNode extends generateDecoratorNode({ + nodeType: 'audio', + properties: audioProperties, + defaultRenderFn: renderAudioNode +}) { + static importDOM() { + return parseAudioNode(this); + } +} + +export const $createAudioNode = (dataset: AudioData = {}) => { + return new AudioNode(dataset); +}; + +export function $isAudioNode(node: unknown): node is AudioNode { + return node instanceof AudioNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/audio/audio-parser.js b/packages/kg-default-nodes/src/nodes/audio/audio-parser.ts similarity index 61% rename from packages/kg-default-nodes/lib/nodes/audio/audio-parser.js rename to packages/kg-default-nodes/src/nodes/audio/audio-parser.ts index 2ffcf27d22..8c0134eb64 100644 --- a/packages/kg-default-nodes/lib/nodes/audio/audio-parser.js +++ b/packages/kg-default-nodes/src/nodes/audio/audio-parser.ts @@ -1,19 +1,21 @@ -export function parseAudioNode(AudioNode) { +import type {LexicalNode} from 'lexical'; + +export function parseAudioNode(AudioNode: new (data: Record) => LexicalNode) { return { - div: (nodeElem) => { + div: (nodeElem: HTMLElement) => { const isKgAudioCard = nodeElem.classList?.contains('kg-audio-card'); if (nodeElem.tagName === 'DIV' && isKgAudioCard) { return { - conversion(domNode) { + conversion(domNode: HTMLElement) { const titleNode = domNode?.querySelector('.kg-audio-title'); - const audioNode = domNode?.querySelector('.kg-audio-player-container audio'); + const audioNode = domNode?.querySelector('.kg-audio-player-container audio') as HTMLAudioElement | null; const durationNode = domNode?.querySelector('.kg-audio-duration'); - const thumbnailNode = domNode?.querySelector('.kg-audio-thumbnail'); + const thumbnailNode = domNode?.querySelector('.kg-audio-thumbnail') as HTMLImageElement | null; const title = titleNode && titleNode.innerHTML.trim(); const audioSrc = audioNode && audioNode.src; const thumbnailSrc = thumbnailNode && thumbnailNode.src; const durationText = durationNode && durationNode.innerHTML.trim(); - const payload = { + const payload: Record = { src: audioSrc, title: title }; @@ -22,18 +24,19 @@ export function parseAudioNode(AudioNode) { } if (durationText) { - const [minutes, seconds = 0] = durationText.split(':'); - try { - payload.duration = parseInt(minutes) * 60 + parseInt(seconds); - } catch (e) { - // ignore duration + const [rawMinutes, rawSeconds = '0'] = durationText.split(':'); + const minutes = Number(rawMinutes.trim()); + const seconds = Number(rawSeconds.trim()); + + if (Number.isInteger(minutes) && Number.isInteger(seconds)) { + payload.duration = minutes * 60 + seconds; } } const node = new AudioNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } return null; diff --git a/packages/kg-default-nodes/lib/nodes/audio/audio-renderer.js b/packages/kg-default-nodes/src/nodes/audio/audio-renderer.ts similarity index 87% rename from packages/kg-default-nodes/lib/nodes/audio/audio-renderer.js rename to packages/kg-default-nodes/src/nodes/audio/audio-renderer.ts index d7b163c4c4..9a876ef880 100644 --- a/packages/kg-default-nodes/lib/nodes/audio/audio-renderer.js +++ b/packages/kg-default-nodes/src/nodes/audio/audio-renderer.ts @@ -1,9 +1,36 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {renderEmptyContainer} from '../../utils/render-empty-container'; +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions, ExportDOMOutput} from '../../export-dom.js'; +import {renderEmptyContainer} from '../../utils/render-empty-container.js'; + +interface AudioNodeData { + src: string; + title: string; + thumbnailSrc: string; + duration: number; +} + +interface RenderOptions extends ExportDOMOptions {} + +interface EmailAudioRenderOptions extends RenderOptions { + target: 'email'; + postUrl: string; +} + +interface DefaultAudioRenderOptions extends RenderOptions { + target?: string; + postUrl?: string; +} + +type AudioRenderOptions = EmailAudioRenderOptions | DefaultAudioRenderOptions; -export function renderAudioNode(node, options = {}) { +export type AudioExportDOMOutput = + | ExportDOMOutput + | ExportDOMOutput + | ExportDOMOutput; + +export function renderAudioNode(node: AudioNodeData, options: AudioRenderOptions = {}): AudioExportDOMOutput { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument!(); if (!node.src || node.src.trim() === '') { return renderEmptyContainer(document); @@ -13,13 +40,21 @@ export function renderAudioNode(node, options = {}) { const emptyThumbnailCls = getEmptyThumbnailCls(node); if (options.target === 'email') { + if (!isEmailRenderOptions(options)) { + throw new Error('renderAudioNode requires options.postUrl when options.target is "email"'); + } + return emailTemplate(node, document, options, thumbnailCls, emptyThumbnailCls); } else { return frontendTemplate(node, document, thumbnailCls, emptyThumbnailCls); } } -function frontendTemplate(node, document, thumbnailCls, emptyThumbnailCls) { +function isEmailRenderOptions(options: AudioRenderOptions): options is EmailAudioRenderOptions { + return options.target === 'email' && typeof options.postUrl === 'string' && options.postUrl.trim() !== ''; +} + +function frontendTemplate(node: AudioNodeData, document: Document, thumbnailCls: string, emptyThumbnailCls: string) { const element = document.createElement('div'); element.setAttribute('class', 'kg-card kg-audio-card'); const img = document.createElement('img'); @@ -113,7 +148,7 @@ function frontendTemplate(node, document, thumbnailCls, emptyThumbnailCls) { audioDurationTotal.textContent = '/'; const audioDUrationNode = document.createElement('span'); audioDUrationNode.setAttribute('class', 'kg-audio-duration'); - audioDUrationNode.textContent = node.duration; + audioDUrationNode.textContent = String(node.duration); audioDurationTotal.appendChild(audioDUrationNode); audioPlayer.appendChild(audioDurationTotal); @@ -163,10 +198,10 @@ function frontendTemplate(node, document, thumbnailCls, emptyThumbnailCls) { audioPlayerContainer.appendChild(audioPlayer); element.appendChild(audioPlayerContainer); - return {element}; + return {element, type: 'outer' as const}; } -function emailTemplate(node, document, options, thumbnailCls, emptyThumbnailCls) { +function emailTemplate(node: AudioNodeData, document: Document, options: EmailAudioRenderOptions, thumbnailCls: string, emptyThumbnailCls: string) { const html = (` @@ -215,11 +250,10 @@ function emailTemplate(node, document, options, thumbnailCls, emptyThumbnailCls) const container = document.createElement('div'); container.innerHTML = html.trim(); - - return {element: container.firstElementChild}; + return {element: container.firstElementChild as HTMLTableElement, type: 'outer' as const}; } -function getThumbnailCls(node) { +function getThumbnailCls(node: AudioNodeData) { let thumbnailCls = 'kg-audio-thumbnail'; if (!node.thumbnailSrc) { @@ -229,7 +263,7 @@ function getThumbnailCls(node) { return thumbnailCls; } -function getEmptyThumbnailCls(node) { +function getEmptyThumbnailCls(node: AudioNodeData) { let emptyThumbnailCls = 'kg-audio-thumbnail placeholder'; if (node.thumbnailSrc) { @@ -245,4 +279,4 @@ function getFormattedDuration(duration = 200) { const paddedSeconds = String(seconds).padStart(2, '0'); const formattedDuration = `${minutes}:${paddedSeconds}`; return formattedDuration; -} \ No newline at end of file +} diff --git a/packages/kg-default-nodes/src/nodes/bookmark/BookmarkNode.ts b/packages/kg-default-nodes/src/nodes/bookmark/BookmarkNode.ts new file mode 100644 index 0000000000..d5b3912e9f --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/bookmark/BookmarkNode.ts @@ -0,0 +1,122 @@ +import {generateDecoratorNode, type DecoratorNodeProperty} from '../../generate-decorator-node.js'; +import {parseBookmarkNode} from './bookmark-parser.js'; +import {renderBookmarkNode} from './bookmark-renderer.js'; + +interface BookmarkMetadata { + icon?: string; + title?: string; + description?: string; + author?: string; + publisher?: string; + thumbnail?: string; +} + +export interface BookmarkData { + url?: string; + metadata?: BookmarkMetadata; + caption?: string; +} + +export interface BookmarkNode { + title: string; + description: string; + url: string; + caption: string; + author: string; + publisher: string; + icon: string; + thumbnail: string; +} + +const bookmarkProperties = [ + {name: 'title', default: '', wordCount: true}, + {name: 'description', default: '', wordCount: true}, + {name: 'url', default: '', urlType: 'url', wordCount: true}, + {name: 'caption', default: '', wordCount: true}, + {name: 'author', default: ''}, + {name: 'publisher', default: ''}, + {name: 'icon', urlPath: 'metadata.icon', default: '', urlType: 'url'}, + {name: 'thumbnail', urlPath: 'metadata.thumbnail', default: '', urlType: 'url'} +] as const satisfies readonly DecoratorNodeProperty[]; + +export class BookmarkNode extends generateDecoratorNode({ + nodeType: 'bookmark', + properties: bookmarkProperties, + defaultRenderFn: renderBookmarkNode +}) { + static importDOM() { + return parseBookmarkNode(this); + } + + /* override */ + constructor({url, metadata, caption}: BookmarkData = {}, key?: string) { + super({}, key); + this.__url = url || ''; + this.__icon = metadata?.icon || ''; + this.__title = metadata?.title || ''; + this.__description = metadata?.description || ''; + this.__author = metadata?.author || ''; + this.__publisher = metadata?.publisher || ''; + this.__thumbnail = metadata?.thumbnail || ''; + this.__caption = caption || ''; + } + + /* @override */ + getDataset(): Record { + const self = this.getLatest(); + return { + url: self.__url as string, + metadata: { + icon: self.__icon as string, + title: self.__title as string, + description: self.__description as string, + author: self.__author as string, + publisher: self.__publisher as string, + thumbnail: self.__thumbnail as string + }, + caption: self.__caption as string + }; + } + + /* @override */ + static importJSON(serializedNode: Record) { + const {url, metadata, caption} = serializedNode as BookmarkData; + const node = new this({ + url, + metadata, + caption + }); + return node; + } + + /* @override */ + exportJSON() { + const dataset = { + type: 'bookmark', + version: 1, + url: this.url, + metadata: { + icon: this.icon, + title: this.title, + description: this.description, + author: this.author, + publisher: this.publisher, + thumbnail: this.thumbnail + }, + caption: this.caption + }; + return dataset; + } + + isEmpty() { + return !this.url; + } +} + +export const $createBookmarkNode = (dataset: BookmarkData = {}) => { + return new BookmarkNode(dataset); +}; + +export function $isBookmarkNode(node: unknown): node is BookmarkNode { + return node instanceof BookmarkNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-parser.js b/packages/kg-default-nodes/src/nodes/bookmark/bookmark-parser.ts similarity index 73% rename from packages/kg-default-nodes/lib/nodes/bookmark/bookmark-parser.js rename to packages/kg-default-nodes/src/nodes/bookmark/bookmark-parser.ts index c626cba9f4..212755912b 100644 --- a/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-parser.js +++ b/packages/kg-default-nodes/src/nodes/bookmark/bookmark-parser.ts @@ -1,19 +1,21 @@ -export function parseBookmarkNode(BookmarkNode) { +import type {LexicalNode} from 'lexical'; + +export function parseBookmarkNode(BookmarkNode: new (data: Record) => LexicalNode) { return { - figure: (nodeElem) => { + figure: (nodeElem: HTMLElement) => { const isKgBookmarkCard = nodeElem.classList?.contains('kg-bookmark-card'); if (nodeElem.tagName === 'FIGURE' && isKgBookmarkCard) { return { - conversion(domNode) { + conversion(domNode: HTMLElement) { const url = domNode?.querySelector('.kg-bookmark-container')?.getAttribute('href'); - const icon = domNode?.querySelector('.kg-bookmark-icon')?.src; + const icon = domNode?.querySelector('.kg-bookmark-icon')?.getAttribute('src'); const title = domNode?.querySelector('.kg-bookmark-title')?.textContent; const description = domNode?.querySelector('.kg-bookmark-description')?.textContent; const author = domNode?.querySelector('.kg-bookmark-publisher')?.textContent; // NOTE: This is NOT in error. The classes are reversed for theme backwards-compatibility. const publisher = domNode?.querySelector('.kg-bookmark-author')?.textContent; // NOTE: This is NOT in error. The classes are reversed for theme backwards-compatibility. - const thumbnail = domNode?.querySelector('.kg-bookmark-thumbnail img')?.src; + const thumbnail = domNode?.querySelector('.kg-bookmark-thumbnail img')?.getAttribute('src'); const caption = domNode?.querySelector('figure.kg-bookmark-card figcaption')?.textContent; - const payload = { + const payload: Record = { url: url, metadata: { icon: icon, @@ -28,26 +30,26 @@ export function parseBookmarkNode(BookmarkNode) { const node = new BookmarkNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } return null; }, - div: (nodeElem) => { + div: (nodeElem: HTMLElement) => { if (nodeElem.nodeType === 1 && nodeElem.tagName === 'DIV' && nodeElem.className.match(/graf--mixtapeEmbed/)) { return { - conversion(domNode) { + conversion(domNode: HTMLElement) { // Grab the relevant elements - Anchor wraps most of the data const anchorElement = domNode.querySelector('.markup--mixtapeEmbed-anchor'); - const titleElement = anchorElement.querySelector('.markup--mixtapeEmbed-strong'); - const descElement = anchorElement.querySelector('.markup--mixtapeEmbed-em'); + const titleElement = anchorElement?.querySelector('.markup--mixtapeEmbed-strong'); + const descElement = anchorElement?.querySelector('.markup--mixtapeEmbed-em'); // Image is a top level field inside it's own a tag - const imgElement = domNode.querySelector('.mixtapeImage'); + const imgElement = domNode.querySelector('.mixtapeImage') as HTMLElement | null; - domNode.querySelector('br').remove(); + domNode.querySelector('br')?.remove(); // Grab individual values from the elements - const url = anchorElement.getAttribute('href'); + const url = anchorElement?.getAttribute('href') ?? ''; let title = ''; let description = ''; let thumbnail = ''; @@ -55,28 +57,28 @@ export function parseBookmarkNode(BookmarkNode) { if (titleElement && titleElement.innerHTML) { title = titleElement.innerHTML.trim(); // Cleanup anchor so we can see what's left now that we've processed title - anchorElement.removeChild(titleElement); + titleElement.remove(); } if (descElement && descElement.innerHTML) { description = descElement.innerHTML.trim(); // Cleanup anchor so we can see what's left now that we've processed description - anchorElement.removeChild(descElement); + descElement.remove(); } // Publisher is the remaining text in the anchor, once title & desc are removed - let publisher = anchorElement.innerHTML.trim(); + const publisher = anchorElement?.innerHTML.trim() ?? ''; // Image is optional, // The element usually still exists with an additional has.mixtapeImage--empty class and has no background image - if (imgElement && imgElement.style['background-image']) { - const match = imgElement.style['background-image'].match(/url\(([^)]*?)\)/); + if (imgElement && imgElement.style.backgroundImage) { + const match = imgElement.style.backgroundImage.match(/url\(([^)]*?)\)/); if (match?.[1]) { thumbnail = match[1].replace(/^['"]|['"]$/g, ''); } } - let payload = {url, + const payload: Record = {url, metadata: { title, description, @@ -86,7 +88,7 @@ export function parseBookmarkNode(BookmarkNode) { const node = new BookmarkNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } return null; diff --git a/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-renderer.js b/packages/kg-default-nodes/src/nodes/bookmark/bookmark-renderer.ts similarity index 86% rename from packages/kg-default-nodes/lib/nodes/bookmark/bookmark-renderer.js rename to packages/kg-default-nodes/src/nodes/bookmark/bookmark-renderer.ts index 762f62e773..a804a7e9d0 100644 --- a/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-renderer.js +++ b/packages/kg-default-nodes/src/nodes/bookmark/bookmark-renderer.ts @@ -1,12 +1,26 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {renderEmptyContainer} from '../../utils/render-empty-container'; -import {escapeHtml} from '../../utils/escape-html'; -import {truncateHtml} from '../../utils/truncate'; +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions} from '../../export-dom.js'; +import {renderEmptyContainer} from '../../utils/render-empty-container.js'; +import {escapeHtml} from '../../utils/escape-html.js'; +import {truncateHtml} from '../../utils/truncate.js'; + +interface BookmarkNodeData { + url: string; + title: string; + description: string; + icon: string; + author: string; + publisher: string; + thumbnail: string; + caption: string; +} + +interface RenderOptions extends ExportDOMOptions {} -export function renderBookmarkNode(node, options = {}) { +export function renderBookmarkNode(node: BookmarkNodeData, options: RenderOptions = {}) { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument!(); if (!node.url || node.url.trim() === '') { return renderEmptyContainer(document); @@ -19,7 +33,7 @@ export function renderBookmarkNode(node, options = {}) { } } -function emailTemplate(node, document) { +function emailTemplate(node: BookmarkNodeData, document: Document) { const title = escapeHtml(node.title); const publisher = escapeHtml(node.publisher); const author = escapeHtml(node.author); @@ -103,10 +117,10 @@ function emailTemplate(node, document) { `; element.innerHTML = html; - return {element}; + return {element, type: 'outer' as const}; } -function frontendTemplate(node, document) { +function frontendTemplate(node: BookmarkNodeData, document: Document) { const element = document.createElement('figure'); const caption = node.caption; let cardClass = 'kg-card kg-bookmark-card'; @@ -138,39 +152,39 @@ function frontendTemplate(node, document) { metadata.setAttribute('class','kg-bookmark-metadata'); content.appendChild(metadata); - metadata.icon = node.icon; - if (metadata.icon) { + const nodeIcon = node.icon; + if (nodeIcon) { const icon = document.createElement('img'); icon.setAttribute('class','kg-bookmark-icon'); - icon.src = metadata.icon; + icon.src = nodeIcon; icon.alt = ''; metadata.appendChild(icon); } - metadata.publisher = node.publisher; - if (metadata.publisher) { + const nodePublisher = node.publisher; + if (nodePublisher) { const publisher = document.createElement('span'); publisher.setAttribute('class','kg-bookmark-author'); // NOTE: This is NOT in error. The classes are reversed for theme backwards-compatibility. - publisher.textContent = metadata.publisher; + publisher.textContent = nodePublisher; metadata.appendChild(publisher); } - metadata.author = node.author; - if (metadata.author) { + const nodeAuthor = node.author; + if (nodeAuthor) { const author = document.createElement('span'); author.setAttribute('class','kg-bookmark-publisher'); // NOTE: This is NOT in error. The classes are reversed for theme backwards-compatibility. - author.textContent = metadata.author; + author.textContent = nodeAuthor; metadata.appendChild(author); } - metadata.thumbnail = node.thumbnail; - if (metadata.thumbnail) { + const nodeThumbnail = node.thumbnail; + if (nodeThumbnail) { const thumbnailDiv = document.createElement('div'); thumbnailDiv.setAttribute('class','kg-bookmark-thumbnail'); container.appendChild(thumbnailDiv); const thumbnail = document.createElement('img'); - thumbnail.src = metadata.thumbnail; + thumbnail.src = nodeThumbnail; thumbnail.alt = ''; thumbnail.setAttribute('onerror',`this.style.display = 'none'`); // Hide thumbnail div if image fails to load thumbnailDiv.appendChild(thumbnail); @@ -182,5 +196,5 @@ function frontendTemplate(node, document) { element.appendChild(figCaption); } - return {element}; + return {element, type: 'outer' as const}; } diff --git a/packages/kg-default-nodes/src/nodes/button/ButtonNode.ts b/packages/kg-default-nodes/src/nodes/button/ButtonNode.ts new file mode 100644 index 0000000000..d21987a74d --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/button/ButtonNode.ts @@ -0,0 +1,31 @@ +import {generateDecoratorNode, type DecoratorNodeData, type DecoratorNodeProperty, type DecoratorNodeValueMap} from '../../generate-decorator-node.js'; +import {parseButtonNode} from './button-parser.js'; +import {renderButtonNode} from './button-renderer.js'; + +const buttonProperties = [ + {name: 'buttonText', default: ''}, + {name: 'alignment', default: 'center'}, + {name: 'buttonUrl', default: '', urlType: 'url'} +] as const satisfies readonly DecoratorNodeProperty[]; + +export type ButtonData = DecoratorNodeData; + +export interface ButtonNode extends DecoratorNodeValueMap {} + +export class ButtonNode extends generateDecoratorNode({ + nodeType: 'button', + properties: buttonProperties, + defaultRenderFn: renderButtonNode +}) { + static importDOM() { + return parseButtonNode(this); + } +} + +export const $createButtonNode = (dataset: ButtonData = {}) => { + return new ButtonNode(dataset); +}; + +export function $isButtonNode(node: unknown): node is ButtonNode { + return node instanceof ButtonNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/button/button-parser.js b/packages/kg-default-nodes/src/nodes/button/button-parser.ts similarity index 64% rename from packages/kg-default-nodes/lib/nodes/button/button-parser.js rename to packages/kg-default-nodes/src/nodes/button/button-parser.ts index 7e6e326f1b..3972425514 100644 --- a/packages/kg-default-nodes/lib/nodes/button/button-parser.js +++ b/packages/kg-default-nodes/src/nodes/button/button-parser.ts @@ -1,10 +1,12 @@ -export function parseButtonNode(ButtonNode) { +import type {LexicalNode} from 'lexical'; + +export function parseButtonNode(ButtonNode: new (data: Record) => LexicalNode) { return { - div: (nodeElem) => { + div: (nodeElem: HTMLElement) => { const isButtonCard = nodeElem.classList?.contains('kg-button-card'); if (nodeElem.tagName === 'DIV' && isButtonCard) { return { - conversion(domNode) { + conversion(domNode: HTMLElement) { const alignmentClass = nodeElem.className.match(/kg-align-(left|center)/); let alignment; @@ -13,10 +15,10 @@ export function parseButtonNode(ButtonNode) { } const buttonNode = domNode?.querySelector('.kg-btn'); - const buttonUrl = buttonNode.getAttribute('href'); - const buttonText = buttonNode.textContent; + const buttonUrl = buttonNode?.getAttribute('href') ?? ''; + const buttonText = buttonNode?.textContent ?? ''; - const payload = { + const payload: Record = { buttonText: buttonText, alignment: alignment, buttonUrl: buttonUrl @@ -25,7 +27,7 @@ export function parseButtonNode(ButtonNode) { const node = new ButtonNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } return null; diff --git a/packages/kg-default-nodes/lib/nodes/button/button-renderer.js b/packages/kg-default-nodes/src/nodes/button/button-renderer.ts similarity index 75% rename from packages/kg-default-nodes/lib/nodes/button/button-renderer.js rename to packages/kg-default-nodes/src/nodes/button/button-renderer.ts index d72ee8cc56..e23c508dcd 100644 --- a/packages/kg-default-nodes/lib/nodes/button/button-renderer.js +++ b/packages/kg-default-nodes/src/nodes/button/button-renderer.ts @@ -1,11 +1,20 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {renderEmptyContainer} from '../../utils/render-empty-container'; -import {renderEmailButton} from '../../utils/render-helpers/email-button'; -import {html} from '../../utils/tagged-template-fns.mjs'; +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions} from '../../export-dom.js'; +import {renderEmptyContainer} from '../../utils/render-empty-container.js'; +import {renderEmailButton} from '../../utils/render-helpers/email-button.js'; +import {html} from '../../utils/tagged-template-fns.js'; + +interface ButtonNodeData { + buttonUrl: string; + buttonText: string; + alignment: string; +} + +interface RenderOptions extends ExportDOMOptions {} -export function renderButtonNode(node, options = {}) { +export function renderButtonNode(node: ButtonNodeData, options: RenderOptions = {}) { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument!(); if (!node.buttonUrl || node.buttonUrl.trim() === '') { return renderEmptyContainer(document); @@ -18,7 +27,7 @@ export function renderButtonNode(node, options = {}) { } } -function frontendTemplate(node, document) { +function frontendTemplate(node: ButtonNodeData, document: Document) { const cardClasses = getCardClasses(node); const cardDiv = document.createElement('div'); @@ -30,10 +39,10 @@ function frontendTemplate(node, document) { button.textContent = node.buttonText || 'Button Title'; cardDiv.appendChild(button); - return {element: cardDiv}; + return {element: cardDiv, type: 'outer' as const}; } -function emailTemplate(node, options, document) { +function emailTemplate(node: ButtonNodeData, options: RenderOptions, document: Document) { const {buttonUrl, buttonText} = node; let cardHtml; @@ -55,7 +64,7 @@ function emailTemplate(node, options, document) { const element = document.createElement('p'); element.innerHTML = cardHtml; - return {element}; + return {element, type: 'outer' as const}; } else if (options.feature?.emailCustomizationAlpha) { const buttonHtml = renderEmailButton({ alignment: node.alignment, @@ -78,7 +87,7 @@ function emailTemplate(node, options, document) { const element = document.createElement('div'); element.innerHTML = cardHtml; - return {element, type: 'inner'}; + return {element, type: 'inner' as const}; } else { cardHtml = html`
@@ -94,12 +103,12 @@ function emailTemplate(node, options, document) { const element = document.createElement('p'); element.innerHTML = cardHtml; - return {element}; + return {element, type: 'outer' as const}; } } -function getCardClasses(node) { - let cardClasses = ['kg-card kg-button-card']; +function getCardClasses(node: ButtonNodeData) { + const cardClasses = ['kg-card kg-button-card']; if (node.alignment) { cardClasses.push(`kg-align-${node.alignment}`); diff --git a/packages/kg-default-nodes/src/nodes/call-to-action/CallToActionNode.ts b/packages/kg-default-nodes/src/nodes/call-to-action/CallToActionNode.ts new file mode 100644 index 0000000000..ebd400b114 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/call-to-action/CallToActionNode.ts @@ -0,0 +1,45 @@ +import {generateDecoratorNode, type DecoratorNodeData, type DecoratorNodeProperty, type DecoratorNodeValueMap} from '../../generate-decorator-node.js'; +import {renderCallToActionNode} from './calltoaction-renderer.js'; +import {parseCallToActionNode} from './calltoaction-parser.js'; + +const callToActionProperties = [ + {name: 'layout', default: 'minimal'}, + {name: 'alignment', default: 'left'}, + {name: 'textValue', default: '', wordCount: true}, + {name: 'showButton', default: true}, + {name: 'showDividers', default: true}, + {name: 'buttonText', default: 'Learn more'}, + {name: 'buttonUrl', default: ''}, + {name: 'buttonColor', default: '#000000'}, + {name: 'buttonTextColor', default: '#ffffff'}, + {name: 'hasSponsorLabel', default: true}, + {name: 'sponsorLabel', default: '

SPONSORED

'}, + {name: 'backgroundColor', default: 'grey'}, + {name: 'linkColor', default: 'text'}, + {name: 'imageUrl', default: '' as string | null}, + {name: 'imageWidth', default: null as number | null}, + {name: 'imageHeight', default: null as number | null} +] as const satisfies readonly DecoratorNodeProperty[]; + +export type CallToActionData = DecoratorNodeData; + +export interface CallToActionNode extends DecoratorNodeValueMap {} + +export class CallToActionNode extends generateDecoratorNode({ + nodeType: 'call-to-action', + hasVisibility: true, + properties: callToActionProperties, + defaultRenderFn: renderCallToActionNode +}) { + static importDOM() { + return parseCallToActionNode(this); + } +} + +export const $createCallToActionNode = (dataset?: CallToActionData) => { + return new CallToActionNode(dataset); +}; + +export const $isCallToActionNode = (node: unknown): node is CallToActionNode => { + return node instanceof CallToActionNode; +}; diff --git a/packages/kg-default-nodes/lib/nodes/call-to-action/calltoaction-parser.js b/packages/kg-default-nodes/src/nodes/call-to-action/calltoaction-parser.ts similarity index 82% rename from packages/kg-default-nodes/lib/nodes/call-to-action/calltoaction-parser.js rename to packages/kg-default-nodes/src/nodes/call-to-action/calltoaction-parser.ts index f8e93f0324..c6d3e1e8c1 100644 --- a/packages/kg-default-nodes/lib/nodes/call-to-action/calltoaction-parser.js +++ b/packages/kg-default-nodes/src/nodes/call-to-action/calltoaction-parser.ts @@ -1,19 +1,20 @@ -import {rgbToHex} from '../../utils/rgb-to-hex'; +import type {LexicalNode} from 'lexical'; +import {rgbToHex} from '../../utils/rgb-to-hex.js'; import {readImageAttributesFromElement} from '../../utils/read-image-attributes-from-element.js'; -export function parseCallToActionNode(CallToActionNode) { +export function parseCallToActionNode(CallToActionNode: new (data: Record) => LexicalNode) { return { - div: (nodeElem) => { + div: (nodeElem: HTMLElement) => { const isCallToActionElement = nodeElem.classList?.contains('kg-cta-card'); if (isCallToActionElement) { return { - conversion(domNode) { + conversion(domNode: HTMLElement) { const div = domNode; const layout = div.getAttribute('data-layout') || 'minimal'; const alignment = div.getAttribute('data-alignment') || 'left'; const textValueElement = domNode.querySelector('.kg-cta-text'); - const buttonElement = domNode.querySelector('.kg-cta-button'); - const buttonStyles = buttonElement?.style || {}; + const buttonElement = domNode.querySelector('.kg-cta-button') as HTMLElement | null; + const buttonStyles = buttonElement?.style || {} as CSSStyleDeclaration; const buttonColor = buttonStyles.backgroundColor || '#000000'; const buttonTextColor = buttonStyles.color || '#ffffff'; const sponsorLabelElement = domNode.querySelector('.kg-cta-sponsor-label'); @@ -23,7 +24,7 @@ export function parseCallToActionNode(CallToActionNode) { const showDividers = div.classList.contains('kg-cta-has-dividers'); const imageContainer = domNode.querySelector('.kg-cta-image-container'); const imageElement = imageContainer?.querySelector('img'); - let imageData = { + const imageData: {imageUrl: string | number; imageWidth: string | number | null; imageHeight: string | number | null} = { imageUrl: '', imageWidth: null, imageHeight: null @@ -43,13 +44,13 @@ export function parseCallToActionNode(CallToActionNode) { sponsorLabelElement.innerHTML = `

${sponsorLabelElement.innerHTML.trim()}

`; } - const payload = { + const payload: Record = { layout: layout, alignment: alignment, - textValue: textValueElement.textContent.trim() || '', + textValue: textValueElement?.textContent?.trim() || '', showButton: buttonElement ? true : false, showDividers: showDividers, - buttonText: buttonElement?.textContent.trim() || '', + buttonText: buttonElement?.textContent?.trim() || '', buttonUrl: buttonElement?.getAttribute('href'), buttonColor: rgbToHex(buttonColor), buttonTextColor: rgbToHex(buttonTextColor), @@ -64,7 +65,7 @@ export function parseCallToActionNode(CallToActionNode) { const node = new CallToActionNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } return null; diff --git a/packages/kg-default-nodes/lib/nodes/call-to-action/calltoaction-renderer.js b/packages/kg-default-nodes/src/nodes/call-to-action/calltoaction-renderer.ts similarity index 88% rename from packages/kg-default-nodes/lib/nodes/call-to-action/calltoaction-renderer.js rename to packages/kg-default-nodes/src/nodes/call-to-action/calltoaction-renderer.ts index 1be610bbd4..f4fa0df4e7 100644 --- a/packages/kg-default-nodes/lib/nodes/call-to-action/calltoaction-renderer.js +++ b/packages/kg-default-nodes/src/nodes/call-to-action/calltoaction-renderer.ts @@ -1,12 +1,41 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {renderWithVisibility} from '../../utils/visibility'; -import {getResizedImageDimensions} from '../../utils/get-resized-image-dimensions'; -import {isLocalContentImage} from '../../utils/is-local-content-image'; -import {buildCleanBasicHtmlForElement} from '../../utils/build-clean-basic-html-for-element'; +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions} from '../../export-dom.js'; +import {renderWithVisibility, type RenderOutput, type Visibility} from '../../utils/visibility.js'; +import {getResizedImageDimensions} from '../../utils/get-resized-image-dimensions.js'; +import {isLocalContentImage} from '../../utils/is-local-content-image.js'; +import {buildCleanBasicHtmlForElement} from '../../utils/build-clean-basic-html-for-element.js'; -const showButton = dataset => dataset.showButton && dataset.buttonUrl && dataset.buttonText; +interface CTADataset { + layout: string; + alignment: string; + textValue: string; + showButton: boolean; + showDividers: boolean; + buttonText: string; + buttonUrl: string; + buttonColor: string; + buttonTextColor: string; + hasSponsorLabel: boolean; + backgroundColor: string; + sponsorLabel: string; + imageUrl: string; + imageWidth: number; + imageHeight: number; + linkColor: string; +} + +interface CTARenderOptions extends ExportDOMOptions { + design?: { buttonStyle?: string }; + imageOptimization?: { internalImageSizes?: Record }; +} + +interface CTANodeData extends CTADataset { + visibility?: Visibility; +} + +const showButton = (dataset: CTADataset) => dataset.showButton && dataset.buttonUrl && dataset.buttonText; -const wrapWithLink = (dataset, content) => { +const wrapWithLink = (dataset: CTADataset, content: string) => { if (!showButton(dataset)) { return content; } @@ -14,9 +43,9 @@ const wrapWithLink = (dataset, content) => { return `${content}`; }; -function ctaCardTemplate(dataset) { +function ctaCardTemplate(dataset: CTADataset) { // Add validation for buttonColor - if (!dataset.buttonColor || !dataset.buttonColor.match(/^[a-zA-Z\d-]+|#([a-fA-F\d]{3}|[a-fA-F\d]{6})$/)) { + if (!dataset.buttonColor || !dataset.buttonColor.match(/^(?:[a-zA-Z\d-]+|#(?:[a-fA-F\d]{3}|[a-fA-F\d]{6}))$/)) { dataset.buttonColor = 'accent'; } const buttonAccent = dataset.buttonColor === 'accent' ? 'kg-style-accent' : ''; @@ -58,7 +87,7 @@ function ctaCardTemplate(dataset) { `; } -function emailCTATemplate(dataset, options = {}) { +function emailCTATemplate(dataset: CTADataset, options: CTARenderOptions = {}) { // accent button color backgrounds are set in main template styles, // for other button colors we need to set the background color explicitly let buttonStyle = dataset.buttonColor === 'accent' @@ -85,7 +114,7 @@ function emailCTATemplate(dataset, options = {}) { `; } - let imageDimensions; + let imageDimensions: { width: number; height: number } | undefined; if (dataset.imageUrl && dataset.imageWidth && dataset.imageHeight) { imageDimensions = { @@ -100,9 +129,12 @@ function emailCTATemplate(dataset, options = {}) { if (dataset.layout === 'minimal' && dataset.imageUrl) { if (isLocalContentImage(dataset.imageUrl, options.siteUrl) && options.canTransformImage?.(dataset.imageUrl)) { - const [, imagesPath, filename] = dataset.imageUrl.match(/(.*\/content\/images)\/(.*)/); - const iconSize = options?.imageOptimization?.internalImageSizes?.['email-cta-minimal-image'] || {width: 256, height: 256}; // default to 256 since we know the image is a square - dataset.imageUrl = `${imagesPath}/size/w${iconSize.width}h${iconSize.height}/${filename}`; + const match = dataset.imageUrl.match(/(.*\/content\/images)\/(.*)/); + if (match) { + const [, imagesPath, filename] = match; + const iconSize = options?.imageOptimization?.internalImageSizes?.['email-cta-minimal-image'] || {width: 256, height: 256}; // default to 256 since we know the image is a square + dataset.imageUrl = `${imagesPath}/size/w${iconSize.width}h${iconSize.height}/${filename}`; + } } } @@ -335,9 +367,9 @@ function emailCTATemplate(dataset, options = {}) { } } -export function renderCallToActionNode(node, options = {}) { +export function renderCallToActionNode(node: CTANodeData, options: CTARenderOptions = {}) { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument!(); const dataset = { layout: node.layout, alignment: node.alignment, @@ -364,24 +396,24 @@ export function renderCallToActionNode(node, options = {}) { } if (options.target === 'email') { - const emailDoc = options.createDocument(); + const emailDoc = options.createDocument!(); const emailDiv = emailDoc.createElement('div'); emailDiv.innerHTML = emailCTATemplate(dataset, options); - return renderWithVisibility({element: emailDiv.firstElementChild}, node.visibility, options); + return renderWithVisibility({element: emailDiv.firstElementChild as RenderOutput['element'], type: 'outer' as const}, node.visibility, options); } - const element = document.createElement('div'); - if (dataset.hasSponsorLabel) { - const cleanBasicHtml = buildCleanBasicHtmlForElement(element); + const cleanBasicHtml = buildCleanBasicHtmlForElement(document.createElement('div')); const cleanedHtml = cleanBasicHtml(dataset.sponsorLabel, {firstChildInnerContent: true}); - dataset.sponsorLabel = cleanedHtml; + dataset.sponsorLabel = cleanedHtml || ''; } + + const element = document.createElement('div'); const htmlString = ctaCardTemplate(dataset); element.innerHTML = htmlString?.trim(); - return renderWithVisibility({element: element.firstElementChild}, node.visibility, options); + return renderWithVisibility({element: element.firstElementChild as RenderOutput['element'], type: 'outer' as const}, node.visibility, options); } diff --git a/packages/kg-default-nodes/src/nodes/callout/CalloutNode.ts b/packages/kg-default-nodes/src/nodes/callout/CalloutNode.ts new file mode 100644 index 0000000000..94bdbda9e4 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/callout/CalloutNode.ts @@ -0,0 +1,47 @@ +import {generateDecoratorNode, type DecoratorNodeProperty} from '../../generate-decorator-node.js'; +import {renderCalloutNode} from './callout-renderer.js'; +import {parseCalloutNode} from './callout-parser.js'; + +export interface CalloutData { + calloutText?: string; + calloutEmoji?: string; + backgroundColor?: string; +} + +export interface CalloutNode { + calloutText: string; + calloutEmoji: string; + backgroundColor: string; +} + +const calloutProperties = [ + {name: 'calloutText', default: '', wordCount: true}, + {name: 'calloutEmoji', default: '💡'}, + {name: 'backgroundColor', default: 'blue'} +] as const satisfies readonly DecoratorNodeProperty[]; + +export class CalloutNode extends generateDecoratorNode({ + nodeType: 'callout', + properties: calloutProperties, + defaultRenderFn: renderCalloutNode +}) { + /* override */ + constructor({calloutText, calloutEmoji, backgroundColor}: CalloutData = {}, key?: string) { + super({}, key); + this.__calloutText = calloutText || ''; + this.__calloutEmoji = calloutEmoji ?? '💡'; + this.__backgroundColor = backgroundColor || 'blue'; + } + + static importDOM() { + return parseCalloutNode(this); + } +} + +export function $isCalloutNode(node: unknown): node is CalloutNode { + return node instanceof CalloutNode; +} + +export const $createCalloutNode = (dataset: CalloutData = {}) => { + return new CalloutNode(dataset); +}; diff --git a/packages/kg-default-nodes/lib/nodes/callout/callout-parser.js b/packages/kg-default-nodes/src/nodes/callout/callout-parser.ts similarity index 71% rename from packages/kg-default-nodes/lib/nodes/callout/callout-parser.js rename to packages/kg-default-nodes/src/nodes/callout/callout-parser.ts index b55e80d202..6de92c3cd7 100644 --- a/packages/kg-default-nodes/lib/nodes/callout/callout-parser.js +++ b/packages/kg-default-nodes/src/nodes/callout/callout-parser.ts @@ -1,20 +1,21 @@ -const getColorTag = (nodeElem) => { +import type {LexicalNode} from 'lexical'; +const getColorTag = (nodeElem: HTMLElement) => { const colorClass = nodeElem.classList?.value?.match(/kg-callout-card-(\w+)/); return colorClass && colorClass[1]; }; -export function parseCalloutNode(CalloutNode) { +export function parseCalloutNode(CalloutNode: new (data: Record) => LexicalNode) { return { - div: (nodeElem) => { + div: (nodeElem: HTMLElement) => { const isKgCalloutCard = nodeElem.classList?.contains('kg-callout-card'); if (nodeElem.tagName === 'DIV' && isKgCalloutCard) { return { - conversion(domNode) { + conversion(domNode: HTMLElement) { const textNode = domNode?.querySelector('.kg-callout-text'); const emojiNode = domNode?.querySelector('.kg-callout-emoji'); const color = getColorTag(domNode); - const payload = { + const payload: Record = { calloutText: textNode && textNode.innerHTML.trim() || '', calloutEmoji: emojiNode && emojiNode.innerHTML.trim() || '', backgroundColor: color @@ -23,7 +24,7 @@ export function parseCalloutNode(CalloutNode) { const node = new CalloutNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } return null; diff --git a/packages/kg-default-nodes/lib/nodes/callout/callout-renderer.js b/packages/kg-default-nodes/src/nodes/callout/callout-renderer.ts similarity index 73% rename from packages/kg-default-nodes/lib/nodes/callout/callout-renderer.js rename to packages/kg-default-nodes/src/nodes/callout/callout-renderer.ts index 948c0eedbb..2978fc9741 100644 --- a/packages/kg-default-nodes/lib/nodes/callout/callout-renderer.js +++ b/packages/kg-default-nodes/src/nodes/callout/callout-renderer.ts @@ -1,9 +1,18 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {cleanDOM} from '../../utils/clean-dom'; +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions} from '../../export-dom.js'; +import {cleanDOM} from '../../utils/clean-dom.js'; + +interface CalloutNodeData { + backgroundColor: string; + calloutEmoji: string; + calloutText: string; +} + +interface RenderOptions extends ExportDOMOptions {} -export function renderCalloutNode(node, options = {}) { +export function renderCalloutNode(node: CalloutNodeData, options: RenderOptions = {}) { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument!(); const element = document.createElement('div'); // backgroundColor can end up with `rgba(0, 0, 0, 0)` from old mobiledoc copy/paste @@ -34,5 +43,5 @@ export function renderCalloutNode(node, options = {}) { textElement.innerHTML = temporaryContainer.innerHTML; element.appendChild(textElement); - return {element}; + return {element, type: 'outer' as const}; } diff --git a/packages/kg-default-nodes/src/nodes/codeblock/CodeBlockNode.ts b/packages/kg-default-nodes/src/nodes/codeblock/CodeBlockNode.ts new file mode 100644 index 0000000000..ca08aee0f2 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/codeblock/CodeBlockNode.ts @@ -0,0 +1,35 @@ +import {generateDecoratorNode, type DecoratorNodeData, type DecoratorNodeProperty, type DecoratorNodeValueMap} from '../../generate-decorator-node.js'; +import {parseCodeBlockNode} from './codeblock-parser.js'; +import {renderCodeBlockNode} from './codeblock-renderer.js'; + +const codeBlockProperties = [ + {name: 'code', default: '', wordCount: true}, + {name: 'language', default: ''}, + {name: 'caption', default: '', urlType: 'html', wordCount: true} +] as const satisfies readonly DecoratorNodeProperty[]; + +export type CodeBlockData = DecoratorNodeData; + +export interface CodeBlockNode extends DecoratorNodeValueMap {} + +export class CodeBlockNode extends generateDecoratorNode({ + nodeType: 'codeblock', + properties: codeBlockProperties, + defaultRenderFn: renderCodeBlockNode +}) { + static importDOM() { + return parseCodeBlockNode(this); + } + + isEmpty() { + return !this.__code; + } +} + +export function $createCodeBlockNode(dataset: CodeBlockData = {}) { + return new CodeBlockNode(dataset); +} + +export function $isCodeBlockNode(node: unknown): node is CodeBlockNode { + return node instanceof CodeBlockNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/codeblock/codeblock-parser.js b/packages/kg-default-nodes/src/nodes/codeblock/codeblock-parser.ts similarity index 50% rename from packages/kg-default-nodes/lib/nodes/codeblock/codeblock-parser.js rename to packages/kg-default-nodes/src/nodes/codeblock/codeblock-parser.ts index 8c22de773b..bfcb7de559 100644 --- a/packages/kg-default-nodes/lib/nodes/codeblock/codeblock-parser.js +++ b/packages/kg-default-nodes/src/nodes/codeblock/codeblock-parser.ts @@ -1,52 +1,53 @@ -import {readCaptionFromElement} from '../../utils/read-caption-from-element'; +import type {LexicalNode} from 'lexical'; +import {readCaptionFromElement} from '../../utils/read-caption-from-element.js'; -export function parseCodeBlockNode(CodeBlockNode) { +export function parseCodeBlockNode(CodeBlockNode: new (data: Record) => LexicalNode) { return { - figure: (nodeElem) => { + figure: (nodeElem: HTMLElement) => { const pre = nodeElem.querySelector('pre'); if (nodeElem.tagName === 'FIGURE' && pre) { return { - conversion(domNode) { - let code = pre.querySelector('code'); - let figcaption = domNode.querySelector('figcaption'); - + conversion(domNode: HTMLElement) { + const code = pre.querySelector('code'); + const figcaption = domNode.querySelector('figcaption'); + // if there's no caption the pre key should pick it up if (!code || !figcaption) { return null; } - - let payload = { + + const payload: Record = { code: code.textContent, caption: readCaptionFromElement(domNode) }; - - let preClass = pre.getAttribute('class') || ''; - let codeClass = code.getAttribute('class') || ''; - let langRegex = /lang(?:uage)?-(.*?)(?:\s|$)/i; - let languageMatches = preClass.match(langRegex) || codeClass.match(langRegex); + + const preClass = pre.getAttribute('class') || ''; + const codeClass = code.getAttribute('class') || ''; + const langRegex = /lang(?:uage)?-(.*?)(?:\s|$)/i; + const languageMatches = preClass.match(langRegex) || codeClass.match(langRegex); if (languageMatches) { payload.language = languageMatches[1].toLowerCase(); } - + const node = new CodeBlockNode(payload); return {node}; }, - priority: 2 // falls back to pre if no caption + priority: 2 as const // falls back to pre if no caption }; } return null; }, pre: () => ({ - conversion(domNode) { + conversion(domNode: HTMLElement) { if (domNode.tagName === 'PRE') { - let [codeElement] = domNode.children; + const [codeElement] = domNode.children; if (codeElement && codeElement.tagName === 'CODE') { - let payload = {code: codeElement.textContent}; - let preClass = domNode.getAttribute('class') || ''; - let codeClass = codeElement.getAttribute('class') || ''; - let langRegex = /lang(?:uage)?-(.*?)(?:\s|$)/i; - let languageMatches = preClass.match(langRegex) || codeClass.match(langRegex); + const payload: Record = {code: codeElement.textContent}; + const preClass = domNode.getAttribute('class') || ''; + const codeClass = codeElement.getAttribute('class') || ''; + const langRegex = /lang(?:uage)?-(.*?)(?:\s|$)/i; + const languageMatches = preClass.match(langRegex) || codeClass.match(langRegex); if (languageMatches) { payload.language = languageMatches[1].toLowerCase(); } @@ -57,7 +58,7 @@ export function parseCodeBlockNode(CodeBlockNode) { return null; }, - priority: 1 + priority: 1 as const }) }; } diff --git a/packages/kg-default-nodes/lib/nodes/codeblock/codeblock-renderer.js b/packages/kg-default-nodes/src/nodes/codeblock/codeblock-renderer.ts similarity index 55% rename from packages/kg-default-nodes/lib/nodes/codeblock/codeblock-renderer.js rename to packages/kg-default-nodes/src/nodes/codeblock/codeblock-renderer.ts index 1889b7d926..a1e3fe7719 100644 --- a/packages/kg-default-nodes/lib/nodes/codeblock/codeblock-renderer.js +++ b/packages/kg-default-nodes/src/nodes/codeblock/codeblock-renderer.ts @@ -1,9 +1,18 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {renderEmptyContainer} from '../../utils/render-empty-container'; +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions} from '../../export-dom.js'; +import {renderEmptyContainer} from '../../utils/render-empty-container.js'; + +interface CodeBlockNodeData { + code: string; + language: string; + caption: string; +} + +interface RenderOptions extends ExportDOMOptions {} -export function renderCodeBlockNode(node, options = {}) { +export function renderCodeBlockNode(node: CodeBlockNodeData, options: RenderOptions = {}) { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument!(); if (!node.code || node.code.trim() === '') { return renderEmptyContainer(document); @@ -20,16 +29,16 @@ export function renderCodeBlockNode(node, options = {}) { pre.appendChild(code); if (node.caption) { - let figure = document.createElement('figure'); + const figure = document.createElement('figure'); figure.setAttribute('class', 'kg-card kg-code-card'); figure.appendChild(pre); - let figcaption = document.createElement('figcaption'); + const figcaption = document.createElement('figcaption'); figcaption.innerHTML = node.caption; figure.appendChild(figcaption); - return {element: figure}; + return {element: figure, type: 'outer' as const}; } else { - return {element: pre}; + return {element: pre, type: 'outer' as const}; } } diff --git a/packages/kg-default-nodes/src/nodes/email-cta/EmailCtaNode.ts b/packages/kg-default-nodes/src/nodes/email-cta/EmailCtaNode.ts new file mode 100644 index 0000000000..5d6468bfc8 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/email-cta/EmailCtaNode.ts @@ -0,0 +1,31 @@ +import {generateDecoratorNode, type DecoratorNodeData, type DecoratorNodeProperty, type DecoratorNodeValueMap} from '../../generate-decorator-node.js'; +import {renderEmailCtaNode} from './email-cta-renderer.js'; + +const emailCtaProperties = [ + {name: 'alignment', default: 'left'}, + {name: 'buttonText', default: ''}, + {name: 'buttonUrl', default: '', urlType: 'url'}, + {name: 'html', default: '', urlType: 'html'}, + {name: 'segment', default: 'status:free'}, + {name: 'showButton', default: false}, + {name: 'showDividers', default: true} +] as const satisfies readonly DecoratorNodeProperty[]; + +export type EmailCtaData = DecoratorNodeData; + +export interface EmailCtaNode extends DecoratorNodeValueMap {} + +export class EmailCtaNode extends generateDecoratorNode({ + nodeType: 'email-cta', + properties: emailCtaProperties, + defaultRenderFn: renderEmailCtaNode +}) { +} + +export const $createEmailCtaNode = (dataset: EmailCtaData = {}) => { + return new EmailCtaNode(dataset); +}; + +export function $isEmailCtaNode(node: unknown): node is EmailCtaNode { + return node instanceof EmailCtaNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/email-cta/email-cta-renderer.js b/packages/kg-default-nodes/src/nodes/email-cta/email-cta-renderer.ts similarity index 75% rename from packages/kg-default-nodes/lib/nodes/email-cta/email-cta-renderer.js rename to packages/kg-default-nodes/src/nodes/email-cta/email-cta-renderer.ts index 6019b35ef5..8136c949f0 100644 --- a/packages/kg-default-nodes/lib/nodes/email-cta/email-cta-renderer.js +++ b/packages/kg-default-nodes/src/nodes/email-cta/email-cta-renderer.ts @@ -1,12 +1,25 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {removeCodeWrappersFromHelpers, removeSpaces, wrapReplacementStrings} from '../../utils/replacement-strings'; -import {escapeHtml} from '../../utils/escape-html'; -import {renderEmptyContainer} from '../../utils/render-empty-container'; +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions} from '../../export-dom.js'; +import {removeCodeWrappersFromHelpers, removeSpaces, wrapReplacementStrings} from '../../utils/replacement-strings.js'; +import {escapeHtml} from '../../utils/escape-html.js'; +import {renderEmptyContainer} from '../../utils/render-empty-container.js'; -export function renderEmailCtaNode(node, options = {}) { +interface EmailCtaNodeData { + html: string; + buttonText: string; + buttonUrl: string; + showButton: boolean; + alignment: string; + segment: string; + showDividers: boolean; +} + +interface RenderOptions extends ExportDOMOptions {} + +export function renderEmailCtaNode(node: EmailCtaNodeData, options: RenderOptions = {}) { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument!(); const {html, buttonText, buttonUrl, showButton, alignment, segment, showDividers} = node; const hasButton = showButton && !!buttonText && !!buttonUrl; @@ -55,5 +68,5 @@ export function renderEmailCtaNode(node, options = {}) { element.appendChild(document.createElement('hr')); } - return {element}; -} \ No newline at end of file + return {element, type: 'outer' as const}; +} diff --git a/packages/kg-default-nodes/src/nodes/email/EmailNode.ts b/packages/kg-default-nodes/src/nodes/email/EmailNode.ts new file mode 100644 index 0000000000..e3b173f83c --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/email/EmailNode.ts @@ -0,0 +1,25 @@ +import {generateDecoratorNode, type DecoratorNodeData, type DecoratorNodeProperty, type DecoratorNodeValueMap} from '../../generate-decorator-node.js'; +import {renderEmailNode} from './email-renderer.js'; + +const emailProperties = [ + {name: 'html', default: '', urlType: 'html'} +] as const satisfies readonly DecoratorNodeProperty[]; + +export type EmailData = DecoratorNodeData; + +export interface EmailNode extends DecoratorNodeValueMap {} + +export class EmailNode extends generateDecoratorNode({ + nodeType: 'email', + properties: emailProperties, + defaultRenderFn: renderEmailNode +}) { +} + +export const $createEmailNode = (dataset: EmailData = {}) => { + return new EmailNode(dataset); +}; + +export function $isEmailNode(node: unknown): node is EmailNode { + return node instanceof EmailNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/email/email-renderer.js b/packages/kg-default-nodes/src/nodes/email/email-renderer.ts similarity index 53% rename from packages/kg-default-nodes/lib/nodes/email/email-renderer.js rename to packages/kg-default-nodes/src/nodes/email/email-renderer.ts index 127e49ba33..e88bf2f2a7 100644 --- a/packages/kg-default-nodes/lib/nodes/email/email-renderer.js +++ b/packages/kg-default-nodes/src/nodes/email/email-renderer.ts @@ -1,10 +1,18 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {removeSpaces, removeCodeWrappersFromHelpers, wrapReplacementStrings} from '../../utils/replacement-strings'; -import {renderEmptyContainer} from '../../utils/render-empty-container'; +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions, ExportDOMOutput} from '../../export-dom.js'; +import {removeSpaces, removeCodeWrappersFromHelpers, wrapReplacementStrings} from '../../utils/replacement-strings.js'; +import {renderEmptyContainer} from '../../utils/render-empty-container.js'; +import type {EmptyContainerOutput} from '../../utils/render-empty-container.js'; -export function renderEmailNode(node, options = {}) { +interface EmailNodeData { + html: string; +} + +interface RenderOptions extends ExportDOMOptions {} + +export function renderEmailNode(node: EmailNodeData, options: RenderOptions = {}): EmptyContainerOutput | ExportDOMOutput { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument!(); const html = node.html; @@ -19,5 +27,5 @@ export function renderEmailNode(node, options = {}) { // `type: 'inner'` will render only the innerHTML of the element // @see @tryghost/kg-lexical-html-renderer package - return {element, type: 'inner'}; -} \ No newline at end of file + return {element, type: 'inner' as const}; +} diff --git a/packages/kg-default-nodes/src/nodes/embed/EmbedNode.ts b/packages/kg-default-nodes/src/nodes/embed/EmbedNode.ts new file mode 100644 index 0000000000..197bea55d0 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/embed/EmbedNode.ts @@ -0,0 +1,42 @@ +import {generateDecoratorNode, type DecoratorNodeData, type DecoratorNodeProperty, type DecoratorNodeValueMap} from '../../generate-decorator-node.js'; +import {parseEmbedNode} from './embed-parser.js'; +import {renderEmbedNode} from './embed-renderer.js'; + +const embedProperties = [ + {name: 'url', default: '', urlType: 'url'}, + {name: 'embedType', default: ''}, + {name: 'html', default: ''}, + { + name: 'metadata', + get default() { + return {} as Record; + } + }, + {name: 'caption', default: '', wordCount: true} +] as const satisfies readonly DecoratorNodeProperty[]; + +export type EmbedData = DecoratorNodeData; + +export interface EmbedNode extends DecoratorNodeValueMap {} + +export class EmbedNode extends generateDecoratorNode({ + nodeType: 'embed', + properties: embedProperties, + defaultRenderFn: renderEmbedNode +}) { + static importDOM() { + return parseEmbedNode(this); + } + + isEmpty() { + return !this.__url && !this.__html; + } +} + +export const $createEmbedNode = (dataset: EmbedData = {}) => { + return new EmbedNode(dataset); +}; + +export function $isEmbedNode(node: unknown): node is EmbedNode { + return node instanceof EmbedNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/embed/embed-parser.js b/packages/kg-default-nodes/src/nodes/embed/embed-parser.ts similarity index 72% rename from packages/kg-default-nodes/lib/nodes/embed/embed-parser.js rename to packages/kg-default-nodes/src/nodes/embed/embed-parser.ts index ecde2910ed..e05d0e0a63 100644 --- a/packages/kg-default-nodes/lib/nodes/embed/embed-parser.js +++ b/packages/kg-default-nodes/src/nodes/embed/embed-parser.ts @@ -1,14 +1,15 @@ +import type {LexicalNode} from 'lexical'; import {readCaptionFromElement} from '../../utils/read-caption-from-element.js'; // TODO: add NFT card parser -export function parseEmbedNode(EmbedNode) { +export function parseEmbedNode(EmbedNode: new (data: Record) => LexicalNode) { return { - figure: (nodeElem) => { + figure: (nodeElem: HTMLElement) => { if (nodeElem.nodeType === 1 && nodeElem.tagName === 'FIGURE') { const iframe = nodeElem.querySelector('iframe'); if (iframe) { return { - conversion(domNode) { + conversion(domNode: HTMLElement) { const payload = _createPayloadForIframe(iframe); if (!payload) { @@ -20,30 +21,30 @@ export function parseEmbedNode(EmbedNode) { const node = new EmbedNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } const blockquote = nodeElem.querySelector('blockquote'); if (blockquote) { return { - conversion(domNode) { + conversion(domNode: HTMLElement) { const link = domNode.querySelector('a'); if (!link) { return null; } - let url = link.getAttribute('href'); + const url = link.getAttribute('href'); // If we don't have a url, or it's not an absolute URL, we can't handle this if (!url || !url.match(/^https?:\/\//i)) { return null; } - let payload = {url: url}; + const payload: Record = {url: url}; // append caption, remove element from blockquote payload.caption = readCaptionFromElement(domNode); - let figcaption = domNode.querySelector('figcaption'); + const figcaption = domNode.querySelector('figcaption'); figcaption?.remove(); payload.html = domNode.innerHTML; @@ -51,17 +52,21 @@ export function parseEmbedNode(EmbedNode) { const node = new EmbedNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } } return null; }, - iframe: (nodeElem) => { + iframe: (nodeElem: HTMLElement) => { if (nodeElem.nodeType === 1 && nodeElem.tagName === 'IFRAME') { return { - conversion(domNode) { - const payload = _createPayloadForIframe(domNode); + conversion(domNode: HTMLElement) { + if (domNode.tagName !== 'IFRAME') { + return null; + } + + const payload = _createPayloadForIframe(domNode as HTMLIFrameElement); if (!payload) { return null; @@ -70,7 +75,7 @@ export function parseEmbedNode(EmbedNode) { const node = new EmbedNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } return null; @@ -78,7 +83,7 @@ export function parseEmbedNode(EmbedNode) { }; } -function _createPayloadForIframe(iframe) { +function _createPayloadForIframe(iframe: HTMLIFrameElement) { // If we don't have a src Or it's not an absolute URL, we can't handle this // This regex handles http://, https:// or // if (!iframe.src || !iframe.src.match(/^(https?:)?\/\//i)) { @@ -90,11 +95,11 @@ function _createPayloadForIframe(iframe) { iframe.src = `https:${iframe.src}`; } - let payload = { + const payload: Record = { url: iframe.src }; payload.html = iframe.outerHTML; return payload; -} \ No newline at end of file +} diff --git a/packages/kg-default-nodes/lib/nodes/embed/embed-renderer.js b/packages/kg-default-nodes/src/nodes/embed/embed-renderer.ts similarity index 80% rename from packages/kg-default-nodes/lib/nodes/embed/embed-renderer.js rename to packages/kg-default-nodes/src/nodes/embed/embed-renderer.ts index 9592f36b5f..d9d2cb234f 100644 --- a/packages/kg-default-nodes/lib/nodes/embed/embed-renderer.js +++ b/packages/kg-default-nodes/src/nodes/embed/embed-renderer.ts @@ -1,11 +1,29 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {renderEmptyContainer} from '../../utils/render-empty-container'; -import twitterRenderer from './types/twitter'; +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions} from '../../export-dom.js'; +import {renderEmptyContainer} from '../../utils/render-empty-container.js'; +import twitterRenderer from './types/twitter.js'; -export function renderEmbedNode(node, options = {}) { +interface EmbedNodeData { + embedType: string; + html: string; + url: string; + caption: string; + metadata: { + thumbnail_url?: string; + thumbnail_width?: number; + thumbnail_height?: number; + tweet_data?: Record; + [key: string]: unknown; + }; + isEmpty: () => boolean; +} + +interface RenderOptions extends ExportDOMOptions {} + +export function renderEmbedNode(node: EmbedNodeData, options: RenderOptions = {}) { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument!(); const embedType = node.embedType; if (embedType === 'twitter') { @@ -15,7 +33,7 @@ export function renderEmbedNode(node, options = {}) { return renderTemplate(node, document, options); } -function renderTemplate(node, document, options) { +function renderTemplate(node: EmbedNodeData, document: Document, options: RenderOptions) { if (node.isEmpty()) { return renderEmptyContainer(document); } @@ -26,7 +44,7 @@ function renderTemplate(node, document, options) { const figure = document.createElement('figure'); figure.setAttribute('class', 'kg-card kg-embed-card'); - if (isEmail && isVideoWithThumbnail) { + if (isEmail && isVideoWithThumbnail && metadata.thumbnail_width && metadata.thumbnail_height) { const emailTemplateMaxWidth = 600; const thumbnailAspectRatio = metadata.thumbnail_width / metadata.thumbnail_height; const spacerWidth = Math.round(emailTemplateMaxWidth / 4); @@ -69,5 +87,5 @@ function renderTemplate(node, document, options) { figure.setAttribute('class', `${figure.getAttribute('class')} kg-card-hascaption`); } - return {element: figure}; -} \ No newline at end of file + return {element: figure, type: 'outer' as const}; +} diff --git a/packages/kg-default-nodes/lib/nodes/embed/types/twitter.js b/packages/kg-default-nodes/src/nodes/embed/types/twitter.ts similarity index 79% rename from packages/kg-default-nodes/lib/nodes/embed/types/twitter.js rename to packages/kg-default-nodes/src/nodes/embed/types/twitter.ts index 9f8cedfed2..ccc435c217 100644 --- a/packages/kg-default-nodes/lib/nodes/embed/types/twitter.js +++ b/packages/kg-default-nodes/src/nodes/embed/types/twitter.ts @@ -1,7 +1,63 @@ import {DateTime} from 'luxon'; -import toArray from 'lodash/toArray'; -export default function render(node, document, options) { +interface TweetPublicMetrics { + retweet_count: number; + like_count: number; +} + +interface TweetEntities { + mentions?: TwitterEntity[]; + urls?: TwitterEntity[]; + hashtags?: TwitterEntity[]; +} + +interface TweetAttachments { + media_keys?: string[]; + poll_ids?: string[]; +} + +interface TweetIncludes { + media: Array<{ preview_image_url?: string; url?: string }>; +} + +interface TweetData { + id?: string; + text?: string; + created_at?: string; + author_id?: string; + public_metrics?: TweetPublicMetrics; + users?: TwitterUser[]; + entities?: TweetEntities; + attachments?: TweetAttachments; + includes?: TweetIncludes; + [key: string]: unknown; +} + +interface TwitterNode { + html: string; + caption?: string; + metadata?: { + tweet_data?: TweetData; + }; +} + +interface TwitterUser { + id: string; + name?: string; + username?: string; + profile_image_url?: string; +} + +interface TwitterEntity { + start: number; + end: number; + url?: string; + display_url?: string; + username?: string; + tag?: string; +} + +export default function render(node: TwitterNode, document: Document, options: Record) { const metadata = node.metadata; const figure = document.createElement('figure'); @@ -12,7 +68,7 @@ export default function render(node, document, options) { const tweetData = metadata && metadata.tweet_data; const isEmail = options.target === 'email'; - if (tweetData && isEmail) { + if (tweetData && isEmail && tweetData.id) { const tweetId = tweetData.id; const numberFormatter = new Intl.NumberFormat('en-US', { style: 'decimal', @@ -20,29 +76,31 @@ export default function render(node, document, options) { unitDisplay: 'narrow', maximumFractionDigits: 1 }); - const retweetCount = numberFormatter.format(tweetData.public_metrics.retweet_count); - const likeCount = numberFormatter.format(tweetData.public_metrics.like_count); - const authorUser = tweetData.users && tweetData.users.find(user => user.id === tweetData.author_id); - const tweetTime = DateTime.fromISO(tweetData.created_at).toLocaleString(DateTime.TIME_SIMPLE); - const tweetDate = DateTime.fromISO(tweetData.created_at).toLocaleString(DateTime.DATE_MED); + const retweetCount = numberFormatter.format(tweetData.public_metrics?.retweet_count ?? 0); + const likeCount = numberFormatter.format(tweetData.public_metrics?.like_count ?? 0); + const authorUser = tweetData.users && tweetData.users.find((user: TwitterUser) => user.id === tweetData.author_id); + const parsedCreatedAt = tweetData.created_at ? DateTime.fromISO(tweetData.created_at) : null; + const tweetTime = parsedCreatedAt?.isValid ? parsedCreatedAt.toLocaleString(DateTime.TIME_SIMPLE) : ''; + const tweetDate = parsedCreatedAt?.isValid ? parsedCreatedAt.toLocaleString(DateTime.DATE_MED) : ''; const mentions = tweetData.entities && tweetData.entities.mentions || []; const urls = tweetData.entities && tweetData.entities.urls || []; const hashtags = tweetData.entities && tweetData.entities.hashtags || []; - const entities = mentions.concat(urls).concat(hashtags).sort((a, b) => a.start - b.start); + const entities = mentions.concat(urls).concat(hashtags).sort((a: TwitterEntity, b: TwitterEntity) => a.start - b.start); let tweetContent = tweetData.text; let tweetImageUrl = null; - const hasImageOrVideo = tweetData.attachments && tweetData.attachments && tweetData.attachments.media_keys; + const firstMedia = tweetData.includes?.media?.[0]; + const hasImageOrVideo = tweetData.attachments?.media_keys && firstMedia; if (hasImageOrVideo) { - tweetImageUrl = tweetData.includes.media[0].preview_image_url || tweetData.includes.media[0].url; + tweetImageUrl = firstMedia.preview_image_url || firstMedia.url; } const hasPoll = tweetData.attachments && tweetData.attachments && tweetData.attachments.poll_ids; if (mentions) { let last = 0; - let parts = []; - let content = toArray(tweetContent); + const parts = []; + const content = Array.from(tweetContent ?? ''); for (const entity of entities) { let type = 'text'; let data = content.slice(entity.start, entity.end + 1).join('').replace(/\n/g, '
'); @@ -165,5 +223,5 @@ export default function render(node, document, options) { figure.setAttribute('class', `${figure.getAttribute('class')} kg-card-hascaption`); } - return {element: figure}; + return {element: figure, type: 'outer' as const}; } diff --git a/packages/kg-default-nodes/src/nodes/file/FileNode.ts b/packages/kg-default-nodes/src/nodes/file/FileNode.ts new file mode 100644 index 0000000000..3732fd1864 --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/file/FileNode.ts @@ -0,0 +1,54 @@ +import {generateDecoratorNode, type DecoratorNodeData, type DecoratorNodeProperty, type DecoratorNodeValueMap} from '../../generate-decorator-node.js'; +import {renderFileNode} from './file-renderer.js'; +import {parseFileNode} from './file-parser.js'; +import {bytesToSize} from '../../utils/size-byte-converter.js'; + +const fileProperties = [ + {name: 'src', default: '', urlType: 'url'}, + {name: 'fileTitle', default: '', wordCount: true}, + {name: 'fileCaption', default: '', wordCount: true}, + {name: 'fileName', default: ''}, + {name: 'fileSize', default: 0} +] as const satisfies readonly DecoratorNodeProperty[]; + +export type FileData = DecoratorNodeData; + +export interface FileNode extends DecoratorNodeValueMap {} + +export class FileNode extends generateDecoratorNode({ + nodeType: 'file', + properties: fileProperties, + defaultRenderFn: renderFileNode +}) { + /* @override */ + exportJSON() { + const {src, fileTitle, fileCaption, fileName, fileSize} = this; + const isBlob = src && src.startsWith('data:'); + + return { + type: 'file' as const, + version: 1, + src: isBlob ? '' : src, + fileTitle, + fileCaption, + fileName, + fileSize + }; + } + + static importDOM() { + return parseFileNode(this); + } + + get formattedFileSize() { + return bytesToSize(this.fileSize); + } +} + +export function $isFileNode(node: unknown): node is FileNode { + return node instanceof FileNode; +} + +export const $createFileNode = (dataset: FileData = {}) => { + return new FileNode(dataset); +}; diff --git a/packages/kg-default-nodes/lib/nodes/file/file-parser.js b/packages/kg-default-nodes/src/nodes/file/file-parser.ts similarity index 62% rename from packages/kg-default-nodes/lib/nodes/file/file-parser.js rename to packages/kg-default-nodes/src/nodes/file/file-parser.ts index c3740879ff..f7f2cb9ac0 100644 --- a/packages/kg-default-nodes/lib/nodes/file/file-parser.js +++ b/packages/kg-default-nodes/src/nodes/file/file-parser.ts @@ -1,19 +1,20 @@ -import {sizeToBytes} from '../../utils/size-byte-converter'; +import type {LexicalNode} from 'lexical'; +import {sizeToBytes} from '../../utils/size-byte-converter.js'; -export function parseFileNode(FileNode) { +export function parseFileNode(FileNode: new (data: Record) => LexicalNode) { return { - div: (nodeElem) => { + div: (nodeElem: HTMLElement) => { const isKgFileCard = nodeElem.classList?.contains('kg-file-card'); if (nodeElem.tagName === 'DIV' && isKgFileCard) { return { - conversion(domNode) { + conversion(domNode: HTMLElement) { const link = domNode.querySelector('a'); - const src = link.getAttribute('href'); + const src = link?.getAttribute('href') ?? ''; const fileTitle = domNode.querySelector('.kg-file-card-title')?.textContent || ''; const fileCaption = domNode.querySelector('.kg-file-card-caption')?.textContent || ''; const fileName = domNode.querySelector('.kg-file-card-filename')?.textContent || ''; - let fileSize = sizeToBytes(domNode.querySelector('.kg-file-card-filesize')?.textContent || ''); - const payload = { + const fileSize = sizeToBytes(domNode.querySelector('.kg-file-card-filesize')?.textContent || ''); + const payload: Record = { src, fileTitle, fileCaption, @@ -24,7 +25,7 @@ export function parseFileNode(FileNode) { const node = new FileNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } return null; diff --git a/packages/kg-default-nodes/lib/nodes/file/file-renderer.js b/packages/kg-default-nodes/src/nodes/file/file-renderer.ts similarity index 73% rename from packages/kg-default-nodes/lib/nodes/file/file-renderer.js rename to packages/kg-default-nodes/src/nodes/file/file-renderer.ts index e5570b1588..1e0638ad4c 100644 --- a/packages/kg-default-nodes/lib/nodes/file/file-renderer.js +++ b/packages/kg-default-nodes/src/nodes/file/file-renderer.ts @@ -1,11 +1,23 @@ -import {addCreateDocumentOption} from '../../utils/add-create-document-option'; -import {renderEmptyContainer} from '../../utils/render-empty-container'; -import {escapeHtml} from '../../utils/escape-html'; -import {bytesToSize} from '../../utils/size-byte-converter'; +import {addCreateDocumentOption} from '../../utils/add-create-document-option.js'; +import type {ExportDOMOptions} from '../../export-dom.js'; +import {renderEmptyContainer} from '../../utils/render-empty-container.js'; +import {escapeHtml} from '../../utils/escape-html.js'; +import {bytesToSize} from '../../utils/size-byte-converter.js'; + +interface FileNodeData { + src: string; + fileTitle: string; + fileCaption: string; + fileName: string; + fileSize: number; + formattedFileSize: string; +} + +interface RenderOptions extends ExportDOMOptions {} -export function renderFileNode(node, options = {}) { +export function renderFileNode(node: FileNodeData, options: RenderOptions = {}) { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument!(); if (!node.src || node.src.trim() === '') { return renderEmptyContainer(document); @@ -18,7 +30,15 @@ export function renderFileNode(node, options = {}) { } } -function emailTemplate(node, document, options) { +function wrapWithAnchor(content: string, href: string | undefined, cls: string, style?: string) { + if (href) { + const styleAttr = style ? ` style="${style}"` : ''; + return `${content}`; + } + return `${content}`; +} + +function emailTemplate(node: FileNodeData, document: Document, options: RenderOptions) { let iconCls; if (!node.fileTitle && !node.fileCaption) { iconCls = 'margin-top: 6px; height: 20px; width: 20px; max-width: 20px; padding-top: 4px; padding-bottom: 4px;'; @@ -26,6 +46,8 @@ function emailTemplate(node, document, options) { iconCls = 'margin-top: 6px; height: 24px; width: 24px; max-width: 24px;'; } + const href = options.postUrl || node.src || undefined; + const html = (`
@@ -35,22 +57,24 @@ function emailTemplate(node, document, options) {
${node.fileTitle ? `
- ${escapeHtml(node.fileTitle)} + ${wrapWithAnchor(escapeHtml(node.fileTitle), href, 'kg-file-title')}
` : ``} ${node.fileCaption ? `
- ${escapeHtml(node.fileCaption)} + ${wrapWithAnchor(escapeHtml(node.fileCaption), href, 'kg-file-description')}
` : ``}
- ${escapeHtml(node.fileName)} • ${bytesToSize(node.fileSize)} + ${wrapWithAnchor(`${escapeHtml(node.fileName)} • ${bytesToSize(node.fileSize)}`, href, 'kg-file-meta')}
- + ${href + ? ` - + ` + : ``}
@@ -62,10 +86,10 @@ function emailTemplate(node, document, options) { const container = document.createElement('div'); container.innerHTML = html.trim(); - return {element: container.firstElementChild}; + return {element: container.firstElementChild, type: 'outer' as const}; } -function cardTemplate(node, document) { +function cardTemplate(node: FileNodeData, document: Document) { const card = document.createElement('div'); card.setAttribute('class', 'kg-card kg-file-card'); @@ -149,5 +173,5 @@ function cardTemplate(node, document) { container.appendChild(icon); card.appendChild(container); - return {element: card}; -} \ No newline at end of file + return {element: card, type: 'outer' as const}; +} diff --git a/packages/kg-default-nodes/src/nodes/gallery/GalleryNode.ts b/packages/kg-default-nodes/src/nodes/gallery/GalleryNode.ts new file mode 100644 index 0000000000..9a1e49416d --- /dev/null +++ b/packages/kg-default-nodes/src/nodes/gallery/GalleryNode.ts @@ -0,0 +1,45 @@ +import {generateDecoratorNode, type DecoratorNodeData, type DecoratorNodeProperty, type DecoratorNodeValueMap} from '../../generate-decorator-node.js'; +import {parseGalleryNode} from './gallery-parser.js'; +import {renderGalleryNode} from './gallery-renderer.js'; + +const galleryProperties = [ + {name: 'images', default: [] as unknown[]}, + {name: 'caption', default: '', wordCount: true} +] as const satisfies readonly DecoratorNodeProperty[]; + +export type GalleryData = DecoratorNodeData; + +export interface GalleryNode extends DecoratorNodeValueMap {} + +export class GalleryNode extends generateDecoratorNode({ + nodeType: 'gallery', + properties: galleryProperties, + defaultRenderFn: renderGalleryNode +}) { + /* override */ + static get urlTransformMap() { + return { + caption: 'html', + images: { + src: 'url', + caption: 'html' + } + }; + } + + static importDOM() { + return parseGalleryNode(this); + } + + hasEditMode() { + return false; + } +} + +export const $createGalleryNode = (dataset?: GalleryData) => { + return new GalleryNode(dataset); +}; + +export function $isGalleryNode(node: unknown): node is GalleryNode { + return node instanceof GalleryNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/gallery/gallery-parser.js b/packages/kg-default-nodes/src/nodes/gallery/gallery-parser.ts similarity index 69% rename from packages/kg-default-nodes/lib/nodes/gallery/gallery-parser.js rename to packages/kg-default-nodes/src/nodes/gallery/gallery-parser.ts index 0b0b555e66..be669cc9c6 100644 --- a/packages/kg-default-nodes/lib/nodes/gallery/gallery-parser.js +++ b/packages/kg-default-nodes/src/nodes/gallery/gallery-parser.ts @@ -1,23 +1,24 @@ +import type {LexicalNode} from 'lexical'; import {readCaptionFromElement} from '../../utils/read-caption-from-element.js'; import {readImageAttributesFromElement} from '../../utils/read-image-attributes-from-element.js'; -function readGalleryImageAttributesFromElement(element, imgNum) { +function readGalleryImageAttributesFromElement(element: HTMLImageElement, imgNum: number) { const image = readImageAttributesFromElement(element); - image.fileName = element.src.match(/[^/]*$/)[0]; + image.fileName = element.src.match(/[^/]*$/)![0]; image.row = Math.floor(imgNum / 3); return image; } -export function parseGalleryNode(GalleryNode) { +export function parseGalleryNode(GalleryNode: new (data: Record) => LexicalNode) { return { - figure: (nodeElem) => { + figure: (nodeElem: HTMLElement) => { // Koenig gallery card if (nodeElem.classList?.contains('kg-gallery-card')) { return { - conversion(domNode) { - const payload = {}; + conversion(domNode: HTMLElement) { + const payload: Record = {}; const imgs = Array.from(domNode.querySelectorAll('img')); payload.images = imgs.map(readGalleryImageAttributesFromElement); @@ -26,15 +27,15 @@ export function parseGalleryNode(GalleryNode) { const node = new GalleryNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } return null; }, - div: (nodeElem) => { + div: (nodeElem: HTMLElement) => { // Medium "graf" galleries - function isGrafGallery(node) { + function isGrafGallery(node: HTMLElement) { return node.tagName === 'DIV' && node.dataset?.paragraphCount && node.querySelectorAll('img').length > 0; @@ -42,43 +43,46 @@ export function parseGalleryNode(GalleryNode) { if (isGrafGallery(nodeElem)) { return { - conversion(domNode) { - const payload = { - caption: readCaptionFromElement(domNode) - }; + conversion(domNode: HTMLElement) { + const payload: Record = {}; + const captions = [readCaptionFromElement(domNode)].filter((caption): caption is string => Boolean(caption)); // These galleries exist as a series of divs containing multiple figure+img. // Grab the first set of imgs... let imgs = Array.from(domNode.querySelectorAll('img')); // ...and then iterate over any remaining divs until we run out of matches - let nextNode = domNode.nextElementSibling; + let nextNode = domNode.nextElementSibling as HTMLElement | null; while (nextNode && isGrafGallery(nextNode)) { - let currentNode = nextNode; + const currentNode = nextNode; imgs = imgs.concat(Array.from(currentNode.querySelectorAll('img'))); const currentNodeCaption = readCaptionFromElement(currentNode); if (currentNodeCaption) { - payload.caption = `${payload.caption} / ${currentNodeCaption}`; + captions.push(currentNodeCaption); } - nextNode = currentNode.nextElementSibling; + nextNode = currentNode.nextElementSibling as HTMLElement | null; // remove nodes as we go so that they don't go through the parser currentNode.remove(); } + if (captions.length > 0) { + payload.caption = captions.join(' / '); + } + payload.images = imgs.map(readGalleryImageAttributesFromElement); const node = new GalleryNode(payload); return {node}; }, - priority: 1 + priority: 1 as const }; } // Squarespace SQS galleries - function isSqsGallery(node) { + function isSqsGallery(node: HTMLElement) { return node.tagName === 'DIV' && node.className.match(/sqs-gallery-container/) && !node.className.match(/summary-/); @@ -86,19 +90,19 @@ export function parseGalleryNode(GalleryNode) { if (isSqsGallery(nodeElem)) { return { - conversion(domNode) { - const payload = {}; + conversion(domNode: HTMLElement) { + const payload: Record = {}; // Each image exists twice... // The first image is wrapped in `