Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ tmp/

# Generated static documents
/public/pricing.pdf
/public/partner.pdf

# Runtime-generated social feed
/public/social/
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ This repo is a Next.js app used to generate a static site. The final production
## Development (local only)
- Install deps: `npm install`
- Run dev server: `npm run dev` then open http://localhost:3000
> ⚠️ These steps are purely for local development; the production image ships only static assets and never contains a Node/npm runtime.
> ⚠️ These steps are for local development. The production image serves static files and does not include Node or npm.

## Build (production image)
- `docker build -t www-website .`
- Runtime is static files only; mount/serve with RO filesystem (e.g., `docker run --read-only --tmpfs /tmp --tmpfs /config --tmpfs /data -p 3000:3000 www-website`).
> ⚠️ The final container maps only the generated `out` directory and static assets; keep it read-only and do not run any Node/npm scripts there.
- The runtime serves static files. Use a read-only filesystem (e.g., `docker run --read-only --tmpfs /tmp --tmpfs /config --tmpfs /data -p 3000:3000 www-website`).
> ⚠️ The final container serves the generated `out` directory. Keep it read-only. Run Node/npm scripts in the build stage or locally.

## Repository size budget
- CI fails if the tracked tree exceeds `10 MiB`, `public/` exceeds `8 MiB`, any tracked blob exceeds `1 MiB`, or HEAD Git blob payloads exceed `15 MiB`.
Expand All @@ -33,6 +33,13 @@ This repo is a Next.js app used to generate a static site. The final production

## Deployment notes
- Published image: `ghcr.io/devsh-graphics-programming/www-website` (tags: `latest`, `master`, `sha-*`).
- Blog content is proxied separately; the main image does not include Node/npm or blog runtime.
- Blog content is proxied separately. The main image does not include Node/npm or the blog runtime.
- Security
- Enable GitHub's push protection/secret scanning (see https://docs.github.com/en/code-security/secret-scanning/introduction/about-push-protection) so that any accidental secret leaks are flagged before a push finishes.

## Generated PDFs

