blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

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>