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")}