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 ? `
` : ``}
${node.fileCaption ? `
` : ``}
|
-
+ ${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 `