src/pages/index.astro (9844B)
1 ---
2 import Base from '../layouts/Base.astro';
3 import { getEntry, render } from 'astro:content';
4 import { hasHangul } from '../lib/hangul';
5 import { assertRendered } from '../lib/rendered';
6 import reading from '../lib/goodreads.json';
7
8 const entry = await getEntry('about', 'about');
9 assertRendered(entry!);
10 const { Content } = await render(entry!);
11
12 /* The reading list: the most recently finished books on the Goodreads read shelf, which
13 scripts/goodreads.mjs fetches at build time. A book with no finish date sorts by the day
14 it was shelved. The subtitle after ": " is dropped, "Collective Illusions" rather than
15 the eleven words after it, so a title holds to a line or two. */
16 const READING_COUNT = 5;
17 const books = [...reading.books]
18 .sort((a, b) => (b.readAt ?? b.addedAt ?? '').localeCompare(a.readAt ?? a.addedAt ?? ''))
19 .slice(0, READING_COUNT);
20 const shortTitle = (t: string) => t.replace(/:\s.*$/, '');
21 const ym = (iso: string) => `${iso.slice(0, 4)}.${iso.slice(5, 7)}`;
22 /* Kana or CJK ideographs. Japanese titles are common on the shelf, and lang="ja" is what
23 picks a Japanese voice and Japanese glyph forms over the page's lang="en". */
24 const KANA_KANJI = /[-ヿ一-鿿]/;
25 const titleLang = (t: string) => (hasHangul(t) ? 'ko' : KANA_KANJI.test(t) ? 'ja' : undefined);
26
27 /* Ask the body, not the metadata. The title and description below are English whatever
28 about.md says, so only the body can tell whether the page needs the 118KB Korean face.
29 about.md is English throughout today, so the answer is false and the page fetches no
30 Korean font. The test stays because the answer is the body's to give: one Korean line
31 added to about.md starts needing that face, and nothing else in this file would
32 notice. The reading list is part of the body for this purpose: a Korean book title
33 needs the face just as a Korean sentence does. */
34 const ko = hasHangul(entry!.body) || books.some((b) => hasHangul(b.title + b.author));
35 ---
36 <Base
37 title="debuman"
38 description="Hyukjin An — software and security engineer, and a founder, in South Korea."
39 ko={ko}
40 >
41 {/* about.md is running prose with no headings, the shape the reference's own home page
42 uses. The bar's left slot prints the name but it is a <span>, not a heading — so the
43 page needs one here or it has no h1 at all. A `#` heading in the body would not
44 serve either: satteriProse demotes it to h2. Visually hidden for the same reason as
45 the other section pages: the reference prints no page title either. */}
46 <h1 class="sr-only">About</h1>
47 {/* The portrait and the CV link share one floated column, so the link sits directly
48 under the picture. That is why the portrait is here and not in about.md: Markdown
49 can carry neither the link's styling (satteriGuard refuses raw HTML) nor a wrapper
50 around the two. Not lazy, as satteriProse made it: it is above the fold on every
51 screen. The CV opens in the same tab, in the browser's own PDF viewer, as every
52 other link on the site does. */}
53 <article class="about">
54 <aside class="about__side">
55 <figure class="about__portrait">
56 <img src="/uploads/profile.jpg" alt="Hyukjin An" width="480" height="600" decoding="async" />
57 </figure>
58 <p class="about__cv"><a href="/HyukjinAn-CV.pdf" type="application/pdf">CV</a></p>
59 </aside>
60 <Content />
61 </article>
62
63 {/* Rows on the Writing index's pattern: a date rail, the title, and here the rating in
64 place of a deck. The stars are drawn twice over, filled and empty, and hidden from
65 assistive tech, which reads the rating from the label instead. */}
66 {books.length > 0 && (
67 <section class="reading" aria-labelledby="reading">
68 <h2 class="reading__label" id="reading">Reading</h2>
69 <ol class="reading__list">
70 {books.map((b) => (
71 <li class="book">
72 {b.readAt && <time class="book__date" datetime={b.readAt.slice(0, 7)}>{ym(b.readAt)}</time>}
73 <p class="book__body">
74 <a class="book__title" href={b.url} lang={titleLang(b.title)}>{shortTitle(b.title)}</a>
75 <span class="book__author">{b.author}</span>
76 </p>
77 {b.rating > 0 && (
78 <span class="book__rating" role="img" aria-label={`Rated ${b.rating} out of 5`}>
79 <span class="book__stars" aria-hidden="true">{'★'.repeat(b.rating)}</span><span class="book__stars book__stars--empty" aria-hidden="true">{'★'.repeat(5 - b.rating)}</span>
80 </span>
81 )}
82 </li>
83 ))}
84 </ol>
85 <p class="reading__more"><a href="https://www.goodreads.com/user/show/205369673-hyukjin-an">More on Goodreads</a></p>
86 </section>
87 )}
88 </Base>
89
90 {/* is:global for the same reason the article page uses it: the elements <Content />
91 emits carry no scope id, so a scoped selector would miss every one of them. */}
92 <style is:global>
93 /* The pair the article page carries on .prose, restated here because this is the one
94 page that renders <Content /> without that wrapper. keep-all is the house rule
95 wherever Korean can appear. The break rules at the end of this block
96 (`.about :is(p, li, h1, h2, h3, h4)` and `.about a`) are the escape for a long
97 unbroken token such as a bare URL: left unbroken it sets the page's minimum width,
98 and `/` scrolls sideways on a phone. */
99 .about { word-break: keep-all; }
100
101 /* The portrait and the CV link under it, set the way the reference sets its own
102 portrait: floated to the inline end with the prose running beside it, not a banner
103 above the text.
104
105 Two things it buys beyond looking composed. It pulls the reading measure in from the
106 full 1024px this page gets over the paragraphs it passes, which is the direction this
107 page's line length wanted to move anyway. And the 4:5 portrait keeps its own aspect
108 from the width/height on the <img>, so nothing reflows when it loads.
109
110 Below 40rem the float comes off: the picture and the column beside it would both be
111 too narrow, and the reference drops its own block to full width at a similar point. */
112 .about__side {
113 float: inline-end;
114 inline-size: min(14.5rem, 34%);
115 margin: 0 0 var(--sp-40) var(--sp-48);
116 shape-margin: var(--sp-40);
117 }
118 .about__portrait { margin: 0; }
119 .about__portrait img { display: block; inline-size: 100%; block-size: auto; }
120
121 /* The CV link is a plain body link centred under the photo, the way the reference sets
122 the links under its own portrait: no box, only the crimson every link here carries. */
123 .about__cv { margin: var(--sp-30) 0 0; text-align: center; }
124
125 /* Clears the float so the footer, and anything else after the prose, cannot ride up
126 beside the picture when the prose beside it ends first. */
127 .about::after { content: ""; display: block; clear: both; }
128
129 @media (max-width: 40rem) {
130 .about__side {
131 float: none;
132 inline-size: min(12rem, 45%);
133 margin: 0 0 var(--sp-40);
134 }
135 }
136 .about :is(p, li, h1, h2, h3, h4) { overflow-wrap: break-word; }
137 .about a { overflow-wrap: anywhere; }
138
139 /* The label is the site's kicker idiom with its 4px x 3rem rule, as on the Writing
140 index's year headings, rather than a second heading style for one section. */
141 .reading { margin-block-start: var(--sp-48); }
142 .reading__label {
143 margin: 0 0 var(--sp-40);
144 font-family: var(--font-body);
145 font-size: var(--fs-small);
146 font-weight: 700;
147 line-height: var(--lh-medium);
148 letter-spacing: 0.105rem;
149 text-transform: uppercase;
150 color: var(--ppc-text);
151 }
152 .reading__label::before {
153 content: "";
154 display: block;
155 inline-size: 3rem;
156 border-top: 4px solid var(--ppc-ui);
157 margin-block-end: var(--sp-40);
158 }
159 /* Each book is a Writing index row in miniature: the same 72px date rail and 24px gap,
160 hairlines between rows, a title in --page-fg that steps to crimson under the pointer.
161 The rail carries the month the book was finished; the rating takes the end column. */
162 .reading__list { list-style: none; margin: 0; padding: 0; }
163 .book {
164 display: grid;
165 grid-template-columns: 4.5rem minmax(0, 1fr) auto;
166 grid-template-areas: "date body rating";
167 column-gap: 1.5rem;
168 align-items: baseline;
169 padding-block: var(--sp-30);
170 border-bottom: 1px solid var(--page-hairline);
171 }
172 .book:last-child { border-bottom: none; }
173 .book__date {
174 grid-area: date;
175 justify-self: end;
176 font-size: var(--fs-small);
177 font-variant-numeric: tabular-nums;
178 letter-spacing: 0.02em;
179 color: var(--page-meta);
180 white-space: nowrap;
181 }
182 .book__body { grid-area: body; margin: 0; line-height: var(--lh-medium); }
183 .book__title {
184 font-family: var(--font-display);
185 font-size: var(--fs-h6);
186 font-weight: 500;
187 color: var(--page-fg);
188 }
189 .book__title:is(:hover, :focus-visible) { color: var(--crimson); }
190 .book__author {
191 margin-inline-start: 0.6em;
192 font-size: var(--fs-small);
193 color: var(--page-meta);
194 white-space: nowrap;
195 }
196 .book__rating { grid-area: rating; font-size: var(--fs-small); letter-spacing: 0.08em; white-space: nowrap; }
197 .book__stars { color: var(--crimson); }
198 .book__stars--empty { color: var(--page-hairline); }
199 .reading__more {
200 margin-block-start: var(--sp-40);
201 padding-inline-start: calc(4.5rem + 1.5rem);
202 font-size: var(--fs-small);
203 }
204
205 /* Below 40rem the rail would leave the title too narrow a column beside the rating, so
206 the month moves above the title and the rows start at the page edge. */
207 @media (max-width: 40rem) {
208 .book {
209 grid-template-columns: minmax(0, 1fr) auto;
210 grid-template-areas: "date date" "body rating";
211 }
212 .book__date { justify-self: start; }
213 .reading__more { padding-inline-start: 0; }
214 }
215 </style>