Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ updates:
# Cucumber e2e-ui steps. Held on the 1.48 line.
- dependency-name: "@playwright/test"
update-types: ["version-update:semver-minor", "version-update:semver-major"]
# tiptap-markdown: 0.9+ requires @tiptap/core v3; project is on the tiptap v2 line.
- dependency-name: "tiptap-markdown"
update-types: ["version-update:semver-minor", "version-update:semver-major"]
# @lingui/*: 5.9 breaks the build — <Trans> return type is ReactNode, not a
# valid JSX element under the current @types/react. Needs a @types/react
# upgrade first. Held on the 5.1 line.
Expand All @@ -47,13 +44,11 @@ updates:
update-types: ["version-update:semver-major"]
- dependency-name: "babel-loader"
update-types: ["version-update:semver-major"]
# marked: 5+ is ESM-only and rewrote the Renderer API. 4 -> 18 needs a rewrite of
# public/services/markdown.ts (custom marked.Renderer() + synchronous marked()).
# Held on the 4.x line until that migration is done.
# marked: now on 17 (renderer rewritten to the token-based API in markdown.ts).
# Held on 17.x because @tiptap/markdown peers marked ^17; unblock 18 once the
# editor's @tiptap/markdown supports it.
- dependency-name: "marked"
update-types: ["version-update:semver-major"]
- dependency-name: "@types/marked"
update-types: ["version-update:semver-major"]

# Go modules - monthly, all grouped together
- package-ecosystem: "gomod"
Expand Down
6 changes: 1 addition & 5 deletions .babelrc → babel.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@
"presets": ["@babel/env", "@babel/react", "@babel/preset-typescript"],
"plugins": [
"@babel/plugin-proposal-class-properties",
["macros", {
"lingui": {
"version": 5
}
}],
["macros", { "lingui": { "version": 5 } }],
"@lingui/babel-plugin-lingui-macro"
]
}
835 changes: 325 additions & 510 deletions package-lock.json

Large diffs are not rendered by default.

31 changes: 16 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,22 @@
"dependencies": {
"@lingui/format-json": "^5.1.2",
"@lingui/react": "^5.1.2",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-link": "^2.11.5",
"@tiptap/extension-mention": "^2.11.5",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@tiptap/suggestion": "^2.11.5",
"@tiptap/extension-image": "3.29.2",
"@tiptap/extension-link": "3.29.2",
"@tiptap/extension-mention": "3.29.2",
"@tiptap/extensions": "3.29.2",
"@tiptap/markdown": "3.29.2",
"@tiptap/pm": "3.29.2",
"@tiptap/react": "3.29.2",
"@tiptap/starter-kit": "3.29.2",
"@tiptap/suggestion": "3.29.2",
"dompurify": "^3.4.12",
"inter-ui": "^4.1.1",
"marked": "^4.0.15",
"prosemirror-markdown": "^1.13.5",
"marked": "^17.0.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-textarea-autosize": "^8.5.9",
"react-toastify": "^9.0.1",
"tiptap-markdown": "^0.8.10"
"react-toastify": "^9.0.1"
},
"devDependencies": {
"@babel/cli": "^7.28.3",
Expand All @@ -42,7 +41,6 @@
"@types/debug": "4.1.13",
"@types/dompurify": "3.2.0",
"@types/jsdom": "16.2.14",
"@types/marked": "4.0.3",
"@types/node": "17.0.31",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
Expand Down Expand Up @@ -82,7 +80,7 @@
"jest": {
"testEnvironment": "jsdom",
"transform": {
".+\\.(tsx|ts)?$": "babel-jest"
".+\\.(t|j)sx?$": "babel-jest"
},
"setupFilesAfterEnv": [
"<rootDir>/public/jest.setup.tsx"
Expand All @@ -100,7 +98,10 @@
".*\\.(png|scss|svg)$": "<rootDir>/public/jest.assets.ts",
"@fider/(.*)": "<rootDir>/public/$1",
"@locale/(.*)": "<rootDir>/locale/$1"
}
},
"transformIgnorePatterns": [
"/node_modules/(?!(marked|@tiptap|tiptap-markdown|prosemirror-.*)/)"
]
},
"engines": {
"npm": "10.x || 11.x",
Expand Down
63 changes: 63 additions & 0 deletions public/components/common/form/CommentEditor.markdown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Editor } from "@tiptap/core"
import StarterKit from "@tiptap/starter-kit"
import Link from "@tiptap/extension-link"
import { Markdown } from "@tiptap/markdown"
import { CustomImage } from "./CustomImage"
import { CustomMention } from "./CustomMention"