Run `npm run generate:pdfs` to rebuild `public/pricing.pdf` and `public/partner.pdf`.
This also runs with `npm run dev` and `npm run build`. Both PDFs stay out of Git.
Edit prices in `app/data/pricing-rates.json` and partner text in `app/data/partner-content.json`.
Both generators share the fonts, colors and footer in `scripts/lib/pdf-brand.mjs`.
1 change: 1 addition & 0 deletions app/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const quickLinks = [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: "Pricing", href: "/pricing.pdf" },
{ label: "Referral Partners", href: "/partner.pdf" },
{ label: "Nabla", href: "/nabla" },
{ label: "Research", href: "/research" },
{ label: "Blog", href: "/blog" },
Expand Down
1 change: 1 addition & 0 deletions app/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const links: Link[] = [
{ name: "When to Call", url: "/when-to-call-devsh" },
{ name: "Sponsorship", url: "/#sponsorship" },
{ name: "Pricing", url: "/pricing.pdf" },
{ name: "Referral Partners", url: "/partner.pdf" },
{ name: "Nabla", url: "/nabla" },
{ name: "Research", url: "/presentations" },
{ name: "Blog", url: "/blog" },
Expand Down
102 changes: 102 additions & 0 deletions app/data/partner-content.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
{
"blocks": [
{
"type": "title",
"text": "DevSH referral partner"
},
{
"type": "reward",
"text": "10% success reward"
},
{
"type": "subtitle",
"text": "We handle the execution, you earn the commission."
},
{
"type": "bullet",
"text": "\"Our renderer drops frames on large client datasets.\""
},
{
"type": "bullet",
"text": "\"We’re stuck on legacy code and can’t leverage modern GPUs.\""
},
{
"type": "bullet",
"text": "\"We can't find low-level Vulkan / C++ engineers to save our lives.\""
},
{
"type": "bullet",
"text": "\"Our FTEs vibecoded the codebase and now every change requires exponentially more tokens\""
},
{
"type": "paragraph",
"text": "If you sit in those conversations, you’re the partner we’re looking for."
},
{
"type": "heading",
"text": "How it works"
},
{
"type": "subheading",
"text": "Point us to a project"
},
{
"type": "paragraph",
"text": "Connect us with teams in CAD, gaming, simulation, visualization, or automotive requiring graphics dev, GPU computing, code audits, or corporate training."
},
{
"type": "subheading",
"text": "We run the deal"
},
{
"type": "paragraph",
"text": "DevSH leads the technical discussions, defines the scope of work, and finalizes the service contract. You may choose to stay involved or step back completely."
},
{
"type": "subheading",
"text": "Earn your fee"
},
{
"type": "paragraph",
"text": "Earn 10% on all payments we receive across a new client’s projects for 12 months after introduction. No minimum project value or commission cap. For corporate training, earn 10% for its full duration. We pay once client payments are received and no longer subject to offsets, contractual penalties or refunds. When referring your employer or client, tell them immediately about your 10% commission."
},
{
"type": "heading",
"text": "Who is our ideal partner?"
},
{
"type": "bullet",
"text": "Industry networks: Existing contacts among CTOs, VPs of engineering, product directors, and R&D leads in gaming, CAD, simulation, automotive, or graphics technology."
},
{
"type": "bullet",
"text": "Technical familiarity: Ability to recognize high-level technical pain points (such as GPU bottlenecks, rendering issues, or low-level performance needs) in casual or professional conversations."
},
{
"type": "bullet",
"text": "Professional trust: Established credibility with decision-makers to facilitate warm, effective introductions."
},
{
"type": "heading",
"text": "How we support you"
},
{
"type": "bullet",
"text": "Competitive 10% commission: Earn commission on payments we receive from clients you introduce."
},
{
"type": "bullet",
"text": "Zero operational burden: No cold calling, sales targets, quotas, CRM entry, or required travel."
},
{
"type": "bullet",
"text": "Complete flexibility: Work on your own schedule from anywhere, simply leveraging your existing network."
},
{
"type": "contact",
"text": "Ready to turn your network into revenue?",
"prompt": "Contact our team at",
"email": "partner@devsh.eu"
}
]
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
"private": true,
"scripts": {
"dev": "next dev -p 24631 -H 0.0.0.0",
"predev": "npm run generate:pdfs",
"generate:pricing-pdf": "node scripts/generate-pricing-pdf.mjs",
"generate:partner-pdf": "node scripts/generate-partner-pdf.mjs",
"generate:pdfs": "npm run generate:pricing-pdf && npm run generate:partner-pdf",
"sync:linkedin-feed": "node scripts/sync-linkedin-feed.mjs",
"prebuild": "npm run generate:pricing-pdf",
"prebuild": "npm run generate:pdfs",
"build": "next build",
"start": "next start -p 24631 -H 0.0.0.0",
"lint": "eslint ."
Expand Down
133 changes: 133 additions & 0 deletions scripts/generate-partner-pdf.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
rootDir, logoPath, mm, colors, createDocument, finishDocument, drawLine, drawFooter,
} from "./lib/pdf-brand.mjs";

const content = JSON.parse(await readFile(path.join(rootDir, "app/data/partner-content.json"), "utf8"));
const outputPath = path.join(rootDir, "public/partner.pdf");
const contact = content.blocks.find((block) => block.type === "contact");
const document = createDocument(content.blocks[0].text);
document.on("pageAdded", () => {
throw new Error("Partner PDF must fit on one A4 page");
});
const left = mm(22);
const width = document.page.width - left * 2;
const maxFooterTop = mm(257);
const sectionGap = 9;
const space = {
block: 3,
section: sectionGap,
ruleToHeading: 6,
headingToBody: 3,
step: 6,
stepToBody: 2,
titleToReward: 3,
rewardToSubtitle: 6.5,
intro: sectionGap * 2,
quote: 3.5,
quoteEnd: 1.5,
};
const bodyColor = "#e5eae7";
const rewardColor = "#92dfd2";
const bodySize = 9.6;
let y = mm(23);

const logoSize = mm(14);
const logoY = mm(8);
document.image(logoPath, (document.page.width - logoSize) / 2, logoY, { width: logoSize });

function checkBounds(text) {
if (y > maxFooterTop - space.section) {
throw new Error(`Partner content exceeds the A4 layout at ${y.toFixed(1)}pt: ${text.slice(0, 60)}`);
}
}

function drawText(text, {
size = bodySize, font = "FuturaBook", color = bodyColor,
after = space.block, indent = 0, align = "left", characterSpacing = 0,
} = {}) {
document.font(font).fontSize(size).fillColor(color);
const options = { width: width - indent, lineGap: 1.2, align, characterSpacing };
const height = document.heightOfString(text, options);
checkBounds(text);
document.text(text, left + indent, y, options);
y += height + after;
checkBounds(text);
}

function drawBullet(text, nextBlock) {
const indent = 10;
const colon = text.indexOf(":");
document.save().circle(left + 2, y + 5, 1).fill(colors.accent).restore();
if (colon < 0) {
const continues = nextBlock?.type === "bullet";
drawText(text, { indent, after: continues ? space.quote : space.quoteEnd });
if (!continues) y += space.block;
return;
}
const options = { width: width - indent, lineGap: 1.2 };
document.font("FuturaDemi").fontSize(bodySize).fillColor(colors.white)
.text(`${text.slice(0, colon + 1)} `, left + indent, y, { ...options, continued: true });
document.font("FuturaBook").fillColor(bodyColor)
.text(text.slice(colon + 1).trimStart(), { ...options, continued: false });
y = document.y + space.block;
checkBounds(text);
}

function drawSectionHeading(text) {
y += space.section;
drawLine(document, left, y, left + width, y, 0.32, 0.45);
y += space.ruleToHeading;
drawText(text, { font: "FuturaDemi", size: 13, color: colors.white, after: space.headingToBody });
}

for (const [index, block] of content.blocks.entries()) {
if (block.type === "title") {
const titleSize = 24;
document.font("FuturaDemi").fontSize(titleSize);
if (document.widthOfString(block.text) > width) {
throw new Error("Partner title exceeds the content width");
}
drawText(block.text, { font: "FuturaDemi", size: titleSize, color: colors.white, align: "center", after: space.titleToReward });
} else if (block.type === "reward") {
drawText(block.text, { font: "FuturaDemi", size: 24, color: rewardColor, align: "center", characterSpacing: 0.6, after: space.rewardToSubtitle });
} else if (block.type === "subtitle") {
drawText(block.text, { size: 11.5, color: bodyColor, align: "center", after: space.intro });
} else if (block.type === "heading") {
drawSectionHeading(block.text);
} else if (block.type === "subheading") {
if (content.blocks[index - 1]?.type !== "heading") y += space.step;
drawText(block.text, { font: "FuturaDemi", size: 10.2, color: colors.white, after: space.stepToBody });
} else if (block.type === "contact") {
drawSectionHeading(block.text);
const baseline = y + 10.5;
document.font("FuturaBook").fontSize(10.5).fillColor(bodyColor);
const emailX = left + document.widthOfString(block.prompt) + 5;
document.text(block.prompt, left, baseline, { width, baseline: "alphabetic", lineBreak: false });
document.font("FuturaMedium").fontSize(10.5).fillColor(colors.accent)
.text(block.email, emailX, baseline, {
width: left + width - emailX, baseline: "alphabetic", lineBreak: false, link: `mailto:${block.email}`,
});
y = baseline + 3;
checkBounds(block.email);
} else if (block.type === "bullet") {
drawBullet(block.text, content.blocks[index + 1]);
} else if (block.type === "paragraph") {
drawText(block.text);
} else {
throw new Error(`Unknown partner content block type: ${block.type}`);
}
}

const footerTop = y + space.section;
if (footerTop > maxFooterTop) throw new Error("Partner footer exceeds the A4 layout");
drawFooter(document, footerTop, contact.email, {
colors: { white: "#bdc9c5", muted: "#889692", accent: "#a6bdb5" },
lineOpacity: 0.28,
legalLineOpacity: 0.20,
});
await mkdir(path.dirname(outputPath), { recursive: true });
const pdf = await finishDocument(document);
await writeFile(outputPath, pdf);
console.log(`Generated ${path.relative(rootDir, outputPath)} (${pdf.length} bytes)`);
Loading
Loading