diff --git a/components/Comment/lib/commentEditorExtensions.ts b/components/Comment/lib/commentEditorExtensions.ts new file mode 100644 index 000000000..b4fe473e7 --- /dev/null +++ b/components/Comment/lib/commentEditorExtensions.ts @@ -0,0 +1,97 @@ +import { AnyExtension } from '@tiptap/core'; +import { StarterKit } from '@tiptap/starter-kit'; +import { Underline } from '@tiptap/extension-underline'; +import { Image } from '@tiptap/extension-image'; +import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; +import { Placeholder } from '@tiptap/extension-placeholder'; +import { createLowlight } from 'lowlight'; +import javascript from 'highlight.js/lib/languages/javascript'; +import typescript from 'highlight.js/lib/languages/typescript'; +import python from 'highlight.js/lib/languages/python'; +import { CommentLink } from './extensions/CommentLink'; +import { ExitLinkOnSpace } from './extensions/ExitLinkOnSpace'; +import { RichLinkExtension } from './extensions/RichLinkExtension'; +import { MentionExtension } from './MentionExtension'; +import { ReviewExtension } from './ReviewExtension'; + +// Initialize lowlight with supported languages +const lowlight = createLowlight(); +lowlight.register('javascript', javascript); +lowlight.register('typescript', typescript); +lowlight.register('python', python); + +export interface CommentEditorExtensionOptions { + placeholder?: string; + isReview?: boolean; + rating?: number; + onRatingChange?: (rating: number) => void; +} + +/** + * Single source of truth for the comment editor's extension set, shared by + * `useCommentEditor` and `scripts/export-prosemirror-schema.ts` (which derives + * the backend-facing ProseMirror schema from it). After adding, removing, or + * reconfiguring an extension here, run `npm run schema:export` and ship the + * regenerated schema to the backend. + */ +export const getCommentEditorExtensions = ({ + placeholder = 'Write a comment...', + isReview = false, + rating = 0, + onRatingChange = () => {}, +}: CommentEditorExtensionOptions = {}): AnyExtension[] => [ + StarterKit.configure({ + blockquote: { + HTMLAttributes: { + class: 'border-l-4 border-gray-200 pl-4 my-4 italic text-gray-700', + }, + }, + codeBlock: false, + // Bundled into StarterKit as of v3. Link/Underline are registered + // separately below; trailingNode and listKeymap were not part of this + // editor's v2 behavior. + link: false, + underline: false, + trailingNode: false, + listKeymap: false, + }), + Underline, + // Listed before Link so its paste handler intercepts standalone URLs + // and converts them to inline `richLink` nodes (with favicon + creator + // + title rendering and hover preview) instead of letting Link's paste + // rule wrap them as plain link marks. + RichLinkExtension, + CommentLink.configure({ + openOnClick: false, + HTMLAttributes: { + class: 'text-blue-600 hover:text-blue-800 cursor-pointer relative group', + }, + }), + Image.configure({ + HTMLAttributes: { + class: 'max-w-full rounded-lg', + }, + }), + ExitLinkOnSpace, + CodeBlockLowlight.configure({ + lowlight, + defaultLanguage: 'javascript', + languageClassPrefix: 'hljs language-', + HTMLAttributes: { + class: 'not-prose', + }, + }), + MentionExtension, + Placeholder.configure({ + placeholder, + emptyEditorClass: 'is-editor-empty', + }), + ...(isReview + ? [ + ReviewExtension.configure({ + rating, + onRatingChange, + }), + ] + : []), +]; diff --git a/components/Comment/lib/hooks/useCommentEditor.tsx b/components/Comment/lib/hooks/useCommentEditor.tsx index 6cd6c3d7a..fe3ba8b99 100644 --- a/components/Comment/lib/hooks/useCommentEditor.tsx +++ b/components/Comment/lib/hooks/useCommentEditor.tsx @@ -1,31 +1,12 @@ import { useEditor, Content, JSONContent } from '@tiptap/react'; -import { StarterKit } from '@tiptap/starter-kit'; -import { Underline } from '@tiptap/extension-underline'; -import { CommentLink } from '../extensions/CommentLink'; -import { Image } from '@tiptap/extension-image'; -import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; -import { Placeholder } from '@tiptap/extension-placeholder'; -import { createLowlight } from 'lowlight'; -import javascript from 'highlight.js/lib/languages/javascript'; -import typescript from 'highlight.js/lib/languages/typescript'; -import python from 'highlight.js/lib/languages/python'; import { useState, useEffect, useRef } from 'react'; -import { ExitLinkOnSpace } from '../extensions/ExitLinkOnSpace'; -import { RichLinkExtension } from '../extensions/RichLinkExtension'; -import { MentionExtension } from '../MentionExtension'; -import { ReviewExtension } from '../ReviewExtension'; +import { getCommentEditorExtensions } from '../commentEditorExtensions'; import { parseContent } from '../commentContentUtils'; import { normalizeRichLinks } from '../embedDoc'; import { CommentType } from '@/types/comment'; import { useCommentDraft } from '../useCommentDraft'; import { CommentContent } from '../types'; -// Initialize lowlight with supported languages -const lowlight = createLowlight(); -lowlight.register('javascript', javascript); -lowlight.register('typescript', typescript); -lowlight.register('python', python); - export interface UseCommentEditorProps { onUpdate?: (content: CommentContent) => void; onContentChange?: (plainText: string, html: string) => void; @@ -104,62 +85,12 @@ export const useCommentEditor = ({ }; const editor = useEditor({ - extensions: [ - StarterKit.configure({ - blockquote: { - HTMLAttributes: { - class: 'border-l-4 border-gray-200 pl-4 my-4 italic text-gray-700', - }, - }, - codeBlock: false, - // Bundled into StarterKit as of v3. Link/Underline are registered - // separately below; trailingNode and listKeymap were not part of this - // editor's v2 behavior. - link: false, - underline: false, - trailingNode: false, - listKeymap: false, - }), - Underline, - // Listed before Link so its paste handler intercepts standalone URLs - // and converts them to inline `richLink` nodes (with favicon + creator - // + title rendering and hover preview) instead of letting Link's paste - // rule wrap them as plain link marks. - RichLinkExtension, - CommentLink.configure({ - openOnClick: false, - HTMLAttributes: { - class: 'text-blue-600 hover:text-blue-800 cursor-pointer relative group', - }, - }), - Image.configure({ - HTMLAttributes: { - class: 'max-w-full rounded-lg', - }, - }), - ExitLinkOnSpace, - CodeBlockLowlight.configure({ - lowlight, - defaultLanguage: 'javascript', - languageClassPrefix: 'hljs language-', - HTMLAttributes: { - class: 'not-prose', - }, - }), - MentionExtension, - Placeholder.configure({ - placeholder, - emptyEditorClass: 'is-editor-empty', - }), - ...(isReview - ? [ - ReviewExtension.configure({ - rating, - onRatingChange: setRating, - }), - ] - : []), - ], + extensions: getCommentEditorExtensions({ + placeholder, + isReview, + rating, + onRatingChange: setRating, + }), content: getTipTapContent(), editable: !isReadOnly, editorProps: { diff --git a/package-lock.json b/package-lock.json index 0ffbeadcc..4d57bdf2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -136,6 +136,7 @@ "postcss": "^8.4.49", "prettier": "^3.4.2", "tailwindcss": "^3.4.15", + "tsx": "^4.23.12", "typescript": "^6.0.3" } }, @@ -1984,443 +1985,417 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -11508,13 +11483,11 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11522,32 +11495,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -12956,7 +12929,7 @@ "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -16862,7 +16835,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true, + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -18212,15 +18185,12 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", - "license": "MIT", - "optional": true, - "peer": true, + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "devOptional": true, "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" diff --git a/package.json b/package.json index b5cb94d14..70bd28c0c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint:fix": "eslint . --fix", "lint:work-pages": "eslint \"app/{paper,post,proposal,question,report}/[[]id[]]/[[]slug[]]/**/page.tsx\"", "format": "prettier --write --ignore-path .gitignore \"./**/*.{js,jsx,ts,tsx,json,css}\"", + "schema:export": "node --require ./scripts/stub-css-require.cjs --import tsx scripts/export-prosemirror-schema.ts", "type-check": "tsc --noEmit", "type-check:watch": "tsc --noEmit --watch", "prepare": "husky" @@ -143,6 +144,7 @@ "postcss": "^8.4.49", "prettier": "^3.4.2", "tailwindcss": "^3.4.15", + "tsx": "^4.23.12", "typescript": "^6.0.3" }, "lint-staged": { diff --git a/schemas/prosemirror/README.md b/schemas/prosemirror/README.md new file mode 100644 index 000000000..8bc62c780 --- /dev/null +++ b/schemas/prosemirror/README.md @@ -0,0 +1,71 @@ +# ProseMirror schemas + +JSON schema specs for the TipTap editors in this app, generated from the same +extension sets the frontend runs. They let backend code parse, validate, and +write editor documents against the exact schema the editors enforce — e.g. with +[prosemirror-py](https://github.com/fellowapp/prosemirror-py): + +```python +import json +from prosemirror.model import Node, Schema + +with open("comment-editor.json") as f: + schema = Schema(json.load(f)) + +doc = Node.from_json(schema, comment_json) # raises on unknown node/mark types +doc.check() # raises on invalid nesting/content +``` + +The validation boundary: unknown node/mark _types_, missing _required_ +attributes, and invalid nesting all raise. Unrecognized _attributes_ do not — +they are silently stripped during parsing, so parsed output only ever carries +schema-declared attributes. + +| File | Source extension set | Covers | +| --------------------- | ------------------------------------------------------------------------ | --------------------------- | +| `block-editor.json` | `components/Editor/extensions/extension-kit.ts` (+ `AiWriter`/`AiImage`) | Notebook notes, posts | +| `comment-editor.json` | `components/Comment/lib/commentEditorExtensions.ts` | Comments, incl. review mode | + +## Regenerating + +```bash +npm run schema:export +``` + +Output is deterministic — rerunning without extension changes produces +byte-identical files. **Any change to an editor's extensions (adding, +removing, or reconfiguring — configuration can alter attribute defaults) must +be accompanied by a regenerated schema**, and the backend copy updated. +Enforcement via CI diff is a planned follow-up; until then this is by +convention. + +## What the export contains + +Everything schema-relevant from each ProseMirror `NodeSpec`/`MarkSpec`: +content expressions, groups, attributes with defaults (including TipTap +global attributes like `textAlign` and UniqueID's `id`), `inline`/`atom`, +mark `excludes`, etc., in schema order (order affects content-fill defaults +and mark precedence). + +Intentional deviations: + +- **DOM-only fields are stripped** (`toDOM`, `parseDOM`, `leafText`, + `toDebugString`) — they're functions and only matter in the browser. HTML + conversion is therefore _not_ possible from these files; they are for + working with documents in JSON form. +- **`default: undefined` becomes `default: null`** (e.g. `imageBlock.alt`). + JSON can't distinguish "optional, serialized nodes omit the attr" from "no + default = required", and keeping the attribute optional is the semantics + that matter. +- **`comment-editor.json` is the review-mode superset** — it includes + `sectionHeader`, which only review comments may contain. A generic comment + containing one passes schema validation; rejecting that is app-level + validation, not schema. +- **`block-editor.json` includes `aiWriter`/`aiImage`** — registered only + when AI is enabled, but autosave can persist them mid-generation, so the + schema accepts them. +- **`doc` is the permissive `block+` variant.** The _editable_ notebook + editor swaps in a stricter document node (`content: "heading block+"`, see + `useBlockEditor.ts`); the export keeps the permissive form so every + persisted document validates. Backends writing notebook content should + still emit a leading heading. diff --git a/schemas/prosemirror/block-editor.json b/schemas/prosemirror/block-editor.json new file mode 100644 index 000000000..94b4ac375 --- /dev/null +++ b/schemas/prosemirror/block-editor.json @@ -0,0 +1,402 @@ +{ + "topNode": "doc", + "nodes": { + "paragraph": { + "content": "inline*", + "group": "block", + "attrs": { + "id": { + "default": null + }, + "class": { + "default": null + }, + "textAlign": { + "default": null + } + } + }, + "doc": { + "content": "(block|columns)+" + }, + "columns": { + "content": "column column", + "group": "columns", + "defining": true, + "isolating": true, + "attrs": { + "layout": { + "default": "two-column" + } + } + }, + "taskList": { + "content": "taskItem+", + "group": "block list" + }, + "taskItem": { + "content": "paragraph block*", + "defining": true, + "attrs": { + "checked": { + "default": false + } + } + }, + "column": { + "content": "block+", + "isolating": true, + "attrs": { + "position": { + "default": "" + } + } + }, + "heading": { + "content": "inline*", + "group": "block", + "defining": true, + "attrs": { + "id": { + "default": null + }, + "data-toc-id": { + "default": null + }, + "textAlign": { + "default": null + }, + "level": { + "default": 1 + } + } + }, + "horizontalRule": { + "group": "block" + }, + "bulletList": { + "content": "listItem+", + "group": "block list" + }, + "hardBreak": { + "group": "inline", + "inline": true, + "selectable": false, + "linebreakReplacement": true + }, + "listItem": { + "content": "paragraph block*", + "defining": true + }, + "orderedList": { + "content": "listItem+", + "group": "block list", + "attrs": { + "start": { + "default": 1 + }, + "type": { + "default": null + } + } + }, + "text": { + "group": "inline" + }, + "details": { + "allowGapCursor": false, + "content": "detailsSummary detailsContent", + "group": "block", + "defining": true, + "isolating": true, + "attrs": { + "open": { + "default": false + } + } + }, + "detailsContent": { + "content": "block+", + "selectable": false, + "defining": true + }, + "detailsSummary": { + "content": "text*", + "selectable": false, + "defining": true, + "isolating": true + }, + "codeBlock": { + "content": "text*", + "marks": "", + "group": "block", + "code": true, + "defining": true, + "attrs": { + "id": { + "default": null + }, + "language": { + "default": "javascript" + } + } + }, + "tableOfContentsNode": { + "group": "block", + "inline": false, + "atom": true, + "selectable": true, + "draggable": true + }, + "imageUpload": { + "group": "block", + "inline": false, + "selectable": true, + "draggable": true, + "defining": true, + "isolating": true + }, + "imageBlock": { + "group": "block", + "inline": false, + "draggable": true, + "defining": true, + "isolating": true, + "attrs": { + "src": { + "default": "" + }, + "width": { + "default": "100%" + }, + "align": { + "default": "center" + }, + "alt": { + "default": null + } + } + }, + "emoji": { + "group": "inline", + "inline": true, + "selectable": false, + "attrs": { + "name": { + "default": null + } + } + }, + "blockMath": { + "group": "block", + "atom": true, + "attrs": { + "latex": { + "default": "" + } + } + }, + "inlineMath": { + "group": "inline", + "inline": true, + "atom": true, + "attrs": { + "latex": { + "default": "" + } + } + }, + "table": { + "tableRole": "table", + "content": "tableRow+", + "group": "block", + "isolating": true, + "attrs": { + "id": { + "default": null + } + } + }, + "tableCell": { + "tableRole": "cell", + "content": "block+", + "isolating": true, + "attrs": { + "colspan": { + "default": 1 + }, + "rowspan": { + "default": 1 + }, + "colwidth": { + "default": null + }, + "style": { + "default": null + } + } + }, + "tableHeader": { + "tableRole": "header_cell", + "content": "block+", + "isolating": true, + "attrs": { + "colspan": { + "default": 1 + }, + "rowspan": { + "default": 1 + }, + "colwidth": { + "default": null + }, + "style": { + "default": null + } + } + }, + "tableRow": { + "allowGapCursor": false, + "tableRole": "row", + "content": "tableCell*" + }, + "figcaption": { + "content": "inline*", + "marks": "link", + "selectable": false, + "draggable": false + }, + "blockquoteFigure": { + "content": "quote quoteCaption", + "group": "block", + "selectable": true, + "draggable": true, + "defining": true, + "isolating": true + }, + "quote": { + "content": "paragraph+", + "marks": "", + "defining": true + }, + "quoteCaption": { + "content": "text*", + "group": "block", + "defining": true, + "isolating": true + }, + "youtube": { + "group": "block", + "inline": false, + "draggable": true, + "attrs": { + "src": { + "default": null + }, + "start": { + "default": 0 + }, + "width": { + "default": 640 + }, + "height": { + "default": 480 + } + } + }, + "figure": { + "content": "block*", + "group": "block", + "attrs": { + "originalContent": { + "default": "" + }, + "class": { + "default": "" + } + } + }, + "aiWriter": { + "group": "block", + "draggable": true, + "attrs": { + "id": { + "default": null + }, + "authorId": { + "default": null + }, + "authorName": { + "default": null + } + } + }, + "aiImage": { + "group": "block", + "draggable": true, + "attrs": { + "id": { + "default": null + }, + "authorId": { + "default": null + }, + "authorName": { + "default": null + } + } + } + }, + "marks": { + "link": { + "inclusive": false, + "attrs": { + "href": { + "default": null + }, + "target": { + "default": "_blank" + }, + "rel": { + "default": "noopener noreferrer nofollow" + }, + "class": { + "default": "link" + }, + "title": { + "default": null + } + } + }, + "textStyle": { + "attrs": { + "fontSize": { + "default": null + }, + "fontFamily": { + "default": null + }, + "color": { + "default": null + } + } + }, + "bold": {}, + "code": { + "excludes": "_", + "code": true + }, + "italic": {}, + "strike": {}, + "highlight": { + "attrs": { + "color": { + "default": null + } + } + }, + "underline": {}, + "subscript": {}, + "superscript": {} + } +} diff --git a/schemas/prosemirror/comment-editor.json b/schemas/prosemirror/comment-editor.json new file mode 100644 index 000000000..1366a60e9 --- /dev/null +++ b/schemas/prosemirror/comment-editor.json @@ -0,0 +1,202 @@ +{ + "topNode": "doc", + "nodes": { + "paragraph": { + "content": "inline*", + "group": "block" + }, + "mention": { + "group": "inline", + "inline": true, + "atom": true, + "selectable": false, + "attrs": { + "id": { + "default": null + }, + "label": { + "default": null + }, + "mentionSuggestionChar": { + "default": "@" + }, + "entityType": { + "default": null + }, + "displayName": { + "default": null + }, + "userId": { + "default": null + }, + "authorProfileId": { + "default": null + }, + "doi": { + "default": null + } + } + }, + "blockquote": { + "content": "block+", + "group": "block", + "defining": true + }, + "bulletList": { + "content": "listItem+", + "group": "block list" + }, + "doc": { + "content": "block+" + }, + "hardBreak": { + "group": "inline", + "inline": true, + "selectable": false, + "linebreakReplacement": true + }, + "heading": { + "content": "inline*", + "group": "block", + "defining": true, + "attrs": { + "level": { + "default": 1 + } + } + }, + "horizontalRule": { + "group": "block" + }, + "listItem": { + "content": "paragraph block*", + "defining": true + }, + "orderedList": { + "content": "listItem+", + "group": "block list", + "attrs": { + "start": { + "default": 1 + }, + "type": { + "default": null + } + } + }, + "text": { + "group": "inline" + }, + "richLink": { + "group": "inline", + "inline": true, + "atom": true, + "selectable": true, + "draggable": false, + "attrs": { + "url": { + "default": null + }, + "kind": { + "default": null + }, + "videoId": { + "default": null + }, + "tweetId": { + "default": null + }, + "linkedinUrn": { + "default": null + } + } + }, + "image": { + "group": "block", + "inline": false, + "draggable": true, + "attrs": { + "src": { + "default": null + }, + "alt": { + "default": null + }, + "title": { + "default": null + }, + "width": { + "default": null + }, + "height": { + "default": null + } + } + }, + "codeBlock": { + "content": "text*", + "marks": "", + "group": "block", + "code": true, + "defining": true, + "attrs": { + "language": { + "default": "javascript" + } + } + }, + "sectionHeader": { + "group": "block", + "atom": true, + "selectable": false, + "draggable": false, + "attrs": { + "sectionId": { + "default": null + }, + "title": { + "default": null + }, + "description": { + "default": null + }, + "rating": { + "default": 0 + } + } + } + }, + "marks": { + "link": { + "inclusive": true, + "attrs": { + "href": { + "default": null + }, + "target": { + "default": "_blank" + }, + "rel": { + "default": "noopener noreferrer nofollow" + }, + "class": { + "default": "text-blue-600 hover:text-blue-800 cursor-pointer relative group" + }, + "title": { + "default": null + }, + "noRichPreview": { + "default": false + } + } + }, + "bold": {}, + "code": { + "excludes": "_", + "code": true + }, + "italic": {}, + "strike": {}, + "underline": {} + } +} diff --git a/scripts/export-prosemirror-schema.ts b/scripts/export-prosemirror-schema.ts new file mode 100644 index 000000000..01c058dfb --- /dev/null +++ b/scripts/export-prosemirror-schema.ts @@ -0,0 +1,112 @@ +/** + * Exports the ProseMirror schemas implied by our TipTap extension sets as JSON + * specs the backend can load (e.g. with prosemirror-py's `Schema(spec)`), so + * server-side code can validate, read, and write editor documents against the + * exact same schema the frontend enforces. + * + * Run with: npm run schema:export + * + * One file is written per editor surface to schemas/prosemirror/: + * - block-editor.json components/Editor (notebook + posts) + * - comment-editor.json components/Comment (incl. review-mode nodes) + * + * Only the schema-relevant parts of each spec survive serialization: + * DOM-oriented fields (`toDOM`/`parseDOM`/`toDebugString`) are stripped, plus + * `leafText` (model-level, but a function and unset by our extensions), and + * JSON.stringify drops any other function-valued field (e.g. TipTap's + * `toText`) natively. Attribute `default`s must be JSON-serializable — the + * script fails loudly if one isn't, because silently dropping a default would + * turn an optional attribute into a required one. + * + * Output is deterministic (schema order, 2-space indent) so regenerating with + * no extension changes yields a byte-identical file. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { getSchema } from '@tiptap/core'; +import type { AttributeSpec, Schema } from '@tiptap/pm/model'; + +import { ExtensionKit } from '@/components/Editor/extensions/extension-kit'; +import { AiImage, AiWriter } from '@/components/Editor/extensions'; +import { getCommentEditorExtensions } from '@/components/Comment/lib/commentEditorExtensions'; + +const OUTPUT_DIR = path.join(__dirname, '..', 'schemas', 'prosemirror'); + +const STRIPPED_KEYS = new Set(['parseDOM', 'toDOM', 'toDebugString', 'leafText']); + +/** + * One NodeSpec/MarkSpec with stripped keys removed and attribute defaults made + * JSON-safe. In ProseMirror an attribute is optional iff its spec has a + * `default` property, so a default JSON.stringify would drop must fail loudly + * instead of silently making the attribute required. `default: undefined` + * (optional; the attr is simply omitted from serialized nodes, e.g. + * imageBlock.alt) becomes `default: null`, which JSON can represent. + */ +function sanitizeSpec(typeName: string, spec: object): Record { + const clean: Record = Object.fromEntries( + Object.entries(spec).filter(([key]) => !STRIPPED_KEYS.has(key)) + ); + + const attrs = clean.attrs as Record | undefined; + if (attrs) { + clean.attrs = Object.fromEntries( + Object.entries(attrs).map(([attrName, attrSpec]) => { + if (!('default' in attrSpec)) return [attrName, attrSpec]; + if (attrSpec.default === undefined) return [attrName, { ...attrSpec, default: null }]; + if (JSON.stringify(attrSpec.default) === undefined) { + throw new Error( + `Attribute default for "${typeName}.${attrName}" is not JSON-serializable ` + + `(${typeof attrSpec.default}); dropping it would make the attribute required.` + ); + } + return [attrName, attrSpec]; + }) + ); + } + + return clean; +} + +/** Serializes a schema to a plain-JSON spec, preserving schema order. */ +function schemaToJsonSpec(schema: Schema): Record { + const sanitizeAll = (specs: Record, kind: string) => + Object.fromEntries( + Object.entries(specs).map(([name, spec]) => [name, sanitizeSpec(`${kind}.${name}`, spec)]) + ); + return { + topNode: schema.topNodeType.name, + nodes: sanitizeAll(schema.spec.nodes.toObject(), 'nodes'), + marks: sanitizeAll(schema.spec.marks.toObject(), 'marks'), + }; +} + +/** Guards against values JSON.stringify would silently corrupt into null. */ +function replacer(key: string, value: unknown): unknown { + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error(`Non-finite number under key "${key}"`); + } + return value; +} + +const schemas = { + // The block editor registers AiWriter/AiImage only when AI is enabled, but a + // document autosaved mid-generation can contain their nodes, so the exported + // schema includes them. Note: the editable notebook editor swaps `doc` for a + // stricter `heading block+` variant (see useBlockEditor); this export keeps + // the permissive default so it accepts every document the app can persist. + 'block-editor': getSchema([...ExtensionKit({}), AiWriter, AiImage]), + // Review mode adds the sectionHeader node; exporting with it included makes + // the schema a superset covering both generic comments and reviews. + 'comment-editor': getSchema(getCommentEditorExtensions({ isReview: true })), +}; + +fs.mkdirSync(OUTPUT_DIR, { recursive: true }); +for (const [name, schema] of Object.entries(schemas)) { + const outPath = path.join(OUTPUT_DIR, `${name}.json`); + fs.writeFileSync(outPath, `${JSON.stringify(schemaToJsonSpec(schema), replacer, 2)}\n`); + const nodeCount = Object.keys(schema.nodes).length; + const markCount = Object.keys(schema.marks).length; + console.log( + `Wrote ${path.relative(process.cwd(), outPath)} (${nodeCount} nodes, ${markCount} marks)` + ); +} diff --git a/scripts/stub-css-require.cjs b/scripts/stub-css-require.cjs new file mode 100644 index 000000000..9062514a7 --- /dev/null +++ b/scripts/stub-css-require.cjs @@ -0,0 +1,12 @@ +/** + * Stubs out CSS imports (e.g. `tippy.js/dist/tippy.css` inside editor + * extensions) so application modules can be loaded by plain Node scripts, + * outside a bundler. tsx compiles this package's TypeScript to CommonJS, so + * CSS arrives via require(); without a handler Node falls back to compiling + * it as JavaScript and throws a SyntaxError. + * + * Usage: node --require ./scripts/stub-css-require.cjs --import tsx