Skip to content

feat(text-editor): add inlineImages config for inline image handling#4150

Merged
FredrikWallstrom merged 2 commits into
mainfrom
poc/inline-images-component-markup
Jul 8, 2026
Merged

feat(text-editor): add inlineImages config for inline image handling#4150
FredrikWallstrom merged 2 commits into
mainfrom
poc/inline-images-component-markup

Conversation

@FredrikWallstrom

@FredrikWallstrom FredrikWallstrom commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Part of the Shared Files inline-images work for the feed (Closes Lundalogik/crm-client#1148). This is the lime-elements piece — the generic editor support. The CRM-specific wiring (building-blocks, crm-components, limepkg-email) follows in separate PRs, which is why this is a draft.

What

Adds an inlineImages config so the editor owns the whole paste lifecycle itself — show a thumbnail, run the consumer's upload, then swap in a resizable image (or a failed state) — instead of the consumer driving that through the imagePasted event.

It supports two persistence shapes, modelled as a discriminated union so invalid combinations are compile errors rather than runtime surprises:

  • InlineImageTag{ tagName, getUrl, upload } persists <tagName image-id="…">, an id-only microformat that's resolved to a URL at render. Portable; no URL is baked into the stored content. upload returns the stored id; getUrl resolves it for display inside the editor.
  • InlineImageSrc{ upload } persists a plain <img src="…">. upload returns the src directly (a URL or a data URI — base64 or external storage both fit here).

isInlineImageTag narrows the union for consumers.

Compatibility

The old imagePasted/imageRemoved/metadataChange API is untouched and is still used whenever inlineImages isn't provided, so existing consumers (e.g. solution-grc) keep working. Existing <img src> content round-trips unchanged — only newly pasted images use the new microformat.

The two examples were migrated to the new config and renamed to reflect the two shapes (image-source and custom-tag).

Not in this PR

Reconcile-on-save (attaching the uploaded shared files to a limeobject) lives in the consumers/backend and lands separately.

Summary by CodeRabbit

  • New Features
    • Added configurable inline image handling to the text editor via an inlineImages setting (tag-based or src-based persistence).
    • Added/updated inline-image examples, including a new custom-tag example and improved inline paste handling with an editor-managed upload flow.
  • Bug Fixes
    • Inline pasted images now consistently transition from “loading” to success or failure.
    • Inline images are now better preserved across Markdown/HTML conversion, including sanitization/whitelisting for the configured custom tag.
    • Prevented redundant dimension persistence when an image already has stored width.
  • Chores
    • Removed the older inline-image file-storage example.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cf502409-e970-46ea-9019-ae8362a7715e

📥 Commits

Reviewing files that changed from the base of the PR and between 3b29952 and 4f8a7cc.

⛔ Files ignored due to path filters (1)
  • etc/lime-elements.api.md is excluded by !etc/lime-elements.api.md
📒 Files selected for processing (15)
  • src/components/text-editor/examples/text-editor-with-inline-images-base64.tsx
  • src/components/text-editor/examples/text-editor-with-inline-images-custom-tag.tsx
  • src/components/text-editor/examples/text-editor-with-inline-images-file-storage.tsx
  • src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts
  • src/components/text-editor/prosemirror-adapter/plugins/image/node.spec.ts
  • src/components/text-editor/prosemirror-adapter/plugins/image/node.ts
  • src/components/text-editor/prosemirror-adapter/plugins/image/view.ts
  • src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx
  • src/components/text-editor/text-editor.tsx
  • src/components/text-editor/text-editor.types.spec.ts
  • src/components/text-editor/text-editor.types.ts
  • src/components/text-editor/utils/content-type-converter.spec.ts
  • src/components/text-editor/utils/content-type-converter.ts
  • src/components/text-editor/utils/html-converter.ts
  • src/components/text-editor/utils/markdown-converter.ts
💤 Files with no reviewable changes (1)
  • src/components/text-editor/examples/text-editor-with-inline-images-file-storage.tsx

📝 Walkthrough

Walkthrough

This PR adds inlineImages support to text editor image handling, threading the configuration through props, paste processing, node serialization/parsing, content sanitization, and example components.

Changes

Inline images feature

Layer / File(s) Summary
Types and prop wiring
src/components/text-editor/text-editor.types.ts, src/components/text-editor/text-editor.types.spec.ts, src/components/text-editor/text-editor.tsx, src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx, src/components/text-editor/utils/content-type-converter.ts, src/components/text-editor/utils/html-converter.ts, src/components/text-editor/utils/markdown-converter.ts, src/components/text-editor/utils/content-type-converter.spec.ts
Defines inline image configuration types, adds inlineImages props on the text editor and adapter, validates adapter input, and threads the configuration into HTML and markdown conversion.
Paste upload lifecycle
src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts
Extends the image inserter plugin to accept inlineImages, switch paste handling between inline upload flow and legacy events, and replace pasted placeholders with loading, success, or failed image nodes.
Image node parsing and serialization
src/components/text-editor/prosemirror-adapter/plugins/image/node.ts, src/components/text-editor/prosemirror-adapter/plugins/image/view.ts, src/components/text-editor/prosemirror-adapter/plugins/image/node.spec.ts
Updates image node attrs, markdown serialization, DOM rendering, DOM parsing, and image load persistence to support inline image tags with imageId and cached image elements alongside standard img handling.
Examples and inline-image docs
src/components/text-editor/examples/text-editor-with-inline-images-base64.tsx, src/components/text-editor/examples/text-editor-with-inline-images-custom-tag.tsx
Updates the base64 example to use inlineImages.upload, adds the custom-tag example component, and refreshes example documentation for inline image usage.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: adrianschmidt, jgroth

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding an inlineImages config for inline image handling in text-editor.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch poc/inline-images-component-markup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

Copy link
Copy Markdown

Documentation has been published to https://lundalogik.github.io/lime-elements/versions/PR-4150/

@FredrikWallstrom FredrikWallstrom force-pushed the poc/inline-images-component-markup branch from 496df37 to 6c2d190 Compare June 30, 2026 09:02
@FredrikWallstrom FredrikWallstrom marked this pull request as ready for review July 1, 2026 06:35
@FredrikWallstrom FredrikWallstrom requested a review from a team as a code owner July 1, 2026 06:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts`:
- Around line 328-340: The paste handling in the image inserter still falls back
to the legacy imagePasted event when inlineImages is configured but has no
upload handler. Update the logic around runInlineImageUpload in the inserter
flow so that the presence of an InlineImageTag config itself suppresses the
imagePasted dispatch, and only allow the legacy event when inlineImages is not
configured at all. Use the inlineImages, inlineImages?.upload, and imagePasted
branches to locate and adjust the decision point.

In `@src/components/text-editor/prosemirror-adapter/plugins/image/node.ts`:
- Around line 263-277: The inline image parser in the image node plugin
currently accepts the configured tag even when `image-id` is missing, which lets
invalid tags reach `tag.getUrl('')` inside the `rules.push`/`getAttrs` path.
Update the `getAttrs` logic in
`src/components/text-editor/prosemirror-adapter/plugins/image/node.ts` to reject
tags without a valid `IMAGE_ID_ATTRIBUTE` value before building
`ImageNodeAttrs`, so the parser does not create a success image or throw during
editor parsing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 01966e36-bcef-4989-a3d9-ea4e30f23baa

📥 Commits

Reviewing files that changed from the base of the PR and between cdb1dde and 6c2d190.

⛔ Files ignored due to path filters (1)
  • etc/lime-elements.api.md is excluded by !etc/lime-elements.api.md
📒 Files selected for processing (11)
  • src/components/text-editor/examples/text-editor-with-inline-images-base64.tsx
  • src/components/text-editor/examples/text-editor-with-inline-images-custom-tag.tsx
  • src/components/text-editor/examples/text-editor-with-inline-images-file-storage.tsx
  • src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts
  • src/components/text-editor/prosemirror-adapter/plugins/image/node.ts
  • src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx
  • src/components/text-editor/text-editor.tsx
  • src/components/text-editor/text-editor.types.ts
  • src/components/text-editor/utils/content-type-converter.ts
  • src/components/text-editor/utils/html-converter.ts
  • src/components/text-editor/utils/markdown-converter.ts
💤 Files with no reviewable changes (1)
  • src/components/text-editor/examples/text-editor-with-inline-images-file-storage.tsx

Comment thread src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts Outdated
@FredrikWallstrom FredrikWallstrom force-pushed the poc/inline-images-component-markup branch from 6c2d190 to 89daecb Compare July 1, 2026 09:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts (1)

18-119: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

New private helpers break up the two exported functions.

runInlineImageUpload and replaceThumbnailWithInlineImage (private helpers, Lines 37-106) are now sandwiched between the exported createImageInserterPlugin (Lines 18-35) and the exported imageInserterFactory (Lines 108-119). Moving the helpers below both exported functions would keep the public API surfaced first.

Based on learnings, "apply the 'news article principle': put the most important public-facing functions at the top of each file... ensure the exported/public functions come before internal helpers."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts`
around lines 18 - 119, The internal helpers are interrupting the public API
order in this module. Move `runInlineImageUpload` and
`replaceThumbnailWithInlineImage` below both exported symbols,
`createImageInserterPlugin` and `imageInserterFactory`, so the file presents the
public functions first and keeps private implementation details after them.
♻️ Duplicate comments (1)
src/components/text-editor/utils/content-type-converter.ts (1)

3-4: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Cross-file attribute-name duplication with node.ts.

buildContentWhitelist re-declares the inline-image attribute names ('image-id', 'width', 'height', 'alt') independently of IMAGE_ID_ATTRIBUTE and the attribute set built in node.ts's getInlineImageHTML/buildInlineImageDOMAttrs. See the corresponding comment on src/components/text-editor/prosemirror-adapter/plugins/image/node.ts (lines 131-166) for the consolidated fix suggestion — exporting a shared constant from node.ts would let this whitelist stay in sync automatically.

Also applies to: 18-43

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/text-editor/utils/content-type-converter.ts` around lines 3 -
4, buildContentWhitelist is duplicating the inline-image attribute names instead
of reusing the shared source from node.ts, so keep the whitelist in sync by
exporting and consuming the same inline-image attribute constant/set used by
getInlineImageHTML and buildInlineImageDOMAttrs. Update buildContentWhitelist to
reference the shared definition (including IMAGE_ID_ATTRIBUTE and the other
inline image attrs) rather than hardcoding 'image-id', 'width', 'height', and
'alt'.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/components/text-editor/examples/text-editor-with-inline-images-custom-tag.tsx`:
- Around line 73-88: The uploadImage helper in
text-editor-with-inline-images-custom-tag.tsx creates object URLs with
URL.createObjectURL but never cleans them up. Update the image storage flow
around uploadImage and the store usage so any replaced or removed entry has its
URL revoked with URL.revokeObjectURL, and add a disconnectedCallback on the
component to revoke all remaining stored object URLs when the editor is torn
down.

In `@src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts`:
- Around line 62-72: The upload failure in the image inserter is being swallowed
by the bare catch, so add error handling in inserter.ts around the upload(file)
call inside the thumbnail insertion flow. Update the catch in the inserter logic
to capture the thrown error and log it or emit it through the existing
inserter/event path before calling inserter.insertFailedThumbnail(), using the
surrounding symbols upload, replaceThumbnailWithInlineImage, and
inserter.insertFailedThumbnail to locate the fix.
- Around line 47-73: The inline image upload flow in runInlineImageUpload has no
timeout or cancellation around inlineImages.upload(file), so a hung promise can
leave the thumbnail loading forever. Update runInlineImageUpload to race the
upload against a timeout (or cancellation signal) and treat timeout as a failure
path that falls through to inserter.insertFailedThumbnail(), while keeping the
existing success path through replaceThumbnailWithInlineImage.

In `@src/components/text-editor/prosemirror-adapter/plugins/image/node.ts`:
- Around line 131-148: The inline image HTML path is using a custom escape
helper that static analysis flags as risky; replace the bespoke
escapeAttributeValue logic in node.ts with a vetted escaping utility already
used in the codebase, or a small well-tested encoder such as he.encode. Update
getInlineImageHTML to use that shared helper for IMAGE_ID_ATTRIBUTE, width,
height, and alt so attribute escaping is centralized and future-safe.
- Around line 131-166: The inline image attribute names are duplicated and
`buildInlineImageDOMAttrs` hardcodes `'image-id'` instead of reusing
`IMAGE_ID_ATTRIBUTE`, which can drift from `getInlineImageHTML` and
`createImageParseDOM`. Update `buildInlineImageDOMAttrs` to use the shared
constant, and centralize the inline image attribute list so
`content-type-converter.ts`’s `buildContentWhitelist` can reuse the same source
of truth for `'image-id'`, `'width'`, `'height'`, and `'alt'`.

In `@src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx`:
- Around line 118-128: The doc comment for the `inlineImages` prop in
`ProsemirrorAdapter` is too narrow because it only describes the tag-based
persistence shape and omits the `InlineImageSrc` variant. Update the JSDoc on
`inlineImages` to match the shape-agnostic wording used in `text-editor.tsx`, so
it clearly covers both tag-based and plain `<img src>` inline image persistence
without implying only file-id tags are supported.

---

Outside diff comments:
In `@src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts`:
- Around line 18-119: The internal helpers are interrupting the public API order
in this module. Move `runInlineImageUpload` and
`replaceThumbnailWithInlineImage` below both exported symbols,
`createImageInserterPlugin` and `imageInserterFactory`, so the file presents the
public functions first and keeps private implementation details after them.

---

Duplicate comments:
In `@src/components/text-editor/utils/content-type-converter.ts`:
- Around line 3-4: buildContentWhitelist is duplicating the inline-image
attribute names instead of reusing the shared source from node.ts, so keep the
whitelist in sync by exporting and consuming the same inline-image attribute
constant/set used by getInlineImageHTML and buildInlineImageDOMAttrs. Update
buildContentWhitelist to reference the shared definition (including
IMAGE_ID_ATTRIBUTE and the other inline image attrs) rather than hardcoding
'image-id', 'width', 'height', and 'alt'.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 39d53d14-48d2-4df1-8023-37e153250e44

📥 Commits

Reviewing files that changed from the base of the PR and between 6c2d190 and 89daecb.

⛔ Files ignored due to path filters (1)
  • etc/lime-elements.api.md is excluded by !etc/lime-elements.api.md
📒 Files selected for processing (11)
  • src/components/text-editor/examples/text-editor-with-inline-images-base64.tsx
  • src/components/text-editor/examples/text-editor-with-inline-images-custom-tag.tsx
  • src/components/text-editor/examples/text-editor-with-inline-images-file-storage.tsx
  • src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts
  • src/components/text-editor/prosemirror-adapter/plugins/image/node.ts
  • src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx
  • src/components/text-editor/text-editor.tsx
  • src/components/text-editor/text-editor.types.ts
  • src/components/text-editor/utils/content-type-converter.ts
  • src/components/text-editor/utils/html-converter.ts
  • src/components/text-editor/utils/markdown-converter.ts
💤 Files with no reviewable changes (1)
  • src/components/text-editor/examples/text-editor-with-inline-images-file-storage.tsx

Comment on lines +73 to +88
private readonly uploadImage = (file: File): Promise<string> => {
// Simulate uploading to external storage and returning the stored id.
return new Promise((resolve, reject) => {
setTimeout(() => {
if (this.uploadImageFails) {
reject(new Error('Server error'));

return;
}

const id = crypto.randomUUID();
this.store.set(id, URL.createObjectURL(file));
resolve(id);
}, 2000);
});
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Object URLs created via URL.createObjectURL are never revoked.

Each simulated upload calls URL.createObjectURL(file) and stores the result, but nothing ever calls URL.revokeObjectURL for replaced/removed entries. In this demo it's a minor leak, but since this is example code other consumers may copy, it's worth showing the cleanup pattern (e.g., revoke previous URL for a given id, or revoke on component disconnect).

♻️ Proposed fix
                 const id = crypto.randomUUID();
-                this.store.set(id, URL.createObjectURL(file));
+                const url = URL.createObjectURL(file);
+                this.store.set(id, url);
                 resolve(id);

And revoke stored URLs in a disconnectedCallback if the component is expected to be destroyed/recreated repeatedly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/components/text-editor/examples/text-editor-with-inline-images-custom-tag.tsx`
around lines 73 - 88, The uploadImage helper in
text-editor-with-inline-images-custom-tag.tsx creates object URLs with
URL.createObjectURL but never cleans them up. Update the image storage flow
around uploadImage and the store usage so any replaced or removed entry has its
URL revoked with URL.revokeObjectURL, and add a disconnectedCallback on the
component to revoke all remaining stored object URLs when the editor is torn
down.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving as-is. In this example the stored object URL is exactly what getUrl returns to render the image, so revoking it would break the demo; only disconnect-time cleanup would be valid, which adds noise to a docs example for little gain.

Comment on lines +47 to +73
const runInlineImageUpload = async (
view: EditorView,
file: File,
base64Data: string,
fileInfo: FileInfo,
inlineImages: InlineImages
): Promise<void> => {
const upload = inlineImages.upload;
if (!upload) {
return;
}

const inserter = imageInserterFactory(view, base64Data, fileInfo);
inserter.insertThumbnail();

try {
const uploadResult = await upload(file);
replaceThumbnailWithInlineImage(
view,
fileInfo,
uploadResult,
inlineImages
);
} catch {
inserter.insertFailedThumbnail();
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No timeout/cancellation around inlineImages.upload(file).

If the consumer's upload promise never settles (hung network request, dropped connection), the thumbnail stays stuck in the loading state indefinitely with no way to recover — the user has no feedback and the paste effectively becomes unusable. Consider racing the upload against a timeout so the flow can fall through to insertFailedThumbnail() in that case.

🕐 Proposed fix
     try {
-        const uploadResult = await upload(file);
+        const uploadResult = await withTimeout(upload(file), UPLOAD_TIMEOUT_MS);
         replaceThumbnailWithInlineImage(
             view,
             fileInfo,
             uploadResult,
             inlineImages
         );
     } catch {
         inserter.insertFailedThumbnail();
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts`
around lines 47 - 73, The inline image upload flow in runInlineImageUpload has
no timeout or cancellation around inlineImages.upload(file), so a hung promise
can leave the thumbnail loading forever. Update runInlineImageUpload to race the
upload against a timeout (or cancellation signal) and treat timeout as a failure
path that falls through to inserter.insertFailedThumbnail(), while keeping the
existing success path through replaceThumbnailWithInlineImage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving this out. Timeout/cancellation is really the consumer's upload contract — an editor-side timeout would risk failing legitimately slow or large uploads. A never-settling upload is an edge case we'd rather not mask with a hardcoded limit here.

Comment on lines +131 to +148
function escapeAttributeValue(value: string): string {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}

function getInlineImageHTML(attrs: ImageNodeAttrs, tag: string): string {
const attributes = [
`${IMAGE_ID_ATTRIBUTE}="${escapeAttributeValue(attrs.imageId ?? '')}"`,
attrs.width ? `width="${escapeAttributeValue(attrs.width)}"` : '',
attrs.height ? `height="${escapeAttributeValue(attrs.height)}"` : '',
`alt="${escapeAttributeValue(attrs.alt ?? '')}"`,
].filter(Boolean);

return `<${tag} ${attributes.join(' ')}></${tag}>`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Consider a vetted escaping utility instead of hand-rolled entity replacement.

escapeAttributeValue manually replaces &, ", <, >. It's used correctly here (values are always placed inside double-quoted attributes, so the missing ' escape isn't currently exploitable), but static analysis flags this pattern as risky (CWE-79) since any future change to single-quoted attributes or additional consumers of this helper could reopen an injection vector.

Consider using a small, well-tested escaping utility (e.g. he.encode, or a shared sanitize-html/DOMPurify-based helper already used elsewhere in the codebase) rather than a bespoke replacement chain.

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 131-135: Avoid hand-rolled HTML escaping (replacing characters with HTML entities); use a vetted encoder/sanitizer such as DOMPurify or sanitize-html.
Context: String(value)
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll('<', '<')
.replaceAll('>', '>')
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(manual-sanitization-typescript)


[warning] 131-134: Avoid hand-rolled HTML escaping (replacing characters with HTML entities); use a vetted encoder/sanitizer such as DOMPurify or sanitize-html.
Context: String(value)
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll('<', '<')
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(manual-sanitization-typescript)


[warning] 131-133: Avoid hand-rolled HTML escaping (replacing characters with HTML entities); use a vetted encoder/sanitizer such as DOMPurify or sanitize-html.
Context: String(value)
.replaceAll('&', '&')
.replaceAll('"', '"')
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(manual-sanitization-typescript)


[warning] 131-132: Avoid hand-rolled HTML escaping (replacing characters with HTML entities); use a vetted encoder/sanitizer such as DOMPurify or sanitize-html.
Context: String(value)
.replaceAll('&', '&')
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(manual-sanitization-typescript)


[warning] 131-135: Manual HTML entity encoding detected. Avoid manually sanitizing HTML input as it can be error-prone and lead to XSS vulnerabilities. Use dedicated libraries like sanitize-html or DOMPurify for proper HTML sanitization.
Context: String(value)
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll('<', '<')
.replaceAll('>', '>')
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation

(manual-html-entity-encoding)


[warning] 131-134: Manual HTML entity encoding detected. Avoid manually sanitizing HTML input as it can be error-prone and lead to XSS vulnerabilities. Use dedicated libraries like sanitize-html or DOMPurify for proper HTML sanitization.
Context: String(value)
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll('<', '<')
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation

(manual-html-entity-encoding)


[warning] 131-135: Manual HTML sanitization detected using string replacement methods. Manual sanitization is error-prone and can be bypassed. Use dedicated HTML sanitization libraries like 'sanitize-html' or 'DOMPurify' instead.
Context: String(value)
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll('<', '<')
.replaceAll('>', '>')
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation

(manual-html-sanitization)


[warning] 131-134: Manual HTML sanitization detected using string replacement methods. Manual sanitization is error-prone and can be bypassed. Use dedicated HTML sanitization libraries like 'sanitize-html' or 'DOMPurify' instead.
Context: String(value)
.replaceAll('&', '&')
.replaceAll('"', '"')
.replaceAll('<', '<')
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation

(manual-html-sanitization)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/text-editor/prosemirror-adapter/plugins/image/node.ts` around
lines 131 - 148, The inline image HTML path is using a custom escape helper that
static analysis flags as risky; replace the bespoke escapeAttributeValue logic
in node.ts with a vetted escaping utility already used in the codebase, or a
small well-tested encoder such as he.encode. Update getInlineImageHTML to use
that shared helper for IMAGE_ID_ATTRIBUTE, width, height, and alt so attribute
escaping is centralized and future-safe.

Source: Linters/SAST tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving the small helper. As it notes, the values are always placed inside double-quoted attributes so this isn't exploitable, and pulling in an encoder dependency isn't warranted here.

@FredrikWallstrom FredrikWallstrom force-pushed the poc/inline-images-component-markup branch 2 times, most recently from 60ed000 to e3568ea Compare July 1, 2026 10:20
return [
...customNodes,
{
tagName: inlineImages.tagName,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This could be an over-defensive suggestion but tagName is used as a sanitizer whitelist key without validation. If a consumer passes tagName: 'img', this entry replaces the sanitizer's default attribute rules for img — so src is stripped from every existing <img> in content, and all regular images render blank. No error anywhere; just a silent display bug far from its cause. ('script' would likewise become an allowed tag.)

Plausible mistake too: someone wanting plain <img> persistence writes { tagName: 'img', ... } instead of using the InlineImageSrc shape — type-valid, and isInlineImageTag routes it here.

Since the JSDoc already says "Not img", suggest enforcing it:

const CUSTOM_ELEMENT_NAME = /^[a-z][a-z0-9._-]*-[a-z0-9._-]*$/;

if (!CUSTOM_ELEMENT_NAME.test(inlineImages.tagName)) {
    console.error(`inlineImages.tagName "${inlineImages.tagName}" is not a valid custom element name; ignoring inlineImages config`);
    return customNodes;
}

Custom element names must contain a hyphen, so this can never collide with a built-in tag. Warn-and-ignore so a typo degrades to legacy behavior instead of breaking the editor.

@FredrikWallstrom FredrikWallstrom Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, thanks — real footgun. Fixed two ways b19e025:

tagName is now typed ${string}-${string} instead of string, so a literal 'img'/'script' is a compile error. And for dynamically-built names that dodge the type, there's a runtime guard — but I put it upstream in the adapter rather than in buildContentWhitelist, since tagName also feeds the node parse rule and the paste inserter. Validating once when the config is read (invalid → treated as absent + a console.error) degrades to legacy handling everywhere, not just the sanitizer.

Used a hyphen check (/^[a-z][a-z0-9._-]*$/ + includes('-')) instead of your single regex — SonarJS flagged that one for backtracking. Same idea though: valid custom element names have a hyphen, built-ins never do.

john-traas
john-traas previously approved these changes Jul 8, 2026

@john-traas john-traas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks great! Excellent work!

@FredrikWallstrom FredrikWallstrom force-pushed the poc/inline-images-component-markup branch from b19e025 to 75f5961 Compare July 8, 2026 09:03
@adrianschmidt

Copy link
Copy Markdown
Contributor

Consolidated PR Review

Automated 6-agent-review → fix-loop, iteration 1 · 7 dimensions (Lime CRM project detected)

PR Summary

Adds an inlineImages config to limel-text-editor so the editor owns the paste→thumbnail→upload→resizable-image lifecycle itself, instead of the consumer driving it via the legacy imagePasted event. Two persistence shapes are modelled as a discriminated union: InlineImageTag (<tagName image-id="…"> id-only microformat, resolved to a URL at render via getUrl) and InlineImageSrc (plain <img src>). 13 files, +662/−270; the legacy API is preserved and only bypassed when inlineImages is set. @alpha public surface.

Merge Readiness -- READY TO MERGE ✅

This PR is strictly better than main: the public surface change is purely additive (@alpha), the legacy imagePasted/imageRemoved/metadataChange path is untouched, existing <img src> content round-trips unchanged, and getImageHTML now escapes attributes it previously interpolated raw — a security hardening over the target branch. No dimension found a regression. Human-approved (john-traas) and two CodeRabbit rounds already resolved/declined.

  • Blockers: None. No [High]/[Critical] findings in any of the 7 dimensions.
  • Non-blocking but worth addressing: One [Medium] — no test coverage for any of the new inline-image logic (Code Quality). Plus a cluster of [Low] maintainability/observability nits. See Top Recommendations.

1. Backward Compatibility -- SAFE ✅

Additive public surface; legacy path preserved.

  • [Low] view.ts:50-57persistDimensions() is now skipped when node.attrs.width is already set. This is an un-gated behavioral change touching all consumers (not just inline-image ones), but it's the documented fix that stops spurious change/dirty events when loading stored content with sized images, and freshly pasted images (width: '') still get measured. Strictly better than main; no regression.
  • [Low] node.ts:227 getImageHTML — existing <img> output now wraps src/alt/style in escapeAttributeValue, so a URL containing & serializes as &amp; where it was raw before. Semantically equivalent (and more correct), but a consumer byte-comparing editor output against previously stored content could see a one-time "changed" diff. Not a functional break.
  • Positives: etc/lime-elements.api.md diff is 100% additive; legacy events/payload types fully preserved and only bypassed when inlineImages is explicitly configured; existing <img src> round-trips across absent/Src/Tag configs.

2. Code Quality -- GOOD ✅

Sound types and error handling; the notable gap is test coverage.

  • [Medium] No test coverage for any new inline-image logic. grep confirms zero test references to inlineImages, isInlineImageTag, buildContentWhitelist, or InlineImageTag across src/; the touched converter specs still use the old signatures and never pass an inlineImages config. Several added units are trivially unit-testable without a DOM and encode invariants the code comments explicitly promise: isInlineImageTag narrowing (text-editor.types.ts:206), validateInlineImages/isValidTagName — the img/script rejection guard that is the whole point of the tagName footgun fix (prosemirror-adapter.tsx:402-421), buildContentWhitelist (content-type-converter.ts:29-42), and the node.ts parse/serialize round-trip incl. the image-id-missing → false reject. Calibration: the image plugin was already untested on main and this is @alpha, so it doesn't make things worse[Medium], not [High]. But the added surface is markedly larger and branch-heavier than what it replaces.
  • [Low] 'image-id' magic string duplicated: IMAGE_ID_ATTRIBUTE (node.ts:18, unexported) vs the hardcoded literal in the sanitizer whitelist (content-type-converter.ts:40). Known/accepted gap from the CodeRabbit thread, but the whitelist attribute set must be hand-synced with what buildInlineImageDOMAttrs/getInlineImageHTML emit; a future added attribute is a silent strip-bug waiting to happen.
  • [Low] escapeAttributeValue typed (value: string) but calls String(value) (node.ts:131-137) — dead defensiveness; and as a general HTML-attr escaper it would sit better in utils than inside image node.ts.
  • [Low] Dimensions serialized as CSS-unit strings (width="200px") into HTML attributes (see cross-referenced Architecture finding below). Round-trips fine inside the editor; only matters for the external renderer.
  • Positives: The discriminated union + 'tagName' in config narrowing is sound and consistently applied; async error handling in runInlineImageUpload is self-contained (log + failed-thumbnail, no unhandled rejection) and re-reads view.state so a deleted thumbnail degrades to a harmless empty transaction; naming and JSDoc are clear.

3. Architecture -- GOOD ✅

Cleanly additive; legacy and new lifecycles never interleave.

  • [Low] Duplicated "find-node-by-fileInfoId-and-replace" logic — replaceThumbnailWithInlineImage (inserter.ts:76-107) reimplements the descendants-walk/match/replaceWith/dispatch pattern already in createImageInserter (inserter.ts:139-161), differing only in setting imageId/resolving src/hard-coding maxWidth. This is the one spot where the two lifecycles could drift; parameterizing the shared factory would collapse them.
  • [Low] Two independent serializers for one microformat: toDOMbuildInlineImageDOMAttrs (node.ts:150-167, order image-id, alt, width, height) and getInlineImageHTML (node.ts:139-148, order image-id, width, height, alt). They agree today but any future attribute must be added in both (plus the whitelist) or the two persistence formats silently diverge.
  • [Low] buildContentWhitelist (content-type-converter.ts:41-56) injects inlineImages.tagName as a sanitizer whitelist key with no validation of its own — the "not a built-in like img" invariant is enforced only upstream in validateInlineImages (prosemirror-adapter.tsx:402-424). Safe today because the sole construction site passes validatedInlineImages, but the invariant lives in a different file from the boundary that depends on it. (Cross-ref: Security §4 rated the same code safe.)
  • [Low] CSS-unit dimensions into HTML attributes (width="300px") — round-trips inside the editor via applyImageStyles, but the id-only microformat is meant to be portable to the external limebb-inline-image renderer, where width="300px" is not a valid HTML width value. Worth confirming the building-blocks renderer expects units. (Same finding surfaced by Code Quality.)
  • Positives: Single early-return branch point (inserter.ts:329-344) keeps the two paths from interleaving; validation centralized at the composition root and fanned out from one validatedInlineImages; the per-node imageId discriminator lets mixed legacy/new content round-trip through all three converters without a mode flag.

4. Security -- SAFE ✅

Strictly safer than main on the serialization path; no new vulnerability.

  • [Low] image-id is passed to getUrl(imageId) (node.ts:277) validated only for non-emptiness. Attacker-controlled stored content (e.g. image-id="../../other") could steer the consumer's URL construction — but within lime-elements the result only becomes an inert img.src, so any SSRF/path-traversal risk lives entirely in the consumer's getUrl and backend, a documented layer boundary. Worth a one-line note in the getUrl doc comment that the id is untrusted.
  • [Low] escapeAttributeValue omits ' (node.ts:131) — acknowledging the prior declined CodeRabbit thread. Confirmed every interpolation site uses double-quoted attributes and no value reaches a single-quoted/unquoted/innerHTML context. The decline holds; no new exploit argument. Noted for completeness only.
  • Positives: getImageHTML now runs src/alt/style through escapeAttributeValue where main interpolated them raw (a real hardening); the id-only microformat keeps resolved URLs out of stored content; the new-tag whitelist is minimal and handler-free (image-id/width/height/alt, no src/href); the tag name is validated against injection and sanitizer-key hijacking with safe degradation; malformed id-less tags are rejected in getAttrs; a malicious getUrl return can only reach an inert <img src>, never an active context.

5. Observability -- GOOD ✅

Actionable config-validation and upload-failure logging; a few silent-drop edges.

  • [Low] replaceThumbnailWithInlineImage (inserter.ts:76-107) — a successful upload whose thumbnail node is no longer in the doc (user deleted/undid during the multi-second upload) dispatches an empty transaction and inserts nothing, silently orphaning the already-stored backend file with zero trace. That's the exact class of problem this epic exists to solve; a console.warn on the "upload resolved but target node gone" branch would make the orphan diagnosable. Edge case + reconcile-on-save is out of scope → Low.
  • [Low] createImageParseDOM getAttrs — a malformed inline-image tag missing image-id is dropped by returning false with no trace; a consumer debugging "why did my tag disappear" gets no hint. A one-line console.warn would help. (Documented intent caps at Low.)
  • [Low] inserter.ts:71 — the upload-failure error message omits fileInfo.filename (in scope), which the deleted file-storage example used to log. The full error object is still logged, so this is minor specificity loss, not a signal regression.
  • Positives: The config-validation guard (prosemirror-adapter.tsx:414-424) quotes the offending tagName and states the consequence — exemplary actionable messaging. The resolved upload-failure catch now console.errors the raw error object before the failed thumbnail (verified adequate). No sensitive data reaches any log (image bytes/base64 never logged).

6. Performance -- GOOD ✅

Constant-factor only; the width-guard is a genuine win.

  • [Low] getImageHTML (node.ts:200) now wraps attrs.src in escapeAttributeValue (4 sequential .replaceAll scans), and serialize runs per keystroke (only the change emit is debounced). For the InlineImageSrc+base64+markdown combination attrs.src is a multi-MB data URI, so each keystroke adds 4 extra O(n) scans. Kept Low: the base64 alphabet contains none of & " < > so replaceAll returns the same reference without copying (scans, not allocations); serialize was already O(content) per keystroke on main (constant-factor bump, not a new complexity class); the tag shape never escapes src; and the base64 example uses contentType="html", which avoids this path entirely. Deliberate correctness fix → Low.
  • Positives: The view.ts width-guard (if (!this.node.attrs.width)) is a correct, worthwhile optimization — verified across fresh-paste, post-upload swap, and reload-of-persisted-content; it eliminates a spurious setNodeMarkup dispatch + change event on every image load after reload. No new object-URL/timer/listener/plugin-state leak in production paths; getUrl is not called during render or serialize; imageCache cleanup is intact and the tag shape never populates it.

7. Lime Platform Issues -- SAFE ✅

LPID catalogue loaded successfully (via internal-docs MCP); the changed code was evaluated against the Frontend (FE) rule category.

No LPID findings. The editor performs no HTTP itself — upload(file)/getUrl(id) are consumer-supplied callbacks, so the FE HTTP rules (FE-1..FE-4) don't apply; the per-file paste loop wraps the callback with its own try/catch (not fire-and-forget). No new XSS/HTML DOM sink (FE-10) — the new builders produce serializer output, and the only innerHTML write is pre-existing and fed through sanitizeHTML with the whitelist the PR merely extends. No @State/prop in-place mutation (FE-6), no state write in render hooks (FE-7), no blocking async lifecycle (FE-5), no uncleaned timer/listener (FE-8). The server-side LPID categories don't apply — no Python/backend code in the diff.

  • Positives: The view.ts change removes a spurious change-event emission on image re-load; the load listener lives on the NodeView's own <img> and is torn down by ProseMirror.

Overall Verdicts

Dimension Verdict
Backward Compatibility SAFE
Code Quality GOOD
Architecture GOOD
Security SAFE
Observability GOOD
Performance GOOD
Lime Platform Issues SAFE
Merge Readiness READY TO MERGE

Top Recommendations

  1. [Medium from Code Quality] Add unit tests for the new, DOM-free units — isInlineImageTag narrowing, validateInlineImages/isValidTagName (the img/script rejection guard), buildContentWhitelist, and the node.ts tag parse/serialize round-trip (incl. the image-id-missing → false reject and id-only stability). These pin invariants the code comments explicitly promise and guard the footgun fix against regression.
  2. [Low from Code Quality / Architecture] De-duplicate the 'image-id' attribute name and the tag attribute set: export/reuse IMAGE_ID_ATTRIBUTE in content-type-converter.ts, and consider a single source of truth for the emitted attribute list shared by buildInlineImageDOMAttrs, getInlineImageHTML, and the whitelist so the parse/serialize/sanitize triangle can't drift.
  3. [Low from Architecture] Collapse the duplicated find-node-by-fileInfoId-and-replace logic by parameterizing createImageInserter with optional imageId/getUrl, so the success-swap step isn't a forked copy of the shared inserter.
  4. [Low from Observability] Add a console.warn on the "upload succeeded but target thumbnail node is gone" branch in replaceThumbnailWithInlineImage so silently-orphaned uploaded files are diagnosable; optionally include fileInfo.filename in the upload-failure error log.
  5. [Low from Security] Document in the getUrl doc comment that image-id originates from untrusted stored content, so consumers validate it before building a backend URL.
  6. [Low from Architecture] Confirm the building-blocks limebb-inline-image renderer expects CSS-unit ("300px") width/height values, since the portable microformat serializes them that way; if not, store unitless values.
  7. [Low from Architecture] Consider asserting the hyphenated-custom-element rule inside buildContentWhitelist itself so the sanitizer boundary is self-protecting rather than relying on an invariant enforced in another file.

@adrianschmidt

Copy link
Copy Markdown
Contributor

Addressed review feedback

Pushed a fixup (ee3a789) plus a standalone test: commit (3b29952).

Fixed

  • Recommendation 1 (test coverage) — added three spec files covering the DOM-free units:
    • text-editor.types.spec.ts: isInlineImageTag narrowing.
    • content-type-converter.spec.ts: buildContentWhitelist, incl. the hyphen-rule rejection.
    • plugins/image/node.spec.ts: the image node parse/serialize round-trip — id→src resolution via getUrl, the missing/empty image-id reject, id-only round-trip stability, attribute escaping, toDOM tag emission, and the plain-<img> fallback. All exercise the existing public exports (getImageNode / getImageNodeMarkdownSerializer), no awkward export changes needed. 16 tests, all green.
  • Recommendation 2 ('image-id' magic string) — exported IMAGE_ID_ATTRIBUTE and added an exported INLINE_IMAGE_ATTRIBUTES list in node.ts; the sanitizer whitelist now consumes that list instead of a hardcoded literal, so the emit/whitelist sides share one source of truth.
  • Recommendation 3 (duplicated find-and-replace) — extracted replaceImageNodeByFileInfoId(view, fileInfoId, buildNode) in inserter.ts; replaceThumbnailWithInlineImage, createImageInserter and createFailedThumbnailInserter now all use it.
  • Recommendation 4 (observability) — the helper returns whether a node was matched; replaceThumbnailWithInlineImage now console.warns when a successful upload's thumbnail is gone (orphaned file), and the upload-failure log now includes fileInfo.filename.
  • Recommendation 5 (untrusted id) — added a note to the getUrl doc comment that the id is untrusted and should be validated before building a backend URL.
  • Recommendation 6 (dimension units) — added a code comment on getInlineImageHTML documenting the CSS-length unit contract the external limebb-inline-image renderer must honour (could not verify the consumer cross-repo, so documented rather than changed the format).
  • Recommendation 7 (self-protecting whitelist)buildContentWhitelist now independently enforces the hyphen rule and drops the tag if it is missing, so the boundary no longer relies solely on upstream validation.
  • Informational — removed the dead String() coercion in escapeAttributeValue.

Not changed

  • Recommendation 1, validateInlineImages / isValidTagName — left untested directly: it is a private component method with the regex inlined, so a clean unit test would require an awkward export/extraction that is not idiomatic here. The equivalent hyphen-rejection invariant is now covered instead via buildContentWhitelist (recommendation 7), which is the actual security boundary.
  • Recommendation 2, full attribute-list SSOT across buildInlineImageDOMAttrs / getInlineImageHTML — those two build attribute maps/strings conditionally (per-attribute presence checks) rather than iterating a list, so folding them onto the shared array would obscure more than it dedupes. Shared the constant for the whitelist only.

@adrianschmidt

Copy link
Copy Markdown
Contributor

Consolidated PR Review — iteration 2

Automated 6-agent-review → fix-loop, iteration 2 · 7 dimensions (Lime CRM project detected). Re-review of the current head after the iteration-1 fixup (ee3a789) + test commit (3b29952).

PR Summary

Adds an inlineImages config to limel-text-editor so the editor owns the paste→thumbnail→upload→resizable-image lifecycle, via a discriminated union of two persistence shapes (InlineImageTag id-only microformat resolved by getUrl, or InlineImageSrc plain <img src>). The iteration-1 round added test coverage (3 spec files, 16 tests), an extracted replaceImageNodeByFileInfoId inserter helper, an exported INLINE_IMAGE_ATTRIBUTES whitelist source, a self-guarding hyphen check in buildContentWhitelist, and orphan/observability logging.

Merge Readiness -- READY TO MERGE ✅

Still a strict improvement over main, and stronger than iteration 1: the previously-flagged [Medium] test-coverage gap is closed, and the inserter refactor incidentally corrected two latent bugs in the old duplicated node-replace loops (unconditional empty-transaction dispatch; missing short-circuit that replaced duplicate-fileInfoId nodes against stale positions). No dimension found a regression.

  • Blockers: None. No [High]/[Critical] findings in any of the 7 dimensions.
  • Non-blocking but worth addressing: A handful of [Low] polish items — chiefly a doc-comment that overstates a "single source of truth" guarantee the code doesn't enforce. See Top Recommendations.

1. Backward Compatibility -- SAFE ✅

Public surface still purely additive; internal exports stay internal.

  • [Low] node.ts getImageHTML now HTML-escapes src/alt/style on the legacy <img> serialization path (reached when inlineImages is absent), so existing content whose src/alt contains & < > " serializes to a different byte string (e.g. ?a=1&b=2?a=1&amp;b=2). Semantically equivalent after re-parse and a correctness/security improvement — noted only for a consumer doing string-level comparison on stored values.
  • [Low] view.ts:54 skips persistDimensions() when node.attrs.width is already set, affecting legacy images too (an already-sized image is no longer re-measured on load). Documented as intentional suppression of a spurious per-load change event; an improvement, low risk.
  • Positives: etc/lime-elements.api.md diff is strictly additive (@alpha); IMAGE_ID_ATTRIBUTE/INLINE_IMAGE_ATTRIBUTES are module-internal (absent from the API report); legacy imagePasted/imageRemoved/metadataChange and <img src> round-tripping are untouched behind the inlineImages guard; the refactor is behavior-preserving on the happy path and fixes two latent edge cases.

2. Code Quality -- GOOD ✅

The iteration-1 [Medium] test gap is resolved; new tests are meaningful.

  • [Low] Misleading "single source of truth" doc comment on INLINE_IMAGE_ATTRIBUTES (node.ts:20-28). The comment says the constant is shared by "the serializers here and the sanitizer whitelist… so the parse/serialize/sanitize triangle can't drift," but grep confirms it's consumed only by buildContentWhitelist (content-type-converter.ts:49); the serializers (getInlineImageHTML, buildInlineImageDOMAttrs) still hand-enumerate their attributes. The lists agree today but nothing enforces it — adding an attribute to the constant would widen the whitelist without the serializers emitting it. Folding the serializers onto the list was reasonably declined last round; the residual is only the comment claiming an invariant the code doesn't back. Soften the comment to "shared with the sanitizer whitelist."
  • [Low] The hyphen invariant is now encoded in three places — the type (`${string}-${string}`), validateInlineImages (strict regex, prosemirror-adapter.tsx:408), and buildContentWhitelist (hyphen-only, content-type-converter.ts:38). Deliberate defense-in-depth, documented at both runtime sites, and harmless (the looser check only ever sees a pre-validated value). Worth a one-line note that buildContentWhitelist intentionally re-checks a pre-validated value so a future maintainer keeps the two in sync. (Cross-ref: Architecture §3.)
  • Positives: New tests exercise real behavior — parse→serialize round-trip, missing/empty-image-id reject, attribute-escaping order (&-first), <img> fallback, toDOM tag path, isInlineImageTag narrowing both directions — co-located .spec.ts matching repo convention; replaceImageNodeByFileInfoId is a clean extraction with correct behavior at all three call sites.

3. Architecture -- GOOD ✅

Refactor improves cohesion and fixes latent bugs; two documented-intent [Low]s.

  • [Low] INLINE_IMAGE_ATTRIBUTES comment overstates the guarantee — it backs the sanitizer leg only, not the two serializers (same finding as Code Quality §2; kept here for the drift-risk framing). Trimming the comment resolves it.
  • [Low] Hyphen rule lives in two runtime checks with divergent strictness (validateInlineImages full regex vs buildContentWhitelist hyphen-only). Deliberate, documented boundary guard; the shared leg is the safe narrow one (hyphen → cannot hijack a built-in sanitizer key) and the divergence is one-directional (the whitelist check only ever sees already-validated input), so it is not the drift trap it guards against. Noting only that edits to one hyphen check should mirror the other.
  • Positives: replaceImageNodeByFileInfoId genuinely improves cohesion across all three inserters and quietly corrects two latent transaction/position bugs from the old duplicated loops; the exported constant follows the established utils → node import direction (no cycle, no new coupling); the additive-coexistence design with the legacy path holds.

4. Security -- SAFE ✅

Strict improvement over main; the injection-relevant invariants are now regression-tested.

  • No [High]/[Medium]/[Low] findings. The buildContentWhitelist independent guard can only reject, never admit more than its caller passes (hyphen-only still drops img/script/iframe/svg — all hyphenless); the whitelist entry carries only image-id/width/height/alt, no src/handlers. Removing String() from escapeAttributeValue is safe (all callers pass typed strings; a hostile non-string would fail-closed on .replaceAll, not interpolate unescaped). Tag/id/URL interpolation remains injection-safe.
  • Positives: getImageHTML now escapes src/alt/style where main interpolated them raw (a net XSS reduction); two independent tag-name guards (compile-time type + runtime regex + whitelist hyphen check); new tests assert the missing-id reject, hyphen reject, and escaping order — pinning exactly the invariants that matter.

5. Observability -- GOOD ✅

The iteration-1 logging fixes are sound; two minor [Low]s remain.

  • [Low] The orphaned-successful-upload console.warn (inserter.ts:140-143) identifies the leak only by fileInfo.filename, not by the stored backend id (uploadResult). The message's whole purpose is remediation of the orphaned file, and the id is the field that actually locates it — a filename may not be unique and isn't the backend key. Including uploadResult is a one-token improvement to that message.
  • [Low] A stored inline-image tag with a missing/empty image-id is silently dropped at parse (node.ts getAttrsreturn false, no trace), so a consumer whose image vanishes on load gets no diagnostic. Carried from iteration 1, unchanged; genuinely borderline (parsing untrusted stored content on every load argues against noisy logging), so optional.
  • Positives: Upload-failure path now logs filename + error object (no image bytes); the orphan warn is on the strictly-correct success-but-node-gone branch with a correctly-scoped match flag; the invalid-tagName console.error names the offending value and states the degradation.

6. Performance -- GOOD ✅

No new concerns; the refactor is perf-neutral-to-better.

  • [Low] escapeAttributeValue on the src in getImageHTML does 4 replaceAll scans over the full value, which is large for a base64 data URI, on the per-keystroke serialize path for the InlineImageSrc/base64 shape. Pre-existing from iteration 1 and already deemed acceptable (constant-factor scans, not copies — the base64 alphabet contains none of & " < >); no action needed.
  • Positives: Centralizing the three duplicated descendants loops made the traversal strictly cheaper (early-exit + conditional dispatch); the new whitelist validation sits on the parse (value-set) path, not the keystroke path, and uses .includes('-') not a regex; the view.ts load-guard genuinely reduces re-measurement/change-event churn vs main.

7. Lime Platform Issues -- SAFE ✅

LPID catalogue loaded (internal-docs MCP); only the Frontend (FE) category is in scope for this client-side Stencil library.

No LPID findings. The new escaping/HTML-string builders produce serialized editor output (re-sanitized on parse), not a DOM-parsing sink — not FE-10. runInlineImageUpload awaits the consumer upload inside a try/catch that logs and inserts a failed thumbnail — not FE-4 fire-and-forget (the editor performs no HTTP itself). The view.ts load listener predates the PR and is torn down with the node view — not FE-8. Whitelist guard / exported constants / spec files carry no runtime FE surface.

  • Positives: Where the fixer's changes touch security-adjacent surface (attribute escaping, tag-name validation) they reduce rather than introduce platform risk.

Overall Verdicts

Dimension Verdict
Backward Compatibility SAFE
Code Quality GOOD
Architecture GOOD
Security SAFE
Observability GOOD
Performance GOOD
Lime Platform Issues SAFE
Merge Readiness READY TO MERGE

Top Recommendations

All remaining items are [Low] polish; none blocks merge.

  1. [Low from Code Quality / Architecture] Soften the INLINE_IMAGE_ATTRIBUTES doc comment (node.ts:20-28) to state it backs the sanitizer whitelist only — not that the serializers share it — since getInlineImageHTML/buildInlineImageDOMAttrs still hand-enumerate their attributes and nothing enforces the "can't drift" claim.
  2. [Low from Observability] Include the stored backend id (uploadResult) in the orphaned-upload console.warn (inserter.ts:140-143), not just fileInfo.filename — the id is the field needed to actually locate the orphaned file the warning is about.
  3. [Low from Code Quality / Architecture] Add a one-line comment at buildContentWhitelist's hyphen check noting it intentionally re-checks an already-validateInlineImages-validated value (defense-in-depth), so a future maintainer keeps the two hyphen checks in sync.
  4. [Low from Observability] Optional: a console.warn when a stored inline-image tag is dropped at parse for a missing/empty image-id (node.ts getAttrs), so a consumer whose image silently vanishes on load has a diagnostic. Reasonable to leave given it fires on untrusted stored content parsed every load.

Informational (no action expected): the legacy-<img> escaping byte-diff (Backward Compat §1) and the base64 escapeAttributeValue scan cost (Performance §6) are both accepted trade-offs of a correctness/security improvement.

@adrianschmidt

Copy link
Copy Markdown
Contributor

Addressed the review (all [Low] polish). Pushed as a fixup on ee3a789.

Fixed

  • Recommendation 1: Softened the INLINE_IMAGE_ATTRIBUTES doc comment. It no longer claims to be a "single source of truth" shared by the serializers. It now states it backs the sanitizer whitelist in buildContentWhitelist and notes the serializers enumerate their own output attributes separately.
  • Recommendation 2: The orphan console.warn in replaceThumbnailWithInlineImage now includes the stored backend id, logging { id: uploadResult, filename: fileInfo.filename } so the orphaned file can actually be located (the id is the backend key; filename may not be unique).

Not changed

  • Recommendation 3: The existing comment at the hyphen guard already documents this as a deliberate independent re-check ("Guard the boundary independently of the upstream validateInlineImages check..."), so the defense-in-depth intent is clear. No further comment added.
  • Recommendation 4 (parse-time console.warn for dropped id-less tags): Declined. It would fire per-tag on every parse of untrusted stored content, and dropping a malformed tag is an expected path, so the recurring log noise outweighs the diagnostic value.

@adrianschmidt

Copy link
Copy Markdown
Contributor

Consolidated PR Review — iteration 3

Automated 6-agent-review → fix-loop, iteration 3 · 7 dimensions (Lime CRM project detected). Re-review of the current head after fixup a1e17a65e (a console.warn payload + a softened doc comment; 2 files, +5/−4 vs iteration 2).

PR Summary

Adds an inlineImages config to limel-text-editor (discriminated union: InlineImageTag id-only microformat or InlineImageSrc plain <img src>). This round's delta was tiny: the orphaned-successful-upload console.warn now carries the stored id, and the INLINE_IMAGE_ATTRIBUTES doc comment was corrected to describe its real coupling.

Merge Readiness -- READY TO MERGE ✅

Still a strict improvement over main, and cleaner than iteration 2: the iteration-2 [Low] about the overstated "single source of truth" comment is resolved, and the orphan warning is now keyed off the stored id (aligning it with the reconciliation model). No regression in any dimension. Both prior fix rounds' declines were sound and were not re-raised.

  • Blockers: None. No [High]/[Critical] findings in any of the 7 dimensions.
  • Non-blocking but worth addressing: One [Low], itself a small side-effect of the iteration-2 fix — the orphan-warn { id: … } key mislabels the value for the InlineImageSrc shape. See Top Recommendations.

1. Backward Compatibility -- SAFE ✅

No significant issues found.

  • The two new edits are internal (a console.warn payload inside a module-private function; a doc comment) — zero public-surface impact. etc/lime-elements.api.md remains purely additive (@alpha); legacy imagePasted/imageRemoved/metadataChange and <img src> round-tripping are intact behind the inlineImages guard.
  • Positives: All new API surface is additive and @alpha-gated; the iteration-3 delta touches only a log payload and a comment — no reachable behavior change.

2. Code Quality -- GOOD ✅

The iteration-2 comment [Low] is resolved; the new comment is accurate on all three of its claims (verified against every consumer of INLINE_IMAGE_ATTRIBUTES).

  • [Low] inserter.ts orphan-warn — the { id: uploadResult } key is accurate only for the tag shape. replaceThumbnailWithInlineImage runs for both union shapes; for InlineImageSrc, uploadResult is the src itself (URL or data URI), so logging it under id mislabels it. (Cross-ref: Observability §5, which also notes the base64-src data-URI bloat.) A neutral key like uploadResult would be correct for both shapes. Optional.
  • Positives: Rather than deleting the overstated comment, the fixer replaced it with a precise account of the actual coupling (whitelist yes, serializers no) — more useful to a future reader than silence; no new smell introduced.

3. Architecture -- GOOD ✅

No findings; the dimension is clean.

  • The softened INLINE_IMAGE_ATTRIBUTES comment now describes the real one-way coupling (constant → whitelist only), verified against usage — the iteration-2 [Low] is resolved. The additive-coexistence design and the replaceImageNodeByFileInfoId shared primitive still hold; no new coupling.
  • Positives: The comment fix removes the last SSOT-claim mismatch; the structured log payload keys the orphan warning off the stable stored id, aligning observability with the reconciliation model.

4. Security -- SAFE ✅

No findings; clean.

  • The new console.warn object is a structured console arg, never concatenated into HTML/markdown/DOM/serialized output — not an injection vector; uploadResult (non-secret stored id) and filename are not credential/PII-class. The comment change is inert. All iteration-2 escaping/whitelist/parse-reject guarantees are intact and untouched.
  • Positives: The delta reopens none of the injection/whitelist/escaping invariants; the surface remains a strict improvement over main.

5. Observability -- GOOD ✅

The iteration-2 recommendation was implemented soundly — orphaned successful uploads are now locatable by stored id, on the strictly-correct success-but-node-gone branch, leaking nothing sensitive in the intended tag-shape case.

  • [Low] inserter.ts — the id field is accurate only for the tag shape. For InlineImageSrc (e.g. the base64 example, which returns a full data URI), the warn dumps the entire image data under a key named id — a mislabel and log bloat. Not sensitive-exposure (the user's own pasted image in their own client-side console, no privilege boundary crossed), so ≤ [Low]; worth noting only because a consumer could forward console.warn to a telemetry sink. A neutral key resolves both this and the Code Quality framing.
  • Positives: Failure-path log (filename + error) preserved through the refactor; the warn is gated on the true orphan condition only — no new noise on the happy path.

6. Performance -- GOOD ✅

Clean. Both delta edits are performance no-ops.

  • The orphan console.warn object literal is on the !replaced branch (fires at most once per upload, only on the rare undo/delete-mid-flight path) — negligible, off every hot path. The comment change has zero runtime effect. The pre-existing accepted [Low] (escapeAttributeValue base64 scans on per-keystroke serialize) is untouched.
  • Positives: The orphan-branch cost is confined behind the !replaced guard; replaceImageNodeByFileInfoId retains its early-exit traversal; the view.ts width-guard remains a small win over main.

7. Lime Platform Issues -- SAFE ✅

LPID catalogue loaded (internal-docs MCP); only the Frontend (FE) category applies to this client-side Stencil library.

No LPID findings. The console.warn is a browser console call (the LPID logging rules target Python-backend logger.*), logging a non-secret id + filename. The comment change has no runtime effect. Re-checked FE-10 (XSS sink — the PR builds HTML strings re-sanitized on parse, no innerHTML/eval sink) and FE-3/FE-4 (the consumer upload isn't a fetch/http.* call and its failures are handled) against the whole PR — none match, consistent with iterations 1 & 2.

  • Positives: Where the changes touch security-adjacent surface (attribute escaping, tag-name validation) they reduce rather than introduce platform risk.

Overall Verdicts

Dimension Verdict
Backward Compatibility SAFE
Code Quality GOOD
Architecture GOOD
Security SAFE
Observability GOOD
Performance GOOD
Lime Platform Issues SAFE
Merge Readiness READY TO MERGE

Top Recommendations

The single remaining item is [Low] and does not block merge.

  1. [Low from Observability / Code Quality] In the orphaned-upload console.warn (inserter.ts, replaceThumbnailWithInlineImage), rename the id key to a shape-neutral one (e.g. uploadResult). replaceThumbnailWithInlineImage runs for both union shapes, and for InlineImageSrc uploadResult is the src (a URL or data URI), not a backend id — so { id: … } both mislabels it and, for base64 srcs, dumps the whole data URI under id. A neutral key is correct for both shapes and avoids the bloat.

@adrianschmidt

Copy link
Copy Markdown
Contributor

Review round 3 addressed

Fixed the single [Low] finding — the orphaned-upload console.warn payload in inserter.ts.

The payload key was id, which is only accurate for the InlineImageTag shape. Since replaceThumbnailWithInlineImage runs for both union shapes, and for InlineImageSrc the uploadResult is the src itself (a URL or base64 data URI, not a backend id), the id label was wrong for that case. Renamed it to the shape-neutral uploadResult (shorthand { uploadResult, filename: fileInfo.filename }); the rest of the warn message is unchanged.

Pushed as fixup 4c019b3d0 targeting a1e17a65e (where the payload was introduced).

@adrianschmidt

Copy link
Copy Markdown
Contributor

Consolidated PR Review — iteration 4 (converged ✅)

Automated 6-agent-review → fix-loop, iteration 4 · 7 dimensions (Lime CRM project detected). Re-review of the current head after fixup 4c019b3d0 (a one-line console.warn key rename; 1 file, +1/−1 vs iteration 3).

PR Summary

Adds an inlineImages config to limel-text-editor (discriminated union: InlineImageTag id-only microformat or InlineImageSrc plain <img src>). This round's delta was a single line: the orphaned-upload console.warn payload key was renamed to the shape-neutral uploadResult, implementing the sole iteration-3 finding.

Merge Readiness -- READY TO MERGE ✅

The PR has converged: every dimension is clean this round and there are no outstanding recommendations. It is a strict improvement over main — additive @alpha public surface, the legacy imagePasted/imageRemoved/metadataChange path preserved, <img src> round-tripping unchanged, and incidental hardening (attribute escaping on the legacy <img> path; a refactor that fixed two latent node-replace bugs). Over four rounds the flagged [Medium] (test coverage) and every actionable [Low] were resolved, and the deliberately-declined items had sound, recorded reasoning.

  • Blockers: None. No [High]/[Critical] in any of the 7 dimensions.
  • Non-blocking but worth addressing: None outstanding. The only remaining accepted trade-offs are informational (the legacy-<img> escaping byte-diff and the base64 escapeAttributeValue per-keystroke scan cost), both deliberate consequences of correctness/security improvements.

1. Backward Compatibility -- SAFE ✅

No significant issues found. The one-line delta is an internal console.warn key (no public surface). etc/lime-elements.api.md is purely additive (@alpha); the legacy path and <img src> round-trip are intact behind the inlineImages guard.

  • Positives: All new API surface is additive and @alpha-gated; existing imagePasted-API consumers are provably unaffected (early-return gating + untouched event dispatch).

2. Code Quality -- GOOD ✅

No findings. The iteration-3 id-key mislabel is resolved — the payload is now shape-neutral { uploadResult, filename }, correct for both union arms, with the warn message and adjacent explanatory comment intact.

  • Positives: Surgical fix; object shorthand reads cleanly and matches the shape-neutral branching directly above it. The test suite added in round 1 (3 spec files) remains meaningful coverage of the parse/serialize round-trip, missing-id reject, hyphen reject, and union narrowing.

3. Architecture -- GOOD ✅

No findings. The delta is architecturally inert (a log-payload key). The additive-coexistence design, the shared replaceImageNodeByFileInfoId primitive, and the single validatedInlineImages config gate all hold.

  • Positives: The rename tightens observability accuracy at zero structural cost, respecting the discriminated-union design where the same field means different things per variant.

4. Security -- SAFE ✅

No findings. The rename changes only the log key, not the value or any behavior; console.warn structured args are not an injection surface. All escaping/whitelist/parse-reject invariants remain identical to iteration 3 and regression-tested.

  • Positives: Injection defenses (double-quoted-attribute escaping, hyphenated-custom-element tagName guard, malformed-tag rejection) remain intact; the branch stays a strict security improvement over main.

5. Observability -- GOOD ✅

No findings. The iteration-3 finding is resolved: the shape-neutral uploadResult key is accurate for both the tag shape (opaque id) and the src shape (URL/data URI), on the correct success-but-node-gone branch, leaking nothing sensitive (user's own pasted reference + filename, in their own console).

  • Positives: Orphan warning remains distinct from the failure-path console.error and stays actionable for locating a stranded backend file.

6. Performance -- GOOD ✅

No findings. A property-key rename on the rare orphan branch — no allocation, iteration, or hot-path impact. The pre-existing accepted [Low] (per-keystroke escapeAttributeValue base64 scans) is untouched — noted only in passing, no action.

  • Positives: The replaceImageNodeByFileInfoId refactor still short-circuits its descendants walk; the view.ts width-guard remains a small win over main.

7. Lime Platform Issues -- SAFE ✅

No LPID findings. LPID catalogue loaded (internal-docs MCP); only the Frontend (FE) category applies. The delta is a browser-console diagnostic key rename — no runtime FE surface. Re-checked FE-1..FE-10 (no fetch/HTTP added; upload is a handled consumer callback; no state-in-render, no uncleaned listener, no HTML/eval sink) and confirmed the backend-oriented logging rules (SEC-5/6/10) don't apply to console.warn. Consistent with iterations 1–3.

  • Positives: Where the PR touches security-adjacent surface (attribute escaping, tag-name validation) it reduces rather than introduces platform risk.

Overall Verdicts

Dimension Verdict
Backward Compatibility SAFE
Code Quality GOOD
Architecture GOOD
Security SAFE
Observability GOOD
Performance GOOD
Lime Platform Issues SAFE
Merge Readiness READY TO MERGE

Top Recommendations

None — the review is clean and the PR has converged. No [High]/[Medium]/[Low] items remain actionable. The fixup history from the four review rounds is left intact for a human to squash at merge time.

adrianschmidt
adrianschmidt previously approved these changes Jul 8, 2026
@adrianschmidt adrianschmidt dismissed their stale review July 8, 2026 10:18

Forgot to check manually 😅

FredrikWallstrom and others added 2 commits July 8, 2026 13:12
Add an `inlineImages` prop so the editor can own the paste lifecycle
(thumbnail -> upload -> resizable image or failed state) instead of the
consumer sequencing it via the `imagePasted` event.

Two persistence shapes via a discriminated union:
- InlineImageTag ({ tagName, getUrl, upload }) -> `<tagName image-id>`,
  an id-only microformat resolved to a URL at render.
- InlineImageSrc ({ upload }) -> plain `<img src>` (upload returns the src).

`isInlineImageTag` narrows the union. The old `imagePasted`/`metadataChange`
API is untouched and used when `inlineImages` is absent, and existing
`<img src>` content round-trips unchanged. Examples migrated to the new config.
Add spec coverage for the DOM-free inline-image units introduced with the
`inlineImages` config:
- `isInlineImageTag` union narrowing
- `buildContentWhitelist`, including the hyphen-rule rejection of a
  built-in tag name
- the `image` node parse/serialize round-trip: id resolution via `getUrl`,
  the missing/empty `image-id` reject, id-only round-trip stability,
  attribute escaping and the plain-`<img>` fallback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FredrikWallstrom FredrikWallstrom force-pushed the poc/inline-images-component-markup branch from 4c019b3 to 4f8a7cc Compare July 8, 2026 11:12
@FredrikWallstrom FredrikWallstrom enabled auto-merge (rebase) July 8, 2026 11:12
@FredrikWallstrom FredrikWallstrom merged commit 714997e into main Jul 8, 2026
17 of 18 checks passed
@FredrikWallstrom FredrikWallstrom deleted the poc/inline-images-component-markup branch July 8, 2026 11:17
@lime-opensource

Copy link
Copy Markdown
Collaborator

🎉 This PR is included in version 39.41.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants