# The one inline script, and the hash that lets it run > Dark mode without a flash needs a script that runs before the page paints, and a strict CSP wants to know exactly which one. Here's how they get along. 9 October 2026 · #security #astro · https://blog.peng.ly/one-inline-script/ The theme toggle has a classic problem. Your pick lives in `localStorage`, and nothing can read it before the page paints unless a script runs first. Load that script the normal way and you get a flash of the wrong theme on every single page. Not exactly ideal. ## The script It goes inline in the ``, before any CSS, so it runs before anything gets drawn: ```js title="src/lib/theme.ts" try { const theme = localStorage.getItem('theme'); if (theme) document.documentElement.dataset.theme = theme; } catch {} ``` The real one's squashed onto a single line, but it's the same thing. The `try` is there because browsers throw if you touch storage while cookies are blocked, and a theme isn't worth breaking the page over. ## The policy This site sends a Content Security Policy that only lets scripts from its own origin run. An inline script breaks that rule, so the policy names this one by its SHA-256 hash: ```diff - script-src 'self' 'unsafe-inline' + script-src 'self' 'sha256-1Ub7...' ``` `'unsafe-inline'` would've worked too, but then any script that got injected into a page would run as well, which is the exact thing CSP is there to stop. ### Keeping the two in step Change one character of the script and the hash stops matching. The browser blocks it, the console grumbles, and the theme starts flashing again. Nothing on the page tells you. So there's a test that hashes the script and checks `vercel.json` still has it: ```ts title="src/lib/theme.test.ts" {4} test("vercel.json's CSP trusts the theme script as it is now", async () => { const { headers } = JSON.parse(await readFile('vercel.json', 'utf8')); // ...find the Content-Security-Policy header... const hash = `'sha256-${createHash('sha256').update(themeScript).digest('base64')}'`; assert.ok(csp.includes(hash), `script-src wants ${hash}`); }); ``` If it fails, the message has the new hash in it, ready to paste. ## Everything else Every other script here ships as its own file, so `'self'` covers it. The JSON-LD blocks look like scripts but browsers never run them, so CSP leaves them alone. Styles get `'unsafe-inline'`, because the syntax highlighting puts its colours in `style` attributes, and an injected style is a much smaller worry than an injected script. You can see the whole policy in the response headers of this page, if you fancy a look. --- # How a post gets written here > Folders, frontmatter, and the three MDX components worth the faff - callouts, figures with captions, and videos that only load when you ask. 8 October 2026 · #astro #mdx #writing · https://blog.peng.ly/how-a-post-gets-written-here/ A post is a folder in `posts/`, named after the address you want. Inside goes `index.md`, or `index.mdx` if you want components, plus any pictures. This one lives at `posts/how-a-post-gets-written-here/index.mdx`. ## The frontmatter Every post starts with a block like this, and the build checks it. Leave out the description, put a capital in a tag or misspell a field, and the build stops and tells you what's wrong. ```yaml title="index.md" --- title: How a post gets written here description: Between 50 and 160 characters, since that's about what Google shows date: 2026-10-08 updated: 2026-10-09 # optional tags: [astro, mdx] # lowercase, with hyphens if you need them draft: true # optional, drafts only show on the dev server cover: # optional src: ./cover.png alt: What's in the picture --- ``` ## Callouts Four flavours, for the bits you'd otherwise put in bold and hope for the best. > [!NOTE] > A note, for context that's handy but not essential. > [!TIP] > **Pro tip** > The title's optional, each type has its own. > [!WARNING] > Something that'll catch you out if you're not paying attention. > [!CAUTION] > Something that breaks things, or leaks something it shouldn't. ```mdx The title's optional, each type has its own. ``` ## Figures Pictures get turned into AVIF and WebP at a few sizes, so a phone never downloads the desktop one. Wrap one in a `Figure` and you get a caption as well. ![A post page, with the date, reading time, tags and contents in a column to the left of the text](https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/how-a-post-gets-written-here/rail.png) _The rail, where the date, reading time, tags and contents live, out of the way of the words_ Plain Markdown images work too, they just don't get a caption. ## Video Embeds load nothing from YouTube until you press play, and even then it's the no-cookie player. A video file sat next to the post works as well, with `src` instead of `youtube`. [Video: Me at the zoo, the first video ever uploaded to YouTube](https://www.youtube.com/watch?v=jNQXAC9IVRw) [Video: The theme toggle on this blog, flipping between light and dark](https://raw.githubusercontent.com/NotAFlightRisk/blog/main/posts/how-a-post-gets-written-here/toggle.webm) ```mdx