From c01732e0d2baecf9699f0e78ce472411a53d5b12 Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Tue, 18 Aug 2026 20:03:46 -0400 Subject: [PATCH 1/5] Export editor ProseMirror schemas for backend consumption Adds npm run schema:export, which derives ProseMirror schema JSON from the same TipTap extension sets the editors run and writes them to schemas/prosemirror/ (block editor + comment editor). The backend can load these with prosemirror-py to validate, read, and write editor documents against the exact schema the frontend enforces. The comment editor's extension list moves out of useCommentEditor into commentEditorExtensions.ts so the hook and the export script share one source of truth. --- .../Comment/lib/commentEditorExtensions.ts | 97 +++++ .../Comment/lib/hooks/useCommentEditor.tsx | 83 +--- package-lock.json | 305 +++++-------- package.json | 2 + schemas/prosemirror/README.md | 66 +++ schemas/prosemirror/block-editor.json | 402 ++++++++++++++++++ schemas/prosemirror/comment-editor.json | 202 +++++++++ scripts/export-prosemirror-schema.ts | 141 ++++++ scripts/stub-css-require.cjs | 12 + 9 files changed, 1045 insertions(+), 265 deletions(-) create mode 100644 components/Comment/lib/commentEditorExtensions.ts create mode 100644 schemas/prosemirror/README.md create mode 100644 schemas/prosemirror/block-editor.json create mode 100644 schemas/prosemirror/comment-editor.json create mode 100644 scripts/export-prosemirror-schema.ts create mode 100644 scripts/stub-css-require.cjs 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..c161c14e5 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" } }, @@ -1665,21 +1666,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/@coinbase/cdp-sdk/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@coinbase/cdp-sdk/node_modules/undici-types": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.28.0.tgz", @@ -1984,9 +1970,9 @@ } }, "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" ], @@ -1995,15 +1981,14 @@ "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" ], @@ -2012,15 +1997,14 @@ "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" ], @@ -2029,15 +2013,14 @@ "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" ], @@ -2046,15 +2029,14 @@ "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" ], @@ -2063,15 +2045,14 @@ "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" ], @@ -2080,15 +2061,14 @@ "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" ], @@ -2097,15 +2077,14 @@ "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" ], @@ -2114,15 +2093,14 @@ "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" ], @@ -2131,15 +2109,14 @@ "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" ], @@ -2148,15 +2125,14 @@ "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" ], @@ -2165,15 +2141,14 @@ "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" ], @@ -2182,15 +2157,14 @@ "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" ], @@ -2199,15 +2173,14 @@ "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" ], @@ -2216,15 +2189,14 @@ "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" ], @@ -2233,15 +2205,14 @@ "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" ], @@ -2250,15 +2221,14 @@ "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" ], @@ -2267,15 +2237,14 @@ "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" ], @@ -2284,15 +2253,14 @@ "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" ], @@ -2301,15 +2269,14 @@ "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" ], @@ -2318,15 +2285,14 @@ "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" ], @@ -2335,15 +2301,14 @@ "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" ], @@ -2352,15 +2317,14 @@ "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" ], @@ -2369,15 +2333,14 @@ "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" ], @@ -2386,15 +2349,14 @@ "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" ], @@ -2403,15 +2365,14 @@ "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" ], @@ -2420,7 +2381,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -9129,21 +9089,6 @@ "ws": "^7.5.1" } }, - "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", @@ -11508,13 +11453,12 @@ } }, "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 +11466,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 +12900,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" @@ -13966,21 +13910,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -16862,7 +16791,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 +18141,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "devOptional": true, "license": "MIT", - "optional": true, - "peer": 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..8d92aa79d --- /dev/null +++ b/schemas/prosemirror/README.md @@ -0,0 +1,66 @@ +# 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 nodes/marks/attrs +doc.check() # raises on invalid nesting/content +``` + +| 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..9c5f54313 --- /dev/null +++ b/scripts/export-prosemirror-schema.ts @@ -0,0 +1,141 @@ +/** + * 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 + * concerns (`toDOM`/`parseDOM`/`leafText`/`toDebugString`) are stripped, and + * any other function-valued field is dropped. 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 { 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'); + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +/** Spec fields that only make sense with a DOM; meaningless to the backend. */ +const DOM_ONLY_KEYS = new Set(['parseDOM', 'toDOM', 'toDebugString', 'leafText']); + +/** Deep-copies a spec value, dropping functions and undefined. */ +function sanitizeValue(value: unknown, atPath: string): JsonValue | undefined { + if (value === null) return null; + switch (typeof value) { + case 'string': + case 'boolean': + return value; + case 'number': + if (!Number.isFinite(value)) { + throw new Error(`Non-finite number at ${atPath}`); + } + return value; + case 'function': + case 'undefined': + return undefined; + case 'object': { + if (Array.isArray(value)) { + return value + .map((item, i) => sanitizeValue(item, `${atPath}[${i}]`)) + .filter((item): item is JsonValue => item !== undefined); + } + const out: { [key: string]: JsonValue } = {}; + for (const [key, item] of Object.entries(value as Record)) { + const sanitized = sanitizeValue(item, `${atPath}.${key}`); + if (sanitized !== undefined) out[key] = sanitized; + } + return out; + } + default: + throw new Error(`Cannot serialize ${typeof value} at ${atPath}`); + } +} + +/** + * Sanitizes one NodeSpec/MarkSpec. In ProseMirror, an attribute is optional + * iff its spec has a `default` property, so a `default` that can't be + * serialized (function/undefined) would silently become "required" — fail + * loudly instead so the extension gets fixed or special-cased consciously. + */ +function sanitizeSpec(typeName: string, spec: Record): JsonValue { + const clean = Object.fromEntries(Object.entries(spec).filter(([key]) => !DOM_ONLY_KEYS.has(key))); + + const attrs = clean.attrs as Record> | undefined; + if (attrs) { + clean.attrs = Object.fromEntries( + Object.entries(attrs).map(([attrName, attrSpec]) => { + if (!Object.prototype.hasOwnProperty.call(attrSpec, 'default')) { + return [attrName, attrSpec]; + } + if (attrSpec.default === undefined) { + // JSON can't distinguish `default: undefined` (optional; the attr is + // simply omitted from serialized nodes, e.g. imageBlock.alt) from no + // `default` at all (required). Map to null to keep the attr optional. + return [attrName, { ...attrSpec, default: null }]; + } + if (sanitizeValue(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 sanitizeValue(clean, typeName) ?? {}; +} + +/** Serializes a schema to a plain-JSON spec, preserving schema order. */ +function schemaToJsonSpec(schema: Schema): JsonValue { + const nodes: { [name: string]: JsonValue } = {}; + schema.spec.nodes.forEach((name: string, spec: Record) => { + nodes[name] = sanitizeSpec(`nodes.${name}`, spec); + }); + const marks: { [name: string]: JsonValue } = {}; + schema.spec.marks.forEach((name: string, spec: Record) => { + marks[name] = sanitizeSpec(`marks.${name}`, spec); + }); + return { topNode: schema.topNodeType.name, nodes, marks }; +} + +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), null, 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