Skip to content

Migrate kg-converters to TypeScript#1761

Open
kevinansfield wants to merge 2 commits intomainfrom
ts/kg-converters
Open

Migrate kg-converters to TypeScript#1761
kevinansfield wants to merge 2 commits intomainfrom
ts/kg-converters

Conversation

@kevinansfield
Copy link
Member

Summary

  • Convert kg-converters from JavaScript to TypeScript
  • Add tsconfig.json + tsconfig.cjs.json, remove rollup config (replaced by tsc)
  • Rename source and test files from .js to .ts with type annotations
  • Update package.json: add build/prepare scripts, TypeScript + tsx devDeps, update test command, update exports for dual CJS/ESM output
  • Update eslint config for TypeScript

Test plan

  • yarn test passes in kg-converters
  • yarn lint passes in kg-converters
  • CI passes

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The PR converts packages/kg-converters to a TypeScript-first layout: adds tsconfig files, moves source to src/*.ts, and replaces Rollup/JS build assets with a single build output and an exports map in package.json. It adds strongly-typed implementations for lexical↔mobiledoc converters (src/lexical-to-mobiledoc.ts, src/mobiledoc-to-lexical.ts), re-exports them from src/index.ts, removes JS lib barrels and rollup.config.mjs, replaces ESLint flat config with a typescript-eslint wrapper, updates .gitignore to ignore build/, and migrates tests to TypeScript/ESM.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title 'Migrate kg-converters to TypeScript' directly summarizes the main objective of the changeset: converting the kg-converters package from JavaScript to TypeScript.
Description check ✅ Passed The pull request description provides detailed information about the migration changes including file conversions, configuration updates, and testing plans, all directly related to the TypeScript migration changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ts/kg-converters
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (2)
packages/kg-converters/tsconfig.json (2)

12-12: Pin tsBuildInfoFile when using incremental builds

On Line 12, incremental: true is good for local speed, but without tsBuildInfoFile the cache file location is less controlled. Consider placing it under build/ to avoid workspace clutter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/tsconfig.json` at line 12, Add a pinned
tsBuildInfoFile to the tsconfig so the incremental compiler output is stored in
a controlled location (e.g., "build/tsconfig.tsbuildinfo"); update the
tsconfig.json near the incremental: true setting by adding the tsBuildInfoFile
property, referencing the same project name/intent, so incremental builds write
their .tsbuildinfo into build/ instead of scattering files at workspace root.

5-5: All relative imports already use explicit .js extensions; however, consider switching ESM moduleResolution to NodeNext for compile-time enforcement

The codebase correctly uses explicit .js extensions on all relative imports (3 total), which ensures Node ESM runtime compatibility despite bundler resolution. Since this is a published library with dual ESM/CJS output, switching the ESM tsconfig.json from moduleResolution: "bundler" to NodeNext would enforce this best practice at compile-time rather than relying on developer discipline.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/tsconfig.json` at line 5, Update the ESM TypeScript
config to use NodeNext resolution: change the tsconfig setting named
"moduleResolution" from "bundler" to "NodeNext" so the compiler enforces
explicit .js extensions for ESM imports; locate the JSON property
"moduleResolution" in the tsconfig used for ESM output (the entry currently set
to "bundler") and replace its value with "NodeNext".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/kg-converters/eslint.config.mjs`:
- Around line 18-19: The current config spreads ghostPlugin.configs.ts.rules
(and similarly ts-test) which are legacy .eslintrc presets and incompatible with
ESLint v9 flat-config; replace that spread usage by converting the legacy
presets via `@eslint/eslintrc`'s FlatCompat (create a FlatCompat instance, call
compat.extends('plugin:ghost/ts') and compat.extends('plugin:ghost/ts-test')
where needed) and then supply the returned arrays into the tseslint.config call
alongside your files/ rules blocks (leave your
'@typescript-eslint/no-explicit-any' rule in the appropriate files-scoped rules
object); ensure you import and instantiate FlatCompat with a correct
baseDirectory (use fileURLToPath(dirname) pattern) so the legacy plugin presets
are bridged into the flat config.

In `@packages/kg-converters/package.json`:
- Around line 10-16: The "node" conditional in the package.json exports
currently precedes "import"/"require" and thus shadows the ESM entry; update the
"." export object so the "import" (./build/esm/index.js) and "require"
(./build/cjs/index.js) keys come before "node" to ensure ESM imports resolve to
the ESM build and Node-specific fallback ("node": ./build/cjs/index.js) is only
used when appropriate; adjust the ordering of the keys "import", "require", and
"node" in the exports block accordingly.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts`:
- Around line 128-130: The callout handler currently casts
payload.backgroundColor to string and calls .match(), which will throw for
non-string truthy values; update the callout function to first check that
payload.backgroundColor is a string (e.g., typeof payload.backgroundColor ===
'string') before invoking .match(), and if it is not a string or the regex test
fails, set payload.backgroundColor = 'white'; ensure you only perform the regex
match on a validated string to avoid runtime errors during conversion.
- Around line 246-258: The link attributes (href and rel) are reused across
iterations causing rel to bleed into subsequent links; inside the
openMarkupsIndexes.forEach handler (where openLinkMarkup, href, rel and markup
are referenced) reset href and rel to undefined (or empty string per project
conventions) at the start of each iteration or immediately when you detect a new
'a' markup before reading attrs so that a link without a rel does not inherit
the previous rel value.

In `@packages/kg-converters/test/exports.test.ts`:
- Around line 1-18: The test imports converters from the source barrel
(lexicalToMobiledoc, mobiledocToLexical) which doesn't validate the
published/build entrypoints; change the import in
packages/kg-converters/test/exports.test.ts to require the package's built
entrypoint (the package root/main/exports) instead of ../src/index.js so the
test exercises package.json#exports and the generated build files; keep the same
assertions and symbol names (lexicalToMobiledoc, mobiledocToLexical) and run the
test to ensure the built entrypoint exports both functions.

In `@packages/kg-converters/tsconfig.cjs.json`:
- Line 5: The tsconfig CJS config currently sets "moduleResolution": "Node10",
which ignores package.json "exports" conditional fields; update the
moduleResolution option in packages/kg-converters/tsconfig.cjs.json from
"Node10" to "Node16" (or "nodenext") so TypeScript will honor package.json
exports and conditional resolution for CJS builds—change the "moduleResolution"
property value and run a quick build/test to verify no downstream resolution
breaks.

---

Nitpick comments:
In `@packages/kg-converters/tsconfig.json`:
- Line 12: Add a pinned tsBuildInfoFile to the tsconfig so the incremental
compiler output is stored in a controlled location (e.g.,
"build/tsconfig.tsbuildinfo"); update the tsconfig.json near the incremental:
true setting by adding the tsBuildInfoFile property, referencing the same
project name/intent, so incremental builds write their .tsbuildinfo into build/
instead of scattering files at workspace root.
- Line 5: Update the ESM TypeScript config to use NodeNext resolution: change
the tsconfig setting named "moduleResolution" from "bundler" to "NodeNext" so
the compiler enforces explicit .js extensions for ESM imports; locate the JSON
property "moduleResolution" in the tsconfig used for ESM output (the entry
currently set to "bundler") and replace its value with "NodeNext".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6043eff7-02ef-430f-a5ae-1929f7356b8f

📥 Commits

Reviewing files that changed from the base of the PR and between a12e092 and 5031400.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (16)
  • packages/kg-converters/.gitignore
  • packages/kg-converters/eslint.config.mjs
  • packages/kg-converters/index.js
  • packages/kg-converters/lib/kg-converters.js
  • packages/kg-converters/package.json
  • packages/kg-converters/rollup.config.mjs
  • packages/kg-converters/src/index.ts
  • packages/kg-converters/src/kg-converters.ts
  • packages/kg-converters/src/lexical-to-mobiledoc.ts
  • packages/kg-converters/src/mobiledoc-to-lexical.ts
  • packages/kg-converters/test/exports.test.js
  • packages/kg-converters/test/exports.test.ts
  • packages/kg-converters/test/lexical-to-mobiledoc.test.ts
  • packages/kg-converters/test/mobiledoc-to-lexical.test.ts
  • packages/kg-converters/tsconfig.cjs.json
  • packages/kg-converters/tsconfig.json
💤 Files with no reviewable changes (4)
  • packages/kg-converters/test/exports.test.js
  • packages/kg-converters/index.js
  • packages/kg-converters/rollup.config.mjs
  • packages/kg-converters/lib/kg-converters.js

Comment on lines +18 to +19
...ghostPlugin.configs.ts.rules,
'@typescript-eslint/no-explicit-any': 'error',
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

plugin_dir="$(fd '^eslint-plugin-ghost$' packages -t d | head -n1)"
if [ -z "$plugin_dir" ]; then
  echo "Could not locate eslint-plugin-ghost under packages/" >&2
  exit 1
fi

rg -n -C8 "\\bts\\b\\s*:\\s*\\{|['\\\"]ts-test['\\\"]\\s*:\\s*\\{|languageOptions\\s*:|plugins\\s*:|settings\\s*:|ignores\\s*:" "$plugin_dir" -g '!**/build/**'

Repository: TryGhost/Koenig

Length of output: 111


🏁 Script executed:

#!/bin/bash

# Check if eslint-plugin-ghost is listed as an external dependency
echo "=== Checking package.json for eslint-plugin-ghost ==="
fd package.json packages/kg-converters -x cat {} | grep -A10 -B10 "eslint-plugin-ghost" || echo "Not found in direct search"

# Display the full eslint.config.mjs file to see the imports and context
echo -e "\n=== Full eslint.config.mjs content ==="
cat packages/kg-converters/eslint.config.mjs

# Check if there's a monorepo root package.json
echo -e "\n=== Checking root package.json for eslint-plugin-ghost ==="
cat package.json | grep -A5 -B5 "eslint-plugin-ghost" || echo "Not found in root"

Repository: TryGhost/Koenig

Length of output: 1368


🌐 Web query:

eslint-plugin-ghost npm preset config ts rules languageOptions

💡 Result:

eslint-plugin-ghost ships legacy .eslintrc* “preset configs” (exposed as plugin:ghost/<preset>), not ESLint v9 flat-config languageOptions objects. Usage is via extends: ['plugin:ghost/<preset>'] (presets include node, browser, es, ember, ts, test, filenames, plus *-no-style variants, and ts-test). [1]

What the ts preset does (rules / parser options)

The plugin:ghost/ts preset:

  • sets env: { node: true, es6: true }
  • sets parserOptions: { ecmaVersion: 2022 }
  • enables @typescript-eslint and extends plugin:@typescript-eslint/recommended``
  • merges Ghost’s shared rules and disables @typescript-eslint/no-inferrable-types [2]

plugin:ghost/ts-test extends ./ts and adds ./test-rules. [3]

ESLint v9 flat config (languageOptions) equivalent (if you want to avoid .eslintrc)

If you’re using eslint.config.js, you typically bring these legacy presets in via FlatCompat, then add/override languageOptions as needed (especially the TS parser):

// eslint.config.js
import { FlatCompat } from '@eslint/eslintrc'
import tsParser from '@typescript-eslint/parser'

const compat = new FlatCompat({ baseDirectory: import.meta.dirname })

export default [
  ...compat.extends('plugin:ghost/ts'),
  {
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      parser: tsParser,
      parserOptions: { ecmaVersion: 2022 },
    },
  },
]

(Typescript-eslint’s flat-config docs show languageOptions.parser / languageOptions.parserOptions as the place to configure TS parsing in flat config.) [4]

Sources: [1] [2] [3] [4]


Replace spread of Ghost plugin rules with proper ESLint v9 flat-config integration.

eslint-plugin-ghost v3.5.0 exports legacy .eslintrc* presets (e.g., plugin:ghost/ts, plugin:ghost/ts-test), not ESLint v9 flat-config objects. Accessing ghostPlugin.configs.ts.rules and ghostPlugin.configs['ts-test'].rules is not compatible with the plugin's actual structure and will not work reliably.

Use @eslint/eslintrc's FlatCompat to bridge the legacy presets into flat config, or migrate to .eslintrc.json format. If using FlatCompat:

♻️ Example using FlatCompat
import { FlatCompat } from '@eslint/eslintrc';
import tseslint from 'typescript-eslint';
import path from 'path';
import { fileURLToPath } from 'url';

const dirname = path.dirname(fileURLToPath(import.meta.url));
const compat = new FlatCompat({ baseDirectory: dirname });

export default tseslint.config([
  { ignores: ['build/**'] },
  ...compat.extends('plugin:ghost/ts'),
  {
    files: ['**/*.ts'],
    rules: {
      '@typescript-eslint/no-explicit-any': 'error',
    },
  },
  {
    files: ['test/**/*.ts'],
    ...compat.extends('plugin:ghost/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',
    },
  },
]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/eslint.config.mjs` around lines 18 - 19, The current
config spreads ghostPlugin.configs.ts.rules (and similarly ts-test) which are
legacy .eslintrc presets and incompatible with ESLint v9 flat-config; replace
that spread usage by converting the legacy presets via `@eslint/eslintrc`'s
FlatCompat (create a FlatCompat instance, call compat.extends('plugin:ghost/ts')
and compat.extends('plugin:ghost/ts-test') where needed) and then supply the
returned arrays into the tseslint.config call alongside your files/ rules blocks
(leave your '@typescript-eslint/no-explicit-any' rule in the appropriate
files-scoped rules object); ensure you import and instantiate FlatCompat with a
correct baseDirectory (use fileURLToPath(dirname) pattern) so the legacy plugin
presets are bridged into the flat config.

Comment on lines +246 to 258
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;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reset link attributes before reusing them.

rel is section-scoped state here. If one link has rel and the next link does not, the second link inherits the first link's rel because nothing clears it before the next <a> is parsed.

Proposed fix
             if (markup[0] === 'a') {
                 openLinkMarkup = true;
+                href = undefined;
+                rel = undefined;
                 const attrs = markup[1] as unknown[] | undefined;
                 if (attrs && attrs[0] === 'href') {
                     href = attrs[1] as string;
@@
             if (markup && markup[0] === 'a') {
                 embedChildNode(lexicalNode, linkNode);
                 openLinkMarkup = false;
                 href = undefined;
+                rel = undefined;
                 linkNode = undefined;
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 246 - 258,
The link attributes (href and rel) are reused across iterations causing rel to
bleed into subsequent links; inside the openMarkupsIndexes.forEach handler
(where openLinkMarkup, href, rel and markup are referenced) reset href and rel
to undefined (or empty string per project conventions) at the start of each
iteration or immediately when you detect a new 'a' markup before reading attrs
so that a link without a rel does not inherit the previous rel value.

Comment on lines +1 to +18
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('{}'));
});
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Point this suite at the built entrypoints, not src/.

Importing ../src/index.js only proves the source barrel works under tsx; it does not exercise package.json#exports, main, or the generated build/* files. A broken publish surface would still pass this test.

Proposed fix
 import assert from 'assert/strict';
-import {lexicalToMobiledoc, mobiledocToLexical} from '../src/index.js';
+import {createRequire} from 'node:module';
+import {lexicalToMobiledoc, mobiledocToLexical} from '../build/esm/index.js';
+
+const require = createRequire(import.meta.url);
+const cjsExports = require('../build/cjs/index.js');
@@
     it('includes both converter functions', function () {
         assert.ok(lexicalToMobiledoc);
         assert.equal(typeof lexicalToMobiledoc, 'function');
         assert.ok(mobiledocToLexical);
         assert.equal(typeof mobiledocToLexical, 'function');
+        assert.equal(typeof cjsExports.lexicalToMobiledoc, 'function');
+        assert.equal(typeof cjsExports.mobiledocToLexical, 'function');
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/test/exports.test.ts` around lines 1 - 18, The test
imports converters from the source barrel (lexicalToMobiledoc,
mobiledocToLexical) which doesn't validate the published/build entrypoints;
change the import in packages/kg-converters/test/exports.test.ts to require the
package's built entrypoint (the package root/main/exports) instead of
../src/index.js so the test exercises package.json#exports and the generated
build files; keep the same assertions and symbol names (lexicalToMobiledoc,
mobiledocToLexical) and run the test to ensure the built entrypoint exports both
functions.

