updates to printer, added changelog entry, restored deploy script in CI

This commit is contained in:
Amruth Pillai
2026-01-24 16:58:14 +01:00
parent 333b5e50c4
commit 21aec46763
4 changed files with 119 additions and 91 deletions
+12 -1
View File
@@ -3,6 +3,7 @@ name: Build Docker Image
on:
workflow_dispatch:
push:
branches: [ main ]
tags: [ "v*.*.*" ]
concurrency:
@@ -204,4 +205,14 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
- name: Redeploy Stack
uses: appleboy/ssh-action@v1
with:
key: ${{ secrets.SSH_KEY }}
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
script: |
cd docker
./manage_stack.sh up reactive_resume
+10 -1
View File
@@ -4,7 +4,16 @@ description: "List of all notable changes and updates to Reactive Resume"
rss: true
---
<Update label="v5.0.0" description="26th January 2026">
<Update label="v5.0.1" description="24th January 2026">
- Updated translations from Crowdin.
- Added a Community Spotlight section to the documentation.
- Remove `-r require-metadata` from the Dockerfile as it was not needed.
- Fixed inconsistencies in the docker compose examples in the documentation.
- Fixed an issue with usernames not allowing hyphens in them.
- Fixed issues with the printer service, when using the `getResumeScreenshot` or `printResumeAsPDF` endpoints.
</Update>
<Update label="v5.0.0" description="22th January 2026">
This has been a major overhaul from the previous version of Reactive Resume. The app has been completely redesigned and rebuilt from scratch, to be more intuitive and user-friendly.
**Here are some of the key changes from the previous version:**
+95 -87
View File
@@ -1,3 +1,4 @@
import { ORPCError } from "@orpc/server";
import type { InferSelectModel } from "drizzle-orm";
import puppeteer, { type Browser, type ConnectOptions } from "puppeteer-core";
import type { schema } from "@/integrations/drizzle";
@@ -19,23 +20,16 @@ const pageDimensions = {
const SCREENSHOT_TTL = 1000 * 60 * 60; // 1 hour
let browser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
const endpoint = new URL(env.PRINTER_ENDPOINT);
const isWebSocket = endpoint.protocol.startsWith("ws");
const connectOptions: ConnectOptions = {
acceptInsecureCerts: true,
defaultViewport: pageDimensions.a4,
};
const connectOptions: ConnectOptions = { acceptInsecureCerts: true };
if (isWebSocket) connectOptions.browserWSEndpoint = env.PRINTER_ENDPOINT;
else connectOptions.browserURL = env.PRINTER_ENDPOINT;
if (browser?.connected) return browser;
browser = await puppeteer.connect(connectOptions);
return browser;
return puppeteer.connect(connectOptions);
}
export const printerService = {
@@ -110,83 +104,90 @@ export const printerService = {
marginY = Math.round(data.metadata.page.marginY / 0.75);
}
// Step 4: Connect to the browser and navigate to the printer route
const browser = await getBrowser();
let browser: Browser | null = null;
// Set locale cookie so the resume renders in the correct language
await browser.setCookie({ name: "locale", value: locale, domain });
try {
// Step 4: Connect to the browser and navigate to the printer route
browser = await getBrowser();
const page = await browser.newPage();
// Set locale cookie so the resume renders in the correct language
await browser.setCookie({ name: "locale", value: locale, domain });
// Wait for the page to fully load (network idle + custom loaded attribute)
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
const page = await browser.newPage();
// Step 5: Adjust the DOM for proper PDF pagination
// This runs in the browser context to modify CSS before PDF generation
await page.evaluate((marginY: number) => {
const root = document.documentElement;
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
// Wait for the page to fully load (network idle + custom loaded attribute)
await page.setViewport(pageDimensions[format]);
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
// The --page-height CSS variable controls the height of each resume page.
// We need to reduce it by the PDF margins so content fits within the printable area.
// Without this, content would overflow and create empty pages.
const containerHeight = container ? getComputedStyle(container).getPropertyValue("--page-height").trim() : null;
const rootHeight = getComputedStyle(root).getPropertyValue("--page-height").trim();
const currentHeight = containerHeight || rootHeight;
const heightValue = Number.parseFloat(currentHeight);
// Step 5: Adjust the DOM for proper PDF pagination
// This runs in the browser context to modify CSS before PDF generation
await page.evaluate((marginY: number) => {
const root = document.documentElement;
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
if (!Number.isNaN(heightValue)) {
// Subtract top + bottom margins from page height
const newHeight = `${heightValue - marginY}px`;
if (container) container.style.setProperty("--page-height", newHeight);
root.style.setProperty("--page-height", newHeight);
}
// The --page-height CSS variable controls the height of each resume page.
// We need to reduce it by the PDF margins so content fits within the printable area.
// Without this, content would overflow and create empty pages.
const containerHeight = container ? getComputedStyle(container).getPropertyValue("--page-height").trim() : null;
const rootHeight = getComputedStyle(root).getPropertyValue("--page-height").trim();
const currentHeight = containerHeight || rootHeight;
const heightValue = Number.parseFloat(currentHeight);
// Add page break CSS to each resume page element (identified by data-page-index attribute)
// This ensures each visual resume page starts a new PDF page
const pageElements = document.querySelectorAll("[data-page-index]");
if (!Number.isNaN(heightValue)) {
// Subtract top + bottom margins from page height
const newHeight = `${heightValue - marginY}px`;
if (container) container.style.setProperty("--page-height", newHeight);
root.style.setProperty("--page-height", newHeight);
}
for (const el of pageElements) {
const element = el as HTMLElement;
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
// Add page break CSS to each resume page element (identified by data-page-index attribute)
// This ensures each visual resume page starts a new PDF page
const pageElements = document.querySelectorAll("[data-page-index]");
// Force a page break before each page except the first
if (index > 0) element.style.breakBefore = "page";
for (const el of pageElements) {
const element = el as HTMLElement;
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
// Allow content within a page to break naturally if it overflows
// (e.g., if a single page has more content than fits on one PDF page)
element.style.breakInside = "auto";
}
}, marginY);
// Force a page break before each page except the first
if (index > 0) element.style.breakBefore = "page";
// Step 6: Generate the PDF with the specified dimensions and margins
const pdfBuffer = await page.pdf({
width: `${pageDimensions[format].width}px`,
height: `${pageDimensions[format].height}px`,
tagged: true, // Adds accessibility tags to the PDF
waitForFonts: true, // Ensures all fonts are loaded before rendering
printBackground: true, // Includes background colors and images
margin: {
top: marginY,
right: marginX,
// bottom: marginY,
left: marginX,
},
});
// Allow content within a page to break naturally if it overflows
// (e.g., if a single page has more content than fits on one PDF page)
element.style.breakInside = "auto";
}
}, marginY);
await page.close();
// Step 6: Generate the PDF with the specified dimensions and margins
const pdfBuffer = await page.pdf({
width: `${pageDimensions[format].width}px`,
height: `${pageDimensions[format].height}px`,
tagged: true, // Adds accessibility tags to the PDF
waitForFonts: true, // Ensures all fonts are loaded before rendering
printBackground: true, // Includes background colors and images
margin: {
top: marginY,
right: marginX,
// bottom: marginY,
left: marginX,
},
});
// Step 7: Upload the generated PDF to storage
const result = await uploadFile({
userId,
resumeId: id,
data: new Uint8Array(pdfBuffer),
contentType: "application/pdf",
type: "pdf",
});
// Step 7: Upload the generated PDF to storage
const result = await uploadFile({
userId,
resumeId: id,
data: new Uint8Array(pdfBuffer),
contentType: "application/pdf",
type: "pdf",
});
return result.url;
return result.url;
} catch (error) {
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
} finally {
if (browser) await browser.close();
}
},
getResumeScreenshot: async (
@@ -228,27 +229,34 @@ export const printerService = {
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
const browser = await getBrowser();
let browser: Browser | null = null;
await browser.setCookie({ name: "locale", value: locale, domain });
try {
browser = await getBrowser();
const page = await browser.newPage();
await browser.setCookie({ name: "locale", value: locale, domain });
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
const page = await browser.newPage();
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
await page.setViewport(pageDimensions.a4);
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
await page.close();
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
const result = await uploadFile({
userId,
resumeId: id,
data: new Uint8Array(screenshotBuffer),
contentType: "image/webp",
type: "screenshot",
});
const result = await uploadFile({
userId,
resumeId: id,
data: new Uint8Array(screenshotBuffer),
contentType: "image/webp",
type: "screenshot",
});
return result.url;
return result.url;
} catch (error) {
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
} finally {
if (browser) await browser.close();
}
},
};
+2 -2
View File
@@ -27,8 +27,8 @@ export function Hero() {
muted
autoPlay
playsInline
// @ts-expect-error - typescript doesn't know about fetchpriority for video elements
fetchpriority="high"
// @ts-expect-error - typescript doesn't know about fetchPriority for video elements
fetchPriority="high"
src="/videos/timelapse.webm"
aria-label={t`Timelapse demonstration of building a resume with Reactive Resume`}
className="pointer-events-none aspect-video size-full rounded-lg border object-cover shadow-2xl"