From 3468cf7a432a56ffe18a3052ef291d73e67871fb Mon Sep 17 00:00:00 2001 From: geulah Date: Sun, 5 Oct 2025 23:56:09 +0100 Subject: [PATCH] feat(editor): add placeholder and JSON support to summary editor - Add @tiptap/extension-placeholder dependency - Implement placeholder text in summary editor - Add JSON persistence for summary content - Enhance HTML sanitization to support safe links --- cv-engine/package-lock.json | 14 ++++++++++++++ cv-engine/package.json | 1 + cv-engine/src/editors/SummaryEditor.tsx | 17 +++++++++++++++-- cv-engine/src/schema/cvSchema.ts | 23 +++++++++++++++++++++-- cv-engine/src/store/cvStore.ts | 9 +++++++++ 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/cv-engine/package-lock.json b/cv-engine/package-lock.json index 8484b8d..a4f58bc 100644 --- a/cv-engine/package-lock.json +++ b/cv-engine/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@tanstack/react-query": "^5.90.2", + "@tiptap/extension-placeholder": "^3.6.5", "@tiptap/react": "^3.6.5", "@tiptap/starter-kit": "^3.6.5", "autoprefixer": "^10.4.21", @@ -2022,6 +2023,19 @@ "@tiptap/core": "^3.6.5" } }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.6.5.tgz", + "integrity": "sha512-9CLixogEb/4UkEyuDr4JdOlLvphcOVfZMdNMKmUVQdqo4MuZCdTDyK5ypfTPQJl8aUo0oCiEhqE0bQerYlueJQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.6.5" + } + }, "node_modules/@tiptap/extension-strike": { "version": "3.6.5", "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.6.5.tgz", diff --git a/cv-engine/package.json b/cv-engine/package.json index 71f0e20..833ae1f 100644 --- a/cv-engine/package.json +++ b/cv-engine/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.90.2", + "@tiptap/extension-placeholder": "^3.6.5", "@tiptap/react": "^3.6.5", "@tiptap/starter-kit": "^3.6.5", "autoprefixer": "^10.4.21", diff --git a/cv-engine/src/editors/SummaryEditor.tsx b/cv-engine/src/editors/SummaryEditor.tsx index 6150fc9..09b74e0 100644 --- a/cv-engine/src/editors/SummaryEditor.tsx +++ b/cv-engine/src/editors/SummaryEditor.tsx @@ -2,15 +2,22 @@ import React, { useEffect, useMemo } from 'react'; import { useSummaryData, useCvStore } from '../store/cvStore'; import { EditorContent, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; +import Placeholder from '@tiptap/extension-placeholder'; interface SummaryEditorProps {} const SummaryEditor: React.FC = () => { const summary = useSummaryData(); - const { updateSummary } = useCvStore(); + const { updateSummary, updateSummaryJson } = useCvStore(); const editor = useEditor({ - extensions: [StarterKit], + extensions: [ + StarterKit, + Placeholder.configure({ + placeholder: 'Briefly summarize your experience…', + includeChildren: true, + }), + ], content: summary || '', editorProps: { attributes: { @@ -20,6 +27,12 @@ const SummaryEditor: React.FC = () => { onUpdate: ({ editor }) => { const html = editor.getHTML(); updateSummary(html); + try { + const json = editor.getJSON(); + updateSummaryJson(json); + } catch { + // ignore JSON persistence errors silently + } }, }); diff --git a/cv-engine/src/schema/cvSchema.ts b/cv-engine/src/schema/cvSchema.ts index bfa094a..2646373 100644 --- a/cv-engine/src/schema/cvSchema.ts +++ b/cv-engine/src/schema/cvSchema.ts @@ -1,6 +1,21 @@ import { z } from 'zod'; import DOMPurify from 'dompurify'; +// Initialize link-safety hook: force rel/target and restrict href schemes +// Note: Hooks may be registered multiple times during HMR; this hook is idempotent. +DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node && node.nodeName && node.nodeName.toLowerCase() === 'a') { + const href = node.getAttribute('href') || ''; + const isSafe = /^(https?:\/\/|mailto:)/i.test(href); + if (!isSafe) { + node.removeAttribute('href'); + } + // Enforce safe link attributes + node.setAttribute('rel', 'noopener noreferrer'); + node.setAttribute('target', '_blank'); + } +}); + // Helper function to generate unique IDs export const generateId = () => Math.random().toString(36).substring(2, 9); @@ -69,6 +84,8 @@ export const CvSchema = z.object({ date: z.string().optional() }) ).optional().default([]), + // Optional ProseMirror JSON for summary rich content persistence + summaryJson: z.any().optional(), templateId: z.string().default('ats'), }); @@ -95,6 +112,7 @@ export const createEmptyCV = (): CV => ({ skills: [], languages: [], certifications: [], + summaryJson: undefined, templateId: "ats", }); @@ -112,8 +130,9 @@ export const validateSection = (section: K, data: CV[K]) => export const sanitizeHtml = (html: string): string => { // Sanitize summary while preserving basic formatting elements return DOMPurify.sanitize(html, { - ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br'], - ALLOWED_ATTR: [] + ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'], + ALLOWED_ATTR: ['href', 'rel', 'target'], + ALLOW_UNKNOWN_PROTOCOLS: false }); }; diff --git a/cv-engine/src/store/cvStore.ts b/cv-engine/src/store/cvStore.ts index aa3243d..1fa8771 100644 --- a/cv-engine/src/store/cvStore.ts +++ b/cv-engine/src/store/cvStore.ts @@ -22,6 +22,7 @@ interface CvState { updateEducation: (education: z.infer['education']) => void; updateSkills: (skills: z.infer['skills']) => void; updateSummary: (summary: string) => void; + updateSummaryJson: (json: unknown) => void; updateTemplateId: (templateId: string) => void; // Work item operations @@ -110,6 +111,13 @@ export const useCvStore = create((set, get) => ({ isDirty: true })); }, + + updateSummaryJson: (json) => { + set((state) => ({ + cv: { ...state.cv, summaryJson: json }, + isDirty: true + })); + }, updateTemplateId: (templateId) => { set((state) => ({ @@ -354,6 +362,7 @@ export const useWorkData = () => useCvStore(state => state.cv.work); export const useEducationData = () => useCvStore(state => state.cv.education); export const useSkillsData = () => useCvStore(state => state.cv.skills); export const useSummaryData = () => useCvStore(state => state.cv.summary); +export const useSummaryJson = () => useCvStore(state => state.cv.summaryJson); export const useTemplateId = () => useCvStore(state => state.cv.templateId); export const useActiveStep = () => useCvStore(state => state.activeStep); export const useIsDirty = () => useCvStore(state => state.isDirty);