@kevinansfield kevinansfield force-pushed the ts/kg-converters branch 4 times, most recently from e550854 to 9194eb6 Compare March 17, 2026 21:57
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (4)
packages/kg-converters/package.json (1)

13-15: ⚠️ Potential issue | 🟠 Major

Reorder conditional exports to avoid ESM/CJS shadowing.

At Line 13, "node" is checked before "import"/"require", which can route ESM imports to CJS in Node due to condition-order precedence.

In Node.js package.json conditional exports, does object key order control precedence, and if "node" appears before "import", which target is chosen for an ESM import?
Proposed fix
   "exports": {
     ".": {
       "types": "./build/esm/index.d.ts",
-      "node": "./build/cjs/index.js",
       "import": "./build/esm/index.js",
-      "require": "./build/cjs/index.js"
+      "require": "./build/cjs/index.js",
+      "default": "./build/esm/index.js"
     }
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/package.json` around lines 13 - 15, The conditional
export object currently has "node" before "import", which can make ESM imports
resolve to the CJS target; reorder the keys so "import" appears before "node"
(e.g., "import", "node", "require") in the package.json exports to ensure ESM
consumers pick the ESM bundle, and keep "require" pointing at the CJS build;
update the export object keys "import", "node", and "require" accordingly.
packages/kg-converters/test/exports.test.ts (1)

2-18: 🛠️ Refactor suggestion | 🟠 Major

Exercise the built package entrypoints, not src/.

Importing ../src/index.js only proves the source barrel loads in tests. It will not catch a broken package.json#exports, main, or generated CJS/ESM output, which is the surface this PR changes. Point this suite at the built package surface, or the package root after build, instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/test/exports.test.ts` around lines 2 - 18, The tests
import the source barrel (../src/index.js) instead of the built package
entrypoint so they don't validate package.json exports or generated outputs;
update the import in the test that references lexicalToMobiledoc and
mobiledocToLexical to import from the package's built entry (the package root /
published entrypoint) instead of '../src/index.js' so the suite exercises the
actual distributed CJS/ESM output and package.json#exports.
packages/kg-converters/src/mobiledoc-to-lexical.ts (2)