// Headless editor with the same extension set CommentEditor uses (minus UI-only bits).
// This locks the markdown round-trip (markdown -> doc -> markdown) so the later engine
// swap to @tiptap/markdown can be verified byte-for-byte against it.
const makeEditor = () =>
new Editor({
extensions: [
StarterKit.configure({ link: false, underline: false }),
Link.configure({ openOnClick: true, autolink: true, defaultProtocol: "https" }),
Markdown.configure({ markedOptions: { breaks: true, gfm: true } }),
CustomMention.configure({ HTMLAttributes: { class: "mention" } }),
CustomImage.configure({ allowBase64: true }),
],
})

const roundTrip = (md: string): string => {
const editor = makeEditor()
editor.commands.setContent(md, { emitUpdate: false, contentType: "markdown" })
const out = editor.getMarkdown().trim()
editor.destroy()
return out
}

describe("CommentEditor markdown round-trip (@tiptap/markdown)", () => {
const cases: Array<[string, string]> = [
["mention", "@[Jane Doe]"],
["mention in text", "Hey @[Jane Doe], welcome aboard"],
["two mentions", "@[Jane Doe] and @[John Smith]"],
["fider image", "![](fider-image:attachments/zy0hBtqrjQki7M56p26AuAXljRoaNUSwZO6MOky5gnYm2nW1rsMmrp3dwhjGk7ok-aden.jpeg)"],
["fider image in text", "look ![](fider-image:attachments/abc-x.jpeg) here"],
["plain image", "![](http://demo.dev.fider.io:3000/images/100/28)"],
["bold", "**bold**"],
["italic", "*italic*"],
["strike", "~~struck~~"],
["inline code", "`code`"],
["mixed inline", "a **bold** and *italic* and `code` and @[Jane Doe]"],
["h2", "## Heading two"],
["h3", "### Heading three"],
["bullet list", "- one\n- two"],
["ordered list", "1. one\n2. two"],
["blockquote", "> quoted"],
["link", "[GitHub](https://github.com)"],
["link with mention", "see [GitHub](https://github.com) cc @[Jane Doe]"],
["multi paragraph", "First paragraph.\n\nSecond paragraph."],
// Hard breaks serialize to the standard two-space form. This is render-equivalent: the
// marked renderer emits identical HTML (<br>) for bare "\n" and " \n" under breaks:true,
// so existing stored content (bare \n) is unaffected.
["hard breaks", "line one \nline two \nline three"],
["heading then text", "## Title\n\nSome body text."],
]

cases.forEach(([name, md]) => {
test(`${name} round-trips byte-identically: ${md}`, () => {
expect(roundTrip(md)).toEqual(md)
})
})
})
23 changes: 12 additions & 11 deletions public/components/common/form/CommentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import StarterKit from "@tiptap/starter-kit"
import Link from "@tiptap/extension-link"
import React, { useState, useRef, useEffect } from "react"
import { EditorContent, useEditor } from "@tiptap/react"
import { Markdown } from "tiptap-markdown"
import Placeholder from "@tiptap/extension-placeholder"
import { Markdown } from "@tiptap/markdown"
import { Placeholder } from "@tiptap/extensions"
import { i18n } from "@lingui/core"
import { useAllowedProtocols } from "@fider/hooks"

