diff --git a/packages/kg-converters/.gitignore b/packages/kg-converters/.gitignore index 8e6861d207..0264be4400 100644 --- a/packages/kg-converters/.gitignore +++ b/packages/kg-converters/.gitignore @@ -1,2 +1,2 @@ -cjs/ -es/ +build/ +tsconfig.tsbuildinfo diff --git a/packages/kg-converters/eslint.config.mjs b/packages/kg-converters/eslint.config.mjs index e41bea6d80..a3e54db18a 100644 --- a/packages/kg-converters/eslint.config.mjs +++ b/packages/kg-converters/eslint.config.mjs @@ -1,45 +1,33 @@ -import {fixupPluginRules} from '@eslint/compat'; +import {defineConfig} from 'eslint/config'; import eslint from '@eslint/js'; 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'], - 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: ['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', }, - { - files: ['test/**/*.js'], - plugins: {ghost}, - languageOptions: { - globals: { - ...globals.node, - ...globals.mocha, - should: true, - sinon: true - } - }, - rules: { - ...ghostPlugin.configs.test.rules - } - } -]; + }, +]); diff --git a/packages/kg-converters/index.js b/packages/kg-converters/index.js deleted file mode 100644 index 43c89d46e9..0000000000 --- a/packages/kg-converters/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './lib/kg-converters'; diff --git a/packages/kg-converters/lib/kg-converters.js b/packages/kg-converters/lib/kg-converters.js deleted file mode 100644 index 967df925ba..0000000000 --- a/packages/kg-converters/lib/kg-converters.js +++ /dev/null @@ -1,4 +0,0 @@ -import {lexicalToMobiledoc} from './lexical-to-mobiledoc'; -import {mobiledocToLexical} from './mobiledoc-to-lexical'; - -export {lexicalToMobiledoc, mobiledocToLexical}; diff --git a/packages/kg-converters/package.json b/packages/kg-converters/package.json index 48b37e4390..76b7f67450 100644 --- a/packages/kg-converters/package.json +++ b/packages/kg-converters/package.json @@ -4,35 +4,42 @@ "repository": "https://github.com/TryGhost/Koenig/tree/main/packages/kg-converters", "author": "Ghost Foundation", "license": "MIT", - "main": "cjs/kg-converters.js", - "module": "es/kg-converters.js", - "source": "lib/kg-converters.js", + "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" + } + }, "scripts": { - "dev": "rollup -c -w", - "build": "rollup -c", - "prepare": "NODE_ENV=production yarn build", - "pretest": "yarn build", - "test:unit": "NODE_ENV=testing c8 --all --src lib/ --exclude lib/kg-converters.js --check-coverage --reporter text --reporter cobertura mocha './test/**/*.test.js'", + "dev": "tsc --watch --preserveWatchOutput", + "build": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json", + "prepare": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json", + "pretest": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json && tsc -p tsconfig.test.json", + "test:unit": "NODE_ENV=testing c8 --all --src src/ --check-coverage --reporter text --reporter cobertura tsx node_modules/.bin/mocha './test/**/*.test.ts'", "test": "yarn test:unit", "posttest": "yarn lint", "lint": "eslint . --cache" }, "files": [ - "LICENSE", - "README.md", - "cjs/", - "es/", - "lib" + "build" ], "publishConfig": { "access": "public" }, "devDependencies": { - "@rollup/plugin-babel": "7.0.0", + "@eslint/js": "9.39.4", + "@types/mocha": "^10.0.0", + "@types/node": "^22.0.0", "c8": "11.0.0", "mocha": "11.7.5", - "rollup": "4.59.0", - "sinon": "21.0.2" + "sinon": "21.0.2", + "tsx": "^4.0.0", + "typescript": "5.9.3", + "typescript-eslint": "8.33.1" }, "dependencies": { "lodash": "^4.17.21" diff --git a/packages/kg-converters/rollup.config.mjs b/packages/kg-converters/rollup.config.mjs deleted file mode 100644 index d087068f48..0000000000 --- a/packages/kg-converters/rollup.config.mjs +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-env node */ -import pkg from './package.json' with { type: 'json' }; -import babel from '@rollup/plugin-babel'; - -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 - }, - external: dependencies - }, - - // 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: [ - 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: dependencies - } -]; diff --git a/packages/kg-converters/src/index.ts b/packages/kg-converters/src/index.ts new file mode 100644 index 0000000000..b5fad41c8c --- /dev/null +++ b/packages/kg-converters/src/index.ts @@ -0,0 +1 @@ +export {lexicalToMobiledoc, mobiledocToLexical} from './kg-converters.js'; diff --git a/packages/kg-converters/src/kg-converters.ts b/packages/kg-converters/src/kg-converters.ts new file mode 100644 index 0000000000..1e1818d09f --- /dev/null +++ b/packages/kg-converters/src/kg-converters.ts @@ -0,0 +1,4 @@ +import {lexicalToMobiledoc} from './lexical-to-mobiledoc.js'; +import {mobiledocToLexical} from './mobiledoc-to-lexical.js'; + +export {lexicalToMobiledoc, mobiledocToLexical}; diff --git a/packages/kg-converters/lib/lexical-to-mobiledoc.js b/packages/kg-converters/src/lexical-to-mobiledoc.ts similarity index 68% rename from packages/kg-converters/lib/lexical-to-mobiledoc.js rename to packages/kg-converters/src/lexical-to-mobiledoc.ts index d2726a8602..008ad3e172 100644 --- a/packages/kg-converters/lib/lexical-to-mobiledoc.js +++ b/packages/kg-converters/src/lexical-to-mobiledoc.ts @@ -1,7 +1,33 @@ const MOBILEDOC_VERSION = '0.3.1'; const GHOST_VERSION = '4.0'; -const BLANK_DOC = { +// Mobiledoc types +type MobiledocMarkup = [string, ...unknown[]]; +type MobiledocAtom = [string, string, Record]; +type MobiledocCard = [string, Record]; +type MobiledocSection = unknown[]; + +interface MobiledocDocument { + version: string; + ghostVersion: string; + atoms: MobiledocAtom[]; + cards: MobiledocCard[]; + markups: MobiledocMarkup[]; + sections: MobiledocSection[]; +} + +// Lexical types +interface LexicalNode { + type: string; + format?: number; + text?: string; + tag?: string; + url?: string; + children?: LexicalNode[]; + [key: string]: unknown; +} + +const BLANK_DOC: MobiledocDocument = { version: MOBILEDOC_VERSION, ghostVersion: GHOST_VERSION, markups: [], @@ -29,7 +55,7 @@ const L_IS_CODE = 1 << 4; const L_IS_SUBSCRIPT = 1 << 5; const L_IS_SUPERSCRIPT = 1 << 6; -const L_FORMAT_MAP = new Map([ +const L_FORMAT_MAP = new Map([ [L_IS_BOLD, 'strong'], [L_IS_ITALIC, 'em'], [L_IS_STRIKETHROUGH, 's'], @@ -72,23 +98,23 @@ const KNOWN_CARDS = [ 'video' ]; -const CARD_NAME_MAP = { +const CARD_NAME_MAP: Record = { codeblock: 'code', horizontalrule: 'hr' }; -const CARD_PROPERTY_MAP = { +const CARD_PROPERTY_MAP: Record> = { embed: { embedType: 'type' } }; -export function lexicalToMobiledoc(serializedLexical) { +export function lexicalToMobiledoc(serializedLexical: string | null | undefined): string { if (serializedLexical === null || serializedLexical === undefined || serializedLexical === '') { return JSON.stringify(BLANK_DOC); } - const lexical = JSON.parse(serializedLexical); + const lexical = JSON.parse(serializedLexical) as {root?: {children?: LexicalNode[]}}; if (!lexical.root) { return JSON.stringify(BLANK_DOC); @@ -96,14 +122,14 @@ export function lexicalToMobiledoc(serializedLexical) { const mobiledoc = buildEmptyDoc(); - lexical.root.children.forEach(child => addRootChild(child, mobiledoc)); + lexical.root.children?.forEach((child: LexicalNode) => addRootChild(child, mobiledoc)); return JSON.stringify(mobiledoc); } /* internal functions ------------------------------------------------------- */ -function buildEmptyDoc() { +function buildEmptyDoc(): MobiledocDocument { return { version: MOBILEDOC_VERSION, ghostVersion: GHOST_VERSION, @@ -114,8 +140,8 @@ function buildEmptyDoc() { }; } -function getOrSetMarkupIndex(markup, mobiledoc) { - let index = mobiledoc.markups.findIndex(m => m[0] === markup); +function getOrSetMarkupIndex(markup: string, mobiledoc: MobiledocDocument): number { + let index = mobiledoc.markups.findIndex((m: MobiledocMarkup) => m[0] === markup); if (index === -1) { mobiledoc.markups.push([markup]); @@ -125,8 +151,8 @@ function getOrSetMarkupIndex(markup, mobiledoc) { return index; } -function getOrSetAtomIndex(atom, mobiledoc) { - let index = mobiledoc.atoms.findIndex(m => m[0] === atom); +function getOrSetAtomIndex(atom: MobiledocAtom, mobiledoc: MobiledocDocument): number { + let index = mobiledoc.atoms.findIndex((m: MobiledocAtom) => m === atom); if (index === -1) { mobiledoc.atoms.push(atom); @@ -136,7 +162,7 @@ function getOrSetAtomIndex(atom, mobiledoc) { return index; } -function addRootChild(child, mobiledoc) { +function addRootChild(child: LexicalNode, mobiledoc: MobiledocDocument): void { if (child.type === 'paragraph') { addTextSection(child, mobiledoc); } @@ -162,26 +188,26 @@ function addRootChild(child, mobiledoc) { } } -function addTextSection(childWithFormats, mobiledoc, tagName = 'p') { +function addTextSection(childWithFormats: LexicalNode, mobiledoc: MobiledocDocument, tagName: string = 'p'): void { const markers = buildMarkers(childWithFormats, mobiledoc); - const section = [MD_TEXT_SECTION, tagName, markers]; + const section: MobiledocSection = [MD_TEXT_SECTION, tagName, markers]; mobiledoc.sections.push(section); } -function addListSection(listChild, mobiledoc, tagName = 'ul') { +function addListSection(listChild: LexicalNode, mobiledoc: MobiledocDocument, tagName: string = 'ul'): void { const listItems = buildListItems(listChild, mobiledoc); - const section = [MD_LIST_SECTION, tagName, listItems]; + const section: MobiledocSection = [MD_LIST_SECTION, tagName, listItems]; mobiledoc.sections.push(section); } -function buildListItems(listRoot, mobiledoc) { - const listItems = []; +function buildListItems(listRoot: LexicalNode, mobiledoc: MobiledocDocument): unknown[][] { + const listItems: unknown[][] = []; flattenListChildren(listRoot); - listRoot.children.forEach((listItemChild) => { + listRoot.children?.forEach((listItemChild: LexicalNode) => { if (listItemChild.type === 'listitem') { const markers = buildMarkers(listItemChild, mobiledoc); listItems.push(markers); @@ -191,19 +217,19 @@ function buildListItems(listRoot, mobiledoc) { return listItems; } -function flattenListChildren(listRoot) { - const flatListItems = []; +function flattenListChildren(listRoot: LexicalNode): void { + const flatListItems: LexicalNode[] = []; - function traverse(item) { - item.children?.forEach((child) => { - child.children?.forEach((grandchild) => { + function traverse(item: LexicalNode): void { + item.children?.forEach((child: LexicalNode) => { + child.children?.forEach((grandchild: LexicalNode) => { if (grandchild.type === 'list') { traverse(grandchild); - child.children.splice(child.children.indexOf(grandchild), 1); + child.children!.splice(child.children!.indexOf(grandchild), 1); } }); - if (child.type === 'listitem' && child.children.length) { + if (child.type === 'listitem' && child.children?.length) { flatListItems.push(child); } }); @@ -213,25 +239,28 @@ function flattenListChildren(listRoot) { listRoot.children = flatListItems; } -function buildMarkers(childWithFormats, mobiledoc) { - const markers = []; +// markup type: string tag name or [string, string[]] link markup +type OpenMarkup = string | MobiledocMarkup; - if (!childWithFormats.children.length) { +function buildMarkers(childWithFormats: LexicalNode, mobiledoc: MobiledocDocument): unknown[] { + const markers: unknown[] = []; + + if (!childWithFormats.children?.length) { markers.push([MD_TEXT_MARKER, [], 0, '']); } else { // mobiledoc tracks opened/closed formats across markers whereas lexical // lists all formats for each marker so we need to manually track open formats - let openMarkups = []; + let openMarkups: OpenMarkup[] = []; // markup: a specific format, or tag name+attributes // marker: a piece of text with 0 or more markups - childWithFormats.children.forEach((child, childIndex) => { + childWithFormats.children.forEach((child: LexicalNode, childIndex: number) => { if (TEXT_TYPES.includes(child.type)) { if (child.format !== 0) { // text child has formats, track which are new and which have closed - const openedFormats = []; - const childFormats = readFormat(child.format); + const openedFormats: string[] = []; + const childFormats = readFormat(child.format as number); let closedFormatCount = 0; childFormats.forEach((format) => { @@ -242,14 +271,14 @@ function buildMarkers(childWithFormats, mobiledoc) { }); // mobiledoc will immediately close any formats if the next section doesn't use them or it's not a text section - if (!childWithFormats.children[childIndex + 1] || !TEXT_TYPES.includes(childWithFormats.children[childIndex + 1].type)) { + if (!childWithFormats.children![childIndex + 1] || !TEXT_TYPES.includes(childWithFormats.children![childIndex + 1].type)) { // no more children, close all formats closedFormatCount = openMarkups.length; openMarkups = []; } else { - const nextChild = childWithFormats.children[childIndex + 1]; - const nextFormats = readFormat(nextChild.format); - const firstMissingFormatIndex = openMarkups.findIndex(format => !nextFormats.includes(format)); + const nextChild = childWithFormats.children![childIndex + 1]; + const nextFormats = readFormat(nextChild.format as number); + const firstMissingFormatIndex = openMarkups.findIndex((format: OpenMarkup) => !nextFormats.includes(format as string)); if (firstMissingFormatIndex !== -1) { const formatsToClose = openMarkups.slice(firstMissingFormatIndex); @@ -262,7 +291,7 @@ function buildMarkers(childWithFormats, mobiledoc) { markers.push([MD_TEXT_MARKER, markupIndexes, closedFormatCount, child.text]); } else { // text child has no formats so we close all formats in mobiledoc - let closedFormatCount = openMarkups.length; + const closedFormatCount = openMarkups.length; openMarkups = []; markers.push([MD_TEXT_MARKER, [], closedFormatCount, child.text]); @@ -270,13 +299,13 @@ function buildMarkers(childWithFormats, mobiledoc) { } if (child.type === 'link') { - const linkMarkup = ['a', ['href', child.url]]; + const linkMarkup: MobiledocMarkup = ['a', ['href', child.url]]; const linkMarkupIndex = mobiledoc.markups.push(linkMarkup) - 1; - child.children.forEach((linkChild, linkChildIndex) => { + child.children?.forEach((linkChild: LexicalNode, linkChildIndex: number) => { if (linkChild.format !== 0) { - const openedMarkupIndexes = []; - const openedFormats = []; + const openedMarkupIndexes: number[] = []; + const openedFormats: string[] = []; // first child of a link opens the link markup if (linkChildIndex === 0) { @@ -285,7 +314,7 @@ function buildMarkers(childWithFormats, mobiledoc) { } // text child has formats, track which are new and which have closed - const childFormats = readFormat(linkChild.format); + const childFormats = readFormat(linkChild.format as number); let closedMarkupCount = 0; childFormats.forEach((format) => { @@ -296,17 +325,17 @@ function buildMarkers(childWithFormats, mobiledoc) { }); // mobiledoc will immediately close any formats if the next section doesn't use them - if (!child.children[linkChildIndex + 1]) { + if (!child.children![linkChildIndex + 1]) { // last child of a link closes all markups closedMarkupCount = openMarkups.length; openMarkups = []; } else { - const nextChild = child.children[linkChildIndex + 1]; - const nextFormats = readFormat(nextChild.format); + const nextChild = child.children![linkChildIndex + 1]; + const nextFormats = readFormat(nextChild.format as number); - const firstMissingFormatIndex = openMarkups.findIndex((markup) => { + const firstMissingFormatIndex = openMarkups.findIndex((markup: OpenMarkup) => { const markupIsLink = JSON.stringify(markup) === JSON.stringify(linkMarkup); - return !markupIsLink && !nextFormats.includes(markup); + return !markupIsLink && !nextFormats.includes(markup as string); }); if (firstMissingFormatIndex !== -1) { @@ -320,7 +349,7 @@ function buildMarkers(childWithFormats, mobiledoc) { markers.push([MD_TEXT_MARKER, openedMarkupIndexes, closedMarkupCount, linkChild.text]); } else { - const openedMarkupIndexes = []; + const openedMarkupIndexes: number[] = []; // first child of a link opens the link markup if (linkChildIndex === 0) { @@ -331,7 +360,7 @@ function buildMarkers(childWithFormats, mobiledoc) { let closedMarkupCount = openMarkups.length - 1; // don't close the link markup, just the others // last child of a link closes all markups - if (!child.children[linkChildIndex + 1]) { + if (!child.children![linkChildIndex + 1]) { closedMarkupCount += 1; // close the link markup openMarkups = []; } @@ -342,7 +371,7 @@ function buildMarkers(childWithFormats, mobiledoc) { } if (child.type === 'linebreak') { - const atom = ['soft-return', '', {}]; + const atom: MobiledocAtom = ['soft-return', '', {}]; const atomIndex = getOrSetAtomIndex(atom, mobiledoc); markers.push([MD_ATOM_MARKER, [], 0, atomIndex]); } @@ -354,8 +383,8 @@ function buildMarkers(childWithFormats, mobiledoc) { // Lexical stores formats as a bitmask, so we need to read the bitmask to // determine which formats are present -function readFormat(format) { - const formats = []; +function readFormat(format: number): string[] { + const formats: string[] = []; L_FORMAT_MAP.forEach((value, key) => { if ((format & key) !== 0) { @@ -366,32 +395,37 @@ function readFormat(format) { return formats; } -function addCardSection(child, mobiledoc) { +function addCardSection(child: LexicalNode, mobiledoc: MobiledocDocument): void { const cardType = child.type; - let cardName = child.type; + let cardName: string = child.type; // rename card if there's a difference between lexical/mobiledoc if (CARD_NAME_MAP[cardName]) { cardName = CARD_NAME_MAP[cardName]; } // don't include type in the payload - delete child.type; + const payload: Record = {}; + for (const key of Object.keys(child)) { + if (key !== 'type') { + payload[key] = child[key]; + } + } // rename any properties to match mobiledoc if (CARD_PROPERTY_MAP[cardType]) { const map = CARD_PROPERTY_MAP[cardType]; for (const [key, value] of Object.entries(map)) { - child[value] = child[key]; - delete child[key]; + payload[value] = payload[key]; + delete payload[key]; } } - const card = [cardName, child]; + const card: MobiledocCard = [cardName, payload]; mobiledoc.cards.push(card); const cardIndex = mobiledoc.cards.length - 1; - const section = [MD_CARD_SECTION, cardIndex]; + const section: MobiledocSection = [MD_CARD_SECTION, cardIndex]; mobiledoc.sections.push(section); } diff --git a/packages/kg-converters/lib/mobiledoc-to-lexical.js b/packages/kg-converters/src/mobiledoc-to-lexical.ts similarity index 61% rename from packages/kg-converters/lib/mobiledoc-to-lexical.js rename to packages/kg-converters/src/mobiledoc-to-lexical.ts index cf79adb1cb..88c07bae57 100644 --- a/packages/kg-converters/lib/mobiledoc-to-lexical.js +++ b/packages/kg-converters/src/mobiledoc-to-lexical.ts @@ -1,7 +1,49 @@ +// Mobiledoc types - arrays represent the mobiledoc wire format +// [tagName, [attributes...]] e.g. ['a', ['href', 'https://...']] +type MobiledocMarkup = [string, ...unknown[]]; +// [atomName, atomValue, atomPayload] +type MobiledocAtom = [string, unknown, unknown]; +// [cardName, cardPayload] +type MobiledocCard = [string, Record]; +// [typeIdentifier, ...rest] where typeIdentifier determines the structure +type MobiledocSection = [number, ...unknown[]]; +// [textTypeIdentifier, openMarkupIndexes, numberOfClosedMarkups, value] +type MobiledocMarker = [number, number[], number, unknown]; + +interface MobiledocDocument { + markups: MobiledocMarkup[]; + atoms: MobiledocAtom[]; + cards: MobiledocCard[]; + sections: MobiledocSection[]; +} + +// Lexical types +interface LexicalNode { + type: string; + version: number; + children?: LexicalNode[]; + direction?: string | null; + format?: string | number; + indent?: number; + text?: string; + [key: string]: unknown; +} + +interface LexicalDocument { + root: { + children: LexicalNode[]; + direction: string | null; + format: string; + indent: number; + type: string; + version: number; + }; +} + const BLANK_DOC = { root: { - children: [], - direction: null, + children: [] as LexicalNode[], + direction: null as string | null, format: '', indent: 0, type: 'root', @@ -9,7 +51,7 @@ const BLANK_DOC = { } }; -const TAG_TO_LEXICAL_NODE = { +const TAG_TO_LEXICAL_NODE: Record> = { p: { type: 'paragraph' }, @@ -52,14 +94,14 @@ const TAG_TO_LEXICAL_NODE = { } }; -const ATOM_TO_LEXICAL_NODE = { +const ATOM_TO_LEXICAL_NODE: Record = { 'soft-return': { type: 'linebreak', version: 1 } }; -const MARKUP_TO_FORMAT = { +const MARKUP_TO_FORMAT: Record = { strong: 1, b: 1, em: 1 << 1, @@ -71,20 +113,20 @@ const MARKUP_TO_FORMAT = { sup: 1 << 6 }; -const CARD_NAME_MAP = { +const CARD_NAME_MAP: Record = { code: 'codeblock', hr: 'horizontalrule' }; -const CARD_PROPERTY_MAP = { +const CARD_PROPERTY_MAP: Record> = { embed: { type: 'embedType' } }; -const CARD_FIXES_MAP = { - callout: (payload) => { - if (payload.backgroundColor && !payload.backgroundColor.match(/^[a-zA-Z\d-]+$/)) { +const CARD_FIXES_MAP: Record) => Record> = { + callout: (payload: Record) => { + if (payload.backgroundColor && !(payload.backgroundColor as string).match(/^[a-zA-Z\d-]+$/)) { payload.backgroundColor = 'white'; } @@ -92,12 +134,12 @@ const CARD_FIXES_MAP = { } }; -export function mobiledocToLexical(serializedMobiledoc) { +export function mobiledocToLexical(serializedMobiledoc: string | null | undefined): string { if (serializedMobiledoc === null || serializedMobiledoc === undefined || serializedMobiledoc === '') { return JSON.stringify(BLANK_DOC); } - const mobiledoc = JSON.parse(serializedMobiledoc); + const mobiledoc = JSON.parse(serializedMobiledoc) as MobiledocDocument; if (!mobiledoc.sections) { return JSON.stringify(BLANK_DOC); @@ -105,14 +147,14 @@ export function mobiledocToLexical(serializedMobiledoc) { const lexical = buildEmptyDoc(); - mobiledoc.sections.forEach(child => addRootChild(child, mobiledoc, lexical)); + mobiledoc.sections.forEach((child: MobiledocSection) => addRootChild(child, mobiledoc, lexical)); return JSON.stringify(lexical); } /* internal functions ------------------------------------------------------- */ -function buildEmptyDoc() { +function buildEmptyDoc(): LexicalDocument { return { root: { children: [], @@ -125,7 +167,7 @@ function buildEmptyDoc() { }; } -function addRootChild(child, mobiledoc, lexical) { +function addRootChild(child: MobiledocSection, mobiledoc: MobiledocDocument, lexical: LexicalDocument): void { const sectionTypeIdentifier = child[0]; if (sectionTypeIdentifier === 1) { // Markup (text) section @@ -136,7 +178,7 @@ function addRootChild(child, mobiledoc, lexical) { // Otherwise direction should be null // Not sure if this is necessary: // if we don't plan to support RTL, we could just set 'ltr' in all cases and ignore null - if (lexicalChild.children?.length > 0) { + if (lexicalChild.children && lexicalChild.children.length > 0) { lexical.root.direction = 'ltr'; } } else if (sectionTypeIdentifier === 2) { @@ -154,9 +196,9 @@ function addRootChild(child, mobiledoc, lexical) { } } -function convertMarkupSectionToLexical(section, mobiledoc) { - const tagName = section[1]; // e.g. 'p' - const markers = section[2]; // e.g. [[0, [0], 0, "Hello world"]] +function convertMarkupSectionToLexical(section: MobiledocSection, mobiledoc: MobiledocDocument): LexicalNode { + const tagName = section[1] as string; // e.g. 'p' + const markers = section[2] as MobiledocMarker[]; // e.g. [[0, [0], 0, "Hello world"]] // Create an empty Lexical node from the tag name // We will add nodes to the children array later @@ -167,15 +209,15 @@ function convertMarkupSectionToLexical(section, mobiledoc) { return lexicalNode; } -function populateLexicalNodeWithMarkers(lexicalNode, markers, mobiledoc) { +function populateLexicalNodeWithMarkers(lexicalNode: LexicalNode, markers: MobiledocMarker[], mobiledoc: MobiledocDocument): void { const markups = mobiledoc.markups; const atoms = mobiledoc.atoms; // Initiate some variables before looping over all the markers - let openMarkups = []; // tracks which markup tags are open for the current marker - let linkNode = undefined; // tracks current link node or undefined if no a tag is open - let href = undefined; // tracks the href for the current link node or undefined if no a tag is open - let rel = undefined; //tracks the rel attribute for the current link node or undefined if no a tag is open + const openMarkups: MobiledocMarkup[] = []; // tracks which markup tags are open for the current marker + let linkNode: LexicalNode | undefined = undefined; // tracks current link node or undefined if no a tag is open + let href: string | undefined = undefined; // tracks the href for the current link node or undefined if no a tag is open + let rel: string | undefined = undefined; //tracks the rel attribute for the current link node or undefined if no a tag is open let openLinkMarkup = false; // tracks whether the current node is a link node // loop over markers and convert each one to lexical @@ -193,7 +235,7 @@ function populateLexicalNodeWithMarkers(lexicalNode, markers, mobiledoc) { // If the current marker is an atom, convert the atom to Lexical and add to the node if (markerType === 'atom') { - const atom = atoms[value]; + const atom = atoms[value as number]; const atomName = atom[0]; const childNode = ATOM_TO_LEXICAL_NODE[atomName]; embedChildNode(lexicalNode, childNode); @@ -201,17 +243,18 @@ function populateLexicalNodeWithMarkers(lexicalNode, markers, mobiledoc) { } // calculate which markups are open for the current marker - openMarkupsIndexes.forEach((markupIndex) => { + openMarkupsIndexes.forEach((markupIndex: number) => { const markup = markups[markupIndex]; // Extract the href from the markup if it's a link if (markup[0] === 'a') { openLinkMarkup = true; - if (markup[1] && markup[1][0] === 'href') { - href = markup[1][1]; + const attrs = markup[1] as unknown[] | undefined; + if (attrs && attrs[0] === 'href') { + href = attrs[1] as string; } - if (markup[1] && markup[1][2] === 'rel') { - rel = markup[1][3]; + if (attrs && attrs[2] === 'rel') { + rel = attrs[3] as string; } } // Add the markup to the list of open markups @@ -229,10 +272,10 @@ function populateLexicalNodeWithMarkers(lexicalNode, markers, mobiledoc) { linkNode = linkNode !== undefined ? linkNode : createEmptyLexicalNode('a', {url: href, rel: rel || null}); // Create a text node and add it to the link node - const textNode = createTextNode(value, format); + const textNode = createTextNode(value as string, format); embedChildNode(linkNode, textNode); } else { - const textNode = createTextNode(value, format); + const textNode = createTextNode(value as string, format); embedChildNode(lexicalNode, textNode); } } @@ -256,7 +299,7 @@ function populateLexicalNodeWithMarkers(lexicalNode, markers, mobiledoc) { } // Creates a text node from the given text and format -function createTextNode(text, format) { +function createTextNode(text: string, format: number): LexicalNode { return { detail: 0, format: format, @@ -269,28 +312,29 @@ function createTextNode(text, format) { } // Creates an empty Lexical node from the given tag name and additional attributes -function createEmptyLexicalNode(tagName, attributes = {}) { +function createEmptyLexicalNode(tagName: string, attributes: Record = {}): LexicalNode { const nodeParams = TAG_TO_LEXICAL_NODE[tagName]; - const node = { + const node: LexicalNode = { children: [], direction: 'ltr', format: '', indent: 0, ...nodeParams, ...attributes, + type: (nodeParams?.type as string) || (attributes.type as string) || tagName, version: 1 }; return node; } // Adds a child node to a parent node -function embedChildNode(parentNode, childNode) { +function embedChildNode(parentNode: LexicalNode, childNode: LexicalNode | undefined): void { // If there is no child node, do nothing if (!childNode) { return; } // Add textNode to node's children - parentNode.children.push(childNode); + parentNode.children!.push(childNode); // If there is any text (e.g. not a blank text node), set the direction to ltr if (childNode && 'text' in childNode && childNode.text) { @@ -301,32 +345,35 @@ function embedChildNode(parentNode, childNode) { // Lexical stores formats as a bitmask // Mobiledoc stores formats as a list of open markup tags // This function converts a list of open tags to a bitmask compatible with lexical -function convertMarkupTagsToLexicalFormatBitmask(tags) { +function convertMarkupTagsToLexicalFormatBitmask(tags: MobiledocMarkup[]): number { let format = 0; tags.forEach((tag) => { - if (tag in MARKUP_TO_FORMAT) { - format = format | MARKUP_TO_FORMAT[tag]; + const tagName = tag[0] as string; + if (tagName in MARKUP_TO_FORMAT) { + format = format | MARKUP_TO_FORMAT[tagName]; } }); return format; } -function convertListSectionToLexical(child, mobiledoc) { - const tag = child[1]; +function convertListSectionToLexical(child: MobiledocSection, mobiledoc: MobiledocDocument): LexicalNode { + const tag = child[1] as string; + const listItems = child[2] as MobiledocMarker[][] | undefined; const listType = tag === 'ul' ? 'bullet' : 'number'; const listNode = createEmptyLexicalNode(tag, {tag, type: 'list', listType, start: 1, direction: 'ltr'}); - child[2]?.forEach((listItem, i) => { + listItems?.forEach((listItem: MobiledocMarker[], i: number) => { const listItemNode = createEmptyLexicalNode('li', {type: 'listitem', value: i + 1, direction: 'ltr'}); populateLexicalNodeWithMarkers(listItemNode, listItem, mobiledoc); - listNode.children.push(listItemNode); + listNode.children!.push(listItemNode); }); return listNode; } -function convertCardSectionToLexical(child, mobiledoc) { - let [cardName, payload] = mobiledoc.cards[child[1]]; +function convertCardSectionToLexical(child: MobiledocSection, mobiledoc: MobiledocDocument): LexicalNode { + const cardIndex = child[1] as number; + let [cardName, payload] = mobiledoc.cards[cardIndex]; // rename card if there's a difference between mobiledoc and lexical cardName = CARD_NAME_MAP[cardName] || cardName; @@ -347,7 +394,7 @@ function convertCardSectionToLexical(child, mobiledoc) { } delete payload.type; - const decoratorNode = {type: cardName, ...payload}; + const decoratorNode: LexicalNode = {type: cardName, ...payload} as LexicalNode; return decoratorNode; } diff --git a/packages/kg-converters/test/exports.test.js b/packages/kg-converters/test/exports.test.js deleted file mode 100644 index 411b21fcdb..0000000000 --- a/packages/kg-converters/test/exports.test.js +++ /dev/null @@ -1,23 +0,0 @@ -const assert = require('assert/strict'); - -describe('Exports', function () { - it('includes both converter functions', function () { - const converters = require('../'); - - assert.ok(converters); - assert.ok(converters.lexicalToMobiledoc); - assert.equal(typeof converters.lexicalToMobiledoc, 'function'); - assert.ok(converters.mobiledocToLexical); - assert.equal(typeof converters.mobiledocToLexical, 'function'); - }); - - it('lexicalToMobiledoc runs without error', function () { - const converters = require('../'); - assert.ok(converters.lexicalToMobiledoc('{}')); - }); - - it('mobiledocToLexical runs without error', function () { - const converters = require('../'); - assert.ok(converters.mobiledocToLexical('{}')); - }); -}); diff --git a/packages/kg-converters/test/exports.test.ts b/packages/kg-converters/test/exports.test.ts new file mode 100644 index 0000000000..65466554c0 --- /dev/null +++ b/packages/kg-converters/test/exports.test.ts @@ -0,0 +1,19 @@ +import assert from 'assert/strict'; +import {lexicalToMobiledoc, mobiledocToLexical} from '../src/index.js'; + +describe('Exports', function () { + it('includes both converter functions', function () { + assert.ok(lexicalToMobiledoc); + assert.equal(typeof lexicalToMobiledoc, 'function'); + assert.ok(mobiledocToLexical); + assert.equal(typeof mobiledocToLexical, 'function'); + }); + + it('lexicalToMobiledoc runs without error', function () { + assert.ok(lexicalToMobiledoc('{}')); + }); + + it('mobiledocToLexical runs without error', function () { + assert.ok(mobiledocToLexical('{}')); + }); +}); diff --git a/packages/kg-converters/test/lexical-to-mobiledoc.test.js b/packages/kg-converters/test/lexical-to-mobiledoc.test.ts similarity index 99% rename from packages/kg-converters/test/lexical-to-mobiledoc.test.js rename to packages/kg-converters/test/lexical-to-mobiledoc.test.ts index 041073d8ad..8ec2b76f8d 100644 --- a/packages/kg-converters/test/lexical-to-mobiledoc.test.js +++ b/packages/kg-converters/test/lexical-to-mobiledoc.test.ts @@ -1,5 +1,5 @@ -const assert = require('assert/strict'); -const {lexicalToMobiledoc} = require('../'); +import assert from 'assert/strict'; +import {lexicalToMobiledoc} from '../src/index.js'; const MOBILEDOC_VERSION = '0.3.1'; const GHOST_VERSION = '4.0'; diff --git a/packages/kg-converters/test/mobiledoc-to-lexical.test.js b/packages/kg-converters/test/mobiledoc-to-lexical.test.ts similarity index 99% rename from packages/kg-converters/test/mobiledoc-to-lexical.test.js rename to packages/kg-converters/test/mobiledoc-to-lexical.test.ts index 4b7533c08f..0c3eb68032 100644 --- a/packages/kg-converters/test/mobiledoc-to-lexical.test.js +++ b/packages/kg-converters/test/mobiledoc-to-lexical.test.ts @@ -1,5 +1,5 @@ -const assert = require('assert/strict'); -const {mobiledocToLexical} = require('../'); +import assert from 'assert/strict'; +import {mobiledocToLexical} from '../src/index.js'; const MOBILEDOC_VERSION = '0.3.1'; const GHOST_VERSION = '4.0'; diff --git a/packages/kg-converters/tsconfig.cjs.json b/packages/kg-converters/tsconfig.cjs.json new file mode 100644 index 0000000000..bd981491bc --- /dev/null +++ b/packages/kg-converters/tsconfig.cjs.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "build/cjs", + "verbatimModuleSyntax": false, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "incremental": false + } +} diff --git a/packages/kg-converters/tsconfig.json b/packages/kg-converters/tsconfig.json new file mode 100644 index 0000000000..56c51dd045 --- /dev/null +++ b/packages/kg-converters/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "ES2022", + "moduleResolution": "bundler", + "rootDir": "src", + "outDir": "build/esm", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "incremental": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"] +} diff --git a/packages/kg-converters/tsconfig.test.json b/packages/kg-converters/tsconfig.test.json new file mode 100644 index 0000000000..b092804ad8 --- /dev/null +++ b/packages/kg-converters/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": null, + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "incremental": false + }, + "include": [ + "src/**/*", + "test/**/*" + ] +}