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
101 changes: 101 additions & 0 deletions apps/site/scripts/vendor-public-profile-fonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Regenerates `src/lib/server/public-profile-fonts.ts`, the OFL fonts embedded
* in the standalone public résumé document so the headless Chromium in the
* production image (which has no web fonts) prints the same faces as the site.
*
* Latin subsets of the Google Fonts builds the site already loads in
* `src/app.html`. Both families are licensed under the SIL Open Font License
* 1.1, which permits embedding and bundling; see `public-profile-fonts.ts`.
*
* pnpm --filter @willgriffin/iolaus-site exec tsx scripts/vendor-public-profile-fonts.ts
*/
import { writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

interface VendoredFace {
family: string;
style: 'normal' | 'italic';
/** A single weight, or the range a variable slice covers. */
weight: string;
url: string;
}

// Pinned gstatic URLs (latin subset) as served for the families in app.html.
// Google serves one variable slice for every weight of a variable family, so
// the regular and semibold requests resolve to the same file.
const FACES: VendoredFace[] = [
{
family: 'Source Serif 4',
style: 'normal',
weight: '400 600',
url: 'https://fonts.gstatic.com/s/sourceserif4/v15/vEFF2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6kDXr4Y3qwzQ.woff2',
},
{
family: 'Source Serif 4',
style: 'italic',
weight: '400',
url: 'https://fonts.gstatic.com/s/sourceserif4/v15/vEF02_tTDB4M7-auWDN0ahZJW1ge6NmXpVAHV83Bfb_US2D2QYxoUKIkn98pRl9tDMQCjDDUXmAz.woff2',
},
{
family: 'Inter Tight',
style: 'normal',
weight: '500 600',
url: 'https://fonts.gstatic.com/s/intertight/v9/NGSwv5HMAFg6IuGlBNMjxLsH8ahuQ2e8.woff2',
},
];

async function fetchFace(face: VendoredFace): Promise<string> {
const response = await fetch(face.url);
if (!response.ok)
throw new Error(`${face.family} ${face.weight}: HTTP ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.length < 1_000 || String.fromCharCode(...bytes.slice(0, 4)) !== 'wOF2')
throw new Error(`${face.family} ${face.weight}: not a WOFF2 file`);
return Buffer.from(bytes).toString('base64');
}

const faces = await Promise.all(
FACES.map(async (face) => ({ ...face, data: await fetchFace(face) })),
);

const entries = faces
.map(
(face) => ` {
family: '${face.family}',
style: '${face.style}',
weight: '${face.weight}',
data: '${face.data}',
},`,
)
.join('\n');

const source = `// Generated by scripts/vendor-public-profile-fonts.ts — do not edit by hand.
//
// Latin subsets of Source Serif 4 (© 2014–2023 Adobe, designed by Frank
// Grießhammer) and Inter Tight (© 2016 The Inter Project Authors, designed by
// Rasmus Andersson), both licensed under the SIL Open Font License, Version
// 1.1 (https://openfontlicense.org). The OFL permits embedding the fonts in
// documents and bundling them with software; the fonts may not be sold by
// themselves. Full license text: ./public-profile-fonts.OFL.txt

/** One embeddable face: a base64 WOFF2 latin subset. */
export interface PublicProfileFontFace {
family: string;
style: 'normal' | 'italic';
/** A single weight, or the range a variable slice covers. */
weight: string;
data: string;
}

export const PUBLIC_PROFILE_FONT_FACES: readonly PublicProfileFontFace[] = [
${entries}
];
`;

const target = fileURLToPath(
new URL('../src/lib/server/public-profile-fonts.ts', import.meta.url),
);
await writeFile(target, source);
console.log(
`wrote ${target} (${faces.map((face) => `${face.family} ${face.style} ${face.weight}`).join(', ')})`,
);
80 changes: 70 additions & 10 deletions apps/site/src/lib/components/PublicProfileResume.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<script lang="ts">
import type { PublicProfileSnapshotV1 } from '$lib/public-profile-contract';
import { formatPublicProfileDateRange } from '$lib/public-profile-dates';

let { snapshot }: { snapshot: PublicProfileSnapshotV1 } = $props();
</script>

<!-- Section order and type hierarchy mirror the standalone document in
$lib/server/public-profile-render.ts, which feeds the PDF. -->
<main class="profile">
<header>
<header class="masthead">
<h1>{snapshot.name}</h1>
<p class="title">{snapshot.title}</p>
{#if snapshot.email || snapshot.phone || snapshot.location || snapshot.links.length}
Expand All @@ -18,10 +21,38 @@ let { snapshot }: { snapshot: PublicProfileSnapshotV1 } = $props();
{/if}
</header>
<section><h2>Profile</h2><p>{snapshot.summary}</p></section>
{#if snapshot.experience.length}<section><h2>Experience</h2>{#each snapshot.experience as position}<article><h3>{position.role} · {position.company}</h3><p class="muted">{position.start} – {position.end}</p><p>{position.summary}</p>{#if position.bullets.length}<ul>{#each position.bullets as bullet}<li>{bullet}</li>{/each}</ul>{/if}{#each position.projects as project}<section class="project"><strong>{#if project.url}<a href={project.url} target="_blank" rel="noopener noreferrer">{project.name}</a>{:else}{project.name}{/if}</strong><p>{project.summary}</p>{#if project.bullets?.length}<ul>{#each project.bullets as bullet}<li>{bullet}</li>{/each}</ul>{/if}</section>{/each}</article>{/each}</section>{/if}
{#if snapshot.education.length}<section><h2>Education</h2>{#each snapshot.education as education}<article><h3>{education.credential}</h3><p>{education.institution}</p>{#if education.start || education.end}<p class="muted">{education.start ?? ''}{education.start && education.end ? ' – ' : ''}{education.end ?? ''}</p>{/if}{#if education.summary}<p>{education.summary}</p>{/if}</article>{/each}</section>{/if}
{#if snapshot.other.length}<section><h2>Additional experience</h2><ul>{#each snapshot.other as item}<li><strong>{item.label}</strong>: {item.value}</li>{/each}</ul></section>{/if}
{#if snapshot.skills.length}<section><h2>Skills</h2><ul>{#each snapshot.skills as group}<li><strong>{group.label}</strong>: {group.skills.join(', ')}</li>{/each}</ul></section>{/if}
{#if snapshot.skills.length}<section class="skills"><h2>Skills</h2><ul>{#each snapshot.skills as group}<li><strong>{group.label}</strong> {group.skills.join(', ')}</li>{/each}</ul></section>{/if}
{#if snapshot.experience.length}
<section><h2>Experience</h2>
{#each snapshot.experience as position}
<article>
<div class="keep">
<h3><span class="lead">{position.role}</span><span class="follow">{position.company}</span>{#if position.start || position.end}<span class="when">{formatPublicProfileDateRange(position.start, position.end)}</span>{/if}</h3>
{#if position.summary}<p>{position.summary}</p>{/if}
</div>
{#if position.bullets.length}<ul>{#each position.bullets as bullet}<li>{bullet}</li>{/each}</ul>{/if}
{#if position.projects.length}
<ul class="projects">
{#each position.projects as project}
<li><strong>{#if project.url}<a href={project.url} target="_blank" rel="noopener noreferrer">{project.name}</a>{:else}{project.name}{/if}</strong>{#if project.summary} <span class="dash" aria-hidden="true">—</span> {project.summary}{/if}{#if project.bullets?.length}<ul>{#each project.bullets as bullet}<li>{bullet}</li>{/each}</ul>{/if}</li>
{/each}
</ul>
{/if}
</article>
{/each}
</section>
{/if}
{#if snapshot.education.length}
<section><h2>Education</h2>
{#each snapshot.education as education}
<article class="keep">
<h3><span class="lead">{education.institution}</span><span class="follow">{education.credential}</span>{#if education.start || education.end}<span class="when">{formatPublicProfileDateRange(education.start, education.end)}</span>{/if}</h3>
{#if education.summary}<p>{education.summary}</p>{/if}
</article>
{/each}
</section>
{/if}
{#if snapshot.other.length}<section class="other"><h2>Additional experience</h2><ul>{#each snapshot.other as item}<li><strong>{item.label}</strong> {item.value}</li>{/each}</ul></section>{/if}
</main>

<style>
Expand All @@ -40,6 +71,8 @@ let { snapshot }: { snapshot: PublicProfileSnapshotV1 } = $props();
--profile-muted: #5c584f;
--profile-rule: #d6d1c2;
--profile-accent: #8a3f1f;
--profile-serif: "Source Serif 4", Georgia, "Times New Roman", serif;
--profile-sans: "Inter Tight", "Liberation Sans", Arial, sans-serif;
}
@media (prefers-color-scheme: dark) {
.profile {
Expand Down Expand Up @@ -67,9 +100,36 @@ let { snapshot }: { snapshot: PublicProfileSnapshotV1 } = $props();
--profile-rule: #d6d1c2;
--profile-accent: #8a3f1f;
}
.profile { width: min(100%, 48rem); margin: 0 auto; padding: clamp(1rem, 4vw, 3rem); background: var(--profile-bg); color: var(--profile-ink); overflow-wrap: anywhere; }
h1, h2, h3, p { margin: 0 0 .65rem; } h1 { font-size: clamp(2rem, 8vw, 3rem); line-height: 1.1; } h2 { margin-top: 2rem; padding-bottom: .35rem; border-bottom: 1px solid var(--profile-rule); } h3 { font-size: 1.05rem; } p, li { line-height: 1.55; } .title { color: var(--profile-ink-soft); font-size: 1.2rem; } .contact { display: flex; flex-wrap: wrap; gap: .5rem 1rem; } a { color: var(--profile-accent); text-decoration: underline; text-underline-offset: .15em; } .muted { color: var(--profile-muted); } article { margin: 0 0 1.5rem; } ul { padding-left: 1.3rem; } .project { margin: .8rem 0 .8rem 1rem; } :is(a):focus-visible { outline: 2px solid var(--profile-accent); outline-offset: 3px; }
/* Print pins light. The toggle selector must be repeated here so this rule
ties its specificity and wins by source order. */
@media print { .profile, :global(:root[data-color-scheme]) .profile { --profile-bg: #ffffff; --profile-ink: #1a1814; --profile-ink-soft: #3a3630; --profile-muted: #5c584f; --profile-rule: #d6d1c2; --profile-accent: #1a1814; } a { text-decoration: none; } }
/* Same hierarchy as the PDF document, scaled for the screen: serif body,
sans headings, small-caps section labels, dates on the heading line. */
.profile { width: min(100%, 46rem); margin: 0 auto; padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 3rem); background: var(--profile-bg); color: var(--profile-ink); font: 1.0625rem/1.5 var(--profile-serif); overflow-wrap: anywhere; }
h1, h2, h3, p, ul { margin: 0; }
h1 { font: 600 clamp(1.9rem, 6vw, 2.5rem)/1.1 var(--profile-sans); letter-spacing: -.01em; }
.title { font: 500 1.15rem/1.3 var(--profile-sans); color: var(--profile-ink-soft); margin-top: .3rem; }
.contact { display: flex; flex-wrap: wrap; gap: .25rem 1rem; font: .9rem/1.4 var(--profile-sans); color: var(--profile-muted); margin-top: .5rem; }
h2 { font: 600 .8rem/1.2 var(--profile-sans); letter-spacing: .09em; text-transform: uppercase; color: var(--profile-ink-soft); border-bottom: 1px solid var(--profile-rule); margin: 1.75rem 0 .6rem; padding-bottom: .3rem; }
h3 { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: .5em; font: 600 1.05rem/1.3 var(--profile-sans); }
h3 .follow { font-weight: 500; color: var(--profile-ink-soft); }
h3 .follow::before { content: "·"; color: var(--profile-muted); margin-right: .5em; }
h3 .when { margin-left: auto; font: 500 .9rem/1.3 var(--profile-sans); color: var(--profile-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
article { margin: 0 0 1rem; }
article .keep p { margin-top: .15rem; }
ul { padding-left: 1.2em; margin-top: .3rem; }
li { margin: 0 0 .2rem; }
li::marker { color: var(--profile-muted); }
ul.projects { list-style: none; padding-left: 1.2em; margin-top: .4rem; border-left: 1px solid var(--profile-rule); }
ul.projects > li { margin-bottom: .3rem; }
ul.projects strong { font: 600 1rem/1.3 var(--profile-sans); }
ul.projects .dash { color: var(--profile-muted); }
ul.projects ul { font-size: .95rem; margin-top: .15rem; }
.skills ul, .other ul { list-style: none; padding-left: 0; margin-top: 0; }
.skills li { font-size: .95rem; margin-bottom: .15rem; }
.skills strong, .other strong { font: 600 .8rem/1.3 var(--profile-sans); color: var(--profile-ink-soft); margin-right: .2em; }
.other li { margin-bottom: .3rem; }
a { color: var(--profile-accent); text-decoration: underline; text-underline-offset: .15em; }
:is(a):focus-visible { outline: 2px solid var(--profile-accent); outline-offset: 3px; }
/* Print pins light and paginates like the PDF: headings keep their first
lines, lists flow. The toggle selector must be repeated here so this
rule ties its specificity and wins by source order. */
@media print { .profile, :global(:root[data-color-scheme]) .profile { --profile-bg: #ffffff; --profile-ink: #1a1814; --profile-ink-soft: #3a3630; --profile-muted: #5c584f; --profile-rule: #d6d1c2; --profile-accent: #1a1814; } a { text-decoration: none; } h2, h3 { break-after: avoid; } .keep, li { break-inside: avoid; } ul.projects > li { break-inside: auto; } ul.projects > li > ul { break-before: avoid; } p { orphans: 2; widows: 2; } }
</style>
39 changes: 39 additions & 0 deletions apps/site/src/lib/public-profile-dates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Date display for the public résumé. Snapshot dates are short strings the
* owner typed (`YYYY-MM`, `YYYY`, or words such as "Present"); both renderers
* show them the same way so the page and the PDF read alike.
*/
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
] as const;

/** `2024-12` → `Dec 2024`; `2008` → `2008`; anything else is returned as typed. */
export function formatPublicProfileDate(value: string | undefined): string {
const trimmed = value?.trim() ?? '';
const match = /^(\d{4})(?:-(\d{2}))?(?:-\d{2})?$/u.exec(trimmed);
if (!match) return trimmed;
const month = match[2] ? Number(match[2]) : 0;
const name = month >= 1 && month <= 12 ? MONTHS[month - 1] : undefined;
return name ? `${name} ${match[1]}` : match[1];
}

/** `Dec 2024 – Present`, or a single date when only one side is present. */
export function formatPublicProfileDateRange(
start: string | undefined,
end: string | undefined,
): string {
return [formatPublicProfileDate(start), formatPublicProfileDate(end)]
.filter(Boolean)
.join(' – ');
}
95 changes: 95 additions & 0 deletions apps/site/src/lib/server/public-profile-fonts.OFL.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
Fonts embedded in the public résumé PDF (src/lib/server/public-profile-fonts.ts):

- Source Serif 4: Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name "Source". Source is a trademark of Adobe in the United States and/or other countries.

- Inter Tight: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter), with Reserved Font Name "Inter".

Both are licensed under the SIL Open Font License, Version 1.1, reproduced below.


-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
38 changes: 38 additions & 0 deletions apps/site/src/lib/server/public-profile-fonts.ts

Large diffs are not rendered by default.

Loading
Loading