src/layouts/Base.astro (7354B)
1 ---
2 import '../styles/fonts.css';
3 import '../styles/tokens.css';
4 import '../styles/base.css';
5 import { hasHangul } from '../lib/hangul';
6
7 interface Props {
8 title: string;
9 description?: string;
10 section?: string;
11 /**
12 * Does this page put Hangul on screen? It decides whether the Korean face is preloaded,
13 * and it is NOT the same question as the document language. /writing/ is an English
14 * document — heading "Writing", year kickers — that renders Korean titles above the
15 * fold; `/` takes the answer from about.md's body and /code/ from each project's name
16 * and body, which no title or description reveals. Pages with no Korean on screen skip the face: /papers/, /talk/
17 * and /404.html always, `/` and /code/ while about.md and the project descriptions stay
18 * English.
19 * Pass it explicitly wherever the Korean text is not already in title or description.
20 */
21 ko?: boolean;
22 }
23 const { title, description, section = 'slate', ko } = Astro.props;
24
25 /**
26 * Preload only the faces that block first paint on THIS route.
27 *
28 * Measured: the Korean face is 118,136B against 22,300B for Lora, and it is discovered
29 * last — HTML, then CSS, then layout, then the request. Lora sets every page, so it is
30 * preloaded everywhere; the Korean face only where the page puts Hangul on screen, which
31 * is what `ko` answers and what `needsKo` falls back to asking of the title and
32 * description.
33 *
34 * woff2 is already Brotli-compressed, so none of these numbers move on the wire.
35 */
36 const needsKo = ko ?? hasHangul(title, description);
37 const NAV = [
38 { label: 'About', href: '/' },
39 { label: 'Papers', href: '/papers/' },
40 { label: 'Talk', href: '/talk/' },
41 { label: 'Code', href: '/code/' },
42 { label: 'Writing', href: '/writing/' },
43 ];
44 const here = Astro.url.pathname;
45 // An exact match would leave every nav item unmarked on /writing/2026/vtzero/.
46 // Only '/' compares exactly, so that About is not current on every page.
47 const isCurrent = (href: string) => (href === '/' ? here === '/' : here.startsWith(href));
48
49 /**
50 * The header's left slot is the name of the page you are on, not a wordmark. That is the
51 * whole state mechanic of the reference header, and the reason its nav carries no
52 * current-item marker at all: /papers reads "Papers", /code reads "Code", and an article
53 * reads its SECTION — measured on the live reference, /writing/2025/… shows "Writing",
54 * never the article's own title, which already has an h1 below.
55 *
56 * It is derived from NAV and isCurrent rather than from a new prop, so it needs nothing
57 * of the pages: startsWith already carries /writing/2026/vtzero/ to "Writing". Root is
58 * excluded from the match because its nav label is "About", which names a page, not the
59 * site; / and anything unrouted (404) fall through to the site name instead — the
60 * reference does the same, labelling its own root nav item "Main" while the slot reads
61 * the site owner's name.
62 *
63 * Note this is NOT the `section` prop above: that one is the palette key (is-slate,
64 * is-writing) and only article pages pass it.
65 */
66 const currentNav = NAV.find((n) => n.href !== '/' && isCurrent(n.href));
67 /* The site owner's name, not the domain. The reference's own root slot reads "Nicholas
68 * Carlini" where its nav item reads "Main"; this reads the same way against "About". */
69 const SITE_OWNER = 'Hyukjin An';
70 const pageName = currentNav?.label ?? SITE_OWNER;
71
72 /* The author's repository browser and social profiles. */
73 const ELSEWHERE = [
74 { label: 'GitHub', href: 'https://oni.sqzer.com' },
75 { label: 'LinkedIn', href: 'https://www.linkedin.com/in/sqzer' },
76 { label: 'Instagram', href: 'https://www.instagram.com/sqzer_x' },
77 ];
78
79 /* Copyright range. The opening year is fixed; the closing year is the build's, in UTC like
80 every other date on this site. Stamping it at build time rather than typing 2026 means the
81 notice cannot quietly go stale — and if a year passes with no push, it correctly keeps
82 showing the year of the last publication rather than a year nothing was published in. */
83 const SITE_YEAR_FROM = 2009;
84 const buildYear = new Date().getUTCFullYear();
85 ---
86 <!doctype html>
87 <html lang="en">
88 <head>
89 <meta charset="utf-8" />
90 <meta name="viewport" content="width=device-width, initial-scale=1" />
91 <title>{title}</title>
92 {description && <meta name="description" content={description} />}
93 <link rel="canonical" href={new URL(here, Astro.site)} />
94 <link rel="alternate" type="application/rss+xml" title="debuman" href="/index.xml" />
95 {/* The icon is the author's own portrait, cropped square on the head rather than
96 scaled from the 4:5 original — a whole portrait shrunk to 16px is a smudge, where
97 a head fills the frame and still reads.
98 No SVG icon: this is a photograph, and the .ico carries 16, 32 and 48 for the
99 sizes that actually get asked for. */}
100 <link rel="icon" href="/favicon.ico" sizes="48x48" />
101 <link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
102 <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
103 {/* One Latin face, so this is unconditional: every page paints Lora. The Korean
104 face follows needsKo. */}
105 <link rel="preload" href="/fonts/latin-9bba8f25.woff2" as="font" type="font/woff2" crossorigin />
106 {needsKo && <link rel="preload" href="/fonts/ko-02f8cebd.woff2" as="font" type="font/woff2" crossorigin />}
107 </head>
108 <body class={`is-${section}`}>
109 <a class="skip" href="#main">Skip to content</a>
110 <header>
111 {/* A span, not a link and not a heading. Once the slot reads "Papers" an href="/"
112 on it is a link whose visible name is not its destination, and the same control
113 would take five different names across the site; the reference's own slot is a
114 plain div for that reason. / is already in the nav as "About". Not a heading
115 either: /papers/, /talk/, /code/ and /writing/ each already carry an h1 holding
116 exactly this word, and a second one inside the banner landmark would duplicate
117 it. */}
118 <span class="page-name">{pageName}</span>
119 {/* Named, as the page's other navs are. "page" only on the page itself: on an
120 article, Writing is the section it belongs to but a link to another page, and
121 "true" says exactly that. */}
122 <nav aria-label="Primary">
123 {NAV.map((n) => (
124 <a href={n.href} aria-current={here === n.href ? 'page' : isCurrent(n.href) ? 'true' : undefined}>{n.label}</a>
125 ))}
126 </nav>
127 </header>
128 <main id="main"><slot /></main>
129 <footer class="site-foot">
130 <p>Copyright © {SITE_YEAR_FROM}–{buildYear} {SITE_OWNER}</p>
131 {/* rel="me" because these are identity claims, not references — it is what lets a
132 profile link back and have the pair mean something. No target="_blank": the site
133 opens nothing in a new tab anywhere else, and the footer is not the place to
134 start. Text rather than the icons sqzer.com uses, because this design has no
135 icon vocabulary in its chrome and one pair of glyphs would be the whole of it. */}
136 <nav class="site-foot__social" aria-label="Elsewhere">
137 {ELSEWHERE.map((n) => <a href={n.href} rel="me">{n.label}</a>)}
138 </nav>
139 </footer>
140 </body>
141 </html>