246-258: ⚠️ Potential issue | 🟠 Major

Reset rel when opening and closing links.

rel is section-scoped state here. If one link has rel and the next one doesn't, the second link inherits the first link's value.

Suggested fix
         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;
+                href = undefined;
+                rel = undefined;
                 const attrs = markup[1] as unknown[] | undefined;
                 if (attrs && attrs[0] === 'href') {
                     href = attrs[1] as string;
@@
             if (markup && markup[0] === 'a') {
                 embedChildNode(lexicalNode, linkNode);
                 openLinkMarkup = false;
                 href = undefined;
+                rel = undefined;
                 linkNode = undefined;
             }

Also applies to: 291-295

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 246 - 258,
openMarkupsIndexes.forEach loop is keeping section-scoped rel state so a link
without rel inherits the previous value; update the logic in the open-markup
handling (inside openMarkupsIndexes.forEach) to explicitly reset rel when
starting a new link that has no rel attribute (set rel to undefined or empty
string when attrs is missing or attrs[2] !== 'rel'), and likewise ensure rel is
cleared when closing the link (where openLinkMarkup is set false) so subsequent
links do not inherit the previous rel; reference variables:
openMarkupsIndexes.forEach, markup, openLinkMarkup, href, rel to locate where to
add the resets.

127-130: ⚠️ Potential issue | 🟠 Major

Validate backgroundColor before calling .match().

A truthy non-string payload.backgroundColor still throws here and aborts the whole conversion.

Suggested fix
     callout: (payload: Record<string, unknown>) => {
-        if (payload.backgroundColor && !(payload.backgroundColor as string).match(/^[a-zA-Z\d-]+$/)) {
+        if (
+            payload.backgroundColor !== undefined &&
+            (typeof payload.backgroundColor !== 'string' || !/^[a-zA-Z\d-]+$/.test(payload.backgroundColor))
+        ) {
             payload.backgroundColor = 'white';
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 127 - 130,
The callout card handler in CARD_FIXES_MAP calls payload.backgroundColor.match()
without ensuring backgroundColor is a string, causing runtime errors for
non-string truthy values; update the callout fixer (the callout function inside
CARD_FIXES_MAP) to first check that typeof payload.backgroundColor === 'string'
(or safely coerce to string) before using .match(), and if the check fails or
the regex doesn't match, set payload.backgroundColor = 'white' as the fallback.
🧹 Nitpick comments (2)
packages/kg-converters/eslint.config.mjs (1)

22-31: Test config relies on plugin registration from the preceding config block.

The test config at lines 22-31 doesn't register the ghost plugin but references ghost/mocha/* rules. This works because ESLint flat config merges configs for files matching multiple patterns, so test/**/*.ts files inherit the ghost plugin from the **/*.ts config block. This is valid but worth noting for maintainability—if the main config changes, the test rules could break.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/eslint.config.mjs` around lines 22 - 31, The test
files block references rules under the ghost plugin (e.g., 'ghost/mocha/*') but
doesn't register ghostPlugin, relying on inheritance from the earlier "**/*.ts"
block; make the test config explicitly register the plugin so it doesn't break
if the other block changes — update the files: ['test/**/*.ts'] config object to
include the ghost plugin registration (use the existing ghostPlugin identifier)
alongside the rules in that block so 'ghost/mocha/*' rules resolve locally.
packages/kg-converters/package.json (1)

20-22: Deduplicate repeated build pipeline command.

Lines 20-22 repeat the same command string three times; this is easy to drift and harder to maintain.

Proposed refactor
   "scripts": {
     "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",
+    "build:artifacts": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"module\"}' > build/esm/package.json",
+    "build": "yarn build:artifacts",
+    "prepare": "yarn build:artifacts",
+    "pretest": "yarn build:artifacts",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/package.json` around lines 20 - 22, The package.json
repeats the same command in the "build", "prepare", and "pretest" scripts; add a
single canonical script (e.g., "build:all") containing the current command
string and update "build", "prepare", and "pretest" to invoke that script (e.g.,
"npm run build:all") so the build command is defined once; look for the "build",
"prepare", and "pretest" script entries in package.json to change them to
reference the new "build:all" script.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/kg-converters/src/lexical-to-mobiledoc.ts`:
- Around line 301-303: The link markup currently only serializes href causing
rel to be lost on round-trip; when handling child.type === 'link' in
lexical-to-mobiledoc.ts (the code that builds linkMarkup and pushes to
mobiledoc.markups), include the rel attribute if present on child (child.rel) in
the markup attributes array alongside href before pushing linkMarkup to
mobiledoc.markups so the rel metadata is preserved for mobiledoc-to-lexical.ts
to read back.
- Around line 112-120: In lexicalToMobiledoc, JSON.parse(serializedLexical) can
return the literal null (e.g. when serializedLexical === "null"), so avoid
accessing lexical.root without checking lexical itself; after parsing, validate
that lexical is non-null and that lexical.root exists (e.g., if (!lexical ||
!lexical.root) return JSON.stringify(BLANK_DOC)); update the parsing flow around
the const lexical = JSON.parse(...) and the subsequent if that checks .root to
handle the parsed null case.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts`:
- Around line 137-145: The function mobiledocToLexical currently parses
serializedMobiledoc and immediately accesses mobiledoc.sections which will throw
if JSON.parse returns the literal null; update mobiledocToLexical to validate
the parsed mobiledoc (the variable mobiledoc) immediately after JSON.parse — if
mobiledoc is null or not an object or mobiledoc.sections is falsy, return
JSON.stringify(BLANK_DOC); keep the rest of the conversion logic unchanged.

---

Duplicate comments:
In `@packages/kg-converters/package.json`:
- Around line 13-15: The conditional export object currently has "node" before
"import", which can make ESM imports resolve to the CJS target; reorder the keys
so "import" appears before "node" (e.g., "import", "node", "require") in the
package.json exports to ensure ESM consumers pick the ESM bundle, and keep
"require" pointing at the CJS build; update the export object keys "import",
"node", and "require" accordingly.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts`:
- Around line 246-258: openMarkupsIndexes.forEach loop is keeping section-scoped
rel state so a link without rel inherits the previous value; update the logic in
the open-markup handling (inside openMarkupsIndexes.forEach) to explicitly reset
rel when starting a new link that has no rel attribute (set rel to undefined or
empty string when attrs is missing or attrs[2] !== 'rel'), and likewise ensure
rel is cleared when closing the link (where openLinkMarkup is set false) so
subsequent links do not inherit the previous rel; reference variables:
openMarkupsIndexes.forEach, markup, openLinkMarkup, href, rel to locate where to
add the resets.
- Around line 127-130: The callout card handler in CARD_FIXES_MAP calls
payload.backgroundColor.match() without ensuring backgroundColor is a string,
causing runtime errors for non-string truthy values; update the callout fixer
(the callout function inside CARD_FIXES_MAP) to first check that typeof
payload.backgroundColor === 'string' (or safely coerce to string) before using
.match(), and if the check fails or the regex doesn't match, set
payload.backgroundColor = 'white' as the fallback.

In `@packages/kg-converters/test/exports.test.ts`:
- Around line 2-18: The tests import the source barrel (../src/index.js) instead
of the built package entrypoint so they don't validate package.json exports or
generated outputs; update the import in the test that references
lexicalToMobiledoc and mobiledocToLexical to import from the package's built
entry (the package root / published entrypoint) instead of '../src/index.js' so
the suite exercises the actual distributed CJS/ESM output and
package.json#exports.

---

Nitpick comments:
In `@packages/kg-converters/eslint.config.mjs`:
- Around line 22-31: The test files block references rules under the ghost
plugin (e.g., 'ghost/mocha/*') but doesn't register ghostPlugin, relying on
inheritance from the earlier "**/*.ts" block; make the test config explicitly
register the plugin so it doesn't break if the other block changes — update the
files: ['test/**/*.ts'] config object to include the ghost plugin registration
(use the existing ghostPlugin identifier) alongside the rules in that block so
'ghost/mocha/*' rules resolve locally.

In `@packages/kg-converters/package.json`:
- Around line 20-22: The package.json repeats the same command in the "build",
"prepare", and "pretest" scripts; add a single canonical script (e.g.,
"build:all") containing the current command string and update "build",
"prepare", and "pretest" to invoke that script (e.g., "npm run build:all") so
the build command is defined once; look for the "build", "prepare", and
"pretest" script entries in package.json to change them to reference the new
"build:all" script.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8199cdc2-ba7e-4528-bfbf-a229381692ce

📥 Commits

Reviewing files that changed from the base of the PR and between 5031400 and e550854.

📒 Files selected for processing (16)
  • packages/kg-converters/.gitignore
  • packages/kg-converters/eslint.config.mjs
  • packages/kg-converters/index.js
  • packages/kg-converters/lib/kg-converters.js
  • packages/kg-converters/package.json
  • packages/kg-converters/rollup.config.mjs
  • packages/kg-converters/src/index.ts
  • packages/kg-converters/src/kg-converters.ts
  • packages/kg-converters/src/lexical-to-mobiledoc.ts
  • packages/kg-converters/src/mobiledoc-to-lexical.ts
  • packages/kg-converters/test/exports.test.js
  • packages/kg-converters/test/exports.test.ts
  • packages/kg-converters/test/lexical-to-mobiledoc.test.ts
  • packages/kg-converters/test/mobiledoc-to-lexical.test.ts
  • packages/kg-converters/tsconfig.cjs.json
  • packages/kg-converters/tsconfig.json
💤 Files with no reviewable changes (4)
  • packages/kg-converters/test/exports.test.js
  • packages/kg-converters/lib/kg-converters.js
  • packages/kg-converters/rollup.config.mjs
  • packages/kg-converters/index.js
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/kg-converters/src/kg-converters.ts
  • packages/kg-converters/tsconfig.json
  • packages/kg-converters/tsconfig.cjs.json
  • packages/kg-converters/src/index.ts
  • packages/kg-converters/test/lexical-to-mobiledoc.test.ts
  • packages/kg-converters/test/mobiledoc-to-lexical.test.ts
  • packages/kg-converters/.gitignore

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (5)
packages/kg-converters/src/lexical-to-mobiledoc.ts (2)

301-303: ⚠️ Potential issue | 🟠 Major

Preserve link rel when serializing to Mobiledoc markup.

Line 302 only writes href, so round-tripping drops rel metadata that the reverse converter already reads.

Proposed fix
-                const linkMarkup: MobiledocMarkup = ['a', ['href', child.url]];
+                const attrs: unknown[] = ['href', child.url];
+                if (typeof child.rel === 'string' && child.rel) {
+                    attrs.push('rel', child.rel);
+                }
+                const linkMarkup: MobiledocMarkup = ['a', attrs];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/lexical-to-mobiledoc.ts` around lines 301 - 303,
The link markup currently only writes href and drops rel metadata; update the
serialization in the lexical-to-mobiledoc converter where child.type === 'link'
(the linkMarkup and its push into mobiledoc.markups) to include rel when present
(e.g., add ['rel', child.rel] alongside ['href', child.url]) so the linkMarkup
array preserves rel for round-tripping back to the reverse converter.

117-120: ⚠️ Potential issue | 🟠 Major

Handle parsed null before reading .root.

At Line 119, JSON.parse('null') leads to a null dereference instead of returning the blank Mobiledoc.

Proposed fix
-    const lexical = JSON.parse(serializedLexical) as {root?: {children?: LexicalNode[]}};
-
-    if (!lexical.root) {
+    const lexical = JSON.parse(serializedLexical) as {root?: {children?: LexicalNode[]}} | null;
+
+    if (!lexical?.root) {
         return JSON.stringify(BLANK_DOC);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/lexical-to-mobiledoc.ts` around lines 117 - 120,
The code currently assumes JSON.parse(serializedLexical) returns an object and
directly checks lexical.root, which throws if serializedLexical is "null";
change the guard to handle a null parse result before reading .root: after
parsing serializedLexical into the lexical variable, ensure you check that
lexical is truthy (e.g., lexical !== null) and that lexical.root exists (e.g.,
if (!lexical || !lexical.root) return JSON.stringify(BLANK_DOC)); update any
type assertions as needed to perform a proper runtime null check on lexical
before accessing .root to avoid the null dereference.
packages/kg-converters/src/mobiledoc-to-lexical.ts (3)

127-130: ⚠️ Potential issue | 🟠 Major

Guard backgroundColor type before calling .match().

Line 129 still does a cast-only check; a truthy non-string value will throw and stop conversion.

Proposed fix
 const CARD_FIXES_MAP: Record<string, (payload: Record<string, unknown>) => Record<string, unknown>> = {
     callout: (payload: Record<string, unknown>) => {
-        if (payload.backgroundColor && !(payload.backgroundColor as string).match(/^[a-zA-Z\d-]+$/)) {
+        if (
+            payload.backgroundColor !== undefined &&
+            (typeof payload.backgroundColor !== 'string' || !/^[a-zA-Z\d-]+$/.test(payload.backgroundColor))
+        ) {
             payload.backgroundColor = 'white';
         }
 
         return payload;
     }
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 127 - 130,
In the callout entry of CARD_FIXES_MAP, guard backgroundColor's type before
calling .match(): instead of casting to string and calling match unconditionally
in the callout handler, first check typeof payload.backgroundColor === 'string'
(and truthiness) before using .match(), and only set payload.backgroundColor =
'white' when the string fails the regex; update the callout handler inside
CARD_FIXES_MAP accordingly to avoid throwing on non-string values.

142-145: ⚠️ Potential issue | 🟠 Major

Handle parsed null before reading .sections.

At Line 144, JSON.parse('null') makes mobiledoc null, so property access throws before the fallback path is reached.

Proposed fix
-    const mobiledoc = JSON.parse(serializedMobiledoc) as MobiledocDocument;
-
-    if (!mobiledoc.sections) {
+    const mobiledoc = JSON.parse(serializedMobiledoc) as MobiledocDocument | null;
+
+    if (!Array.isArray(mobiledoc?.sections)) {
         return JSON.stringify(BLANK_DOC);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 142 - 145,
The code parses serializedMobiledoc into the variable mobiledoc and immediately
reads mobiledoc.sections, which will throw if JSON.parse returns null (e.g.,
JSON "null"); update the logic that follows the JSON.parse call (the mobiledoc
variable usage) to first check whether mobiledoc is truthy (non-null) before
accessing mobiledoc.sections and return JSON.stringify(BLANK_DOC) when mobiledoc
is null or when mobiledoc.sections is missing; locate the
JSON.parse(serializedMobiledoc) assignment and the subsequent conditional that
references mobiledoc.sections to implement this null guard.

246-258: ⚠️ Potential issue | 🟠 Major

Reset link-scoped href/rel state between link markups.

Lines 246-258 reuse section-scoped state, and Line 295 close logic still doesn’t clear rel. A link without rel can inherit a previous one.

Proposed fix
         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;
+                href = undefined;
+                rel = undefined;
                 const attrs = markup[1] as unknown[] | undefined;
                 if (attrs && attrs[0] === 'href') {
                     href = attrs[1] as string;
                 }
@@
             if (markup && markup[0] === 'a') {
                 embedChildNode(lexicalNode, linkNode);
                 openLinkMarkup = false;
                 href = undefined;
+                rel = undefined;
                 linkNode = undefined;
             }

Also applies to: 291-296

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 246 - 258,
The code reuses section-scoped link state (href and rel) across iterations
causing a link without rel to inherit a previous value; in the loop that
iterates openMarkupsIndexes (and in the link-close logic around where
openLinkMarkup is cleared) reset href and rel to undefined before processing a
new markup and also explicitly set rel = undefined when closing a link; update
the logic inside the block handling markups[markupIndex] (where markup[0] ===
'a', openLinkMarkup, attrs, href, rel are used) and the corresponding closing
branch that clears openLinkMarkup to ensure href and rel are cleared so state
doesn’t leak between link markups.
🧹 Nitpick comments (1)
packages/kg-converters/package.json (1)

19-22: Consider DRYing up the duplicated build commands.

The build, prepare, and pretest scripts contain identical commands. You could have prepare and pretest invoke build instead:

Suggested refactor
     "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",
+    "prepare": "yarn build",
+    "pretest": "yarn build",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/package.json` around lines 19 - 22, The package.json
scripts duplicate the same build command for "build", "prepare", and "pretest";
keep "build" unchanged and make "prepare" and "pretest" call that script (e.g.,
replace their command strings with "npm run build") so you DRY up the commands
while preserving the existing build behavior for the "build" script.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/kg-converters/package.json`:
- Line 40: The package.json devDependencies are missing TypeScript typings for
sinon; add `@types/sinon` to devDependencies so tests using sinon in TypeScript
compile. Update packages/kg-converters package.json by adding an entry for
"@types/sinon" under devDependencies (choose a compatible version for sinon 21.x
or use a caret range) and run install to update lockfiles; ensure imports in
tests continue to reference sinon as before.

---

Duplicate comments:
In `@packages/kg-converters/src/lexical-to-mobiledoc.ts`:
- Around line 301-303: The link markup currently only writes href and drops rel
metadata; update the serialization in the lexical-to-mobiledoc converter where
child.type === 'link' (the linkMarkup and its push into mobiledoc.markups) to
include rel when present (e.g., add ['rel', child.rel] alongside ['href',
child.url]) so the linkMarkup array preserves rel for round-tripping back to the
reverse converter.
- Around line 117-120: The code currently assumes JSON.parse(serializedLexical)
returns an object and directly checks lexical.root, which throws if
serializedLexical is "null"; change the guard to handle a null parse result
before reading .root: after parsing serializedLexical into the lexical variable,
ensure you check that lexical is truthy (e.g., lexical !== null) and that
lexical.root exists (e.g., if (!lexical || !lexical.root) return
JSON.stringify(BLANK_DOC)); update any type assertions as needed to perform a
proper runtime null check on lexical before accessing .root to avoid the null
dereference.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts`:
- Around line 127-130: In the callout entry of CARD_FIXES_MAP, guard
backgroundColor's type before calling .match(): instead of casting to string and
calling match unconditionally in the callout handler, first check typeof
payload.backgroundColor === 'string' (and truthiness) before using .match(), and
only set payload.backgroundColor = 'white' when the string fails the regex;
update the callout handler inside CARD_FIXES_MAP accordingly to avoid throwing
on non-string values.
- Around line 142-145: The code parses serializedMobiledoc into the variable
mobiledoc and immediately reads mobiledoc.sections, which will throw if
JSON.parse returns null (e.g., JSON "null"); update the logic that follows the
JSON.parse call (the mobiledoc variable usage) to first check whether mobiledoc
is truthy (non-null) before accessing mobiledoc.sections and return
JSON.stringify(BLANK_DOC) when mobiledoc is null or when mobiledoc.sections is
missing; locate the JSON.parse(serializedMobiledoc) assignment and the
subsequent conditional that references mobiledoc.sections to implement this null
guard.
- Around line 246-258: The code reuses section-scoped link state (href and rel)
across iterations causing a link without rel to inherit a previous value; in the
loop that iterates openMarkupsIndexes (and in the link-close logic around where
openLinkMarkup is cleared) reset href and rel to undefined before processing a
new markup and also explicitly set rel = undefined when closing a link; update
the logic inside the block handling markups[markupIndex] (where markup[0] ===
'a', openLinkMarkup, attrs, href, rel are used) and the corresponding closing
branch that clears openLinkMarkup to ensure href and rel are cleared so state
doesn’t leak between link markups.

---

Nitpick comments:
In `@packages/kg-converters/package.json`:
- Around line 19-22: The package.json scripts duplicate the same build command
for "build", "prepare", and "pretest"; keep "build" unchanged and make "prepare"
and "pretest" call that script (e.g., replace their command strings with "npm
run build") so you DRY up the commands while preserving the existing build
behavior for the "build" script.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a6a2e323-a287-41d4-8b66-f221a8cc04fa

📥 Commits

Reviewing files that changed from the base of the PR and between e550854 and 9194eb6.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (14)
  • packages/kg-converters/.gitignore
  • packages/kg-converters/eslint.config.mjs
  • packages/kg-converters/index.js
  • packages/kg-converters/package.json
  • packages/kg-converters/rollup.config.mjs
  • packages/kg-converters/src/index.ts
  • packages/kg-converters/src/kg-converters.ts
  • packages/kg-converters/src/lexical-to-mobiledoc.ts
  • packages/kg-converters/src/mobiledoc-to-lexical.ts
  • packages/kg-converters/test/exports.test.ts
  • packages/kg-converters/test/lexical-to-mobiledoc.test.ts
  • packages/kg-converters/test/mobiledoc-to-lexical.test.ts
  • packages/kg-converters/tsconfig.cjs.json
  • packages/kg-converters/tsconfig.json
💤 Files with no reviewable changes (2)
  • packages/kg-converters/index.js
  • packages/kg-converters/rollup.config.mjs
🚧 Files skipped from review as they are similar to previous changes (8)
  • packages/kg-converters/src/index.ts
  • packages/kg-converters/test/lexical-to-mobiledoc.test.ts
  • packages/kg-converters/test/exports.test.ts
  • packages/kg-converters/.gitignore
  • packages/kg-converters/src/kg-converters.ts
  • packages/kg-converters/tsconfig.json
  • packages/kg-converters/tsconfig.cjs.json
  • packages/kg-converters/eslint.config.mjs

"mocha": "11.7.5",
"rollup": "4.59.0",
"sinon": "21.0.2"
"sinon": "21.0.1",
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Does sinon 21.x include bundled TypeScript types or is @types/sinon needed?

💡 Result:

Sinon 21.x does not bundle TypeScript declaration files, so in a TypeScript project you should still install @types/sinon.

Evidence: the sinon@21.0.0 package.json has no types/typings entry and its published files list doesn’t include any .d.ts declarations. [1] The type declarations are provided separately via DefinitelyTyped as @types/sinon (e.g., version 21.0.0). [2]

Answer: install both:

  • sinon
  • @types/sinon [2]

Sources
[1] (app.unpkg.com)
[2] (npmjs.com)

Citations:


Add @types/sinon to devDependencies.

The sinon package (21.0.1) does not include bundled TypeScript type definitions. Since the project uses TypeScript with sinon in tests, @types/sinon must be explicitly added to devDependencies.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/package.json` at line 40, The package.json
devDependencies are missing TypeScript typings for sinon; add `@types/sinon` to
devDependencies so tests using sinon in TypeScript compile. Update
packages/kg-converters package.json by adding an entry for "@types/sinon" under
devDependencies (choose a compatible version for sinon 21.x or use a caret
range) and run install to update lockfiles; ensure imports in tests continue to
reference sinon as before.

@codecov-commenter
Copy link

Codecov Report

❌ Patch coverage is 98.96907% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.81%. Comparing base (ea8be2e) to head (294a52e).
⚠️ Report is 42 commits behind head on main.

Files with missing lines Patch % Lines
packages/kg-converters/src/kg-converters.ts 75.00% 1 Missing ⚠️
packages/kg-converters/src/mobiledoc-to-lexical.ts 98.93% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1761      +/-   ##
==========================================
- Coverage   88.51%   83.81%   -4.71%     
==========================================
  Files         201      199       -2     
  Lines       18456    13039    -5417     
  Branches     2019     1930      -89     
==========================================
- Hits        16337    10929    -5408     
+ Misses       2108     2098      -10     
- Partials       11       12       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (6)
packages/kg-converters/src/mobiledoc-to-lexical.ts (3)

142-145: ⚠️ Potential issue | 🟠 Major

Handle parsed null before reading .sections.

Line 144 can throw for input "null" because JSON.parse returns null.

Proposed fix
-    const mobiledoc = JSON.parse(serializedMobiledoc) as MobiledocDocument;
-
-    if (!mobiledoc.sections) {
+    const mobiledoc = JSON.parse(serializedMobiledoc) as MobiledocDocument | null;
+
+    if (!Array.isArray(mobiledoc?.sections)) {
         return JSON.stringify(BLANK_DOC);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 142 - 145,
The code parses serializedMobiledoc into mobiledoc then reads
mobiledoc.sections, which will throw if JSON.parse returns null (e.g., input
"null"); update the guard to check for a null/undefined mobiledoc before
accessing .sections (e.g., replace the existing if (!mobiledoc.sections) check
with a combined check like if (!mobiledoc || !mobiledoc.sections) and return
JSON.stringify(BLANK_DOC)); ensure the change references the parsed variable
mobiledoc and the existing BLANK_DOC return path so the function safely handles
serialized "null".

246-257: ⚠️ Potential issue | 🟠 Major

Reset link attributes when opening/closing <a> markups.

rel is not cleared, so a later link without rel can inherit stale data.

Proposed fix
         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;
+                href = undefined;
+                rel = undefined;
                 const attrs = markup[1] as unknown[] | undefined;
                 if (attrs && attrs[0] === 'href') {
                     href = attrs[1] as string;
@@
             if (markup && markup[0] === 'a') {
                 embedChildNode(lexicalNode, linkNode);
                 openLinkMarkup = false;
                 href = undefined;
+                rel = undefined;
                 linkNode = undefined;
             }

Also applies to: 291-295

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 246 - 257,
The link state (openLinkMarkup, href, rel) is not being reset, so rel can leak
to subsequent links; inside the openMarkupsIndexes.forEach loop in
mobiledoc-to-lexical.ts (the block that checks if markup[0] === 'a') explicitly
clear or reset href and rel when attrs are missing and when closing an <a>
markup: set href = undefined and rel = undefined by default before reading
attrs, assign them only if present, and when you detect a closing link (where
you set openLinkMarkup = false) also clear href and rel; apply the same fix in
the identical code path around lines 291–295.

128-130: ⚠️ Potential issue | 🟠 Major

Guard backgroundColor type before regex check.

Line 129 can throw when payload.backgroundColor is truthy but not a string.

Proposed fix
-        if (payload.backgroundColor && !(payload.backgroundColor as string).match(/^[a-zA-Z\d-]+$/)) {
+        if (
+            payload.backgroundColor !== undefined &&
+            (typeof payload.backgroundColor !== 'string' || !/^[a-zA-Z\d-]+$/.test(payload.backgroundColor))
+        ) {
             payload.backgroundColor = 'white';
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 128 - 130,
In the callout handler (callout: (payload: Record<string, unknown>) => { ... })
you currently run a string regex on payload.backgroundColor which can throw if
the value is truthy but not a string; guard the check by verifying typeof
payload.backgroundColor === 'string' before calling .match(), and if it is not a
string (or the match fails) set payload.backgroundColor = 'white' to normalize
the value.
packages/kg-converters/src/lexical-to-mobiledoc.ts (2)

301-303: ⚠️ Potential issue | 🟠 Major

Preserve link rel when serializing Mobiledoc markups.

Current serialization drops rel, so link metadata is lost on round-trip.

Proposed fix
-                const linkMarkup: MobiledocMarkup = ['a', ['href', child.url]];
+                const attrs: unknown[] = ['href', child.url];
+                if (typeof child.rel === 'string' && child.rel) {
+                    attrs.push('rel', child.rel);
+                }
+                const linkMarkup: MobiledocMarkup = ['a', attrs];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/lexical-to-mobiledoc.ts` around lines 301 - 303,
The current serialization for link nodes only pushes ['a', ['href', child.url]]
into mobiledoc.markups and drops link metadata like rel; update the link markup
creation in lexical-to-mobiledoc.ts (the block handling child.type === 'link')
to include the rel attribute when present (i.e., construct markup entries for
['href', child.url] and, if child.rel exists, also ['rel', child.rel] before
pushing to mobiledoc.markups so linkMarkup preserves child.rel on round-trip).

117-120: ⚠️ Potential issue | 🟠 Major

Guard parsed lexical value before reading .root.

Line 119 can throw when input is "null".

Proposed fix
-    const lexical = JSON.parse(serializedLexical) as {root?: {children?: LexicalNode[]}};
-
-    if (!lexical.root) {
+    const lexical = JSON.parse(serializedLexical) as {root?: {children?: LexicalNode[]}} | null;
+
+    if (!lexical?.root) {
         return JSON.stringify(BLANK_DOC);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/lexical-to-mobiledoc.ts` around lines 117 - 120,
The code reads lexical.root without guarding against the parsed value being null
(e.g., when serializedLexical === "null"); update the guard to check the parsed
object before accessing .root by replacing the current if (!lexical.root) { ...
} with a null-safe check such as if (!lexical || !lexical.root) { return
JSON.stringify(BLANK_DOC); }, referencing the parsed variable lexical, the input
serializedLexical, and BLANK_DOC to locate the spot.
packages/kg-converters/package.json (1)

10-16: ⚠️ Potential issue | 🟠 Major

Reorder conditional exports to avoid ESM resolution being shadowed.

"node" is currently before "import"/"require". In Node conditional exports, that can route ESM imports to CJS unexpectedly.

Proposed fix
   "exports": {
     ".": {
       "types": "./build/esm/index.d.ts",
-      "node": "./build/cjs/index.js",
       "import": "./build/esm/index.js",
-      "require": "./build/cjs/index.js"
+      "require": "./build/cjs/index.js",
+      "default": "./build/esm/index.js"
     }
   },
In Node.js conditional exports, does condition key order determine precedence? For {"node":"...","import":"...","require":"..."}, which target is selected for an ESM import?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/package.json` around lines 10 - 16, The conditional
export ordering in package.json's "exports" object is wrong: the "node"
condition appears before "import"/"require" which can cause ESM resolution to be
shadowed; update the "exports" mapping so that "import" and "require" appear
before "node" (i.e., ensure the "exports" keys are ordered "types"/"." metadata
then "import", "require", and finally "node") so ESM imports resolve to the ESM
build; adjust the "exports" block accordingly in package.json referencing the
existing "exports" object and the "node", "import", and "require" keys.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/kg-converters/package.json`:
- Around line 20-23: Replace the duplicated, non-portable echo call with a
single cross-platform npm script and reference it from build, prepare, and
pretest: add a new script (e.g., "write-esm-package") that uses Node
(fs.mkdirSync with {recursive:true} and fs.writeFileSync) to create build/esm
and write {"type":"module"} to build/esm/package.json, then simplify "build",
"prepare", and "pretest" to run their tsc commands and invoke the new
"write-esm-package" script instead of repeating the echo snippet.

---

Duplicate comments:
In `@packages/kg-converters/package.json`:
- Around line 10-16: The conditional export ordering in package.json's "exports"
object is wrong: the "node" condition appears before "import"/"require" which
can cause ESM resolution to be shadowed; update the "exports" mapping so that
"import" and "require" appear before "node" (i.e., ensure the "exports" keys are
ordered "types"/"." metadata then "import", "require", and finally "node") so
ESM imports resolve to the ESM build; adjust the "exports" block accordingly in
package.json referencing the existing "exports" object and the "node", "import",
and "require" keys.

In `@packages/kg-converters/src/lexical-to-mobiledoc.ts`:
- Around line 301-303: The current serialization for link nodes only pushes
['a', ['href', child.url]] into mobiledoc.markups and drops link metadata like
rel; update the link markup creation in lexical-to-mobiledoc.ts (the block
handling child.type === 'link') to include the rel attribute when present (i.e.,
construct markup entries for ['href', child.url] and, if child.rel exists, also
['rel', child.rel] before pushing to mobiledoc.markups so linkMarkup preserves
child.rel on round-trip).
- Around line 117-120: The code reads lexical.root without guarding against the
parsed value being null (e.g., when serializedLexical === "null"); update the
guard to check the parsed object before accessing .root by replacing the current
if (!lexical.root) { ... } with a null-safe check such as if (!lexical ||
!lexical.root) { return JSON.stringify(BLANK_DOC); }, referencing the parsed
variable lexical, the input serializedLexical, and BLANK_DOC to locate the spot.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts`:
- Around line 142-145: The code parses serializedMobiledoc into mobiledoc then
reads mobiledoc.sections, which will throw if JSON.parse returns null (e.g.,
input "null"); update the guard to check for a null/undefined mobiledoc before
accessing .sections (e.g., replace the existing if (!mobiledoc.sections) check
with a combined check like if (!mobiledoc || !mobiledoc.sections) and return
JSON.stringify(BLANK_DOC)); ensure the change references the parsed variable
mobiledoc and the existing BLANK_DOC return path so the function safely handles
serialized "null".
- Around line 246-257: The link state (openLinkMarkup, href, rel) is not being
reset, so rel can leak to subsequent links; inside the
openMarkupsIndexes.forEach loop in mobiledoc-to-lexical.ts (the block that
checks if markup[0] === 'a') explicitly clear or reset href and rel when attrs
are missing and when closing an <a> markup: set href = undefined and rel =
undefined by default before reading attrs, assign them only if present, and when
you detect a closing link (where you set openLinkMarkup = false) also clear href
and rel; apply the same fix in the identical code path around lines 291–295.
- Around line 128-130: In the callout handler (callout: (payload: Record<string,
unknown>) => { ... }) you currently run a string regex on
payload.backgroundColor which can throw if the value is truthy but not a string;
guard the check by verifying typeof payload.backgroundColor === 'string' before
calling .match(), and if it is not a string (or the match fails) set
payload.backgroundColor = 'white' to normalize the value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e61386ce-4bbc-42ed-9c56-84dba59c4584

📥 Commits

Reviewing files that changed from the base of the PR and between 9194eb6 and 294a52e.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (16)
  • packages/kg-converters/.gitignore
  • packages/kg-converters/eslint.config.mjs
  • packages/kg-converters/index.js
  • packages/kg-converters/lib/kg-converters.js
  • packages/kg-converters/package.json
  • packages/kg-converters/rollup.config.mjs
  • packages/kg-converters/src/index.ts
  • packages/kg-converters/src/kg-converters.ts
  • packages/kg-converters/src/lexical-to-mobiledoc.ts
  • packages/kg-converters/src/mobiledoc-to-lexical.ts
  • packages/kg-converters/test/exports.test.js
  • packages/kg-converters/test/exports.test.ts
  • packages/kg-converters/test/lexical-to-mobiledoc.test.ts
  • packages/kg-converters/test/mobiledoc-to-lexical.test.ts
  • packages/kg-converters/tsconfig.cjs.json
  • packages/kg-converters/tsconfig.json
💤 Files with no reviewable changes (4)
  • packages/kg-converters/lib/kg-converters.js
  • packages/kg-converters/rollup.config.mjs
  • packages/kg-converters/test/exports.test.js
  • packages/kg-converters/index.js
✅ Files skipped from review due to trivial changes (1)
  • packages/kg-converters/tsconfig.cjs.json
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/kg-converters/test/lexical-to-mobiledoc.test.ts
  • packages/kg-converters/test/exports.test.ts
  • packages/kg-converters/src/index.ts
  • packages/kg-converters/.gitignore
  • packages/kg-converters/test/mobiledoc-to-lexical.test.ts
  • packages/kg-converters/tsconfig.json

Comment on lines +20 to +23
"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",
"test:unit": "NODE_ENV=testing c8 --all --src src/ --check-coverage --reporter text --reporter cobertura tsx node_modules/.bin/mocha './test/**/*.test.ts'",
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In npm scripts, is echo '{"type":"module"}' > file with single quotes portable across POSIX shells and Windows cmd/PowerShell?

💡 Result:

No.

  • POSIX shells (/bin/sh, bash, dash, etc.): echo '{"type":"module"}' > file is fine—single quotes are standard shell quoting. npm runs scripts via /bin/sh by default on Unix-like systems. [1]
  • Windows (default npm behavior): npm runs scripts via cmd.exe by default (even if you launch npm run from PowerShell), and cmd.exe does not treat '...' as quoting—single quotes are just literal characters. So you’ll typically end up writing the quotes into the file (or otherwise get different parsing than you expect). [1][2]
  • PowerShell: PowerShell does support single-quoted strings, so the command itself would work if npm were using PowerShell as the script shell—but by default it isn’t. You can change the shell with npm’s script-shell config. [1][3]

If you need cross-platform behavior, prefer a Node-based write (e.g., node -e "require('fs').writeFileSync('file','{\"type\":\"module\"}\\n')"), or explicitly set script-shell to pwsh / bash for your project/CI. [3]

Sources
[1] npm Docs — npm run-script: default shell is /bin/sh on Unix, cmd.exe on Windows https://docs.npmjs.com/cli/v10/commands/npm-run-script/
[2] Stack Overflow — cmd.exe does not recognize single quotes as string delimiters https://stackoverflow.com/questions/562038/escaping-double-quotes-in-batch-script
[3] npm Docs — script-shell config (default /bin/sh on POSIX, cmd.exe on Windows) https://docs.npmjs.com/cli/v10/using-npm/config/


🏁 Script executed:

# Verify the file exists and check its content
if [ -f "packages/kg-converters/package.json" ]; then
  echo "=== File content (full) ==="
  cat -n "packages/kg-converters/package.json"
  echo ""
  echo "=== Lines 15-30 (context around lines 20-23) ==="
  sed -n '15,30p' "packages/kg-converters/package.json"
else
  echo "File not found at packages/kg-converters/package.json"
  # Try to find it
  find . -name "package.json" -path "*/kg-converters/*" 2>/dev/null | head -5
fi

Repository: TryGhost/Koenig

Length of output: 2723


Fix shell portability and eliminate build script duplication.

The echo '{"type":"module"}' > build/esm/package.json pattern is not portable to Windows (cmd.exe does not recognize single quotes as string delimiters), and the same 71-character build command is duplicated across build, prepare, and pretest.

Proposed fix
   "scripts": {
     "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",
+    "write:esm-package-json": "node -e \"require('node:fs').writeFileSync('build/esm/package.json', JSON.stringify({type:'module'}))\"",
+    "build": "tsc && tsc -p tsconfig.cjs.json && yarn write:esm-package-json",
+    "prepare": "yarn build",
+    "pretest": "yarn build",
     "test:unit": "NODE_ENV=testing c8 --all --src src/ --check-coverage --reporter text --reporter cobertura tsx node_modules/.bin/mocha './test/**/*.test.ts'",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"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",
"test:unit": "NODE_ENV=testing c8 --all --src src/ --check-coverage --reporter text --reporter cobertura tsx node_modules/.bin/mocha './test/**/*.test.ts'",
"dev": "tsc --watch --preserveWatchOutput",
"write:esm-package-json": "node -e \"require('node:fs').writeFileSync('build/esm/package.json', JSON.stringify({type:'module'}))\"",
"build": "tsc && tsc -p tsconfig.cjs.json && yarn write:esm-package-json",
"prepare": "yarn build",
"pretest": "yarn build",
"test:unit": "NODE_ENV=testing c8 --all --src src/ --check-coverage --reporter text --reporter cobertura tsx node_modules/.bin/mocha './test/**/*.test.ts'",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/package.json` around lines 20 - 23, Replace the
duplicated, non-portable echo call with a single cross-platform npm script and
reference it from build, prepare, and pretest: add a new script (e.g.,
"write-esm-package") that uses Node (fs.mkdirSync with {recursive:true} and
fs.writeFileSync) to create build/esm and write {"type":"module"} to
build/esm/package.json, then simplify "build", "prepare", and "pretest" to run
their tsc commands and invoke the new "write-esm-package" script instead of
repeating the echo snippet.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (5)
packages/kg-converters/src/mobiledoc-to-lexical.ts (3)

127-135: ⚠️ Potential issue | 🟠 Major

backgroundColor type not validated before .match() call.

The past review noted that a truthy non-string value (e.g., 123) will throw when .match() is called. The cast as string doesn't validate at runtime.

Suggested fix
-    callout: (payload: Record<string, unknown>) => {
-        if (payload.backgroundColor && !(payload.backgroundColor as string).match(/^[a-zA-Z\d-]+$/)) {
+    callout: (payload: Record<string, unknown>) => {
+        if (
+            payload.backgroundColor !== undefined &&
+            (typeof payload.backgroundColor !== 'string' || !/^[a-zA-Z\d-]+$/.test(payload.backgroundColor))
+        ) {
             payload.backgroundColor = 'white';
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 127 - 135,
The callout card fixer in CARD_FIXES_MAP currently assumes
payload.backgroundColor is a string and uses .match(), which can throw for
non-string truthy values; update the callout function to first check typeof
payload.backgroundColor === 'string' (or convert safely) before calling
.match(), and only replace payload.backgroundColor with 'white' when the value
is a string that fails the /^[a-zA-Z\d-]+$/ test (or when it's not a string and
you want to default it), ensuring payload is returned unchanged for valid string
values otherwise.

246-262: ⚠️ Potential issue | 🟠 Major

rel not reset when link closes, causing state bleed to subsequent links.

The past review noted that href and linkNode are reset when a link closes (lines 294-295), but rel is not. If one link has rel="noopener" and the next link has no rel, the second link incorrectly inherits the first's rel value.

Suggested fix
             if (markup && markup[0] === 'a') {
                 embedChildNode(lexicalNode, linkNode);
                 openLinkMarkup = false;
                 href = undefined;
+                rel = undefined;
                 linkNode = undefined;
             }

Also applies to: 291-296

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 246 - 262,
The rel value from link markups isn't being cleared when a link closes, causing
rel to persist across links; update the link-closing logic (where href and
linkNode are reset) to also reset rel to undefined/empty so rel doesn't bleed
into subsequent links. Locate the handling of openLinkMarkup / href / linkNode
resets (the code that processes openMarkupsIndexes and the block that clears
href and linkNode when closing a link) and add resetting of the rel variable
there, and ensure rel is similarly initialized/cleared when opening a new 'a'
markup in the openMarkupsIndexes loop that reads markups and attrs.

142-146: ⚠️ Potential issue | 🟠 Major

JSON.parse('null') returns null, causing .sections access to throw.

The past review flagged this same pattern. If serializedMobiledoc is the string "null", JSON.parse returns the literal null, and accessing .sections throws.

Suggested fix
-    const mobiledoc = JSON.parse(serializedMobiledoc) as MobiledocDocument;
+    const mobiledoc = JSON.parse(serializedMobiledoc) as MobiledocDocument | null;

-    if (!mobiledoc.sections) {
+    if (!mobiledoc?.sections) {
         return JSON.stringify(BLANK_DOC);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts` around lines 142 - 146,
The code parses serializedMobiledoc into mobiledoc and then directly reads
mobiledoc.sections, which will throw if JSON.parse returns null (e.g.,
serializedMobiledoc === "null"); update the logic in the mobiledoc-to-lexical
converter to guard the parsed value (the mobiledoc variable) before accessing
.sections — e.g., treat a falsy or non-object parsed result as an empty document
and return JSON.stringify(BLANK_DOC) if mobiledoc is null/undefined or doesn't
have sections; ensure the check references serializedMobiledoc, mobiledoc, and
BLANK_DOC so the fix is applied around the existing parse and the immediate
conditional.
packages/kg-converters/src/lexical-to-mobiledoc.ts (2)

117-121: ⚠️ Potential issue | 🟠 Major

JSON.parse('null') still causes a runtime error.

The past review flagged that JSON.parse('null') returns the literal null, so accessing lexical.root on line 119 will throw. The type cast on line 117 doesn't include | null, and there's no null check before accessing .root.

Suggested fix
-    const lexical = JSON.parse(serializedLexical) as {root?: {children?: LexicalNode[]}};
+    const lexical = JSON.parse(serializedLexical) as {root?: {children?: LexicalNode[]}} | null;

-    if (!lexical.root) {
+    if (!lexical?.root) {
         return JSON.stringify(BLANK_DOC);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/lexical-to-mobiledoc.ts` around lines 117 - 121,
The code assumes JSON.parse(serializedLexical) returns an object but
JSON.parse('null') yields null, so accessing lexical.root will throw; update the
parsing and guard: parse serializedLexical into a value typed as {root?:
{children?: LexicalNode[]}} | null (or use a fallback like const lexical =
JSON.parse(serializedLexical) ?? null) and add a null check before accessing
lexical.root (e.g., if (!lexical || !lexical.root) return
JSON.stringify(BLANK_DOC)); ensure references to lexical, serializedLexical and
BLANK_DOC are used so the function safely handles 'null' input.

301-303: ⚠️ Potential issue | 🟠 Major

Link rel attribute not preserved in Mobiledoc markup.

The past review noted that only href is serialized, causing rel to be lost on round-trip. The corresponding mobiledoc-to-lexical.ts reads rel (lines 256-258), but this direction doesn't write it.

Suggested fix
-                const linkMarkup: MobiledocMarkup = ['a', ['href', child.url]];
+                const attrs: unknown[] = ['href', child.url];
+                if (typeof child.rel === 'string' && child.rel) {
+                    attrs.push('rel', child.rel);
+                }
+                const linkMarkup: MobiledocMarkup = ['a', attrs];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/src/lexical-to-mobiledoc.ts` around lines 301 - 303,
The current Lexical->Mobiledoc conversion for link nodes only serializes the
href, dropping the rel attribute; update the link serialization in
lexical-to-mobiledoc.ts (the block handling child.type === 'link' where
linkMarkup is created and pushed to mobiledoc.markups) to include rel when
present (e.g., add a ['rel', child.rel] pair alongside ['href', child.url] so
the markup contains both attributes before pushing), ensuring the produced
Mobiledoc preserves rel for round-tripping with mobiledoc-to-lexical.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/kg-converters/tsconfig.test.json`:
- Line 5: Remove the invalid outDir: null entry from compilerOptions in the
tsconfig used for tests; locate the tsconfig JSON that contains
"compilerOptions" and the "outDir" property (currently set to null) and delete
that outDir line — keep the existing "noEmit": true setting so no output is
produced.

---

Duplicate comments:
In `@packages/kg-converters/src/lexical-to-mobiledoc.ts`:
- Around line 117-121: The code assumes JSON.parse(serializedLexical) returns an
object but JSON.parse('null') yields null, so accessing lexical.root will throw;
update the parsing and guard: parse serializedLexical into a value typed as
{root?: {children?: LexicalNode[]}} | null (or use a fallback like const lexical
= JSON.parse(serializedLexical) ?? null) and add a null check before accessing
lexical.root (e.g., if (!lexical || !lexical.root) return
JSON.stringify(BLANK_DOC)); ensure references to lexical, serializedLexical and
BLANK_DOC are used so the function safely handles 'null' input.
- Around line 301-303: The current Lexical->Mobiledoc conversion for link nodes
only serializes the href, dropping the rel attribute; update the link
serialization in lexical-to-mobiledoc.ts (the block handling child.type ===
'link' where linkMarkup is created and pushed to mobiledoc.markups) to include
rel when present (e.g., add a ['rel', child.rel] pair alongside ['href',
child.url] so the markup contains both attributes before pushing), ensuring the
produced Mobiledoc preserves rel for round-tripping with
mobiledoc-to-lexical.ts.

In `@packages/kg-converters/src/mobiledoc-to-lexical.ts`:
- Around line 127-135: The callout card fixer in CARD_FIXES_MAP currently
assumes payload.backgroundColor is a string and uses .match(), which can throw
for non-string truthy values; update the callout function to first check typeof
payload.backgroundColor === 'string' (or convert safely) before calling
.match(), and only replace payload.backgroundColor with 'white' when the value
is a string that fails the /^[a-zA-Z\d-]+$/ test (or when it's not a string and
you want to default it), ensuring payload is returned unchanged for valid string
values otherwise.
- Around line 246-262: The rel value from link markups isn't being cleared when
a link closes, causing rel to persist across links; update the link-closing
logic (where href and linkNode are reset) to also reset rel to undefined/empty
so rel doesn't bleed into subsequent links. Locate the handling of
openLinkMarkup / href / linkNode resets (the code that processes
openMarkupsIndexes and the block that clears href and linkNode when closing a
link) and add resetting of the rel variable there, and ensure rel is similarly
initialized/cleared when opening a new 'a' markup in the openMarkupsIndexes loop
that reads markups and attrs.
- Around line 142-146: The code parses serializedMobiledoc into mobiledoc and
then directly reads mobiledoc.sections, which will throw if JSON.parse returns
null (e.g., serializedMobiledoc === "null"); update the logic in the
mobiledoc-to-lexical converter to guard the parsed value (the mobiledoc
variable) before accessing .sections — e.g., treat a falsy or non-object parsed
result as an empty document and return JSON.stringify(BLANK_DOC) if mobiledoc is
null/undefined or doesn't have sections; ensure the check references
serializedMobiledoc, mobiledoc, and BLANK_DOC so the fix is applied around the
existing parse and the immediate conditional.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7b52de99-d666-44ff-8c47-91d066b21fee

📥 Commits

Reviewing files that changed from the base of the PR and between 294a52e and 45d7c1d.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (15)
  • packages/kg-converters/.gitignore
  • packages/kg-converters/eslint.config.mjs
  • packages/kg-converters/index.js
  • packages/kg-converters/package.json
  • packages/kg-converters/rollup.config.mjs
  • packages/kg-converters/src/index.ts
  • packages/kg-converters/src/kg-converters.ts
  • packages/kg-converters/src/lexical-to-mobiledoc.ts
  • packages/kg-converters/src/mobiledoc-to-lexical.ts
  • packages/kg-converters/test/exports.test.ts
  • packages/kg-converters/test/lexical-to-mobiledoc.test.ts
  • packages/kg-converters/test/mobiledoc-to-lexical.test.ts
  • packages/kg-converters/tsconfig.cjs.json
  • packages/kg-converters/tsconfig.json
  • packages/kg-converters/tsconfig.test.json
💤 Files with no reviewable changes (2)
  • packages/kg-converters/rollup.config.mjs
  • packages/kg-converters/index.js
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/kg-converters/tsconfig.cjs.json
  • packages/kg-converters/.gitignore
  • packages/kg-converters/test/mobiledoc-to-lexical.test.ts
  • packages/kg-converters/test/exports.test.ts
  • packages/kg-converters/src/kg-converters.ts

"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": null,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

TypeScript tsconfig outDir null valid value

💡 Result:

No. compilerOptions.outDir is not a nullable option—TypeScript expects a path string when outDir is present. If you don’t want an output directory, omit outDir entirely; then .js files are emitted next to their corresponding .ts sources. [1]

If your goal is “don’t emit anything”, use "noEmit": true instead. [2]

Sources:
[1] TypeScript TSConfig option outDir docs https://www.typescriptlang.org/tsconfig/outDir.html
[2] TypeScript TSConfig reference (noEmit) https://www.typescriptlang.org/tsconfig/


Remove outDir: null from compilerOptions.

TypeScript's outDir expects a string path and does not support null as a value. Since noEmit: true is already configured (which prevents any output), this setting is both invalid and redundant.

Suggested fix
     "compilerOptions": {
         "rootDir": ".",
-        "outDir": null,
         "noEmit": true,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"outDir": null,
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/kg-converters/tsconfig.test.json` at line 5, Remove the invalid
outDir: null entry from compilerOptions in the tsconfig used for tests; locate
the tsconfig JSON that contains "compilerOptions" and the "outDir" property
(currently set to null) and delete that outDir line — keep the existing
"noEmit": true setting so no output is produced.

- Remove Rollup build (rollup.config.mjs, @rollup/plugin-babel)
- Move lib/ to src/, rename .js to .ts
- Add tsconfig.json (strict, NodeNext, ESM)
- Add "type": "module" to package.json
- Convert source and tests to ESM with type annotations
- Replace .eslintrc.js with eslint.config.js (flat config)
- Output to build/ via tsc (replaces cjs/ and es/ dirs)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants