diff --git a/apps/blog-platform/package.json b/apps/blog-platform/package.json index b1b4c19..9e1cfd1 100644 --- a/apps/blog-platform/package.json +++ b/apps/blog-platform/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/blog-platform", - "version": "3.10.0", + "version": "3.10.2", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/docs-platform/package.json b/apps/docs-platform/package.json index 28dbf6a..c310c39 100644 --- a/apps/docs-platform/package.json +++ b/apps/docs-platform/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/docs-platform", - "version": "3.10.0", + "version": "3.10.2", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/server/package.json b/apps/server/package.json index c2b1811..b4f33f6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/server", - "version": "3.10.0", + "version": "3.10.2", "description": "VeriWorkly Resume Backend API", "main": "dist/index.js", "type": "module", diff --git a/apps/site/package.json b/apps/site/package.json index 94f88b3..f3bb0aa 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/site", - "version": "3.10.0", + "version": "3.10.2", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/studio/components/dashboard/StudioShell.tsx b/apps/studio/components/dashboard/StudioShell.tsx index 123ac6c..ca4bd60 100644 --- a/apps/studio/components/dashboard/StudioShell.tsx +++ b/apps/studio/components/dashboard/StudioShell.tsx @@ -35,7 +35,7 @@ interface StudioShellProps { mainClassName?: string; } -const STUDIO_VERSION = "v3.10.0"; +const STUDIO_VERSION = "v3.10.2"; const StudioShell = ({ children, mainClassName }: StudioShellProps) => { const router = useRouter(); diff --git a/apps/studio/features/documents/rendering/resume-rendering.ts b/apps/studio/features/documents/rendering/resume-rendering.ts index f2cd9f4..80e0348 100644 --- a/apps/studio/features/documents/rendering/resume-rendering.ts +++ b/apps/studio/features/documents/rendering/resume-rendering.ts @@ -38,6 +38,24 @@ export interface RenderContactItem { href?: string; } +export interface ResumeRenderModel { + style: ResumeRenderStyle; + contactItems: RenderContactItem[]; + renderedLinks: ResumeLinkItem[]; + showBasics: boolean; + showLinks: boolean; + showSummary: boolean; + showExperience: boolean; + showEducation: boolean; + showProjects: boolean; + showSkills: boolean; + visibleExperience: ResumeData["experience"]; + visibleEducation: ResumeData["education"]; + visibleProjects: ResumeData["projects"]; + visibleSkills: ResumeData["skills"]; + visibleCustomSections: ResumeData["customSections"]; +} + export function cleanResumeText(value: string | null | undefined): string { return stripEmoji(safeText(value ?? "")).replace(/\s+/g, " "); } @@ -268,3 +286,28 @@ export function hasResumeSectionContent(resume: ResumeData, sectionId: ResumeSec .some((section) => hasCustomSectionContent(section)); } } + +export function getResumeRenderModel(resume: ResumeData): ResumeRenderModel { + const style = getResumeRenderStyle(resume); + + return { + style, + contactItems: getContactItems(resume.basics), + renderedLinks: resume.links.items.filter((link) => normalizeLinkHref(link.url)), + showBasics: hasResumeSectionContent(resume, "basics"), + showLinks: hasResumeSectionContent(resume, "links"), + showSummary: hasResumeSectionContent(resume, "summary"), + showExperience: hasResumeSectionContent(resume, "experience"), + showEducation: hasResumeSectionContent(resume, "education"), + showProjects: hasResumeSectionContent(resume, "projects"), + showSkills: hasResumeSectionContent(resume, "skills"), + visibleExperience: resume.experience.filter(hasExperienceContent), + visibleEducation: resume.education.filter(hasEducationContent), + visibleProjects: resume.projects.filter(hasProjectContent), + visibleSkills: resume.skills.filter(hasSkillGroupContent), + visibleCustomSections: resume.customSections.filter( + (section) => + hasResumeSectionContent(resume, section.kind) && hasCustomSectionContent(section), + ), + }; +} diff --git a/apps/studio/features/resume/editor/ResumePagedPreview.tsx b/apps/studio/features/resume/editor/ResumePagedPreview.tsx index 34a6cb4..6bf5e5f 100644 --- a/apps/studio/features/resume/editor/ResumePagedPreview.tsx +++ b/apps/studio/features/resume/editor/ResumePagedPreview.tsx @@ -61,11 +61,10 @@ export function ResumePagedPreview({ children }: { children: ReactNode }) { useLayoutEffect(() => { const frame = window.requestAnimationFrame(() => { - const container = measureRef.current?.querySelector( - "#resume-container", - ) as HTMLElement | null; + const measureRoot = measureRef.current; + const container = measureRoot?.querySelector("#resume-container") as HTMLElement | null; - if (!container) { + if (!measureRoot || !container) { setPages([]); return; } @@ -91,7 +90,7 @@ export function ResumePagedPreview({ children }: { children: ReactNode }) { width: `${RESUME_PAGE_WIDTH_PX}px`, }); - measureRef.current.appendChild(probe); + measureRoot.appendChild(probe); const fitsPage = (elements: HTMLElement[]) => { probe.innerHTML = elements.map((element) => element.outerHTML).join(""); diff --git a/apps/studio/package.json b/apps/studio/package.json index c813ed6..3f987d9 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/studio", - "version": "3.10.0", + "version": "3.10.2", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/studio/templates/cover-letter/professional/pdf.tsx b/apps/studio/templates/cover-letter/professional/pdf.tsx index 0dbb8a3..3f0a92c 100644 --- a/apps/studio/templates/cover-letter/professional/pdf.tsx +++ b/apps/studio/templates/cover-letter/professional/pdf.tsx @@ -4,14 +4,15 @@ import type { CoverLetterContent } from "@/features/cover-letter/types"; import { FONT_REGISTRY, normalizeFontFamilyId } from "@/features/documents/constants/fonts"; import { + buildCoverLetterFlowContent, + buildProfessionalFlowItems, + getCoverLetterFlowSenderName, pt, PX_TO_PT, - splitParagraphs, - splitMarkdownLines, getCoverLetterLinks, - splitRichTextBlocks, getCoverLetterLinkDisplayMode, isCoverLetterSectionVisible, + type ProfessionalFlowItem, } from "../shared"; import { @@ -152,7 +153,6 @@ export function ProfessionalCoverLetterPdf({ content }: { content: CoverLetterCo const showProfile = isCoverLetterSectionVisible(content, "profile"); const showLinks = isCoverLetterSectionVisible(content, "links"); const showTarget = isCoverLetterSectionVisible(content, "target"); - const showLetter = isCoverLetterSectionVisible(content, "letter"); const font = FONT_REGISTRY[normalizeFontFamilyId(appearance.fontFamily)]; const bodyLineHeight = appearance.lineHeight; @@ -169,7 +169,7 @@ export function ProfessionalCoverLetterPdf({ content }: { content: CoverLetterCo marginBottom: appearance.paragraphSpacing * PX_TO_PT, }; - const senderName = content.senderName || content.signature || "Your Name"; + const senderName = getCoverLetterFlowSenderName(content); const contact = showProfile ? [ @@ -192,14 +192,76 @@ export function ProfessionalCoverLetterPdf({ content }: { content: CoverLetterCo ].filter(Boolean) : []; - const bodyBlocks = showLetter - ? [ - ...splitParagraphs(content.opening).map((text) => ({ type: "paragraph" as const, text })), - ...splitRichTextBlocks(content.body), - ] - : []; + const flowItems = buildProfessionalFlowItems(buildCoverLetterFlowContent(content), senderName); + + function renderFlowItem(item: ProfessionalFlowItem) { + if (item.type === "greeting") { + return ( + + {item.text} + + ); + } + + if (item.type === "paragraph") { + return ( + + {item.text} + + ); + } + + if (item.type === "body-list" || item.type === "proof-list") { + return ( + + {item.items.map((listItem, index) => ( + + + + + + - const highlights = showLetter ? splitMarkdownLines(content.highlights) : []; + {listItem} + + ))} + + ); + } + + if (item.type === "closing") { + return ( + + {item.text} + + ); + } + + if (item.type === "signature") { + return ( + + {item.text} + + ); + } + + return ( + + P.S. {item.text} + + ); + } return ( @@ -270,70 +332,7 @@ export function ProfessionalCoverLetterPdf({ content }: { content: CoverLetterCo ) : null} - {showLetter && content.greeting ? ( - - {content.greeting} - - ) : null} - - {bodyBlocks.map((block, index) => - block.type === "list" ? ( - - {block.items.map((item, i) => ( - - - - - - - - {item} - - ))} - - ) : ( - - {block.text} - - ), - )} - - {highlights.length > 0 ? ( - - {highlights.map((highlight, i) => ( - - - - - - - - {highlight} - - ))} - - ) : null} - - {showLetter && content.closing ? ( - {content.closing} - ) : null} - - {showLetter ? ( - {content.signature || senderName} - ) : null} - - {showLetter && content.postscript ? ( - P.S. {content.postscript} - ) : null} + {flowItems.map((item) => renderFlowItem(item))} diff --git a/apps/studio/templates/cover-letter/professional/web.tsx b/apps/studio/templates/cover-letter/professional/web.tsx index 960dd83..82cc3c4 100644 --- a/apps/studio/templates/cover-letter/professional/web.tsx +++ b/apps/studio/templates/cover-letter/professional/web.tsx @@ -2,15 +2,19 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react"; -import type { RichTextBlock } from "../shared"; import type { CoverLetterContent } from "@/features/cover-letter/types"; import { + buildCoverLetterFlowContent, + buildProfessionalFlowItems, getCoverLetterState, + getFlowPageKey, + getProfessionalFlowItemWeight, + getCoverLetterFlowSenderName, isCoverLetterSectionVisible, - splitMarkdownLines, - splitParagraphs, - splitRichTextBlocks, + paginateMeasuredItems, + paginateWeightedItems, + type ProfessionalFlowItem, } from "../shared"; import { @@ -23,83 +27,6 @@ import { escapeHtml } from "@/features/resume/services/resume-formatters"; import { SOCIAL_ICON_SRC_BY_TYPE } from "@/templates/shared/social-icons"; const PAGE_HEIGHT = 1123; -const PARAGRAPH_CHUNK_WORDS = 70; - -type ProfessionalFlowItem = - | { id: string; type: "greeting"; text: string } - | { id: string; type: "paragraph"; text: string } - | { id: string; type: "body-list"; items: string[] } - | { id: string; type: "proof-list"; items: string[] } - | { id: string; type: "closing"; text: string } - | { id: string; type: "signature"; text: string } - | { id: string; type: "postscript"; text: string }; - -type CoverLetterFlowContent = Pick< - CoverLetterContent, - "body" | "closing" | "greeting" | "highlights" | "opening" | "postscript" | "signature" ->; - -function splitTextIntoChunks(text: string, wordsPerChunk = PARAGRAPH_CHUNK_WORDS) { - const words = text.trim().split(/\s+/).filter(Boolean); - - if (words.length <= wordsPerChunk) return [text]; - - const chunks: string[] = []; - - for (let index = 0; index < words.length; index += wordsPerChunk) { - chunks.push(words.slice(index, index + wordsPerChunk).join(" ")); - } - - return chunks; -} - -function buildProfessionalFlowItems(content: CoverLetterFlowContent, senderName: string) { - const items: ProfessionalFlowItem[] = []; - - if (content.greeting) items.push({ id: "greeting", type: "greeting", text: content.greeting }); - - const bodyBlocks: RichTextBlock[] = [ - ...splitParagraphs(content.opening).map((text) => ({ type: "paragraph" as const, text })), - ...splitRichTextBlocks(content.body), - ]; - - bodyBlocks.forEach((block, blockIndex) => { - if (block.type === "paragraph") { - splitTextIntoChunks(block.text).forEach((text, chunkIndex) => { - items.push({ - id: `body-${blockIndex}-${chunkIndex}`, - type: "paragraph", - text, - }); - }); - return; - } - - items.push({ - id: `body-list-${blockIndex}`, - type: "body-list", - items: block.items, - }); - }); - - const highlights = splitMarkdownLines(content.highlights); - - if (highlights.length > 0) { - items.push({ id: "proof-list", type: "proof-list", items: highlights }); - } - - if (content.closing) items.push({ id: "closing", type: "closing", text: content.closing }); - - items.push({ id: "signature", type: "signature", text: content.signature || senderName }); - - if (content.postscript) { - splitTextIntoChunks(content.postscript, 55).forEach((text, index) => { - items.push({ id: `postscript-${index}`, type: "postscript", text }); - }); - } - - return items; -} function renderFlowItem(item: ProfessionalFlowItem, accentColor: string) { if (item.type === "greeting") return

{item.text}

; @@ -164,31 +91,6 @@ function renderGroupedFlowItems(items: ProfessionalFlowItem[], accentColor: stri return nodes; } -function paginateMeasuredItems( - items: T[], - fitsPage: (items: T[], pageIndex: number) => boolean, -) { - const pages: T[][] = [[]]; - let pageIndex = 0; - - for (const item of items) { - const candidate = [...pages[pageIndex], item]; - - if (pages[pageIndex].length > 0 && !fitsPage(candidate, pageIndex)) { - pages.push([]); - pageIndex += 1; - } - - pages[pageIndex].push(item); - } - - return pages.filter((page) => page.length > 0); -} - -function getProfessionalPageKey(pages: ProfessionalFlowItem[][]) { - return pages.map((page) => page.map((item) => JSON.stringify(item)).join(",")).join("|"); -} - function fitsInsideBottomPadding(container: HTMLElement, content: HTMLElement) { const containerStyle = window.getComputedStyle(container); const paddingBottom = Number.parseFloat(containerStyle.paddingBottom) || 0; @@ -198,37 +100,10 @@ function fitsInsideBottomPadding(container: HTMLElement, content: HTMLElement) { return contentBottom <= containerBottom + 1; } -function getProfessionalHtmlItemWeight(item: ProfessionalFlowItem) { - if (item.type === "body-list" || item.type === "proof-list") { - return 2 + item.items.reduce((total, listItem) => total + Math.ceil(listItem.length / 78), 0); - } - - if (item.type === "postscript") return 2 + Math.ceil(item.text.length / 110); - if (item.type === "closing" || item.type === "signature" || item.type === "greeting") return 1; - - return Math.max(1, Math.ceil(item.text.length / 92)); -} - function paginateProfessionalHtmlItems(items: ProfessionalFlowItem[]) { - const pages: ProfessionalFlowItem[][] = [[]]; - let pageIndex = 0; - let used = 0; - - for (const item of items) { - const limit = pageIndex === 0 ? 17 : 26; - const weight = getProfessionalHtmlItemWeight(item); - - if (pages[pageIndex].length > 0 && used + weight > limit) { - pages.push([]); - pageIndex += 1; - used = 0; - } - - pages[pageIndex].push(item); - used += weight; - } - - return pages.filter((page) => page.length > 0); + return paginateWeightedItems(items, getProfessionalFlowItemWeight, (pageIndex) => + pageIndex === 0 ? 17 : 26, + ); } function renderProfessionalHtmlItem(item: ProfessionalFlowItem) { @@ -258,30 +133,9 @@ export function ProfessionalCoverLetterPreview({ content }: { content: CoverLett recipient, } = state; const fontFamily = FONT_FAMILY_MAP[appearance.fontFamily]; - const flowSenderName = content.senderName || content.signature || "Your Name"; + const flowSenderName = getCoverLetterFlowSenderName(content); const showTarget = isCoverLetterSectionVisible(content, "target"); - const showLetter = isCoverLetterSectionVisible(content, "letter"); - const flowContent = useMemo( - () => ({ - body: showLetter ? content.body : "", - closing: showLetter ? content.closing : "", - greeting: showLetter ? content.greeting : "", - highlights: showLetter ? content.highlights : "", - opening: showLetter ? content.opening : "", - postscript: showLetter ? content.postscript : "", - signature: showLetter ? content.signature : "", - }), - [ - content.body, - content.closing, - content.greeting, - content.highlights, - content.opening, - content.postscript, - content.signature, - showLetter, - ], - ); + const flowContent = useMemo(() => buildCoverLetterFlowContent(content), [content]); const flowItems = useMemo( () => buildProfessionalFlowItems(flowContent, flowSenderName), [flowContent, flowSenderName], @@ -328,10 +182,10 @@ export function ProfessionalCoverLetterPreview({ content }: { content: CoverLett const nextPages = paginateMeasuredItems(flowItems, fitsPage); probe.remove(); - const nextKey = getProfessionalPageKey(nextPages); + const nextKey = getFlowPageKey(nextPages); setPages((current) => { - const currentKey = getProfessionalPageKey(current); + const currentKey = getFlowPageKey(current); return currentKey === nextKey ? current : nextPages; }); }); @@ -571,24 +425,12 @@ export function buildProfessionalCoverLetterHtml(content: CoverLetterContent): s recipient, } = state; const showTarget = isCoverLetterSectionVisible(content, "target"); - const showLetter = isCoverLetterSectionVisible(content, "letter"); const subject = escapeHtml( showTarget ? content.subject || content.jobTitle || "Application" : "", ); const fontFamily = FONT_FAMILY_MAP[appearance.fontFamily]; const fontHref = getFontStylesheetHref(appearance.fontFamily); - const flowItems = buildProfessionalFlowItems( - { - body: showLetter ? content.body : "", - closing: showLetter ? content.closing : "", - greeting: showLetter ? content.greeting : "", - highlights: showLetter ? content.highlights : "", - opening: showLetter ? content.opening : "", - postscript: showLetter ? content.postscript : "", - signature: showLetter ? content.signature : "", - }, - senderName, - ); + const flowItems = buildProfessionalFlowItems(buildCoverLetterFlowContent(content), senderName); const pages = paginateProfessionalHtmlItems(flowItems); return `${escapeHtml(content.senderName || "Cover Letter")}