Base.astro (9618B)
1 --- 2 import { ClientRouter } from 'astro:transitions'; 3 import Header from '../components/Header.astro'; 4 import Footer from '../components/Footer.astro'; 5 import SearchModal from '../components/SearchModal.astro'; 6 import { url } from '../lib/url'; 7 import { serialize, type JsonLd } from '../lib/jsonld'; 8 import '../styles/app.css'; 9 // Preload the two faces that paint above the fold — the wordmark in the 10 // masthead and the DM Mono the masthead/labels use — so the header does not flash 11 // its fallback on first paint. The rest load on demand via @font-face. 12 import notoWordmark from '../fonts/noto-sans-display-var-latin.woff2'; 13 import dmMono from '../fonts/dm-mono-400.woff2'; 14 15 interface Props { 16 title?: string; 17 description?: string; 18 railless?: boolean; 19 /** schema.org objects for this page; see src/lib/jsonld.ts */ 20 jsonLd?: JsonLd | JsonLd[]; 21 } 22 const { 23 jsonLd, 24 title = 'DÆMONBins — the Off-the-Land Almanac', 25 description = 'One filterable catalog merging GTFOBins, LOLBAS, WADComs and LOOBins — plus DÆMON additions. Living-off-the-land and offensive techniques by platform, access, environment and evidence state.', 26 } = Astro.props; 27 // Canonical = site origin + slash-less path (`trailingSlash: 'never'` in 28 // astro.config.mjs; vercel.json 308s `/x/` → `/x`, so a slashed canonical 29 // would point every page at a redirect). Only `/` keeps its slash. 30 const canonical = new URL(Astro.url.pathname.replace(/(.)\/$/, '$1'), Astro.site).href; 31 // Social card: crawlers need an absolute URL and a raster — the PNG is rendered 32 // from the SVG template by `npm run og` (scripts/og.mjs) and committed. 33 const ogImage = new URL('og.png', Astro.site).href; 34 --- 35 <!doctype html> 36 <html lang="en" data-theme="light"> 37 <head> 38 <meta charset="utf-8" /> 39 <meta name="viewport" content="width=device-width, initial-scale=1" /> 40 <title>{title}</title> 41 <meta name="description" content={description} /> 42 <link rel="canonical" href={canonical} /> 43 <link rel="icon" href={url('favicon.svg')} type="image/svg+xml" /> 44 <link rel="preload" href={notoWordmark} as="font" type="font/woff2" crossorigin /> 45 <link rel="preload" href={dmMono} as="font" type="font/woff2" crossorigin /> 46 <meta name="theme-color" content="#191724" media="(prefers-color-scheme: dark)" /> 47 <meta name="theme-color" content="#faf4ed" media="(prefers-color-scheme: light)" /> 48 <meta property="og:type" content="website" /> 49 <meta property="og:site_name" content="DÆMONBins" /> 50 <meta property="og:url" content={canonical} /> 51 <meta property="og:title" content={title} /> 52 <meta property="og:description" content={description} /> 53 <meta property="og:image" content={ogImage} /> 54 <meta property="og:image:width" content="1200" /> 55 <meta property="og:image:height" content="630" /> 56 <meta property="og:image:alt" content="DÆMONBins — the Off-the-Land Almanac: GTFOBins × LOLBAS × WADComs × LOOBins merged into one catalog" /> 57 <meta name="twitter:card" content="summary_large_image" /> 58 <meta name="twitter:title" content={title} /> 59 <meta name="twitter:description" content={description} /> 60 <meta name="twitter:image" content={ogImage} /> 61 {jsonLd && <script is:inline type="application/ld+json" set:html={serialize(jsonLd)} />} 62 <!-- Set theme before first paint to avoid FOUC --> 63 <script is:inline> 64 (function () { 65 function apply() { 66 try { 67 var display = JSON.parse(localStorage.getItem('daemonbins:display') || '{}'); 68 document.documentElement.dataset.motion = display.motion ? 'reduce' : 'system'; 69 var t = localStorage.getItem('theme'); 70 // No stored choice: follow the OS. Dawn (cream) is the default when 71 // the OS has no preference, matching the main DÆMON site — but a 72 // dark-mode reader should not get a cream page under dark chrome. 73 if (t !== 'light' && t !== 'dark') { 74 t = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 75 } 76 document.documentElement.setAttribute('data-theme', t); 77 document.documentElement.style.colorScheme = t; 78 } catch (e) { 79 document.documentElement.setAttribute('data-theme', 'light'); 80 } 81 } 82 83 apply(); 84 85 /* Every page ships `<html data-theme="light">` in its static markup, 86 because that is the default and the build has no way to know what 87 any given reader chose. On a view-transition navigation Astro 88 swaps in the new document — attributes on <html> included — so 89 that baked-in "light" overwrites the live attribute and the page 90 reverts to dawn mid-session. 91 92 This listener re-applies the stored choice on every swap. It runs 93 in `astro:after-swap`, which fires after the new document is in 94 place but before it is painted, so the correction never flashes. 95 96 The listener is registered once, from an inline head script that 97 executes on the first full load only — which is exactly why it 98 has to be `is:inline` and live here rather than in app.ts: a 99 bundled module re-imported per page would re-register it, and a 100 deferred one would run too late to beat the paint. */ 101 document.addEventListener('astro:after-swap', apply); 102 })(); 103 </script> 104 <!-- Page iris. Every internal navigation opens the next page as a 105 circle from the link that was pressed: the theme wipe's gesture, 106 at page pace. The CSS is beside the theme wipe in global.css; 107 this only supplies the origin and the end radius. --> 108 <script is:inline> 109 (function () { 110 function centre(el) { 111 var r = el.getBoundingClientRect(); 112 return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; 113 } 114 115 /* Where the last click landed. Capture phase, so it is recorded 116 before the ClientRouter's own listener takes the click. A click 117 with detail 0 came from the keyboard and has no pointer 118 position, so it opens from the middle of the control. */ 119 var press = null; 120 document.addEventListener('click', function (e) { 121 var at = e.detail === 0 && e.target instanceof Element 122 ? centre(e.target) 123 : { x: e.clientX, y: e.clientY }; 124 at.t = Date.now(); 125 press = at; 126 }, true); 127 128 var origin = null; 129 function paint() { 130 if (!origin) return; 131 var s = document.documentElement.style; 132 s.setProperty('--vt-x', origin.x + 'px'); 133 s.setProperty('--vt-y', origin.y + 'px'); 134 s.setProperty('--vt-r', origin.r + 'px'); 135 } 136 137 /* A press older than a second did not cause this navigation (back, 138 forward, a scripted one), so it falls back to the link Astro 139 reports, then to the middle of the screen. The radius reaches 140 the farthest corner exactly, so the circle finishes as it 141 covers the page rather than part-way through an overshoot. */ 142 document.addEventListener('astro:before-preparation', function (e) { 143 var at = press && Date.now() - press.t < 1000 ? press 144 : e.sourceElement instanceof Element ? centre(e.sourceElement) 145 : { x: innerWidth / 2, y: innerHeight / 2 }; 146 origin = { 147 x: at.x, 148 y: at.y, 149 r: Math.hypot(Math.max(at.x, innerWidth - at.x), Math.max(at.y, innerHeight - at.y)), 150 }; 151 paint(); 152 }); 153 154 /* The swap replaces <html>'s attributes with the new page's, and 155 the inline style carrying these three goes with them, so they 156 are put back before the new page is painted, for the same 157 reason as the theme note above. */ 158 document.addEventListener('astro:after-swap', paint); 159 })(); 160 </script> 161 {import.meta.env.PROD && ( 162 <Fragment> 163 {/* Vercel Web Analytics + Speed Insights. Zero-dependency: Vercel serves 164 these scripts at the edge once the features are enabled for the 165 project (Vercel dashboard → Analytics / Speed Insights). They live in 166 <head> so Astro's ClientRouter keeps them across view transitions 167 rather than re-running them per navigation. Privacy-friendly and 168 cookieless; no-op in dev. */} 169 <script is:inline>window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };</script> 170 <script is:inline defer src="/_vercel/insights/script.js"></script> 171 <script is:inline>window.si = window.si || function () { (window.siq = window.siq || []).push(arguments); };</script> 172 <script is:inline defer src="/_vercel/speed-insights/script.js"></script> 173 </Fragment> 174 )} 175 <ClientRouter /> 176 </head> 177 <body> 178 <a class="skip-link" href="#main">Skip to content</a> 179 <!-- The film over the whole page. It is what keeps a flat colour 180 reading as stock rather than as a swatch, and it stands in for the 181 texture the old glass build got from blur. --> 182 <span class="grain" aria-hidden="true"></span> 183 <!-- The dot field behind the top of the page (src/scripts/dmn). --> 184 <div class="dmn-page-field" aria-hidden="true"><canvas data-dots></canvas></div> 185 <Header /> 186 <main id="main"> 187 <slot /> 188 </main> 189 <Footer /> 190 <SearchModal /> 191 <script> 192 import '../scripts/app.ts'; 193 </script> 194 </body> 195 </html>