[{"data":1,"prerenderedAt":1627},["ShallowReactive",2],{"blog-posts":3},[4,246,1468],{"id":5,"title":6,"author":7,"body":8,"coverImage":230,"description":231,"draft":232,"extension":233,"meta":234,"navigation":235,"path":236,"publishedAt":237,"readingTime":238,"seo":239,"stem":240,"tags":241,"__hash__":245},"blog\u002Fblog\u002Fwatching-the-watchers.md","Watching the watchers","Evan Ritter",{"type":9,"value":10,"toc":220},"minimark",[11,16,20,23,26,34,38,41,48,54,60,66,69,73,76,95,98,108,126,129,132,136,143,146,149,152,156,159,165,168,174,180,187,191,194,197,204,207,211,214,217],[12,13,15],"h2",{"id":14},"a-pi-a-dvb-t-hat-and-the-surprising-difficulty-of-knowing-whats-on-television","A Pi, a DVB-T HAT, and the surprising difficulty of knowing what's on television",[17,18,19],"p",{},"A mast on a hill, a few hundred metres of cable, an aerial on the roof, and you have access to most of what the country is putting out tonight. It's an unglamorous bit of infrastructure that has been quietly working in the background of my life for years.",[17,21,22],{},"A few months ago I got curious. What is the local transmitter actually sending right now? Not what the EPG says. Not what the listings magazines say. What's actually coming down the air, mux by mux, channel by channel, second by second?",[17,24,25],{},"The EPG is a guide. It's a promise. It's not a record of fact. If you want to know what was actually broadcast at 19:42 last Tuesday on your regional BBC One, the EPG isn't going to tell you. Neither, really, is anything else easily accessible. So I built a thing.",[17,27,28,29,33],{},"This is the story of that thing. It's a Raspberry Pi that watches television. Not streams it. Not records it for later. ",[30,31,32],"em",{},"Watches"," it — in the sense that it tries to understand what's on screen and write down what it sees.",[12,35,37],{"id":36},"the-shopping-list","The shopping list",[17,39,40],{},"The hardware was the easy part. The whole stack is off-the-shelf and reasonably cheap.",[17,42,43,47],{},[44,45,46],"strong",{},"Raspberry Pi 4B."," I started with the 2GB model and upgraded to the 4GB once I knew the project was going somewhere. I didn't go with a Pi 5 deliberately — it draws more power, runs hotter, and for a device that's going to sit on a shelf running 24 hours a day, the Pi 4 is more than capable. The Pi 5 is a better machine in absolute terms, but \"better\" isn't the same as \"right.\" For unattended always-on work, lower power and lower heat win. The 4GB Pi 4B is around £65–£85 in the UK depending on supplier.",[17,49,50,53],{},[44,51,52],{},"Sony CXD2880 DVB-T\u002FT2 HAT."," I went with a HAT rather than a USB tuner for a few reasons. The Pi's USB bus isn't fantastic — it shares bandwidth with the ethernet port on some models, and for sustained high-throughput work like streaming a transport stream to disk, you don't want anything competing. A HAT sits on the GPIO header, has its own clean power, and gets out of the way. The CXD2880 itself is genuinely good silicon — it's the chip inside a lot of consumer set-top boxes, and it does its job without drama. The official Raspberry Pi TV HAT is around £20 where you can still find it, though it's now being discontinued by some distributors, so worth grabbing one while they're around.",[17,55,56,59],{},[44,57,58],{},"SSD over HDD."," I went back and forth on this. An HDD is cheaper per gigabyte and for a project that's going to accumulate hours of recordings it's tempting. But spinning rust on a Pi is a bad idea for the long haul. SSDs draw less power, don't care about orientation or vibration, and don't have a mean-time-to-failure measured in spin-up cycles. I had a couple of suitable drives sitting in a drawer, which decided the matter — and that's just as well, because storage pricing in 2026 is having a moment. NAND flash is in global shortage thanks to AI data centre demand, SSD contract prices have doubled or worse since late 2024, and meaningful relief isn't expected before the end of the year at the earliest. If you're starting fresh, the advice is \"look around carefully and check the price the day you publish, because whatever I quote here will be wrong by the time you read it.\" Anything from 240GB upwards is plenty for the project.",[17,61,62,65],{},[44,63,64],{},"An aerial that already worked."," This is the bit nobody mentions in Pi project posts. Reception matters enormously, and the closer you are to a strong transmitter the easier everything is. I have a decent rooftop aerial that's been there longer than I have, with a strong, clean signal. If you tried to do this with a marginal indoor aerial in a fringe area, your problems would multiply considerably. Strong signal in, strong stream out — that's the whole game.",[17,67,68],{},"That's the hardware. The Pi and the HAT together are well under a hundred quid; the SSD is whatever today's market is doing to you. The interesting parts come next.",[12,70,72],{"id":71},"recording-the-stream","Recording the stream",[17,74,75],{},"If you Google \"record DVB-T on Raspberry Pi\" you'll mostly land on tutorials involving ffmpeg or VLC. Both work. Neither is what you want for unattended 24\u002F7 capture.",[17,77,78,79,83,84,86,87,90,91,94],{},"The tool I ended up using is ",[80,81,82],"code",{},"tzap",". It's old, it's lower-level, and the documentation isn't great, which is probably why it doesn't appear in the first ten Google results. What ",[80,85,82],{}," does is tune the demodulator on the DVB hardware to a specific multiplex and lock onto a transport stream. Once it's locked, the raw demultiplexed stream becomes available at ",[80,88,89],{},"\u002Fdev\u002Fdvb\u002Fadapter0\u002Fdvr0",". You can read from that file with plain ",[80,92,93],{},"cat"," and redirect to disk, and what you get is a clean MPEG-TS recording of exactly what came down the air.",[17,96,97],{},"That's it. That's the recording stack. No transcoding. No format conversion. No software trying to be helpful. The Pi tunes the tuner, opens a pipe, and shuffles bytes from the antenna to the SSD. It's about as close as you can get to \"plug the aerial into the file.\"",[99,100,105],"pre",{"className":101,"code":103,"language":104},[102],"language-text","tzap -c ~\u002Fchannels_final.conf -r \"BBC ONE S West\" &\ntimeout 3600 cat \u002Fdev\u002Fdvb\u002Fadapter0\u002Fdvr0 > recording.ts\n","text",[80,106,103],{"__ignoreMap":107},"",[17,109,110,111,114,115,118,119,121,122,125],{},"The one piece that's genuinely fiddly is the ",[80,112,113],{},"channels.conf"," file. It's a colon-separated list of tuning parameters: frequency, modulation type, transmission mode, guard interval, FEC, hierarchy, and so on. If you've ever looked at a ",[80,116,117],{},"wpa_supplicant.conf"," and thought \"I wonder how much worse this could get,\" ",[80,120,113],{}," is the answer. The good news is you only have to assemble it once, and there are tools (",[80,123,124],{},"w_scan"," is one) that will do most of the work for you by sweeping the band and noting what's there.",[17,127,128],{},"Once you've got it, recording is the easy part. The file lands on disk, MPEG-2, 704×576, 25 frames per second. SD by modern standards — good enough for a 2002 television and that's exactly what it is.",[17,130,131],{},"Now you have a file. The file is just bytes. The hard bit comes next.",[12,133,135],{"id":134},"the-thing-nobody-tells-you-about-uk-dtt","The thing nobody tells you about UK DTT",[17,137,138,139,142],{},"I had assumed, when I started, that the EPG data was just ",[30,140,141],{},"in there",". DVB transport streams can carry EPG information in something called the EIT — the Event Information Table. It's part of the spec. Set-top boxes use it. Your TV uses it. Why wouldn't I be able to parse it and just know what's on?",[17,144,145],{},"I spent longer than I'd like to admit trying to work this out. The short version: on UK DTT, particularly on the older MPEG-2 SD multiplexes, the EIT data carried in the stream is patchy at best. Some channels carry a present\u002Fnext entry that tells you what's on now and what's on next, sometimes with start times that lag reality. Some carry essentially nothing useful. The full schedule data that your TV uses to populate its EPG is, in many cases, being pulled from elsewhere or filled in from cached data the receiver has built up over time. Pointing a DVB tuner at a multiplex and saying \"tell me what's on\" doesn't get you the answer you'd expect.",[17,147,148],{},"This was, in a way, the moment the project became interesting. If the EPG had been embedded and reliable, the problem would have been a parsing exercise. Read the EIT, write the answer to a database, go home. The fact that it wasn't meant the only way to know what was actually on the screen was to look at the actual screen.",[17,150,151],{},"Constraint is the engine of interesting engineering. The simple solution wasn't available, so a harder solution became necessary.",[12,153,155],{"id":154},"looking-at-the-picture","Looking at the picture",[17,157,158],{},"This is where the project stops being about broadcast infrastructure and starts being about computer vision. And it's also the part where I'm going to be a bit careful, because some of what I've worked out is genuinely useful and I'd rather not have it copy-pasted into someone else's weekend project. But the general principles are worth talking about.",[17,160,161,164],{},[44,162,163],{},"Downscale first."," The native stream is 704×576. That's about 400,000 pixels per frame. For the kind of analysis I'm doing — recognising the general visual character of what's on screen — that's massively more detail than I need. I downscale aggressively, to 320×180, before doing anything else. The features that matter for this kind of work (broad scene structure, logo presence, on-screen text, overall colour distribution) survive the downscale fine. The features that don't matter (face detail, fabric texture, individual leaves on a tree) disappear into the noise where they belong.",[17,166,167],{},"The principle generalises: throw away pixels until the pixels you have left are the ones doing real work. People reach for more resolution by default because more is more. For analysis on a constrained device, more is usually just slower.",[17,169,170,173],{},[44,171,172],{},"Don't process every frame."," 25 frames per second is 25 chances to look at essentially the same picture. The interesting events on a TV screen — a programme starting, an advert break, a station ident, a scene change — happen on second-or-longer timescales. I sample at three-second intervals. Anything that changes faster than that is either an advert or noise, and you can deal with both separately. The Pi spends most of its time doing nothing, which is exactly what you want for an always-on device that should run cool and last.",[17,175,176,179],{},[44,177,178],{},"OpenCV is enough."," I'm not using TensorFlow. I'm not using a GPU. There is no model to train. Vanilla OpenCV running on the Pi 4 is more than capable of the analysis I'm doing, and the principle behind that is one I'd encourage anyone working in this space to internalise: a lot of problems that get framed as \"AI problems\" are actually classical computer vision problems with sensible preprocessing. Reaching for the heavy ML stack too quickly is a way of paying a lot of complexity tax for something that didn't need it.",[17,181,182,183,186],{},"What the Pi is actually ",[30,184,185],{},"looking at"," — the features it's extracting, how it's deciding what kind of programme is on, how it handles transitions and edge cases — is the part I'm not going to go into. Partly because it's still evolving and I don't want to write down something I'll regret in three months, and partly because that's where the genuinely hard work has been done. The hardware is the easy bit. The pipeline is the easy bit. Working out what the pictures actually mean is where the time has gone.",[12,188,190],{"id":189},"what-it-can-and-cant-do","What it can and can't do",[17,192,193],{},"I want to be honest about this, because I think this is the most useful part of the post for anyone considering a similar project.",[17,195,196],{},"It works well enough to be interesting. It can tell you, broadly, what kind of programme is on at any given moment. It can spot an advert break. It can notice when one programme ends and another begins, most of the time. As a personal curiosity project running on a Pi on a shelf in Cornwall, telling me what came down the air from Redruth this afternoon, it is doing exactly what I wanted it to do.",[17,198,199,200,203],{},"Making it ",[30,201,202],{},"reliable",", in the sense that you could trust it to produce a clean, accurate, complete log of what was broadcast across all the awkward cases, is a much harder problem. Continuity announcers talking over the start of the next programme. Mid-programme idents and \"next\" promos. Ad breaks that don't quite line up with where you'd expect. Regional opt-outs where Cornwall sees something different from London. Late schedule changes. Live programmes that overrun. The cases where the picture is genuinely ambiguous because the broadcaster has chosen to make it ambiguous — fades through black between an end-credits roll and a station ident, for example — are real and they're hard, and any honest treatment of this problem has to acknowledge them.",[17,205,206],{},"I haven't solved those. I've solved enough of them to find the project rewarding. The gap between \"interesting weekend project\" and \"reliable production system\" in this space is enormous, and I've been able to see the shape of that gap more clearly the further into the project I've gone.",[12,208,210],{"id":209},"why-bother","Why bother?",[17,212,213],{},"A reasonable question. The honest answer is: because I wanted to. The project lets me poke at a piece of infrastructure that's been part of my daily life for years, and the engineering is genuinely fun. Computer vision on a Pi watching MPEG-2 SD off an aerial is the kind of project that hits a sweet spot between \"old enough to be approachable\" and \"constrained enough to be interesting.\"",[17,215,216],{},"I've learned more about DVB-T, DTT muxing, MPEG transport streams, and classical computer vision in the last few months than in the previous ten years. The country broadcasts; the Pi watches; the disk fills up with a record of what actually happened.",[17,218,219],{},"It turns out there's quite a lot worth knowing in there. If any of this sounds interesting, the parts are cheap (well — most of them), the documentation is patchy, and the rabbit hole is deeper than it looks. Mind your step.",{"title":107,"searchDepth":221,"depth":221,"links":222},2,[223,224,225,226,227,228,229],{"id":14,"depth":221,"text":15},{"id":36,"depth":221,"text":37},{"id":71,"depth":221,"text":72},{"id":134,"depth":221,"text":135},{"id":154,"depth":221,"text":155},{"id":189,"depth":221,"text":190},{"id":209,"depth":221,"text":210},"\u002Fblog\u002Fwatching-the-watchers\u002Fcover.png","A Pi, a DVB-T HAT, and the surprising difficulty of knowing what's on television.",false,"md",{},true,"\u002Fblog\u002Fwatching-the-watchers","2026-05-23",11,{"title":6,"description":231},"blog\u002Fwatching-the-watchers",[242,243,244],"raspberry-pi","broadcasting","computer-vision","IDJ7zGnbzJA-GTRLIzE3V51AU3mBYnSqclDqYDcS364",{"id":247,"title":248,"author":7,"body":249,"coverImage":1457,"description":1458,"draft":232,"extension":233,"meta":1459,"navigation":235,"path":1460,"publishedAt":1461,"readingTime":387,"seo":1462,"stem":1463,"tags":1464,"__hash__":1467},"blog\u002Fblog\u002Fnuxt-4-azure-swa.md","Deploying Nuxt 4 to Azure Static Web Apps: the configuration that actually worked",{"type":9,"value":250,"toc":1446},[251,254,257,261,264,296,299,429,440,444,447,479,500,508,511,515,526,572,582,601,610,613,620,626,652,655,676,682,696,700,703,1205,1208,1253,1257,1260,1273,1288,1301,1360,1370,1376,1389,1393,1396,1416,1419,1423,1433,1442],[17,252,253],{},"If you've landed here from a search engine at an unsociable hour because your Nuxt 4 deployment to Azure Static Web Apps is failing in a way that none of the existing tutorials describe — skip to the next section. The configuration is there. You can come back for the explanation later.",[17,255,256],{},"For everyone else: Nuxt 4 and Azure Static Web Apps are both perfectly reasonable choices, and they go together perfectly well, but the path between them is paved with stale documentation. Most of what you'll find online assumes Nuxt 3, uses the wrong Nitro preset, or relies on the SWA GitHub Action's default build behaviour in ways that quietly break when you upgrade. The working setup is small, but the path to it isn't obvious. This post is the post I wish I'd found.",[12,258,260],{"id":259},"the-tldr-config","The TL;DR config",[17,262,263],{},"Three things matter:",[265,266,267,279,289],"ol",{},[268,269,270,271,274,275,278],"li",{},"Build the site with ",[80,272,273],{},"npx nuxi generate"," rather than ",[80,276,277],{},"nuxt build",".",[268,280,281,282,285,286,278],{},"Point the SWA action at ",[80,283,284],{},".output\u002Fpublic"," as the ",[80,287,288],{},"app_location",[268,290,291,292,295],{},"Set ",[80,293,294],{},"skip_app_build: true"," so Oryx doesn't try to rebuild what you've already built.",[17,297,298],{},"Here's the relevant slice of the GitHub Actions workflow:",[99,300,304],{"className":301,"code":302,"language":303,"meta":107,"style":107},"language-yaml shiki shiki-themes github-light github-dark","- name: Build Nuxt app\n  run: npx nuxi generate\n\n- name: Deploy to Azure Static Web Apps\n  uses: Azure\u002Fstatic-web-apps-deploy@v1\n  with:\n    azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}\n    repo_token: ${{ secrets.GITHUB_TOKEN }}\n    action: \"upload\"\n    app_location: \".output\u002Fpublic\"\n    skip_app_build: true\n","yaml",[80,305,306,326,336,342,354,365,374,385,396,407,418],{"__ignoreMap":107},[307,308,311,315,319,322],"span",{"class":309,"line":310},"line",1,[307,312,314],{"class":313},"sVt8B","- ",[307,316,318],{"class":317},"s9eBZ","name",[307,320,321],{"class":313},": ",[307,323,325],{"class":324},"sZZnC","Build Nuxt app\n",[307,327,328,331,333],{"class":309,"line":221},[307,329,330],{"class":317},"  run",[307,332,321],{"class":313},[307,334,335],{"class":324},"npx nuxi generate\n",[307,337,339],{"class":309,"line":338},3,[307,340,341],{"emptyLinePlaceholder":235},"\n",[307,343,345,347,349,351],{"class":309,"line":344},4,[307,346,314],{"class":313},[307,348,318],{"class":317},[307,350,321],{"class":313},[307,352,353],{"class":324},"Deploy to Azure Static Web Apps\n",[307,355,357,360,362],{"class":309,"line":356},5,[307,358,359],{"class":317},"  uses",[307,361,321],{"class":313},[307,363,364],{"class":324},"Azure\u002Fstatic-web-apps-deploy@v1\n",[307,366,368,371],{"class":309,"line":367},6,[307,369,370],{"class":317},"  with",[307,372,373],{"class":313},":\n",[307,375,377,380,382],{"class":309,"line":376},7,[307,378,379],{"class":317},"    azure_static_web_apps_api_token",[307,381,321],{"class":313},[307,383,384],{"class":324},"${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}\n",[307,386,388,391,393],{"class":309,"line":387},8,[307,389,390],{"class":317},"    repo_token",[307,392,321],{"class":313},[307,394,395],{"class":324},"${{ secrets.GITHUB_TOKEN }}\n",[307,397,399,402,404],{"class":309,"line":398},9,[307,400,401],{"class":317},"    action",[307,403,321],{"class":313},[307,405,406],{"class":324},"\"upload\"\n",[307,408,410,413,415],{"class":309,"line":409},10,[307,411,412],{"class":317},"    app_location",[307,414,321],{"class":313},[307,416,417],{"class":324},"\".output\u002Fpublic\"\n",[307,419,420,423,425],{"class":309,"line":238},[307,421,422],{"class":317},"    skip_app_build",[307,424,321],{"class":313},[307,426,428],{"class":427},"sj4cs","true\n",[17,430,431,432,435,436,439],{},"No custom Nitro preset. No ",[80,433,434],{},"output_location",". No ",[80,437,438],{},"api_location"," unless you actually have a separate API. If you only need a working deploy, that's the answer — bookmark this and get some sleep.",[12,441,443],{"id":442},"why-this-combination-is-awkward-in-2026","Why this combination is awkward in 2026",[17,445,446],{},"The friction isn't anyone's fault. It's the sum of three reasonable design decisions that don't quite line up.",[17,448,449,452,453,456,457,459,460,463,464,466,467,470,471,474,475,478],{},[44,450,451],{},"Nuxt 4 changed the build output structure."," The ",[80,454,455],{},".output"," directory is now the canonical place where everything lands, with ",[80,458,284],{}," for static assets and ",[80,461,462],{},".output\u002Fserver"," for the Nitro server bundle when there is one. For a fully static deploy — which is what SWA wants — ",[80,465,284],{}," is the directory you care about. Older guides point at ",[80,468,469],{},".nuxt\u002Fdist",", ",[80,472,473],{},"dist\u002F",", or ",[80,476,477],{},"public\u002F",". None of those are right for Nuxt 4.",[17,480,481,484,485,470,488,491,492,495,496,499],{},[44,482,483],{},"Nitro has a long list of deployment presets",", including ",[80,486,487],{},"azure",[80,489,490],{},"azure-functions",", and ",[80,493,494],{},"azure-static",". For a static-generated site going to Static Web Apps, you don't need any of them. Setting one will either build a server bundle you don't want, or build for an Azure target that doesn't match the SWA action's expectations. The cleanest answer is to leave the preset alone and run ",[80,497,498],{},"nuxi generate",", which produces static output by default.",[17,501,502,505,506,278],{},[44,503,504],{},"The SWA GitHub Action wants to be helpful."," By default, when you point it at a source directory it runs Oryx, Microsoft's auto-build system, to detect your framework and build the site for you. Oryx's Nuxt support lags behind current Nuxt releases by enough that, at the time of writing, it will quietly produce broken output for a Nuxt 4 project. The fix isn't to teach Oryx about Nuxt 4; it's to build the site yourself in an earlier step and then tell the action not to bother by setting ",[80,507,294],{},[17,509,510],{},"Each of those decisions is sensible in isolation. Together, they mean the obvious thing — point the SWA action at your repo and let it figure things out — doesn't work, and the fix involves disabling helpful defaults across two different systems.",[12,512,514],{"id":513},"the-nitro-preset-question","The Nitro preset question",[17,516,517,518,521,522,525],{},"Nitro is the server engine underneath Nuxt 3 and 4. It produces deployable output tailored to a target platform — a Vercel build for Vercel, a Cloudflare Workers build for Cloudflare, a Node server for traditional hosting, and so on. You pick the target with a preset, either via the ",[80,519,520],{},"NITRO_PRESET"," environment variable or in ",[80,523,524],{},"nuxt.config.ts",":",[99,527,531],{"className":528,"code":529,"language":530,"meta":107,"style":107},"language-ts shiki shiki-themes github-light github-dark","export default defineNuxtConfig({\n  nitro: {\n    preset: 'node-server'\n  }\n})\n","ts",[80,532,533,549,554,562,567],{"__ignoreMap":107},[307,534,535,539,542,546],{"class":309,"line":310},[307,536,538],{"class":537},"szBVR","export",[307,540,541],{"class":537}," default",[307,543,545],{"class":544},"sScJk"," defineNuxtConfig",[307,547,548],{"class":313},"({\n",[307,550,551],{"class":309,"line":221},[307,552,553],{"class":313},"  nitro: {\n",[307,555,556,559],{"class":309,"line":338},[307,557,558],{"class":313},"    preset: ",[307,560,561],{"class":324},"'node-server'\n",[307,563,564],{"class":309,"line":344},[307,565,566],{"class":313},"  }\n",[307,568,569],{"class":309,"line":356},[307,570,571],{"class":313},"})\n",[17,573,574,575,470,577,491,579,581],{},"For Azure specifically, Nitro ships three presets: ",[80,576,487],{},[80,578,490],{},[80,580,494],{},". The names look like they map cleanly to Azure services. They don't, quite.",[583,584,585,593],"ul",{},[268,586,587,589,590,592],{},[80,588,487],{}," and ",[80,591,490],{}," build a server bundle deployed as Azure Functions. Useful for SSR scenarios on Static Web Apps' managed Functions backend or on Azure Functions directly. Not what you want for a static site.",[268,594,595,597,598,600],{},[80,596,494],{}," builds for Static Web Apps' static hosting. Closer to what you want, but it produces output in a structure tailored to the SWA action's older expectations, and in practice ",[80,599,498],{}," with no preset is simpler and produces output that the action handles cleanly.",[17,602,603,604,606,607,609],{},"If you don't need server-side rendering and you're happy with a fully static site — which is the common case for marketing sites, documentation, blogs, and most of what SWA is good at — then ",[80,605,498],{}," is the right command and you can ignore Nitro presets entirely. Nuxt will produce static HTML for every route it can pre-render, drop it in ",[80,608,284],{},", and that's the directory the SWA action uploads.",[17,611,612],{},"If you do need SSR, Static Web Apps isn't the wrong choice, but the configuration is meaningfully different and worth a separate post. Most of the time, ask yourself whether you actually need SSR before reaching for it. For a content site rendered from Nuxt Content, you don't.",[12,614,616,617,619],{"id":615},"the-app_location-trap","The ",[80,618,288],{}," trap",[17,621,622,623,625],{},"The single most confusing parameter in the SWA action is ",[80,624,288],{},". The documentation describes it as \"the location of your application code\" — which is technically correct and practically misleading.",[17,627,628,629,631,632,635,636,639,640,642,643,635,645,648,649,651],{},"What ",[80,630,288],{}," actually does depends on whether Oryx is going to build your app. If ",[80,633,634],{},"skip_app_build"," is ",[80,637,638],{},"false"," (the default), Oryx looks at ",[80,641,288],{}," for source code to build. If ",[80,644,634],{},[80,646,647],{},"true",", the action treats ",[80,650,288],{}," as the directory of already-built output to upload.",[17,653,654],{},"So the same parameter means two different things depending on a sibling parameter. The combination you want for Nuxt 4 is:",[99,656,658],{"className":301,"code":657,"language":303,"meta":107,"style":107},"app_location: \".output\u002Fpublic\"\nskip_app_build: true\n",[80,659,660,668],{"__ignoreMap":107},[307,661,662,664,666],{"class":309,"line":310},[307,663,288],{"class":317},[307,665,321],{"class":313},[307,667,417],{"class":324},[307,669,670,672,674],{"class":309,"line":221},[307,671,634],{"class":317},[307,673,321],{"class":313},[307,675,428],{"class":427},[17,677,678,679,681],{},"Which reads as: \"Don't build anything. The static output is already sitting in ",[80,680,284],{},". Upload that.\"",[17,683,684,685,589,688,691,692,695],{},"You'll see older guides set ",[80,686,687],{},"app_location: \"\u002F\"",[80,689,690],{},"output_location: \"dist\""," and let Oryx do the build. That route can work for simple cases, but it leaves you at the mercy of Oryx's framework detection, which is the thing that breaks on every Nuxt major version bump. Building the site yourself in an earlier ",[80,693,694],{},"run"," step and pointing the action at the finished output is more verbose by one line and dramatically more predictable.",[12,697,699],{"id":698},"the-full-working-github-actions-workflow","The full working GitHub Actions workflow",[17,701,702],{},"Here's a complete, annotated workflow file. This is what's actually running in production for the kind of Nuxt 4 sites this post is about:",[99,704,706],{"className":301,"code":705,"language":303,"meta":107,"style":107},"name: Deploy to Azure Static Web Apps\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    types: [opened, synchronize, reopened, closed]\n    branches:\n      - main\n\njobs:\n  build_and_deploy:\n    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')\n    runs-on: ubuntu-latest\n    name: Build and deploy\n    steps:\n      - name: Checkout\n        uses: actions\u002Fcheckout@v4\n        with:\n          submodules: true\n\n      - name: Setup Node\n        uses: actions\u002Fsetup-node@v4\n        with:\n          node-version: 22\n          cache: npm\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Build Nuxt app\n        run: npx nuxi generate\n        env:\n          NUXT_PUBLIC_SITE_URL: ${{ vars.SITE_URL }}\n\n      - name: Deploy to Azure Static Web Apps\n        uses: Azure\u002Fstatic-web-apps-deploy@v1\n        with:\n          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}\n          repo_token: ${{ secrets.GITHUB_TOKEN }}\n          action: \"upload\"\n          app_location: \".output\u002Fpublic\"\n          skip_app_build: true\n\n  close_pull_request:\n    if: github.event_name == 'pull_request' && github.event.action == 'closed'\n    runs-on: ubuntu-latest\n    name: Close pull request\n    steps:\n      - name: Close Pull Request\n        uses: Azure\u002Fstatic-web-apps-deploy@v1\n        with:\n          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}\n          action: \"close\"\n",[80,707,708,716,720,727,734,741,749,756,785,791,797,801,809,817,828,839,850,858,870,881,889,899,904,916,926,933,944,955,960,972,983,988,999,1008,1016,1027,1032,1043,1052,1059,1069,1079,1089,1099,1109,1114,1122,1132,1141,1151,1158,1170,1179,1186,1195],{"__ignoreMap":107},[307,709,710,712,714],{"class":309,"line":310},[307,711,318],{"class":317},[307,713,321],{"class":313},[307,715,353],{"class":324},[307,717,718],{"class":309,"line":221},[307,719,341],{"emptyLinePlaceholder":235},[307,721,722,725],{"class":309,"line":338},[307,723,724],{"class":427},"on",[307,726,373],{"class":313},[307,728,729,732],{"class":309,"line":344},[307,730,731],{"class":317},"  push",[307,733,373],{"class":313},[307,735,736,739],{"class":309,"line":356},[307,737,738],{"class":317},"    branches",[307,740,373],{"class":313},[307,742,743,746],{"class":309,"line":367},[307,744,745],{"class":313},"      - ",[307,747,748],{"class":324},"main\n",[307,750,751,754],{"class":309,"line":376},[307,752,753],{"class":317},"  pull_request",[307,755,373],{"class":313},[307,757,758,761,764,767,769,772,774,777,779,782],{"class":309,"line":387},[307,759,760],{"class":317},"    types",[307,762,763],{"class":313},": [",[307,765,766],{"class":324},"opened",[307,768,470],{"class":313},[307,770,771],{"class":324},"synchronize",[307,773,470],{"class":313},[307,775,776],{"class":324},"reopened",[307,778,470],{"class":313},[307,780,781],{"class":324},"closed",[307,783,784],{"class":313},"]\n",[307,786,787,789],{"class":309,"line":398},[307,788,738],{"class":317},[307,790,373],{"class":313},[307,792,793,795],{"class":309,"line":409},[307,794,745],{"class":313},[307,796,748],{"class":324},[307,798,799],{"class":309,"line":238},[307,800,341],{"emptyLinePlaceholder":235},[307,802,804,807],{"class":309,"line":803},12,[307,805,806],{"class":317},"jobs",[307,808,373],{"class":313},[307,810,812,815],{"class":309,"line":811},13,[307,813,814],{"class":317},"  build_and_deploy",[307,816,373],{"class":313},[307,818,820,823,825],{"class":309,"line":819},14,[307,821,822],{"class":317},"    if",[307,824,321],{"class":313},[307,826,827],{"class":324},"github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')\n",[307,829,831,834,836],{"class":309,"line":830},15,[307,832,833],{"class":317},"    runs-on",[307,835,321],{"class":313},[307,837,838],{"class":324},"ubuntu-latest\n",[307,840,842,845,847],{"class":309,"line":841},16,[307,843,844],{"class":317},"    name",[307,846,321],{"class":313},[307,848,849],{"class":324},"Build and deploy\n",[307,851,853,856],{"class":309,"line":852},17,[307,854,855],{"class":317},"    steps",[307,857,373],{"class":313},[307,859,861,863,865,867],{"class":309,"line":860},18,[307,862,745],{"class":313},[307,864,318],{"class":317},[307,866,321],{"class":313},[307,868,869],{"class":324},"Checkout\n",[307,871,873,876,878],{"class":309,"line":872},19,[307,874,875],{"class":317},"        uses",[307,877,321],{"class":313},[307,879,880],{"class":324},"actions\u002Fcheckout@v4\n",[307,882,884,887],{"class":309,"line":883},20,[307,885,886],{"class":317},"        with",[307,888,373],{"class":313},[307,890,892,895,897],{"class":309,"line":891},21,[307,893,894],{"class":317},"          submodules",[307,896,321],{"class":313},[307,898,428],{"class":427},[307,900,902],{"class":309,"line":901},22,[307,903,341],{"emptyLinePlaceholder":235},[307,905,907,909,911,913],{"class":309,"line":906},23,[307,908,745],{"class":313},[307,910,318],{"class":317},[307,912,321],{"class":313},[307,914,915],{"class":324},"Setup Node\n",[307,917,919,921,923],{"class":309,"line":918},24,[307,920,875],{"class":317},[307,922,321],{"class":313},[307,924,925],{"class":324},"actions\u002Fsetup-node@v4\n",[307,927,929,931],{"class":309,"line":928},25,[307,930,886],{"class":317},[307,932,373],{"class":313},[307,934,936,939,941],{"class":309,"line":935},26,[307,937,938],{"class":317},"          node-version",[307,940,321],{"class":313},[307,942,943],{"class":427},"22\n",[307,945,947,950,952],{"class":309,"line":946},27,[307,948,949],{"class":317},"          cache",[307,951,321],{"class":313},[307,953,954],{"class":324},"npm\n",[307,956,958],{"class":309,"line":957},28,[307,959,341],{"emptyLinePlaceholder":235},[307,961,963,965,967,969],{"class":309,"line":962},29,[307,964,745],{"class":313},[307,966,318],{"class":317},[307,968,321],{"class":313},[307,970,971],{"class":324},"Install dependencies\n",[307,973,975,978,980],{"class":309,"line":974},30,[307,976,977],{"class":317},"        run",[307,979,321],{"class":313},[307,981,982],{"class":324},"npm ci\n",[307,984,986],{"class":309,"line":985},31,[307,987,341],{"emptyLinePlaceholder":235},[307,989,991,993,995,997],{"class":309,"line":990},32,[307,992,745],{"class":313},[307,994,318],{"class":317},[307,996,321],{"class":313},[307,998,325],{"class":324},[307,1000,1002,1004,1006],{"class":309,"line":1001},33,[307,1003,977],{"class":317},[307,1005,321],{"class":313},[307,1007,335],{"class":324},[307,1009,1011,1014],{"class":309,"line":1010},34,[307,1012,1013],{"class":317},"        env",[307,1015,373],{"class":313},[307,1017,1019,1022,1024],{"class":309,"line":1018},35,[307,1020,1021],{"class":317},"          NUXT_PUBLIC_SITE_URL",[307,1023,321],{"class":313},[307,1025,1026],{"class":324},"${{ vars.SITE_URL }}\n",[307,1028,1030],{"class":309,"line":1029},36,[307,1031,341],{"emptyLinePlaceholder":235},[307,1033,1035,1037,1039,1041],{"class":309,"line":1034},37,[307,1036,745],{"class":313},[307,1038,318],{"class":317},[307,1040,321],{"class":313},[307,1042,353],{"class":324},[307,1044,1046,1048,1050],{"class":309,"line":1045},38,[307,1047,875],{"class":317},[307,1049,321],{"class":313},[307,1051,364],{"class":324},[307,1053,1055,1057],{"class":309,"line":1054},39,[307,1056,886],{"class":317},[307,1058,373],{"class":313},[307,1060,1062,1065,1067],{"class":309,"line":1061},40,[307,1063,1064],{"class":317},"          azure_static_web_apps_api_token",[307,1066,321],{"class":313},[307,1068,384],{"class":324},[307,1070,1072,1075,1077],{"class":309,"line":1071},41,[307,1073,1074],{"class":317},"          repo_token",[307,1076,321],{"class":313},[307,1078,395],{"class":324},[307,1080,1082,1085,1087],{"class":309,"line":1081},42,[307,1083,1084],{"class":317},"          action",[307,1086,321],{"class":313},[307,1088,406],{"class":324},[307,1090,1092,1095,1097],{"class":309,"line":1091},43,[307,1093,1094],{"class":317},"          app_location",[307,1096,321],{"class":313},[307,1098,417],{"class":324},[307,1100,1102,1105,1107],{"class":309,"line":1101},44,[307,1103,1104],{"class":317},"          skip_app_build",[307,1106,321],{"class":313},[307,1108,428],{"class":427},[307,1110,1112],{"class":309,"line":1111},45,[307,1113,341],{"emptyLinePlaceholder":235},[307,1115,1117,1120],{"class":309,"line":1116},46,[307,1118,1119],{"class":317},"  close_pull_request",[307,1121,373],{"class":313},[307,1123,1125,1127,1129],{"class":309,"line":1124},47,[307,1126,822],{"class":317},[307,1128,321],{"class":313},[307,1130,1131],{"class":324},"github.event_name == 'pull_request' && github.event.action == 'closed'\n",[307,1133,1135,1137,1139],{"class":309,"line":1134},48,[307,1136,833],{"class":317},[307,1138,321],{"class":313},[307,1140,838],{"class":324},[307,1142,1144,1146,1148],{"class":309,"line":1143},49,[307,1145,844],{"class":317},[307,1147,321],{"class":313},[307,1149,1150],{"class":324},"Close pull request\n",[307,1152,1154,1156],{"class":309,"line":1153},50,[307,1155,855],{"class":317},[307,1157,373],{"class":313},[307,1159,1161,1163,1165,1167],{"class":309,"line":1160},51,[307,1162,745],{"class":313},[307,1164,318],{"class":317},[307,1166,321],{"class":313},[307,1168,1169],{"class":324},"Close Pull Request\n",[307,1171,1173,1175,1177],{"class":309,"line":1172},52,[307,1174,875],{"class":317},[307,1176,321],{"class":313},[307,1178,364],{"class":324},[307,1180,1182,1184],{"class":309,"line":1181},53,[307,1183,886],{"class":317},[307,1185,373],{"class":313},[307,1187,1189,1191,1193],{"class":309,"line":1188},54,[307,1190,1064],{"class":317},[307,1192,321],{"class":313},[307,1194,384],{"class":324},[307,1196,1198,1200,1202],{"class":309,"line":1197},55,[307,1199,1084],{"class":317},[307,1201,321],{"class":313},[307,1203,1204],{"class":324},"\"close\"\n",[17,1206,1207],{},"A few details worth noting:",[583,1209,1210,1216,1231,1244],{},[268,1211,1212,1215],{},[44,1213,1214],{},"Node 22",". Nuxt 4 supports Node 20 and above; pin to a specific major version rather than letting the runner default drift.",[268,1217,1218,1226,1227,1230],{},[44,1219,1220,274,1223],{},[80,1221,1222],{},"npm ci",[80,1224,1225],{},"npm install",". Faster, deterministic, and fails loudly if ",[80,1228,1229],{},"package-lock.json"," is out of date — which is what you want in CI.",[268,1232,1233,1236,1237,1239,1240,1243],{},[44,1234,1235],{},"Environment variables at build time",". ",[80,1238,498],{}," runs in CI, so anything from ",[80,1241,1242],{},"NUXT_PUBLIC_*"," that needs to end up in the static bundle has to be injected here, either from GitHub Actions variables (non-sensitive) or secrets (sensitive). This is a common stumbling block: setting environment variables in the SWA portal does nothing for a statically generated site, because the build happened before deployment.",[268,1245,1246,1252],{},[44,1247,616,1248,1251],{},[80,1249,1250],{},"close_pull_request"," job",". SWA gives you preview environments per PR for free. The close job tears them down when the PR closes. Worth keeping.",[12,1254,1256],{"id":1255},"common-failure-modes-and-what-they-mean","Common failure modes and what they mean",[17,1258,1259],{},"A short field guide to the errors you're most likely to hit.",[17,1261,1262,1265,1266,474,1268,1270,1271,278],{},[44,1263,1264],{},"\"Oryx build failed\" or \"App Directory Location not found\"","\nYou either haven't set ",[80,1267,294],{},[80,1269,288],{}," doesn't point at a directory that exists when the action runs. Check that your build step ran successfully and produced ",[80,1272,284],{},[17,1274,1275,1278,1279,1281,1282,1284,1285,1287],{},[44,1276,1277],{},"Deployment succeeds but the site is blank","\nAlmost always means ",[80,1280,288],{}," is pointing at the wrong directory — typically ",[80,1283,455],{}," instead of ",[80,1286,284],{},", which uploads the server bundle alongside the static files and confuses SWA's routing.",[17,1289,1290,1293,1294,1297,1298,1300],{},[44,1291,1292],{},"404s when refreshing on a sub-route","\nSWA doesn't know about your client-side routes by default. Add a ",[80,1295,1296],{},"staticwebapp.config.json"," to the root of your ",[80,1299,477],{}," directory with a fallback rule:",[99,1302,1306],{"className":1303,"code":1304,"language":1305,"meta":107,"style":107},"language-json shiki shiki-themes github-light github-dark","{\n  \"navigationFallback\": {\n    \"rewrite\": \"\u002Findex.html\",\n    \"exclude\": [\"\u002Fassets\u002F*\", \"\u002F*.{css,js,png,jpg,svg,ico}\"]\n  }\n}\n","json",[80,1307,1308,1313,1321,1334,1351,1355],{"__ignoreMap":107},[307,1309,1310],{"class":309,"line":310},[307,1311,1312],{"class":313},"{\n",[307,1314,1315,1318],{"class":309,"line":221},[307,1316,1317],{"class":427},"  \"navigationFallback\"",[307,1319,1320],{"class":313},": {\n",[307,1322,1323,1326,1328,1331],{"class":309,"line":338},[307,1324,1325],{"class":427},"    \"rewrite\"",[307,1327,321],{"class":313},[307,1329,1330],{"class":324},"\"\u002Findex.html\"",[307,1332,1333],{"class":313},",\n",[307,1335,1336,1339,1341,1344,1346,1349],{"class":309,"line":344},[307,1337,1338],{"class":427},"    \"exclude\"",[307,1340,763],{"class":313},[307,1342,1343],{"class":324},"\"\u002Fassets\u002F*\"",[307,1345,470],{"class":313},[307,1347,1348],{"class":324},"\"\u002F*.{css,js,png,jpg,svg,ico}\"",[307,1350,784],{"class":313},[307,1352,1353],{"class":309,"line":356},[307,1354,566],{"class":313},[307,1356,1357],{"class":309,"line":367},[307,1358,1359],{"class":313},"}\n",[17,1361,1362,1363,1365,1366,1369],{},"This file is copied through to ",[80,1364,284],{}," during the build and tells SWA to serve ",[80,1367,1368],{},"index.html"," for any route that doesn't match a static file.",[17,1371,1372,1375],{},[44,1373,1374],{},"Environment variables are undefined in the deployed site","\nYou set them in the SWA portal, expecting them to be available at runtime. For a fully static site there is no runtime. Inject the variables at build time in the workflow instead.",[17,1377,1378,1381,1382,1384,1385,1388],{},[44,1379,1380],{},"Build works locally, fails in CI","\nUsually a Node version mismatch or a missing environment variable. Pin Node in the workflow and double-check that every ",[80,1383,1242],{}," your build reads is set in the ",[80,1386,1387],{},"env:"," block of the build step.",[12,1390,1392],{"id":1391},"when-to-use-swa-container-apps-or-app-service","When to use SWA, Container Apps, or App Service",[17,1394,1395],{},"A quick frame for picking the right Azure service, since this is the question that usually sits behind \"how do I deploy Nuxt to Azure\":",[583,1397,1398,1404,1410],{},[268,1399,1400,1403],{},[44,1401,1402],{},"Static Web Apps"," for static-generated sites. Marketing sites, documentation, blogs, anything where every route can be pre-rendered. Cheap, fast, free preview environments, generous free tier. The sweet spot for Nuxt Content sites.",[268,1405,1406,1409],{},[44,1407,1408],{},"Container Apps"," for SSR or anything that needs a long-running server process. You get full control of the Node version, the container image, and the scaling behaviour. Pricier than SWA but the right answer when you genuinely need rendering at request time.",[268,1411,1412,1415],{},[44,1413,1414],{},"App Service"," if you're already there for other reasons — an existing .NET application, a team that knows it well, or platform requirements that lean that way. Workable for Node but rarely the best fit for a greenfield Nuxt project.",[17,1417,1418],{},"For the Nuxt 4 site this post is about, Static Web Apps is the right answer. If you find yourself fighting SWA to do something it doesn't want to do — server-side data fetching at request time, long-running background jobs, anything stateful — the answer isn't a cleverer SWA configuration. It's Container Apps.",[12,1420,1422],{"id":1421},"a-closing-wish","A closing wish",[17,1424,1425,1426,1429,1430,1432],{},"The configuration above works and will keep working. But it shouldn't take a blog post to find it. A first-class ",[80,1427,1428],{},"nuxt-azure-swa"," preset in Nitro, or a Nuxt 4-aware build path in Oryx, would mean none of this was necessary. Until one of those exists, point the SWA action at ",[80,1431,284],{},", skip the app build, and ship.",[17,1434,1435,1436,1441],{},"If you've hit a Nuxt + Azure issue this post doesn't cover, ",[1437,1438,1440],"a",{"href":1439},"mailto:Evan.Ritter@skette.co.uk","get in touch"," — I'd rather update this with another working answer than leave the next person searching at midnight.",[1443,1444,1445],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}",{"title":107,"searchDepth":221,"depth":221,"links":1447},[1448,1449,1450,1451,1453,1454,1455,1456],{"id":259,"depth":221,"text":260},{"id":442,"depth":221,"text":443},{"id":513,"depth":221,"text":514},{"id":615,"depth":221,"text":1452},"The app_location trap",{"id":698,"depth":221,"text":699},{"id":1255,"depth":221,"text":1256},{"id":1391,"depth":221,"text":1392},{"id":1421,"depth":221,"text":1422},"\u002Fblog\u002Fnuxt-4-azure-swa\u002Fcover.png","A working setup for Nuxt 4 on Azure Static Web Apps, after a day spent fighting Nitro presets, Oryx builds, and the SWA GitHub Action's assumptions.",{},"\u002Fblog\u002Fnuxt-4-azure-swa","2026-05-16",{"title":248,"description":1458},"blog\u002Fnuxt-4-azure-swa",[1465,487,1466],"nuxt","deployment","uqJy60_3P6Rq__uU8jaweiUF8G3w7Zjzsj3Hlqe5b_E",{"id":1469,"title":1470,"author":7,"body":1471,"coverImage":1618,"description":1619,"draft":235,"extension":233,"meta":1620,"navigation":235,"path":1621,"publishedAt":1622,"readingTime":1618,"seo":1623,"stem":1624,"tags":1625,"__hash__":1626},"blog\u002Fblog\u002Fwelcome-to-the-skette-blog.md","Welcome to the Skette blog",{"type":9,"value":1472,"toc":1613},[1473,1476,1480,1483,1490,1494,1505,1509,1512,1531,1537,1543,1596,1599,1610],[17,1474,1475],{},"This is a placeholder post so you can see how the blog looks and behaves. Replace it with your real first post — or keep it, edit it, and publish.",[12,1477,1479],{"id":1478},"why-were-writing","Why we're writing",[17,1481,1482],{},"We spend our days building web products: API-first architecture, payment platforms, data pipelines, and the occasional awkward real-world integration. A lot of what we learn never makes it past a pull request. This blog is where some of it will.",[17,1484,1485,1486,1489],{},"Expect a new post roughly ",[44,1487,1488],{},"every two to four weeks"," — short, practical, and grounded in work we've actually shipped.",[12,1491,1493],{"id":1492},"what-you-can-expect","What you can expect",[583,1495,1496,1499,1502],{},[268,1497,1498],{},"Notes from real projects in health & social care and audience measurement",[268,1500,1501],{},"Practical write-ups on payments, data pipelines, and deployment",[268,1503,1504],{},"The occasional opinion about how software should be built",[12,1506,1508],{"id":1507},"a-few-formatting-examples","A few formatting examples",[17,1510,1511],{},"Everything here is plain Markdown — no HTML or CSS per post. Here's a short list:",[265,1513,1514,1521,1528],{},[268,1515,1516,1517,1520],{},"Headings, bold, and ",[30,1518,1519],{},"italics"," all just work",[268,1522,1523,1524],{},"Links look like ",[1437,1525,1527],{"href":1526},"\u002F","this one to the homepage",[268,1529,1530],{},"Lists, quotes, and code blocks are styled to match the site",[1532,1533,1534],"blockquote",{},[17,1535,1536],{},"A blockquote, for when a point deserves a little more weight on the page.",[17,1538,1539,1540,1542],{},"Inline ",[80,1541,80],{}," looks like this, and fenced blocks render with their own styling:",[99,1544,1546],{"className":528,"code":1545,"language":530,"meta":107,"style":107},"export function greet(name: string): string {\n  return `Hello, ${name} — welcome aboard.`\n}\n",[80,1547,1548,1579,1592],{"__ignoreMap":107},[307,1549,1550,1552,1555,1558,1561,1564,1566,1569,1572,1574,1576],{"class":309,"line":310},[307,1551,538],{"class":537},[307,1553,1554],{"class":537}," function",[307,1556,1557],{"class":544}," greet",[307,1559,1560],{"class":313},"(",[307,1562,318],{"class":1563},"s4XuR",[307,1565,525],{"class":537},[307,1567,1568],{"class":427}," string",[307,1570,1571],{"class":313},")",[307,1573,525],{"class":537},[307,1575,1568],{"class":427},[307,1577,1578],{"class":313}," {\n",[307,1580,1581,1584,1587,1589],{"class":309,"line":221},[307,1582,1583],{"class":537},"  return",[307,1585,1586],{"class":324}," `Hello, ${",[307,1588,318],{"class":313},[307,1590,1591],{"class":324},"} — welcome aboard.`\n",[307,1593,1594],{"class":309,"line":338},[307,1595,1359],{"class":313},[1597,1598],"hr",{},[17,1600,1601,1602,1605,1606,1609],{},"That's it for the placeholder. When you're ready, edit this file at ",[80,1603,1604],{},"content\u002Fblog\u002Fwelcome-to-the-skette-blog.md",", or add a new ",[80,1607,1608],{},".md"," file alongside it for your real first post.",[1443,1611,1612],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":107,"searchDepth":221,"depth":221,"links":1614},[1615,1616,1617],{"id":1478,"depth":221,"text":1479},{"id":1492,"depth":221,"text":1493},{"id":1507,"depth":221,"text":1508},null,"A quick hello, what this blog is for, and what you can expect to read here over the coming months.",{},"\u002Fblog\u002Fwelcome-to-the-skette-blog","2026-05-15",{"title":1470,"description":1619},"blog\u002Fwelcome-to-the-skette-blog",[],"2Ebz-nmXZRc7SSj1LxeqXT2grjqiuAj-uinokBaas_Y",1779548065855]