Expand Down Expand Up @@ -264,15 +264,16 @@ const Tiptap: React.FunctionComponent<CommentEditorProps> = (props) => {
const toggleMarkdownMode = () => {
if (isRawMarkdownMode) {
// Switching FROM markdown TO rich text
// Load the edited markdown back into the existing editor instance
// Load the edited markdown back into the existing editor instance.
// tiptap v3 emits an update on setContent by default; keep v2 semantics (no onChange).
if (editor) {
editor.commands.setContent(markdownText)
editor.commands.setContent(markdownText, { emitUpdate: false, contentType: "markdown" })
}
} else {
// Switching FROM rich text TO markdown
// Get the markdown from the editor and store it
if (editor) {
const markdown = editor.storage.markdown.getMarkdown()
const markdown = editor.getMarkdown().trim()
setMarkdownText(markdown)
}
}
Expand Down Expand Up @@ -334,7 +335,7 @@ const Tiptap: React.FunctionComponent<CommentEditorProps> = (props) => {

const updated = ({ editor }: { editor: Editor; transaction: any }): void => {
// Get the current markdown content
const markdown = isRawMarkdownMode ? editor.getText() : editor.storage.markdown.getMarkdown()
const markdown = isRawMarkdownMode ? editor.getText() : editor.getMarkdown().trim()

setContentLength(markdown.length)

Expand Down Expand Up @@ -487,7 +488,9 @@ const Tiptap: React.FunctionComponent<CommentEditorProps> = (props) => {

// Only use extensions for rich text mode
const extensions = [
StarterKit,
// tiptap v3 StarterKit bundles Link (custom Link.configure below owns the mark) and
// Underline (no markdown representation with the marked renderer); disable both.
StarterKit.configure({ link: false, underline: false }),
Link.configure({
openOnClick: true,
autolink: true,
Expand All @@ -499,10 +502,7 @@ const Tiptap: React.FunctionComponent<CommentEditorProps> = (props) => {
rel: "noopener nofollow",
},
}),
Markdown.configure({
html: false,
breaks: true,
}),
Markdown.configure({ markedOptions: { breaks: true, gfm: true } }),
CustomMention.configure({
HTMLAttributes: {
class: "mention",
Expand Down Expand Up @@ -542,6 +542,7 @@ const Tiptap: React.FunctionComponent<CommentEditorProps> = (props) => {
{
extensions,
content: initialContentRef.current,
contentType: "markdown",
onUpdate: updated,
onFocus: () => {
if (props.onFocus) {
Expand Down
86 changes: 37 additions & 49 deletions public/components/common/form/CustomImage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ImageUpload } from "@fider/models"
import Image from "@tiptap/extension-image"
import * as MarkdownIt from "markdown-it"
import { defaultMarkdownSerializer } from "prosemirror-markdown"
import { JSONContent } from "@tiptap/core"

export interface CustomImageOptions {
HTMLAttributes?: Record<string, any>
Expand All @@ -11,9 +10,18 @@ export interface CustomImageOptions {
onGetImageSrc?: (bkey: string) => string
}

// marked image token shape (the parts we read)
type ImageToken = { href?: string; text?: string; title?: string | null }

export const CustomImage = Image.extend<CustomImageOptions>({
name: "customImage",

// marked tokenizes images as INLINE tokens, so the node must be inline to sit inside
// paragraph content (a block image would be dropped during markdown parse). This also
// matches Fider's `fider-inline-image` rendering.
inline: true,
group: "inline",

addOptions() {
return {
...this.parent?.(),
Expand Down Expand Up @@ -55,56 +63,36 @@ export const CustomImage = Image.extend<CustomImageOptions>({
}
},

addStorage() {
// --- @tiptap/markdown integration (marked engine) ---
// Handle the standard marked "image" token, detecting Fider's ![](fider-image:<bkey>) syntax.
markdownTokenName: "image",

parseMarkdown(token: ImageToken): JSONContent {
// Note: `this` inside parseMarkdown is NOT the extension (no this.name / this.options),
// so use a literal node type. Parsed content is already-stored images, which resolve
// via the static path (the same fallback the editor used before); live base64 uploads
// arrive through the setImage command, not markdown parsing.
const href = token.href || ""
if (href.startsWith("fider-image:")) {
const imageId = href.substring("fider-image:".length)
return {
type: "customImage",
attrs: { src: `/static/images/${imageId}`, alt: "", id: imageId, bkey: imageId },
}
}
return {
images: {},
markdown: {
serialize: (state: any, node: any) => {
if (!node.attrs.bkey && !node.attrs.id) {
// Call the default image serializer
return defaultMarkdownSerializer.nodes.image(state, node, node.parent, node.index)
}

// When serializing to markdown, we use a special syntax: ![](fider-image:bkey)
// Use bkey if available, otherwise fall back to id
const imageId = node.attrs.bkey || node.attrs.id || ""
state.write(`![](fider-image:${imageId})`)
},
parse: {
setup: (markdownit: MarkdownIt) => {
// Custom rule to parse our special image syntax
markdownit.inline.ruler.before("image", "fider-image", (state: MarkdownIt.StateInline, silent: boolean) => {
const match = state.src.slice(state.pos).match(/^!\[\]\(fider-image:([a-zA-Z0-9_/.-]+)\)/)
if (!match) return false

if (!silent) {
const imageId = match[1]
const token = state.push("image", "img", 0)

// Initialize attrs as an empty array first
token.attrs = []

let imageSrc = this.options.onGetImageSrc ? this.options.onGetImageSrc(imageId) : ""
if (imageSrc.length === 0) {
imageSrc = `/static/images/${imageId}`
}

token.attrSet("src", imageSrc)
token.attrSet("alt", "")
token.attrSet("data-id", imageId)
token.attrSet("data-bkey", imageId)

token.children = []
token.content = ""
}
type: "customImage",
attrs: { src: href, alt: token.text || "", id: null, bkey: null },
}
},

state.pos += match[0].length
return true
})
},
},
},
renderMarkdown(node: JSONContent): string {
const attrs = node.attrs || {}
if (attrs.bkey || attrs.id) {
// Fider inline-image syntax; use bkey if available, otherwise id.
return `![](fider-image:${attrs.bkey || attrs.id})`
}
return `![${attrs.alt || ""}](${attrs.src || ""})`
},

// Override the addImage command to include our custom attributes and handle uploads
Expand Down
Loading
Loading