diff --git a/packages/kg-default-nodes/.eslintrc.js b/packages/kg-default-nodes/.eslintrc.js index fafc2745fc..c1aed6b951 100644 --- a/packages/kg-default-nodes/.eslintrc.js +++ b/packages/kg-default-nodes/.eslintrc.js @@ -1,20 +1,15 @@ module.exports = { plugins: ['ghost'], extends: [ - 'plugin:ghost/node' + 'plugin:ghost/node', + 'plugin:ghost/ts' ], - parser: '@babel/eslint-parser', - parserOptions: { - sourceType: 'module', - requireConfigFile: false, - babelOptions: { - plugins: [ - '@babel/plugin-syntax-import-assertions' - ] - } - }, env: { browser: true, node: true + }, + // this fouls up with Lexical's need to prefix with '$' for lifecycle hooks + rules: { + 'ghost/filenames/match-exported-class': 'off' } -}; +}; \ No newline at end of file diff --git a/packages/kg-default-nodes/.gitignore b/packages/kg-default-nodes/.gitignore index 8e6861d207..4837d66e23 100644 --- a/packages/kg-default-nodes/.gitignore +++ b/packages/kg-default-nodes/.gitignore @@ -1,2 +1,4 @@ cjs/ es/ +build/ +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/packages/kg-default-nodes/lib/KoenigDecoratorNode.js b/packages/kg-default-nodes/lib/KoenigDecoratorNode.js deleted file mode 100644 index f6a6537ade..0000000000 --- a/packages/kg-default-nodes/lib/KoenigDecoratorNode.js +++ /dev/null @@ -1,11 +0,0 @@ -/* eslint-disable ghost/filenames/match-exported-class */ -/* c8 ignore start */ -import {DecoratorNode} from 'lexical'; - -export class KoenigDecoratorNode extends DecoratorNode { -} - -export function $isKoenigCard(node) { - return node instanceof KoenigDecoratorNode; -} -/* c8 ignore end */ diff --git a/packages/kg-default-nodes/lib/KoenigDecoratorNode.ts b/packages/kg-default-nodes/lib/KoenigDecoratorNode.ts new file mode 100644 index 0000000000..700310f23a --- /dev/null +++ b/packages/kg-default-nodes/lib/KoenigDecoratorNode.ts @@ -0,0 +1,12 @@ +/* eslint-disable ghost/filenames/match-exported-class */ +/* c8 ignore start */ +import {DecoratorNode} from 'lexical'; +import type {LexicalNode} from 'lexical'; + +export class KoenigDecoratorNode extends DecoratorNode { +} + +export function $isKoenigCard(node: LexicalNode): boolean { + return node instanceof KoenigDecoratorNode; +} +/* c8 ignore end */ \ No newline at end of file diff --git a/packages/kg-default-nodes/lib/generate-decorator-node.js b/packages/kg-default-nodes/lib/generate-decorator-node.ts similarity index 54% rename from packages/kg-default-nodes/lib/generate-decorator-node.js rename to packages/kg-default-nodes/lib/generate-decorator-node.ts index ba81ef8682..4c8331a683 100644 --- a/packages/kg-default-nodes/lib/generate-decorator-node.js +++ b/packages/kg-default-nodes/lib/generate-decorator-node.ts @@ -1,55 +1,92 @@ +import {NodeKey} from 'lexical'; import {KoenigDecoratorNode} from './KoenigDecoratorNode'; import readTextContent from './utils/read-text-content'; /** * Validates the required arguments passed to `generateDecoratorNode` + * @see https://lexical.dev/docs/concepts/nodes#extending-decoratornode */ -function validateArguments(nodeType, properties) { + +// NOTE: There's some liberal use of 'any' in this file despite moving it to typescript. This is a difficult one to get right +// because we're generating classes. + +export type KoenigDecoratorProperty = { + name: string; + default?: number | string | boolean | null | object; + urlType?: 'url'|'html'|'markdown'; + wordCount?: boolean; +}; + +export type KoenigDecoratorRendererOutput = { + element: HTMLElement, + type?: 'inner' | 'value'; +} + +export type KoenigDecoratorNodeProperties = KoenigDecoratorProperty[]; + +function validateArguments(nodeType: string, properties: KoenigDecoratorNodeProperties) { /* eslint-disable ghost/ghost-custom/no-native-error */ /* 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) => { 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 */ } -/** - * @typedef {Object} DecoratorNodeProperty - * @property {string} name - The property's name. - * @property {*} default - The property's default value - * @property {('url'|'html'|'markdown'|null)} urlType - If the property contains a URL, the URL's type: 'url', 'html' or 'markdown'. Use 'url' is the property contains only a URL, 'html' or 'markdown' if the property contains HTML or markdown code, that may contain URLs. - * @property {boolean} wordCount - Whether the property should be counted in the word count - * - * @param {string} nodeType – The node's type (must be unique) - * @param {DecoratorNodeProperty[]} properties - An array of properties for the generated class - * @returns {Object} - The generated class. - */ -export function generateDecoratorNode({nodeType, properties = [], version = 1}) { +type PrivateKoenigProperty = KoenigDecoratorProperty & {privateName: string}; + +// NOTE: This is really what the return type is, but we wrap it in the GeneratedKoenigDecoratorNode class to make it a bit easier to interpret +// the 'magic' behind the scenes that generates the KoenigDecoratorNode classes. +// type GenerateKoenigDecoratorNodeFn = (options: GenerateKoenigDecoratorNodeOptions) => typeof generateDecoratorNode.prototype; +type GenerateKoenigDecoratorNodeFn = (options: GenerateKoenigDecoratorNodeOptions) => typeof GeneratedKoenigDecoratorNode; + +type GenerateKoenigDecoratorNodeOptions = { + nodeType: string; + properties?: KoenigDecoratorNodeProperties; + version?: number; +}; + +type SerializedKoenigDecoratorNode = { + type: string; + version: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; +class GeneratedKoenigDecoratorNode extends KoenigDecoratorNode { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(data?: any) { + super(); + this.generateDecoratorNode(data); + } +} +export const generateDecoratorNode: GenerateKoenigDecoratorNodeFn = ({nodeType, properties = [], version = 1}) => { 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) => { + const __properties: PrivateKoenigProperty[] = properties.map((prop) => { return {...prop, privateName: `__${prop.name}`}; }); class GeneratedDecoratorNode extends KoenigDecoratorNode { - constructor(data = {}, key) { + // allow any type here for ease of use + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(data: any = {}, key?: NodeKey) { super(key); - properties.forEach((prop) => { + __properties.forEach((prop) => { if (typeof prop.default === 'boolean') { this[prop.privateName] = data[prop.name] ?? prop.default; } else { @@ -58,22 +95,11 @@ export function generateDecoratorNode({nodeType, properties = [], version = 1}) }); } - /** - * Returns the node's unique type - * @extends DecoratorNode - * @see https://lexical.dev/docs/concepts/nodes#extending-decoratornode - * @returns {string} - */ - static getType() { + static getType(): string { return nodeType; } - /** - * Creates a copy of an existing node with all its properties - * @extends DecoratorNode - * @see https://lexical.dev/docs/concepts/nodes#extending-decoratornode - */ - static clone(node) { + static clone(node: KoenigDecoratorNode) { return new this(node.getDataset(), node.__key); } @@ -83,9 +109,10 @@ export function generateDecoratorNode({nodeType, properties = [], version = 1}) * @see https://github.com/TryGhost/SDK/tree/main/packages/url-utils */ static get urlTransformMap() { - let map = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const map: any = {}; - properties.forEach((prop) => { + __properties.forEach((prop) => { if (prop.urlType) { map[prop.name] = prop.urlType; } @@ -100,9 +127,9 @@ export function generateDecoratorNode({nodeType, properties = [], version = 1}) */ getDataset() { const self = this.getLatest(); - - let dataset = {}; - properties.forEach((prop) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataset: any = {}; + __properties.forEach((prop) => { dataset[prop.name] = self[prop.privateName]; }); @@ -115,10 +142,12 @@ export function generateDecoratorNode({nodeType, properties = [], version = 1}) * @extends DecoratorNode * @param {Object} serializedNode - Lexical's representation of the node, in JSON format */ - static importJSON(serializedNode) { - const data = {}; - properties.forEach((prop) => { + static importJSON(serializedNode: SerializedKoenigDecoratorNode): KoenigDecoratorNode { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any = {}; + + __properties.forEach((prop) => { data[prop.name] = serializedNode[prop.name]; }); @@ -130,11 +159,12 @@ export function generateDecoratorNode({nodeType, properties = [], version = 1}) * @extends DecoratorNode * @see https://lexical.dev/docs/concepts/serialization#lexicalnodeexportjson */ - exportJSON() { + exportJSON(): SerializedKoenigDecoratorNode { const dataset = { type: nodeType, version: version, - ...properties.reduce((obj, prop) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...__properties.reduce((obj: any, prop) => { obj[prop.name] = this[prop.name]; return obj; }, {}) @@ -143,56 +173,37 @@ export function generateDecoratorNode({nodeType, properties = [], version = 1}) } /* c8 ignore start */ - /** - * Inserts node in the DOM. Required when extending the DecoratorNode. - * @extends DecoratorNode - * @see https://lexical.dev/docs/concepts/nodes#extending-decoratornode - */ - createDOM() { + createDOM(): HTMLElement { return document.createElement('div'); } - /** - * Required when extending the DecoratorNode - * @extends DecoratorNode - * @see https://lexical.dev/docs/concepts/nodes#extending-decoratornode - */ - updateDOM() { + updateDOM(): false { return false; } - /** - * Defines whether a node is a top-level block. - * @see https://lexical.dev/docs/api/classes/lexical.DecoratorNode#isinline - */ - isInline() { + // Defines whether a node is a top-level block. + isInline(): false { // All our cards are top-level blocks. Override if needed. return false; } /* c8 ignore stop */ - /** - * Defines whether a node has dynamic data that needs to be fetched from the server when rendering - */ - hasDynamicData() { + // Defines whether a node has dynamic data that needs to be fetched from the server when rendering + hasDynamicData(): false { return false; } - /** - * Defines whether a node has an edit mode in the editor UI - */ - hasEditMode() { + // Defines whether a node has an edit mode in the editor UI + hasEditMode(): true { // Most of our cards have an edit mode. Override if needed. return true; } - /* - * Returns the text content of the node, used by the editor to calculate the word count - * This method filters out properties without `wordCount: true` - */ - getTextContent() { + // Returns the text content of the node, used by the editor to calculate the word count + // This method filters out properties without `wordCount: true` + getTextContent(): string { const self = this.getLatest(); - const propertiesWithText = properties.filter(prop => !!prop.wordCount); + const propertiesWithText = __properties.filter(prop => !!prop.wordCount); const text = propertiesWithText.map( prop => readTextContent(self, prop.name) @@ -220,7 +231,7 @@ export function generateDecoratorNode({nodeType, properties = [], version = 1}) * * They can be used as `node.content` (getter) and `node.content = 'new value'` (setter) */ - properties.forEach((prop) => { + __properties.forEach((prop) => { Object.defineProperty(GeneratedDecoratorNode.prototype, prop.name, { get: function () { const self = this.getLatest(); @@ -234,4 +245,4 @@ export function generateDecoratorNode({nodeType, properties = [], version = 1}) }); return GeneratedDecoratorNode; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/packages/kg-default-nodes/lib/kg-default-nodes.js b/packages/kg-default-nodes/lib/kg-default-nodes.ts similarity index 100% rename from packages/kg-default-nodes/lib/kg-default-nodes.js rename to packages/kg-default-nodes/lib/kg-default-nodes.ts diff --git a/packages/kg-default-nodes/lib/nodes/ExtendedHeadingNode.js b/packages/kg-default-nodes/lib/nodes/ExtendedHeadingNode.ts similarity index 63% rename from packages/kg-default-nodes/lib/nodes/ExtendedHeadingNode.js rename to packages/kg-default-nodes/lib/nodes/ExtendedHeadingNode.ts index ca02a772ae..d8a7bd63ae 100644 --- a/packages/kg-default-nodes/lib/nodes/ExtendedHeadingNode.js +++ b/packages/kg-default-nodes/lib/nodes/ExtendedHeadingNode.ts @@ -1,5 +1,6 @@ /* eslint-disable ghost/filenames/match-exported-class */ -import {HeadingNode} from '@lexical/rich-text'; +import {HeadingNode, SerializedHeadingNode} from '@lexical/rich-text'; +import {DOMConversion, DOMConversionFn, DOMConversionMap} 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 @@ -9,51 +10,52 @@ 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)}; + +type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; export class ExtendedHeadingNode extends HeadingNode { - constructor(tag, key) { + constructor(tag: HeadingTagType, key?: string) { super(tag, key); } - static getType() { + static getType(): string { return 'extended-heading'; } - static clone(node) { + static clone(node: ExtendedHeadingNode): ExtendedHeadingNode { return new ExtendedHeadingNode(node.__tag, node.__key); } - static importDOM() { + static importDOM(): DOMConversionMap | null { const importers = HeadingNode.importDOM(); + const originalParagraphImporter = importers?.p as DOMConversionFn; return { ...importers, - p: patchParagraphConversion(importers?.p) + p: patchParagraphConversion(originalParagraphImporter) }; } - static importJSON(serializedNode) { + static importJSON(serializedNode: SerializedHeadingNode): ExtendedHeadingNode { return HeadingNode.importJSON(serializedNode); } - exportJSON() { + exportJSON(): SerializedHeadingNode { const json = super.exportJSON(); json.type = 'extended-heading'; return json; } } -function patchParagraphConversion(originalDOMConverter) { - return (node) => { +function patchParagraphConversion(originalDOMConverter: DOMConversionFn): DOMConversion | null { + 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); + const original = originalDOMConverter(node); if (original) { return original; } - const p = node; - // Word uses paragraphs with role="heading" to represent headings // and an aria-level="x" to represent the heading level const hasAriaHeadingRole = p.getAttribute('role') === 'heading'; @@ -62,10 +64,11 @@ function patchParagraphConversion(originalDOMConverter) { if (hasAriaHeadingRole && hasAriaLevel) { const level = parseInt(hasAriaLevel, 10); if (level > 0 && level < 7) { + const tag: HeadingTagType = `h${level}` as HeadingTagType; return { conversion: () => { return { - node: new ExtendedHeadingNode(`h${level}`) + node: new ExtendedHeadingNode(tag) }; }, priority: 1 diff --git a/packages/kg-default-nodes/lib/nodes/ExtendedQuoteNode.js b/packages/kg-default-nodes/lib/nodes/ExtendedQuoteNode.ts similarity index 80% rename from packages/kg-default-nodes/lib/nodes/ExtendedQuoteNode.js rename to packages/kg-default-nodes/lib/nodes/ExtendedQuoteNode.ts index 577cda211f..75e39a5daf 100644 --- a/packages/kg-default-nodes/lib/nodes/ExtendedQuoteNode.js +++ b/packages/kg-default-nodes/lib/nodes/ExtendedQuoteNode.ts @@ -1,6 +1,6 @@ /* eslint-disable ghost/filenames/match-exported-class */ -import {QuoteNode} from '@lexical/rich-text'; -import {$createLineBreakNode, $isParagraphNode} from 'lexical'; +import {QuoteNode, SerializedQuoteNode} from '@lexical/rich-text'; +import {$createLineBreakNode, $isParagraphNode, DOMConversionMap, NodeKey} 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 @@ -13,7 +13,7 @@ import {$createLineBreakNode, $isParagraphNode} from 'lexical'; export const extendedQuoteNodeReplacement = {replace: QuoteNode, with: () => new ExtendedQuoteNode()}; export class ExtendedQuoteNode extends QuoteNode { - constructor(key) { + constructor(key?: NodeKey) { super(key); } @@ -21,11 +21,11 @@ export class ExtendedQuoteNode extends QuoteNode { return 'extended-quote'; } - static clone(node) { + static clone(node: ExtendedQuoteNode): ExtendedQuoteNode { return new ExtendedQuoteNode(node.__key); } - static importDOM() { + static importDOM(): DOMConversionMap | null { const importers = QuoteNode.importDOM(); return { ...importers, @@ -33,24 +33,24 @@ export class ExtendedQuoteNode extends QuoteNode { }; } - static importJSON(serializedNode) { + static importJSON(serializedNode: SerializedQuoteNode): ExtendedQuoteNode { return QuoteNode.importJSON(serializedNode); } - exportJSON() { + exportJSON(): SerializedQuoteNode { const json = super.exportJSON(); json.type = 'extended-quote'; return json; } /* c8 ignore start */ - extractWithChild() { + extractWithChild(): true { return true; } /* c8 ignore end */ } -function convertBlockquoteElement() { +function convertBlockquoteElement(): DOMConversion { return { conversion: () => { const node = new ExtendedQuoteNode(); @@ -61,7 +61,7 @@ function convertBlockquoteElement() { // 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) => { if ($isParagraphNode(child)) { diff --git a/packages/kg-default-nodes/lib/nodes/ExtendedTextNode.js b/packages/kg-default-nodes/lib/nodes/ExtendedTextNode.ts similarity index 78% rename from packages/kg-default-nodes/lib/nodes/ExtendedTextNode.js rename to packages/kg-default-nodes/lib/nodes/ExtendedTextNode.ts index 07744fd710..7a8156a3c1 100644 --- a/packages/kg-default-nodes/lib/nodes/ExtendedTextNode.js +++ b/packages/kg-default-nodes/lib/nodes/ExtendedTextNode.ts @@ -1,5 +1,5 @@ /* eslint-disable ghost/filenames/match-exported-class */ -import {$isTextNode, TextNode} from 'lexical'; +import {$isTextNode, DOMConversion, DOMConversionFn, DOMConversionMap, LexicalNode, NodeKey, SerializedTextNode, TextNode} 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 @@ -8,52 +8,57 @@ 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?: NodeKey) { super(text, key); } - static getType() { + static getType(): string { return 'extended-text'; } - static clone(node) { + static clone(node: ExtendedTextNode): ExtendedTextNode { return new ExtendedTextNode(node.__text, node.__key); } - static importDOM() { + static importDOM(): DOMConversionMap | null { const importers = TextNode.importDOM(); + const importersSpanConversion = importers?.span; return { ...importers, span: () => ({ - conversion: patchConversion(importers?.span, convertSpanElement), + conversion: patchConversion(importersSpanConversion, convertSpanElement), priority: 1 }) }; } - static importJSON(serializedNode) { + static importJSON(serializedNode: SerializedTextNode): ExtendedTextNode { return TextNode.importJSON(serializedNode); } - exportJSON() { + exportJSON(): SerializedTextNode { const json = super.exportJSON(); json.type = 'extended-text'; return json; } - isSimpleText() { + isSimpleText(): boolean { return ( (this.__type === 'text' || this.__type === 'extended-text') && this.__mode === 0 ); } + + isInline() { + return true; + } } -function patchConversion(originalDOMConverter, convertFn) { - return (node) => { +function patchConversion(originalDOMConverter: DOMConversion, convertFn: DOMConversionFn) { + return (node: HTMLElement) => { const original = originalDOMConverter?.(node); if (!original) { return null; @@ -67,7 +72,7 @@ function patchConversion(originalDOMConverter, convertFn) { return { ...originalOutput, forChild: (lexicalNode, parent) => { - const originalForChild = originalOutput?.forChild ?? (x => x); + const originalForChild = originalOutput?.forChild; const result = originalForChild(lexicalNode, parent); if ($isTextNode(result)) { return convertFn(result, node); @@ -78,7 +83,7 @@ function patchConversion(originalDOMConverter, convertFn) { }; } -function convertSpanElement(lexicalNode, domNode) { +function convertSpanElement(lexicalNode: ExtendedTextNode, domNode: HTMLElement): ExtendedTextNode { 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/lib/nodes/TKNode.ts similarity index 66% rename from packages/kg-default-nodes/lib/nodes/TKNode.js rename to packages/kg-default-nodes/lib/nodes/TKNode.ts index f39cd03734..07e57b11c8 100644 --- a/packages/kg-default-nodes/lib/nodes/TKNode.js +++ b/packages/kg-default-nodes/lib/nodes/TKNode.ts @@ -1,28 +1,29 @@ /* eslint-disable ghost/filenames/match-exported-class */ -import {$applyNodeReplacement, TextNode} from 'lexical'; +import {$applyNodeReplacement, EditorConfig, LexicalNode, NodeKey, TextNode} from 'lexical'; +import {SerializedTextNode} from 'lexical/nodes/LexicalTextNode'; export class TKNode extends TextNode { - static getType() { + static getType(): string { return 'tk'; } - static clone(node) { + static clone(node: TKNode): TKNode { return new TKNode(node.__text, node.__key); } - constructor(text, key) { + constructor(text: string, key?: NodeKey) { 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) { + static importJSON(serializedNode: SerializedTextNode): TKNode { const node = $createTKNode(serializedNode.text); node.setFormat(serializedNode.format); node.setDetail(serializedNode.detail); @@ -31,18 +32,18 @@ export class TKNode extends TextNode { return node; } - exportJSON() { + exportJSON(): SerializedTextNode { return { ...super.exportJSON(), type: 'tk' }; } - canInsertTextBefore() { + canInsertTextBefore(): false { return false; } - isTextEntity() { + isTextEntity(): true { return true; } } @@ -52,7 +53,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): TKNode { return $applyNodeReplacement(new TKNode(text)); } @@ -61,6 +62,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: LexicalNode): node is TKNode { return node instanceof TKNode; } diff --git a/packages/kg-default-nodes/lib/nodes/aside/AsideNode.js b/packages/kg-default-nodes/lib/nodes/aside/AsideNode.ts similarity index 52% rename from packages/kg-default-nodes/lib/nodes/aside/AsideNode.js rename to packages/kg-default-nodes/lib/nodes/aside/AsideNode.ts index 995ee83e59..f001e0b6d9 100644 --- a/packages/kg-default-nodes/lib/nodes/aside/AsideNode.js +++ b/packages/kg-default-nodes/lib/nodes/aside/AsideNode.ts @@ -1,13 +1,19 @@ /* eslint-disable ghost/filenames/match-exported-class */ -import {ElementNode} from 'lexical'; -import {AsideParser} from './AsideParser'; +import {DOMConversionMap, ElementFormatType, ElementNode, LexicalNode, NodeKey, SerializedLexicalNode, Spread} from 'lexical'; +import {parseAsideNode} from './aside-parser'; + +export type SerializedAsideNode = Spread<{ + format: ElementFormatType, + indent: number, + direction: 'ltr' | 'rtl' | null +}, SerializedLexicalNode>; export class AsideNode extends ElementNode { - static getType() { + static getType(): string { return 'aside'; } - static clone(node) { + static clone(node: AsideNode): AsideNode { return new this( node.__key ); @@ -17,11 +23,11 @@ export class AsideNode extends ElementNode { return {}; } - constructor(key) { + constructor(key?: NodeKey) { super(key); } - static importJSON(serializedNode) { + static importJSON(serializedNode: SerializedAsideNode): AsideNode { const node = new this(); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); @@ -38,34 +44,33 @@ export class AsideNode extends ElementNode { return dataset; } - static importDOM() { - const parser = new AsideParser(this); - return parser.DOMConversionMap; + static importDOM(): DOMConversionMap | null { + return parseAsideNode(); } /* c8 ignore start */ - createDOM() { + createDOM(): HTMLElement { return document.createElement('div'); } - updateDOM() { + updateDOM(): false { return false; } - isInline() { + isInline(): false { return false; } - extractWithChild() { + extractWithChild(): true { return true; } /* c8 ignore stop */ } -export function $createAsideNode() { +export function $createAsideNode(): AsideNode { return new AsideNode(); } -export function $isAsideNode(node) { +export function $isAsideNode(node: LexicalNode | null): node is AsideNode { return node instanceof AsideNode; } diff --git a/packages/kg-default-nodes/lib/nodes/aside/AsideParser.js b/packages/kg-default-nodes/lib/nodes/aside/AsideParser.js deleted file mode 100644 index 662cf5234d..0000000000 --- a/packages/kg-default-nodes/lib/nodes/aside/AsideParser.js +++ /dev/null @@ -1,24 +0,0 @@ -export class AsideParser { - constructor(NodeClass) { - this.NodeClass = NodeClass; - } - - get DOMConversionMap() { - const self = this; - - return { - blockquote: () => ({ - conversion(domNode) { - const isBigQuote = domNode.classList?.contains('kg-blockquote-alt'); - if (domNode.tagName === 'BLOCKQUOTE' && isBigQuote) { - const node = new self.NodeClass(); - return {node}; - } - - return null; - }, - priority: 0 - }) - }; - } -} diff --git a/packages/kg-default-nodes/lib/nodes/aside/aside-parser.ts b/packages/kg-default-nodes/lib/nodes/aside/aside-parser.ts new file mode 100644 index 0000000000..ef204fd9e2 --- /dev/null +++ b/packages/kg-default-nodes/lib/nodes/aside/aside-parser.ts @@ -0,0 +1,21 @@ +import {DOMConversion, DOMConversionMap, DOMConversionOutput} from 'lexical'; +import {$createAsideNode} from './AsideNode'; + +export function parseAsideNode(): DOMConversionMap | null { + return { + blockquote: (nodeElem: HTMLElement): DOMConversion | null => { + const isBigQuote = nodeElem.classList?.contains('kg-blockquote-alt'); + if (nodeElem.tagName === 'BLOCKQUOTE' && isBigQuote) { + return { + conversion(): DOMConversionOutput { + const node = $createAsideNode(); + return {node}; + }, + priority: 0 + }; + } + + return null; + } + }; +} \ No newline at end of file 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 cc4dce06dd..0000000000 --- a/packages/kg-default-nodes/lib/nodes/audio/AudioNode.js +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable ghost/filenames/match-exported-class */ -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: ''} - ]} -) { - static importDOM() { - return parseAudioNode(this); - } - - exportDOM(options = {}) { - return renderAudioNode(this, options); - } -} - -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/audio/AudioNode.ts b/packages/kg-default-nodes/lib/nodes/audio/AudioNode.ts new file mode 100644 index 0000000000..2ad2d2fe7f --- /dev/null +++ b/packages/kg-default-nodes/lib/nodes/audio/AudioNode.ts @@ -0,0 +1,51 @@ +/* eslint-disable ghost/filenames/match-exported-class */ +import {DOMConversionMap, LexicalNode} from 'lexical'; +import {generateDecoratorNode, KoenigDecoratorNodeProperties} from '../../generate-decorator-node'; +import {parseAudioNode} from './audio-parser'; +import {renderAudioNode} from './audio-renderer'; + +export type AudioNodeDataset = { + duration?: number; + mimeType?: string; + src?: string; + title?: string; + thumbnailSrc?: string; +}; + +type AudioNodeProps = { + nodeType: 'audio'; + properties: KoenigDecoratorNodeProperties +}; + +const audioNodeProps: AudioNodeProps = { + nodeType: 'audio', + properties: [ + {name: 'duration', default: 0}, + {name: 'mimeType', default: ''}, + {name: 'src', default: '', urlType: 'url'}, + {name: 'title', default: ''}, + {name: 'thumbnailSrc', default: ''} + ] +}; + +export class AudioNode extends generateDecoratorNode(audioNodeProps) { + constructor(dataset: AudioNodeDataset) { + super(dataset); + } + + static importDOM(): DOMConversionMap | null { + return parseAudioNode(); + } + + exportDOM(options = {}) { + return renderAudioNode(this, options); + } +} + +export const $createAudioNode = (dataset: AudioNodeDataset) => { + return new AudioNode(dataset); +}; + +export function $isAudioNode(node: LexicalNode) { + return node instanceof AudioNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/audio/audio-parser.js b/packages/kg-default-nodes/lib/nodes/audio/audio-parser.ts similarity index 64% rename from packages/kg-default-nodes/lib/nodes/audio/audio-parser.js rename to packages/kg-default-nodes/lib/nodes/audio/audio-parser.ts index 2ffcf27d22..cac0608eab 100644 --- a/packages/kg-default-nodes/lib/nodes/audio/audio-parser.js +++ b/packages/kg-default-nodes/lib/nodes/audio/audio-parser.ts @@ -1,28 +1,33 @@ -export function parseAudioNode(AudioNode) { +import {DOMConversion, DOMConversionMap, DOMConversionOutput} from 'lexical'; +import {$createAudioNode, AudioNodeDataset} from './AudioNode'; + +export function parseAudioNode(): DOMConversionMap | null { return { - div: (nodeElem) => { + div: (nodeElem: HTMLElement): DOMConversion | null => { const isKgAudioCard = nodeElem.classList?.contains('kg-audio-card'); if (nodeElem.tagName === 'DIV' && isKgAudioCard) { return { - conversion(domNode) { + conversion(domNode: HTMLElement): DOMConversionOutput { 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 = { - src: audioSrc, - title: title + + const payload: AudioNodeDataset = { + src: audioSrc || undefined, + title: title || undefined }; + if (thumbnailSrc) { payload.thumbnailSrc = thumbnailSrc; } if (durationText) { - const [minutes, seconds = 0] = durationText.split(':'); + const [minutes, seconds = '0'] = durationText.split(':'); try { payload.duration = parseInt(minutes) * 60 + parseInt(seconds); } catch (e) { @@ -30,7 +35,7 @@ export function parseAudioNode(AudioNode) { } } - const node = new AudioNode(payload); + const node = $createAudioNode(payload); return {node}; }, priority: 1 diff --git a/packages/kg-default-nodes/lib/nodes/bookmark/BookmarkNode.js b/packages/kg-default-nodes/lib/nodes/bookmark/BookmarkNode.ts similarity index 64% rename from packages/kg-default-nodes/lib/nodes/bookmark/BookmarkNode.js rename to packages/kg-default-nodes/lib/nodes/bookmark/BookmarkNode.ts index 2eb10fa9e3..14db3d4377 100644 --- a/packages/kg-default-nodes/lib/nodes/bookmark/BookmarkNode.js +++ b/packages/kg-default-nodes/lib/nodes/bookmark/BookmarkNode.ts @@ -1,9 +1,31 @@ /* eslint-disable ghost/filenames/match-exported-class */ -import {generateDecoratorNode} from '../../generate-decorator-node'; +import {DOMConversionMap, LexicalNode, NodeKey, SerializedLexicalNode, Spread} from 'lexical'; +import {KoenigDecoratorNodeProperties, generateDecoratorNode} from '../../generate-decorator-node'; import {parseBookmarkNode} from './bookmark-parser'; import {renderBookmarkNode} from './bookmark-renderer'; -export class BookmarkNode extends generateDecoratorNode({nodeType: 'bookmark', +export type BookmarkNodeDataset = { + url?: string; + metadata?: { + icon?: string; + title?: string; + description?: string; + author?: string; + publisher?: string; + thumbnail?: string; + }; + caption?: string; +}; + +export type SerializedBookmarkNode = Spread; + +type BookmarkNodeProps = { + nodeType: 'bookmark'; + properties: KoenigDecoratorNodeProperties +}; + +const bookmarkNodeProps: BookmarkNodeProps = { + nodeType: 'bookmark', properties: [ {name: 'title', default: '', wordCount: true}, {name: 'description', default: '', wordCount: true}, @@ -13,10 +35,12 @@ export class BookmarkNode extends generateDecoratorNode({nodeType: 'bookmark', {name: 'publisher', default: ''}, {name: 'icon', default: '', urlType: 'url'}, {name: 'thumbnail', default: '', urlType: 'url'} - ]} -) { - static importDOM() { - return parseBookmarkNode(this); + ] +}; + +export class BookmarkNode extends generateDecoratorNode(bookmarkNodeProps) { + static importDOM(): DOMConversionMap | null { + return parseBookmarkNode(); } exportDOM(options = {}) { @@ -24,7 +48,7 @@ export class BookmarkNode extends generateDecoratorNode({nodeType: 'bookmark', } /* override */ - constructor({url, metadata, caption} = {}, key) { + constructor({url, metadata, caption}: BookmarkNodeDataset = {}, key?: NodeKey) { super(key); this.__url = url || ''; this.__icon = metadata?.icon || ''; @@ -37,7 +61,7 @@ export class BookmarkNode extends generateDecoratorNode({nodeType: 'bookmark', } /* @override */ - getDataset() { + getDataset(): BookmarkNodeDataset { const self = this.getLatest(); return { url: self.__url, @@ -54,7 +78,7 @@ export class BookmarkNode extends generateDecoratorNode({nodeType: 'bookmark', } /* @override */ - static importJSON(serializedNode) { + static importJSON(serializedNode: SerializedBookmarkNode): BookmarkNode { const {url, metadata, caption} = serializedNode; const node = new this({ url, @@ -65,7 +89,7 @@ export class BookmarkNode extends generateDecoratorNode({nodeType: 'bookmark', } /* @override */ - exportJSON() { + exportJSON(): SerializedBookmarkNode { const dataset = { type: 'bookmark', version: 1, @@ -83,15 +107,15 @@ export class BookmarkNode extends generateDecoratorNode({nodeType: 'bookmark', return dataset; } - isEmpty() { + isEmpty(): boolean { return !this.url; } } -export const $createBookmarkNode = (dataset) => { +export const $createBookmarkNode = (dataset: BookmarkNodeDataset) => { return new BookmarkNode(dataset); }; -export function $isBookmarkNode(node) { +export function $isBookmarkNode(node: LexicalNode) { return node instanceof BookmarkNode; } diff --git a/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-parser.js b/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-parser.ts similarity index 62% rename from packages/kg-default-nodes/lib/nodes/bookmark/bookmark-parser.js rename to packages/kg-default-nodes/lib/nodes/bookmark/bookmark-parser.ts index 8d6a7e7fd3..4ef0c934c5 100644 --- a/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-parser.js +++ b/packages/kg-default-nodes/lib/nodes/bookmark/bookmark-parser.ts @@ -1,31 +1,36 @@ -export function parseBookmarkNode(BookmarkNode) { +import {DOMConversion, DOMConversionMap, DOMConversionOutput} from 'lexical'; +import {$createBookmarkNode, BookmarkNodeDataset} from './BookmarkNode'; + +export function parseBookmarkNode(): DOMConversionMap | null { return { - figure: (nodeElem) => { + figure: (nodeElem: HTMLElement): DOMConversion | null => { const isKgBookmarkCard = nodeElem.classList?.contains('kg-bookmark-card'); if (nodeElem.tagName === 'FIGURE' && isKgBookmarkCard) { return { - conversion(domNode) { + conversion(domNode: HTMLElement): DOMConversionOutput { const url = domNode?.querySelector('.kg-bookmark-container')?.getAttribute('href'); - const icon = domNode?.querySelector('.kg-bookmark-icon')?.src; + const iconNode = domNode?.querySelector('.kg-bookmark-icon') as HTMLImageElement | null; + const icon = iconNode?.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 thumbnailNode = domNode?.querySelector('.kg-bookmark-thumbnail img') as HTMLImageElement | null; + const thumbnail = thumbnailNode?.src; const caption = domNode?.querySelector('figure.kg-bookmark-card figcaption')?.textContent; const payload = { - url: url, + url, metadata: { - icon: icon, - title: title, - description: description, - author: author, - publisher: publisher, - thumbnail: thumbnail + icon, + title, + description, + author, + publisher, + thumbnail }, - caption: caption - }; - const node = new BookmarkNode(payload); + caption + } as BookmarkNodeDataset; + const node = $createBookmarkNode(payload); return {node}; }, priority: 1 @@ -33,24 +38,24 @@ export function parseBookmarkNode(BookmarkNode) { } return null; }, - div: (nodeElem) => { + div: (nodeElem: HTMLElement): DOMConversion | null => { if (nodeElem.nodeType === 1 && nodeElem.tagName === 'DIV' && nodeElem.className.match(/graf--mixtapeEmbed/)) { return { - conversion(domNode) { + conversion(domNode: HTMLElement): DOMConversionOutput { // Grab the relevant elements - Anchor wraps most of the data - const anchorElement = domNode.querySelector('.markup--mixtapeEmbed-anchor'); + const anchorElement = domNode.querySelector('.markup--mixtapeEmbed-anchor') as HTMLAnchorElement; 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 HTMLAnchorElement; - domNode.querySelector('br').remove(); + domNode.querySelector('br')?.remove(); // Grab individual values from the elements const url = anchorElement.getAttribute('href'); - let title = ''; - let description = ''; - let thumbnail = ''; + let title; + let description; + let thumbnail; if (titleElement && titleElement.innerHTML) { title = titleElement.innerHTML.trim(); @@ -65,22 +70,23 @@ export function parseBookmarkNode(BookmarkNode) { } // 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']) { - thumbnail = imgElement.style['background-image'].match(/url\(([^)]*?)\)/)[1]; + if (imgElement) { + const imgElementStyle = imgElement.style.getPropertyValue('background-image'); + thumbnail = imgElementStyle?.match(/url\(([^)]*?)\)/)?.[1]; } - let payload = {url, + const payload = {url, metadata: { title, description, publisher, thumbnail - }}; - const node = new BookmarkNode(payload); + }} as BookmarkNodeDataset; + const node = $createBookmarkNode(payload); return {node}; }, priority: 1 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 985e4bf4e9..0000000000 --- a/packages/kg-default-nodes/lib/nodes/button/ButtonNode.js +++ /dev/null @@ -1,28 +0,0 @@ -/* eslint-disable ghost/filenames/match-exported-class */ -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'} - ]} -) { - static importDOM() { - return parseButtonNode(this); - } - - exportDOM(options = {}) { - return renderButtonNode(this, options); - } -} - -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/button/ButtonNode.ts b/packages/kg-default-nodes/lib/nodes/button/ButtonNode.ts new file mode 100644 index 0000000000..6ff6ca7bb7 --- /dev/null +++ b/packages/kg-default-nodes/lib/nodes/button/ButtonNode.ts @@ -0,0 +1,47 @@ +/* eslint-disable ghost/filenames/match-exported-class */ +import {DOMConversionMap, LexicalNode} from 'lexical'; +import {KoenigDecoratorNodeProperties, generateDecoratorNode} from '../../generate-decorator-node'; +import {parseButtonNode} from './button-parser'; +import {renderButtonNode} from './button-renderer'; + +export type ButtonNodeDataset = { + buttonText?: string | null; + alignment?: string | null; + buttonUrl?: string | null; +}; + +type ButtonNodeProps = { + nodeType: 'button'; + properties: KoenigDecoratorNodeProperties +}; + +const buttonNodeProps: ButtonNodeProps = { + nodeType: 'button', + properties: [ + {name: 'buttonText', default: ''}, + {name: 'alignment', default: 'center'}, + {name: 'buttonUrl', default: '', urlType: 'url'} + ] +}; + +export class ButtonNode extends generateDecoratorNode(buttonNodeProps) { + constructor(dataset: ButtonNodeDataset) { + super(dataset); + } + + static importDOM(): DOMConversionMap | null { + return parseButtonNode(); + } + + exportDOM(options = {}) { + return renderButtonNode(this, options); + } +} + +export const $createButtonNode = (dataset: ButtonNodeDataset) => { + return new ButtonNode(dataset); +}; + +export function $isButtonNode(node: LexicalNode) { + return node instanceof ButtonNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/button/button-parser.js b/packages/kg-default-nodes/lib/nodes/button/button-parser.ts similarity index 50% rename from packages/kg-default-nodes/lib/nodes/button/button-parser.js rename to packages/kg-default-nodes/lib/nodes/button/button-parser.ts index 7e6e326f1b..5c18bdb792 100644 --- a/packages/kg-default-nodes/lib/nodes/button/button-parser.js +++ b/packages/kg-default-nodes/lib/nodes/button/button-parser.ts @@ -1,10 +1,13 @@ -export function parseButtonNode(ButtonNode) { +import {DOMConversion, DOMConversionMap, DOMConversionOutput} from 'lexical'; +import {$createButtonNode, ButtonNodeDataset} from './ButtonNode'; + +export const parseButtonNode = (): DOMConversionMap | null => { return { - div: (nodeElem) => { + div: (nodeElem: HTMLElement): DOMConversion | null => { const isButtonCard = nodeElem.classList?.contains('kg-button-card'); if (nodeElem.tagName === 'DIV' && isButtonCard) { return { - conversion(domNode) { + conversion(domNode: HTMLElement): DOMConversionOutput { const alignmentClass = nodeElem.className.match(/kg-align-(left|center)/); let alignment; @@ -13,16 +16,16 @@ 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 = { - buttonText: buttonText, - alignment: alignment, - buttonUrl: buttonUrl - }; + buttonText, + alignment, + buttonUrl + } as ButtonNodeDataset; - const node = new ButtonNode(payload); + const node = $createButtonNode(payload); return {node}; }, priority: 1 @@ -31,4 +34,4 @@ export function parseButtonNode(ButtonNode) { return null; } }; -} +}; 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 3caaae8fdc..0000000000 --- a/packages/kg-default-nodes/lib/nodes/callout/CalloutNode.js +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable ghost/filenames/match-exported-class */ -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'} - ]} -) { - /* 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); - } - - exportDOM(options = {}) { - return renderCalloutNode(this, options); - } -} - -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/callout/CalloutNode.ts b/packages/kg-default-nodes/lib/nodes/callout/CalloutNode.ts new file mode 100644 index 0000000000..e8295c6dcf --- /dev/null +++ b/packages/kg-default-nodes/lib/nodes/callout/CalloutNode.ts @@ -0,0 +1,51 @@ +/* eslint-disable ghost/filenames/match-exported-class */ +import {KoenigDecoratorNodeProperties, generateDecoratorNode} from '../../generate-decorator-node'; +import {renderCalloutNode} from './callout-renderer'; +import {parseCalloutNode} from './callout-parser'; +import {DOMConversionMap, LexicalNode, NodeKey} from 'lexical'; + +export type CalloutNodeDataset = { + calloutText?: string; + calloutEmoji?: string; + backgroundColor?: string; +}; + +type CalloutNodeProps = { + nodeType: 'callout'; + properties: KoenigDecoratorNodeProperties +}; + +const calloutNodeProps: CalloutNodeProps = { + nodeType: 'callout', + properties: [ + {name: 'calloutText', default: '', wordCount: true}, + {name: 'calloutEmoji', default: '💡'}, + {name: 'backgroundColor', default: 'blue'} + ] +}; + +export class CalloutNode extends generateDecoratorNode(calloutNodeProps) { + /* override */ + constructor({calloutText, calloutEmoji, backgroundColor}: CalloutNodeDataset = {}, key?: NodeKey) { + super(key); + this.__calloutText = calloutText || ''; + this.__calloutEmoji = calloutEmoji !== undefined ? calloutEmoji : '💡'; + this.__backgroundColor = backgroundColor || 'blue'; + } + + static importDOM(): DOMConversionMap | null { + return parseCalloutNode(); + } + + exportDOM(options = {}) { + return renderCalloutNode(this, options); + } +} + +export const $createCalloutNode = (dataset: CalloutNodeDataset) => { + return new CalloutNode(dataset); +}; + +export function $isCalloutNode(node: LexicalNode) { + return node instanceof CalloutNode; +} \ No newline at end of file diff --git a/packages/kg-default-nodes/lib/nodes/callout/callout-parser.js b/packages/kg-default-nodes/lib/nodes/callout/callout-parser.ts similarity index 65% rename from packages/kg-default-nodes/lib/nodes/callout/callout-parser.js rename to packages/kg-default-nodes/lib/nodes/callout/callout-parser.ts index b55e80d202..2c09cfb296 100644 --- a/packages/kg-default-nodes/lib/nodes/callout/callout-parser.js +++ b/packages/kg-default-nodes/lib/nodes/callout/callout-parser.ts @@ -1,15 +1,18 @@ -const getColorTag = (nodeElem) => { +import {DOMConversion, DOMConversionMap, DOMConversionOutput} from 'lexical'; +import {$createCalloutNode, CalloutNodeDataset} from './CalloutNode'; + +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(): DOMConversionMap | null { return { - div: (nodeElem) => { + div: (nodeElem: HTMLElement): DOMConversion | null => { const isKgCalloutCard = nodeElem.classList?.contains('kg-callout-card'); if (nodeElem.tagName === 'DIV' && isKgCalloutCard) { return { - conversion(domNode) { + conversion(domNode: HTMLElement): DOMConversionOutput { const textNode = domNode?.querySelector('.kg-callout-text'); const emojiNode = domNode?.querySelector('.kg-callout-emoji'); const color = getColorTag(domNode); @@ -18,9 +21,9 @@ export function parseCalloutNode(CalloutNode) { calloutText: textNode && textNode.innerHTML.trim() || '', calloutEmoji: emojiNode && emojiNode.innerHTML.trim() || '', backgroundColor: color - }; + } as CalloutNodeDataset; - const node = new CalloutNode(payload); + const node = $createCalloutNode(payload); return {node}; }, priority: 1 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 751d20531c..0000000000 --- a/packages/kg-default-nodes/lib/nodes/codeblock/CodeBlockNode.js +++ /dev/null @@ -1,32 +0,0 @@ -/* eslint-disable ghost/filenames/match-exported-class */ -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} - ]} -) { - static importDOM() { - return parseCodeBlockNode(this); - } - - exportDOM(options = {}) { - return renderCodeBlockNode(this, options); - } - - 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/codeblock/CodeBlockNode.ts b/packages/kg-default-nodes/lib/nodes/codeblock/CodeBlockNode.ts new file mode 100644 index 0000000000..a4b8aba633 --- /dev/null +++ b/packages/kg-default-nodes/lib/nodes/codeblock/CodeBlockNode.ts @@ -0,0 +1,47 @@ +/* eslint-disable ghost/filenames/match-exported-class */ +import {DOMConversionMap, LexicalNode} from 'lexical'; +import {KoenigDecoratorNodeProperties, generateDecoratorNode} from '../../generate-decorator-node'; +import {parseCodeBlockNode} from './codeblock-parser'; +import {renderCodeBlockNode} from './codeblock-renderer'; + +export type CodeBlockNodeDataset = { + code?: string; + language?: string; + caption?: string; +}; + +type CodeBlockNodeProps = { + nodeType: 'codeblock'; + properties: KoenigDecoratorNodeProperties +}; + +const codeBlockNodeProps: CodeBlockNodeProps = { + nodeType: 'codeblock', + properties: [ + {name: 'code', default: '', wordCount: true}, + {name: 'language', default: ''}, + {name: 'caption', default: '', urlType: 'html', wordCount: true} + ] +}; + +export class CodeBlockNode extends generateDecoratorNode(codeBlockNodeProps) { + static importDOM(): DOMConversionMap | null { + return parseCodeBlockNode(); + } + + exportDOM(options = {}) { + return renderCodeBlockNode(this, options); + } + + isEmpty() { + return !this.__code; + } +} + +export function $createCodeBlockNode(dataset: CodeBlockNodeDataset) { + return new CodeBlockNode(dataset); +} + +export function $isCodeBlockNode(node: LexicalNode) { + return node instanceof CodeBlockNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/codeblock/codeblock-parser.js b/packages/kg-default-nodes/lib/nodes/codeblock/codeblock-parser.js deleted file mode 100644 index 8c22de773b..0000000000 --- a/packages/kg-default-nodes/lib/nodes/codeblock/codeblock-parser.js +++ /dev/null @@ -1,63 +0,0 @@ -import {readCaptionFromElement} from '../../utils/read-caption-from-element'; - -export function parseCodeBlockNode(CodeBlockNode) { - return { - figure: (nodeElem) => { - const pre = nodeElem.querySelector('pre'); - if (nodeElem.tagName === 'FIGURE' && pre) { - return { - conversion(domNode) { - let code = pre.querySelector('code'); - let figcaption = domNode.querySelector('figcaption'); - - // if there's no caption the pre key should pick it up - if (!code || !figcaption) { - return null; - } - - let payload = { - 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); - if (languageMatches) { - payload.language = languageMatches[1].toLowerCase(); - } - - const node = new CodeBlockNode(payload); - return {node}; - }, - priority: 2 // falls back to pre if no caption - }; - } - return null; - }, - pre: () => ({ - conversion(domNode) { - if (domNode.tagName === 'PRE') { - let [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); - if (languageMatches) { - payload.language = languageMatches[1].toLowerCase(); - } - const node = new CodeBlockNode(payload); - return {node}; - } - } - - return null; - }, - priority: 1 - }) - }; -} diff --git a/packages/kg-default-nodes/lib/nodes/codeblock/codeblock-parser.ts b/packages/kg-default-nodes/lib/nodes/codeblock/codeblock-parser.ts new file mode 100644 index 0000000000..bb9c3682e2 --- /dev/null +++ b/packages/kg-default-nodes/lib/nodes/codeblock/codeblock-parser.ts @@ -0,0 +1,67 @@ +import {DOMConversion, DOMConversionMap, DOMConversionOutput} from 'lexical'; +import {readCaptionFromElement} from '../../utils/read-caption-from-element'; +import {$createCodeBlockNode, CodeBlockNodeDataset} from './CodeBlockNode'; + +export function parseCodeBlockNode(): DOMConversionMap | null { + return { + figure: (nodeElem: HTMLElement): DOMConversion | null => { + const pre = nodeElem.querySelector('pre'); + if (nodeElem.tagName === 'FIGURE' && pre) { + return { + conversion(domNode: HTMLElement): DOMConversionOutput | null { + 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; + } + + const payload = { + code: code.textContent, + caption: readCaptionFromElement(domNode) + } as CodeBlockNodeDataset; + + 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 = $createCodeBlockNode(payload); + return {node}; + }, + priority: 2 // falls back to pre if no caption + }; + } + return null; + }, + pre: (): DOMConversion | null => ({ + conversion(domNode: HTMLElement): DOMConversionOutput | null { + if (domNode.tagName === 'PRE') { + const [codeElement] = domNode.children; + + if (codeElement && codeElement.tagName === 'CODE') { + const payload = { + code: codeElement.textContent + } as CodeBlockNodeDataset; + 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(); + } + const node = $createCodeBlockNode(payload); + return {node}; + } + } + + return null; + }, + priority: 1 + }) + }; +} diff --git a/packages/kg-default-nodes/lib/nodes/collection/CollectionNode.js b/packages/kg-default-nodes/lib/nodes/collection/CollectionNode.ts similarity index 52% rename from packages/kg-default-nodes/lib/nodes/collection/CollectionNode.js rename to packages/kg-default-nodes/lib/nodes/collection/CollectionNode.ts index c58d9a83ee..221f90c90f 100644 --- a/packages/kg-default-nodes/lib/nodes/collection/CollectionNode.js +++ b/packages/kg-default-nodes/lib/nodes/collection/CollectionNode.ts @@ -1,19 +1,36 @@ /* eslint-disable ghost/filenames/match-exported-class */ -import {generateDecoratorNode} from '../../generate-decorator-node'; +import {KoenigDecoratorNodeProperties, generateDecoratorNode} from '../../generate-decorator-node'; import {renderCollectionNode} from './collection-renderer'; import {collectionParser} from './collection-parser'; +import {DOMConversionMap, LexicalNode} from 'lexical'; -export class CollectionNode extends generateDecoratorNode({nodeType: 'collection', +export type CollectionNodeDataset = { + collection?: string; + postCount?: number; + layout?: string; + columns?: number; + header?: string; +}; + +type CollectionNodeProps = { + nodeType: 'collection'; + properties: KoenigDecoratorNodeProperties +}; + +const collectionNodeProps: CollectionNodeProps = { + nodeType: 'collection', properties: [ {name: 'collection', default: 'latest'}, // start with empty object; might want to just store the slug {name: 'postCount', default: 3}, {name: 'layout', default: 'grid'}, {name: 'columns', default: 3}, {name: 'header', default: '', wordCount: true} - ]} -) { - static importDOM() { - return collectionParser(this); + ] +}; + +export class CollectionNode extends generateDecoratorNode(collectionNodeProps) { + static importDOM(): DOMConversionMap | null { + return collectionParser(); } exportDOM(options = {}) { @@ -24,7 +41,9 @@ export class CollectionNode extends generateDecoratorNode({nodeType: 'collection return true; } - async getDynamicData(options = {}) { + // TODO: build the options object + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getDynamicData(options: any = {}) { const key = this.getKey(); const collection = this.__collection; const postCount = this.__postCount; @@ -38,10 +57,10 @@ export class CollectionNode extends generateDecoratorNode({nodeType: 'collection } } -export const $createCollectionNode = (dataset) => { +export const $createCollectionNode = (dataset: CollectionNodeDataset) => { return new CollectionNode(dataset); }; -export function $isCollectionNode(node) { +export function $isCollectionNode(node: LexicalNode) { return node instanceof CollectionNode; } diff --git a/packages/kg-default-nodes/lib/nodes/collection/collection-parser.js b/packages/kg-default-nodes/lib/nodes/collection/collection-parser.ts similarity index 71% rename from packages/kg-default-nodes/lib/nodes/collection/collection-parser.js rename to packages/kg-default-nodes/lib/nodes/collection/collection-parser.ts index 156bebd840..059b4ac9b4 100644 --- a/packages/kg-default-nodes/lib/nodes/collection/collection-parser.js +++ b/packages/kg-default-nodes/lib/nodes/collection/collection-parser.ts @@ -1,4 +1,7 @@ -function getLayout(domNode) { +import {DOMConversion, DOMConversionMap, DOMConversionOutput} from 'lexical'; +import {$createCollectionNode, CollectionNodeDataset} from './CollectionNode'; + +function getLayout(domNode: HTMLElement) { if (domNode.classList.contains('kg-collection-card-list')) { return 'list'; } else { // should have kg-collection-card-grid @@ -6,7 +9,7 @@ function getLayout(domNode) { } } -function getColumns(domNode) { +function getColumns(domNode: HTMLElement) { if (domNode.classList.contains('columns-1')) { return 1; } @@ -21,14 +24,14 @@ function getColumns(domNode) { } } -export function collectionParser(CollectionNode) { +export function collectionParser(): DOMConversionMap | null { return { - div: (nodeElem) => { + div: (nodeElem: HTMLElement): DOMConversion | null => { const isCollectionNode = nodeElem.classList?.contains('kg-collection-card'); if (nodeElem.tagName === 'DIV' && isCollectionNode) { return { - conversion(domNode) { - const postCount = parseInt(domNode.getAttribute('data-kg-collection-limit')); + conversion(domNode: HTMLElement): DOMConversionOutput { + const postCount = parseInt(domNode.getAttribute('data-kg-collection-limit') || ''); const collection = domNode.getAttribute('data-kg-collection-slug'); const layout = getLayout(domNode); const header = domNode.querySelector('.kg-collection-card-title')?.textContent || ''; @@ -40,9 +43,9 @@ export function collectionParser(CollectionNode) { layout, columns, header - }; + } as CollectionNodeDataset; - const node = new CollectionNode(payload); + const node = $createCollectionNode(payload); return {node}; }, priority: 1 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 271d51d7c8..0000000000 --- a/packages/kg-default-nodes/lib/nodes/email-cta/EmailCtaNode.js +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable ghost/filenames/match-exported-class */ -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} - ]} -) { - exportDOM(options = {}) { - return renderEmailCtaNode(this, options); - } -} - -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-cta/EmailCtaNode.ts b/packages/kg-default-nodes/lib/nodes/email-cta/EmailCtaNode.ts new file mode 100644 index 0000000000..0b644e96cd --- /dev/null +++ b/packages/kg-default-nodes/lib/nodes/email-cta/EmailCtaNode.ts @@ -0,0 +1,47 @@ +/* eslint-disable ghost/filenames/match-exported-class */ +import {LexicalNode} from 'lexical'; +import {KoenigDecoratorNodeProperties, generateDecoratorNode} from '../../generate-decorator-node'; +import {renderEmailCtaNode} from './email-cta-renderer'; + +export type EmailCtaNodeDataset = { + alignment?: string; + buttonText?: string; + buttonUrl?: string; + html?: string; + segment?: string; + showButton?: boolean; + showDividers?: boolean; +}; + +type EmailCtaNodeProps = { + nodeType: 'email-cta'; + properties: KoenigDecoratorNodeProperties; +}; + +const emailCtaNodeProps: EmailCtaNodeProps = { + 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} + ] +}; + +export class EmailCtaNode extends generateDecoratorNode(emailCtaNodeProps) { + // TODO: build options + exportDOM(options = {}) { + return renderEmailCtaNode(this, options); + } +} + +export const $createEmailCtaNode = (dataset: EmailCtaNodeDataset) => { + return new EmailCtaNode(dataset); +}; + +export function $isEmailCtaNode(node: LexicalNode) { + 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 39bd5067ff..0000000000 --- a/packages/kg-default-nodes/lib/nodes/email/EmailNode.js +++ /dev/null @@ -1,21 +0,0 @@ -/* eslint-disable ghost/filenames/match-exported-class */ -import {generateDecoratorNode} from '../../generate-decorator-node'; -import {renderEmailNode} from './email-renderer'; - -export class EmailNode extends generateDecoratorNode({nodeType: 'email', - properties: [ - {name: 'html', default: '', urlType: 'html'} - ]} -) { - exportDOM(options = {}) { - return renderEmailNode(this, options); - } -} - -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/email/EmailNode.ts b/packages/kg-default-nodes/lib/nodes/email/EmailNode.ts new file mode 100644 index 0000000000..a13553d44f --- /dev/null +++ b/packages/kg-default-nodes/lib/nodes/email/EmailNode.ts @@ -0,0 +1,42 @@ +/* eslint-disable ghost/filenames/match-exported-class */ +import {DOMExportOutput, LexicalEditor, LexicalNode} from 'lexical'; +import {KoenigDecoratorNodeProperties, KoenigDecoratorRendererOutput, generateDecoratorNode} from '../../generate-decorator-node'; +import {renderEmailNode} from './email-renderer'; +import {RendererOptions} from '../../types'; + +export type EmailNodeDataset = { + html?: string; +}; + +type EmailNodeProps = { + nodeType: 'email'; + properties: KoenigDecoratorNodeProperties; +}; + +const emailNodeProps: EmailNodeProps = { + nodeType: 'email', + properties: [ + {name: 'html', default: '', urlType: 'html'} + ] +}; + +export class EmailNode extends generateDecoratorNode(emailNodeProps) { + exportDOM(options: LexicalEditor): DOMExportOutput; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + exportDOM(options: RendererOptions): KoenigDecoratorRendererOutput + + exportDOM(options: LexicalEditor | RendererOptions) { + if (this instanceof EmailNode) { + return renderEmailNode(this, options as RendererOptions); + } + return super.exportDOM(options as unknown as LexicalEditor); + } +} + +export const $createEmailNode = (dataset: EmailNodeDataset) => { + return new EmailNode(dataset); +}; + +export function $isEmailNode(node: LexicalNode) { + return node instanceof EmailNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/email/email-renderer.js b/packages/kg-default-nodes/lib/nodes/email/email-renderer.ts similarity index 66% rename from packages/kg-default-nodes/lib/nodes/email/email-renderer.js rename to packages/kg-default-nodes/lib/nodes/email/email-renderer.ts index 127e49ba33..8c5ca9482e 100644 --- a/packages/kg-default-nodes/lib/nodes/email/email-renderer.js +++ b/packages/kg-default-nodes/lib/nodes/email/email-renderer.ts @@ -1,10 +1,17 @@ import {addCreateDocumentOption} from '../../utils/add-create-document-option'; import {removeSpaces, removeCodeWrappersFromHelpers, wrapReplacementStrings} from '../../utils/replacement-strings'; import {renderEmptyContainer} from '../../utils/render-empty-container'; +import {EmailNode} from './EmailNode'; +import {RendererOptions} from '../../types'; +import {KoenigDecoratorRendererOutput} from '../../generate-decorator-node'; -export function renderEmailNode(node, options = {}) { +export function renderEmailNode(node: EmailNode, options: RendererOptions): KoenigDecoratorRendererOutput | null { addCreateDocumentOption(options); - const document = options.createDocument(); + const document = options.createDocument && options.createDocument(); + + if (!document) { + return null; + } const html = node.html; 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 d6d4e05fd0..0000000000 --- a/packages/kg-default-nodes/lib/nodes/embed/EmbedNode.js +++ /dev/null @@ -1,34 +0,0 @@ -/* eslint-disable ghost/filenames/match-exported-class */ -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} - ]} -) { - static importDOM() { - return parseEmbedNode(this); - } - - exportDOM(options = {}) { - return renderEmbedNode(this, options); - } - - 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/embed/EmbedNode.ts b/packages/kg-default-nodes/lib/nodes/embed/EmbedNode.ts new file mode 100644 index 0000000000..c99dad9ab7 --- /dev/null +++ b/packages/kg-default-nodes/lib/nodes/embed/EmbedNode.ts @@ -0,0 +1,51 @@ +/* eslint-disable ghost/filenames/match-exported-class */ +import {DOMConversionMap, LexicalNode} from 'lexical'; +import {KoenigDecoratorNodeProperties, generateDecoratorNode} from '../../generate-decorator-node'; +import {parseEmbedNode} from './embed-parser'; +import {renderEmbedNode} from './embed-renderer'; + +export type EmbedNodeDataset = { + url?: string; + embedType?: string; + html?: string; + metadata?: object; + caption?: string; +}; + +type EmbedNodeProps = { + nodeType: 'embed'; + properties: KoenigDecoratorNodeProperties; +}; + +const embedNodeProps: EmbedNodeProps = { + nodeType: 'embed', + properties: [ + {name: 'url', default: '', urlType: 'url'}, + {name: 'embedType', default: ''}, + {name: 'html', default: ''}, + {name: 'metadata', default: {}}, + {name: 'caption', default: '', wordCount: true} + ] +}; + +export class EmbedNode extends generateDecoratorNode(embedNodeProps) { + static importDOM(): DOMConversionMap | null { + return parseEmbedNode(); + } + + exportDOM(options = {}) { + return renderEmbedNode(this, options); + } + + isEmpty() { + return !this.__url && !this.__html; + } +} + +export const $createEmbedNode = (dataset: EmbedNodeDataset) => { + return new EmbedNode(dataset); +}; + +export function $isEmbedNode(node: LexicalNode) { + return node instanceof EmbedNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/embed/embed-parser.js b/packages/kg-default-nodes/lib/nodes/embed/embed-parser.ts similarity index 67% rename from packages/kg-default-nodes/lib/nodes/embed/embed-parser.js rename to packages/kg-default-nodes/lib/nodes/embed/embed-parser.ts index ecde2910ed..74c3e3d68e 100644 --- a/packages/kg-default-nodes/lib/nodes/embed/embed-parser.js +++ b/packages/kg-default-nodes/lib/nodes/embed/embed-parser.ts @@ -1,15 +1,17 @@ +import {DOMConversion, DOMConversionMap, DOMConversionOutput} from 'lexical/LexicalNode.js'; import {readCaptionFromElement} from '../../utils/read-caption-from-element.js'; +import {$createEmbedNode, EmbedNodeDataset} from './EmbedNode.js'; // TODO: add NFT card parser -export function parseEmbedNode(EmbedNode) { +export function parseEmbedNode(): DOMConversionMap | null { return { - figure: (nodeElem) => { + figure: (nodeElem: HTMLElement): DOMConversion | null => { if (nodeElem.nodeType === 1 && nodeElem.tagName === 'FIGURE') { const iframe = nodeElem.querySelector('iframe'); if (iframe) { return { - conversion(domNode) { - const payload = _createPayloadForIframe(iframe); + conversion(domNode: HTMLElement): DOMConversionOutput | null { + const payload = _createPayloadForIframe(iframe) as EmbedNodeDataset; if (!payload) { return null; @@ -17,7 +19,7 @@ export function parseEmbedNode(EmbedNode) { payload.caption = readCaptionFromElement(domNode); - const node = new EmbedNode(payload); + const node = $createEmbedNode(payload); return {node}; }, priority: 1 @@ -26,29 +28,29 @@ export function parseEmbedNode(EmbedNode) { const blockquote = nodeElem.querySelector('blockquote'); if (blockquote) { return { - conversion(domNode) { + conversion(domNode: HTMLElement): DOMConversionOutput | null { 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 = {url: url} as EmbedNodeDataset; // 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; - const node = new EmbedNode(payload); + const node = $createEmbedNode(payload); return {node}; }, priority: 1 @@ -57,17 +59,17 @@ export function parseEmbedNode(EmbedNode) { } return null; }, - iframe: (nodeElem) => { + iframe: (nodeElem: HTMLElement): DOMConversion | null => { if (nodeElem.nodeType === 1 && nodeElem.tagName === 'IFRAME') { return { - conversion(domNode) { - const payload = _createPayloadForIframe(domNode); + conversion(domNode: HTMLElement): DOMConversionOutput | null { + const payload = _createPayloadForIframe(domNode as HTMLIFrameElement); if (!payload) { return null; } - const node = new EmbedNode(payload); + const node = $createEmbedNode(payload); return {node}; }, priority: 1 @@ -78,7 +80,7 @@ export function parseEmbedNode(EmbedNode) { }; } -function _createPayloadForIframe(iframe) { +function _createPayloadForIframe(iframe: HTMLIFrameElement): EmbedNodeDataset | undefined { // 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,8 +92,9 @@ function _createPayloadForIframe(iframe) { iframe.src = `https:${iframe.src}`; } - let payload = { - url: iframe.src + const payload = { + url: iframe.src, + html: '' }; payload.html = iframe.outerHTML; diff --git a/packages/kg-default-nodes/lib/nodes/file/FileNode.js b/packages/kg-default-nodes/lib/nodes/file/FileNode.ts similarity index 54% rename from packages/kg-default-nodes/lib/nodes/file/FileNode.js rename to packages/kg-default-nodes/lib/nodes/file/FileNode.ts index 00d37268cb..8e07dcf027 100644 --- a/packages/kg-default-nodes/lib/nodes/file/FileNode.js +++ b/packages/kg-default-nodes/lib/nodes/file/FileNode.ts @@ -1,25 +1,45 @@ /* eslint-disable ghost/filenames/match-exported-class */ -import {generateDecoratorNode} from '../../generate-decorator-node'; +import {KoenigDecoratorNodeProperties, generateDecoratorNode} from '../../generate-decorator-node'; import {renderFileNode} from './file-renderer'; import {parseFileNode} from './file-parser'; import {bytesToSize} from '../../utils/size-byte-converter'; +import {DOMConversionMap, LexicalNode, SerializedLexicalNode, Spread} from 'lexical'; -export class FileNode extends generateDecoratorNode({nodeType: 'file', +export type FileNodeDataset = { + src?: string; + fileTitle?: string; + fileCaption?: string; + fileName?: string; + fileSize?: number; +}; + +type FileNodeProps = { + nodeType: 'file'; + properties: KoenigDecoratorNodeProperties; +}; + +const fileNodeProps: FileNodeProps = { + nodeType: 'file', properties: [ {name: 'src', default: '', urlType: 'url'}, {name: 'fileTitle', default: '', wordCount: true}, {name: 'fileCaption', default: '', wordCount: true}, {name: 'fileName', default: ''}, {name: 'fileSize', default: ''} - ]} -) { + ] +}; + +export type SerializedFileNode = Spread; + +export class FileNode extends generateDecoratorNode(fileNodeProps) { /* @override */ - exportJSON() { + exportJSON(): SerializedFileNode { const {src, fileTitle, fileCaption, fileName, fileSize} = this; const isBlob = src.startsWith('data:'); return { type: 'file', + version: 1, src: isBlob ? '' : src, fileTitle, fileCaption, @@ -28,8 +48,8 @@ export class FileNode extends generateDecoratorNode({nodeType: 'file', }; } - static importDOM() { - return parseFileNode(this); + static importDOM(): DOMConversionMap | null { + return parseFileNode(); } exportDOM(options = {}) { @@ -41,10 +61,10 @@ export class FileNode extends generateDecoratorNode({nodeType: 'file', } } -export function $isFileNode(node) { - return node instanceof FileNode; -} - -export const $createFileNode = (dataset) => { +export const $createFileNode = (dataset: FileNodeDataset) => { return new FileNode(dataset); }; + +export function $isFileNode(node: LexicalNode) { + return node instanceof FileNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/file/file-parser.js b/packages/kg-default-nodes/lib/nodes/file/file-parser.ts similarity index 61% rename from packages/kg-default-nodes/lib/nodes/file/file-parser.js rename to packages/kg-default-nodes/lib/nodes/file/file-parser.ts index c3740879ff..ea93a6acbd 100644 --- a/packages/kg-default-nodes/lib/nodes/file/file-parser.js +++ b/packages/kg-default-nodes/lib/nodes/file/file-parser.ts @@ -1,27 +1,29 @@ +import {DOMConversion, DOMConversionMap, DOMConversionOutput} from 'lexical'; import {sizeToBytes} from '../../utils/size-byte-converter'; +import {$createFileNode, FileNodeDataset} from './FileNode'; -export function parseFileNode(FileNode) { +export function parseFileNode(): DOMConversionMap | null { return { - div: (nodeElem) => { + div: (nodeElem: HTMLElement): DOMConversion | null => { const isKgFileCard = nodeElem.classList?.contains('kg-file-card'); if (nodeElem.tagName === 'DIV' && isKgFileCard) { return { - conversion(domNode) { - const link = domNode.querySelector('a'); + conversion(domNode: HTMLElement): DOMConversionOutput { + const link = domNode.querySelector('a') as HTMLAnchorElement; 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 fileSize = sizeToBytes(domNode.querySelector('.kg-file-card-filesize')?.textContent || ''); const payload = { src, fileTitle, fileCaption, fileName, fileSize - }; + } as FileNodeDataset; - const node = new FileNode(payload); + const node = $createFileNode(payload); return {node}; }, priority: 1 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 b99c958e4b..0000000000 --- a/packages/kg-default-nodes/lib/nodes/gallery/GalleryNode.js +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable ghost/filenames/match-exported-class */ -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} - ]} -) { - /* override */ - static get urlTransformMap() { - return { - caption: 'html', - images: { - src: 'url', - caption: 'html' - } - }; - } - - static importDOM() { - return parseGalleryNode(this); - } - - exportDOM(options = {}) { - return renderGalleryNode(this, options); - } - - 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/gallery/GalleryNode.ts b/packages/kg-default-nodes/lib/nodes/gallery/GalleryNode.ts new file mode 100644 index 0000000000..68338384bb --- /dev/null +++ b/packages/kg-default-nodes/lib/nodes/gallery/GalleryNode.ts @@ -0,0 +1,59 @@ +/* eslint-disable ghost/filenames/match-exported-class */ +import {DOMConversionMap, LexicalNode} from 'lexical'; +import {KoenigDecoratorNodeProperties, generateDecoratorNode} from '../../generate-decorator-node'; +import {parseGalleryNode} from './gallery-parser'; +import {renderGalleryNode} from './gallery-renderer'; + +export type GalleryNodeDataset = { + images?: Array<{ + src?: string; + caption?: string; + }>; + caption?: string; +}; + +type GalleryNodeProps = { + nodeType: 'gallery'; + properties: KoenigDecoratorNodeProperties; +}; + +const galleryNodeProps: GalleryNodeProps = { + nodeType: 'gallery', + properties: [ + {name: 'images', default: []}, + {name: 'caption', default: '', wordCount: true} + ] +}; + +export class GalleryNode extends generateDecoratorNode(galleryNodeProps) { + /* override */ + static get urlTransformMap() { + return { + caption: 'html', + images: { + src: 'url', + caption: 'html' + } + }; + } + + static importDOM(): DOMConversionMap | null { + return parseGalleryNode(); + } + + exportDOM(options = {}) { + return renderGalleryNode(this, options); + } + + hasEditMode() { + return false; + } +} + +export const $createGalleryNode = (dataset: GalleryNodeDataset) => { + return new GalleryNode(dataset); +}; + +export function $isGalleryNode(node: LexicalNode) { + return node instanceof GalleryNode; +} diff --git a/packages/kg-default-nodes/lib/nodes/gallery/gallery-parser.js b/packages/kg-default-nodes/lib/nodes/gallery/gallery-parser.ts similarity index 58% rename from packages/kg-default-nodes/lib/nodes/gallery/gallery-parser.js rename to packages/kg-default-nodes/lib/nodes/gallery/gallery-parser.ts index 0b0b555e66..dbf37b6bb7 100644 --- a/packages/kg-default-nodes/lib/nodes/gallery/gallery-parser.js +++ b/packages/kg-default-nodes/lib/nodes/gallery/gallery-parser.ts @@ -1,29 +1,31 @@ +import {DOMConversion, DOMConversionMap, DOMConversionOutput} from 'lexical/LexicalNode.js'; import {readCaptionFromElement} from '../../utils/read-caption-from-element.js'; import {readImageAttributesFromElement} from '../../utils/read-image-attributes-from-element.js'; +import {$createGalleryNode, GalleryNodeDataset} from './GalleryNode.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(): DOMConversionMap | null { return { - figure: (nodeElem) => { + figure: (nodeElem: HTMLElement): DOMConversion | null => { // Koenig gallery card if (nodeElem.classList?.contains('kg-gallery-card')) { return { - conversion(domNode) { - const payload = {}; + conversion(domNode): DOMConversionOutput { + const payload: GalleryNodeDataset = {}; const imgs = Array.from(domNode.querySelectorAll('img')); payload.images = imgs.map(readGalleryImageAttributesFromElement); payload.caption = readCaptionFromElement(domNode); - const node = new GalleryNode(payload); + const node = $createGalleryNode(payload); return {node}; }, priority: 1 @@ -32,18 +34,19 @@ export function parseGalleryNode(GalleryNode) { return null; }, - div: (nodeElem) => { + div: (nodeElem: HTMLElement): DOMConversion | null => { // Medium "graf" galleries - function isGrafGallery(node) { - return node.tagName === 'DIV' - && node.dataset?.paragraphCount - && node.querySelectorAll('img').length > 0; + function isGrafGallery(node: HTMLElement): boolean { + if (node.tagName === 'DIV' && node.dataset?.paragraphCount && node.querySelectorAll('img').length > 0) { + return true; + } + return false; } if (isGrafGallery(nodeElem)) { return { - conversion(domNode) { - const payload = { + conversion(domNode): DOMConversionOutput { + const payload: GalleryNodeDataset = { caption: readCaptionFromElement(domNode) }; @@ -52,9 +55,9 @@ export function parseGalleryNode(GalleryNode) { 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; while (nextNode && isGrafGallery(nextNode)) { - let currentNode = nextNode; + const currentNode = nextNode; imgs = imgs.concat(Array.from(currentNode.querySelectorAll('img'))); const currentNodeCaption = readCaptionFromElement(currentNode); @@ -62,7 +65,7 @@ export function parseGalleryNode(GalleryNode) { payload.caption = `${payload.caption} / ${currentNodeCaption}`; } - nextNode = currentNode.nextElementSibling; + nextNode = currentNode.nextElementSibling as HTMLElement; // remove nodes as we go so that they don't go through the parser currentNode.remove(); @@ -70,7 +73,7 @@ export function parseGalleryNode(GalleryNode) { payload.images = imgs.map(readGalleryImageAttributesFromElement); - const node = new GalleryNode(payload); + const node = $createGalleryNode(payload); return {node}; }, priority: 1 @@ -78,42 +81,46 @@ export function parseGalleryNode(GalleryNode) { } // Squarespace SQS galleries - function isSqsGallery(node) { - return node.tagName === 'DIV' - && node.className.match(/sqs-gallery-container/) - && !node.className.match(/summary-/); + function isSqsGallery(node: HTMLElement): boolean { + if (node.tagName === 'DIV' && node.className.match(/sqs-gallery-container/) && !node.className.match(/summary-/)) { + return true; + } + return false; } if (isSqsGallery(nodeElem)) { return { - conversion(domNode) { - const payload = {}; + conversion(domNode): DOMConversionOutput { + const payload: GalleryNodeDataset = {}; // Each image exists twice... // The first image is wrapped in `