diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index 1b0f9bcc..5ac587d2 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -42,7 +42,7 @@ jobs: echo " Skipping #$pr — unable to retrieve CI status" continue fi - if echo "$checks" | jq -e '[.[] | select(.name | test("Lint|Typecheck|Build|Test")) | select(.state != "success")] | length > 0' > /dev/null; then + if echo "$checks" | jq -e '[.[] | select(.name | test("Lint|Typecheck|Build|Test")) | select(.state | ascii_downcase != "success")] | length > 0' > /dev/null; then echo " Skipping #$pr — CI checks not passing" continue fi diff --git a/.gitignore b/.gitignore index cb4b0afe..db7df7d7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ yarn-error.log* coverage playwright-report test-results + +# Resume preview (dev-only visual verification) +public/resume-preview.png diff --git a/package.json b/package.json index afe08695..a9ac5084 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", "docx": "^9.6.0", + "mammoth": "^1.11.0", "playwright": "^1.58.2", "shadcn": "^3.8.5", "tailwindcss": "^4.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c2c4e90..0933deed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: version: 1.58.2 '@tailwindcss/vite': specifier: ^4.2.1 - version: 4.2.1(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.1(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@types/node': specifier: ^25.0.3 version: 25.0.3 @@ -56,10 +56,13 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^5.1.4 - version: 5.1.4(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) docx: specifier: ^9.6.0 version: 9.6.0 + mammoth: + specifier: ^1.11.0 + version: 1.11.0 playwright: specifier: ^1.58.2 version: 1.58.2 @@ -80,10 +83,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@25.0.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@25.0.3)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -517,6 +520,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -1517,8 +1523,8 @@ packages: '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} - '@types/node@25.3.0': - resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/node@25.3.5': + resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -1569,10 +1575,19 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1604,6 +1619,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1623,11 +1641,17 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} hasBin: true + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -1645,6 +1669,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -1717,6 +1744,9 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -1820,6 +1850,9 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + docx@9.6.0: resolution: {integrity: sha512-y6EaJJMDvt4P7wgGQB9KsZf4wsRkQMJfkc9LlNufRshggI5BT35hGNkXBCAeEoI3MLMwApKguxzjdqqVcBCqNA==} engines: {node: '>=10'} @@ -1828,6 +1861,9 @@ packages: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2345,6 +2381,9 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2356,6 +2395,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mammoth@1.11.0: + resolution: {integrity: sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==} + engines: {node: '>=12.0.0'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2510,6 +2554,9 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + ora@8.2.0: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} @@ -2542,6 +2589,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2737,8 +2788,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sax@1.4.4: - resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} + sax@1.5.0: + resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} engines: {node: '>=11.0.0'} scheduler@0.27.0: @@ -2807,10 +2858,16 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2877,6 +2934,11 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + terser@5.46.0: + resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} + engines: {node: '>=10'} + hasBin: true + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -2945,6 +3007,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -3123,6 +3188,10 @@ packages: xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3554,6 +3623,12 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -4516,12 +4591,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 tailwindcss: 4.2.1 - vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@ts-morph/common@0.27.0': dependencies: @@ -4563,7 +4638,7 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.3.0': + '@types/node@25.3.5': dependencies: undici-types: 7.18.2 @@ -4579,7 +4654,7 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -4587,7 +4662,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -4600,14 +4675,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.0.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.0.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.10(@types/node@25.0.3)(typescript@5.9.3) - vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -4631,11 +4706,16 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@xmldom/xmldom@0.8.11': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 + acorn@8.16.0: + optional: true + agent-base@7.1.4: {} ajv-formats@3.0.1(ajv@8.18.0): @@ -4659,6 +4739,10 @@ snapshots: ansis@4.2.0: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -4673,8 +4757,12 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.0: {} + bluebird@3.4.7: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -4705,6 +4793,9 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-from@1.1.2: + optional: true + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -4761,6 +4852,9 @@ snapshots: commander@14.0.3: {} + commander@2.20.3: + optional: true + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -4826,9 +4920,11 @@ snapshots: diff@8.0.3: {} + dingbat-to-unicode@1.0.1: {} + docx@9.6.0: dependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.5 hash.js: 1.1.7 jszip: 3.10.1 nanoid: 5.1.6 @@ -4837,6 +4933,10 @@ snapshots: dotenv@17.3.1: {} + duck@0.1.12: + dependencies: + underscore: 1.13.8 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5323,6 +5423,12 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.8 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -5335,6 +5441,19 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mammoth@1.11.0: + dependencies: + '@xmldom/xmldom': 0.8.11 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.1 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.8 + xmlbuilder: 10.1.1 + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -5469,6 +5588,8 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + option@0.2.4: {} + ora@8.2.0: dependencies: chalk: 5.6.2 @@ -5504,6 +5625,8 @@ snapshots: path-browserify@1.0.1: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -5756,7 +5879,7 @@ snapshots: safer-buffer@2.1.2: {} - sax@1.4.4: {} + sax@1.5.0: {} scheduler@0.27.0: {} @@ -5879,8 +6002,16 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + optional: true + source-map@0.6.1: {} + sprintf-js@1.0.3: {} + stackback@0.0.2: {} statuses@2.0.2: {} @@ -5935,6 +6066,14 @@ snapshots: tapable@2.3.0: {} + terser@5.46.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + optional: true + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -5998,6 +6137,8 @@ snapshots: typescript@5.9.3: {} + underscore@1.13.8: {} + undici-types@7.16.0: {} undici-types@7.18.2: {} @@ -6041,7 +6182,7 @@ snapshots: vary@1.1.2: {} - vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -6054,13 +6195,14 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 + terser: 5.46.0 tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.18(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@25.0.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@25.0.3)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@25.0.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@25.0.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -6077,7 +6219,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.0.3 @@ -6130,10 +6272,12 @@ snapshots: xml-js@1.6.11: dependencies: - sax: 1.4.4 + sax: 1.5.0 xml@1.0.1: {} + xmlbuilder@10.1.1: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/public/Jon_Bogaty_Resume.docx b/public/Jon_Bogaty_Resume.docx index 0ef23b7b..3b5d288f 100644 Binary files a/public/Jon_Bogaty_Resume.docx and b/public/Jon_Bogaty_Resume.docx differ diff --git a/public/Jon_Bogaty_Resume.pdf b/public/Jon_Bogaty_Resume.pdf index 2af1b903..9f0fe1f8 100644 Binary files a/public/Jon_Bogaty_Resume.pdf and b/public/Jon_Bogaty_Resume.pdf differ diff --git a/scripts/generate-resume-docx.ts b/scripts/generate-resume-docx.ts index 37cf5657..e7a9efbe 100644 --- a/scripts/generate-resume-docx.ts +++ b/scripts/generate-resume-docx.ts @@ -1,8 +1,9 @@ /** * Generate Jon_Bogaty_Resume.docx from resume.json * - * Uses the `docx` npm package to produce a professional Word document - * directly from the canonical resume data source. + * Uses the `docx` npm package with borderless fixed-layout tables + * for reliable two-column alignment (title | date) that renders + * correctly in Word, Apple Pages, and Google Docs. * * Usage: npx tsx scripts/generate-resume-docx.ts * Output: public/Jon_Bogaty_Resume.docx @@ -12,297 +13,231 @@ import { writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { AlignmentType, + BorderStyle, Document, HeadingLevel, Packer, Paragraph, - TabStopPosition, - TabStopType, + Table, + TableCell, + TableLayoutType, + TableRow, TextRun, + WidthType, } from 'docx' -// Import resume data import resume from '../src/content/resume.json' with { type: 'json' } import { formatDateRange } from '../src/lib/dates.ts' -const PRIMARY_COLOR = '0B0D14' -const ACCENT_COLOR = '996B1D' -const LINK_COLOR = '6B8BAD' +const PRIMARY = '0B0D14' +const ACCENT = '996B1D' +const LINK = '6B8BAD' +const FONT = 'Calibri' -// --- Header --- -const headerParagraphs: Paragraph[] = [ - new Paragraph({ - alignment: AlignmentType.CENTER, - spacing: { after: 40 }, +// Letter page (12240 twips) minus 720 left + 720 right margins = 10800 +const PAGE_WIDTH = 10800 +const TITLE_WIDTH = 8000 +const DATE_WIDTH = PAGE_WIDTH - TITLE_WIDTH + +const NONE_BORDER = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' } +const NO_BORDERS = { + top: NONE_BORDER, + bottom: NONE_BORDER, + left: NONE_BORDER, + right: NONE_BORDER, +} + +/** Borderless fixed-width two-column row: left content | right content */ +function twoColumnRow(left: TextRun[], right: TextRun[]): Table { + return new Table({ + width: { size: PAGE_WIDTH, type: WidthType.DXA }, + layout: TableLayoutType.FIXED, + columnWidths: [TITLE_WIDTH, DATE_WIDTH], + borders: { + ...NO_BORDERS, + insideHorizontal: NONE_BORDER, + insideVertical: NONE_BORDER, + }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + width: { size: TITLE_WIDTH, type: WidthType.DXA }, + borders: NO_BORDERS, + children: [ + new Paragraph({ + spacing: { after: 0 }, + children: left, + }), + ], + }), + new TableCell({ + width: { size: DATE_WIDTH, type: WidthType.DXA }, + borders: NO_BORDERS, + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + spacing: { after: 0 }, + children: right, + }), + ], + }), + ], + }), + ], + }) +} + +function sectionHeading(text: string): Paragraph { + return new Paragraph({ + heading: HeadingLevel.HEADING_2, + spacing: { before: 360, after: 200 }, + border: { + bottom: { color: ACCENT, size: 6, space: 4, style: 'single' as const }, + }, children: [ new TextRun({ - text: resume.about.name, + text, bold: true, - size: 36, - color: PRIMARY_COLOR, - font: 'Calibri', + size: 22, + color: PRIMARY, + font: FONT, }), ], + }) +} + +function textRun( + text: string, + opts: { bold?: boolean; italics?: boolean; size?: number; color?: string } = {} +): TextRun { + return new TextRun({ + text, + font: FONT, + size: opts.size ?? 18, + bold: opts.bold, + italics: opts.italics, + color: opts.color, + }) +} + +// --- Header --- +const headerParagraphs: Paragraph[] = [ + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { after: 60 }, + children: [textRun(resume.about.name, { bold: true, size: 36, color: PRIMARY })], }), new Paragraph({ alignment: AlignmentType.CENTER, - spacing: { after: 80 }, - children: [ - new TextRun({ - text: resume.about.label, - size: 20, - color: ACCENT_COLOR, - font: 'Calibri', - }), - ], + spacing: { after: 120 }, + children: [textRun(resume.about.label, { size: 20, color: ACCENT })], }), new Paragraph({ alignment: AlignmentType.CENTER, - spacing: { after: 200 }, + spacing: { after: 300 }, children: [ - new TextRun({ - text: `${resume.about.location.city}, ${resume.about.location.region}`, - size: 18, - font: 'Calibri', - }), - new TextRun({ text: ' | ', size: 18, font: 'Calibri' }), - new TextRun({ text: resume.about.email, size: 18, font: 'Calibri', color: LINK_COLOR }), - new TextRun({ text: ' | ', size: 18, font: 'Calibri' }), - new TextRun({ text: resume.about.url, size: 18, font: 'Calibri', color: LINK_COLOR }), - ...resume.about.profiles.flatMap((p) => [ - new TextRun({ text: ' | ', size: 18, font: 'Calibri' }), - new TextRun({ text: p.url, size: 18, font: 'Calibri', color: LINK_COLOR }), - ]), + textRun(`${resume.about.location.city}, ${resume.about.location.region}`), + textRun(' | '), + textRun(resume.about.email, { color: LINK }), + textRun(' | '), + textRun(resume.about.url, { color: LINK }), + ...resume.about.profiles.flatMap((p) => [textRun(' | '), textRun(p.url, { color: LINK })]), ], }), ] // --- Summary --- -const summaryParagraphs: Paragraph[] = [ - new Paragraph({ - heading: HeadingLevel.HEADING_2, - spacing: { before: 200, after: 100 }, - border: { bottom: { color: ACCENT_COLOR, size: 6, space: 4, style: 'single' as const } }, - children: [ - new TextRun({ - text: 'PROFESSIONAL SUMMARY', - bold: true, - size: 22, - color: PRIMARY_COLOR, - font: 'Calibri', - }), - ], - }), +const summaryParagraphs = [ + sectionHeading('PROFESSIONAL SUMMARY'), ...(Array.isArray(resume.about.summary) ? resume.about.summary : [resume.about.summary]).map( (text) => new Paragraph({ - spacing: { after: 120 }, - children: [ - new TextRun({ - text, - size: 19, - font: 'Calibri', - }), - ], + spacing: { after: 200 }, + children: [textRun(text, { size: 19 })], }) ), ] // --- Work Experience --- -const experienceParagraphs: Paragraph[] = [ - new Paragraph({ - heading: HeadingLevel.HEADING_2, - spacing: { before: 200, after: 100 }, - border: { bottom: { color: ACCENT_COLOR, size: 6, space: 4, style: 'single' as const } }, - children: [ - new TextRun({ - text: 'PROFESSIONAL EXPERIENCE', - bold: true, - size: 22, - color: PRIMARY_COLOR, - font: 'Calibri', - }), - ], - }), - ...resume.work.flatMap((job) => [ +const experienceChildren: (Paragraph | Table)[] = [sectionHeading('PROFESSIONAL EXPERIENCE')] +for (const job of resume.work) { + experienceChildren.push( + twoColumnRow( + [textRun(job.position, { bold: true, size: 21, color: PRIMARY })], + [textRun(formatDateRange(job.startDate, job.endDate), { size: 19, color: ACCENT })] + ) + ) + experienceChildren.push( new Paragraph({ - spacing: { before: 160, after: 40 }, - tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }], - children: [ - new TextRun({ - text: job.position, - bold: true, - size: 21, - font: 'Calibri', - color: PRIMARY_COLOR, - }), - new TextRun({ text: '\t', font: 'Calibri' }), - new TextRun({ - text: formatDateRange(job.startDate, job.endDate), - size: 19, - font: 'Calibri', - color: ACCENT_COLOR, - }), - ], - }), - new Paragraph({ - spacing: { after: 60 }, - children: [ - new TextRun({ - text: job.name, - italics: true, - size: 19, - font: 'Calibri', - }), - ], - }), - ...(job.summary - ? [ - new Paragraph({ - spacing: { after: 60 }, - children: [ - new TextRun({ - text: job.summary, - size: 18, - font: 'Calibri', - }), - ], - }), - ] - : []), - ...(job.highlights ?? []).map( - (h) => - new Paragraph({ - spacing: { after: 40 }, - indent: { left: 360 }, - bullet: { level: 0 }, - children: [new TextRun({ text: h, size: 18, font: 'Calibri' })], - }) - ), - ]), -] + spacing: { after: 100 }, + children: [textRun(job.name, { italics: true, size: 19 })], + }) + ) + if (job.summary) { + experienceChildren.push( + new Paragraph({ + spacing: { after: 160 }, + children: [textRun(job.summary)], + }) + ) + } + for (const h of job.highlights ?? []) { + experienceChildren.push( + new Paragraph({ + spacing: { after: 60 }, + indent: { left: 360 }, + bullet: { level: 0 }, + children: [textRun(h)], + }) + ) + } +} // --- Earlier Career --- -const earlierParagraphs: Paragraph[] = [ +const earlierChildren: (Paragraph | Table)[] = [ + sectionHeading('EARLIER CAREER'), new Paragraph({ - heading: HeadingLevel.HEADING_2, - spacing: { before: 200, after: 100 }, - border: { bottom: { color: ACCENT_COLOR, size: 6, space: 4, style: 'single' as const } }, - children: [ - new TextRun({ - text: 'EARLIER CAREER', - bold: true, - size: 22, - color: PRIMARY_COLOR, - font: 'Calibri', - }), - ], - }), - new Paragraph({ - spacing: { after: 80 }, - children: [ - new TextRun({ - text: resume.earlierCareer.summary, - size: 18, - font: 'Calibri', - }), - ], + spacing: { after: 160 }, + children: [textRun(resume.earlierCareer.summary)], }), - ...resume.earlierCareer.positions.map( - (pos) => - new Paragraph({ - spacing: { after: 30 }, - tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }], - children: [ - new TextRun({ text: `${pos.position}`, bold: true, size: 18, font: 'Calibri' }), - new TextRun({ text: ` — ${pos.name}`, size: 18, font: 'Calibri' }), - new TextRun({ text: '\t', font: 'Calibri' }), - new TextRun({ text: pos.year, size: 18, font: 'Calibri', color: ACCENT_COLOR }), - ], - }) - ), ] +for (const pos of resume.earlierCareer.positions) { + earlierChildren.push( + twoColumnRow( + [textRun(pos.position, { bold: true }), textRun(` — ${pos.name}`)], + [textRun(pos.year, { color: ACCENT })] + ) + ) +} // --- Skills --- -const skillsParagraphs: Paragraph[] = [ - new Paragraph({ - heading: HeadingLevel.HEADING_2, - spacing: { before: 200, after: 100 }, - border: { bottom: { color: ACCENT_COLOR, size: 6, space: 4, style: 'single' as const } }, - children: [ - new TextRun({ - text: 'TECHNICAL SKILLS', - bold: true, - size: 22, - color: PRIMARY_COLOR, - font: 'Calibri', - }), - ], - }), +const skillsParagraphs = [ + sectionHeading('TECHNICAL SKILLS'), ...resume.skills.map( (cat) => new Paragraph({ - spacing: { after: 60 }, - children: [ - new TextRun({ - text: `${cat.name}: `, - bold: true, - size: 18, - font: 'Calibri', - }), - new TextRun({ - text: cat.keywords.join(', '), - size: 18, - font: 'Calibri', - }), - ], + spacing: { after: 100 }, + children: [textRun(`${cat.name}: `, { bold: true }), textRun(cat.keywords.join(', '))], }) ), ] // --- Education --- -const educationParagraphs: Paragraph[] = [ - new Paragraph({ - heading: HeadingLevel.HEADING_2, - spacing: { before: 200, after: 100 }, - border: { bottom: { color: ACCENT_COLOR, size: 6, space: 4, style: 'single' as const } }, - children: [ - new TextRun({ - text: 'EDUCATION', - bold: true, - size: 22, - color: PRIMARY_COLOR, - font: 'Calibri', - }), - ], - }), +const educationParagraphs = [ + sectionHeading('EDUCATION'), ...resume.education.map( (edu) => new Paragraph({ - spacing: { after: 60 }, + spacing: { after: 100 }, children: [ - new TextRun({ - text: `${edu.studyType} — ${edu.area}`, - bold: true, - size: 19, - font: 'Calibri', - }), - new TextRun({ text: '\n', font: 'Calibri' }), - new TextRun({ - text: `${edu.institution} | ${edu.startDate}–${edu.endDate}`, - size: 18, - font: 'Calibri', - }), + textRun(`${edu.studyType} — ${edu.area}`, { bold: true, size: 19 }), + new TextRun({ text: '\n', font: FONT }), + textRun(`${edu.institution} | ${edu.startDate}–${edu.endDate}`), ...(edu.honors ? [ - new TextRun({ text: '\n', font: 'Calibri' }), - new TextRun({ - text: edu.honors.join(' • '), - italics: true, - size: 18, - font: 'Calibri', - color: ACCENT_COLOR, - }), + new TextRun({ text: '\n', font: FONT }), + textRun(edu.honors.join(' • '), { italics: true, color: ACCENT }), ] : []), ], @@ -310,7 +245,7 @@ const educationParagraphs: Paragraph[] = [ ), ] -// --- Assemble Document --- +// --- Assemble --- const doc = new Document({ creator: 'Jon Bogaty', title: 'Jon Bogaty - Resume', @@ -318,15 +253,13 @@ const doc = new Document({ sections: [ { properties: { - page: { - margin: { top: 720, right: 720, bottom: 720, left: 720 }, - }, + page: { margin: { top: 720, right: 720, bottom: 720, left: 720 } }, }, children: [ ...headerParagraphs, ...summaryParagraphs, - ...experienceParagraphs, - ...earlierParagraphs, + ...experienceChildren, + ...earlierChildren, ...skillsParagraphs, ...educationParagraphs, ], @@ -334,7 +267,6 @@ const doc = new Document({ ], }) -// Generate and write const outPath = resolve(import.meta.dirname!, '../public/Jon_Bogaty_Resume.docx') const buffer = await Packer.toBuffer(doc) writeFileSync(outPath, buffer) diff --git a/scripts/generate-resume-pdf.ts b/scripts/generate-resume-pdf.ts index d8308cbe..3b3c65ac 100644 --- a/scripts/generate-resume-pdf.ts +++ b/scripts/generate-resume-pdf.ts @@ -1,8 +1,7 @@ /** * Generate Jon_Bogaty_Resume.pdf from resume.json * - * Uses Playwright to render a print-optimized HTML page built from - * the canonical resume data, then prints it to PDF. + * Uses Playwright to render the shared HTML template and print to PDF. * * Requires: npx playwright install chromium * Usage: npx tsx scripts/generate-resume-pdf.ts @@ -13,159 +12,12 @@ import { writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { chromium } from 'playwright' -import resume from '../src/content/resume.json' with { type: 'json' } -import { formatDateRange } from '../src/lib/dates.ts' +import { resumeHtml } from './resume-html.ts' -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -// Build a print-optimized HTML page from resume data -const html = ` - -
- -${escapeHtml(p)}
`).join('\n')} - -${escapeHtml(resume.earlierCareer.summary)}
-${resume.earlierCareer.positions - .map( - (pos) => ` -${escapeHtml(p)}
`).join('\n')} + +${escapeHtml(resume.earlierCareer.summary)}
+${resume.earlierCareer.positions + .map( + (pos) => ` +