@kolkrabbi/kol-resume 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/bin/export-kit.sh +208 -0
- package/bin/render-pdf.mjs +90 -0
- package/package.json +59 -3
- package/src/components/ContactLine.jsx +34 -0
- package/src/components/ExportButtons.jsx +41 -0
- package/src/components/KitOptions.jsx +98 -0
- package/src/components/LangToggle.jsx +27 -0
- package/src/data.jsx +60 -0
- package/src/index.js +47 -0
- package/src/kol-resume.css +12 -0
- package/src/lib/exportDownload.js +41 -0
- package/src/lib/i18n.js +14 -0
- package/src/lib/kitOptions.js +39 -0
- package/src/lib/kitSections.jsx +18 -0
- package/src/lib/letters.js +38 -0
- package/src/lib/portfolio.js +41 -0
- package/src/lib/resume.js +32 -0
- package/src/lib/treatments.js +64 -0
- package/src/pages/Backside.jsx +37 -0
- package/src/pages/Cover.jsx +33 -0
- package/src/pages/CoverLetter.jsx +52 -0
- package/src/pages/KitPreview.jsx +47 -0
- package/src/pages/Portfolio.jsx +72 -0
- package/src/pages/Resume.jsx +135 -0
- package/src/pages/ResumeGrid.jsx +116 -0
- package/src/pages/ResumeLibrary.jsx +94 -0
- package/src/pages/ResumeMarginalia.jsx +129 -0
- package/src/pages/ResumeRail.jsx +133 -0
- package/src/pages/ResumeSource.jsx +43 -0
- package/src/pages/ResumeTreatment.jsx +36 -0
- package/src/styles/kol-kit-preview.css +122 -0
- package/src/styles/kol-print.css +88 -0
- package/src/styles/kol-resume-source.css +20 -0
- package/src/vite.js +89 -0
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kolkrabbi/kol-resume
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The resume kit: a resume in three treatments, the cover, letter, portfolio and back pages, the A4 and continuous kit, and its PDF export. The person comes through `ResumeProvider`, and nobody's details ship.
|
|
4
|
+
|
|
5
|
+
```jsx
|
|
6
|
+
import { ResumeProvider, ResumeLibrary, ResumeSource, ResumeTreatment } from '@kolkrabbi/kol-resume'
|
|
7
|
+
|
|
8
|
+
<ResumeProvider person={{ BIO, SKILLS, LANGUAGES }} records={{ WORK, EDUCATION }} contact={BRAND_INFO}
|
|
9
|
+
letters={import.meta.glob('./letters/*.md', { query: '?raw', import: 'default', eager: true })}
|
|
10
|
+
portfolio={import.meta.glob('./portfolio/*.md', { query: '?raw', import: 'default', eager: true })}
|
|
11
|
+
assetUrl={(key) => `/bucket/${key}`} portrait="portraits/me.jpg" fileStem="ada-lovelace">
|
|
12
|
+
<Routes>
|
|
13
|
+
<Route path="/resume/library" element={<ResumeLibrary />} />
|
|
14
|
+
<Route path="/resume/source" element={<ResumeSource />} />
|
|
15
|
+
<Route path="/resume/t/:slug" element={<ResumeTreatment />} />
|
|
16
|
+
</Routes>
|
|
17
|
+
</ResumeProvider>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
// vite.config.js
|
|
22
|
+
import { kitExportPlugin } from '@kolkrabbi/kol-resume/vite'
|
|
23
|
+
plugins: [react(), tailwindcss(), kitExportPlugin({ name: 'ada-lovelace' })]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```css
|
|
27
|
+
@import "@kolkrabbi/kol-resume/kol-resume.css"; /* the print rules, after kol-theme */
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The host checklist and the provider's props: ui.kolkrabbi.io, Compositions › Resume.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Export the job-application kit to PDF — one combined file per theme.
|
|
5
|
+
#
|
|
6
|
+
# Builds the app and serves the static output with `vite preview` on a
|
|
7
|
+
# DEDICATED port (not the user's 5173), renders each route with headless
|
|
8
|
+
# Chrome, and merges them in kit order (Cover → Résumé → Letter → Portfolio →
|
|
9
|
+
# Back) with pdfunite. Kills only its own server on exit; never touches a live
|
|
10
|
+
# dev session. A production build (not the dev server) is what keeps Chrome's
|
|
11
|
+
# network-idle from hanging — see the preview note below.
|
|
12
|
+
#
|
|
13
|
+
# Rendering goes through puppeteer-core (app/scripts/render-pdf.mjs), which waits
|
|
14
|
+
# for fonts + images before printing — see that file. Never reintroduce the
|
|
15
|
+
# legacy --virtual-time-budget flag; it hangs forever under --headless=new.
|
|
16
|
+
#
|
|
17
|
+
# Usage: scripts/export-kit.sh [theme ...] (default: light dark)
|
|
18
|
+
# scripts/export-kit.sh --v2 (light only, no background
|
|
19
|
+
# colors, downscaled images →
|
|
20
|
+
# out/$NAME-kit-light-v2.pdf)
|
|
21
|
+
#
|
|
22
|
+
# Flags (combinable with explicit themes):
|
|
23
|
+
# --plain render with ?plain=1 — strips every background fill so the kit
|
|
24
|
+
# prints ink-on-white (light theme only; dark text needs a light bg)
|
|
25
|
+
# --downscale shrink the printed images (portrait + portfolio heroes) in the
|
|
26
|
+
# BUILT dist/ before previewing — originals in public/ are untouched
|
|
27
|
+
# --icelandic render every route with ?lang=is (Icelandic kit), output suffix
|
|
28
|
+
# -icelandic
|
|
29
|
+
# --svg export each section as a self-contained SVG with editable <text>
|
|
30
|
+
# (out/svg/*.svg) + a vector PDF merged from them — for Illustrator
|
|
31
|
+
# --v2 shorthand: light only + --plain + --downscale, output suffix -v2
|
|
32
|
+
# --continuous fit-to-length PDF from /resume/t/kit-continuous — one page per
|
|
33
|
+
# section at its natural height (not A4-paginated) → out/…-continuous.pdf
|
|
34
|
+
#
|
|
35
|
+
# Env: KIT_QUERY — extra URL query appended to every rendered route, e.g.
|
|
36
|
+
# "&pages=cover,resume&role=Art%20Director&company=Acme". Set by the
|
|
37
|
+
# /api/export endpoint from the preview's own options overlay; it narrows
|
|
38
|
+
# which kit sections compile and fills the cover letter's Role/Company.
|
|
39
|
+
|
|
40
|
+
# CARRIED INTO @kolkrabbi/kol-resume (ResumeKit, 2026-10-28): the app is the caller's working directory (the vite
|
|
41
|
+
# plugin spawns this with cwd = the app root), render-pdf.mjs is the package's own, and the file stem is $KIT_NAME —
|
|
42
|
+
# nobody's name is baked in.
|
|
43
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
44
|
+
ROOT="${KIT_APP:-$PWD}"
|
|
45
|
+
APP="$ROOT"
|
|
46
|
+
NAME="${KIT_NAME:-kit}"
|
|
47
|
+
OUT="$ROOT/out"
|
|
48
|
+
PORT=5180
|
|
49
|
+
BASE="http://localhost:$PORT"
|
|
50
|
+
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
51
|
+
|
|
52
|
+
PLAIN=0; DOWNSCALE=0; SVG=0; CONTINUOUS=0; SINGLE=0; ROUTE_SINGLE=""; NAME_SINGLE=""; SUFFIX=""; KIT_LANG="en"; THEME_ARGS=()
|
|
53
|
+
for a in "$@"; do
|
|
54
|
+
case "$a" in
|
|
55
|
+
--v2) PLAIN=1; DOWNSCALE=1; SUFFIX="-v2"; THEME_ARGS=(light) ;;
|
|
56
|
+
--continuous) CONTINUOUS=1; PLAIN=1; DOWNSCALE=1; THEME_ARGS=(light) ;; # fit-to-length PDF from /resume/t/kit-continuous (light plain, like --v2)
|
|
57
|
+
--single=*) SINGLE=1; PLAIN=1; THEME_ARGS=(light) # one route → one ink-on-white A4 PDF (the layout treatments' download buttons)
|
|
58
|
+
rest="${a#--single=}"; ROUTE_SINGLE="${rest%%:*}"; NAME_SINGLE="${rest#*:}" ;;
|
|
59
|
+
--plain) PLAIN=1 ;;
|
|
60
|
+
--downscale) DOWNSCALE=1 ;;
|
|
61
|
+
--icelandic) KIT_LANG="is" ;; # render every route with ?lang=is
|
|
62
|
+
--svg) SVG=1; DOWNSCALE=1 ;; # editable-text SVG per section (implies downscale — images inline as data URIs)
|
|
63
|
+
light|dark) THEME_ARGS+=("$a") ;;
|
|
64
|
+
*) echo "unknown arg: $a" >&2; exit 2 ;;
|
|
65
|
+
esac
|
|
66
|
+
done
|
|
67
|
+
if [ "${#THEME_ARGS[@]}" -gt 0 ]; then THEMES=("${THEME_ARGS[@]}"); else THEMES=(light dark); fi
|
|
68
|
+
QUERY=""; [ "$PLAIN" -eq 1 ] && QUERY="&plain=1"
|
|
69
|
+
[ "$KIT_LANG" = "is" ] && { QUERY="$QUERY&lang=is"; LANGSUF="-icelandic"; } || LANGSUF=""
|
|
70
|
+
# Kit options from the caller (vite's /api/export sets this) — already a
|
|
71
|
+
# URL-encoded "&pages=…&role=…&company=…" fragment. Env, so it never hits the shell.
|
|
72
|
+
QUERY="$QUERY${KIT_QUERY:-}"
|
|
73
|
+
DOWNSCALE_MAX=1400 # longest side; heroes show thumbnail-size, portrait smaller
|
|
74
|
+
|
|
75
|
+
# Render one URL to PDF through puppeteer-core (CDP) — app/scripts/render-pdf.mjs.
|
|
76
|
+
# It waits for document.fonts.ready + image decode before the snapshot, so the
|
|
77
|
+
# PDF can't bake a fallback font or drop an image, then closes Chrome itself.
|
|
78
|
+
# (Replaces a bare `chrome --print-to-pdf`, which raced the fonts and lingered.)
|
|
79
|
+
print_pdf() { # $1 (unused) $2 out.pdf $3 url
|
|
80
|
+
local out="$2" url="$3"
|
|
81
|
+
rm -f "$out"
|
|
82
|
+
CHROME="$CHROME" node "$HERE/render-pdf.mjs" "$url" "$out" >>"$TMP/render.log" 2>&1
|
|
83
|
+
[ -s "$out" ]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# The whole kit is ONE route — the /resume/t/kit-a4 treatment the user actually
|
|
87
|
+
# sees, where A4Page lays each section onto real A4 sheets with proper margins.
|
|
88
|
+
# (The old per-section routes printed the wide screen layout, so the portfolio
|
|
89
|
+
# overflowed the page.) Language rides $QUERY (&lang=is). label = temp filename.
|
|
90
|
+
ROUTES=( "kit:/resume/t/kit-a4" )
|
|
91
|
+
|
|
92
|
+
mkdir -p "$OUT"
|
|
93
|
+
TMP="$(mktemp -d)"
|
|
94
|
+
|
|
95
|
+
echo "▸ building app (production)"
|
|
96
|
+
( cd "$APP" && VITE_BUCKET_LOCAL=1 node_modules/.bin/vite build >"$TMP/build.log" 2>&1 ) \
|
|
97
|
+
|| { echo "build failed:"; cat "$TMP/build.log"; exit 1; }
|
|
98
|
+
|
|
99
|
+
# The images the kit prints, fetched into the build's own dist/bucket/ (VITE_BUCKET_LOCAL=1 points the app there): the
|
|
100
|
+
# buckets are the only copy (2026-10-28), and a remote image is not reliably decoded before Chrome prints. The cover
|
|
101
|
+
# photo comes from the private bucket through wrangler's login, the portfolio heroes from the public R2.
|
|
102
|
+
# THE IMAGES ARE THE APP'S (ResumeKit, 2026-10-28): record fetched its cover from a private bucket through wrangler and
|
|
103
|
+
# its heroes from R2 here. In the package that is the app's own step — `scripts/fetch-kit-images.sh`, run with APP and
|
|
104
|
+
# TMP set, if the app has one; record's block is that file now (its RecordOnKolResume ticket).
|
|
105
|
+
if [ -x "$APP/scripts/fetch-kit-images.sh" ]; then
|
|
106
|
+
echo "▸ fetching kit images ($APP/scripts/fetch-kit-images.sh)"
|
|
107
|
+
APP="$APP" TMP="$TMP" "$APP/scripts/fetch-kit-images.sh" || { echo "image fetch failed"; exit 1; }
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# Downscale the images that actually print (cover photo + portfolio heroes) inside
|
|
111
|
+
# the built dist/ — fetched above; the buckets keep the full-size originals. Heroes render at thumbnail size in the grid and
|
|
112
|
+
# the portrait small, so a 1400px cap is invisible but slashes the PDF weight.
|
|
113
|
+
if [ "$DOWNSCALE" -eq 1 ]; then
|
|
114
|
+
echo "▸ downscaling kit images (cap ${DOWNSCALE_MAX}px)"
|
|
115
|
+
shopt -s nullglob
|
|
116
|
+
# every image the app's fetch step put under dist/bucket (record's cover and heroes were named here; $COVER went with
|
|
117
|
+
# its fetch block)
|
|
118
|
+
{ find "$APP/dist/bucket" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.webp" \) 2>/dev/null || true; } | while IFS= read -r f; do
|
|
119
|
+
[ -f "$f" ] || continue
|
|
120
|
+
before=$(stat -f%z "$f" 2>/dev/null || echo 0)
|
|
121
|
+
# Downscale ONLY — sips -Z upsamples images already under the cap (softens
|
|
122
|
+
# the portrait, bloats the file), so resize only when the long side exceeds it.
|
|
123
|
+
w=$(sips -g pixelWidth "$f" 2>/dev/null | awk '/pixelWidth/{print $2}')
|
|
124
|
+
h=$(sips -g pixelHeight "$f" 2>/dev/null | awk '/pixelHeight/{print $2}')
|
|
125
|
+
long=$(( w > h ? w : h ))
|
|
126
|
+
[ "$long" -gt "$DOWNSCALE_MAX" ] && sips -Z "$DOWNSCALE_MAX" "$f" >/dev/null 2>&1
|
|
127
|
+
case "$f" in *.jpg|*.jpeg) sips -s formatOptions 78 "$f" >/dev/null 2>&1 || true ;; esac
|
|
128
|
+
after=$(stat -f%z "$f" 2>/dev/null || echo 0)
|
|
129
|
+
echo " · ${f#"$APP"/dist/} ${long}px $((before/1024))K → $((after/1024))K"
|
|
130
|
+
done
|
|
131
|
+
shopt -u nullglob
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# Serve the STATIC build, not the dev server: vite preview has no HMR websocket
|
|
135
|
+
# and streams no module requests, so headless Chrome's "network idle" fires
|
|
136
|
+
# immediately. The dev server never reaches idle on heavier routes → Chrome hangs.
|
|
137
|
+
echo "▸ starting preview server on :$PORT"
|
|
138
|
+
( cd "$APP" && exec node_modules/.bin/vite preview --port "$PORT" --strictPort >"$TMP/serve.log" 2>&1 ) &
|
|
139
|
+
VITE_PID=$!
|
|
140
|
+
trap 'kill "$VITE_PID" 2>/dev/null; rm -rf "$TMP"' EXIT
|
|
141
|
+
|
|
142
|
+
for i in $(seq 1 80); do
|
|
143
|
+
curl -sf "$BASE/" >/dev/null 2>&1 && break
|
|
144
|
+
if ! kill -0 "$VITE_PID" 2>/dev/null; then echo "preview died:"; cat "$TMP/serve.log"; exit 1; fi
|
|
145
|
+
sleep 0.5
|
|
146
|
+
done
|
|
147
|
+
|
|
148
|
+
# Single layout treatment → one ink-on-white A4 PDF (the /resume/t/<slug> download
|
|
149
|
+
# buttons hit this via /api/export?layout=<slug>). Renders that one route only.
|
|
150
|
+
if [ "$SINGLE" -eq 1 ]; then
|
|
151
|
+
echo "▸ rendering single route $ROUTE_SINGLE"
|
|
152
|
+
out="$OUT/$NAME_SINGLE.pdf"
|
|
153
|
+
print_pdf "$TMP/chrome-single" "$out" "$BASE$ROUTE_SINGLE?theme=light$QUERY" \
|
|
154
|
+
|| { echo "single render failed:"; cat "$TMP/render.log"; exit 1; }
|
|
155
|
+
echo " → $out ($(du -h "$out" | cut -f1 | tr -d ' '), $(qpdf --show-npages "$out") pp)"
|
|
156
|
+
echo "✓ done"
|
|
157
|
+
exit 0
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
# SVG export — one self-contained SVG per kit section (continuous mode), with
|
|
161
|
+
# live <text> + embedded fonts + inlined images: editable type in Illustrator.
|
|
162
|
+
# No rsvg→PDF here: librsvg ignores the embedded webfonts (falls back to system
|
|
163
|
+
# fonts), so that PDF would be font-unfaithful — the faithful flat PDF is the
|
|
164
|
+
# default (non-SVG) export. SVGs are the editable deliverable.
|
|
165
|
+
if [ "$SVG" -eq 1 ]; then
|
|
166
|
+
echo "▸ rendering SVG (editable text)"
|
|
167
|
+
SVGROUTE="/resume/t/kit-continuous?theme=light$QUERY"
|
|
168
|
+
SVGDIR="$OUT/svg"; rm -rf "$SVGDIR"
|
|
169
|
+
CHROME="$CHROME" node "$APP/scripts/render-svg.mjs" "$BASE" "$SVGROUTE" "$SVGDIR"
|
|
170
|
+
echo " → $SVGDIR/*.svg (open in Illustrator)"
|
|
171
|
+
echo "✓ done"
|
|
172
|
+
exit 0
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
# Continuous PDF — one page per section, each sized to fit its own content length
|
|
176
|
+
# (the /resume/t/kit-continuous look), instead of the A4-paginated employer PDF.
|
|
177
|
+
# render-pdf.mjs writes a numbered PDF per section; pdfunite merges them in order.
|
|
178
|
+
if [ "$CONTINUOUS" -eq 1 ]; then
|
|
179
|
+
echo "▸ rendering continuous PDF (fit-to-length pages)"
|
|
180
|
+
CDIR="$TMP/continuous"; rm -rf "$CDIR"
|
|
181
|
+
CHROME="$CHROME" node "$HERE/render-pdf.mjs" "$BASE/resume/t/kit-continuous?theme=light$QUERY" "$CDIR" continuous >>"$TMP/render.log" 2>&1 \
|
|
182
|
+
|| { echo "continuous render failed:"; cat "$TMP/render.log"; exit 1; }
|
|
183
|
+
out="$OUT/$NAME-kit-continuous$LANGSUF.pdf"
|
|
184
|
+
pdfunite "$CDIR"/*.pdf "$out"
|
|
185
|
+
echo " → $out ($(du -h "$out" | cut -f1 | tr -d ' '), $(qpdf --show-npages "$out") pp)"
|
|
186
|
+
echo "✓ done"
|
|
187
|
+
exit 0
|
|
188
|
+
fi
|
|
189
|
+
|
|
190
|
+
for theme in "${THEMES[@]}"; do
|
|
191
|
+
echo "▸ rendering $theme"
|
|
192
|
+
parts=()
|
|
193
|
+
for entry in "${ROUTES[@]}"; do
|
|
194
|
+
label="${entry%%:*}"; path="${entry#*:}"
|
|
195
|
+
pdf="$TMP/$theme-$label.pdf"
|
|
196
|
+
if print_pdf "$TMP/chrome-$theme-$label" "$pdf" "$BASE$path?theme=$theme$QUERY"; then
|
|
197
|
+
echo " · $label"
|
|
198
|
+
parts+=("$pdf")
|
|
199
|
+
else
|
|
200
|
+
echo " · $label ⚠ render failed — skipped (see $TMP/render.log)"
|
|
201
|
+
fi
|
|
202
|
+
done
|
|
203
|
+
out="$OUT/$NAME-kit-$theme$SUFFIX$LANGSUF.pdf"
|
|
204
|
+
pdfunite "${parts[@]}" "$out"
|
|
205
|
+
echo " → $out ($(du -h "$out" | cut -f1 | tr -d ' '), $(qpdf --show-npages "$out") pp)"
|
|
206
|
+
done
|
|
207
|
+
|
|
208
|
+
echo "✓ done"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Render one URL to a PDF via the installed Chrome (puppeteer-core / CDP).
|
|
4
|
+
*
|
|
5
|
+
* The point of going through CDP instead of `chrome --print-to-pdf`: we can wait
|
|
6
|
+
* for document.fonts.ready and image decode BEFORE the print snapshot, so the PDF
|
|
7
|
+
* never bakes a fallback font (the Right Grotesk cuts use font-display:swap and
|
|
8
|
+
* lose the load race under a bare --print-to-pdf) or drops a half-loaded image.
|
|
9
|
+
*
|
|
10
|
+
* Two modes:
|
|
11
|
+
* a4 (default) — honors the page's own @page CSS (A4 portrait, margin:0 — the
|
|
12
|
+
* kit sets its own inner sheet margins) and prints backgrounds.
|
|
13
|
+
* <out> is the PDF file.
|
|
14
|
+
* continuous — one page per .kol-kit-page sheet, each sized to fit its own
|
|
15
|
+
* content length (no A4 pagination). Renders the SCREEN layout
|
|
16
|
+
* the /resume/t/kit-continuous treatment shows, isolating one sheet at a
|
|
17
|
+
* time. <out> is a DIRECTORY of numbered per-section PDFs (merge
|
|
18
|
+
* with pdfunite downstream, like render-svg.mjs).
|
|
19
|
+
*
|
|
20
|
+
* Usage: node render-pdf.mjs <url> <out> [a4|continuous]
|
|
21
|
+
*/
|
|
22
|
+
import puppeteer from 'puppeteer-core'
|
|
23
|
+
import { mkdirSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
const [url, out, mode = 'a4'] = process.argv.slice(2)
|
|
27
|
+
const CHROME = process.env.CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
28
|
+
|
|
29
|
+
if (!url || !out) {
|
|
30
|
+
console.error('usage: render-pdf.mjs <url> <out> [a4|continuous]')
|
|
31
|
+
process.exit(2)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const browser = await puppeteer.launch({
|
|
35
|
+
executablePath: CHROME,
|
|
36
|
+
headless: true,
|
|
37
|
+
args: ['--hide-scrollbars', '--disable-gpu'],
|
|
38
|
+
})
|
|
39
|
+
try {
|
|
40
|
+
const page = await browser.newPage()
|
|
41
|
+
await page.setViewport({ width: 1200, height: 1600 })
|
|
42
|
+
await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 })
|
|
43
|
+
// Block on the things --print-to-pdf won't wait for: web fonts and image bytes.
|
|
44
|
+
await page.evaluate(async () => {
|
|
45
|
+
await document.fonts.ready
|
|
46
|
+
await Promise.all([...document.images].map((img) => (img.complete ? null : img.decode().catch(() => {}))))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
if (mode === 'continuous') {
|
|
50
|
+
// Match the on-screen continuous preview (not print media, whose @page would
|
|
51
|
+
// force uniform A4 pages). Screen media means the app shell renders too, so
|
|
52
|
+
// replicate what kol-print.css does: hide the sidenav chrome + the download
|
|
53
|
+
// toolbar, and collapse the shell grid to one full-width column (else its
|
|
54
|
+
// sidenav gutter column offsets the sheet). Then measure every sheet.
|
|
55
|
+
const rects = await page.emulateMediaType('screen').then(() =>
|
|
56
|
+
page.evaluate(() => {
|
|
57
|
+
document
|
|
58
|
+
.querySelectorAll('.kol-sidenav, .kol-sidenav-hamburger, .kol-sidenav-backdrop, .kol-kit-preview .print\\:hidden')
|
|
59
|
+
.forEach((el) => (el.style.display = 'none'))
|
|
60
|
+
const layout = document.querySelector('.kol-brand-layout')
|
|
61
|
+
if (layout) layout.style.display = 'block'
|
|
62
|
+
const prev = document.querySelector('.kol-kit-preview')
|
|
63
|
+
Object.assign(prev.style, { background: '#fff', padding: '0', gap: '0', minHeight: '0' })
|
|
64
|
+
return [...document.querySelectorAll('.kol-kit-page')].map((el) => {
|
|
65
|
+
const r = el.getBoundingClientRect()
|
|
66
|
+
return { w: Math.ceil(r.width), h: Math.ceil(r.height), label: el.getAttribute('aria-label') || 'page' }
|
|
67
|
+
})
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
if (!rects.length) { console.error('no .kol-kit-page sections found'); process.exit(1) }
|
|
71
|
+
mkdirSync(out, { recursive: true })
|
|
72
|
+
for (let i = 0; i < rects.length; i++) {
|
|
73
|
+
// Show only sheet i; page.pdf sized to it → exactly one fit-to-length page.
|
|
74
|
+
await page.evaluate((idx) => {
|
|
75
|
+
document.querySelectorAll('.kol-kit-page').forEach((el, k) => { el.style.display = k === idx ? '' : 'none' })
|
|
76
|
+
}, i)
|
|
77
|
+
const { w, h, label } = rects[i]
|
|
78
|
+
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
|
79
|
+
const name = `${String(i + 1).padStart(2, '0')}-${slug}.pdf`
|
|
80
|
+
// pageRanges:'1' guards against a phantom trailing page from sub-px rounding
|
|
81
|
+
// (h is ceil'd, so page 1 always contains the whole sheet).
|
|
82
|
+
await page.pdf({ path: join(out, name), printBackground: true, width: `${w}px`, height: `${h}px`, pageRanges: '1' })
|
|
83
|
+
console.log(` · ${name} (${w}×${h}px)`)
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
await page.pdf({ path: out, printBackground: true, preferCSSPageSize: true })
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
await browser.close()
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-resume",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The resume kit: a resume in three treatments, cover, letter, portfolio and back pages, the A4 kit and its PDF export",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"module": "./src/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./vite": "./src/vite.js",
|
|
12
|
+
"./kol-resume.css": "./src/kol-resume.css",
|
|
13
|
+
"./styles/*": "./src/styles/*"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"kol-resume-pdf": "./bin/render-pdf.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"bin",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"yaml": "^2.9.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@kolkrabbi/kol-component": ">=0.300.0",
|
|
28
|
+
"@kolkrabbi/kol-styleguide": ">=0.14.0",
|
|
29
|
+
"@kolkrabbi/kol-theme": ">=0.196.0",
|
|
30
|
+
"puppeteer-core": ">=22.0.0",
|
|
31
|
+
"react": "^18.3.0 || ^19.0.0",
|
|
32
|
+
"react-dom": "^18.3.0 || ^19.0.0",
|
|
33
|
+
"react-router-dom": ">=6.20.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"puppeteer-core": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@kolkrabbi/kol-component": "^0.311.0",
|
|
42
|
+
"@kolkrabbi/kol-styleguide": "^0.14.0",
|
|
43
|
+
"@kolkrabbi/kol-theme": "^0.196.0"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"kol",
|
|
47
|
+
"kolkrabbi",
|
|
48
|
+
"design-system",
|
|
49
|
+
"resume",
|
|
50
|
+
"cv",
|
|
51
|
+
"print",
|
|
52
|
+
"a4"
|
|
53
|
+
],
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/Tor-Grimsson/kol-ds.git",
|
|
57
|
+
"directory": "packages/resume"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
6
62
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useResume } from '../data.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The kit's contact footer — the identical strip that closes both the Cover and
|
|
5
|
+
* the Back page. Every entry is a real link: Chrome's headless printToPDF keeps
|
|
6
|
+
* <a href> as a clickable annotation, so the exported PDF is live off the page.
|
|
7
|
+
*
|
|
8
|
+
* Display text is the data string verbatim (example.com, linkedin.com/name);
|
|
9
|
+
* only the href is synthesised, so the printed line reads the same as before.
|
|
10
|
+
*/
|
|
11
|
+
/* the contact is the person's (the seam): the entries are built from the provider's BRAND_INFO inside the component */
|
|
12
|
+
const entriesOf = ({ contact = {}, social = {} } = {}) => [
|
|
13
|
+
[contact.emailJobs, `mailto:${contact.emailJobs}`],
|
|
14
|
+
[contact.phone, `tel:${String(contact.phone ?? '').replace(/\s/g, '')}`],
|
|
15
|
+
[contact.web, `https://${contact.web}`],
|
|
16
|
+
[social.linkedin, `https://${social.linkedin}`],
|
|
17
|
+
[social.github, `https://${social.github}`],
|
|
18
|
+
].filter(([label]) => label)
|
|
19
|
+
|
|
20
|
+
export default function ContactLine({ className = '' }) {
|
|
21
|
+
const ENTRIES = entriesOf(useResume().BRAND_INFO)
|
|
22
|
+
return (
|
|
23
|
+
<p className={`kol-mono-10 text-meta mt-auto pt-6 border-t border-fg-08 ${className}`}>
|
|
24
|
+
{ENTRIES.map(([label, href], i) => (
|
|
25
|
+
<span key={label}>
|
|
26
|
+
{i > 0 && ' · '}
|
|
27
|
+
<a href={href} target="_blank" rel="noreferrer" className="no-underline text-inherit">
|
|
28
|
+
{label}
|
|
29
|
+
</a>
|
|
30
|
+
</span>
|
|
31
|
+
))}
|
|
32
|
+
</p>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useExportDownload } from '../lib/exportDownload'
|
|
2
|
+
import { exportRequest } from '../lib/treatments'
|
|
3
|
+
import { useResume } from '../data.jsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fixed top-right download controls for a treatment preview. Kit treatments
|
|
7
|
+
* offer the flat PDF and the editable-text SVG zip; layouts the PDF only. Both
|
|
8
|
+
* go through /api/export (~30–45s: build + headless render), so the running
|
|
9
|
+
* button shows progress. `print:hidden` keeps the chrome out of the file.
|
|
10
|
+
*/
|
|
11
|
+
export default function ExportButtons({ treatment, lang = 'en', options }) {
|
|
12
|
+
const { busy, err, download } = useExportDownload()
|
|
13
|
+
const { FILE_STEM } = useResume()
|
|
14
|
+
const run = (format) => {
|
|
15
|
+
const { url, filename } = exportRequest(treatment, { lang, format, options, name: FILE_STEM })
|
|
16
|
+
download(format, url, filename)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const btn =
|
|
20
|
+
'kol-mono-12 px-3 py-1.5 rounded bg-[#121215] text-white cursor-pointer transition-colors disabled:opacity-60 disabled:cursor-wait'
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="fixed top-4 right-4 z-50 print:hidden flex flex-col items-end gap-2">
|
|
24
|
+
<div className="flex gap-2">
|
|
25
|
+
<button type="button" disabled={!!busy} onClick={() => run('pdf')} className={btn}>
|
|
26
|
+
{busy === 'pdf' ? 'Generating…' : 'Download PDF'}
|
|
27
|
+
</button>
|
|
28
|
+
{treatment.kind === 'kit' && (
|
|
29
|
+
<button type="button" disabled={!!busy} onClick={() => run('svg')} className={btn}>
|
|
30
|
+
{busy === 'svg' ? 'Generating…' : 'SVG (.zip)'}
|
|
31
|
+
</button>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
{err && (
|
|
35
|
+
<pre className="kol-mono-10 max-w-xs whitespace-pre-wrap text-right text-red-700 bg-white/95 border border-red-200 rounded p-2">
|
|
36
|
+
{err}
|
|
37
|
+
</pre>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useSearchParams } from 'react-router-dom'
|
|
3
|
+
import { KIT_PAGES, KIT_PAGE_IDS, readKitOptions } from '../lib/kitOptions'
|
|
4
|
+
import LangToggle from './LangToggle'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The export options — language, which numbered sections compile into the
|
|
8
|
+
* kit, and the Role / Company the cover letter is addressed to.
|
|
9
|
+
*
|
|
10
|
+
* Everything writes straight to the URL query (lib/kitOptions.js, `?lang`):
|
|
11
|
+
* the preview re-renders live, the Library's Open links carry the same query,
|
|
12
|
+
* and the headless export reloads the very same URL — so what is set here is
|
|
13
|
+
* exactly what compiles. `kit` = false (a layout treatment) shows language only.
|
|
14
|
+
*
|
|
15
|
+
* Two shells: `KitOptionsFields` is the bare form (the Library renders it
|
|
16
|
+
* inline); the default export floats it as a light-scoped overlay over a
|
|
17
|
+
* treatment preview.
|
|
18
|
+
*/
|
|
19
|
+
export function KitOptionsFields({ kit = true }) {
|
|
20
|
+
const [params, setParams] = useSearchParams()
|
|
21
|
+
const { pages, role, company } = readKitOptions(params)
|
|
22
|
+
|
|
23
|
+
// Always write every field, so a cleared input actually clears in the URL.
|
|
24
|
+
const set = (next) => {
|
|
25
|
+
const p = new URLSearchParams(params)
|
|
26
|
+
const v = { pages, role, company, ...next }
|
|
27
|
+
v.pages.length === KIT_PAGE_IDS.length ? p.delete('pages') : p.set('pages', v.pages.join(','))
|
|
28
|
+
v.role ? p.set('role', v.role) : p.delete('role')
|
|
29
|
+
v.company ? p.set('company', v.company) : p.delete('company')
|
|
30
|
+
setParams(p, { replace: true })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const togglePage = (id) =>
|
|
34
|
+
set({ pages: KIT_PAGE_IDS.filter((p) => (p === id ? !pages.includes(id) : pages.includes(p))) })
|
|
35
|
+
|
|
36
|
+
const head = 'kol-helper-10 text-meta uppercase'
|
|
37
|
+
const field =
|
|
38
|
+
'kol-mono-12 w-full bg-surface-primary text-emphasis border border-fg-16 rounded px-2 py-1 outline-none focus:border-fg-64'
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex flex-col gap-4">
|
|
42
|
+
<div className="flex flex-col gap-1.5">
|
|
43
|
+
<p className={head}>Language</p>
|
|
44
|
+
<LangToggle className="max-w-[8rem]" />
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{kit && (
|
|
48
|
+
<>
|
|
49
|
+
<div className="flex flex-col gap-1.5">
|
|
50
|
+
<p className={head}>Pages in PDF</p>
|
|
51
|
+
{KIT_PAGES.map((p, i) => (
|
|
52
|
+
<label key={p.id} className="kol-mono-12 flex items-center gap-2 text-emphasis cursor-pointer">
|
|
53
|
+
<input
|
|
54
|
+
type="checkbox"
|
|
55
|
+
checked={pages.includes(p.id)}
|
|
56
|
+
onChange={() => togglePage(p.id)}
|
|
57
|
+
className="accent-current"
|
|
58
|
+
/>
|
|
59
|
+
<span className="text-subtle">{String(i + 1).padStart(2, '0')}</span>
|
|
60
|
+
{p.label}
|
|
61
|
+
</label>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div className="flex flex-col gap-1.5">
|
|
66
|
+
<p className={head}>Cover letter</p>
|
|
67
|
+
<input className={field} placeholder="Role" value={role} onChange={(e) => set({ role: e.target.value })} />
|
|
68
|
+
<input className={field} placeholder="Company" value={company} onChange={(e) => set({ company: e.target.value })} />
|
|
69
|
+
</div>
|
|
70
|
+
</>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default function KitOptions({ kit = true }) {
|
|
77
|
+
const [params] = useSearchParams()
|
|
78
|
+
const { pages } = readKitOptions(params)
|
|
79
|
+
const [open, setOpen] = useState(false)
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="fixed top-16 right-4 z-50 print:hidden flex flex-col items-end gap-2" data-theme="light">
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={() => setOpen(!open)}
|
|
86
|
+
className="kol-mono-12 px-3 py-1.5 rounded bg-[#121215] text-white cursor-pointer"
|
|
87
|
+
>
|
|
88
|
+
{open ? 'Close options' : kit ? `Options · ${pages.length}/${KIT_PAGE_IDS.length}` : 'Options'}
|
|
89
|
+
</button>
|
|
90
|
+
|
|
91
|
+
{open && (
|
|
92
|
+
<div className="w-64 bg-surface-tertiary border border-fg-08 rounded p-3 shadow-lg">
|
|
93
|
+
<KitOptionsFields kit={kit} />
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useSearchParams } from 'react-router-dom'
|
|
2
|
+
import { SegmentedToggle } from '@kolkrabbi/kol-component'
|
|
3
|
+
import { readLang } from '../lib/i18n'
|
|
4
|
+
|
|
5
|
+
/** EN / IS, written to `?lang` — the one value every resume surface (source,
|
|
6
|
+
* library, treatment preview, headless export) reads. English clears the param. */
|
|
7
|
+
export default function LangToggle({ className = '' }) {
|
|
8
|
+
const [params, setParams] = useSearchParams()
|
|
9
|
+
const set = (v) => {
|
|
10
|
+
const p = new URLSearchParams(params)
|
|
11
|
+
v === 'is' ? p.set('lang', 'is') : p.delete('lang')
|
|
12
|
+
setParams(p, { replace: true })
|
|
13
|
+
}
|
|
14
|
+
return (
|
|
15
|
+
<SegmentedToggle
|
|
16
|
+
value={readLang(params)}
|
|
17
|
+
onChange={set}
|
|
18
|
+
size="sm"
|
|
19
|
+
ariaLabel="Language"
|
|
20
|
+
options={[
|
|
21
|
+
{ value: 'en', label: 'EN' },
|
|
22
|
+
{ value: 'is', label: 'IS' },
|
|
23
|
+
]}
|
|
24
|
+
className={className}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
}
|
package/src/data.jsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo } from 'react'
|
|
2
|
+
import { sortedWork, cvEducation, contactLines } from './lib/resume.js'
|
|
3
|
+
import { parseLetters } from './lib/letters.js'
|
|
4
|
+
import { parsePortfolio } from './lib/portfolio.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ResumeProvider — the kit's ONE SEAM (ResumeKit, kol-website 2026-10-28: *"Nobody's details ship in the package"*). Every
|
|
8
|
+
* page, component and selector read `@data/business-data.js`, `@data/info.js`, `@data/records`, `data/letters/*.md`,
|
|
9
|
+
* `data/portfolio/*.md` and a bucket helper; they read this instead, under the same names, so the carried code is
|
|
10
|
+
* unchanged but for one `useResume()` line.
|
|
11
|
+
*
|
|
12
|
+
* person `{ BIO, SKILLS, LANGUAGES }` — the shape of record's `data/business-data.js` (bio with `_is` twins,
|
|
13
|
+
* skill groups, languages)
|
|
14
|
+
* records `{ WORK, EDUCATION }` — record's `data/records`
|
|
15
|
+
* contact record's `data/info.js` `BRAND_INFO` — `{ contact: { phone, emailJobs, web }, social: { linkedin, github } }`
|
|
16
|
+
* letters `{ [path]: raw markdown }` — the app's glob of its cover letters (`NN-<id>.md`, frontmatter + paragraphs)
|
|
17
|
+
* portfolio `{ [path]: raw markdown }` — the app's glob of its projects
|
|
18
|
+
* media the portfolio's downloaded shots by project id (record's `portfolio-manifest.json`)
|
|
19
|
+
* workBase where those shots load from (`<workBase>/<id>/hero.jpg`)
|
|
20
|
+
* workUrl `(id) => url` — a project's public case study, when its frontmatter has no `work:`
|
|
21
|
+
* assetUrl `(key) => url` — an image by bucket key (the cover's portrait)
|
|
22
|
+
* portrait the cover portrait's key (record's `portraits/portrait-bw-street-sunglasses-02.jpg`)
|
|
23
|
+
* copy the page words, per page and language: `{ back: { en: { kicker, title, blurb, current }, is }, portfolio:
|
|
24
|
+
* { en: { kicker, title, intro }, is } }` — each key overrides the kit's default
|
|
25
|
+
* fileStem the exports' file stem (`<stem>-kit.pdf`); defaults to the person's name, slugged
|
|
26
|
+
*/
|
|
27
|
+
const ResumeContext = createContext(null)
|
|
28
|
+
|
|
29
|
+
export function ResumeProvider({ person = {}, records = {}, contact = {}, letters = {}, portfolio = {}, media = {}, workBase = '', workUrl, assetUrl = (k) => k, portrait, fileStem, copy = {}, children }) {
|
|
30
|
+
const value = useMemo(() => {
|
|
31
|
+
const { BIO = {}, SKILLS = [], LANGUAGES = [] } = person
|
|
32
|
+
const { WORK = [], EDUCATION = [] } = records
|
|
33
|
+
const LETTERS = parseLetters(letters)
|
|
34
|
+
const PROJECTS = parsePortfolio(portfolio, { media, workBase, workUrl })
|
|
35
|
+
return {
|
|
36
|
+
BIO, SKILLS, LANGUAGES, WORK, EDUCATION, BRAND_INFO: contact,
|
|
37
|
+
LETTERS,
|
|
38
|
+
DEFAULT_LETTER: LETTERS[0],
|
|
39
|
+
getLetter: (id) => LETTERS.find((l) => l.id === id) ?? LETTERS[0],
|
|
40
|
+
PROJECTS,
|
|
41
|
+
getProject: (id) => PROJECTS.find((p) => p.id === id),
|
|
42
|
+
getMedia: (id) => media[id] ?? [],
|
|
43
|
+
PORTFOLIO_BASE: workBase,
|
|
44
|
+
bucketUrl: assetUrl,
|
|
45
|
+
PORTRAIT: portrait,
|
|
46
|
+
COPY: copy,
|
|
47
|
+
FILE_STEM: fileStem ?? (String(BIO.displayName ?? 'kit').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'kit'),
|
|
48
|
+
sortedWork: () => sortedWork(WORK),
|
|
49
|
+
cvEducation: () => cvEducation(EDUCATION),
|
|
50
|
+
contactLines: () => contactLines(BIO, contact),
|
|
51
|
+
}
|
|
52
|
+
}, [person, records, contact, letters, portfolio, media, workBase, workUrl, assetUrl, portrait, fileStem, copy])
|
|
53
|
+
return <ResumeContext.Provider value={value}>{children}</ResumeContext.Provider>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useResume() {
|
|
57
|
+
const v = useContext(ResumeContext)
|
|
58
|
+
if (!v) throw new Error('kol-resume: wrap the kit in <ResumeProvider> — its pages read the person from it')
|
|
59
|
+
return v
|
|
60
|
+
}
|