[{"data":1,"prerenderedAt":12924},["ShallowReactive",2],{"sidebar-posts":3,"post-from-zero-to-2-dollar-6-months-tool-site":12661},[4,949,4889,5291,5514,5772,5952,9711,9939,10925,12059],{"id":5,"title":6,"body":7,"description":924,"extension":925,"meta":926,"navigation":702,"path":937,"seo":938,"stem":947,"__hash__":948},"posts\u002Fposts\u002F68k-impressions-8-clicks-image-sitemap-blind-spot.md","68,000 Impressions, 8 Clicks: The Image Sitemap Blind Spot I Didn't Know I Had",{"type":8,"value":9,"toc":897},"minimark",[10,15,27,30,34,48,56,64,71,74,77,79,83,90,93,99,151,154,157,160,162,166,169,172,177,232,235,238,241,243,247,250,261,271,274,289,292,295,297,301,304,313,324,331,436,443,449,452,459,462,539,542,551,570,574,577,637,640,643,647,650,653,656,737,740,742,746,749,755,761,767,769,773,779,782,787,789,793,830,832,836,840,843,853,862,866,876,880,883,890,893],[11,12,14],"h2",{"id":13},"tldr","TL;DR",[16,17,18,19,26],"p",{},"I had 68,200 image search impressions across my tool site ",[20,21,25],"a",{"href":22,"rel":23},"https:\u002F\u002Fbulkpictools.com",[24],"nofollow","bulkpictools.com"," and only 8 clicks. Average ranking: 47.9. I didn't even know this data existed until recently — because I'd never opened the Image tab in Google Search Console. The root cause wasn't bad content. It was that I had never told Google what my images actually were. This post is about what I found, why it happened, and what I changed.",[28,29],"hr",{},[11,31,33],{"id":32},"background-an-accident-on-a-different-site","Background: An Accident on a Different Site",[16,35,36,37,41,42,47],{},"I run two sites. ",[20,38,40],{"href":22,"rel":39},[24],"BulkPicTools"," is a bulk image processing tool — compress, convert, crop, all locally in the browser. ",[20,43,46],{"href":44,"rel":45},"https:\u002F\u002Faifindr.org",[24],"aifindr.org"," is newer, a directory of useful AI tools.",[16,49,50,51,55],{},"While setting up a tool page on aifindr.org, I added a ",[52,53,54],"code",{},"video:video"," node to the sitemap entry. The video content itself wasn't ready — the node was basically empty. I added it mostly as a placeholder, planning to fill it in later.",[16,57,58,59,63],{},"A few days later, I opened Google Search Console and noticed something: that page had appeared in the ",[60,61,62],"strong",{},"Video indexing"," report. Status: \"Video discovered — currently not indexed.\"",[16,65,66],{},[67,68],"img",{"alt":69,"src":70},"Google Search Console video indexing report showing 'Video discovered — currently not indexed' status for aifindr.org","\u002Fimages\u002Ftools-workflow\u002F68k-impressions-8-clicks-image-sitemap-blind-spot\u002Fimage-sitemap-blind-spot-aifindr-video-discovered.webp",[16,72,73],{},"An empty node was enough for Google to notice the page had video content. I hadn't submitted anything — just declared the intent in the sitemap. Google responded within days.",[16,75,76],{},"That small accident made me think: if Google picks up a video signal that fast, what's happening with images? I went back to BulkPicTools and opened a tab I had honestly never looked at before.",[28,78],{},[11,80,82],{"id":81},"the-problem-68000-impressions-i-was-basically-wasting","The Problem: 68,000 Impressions I Was Basically Wasting",[16,84,85,86,89],{},"In Google Search Console, under ",[60,87,88],{},"Performance",", there's a dropdown to switch Search Type from \"Web\" to \"Image.\" I had never switched it.",[16,91,92],{},"When I did, this is what I saw:",[16,94,95],{},[67,96],{"alt":97,"src":98},"Google Search Console image search performance showing 68,200 impressions, 8 clicks, 0.012% CTR, average position 47.9","\u002Fimages\u002Ftools-workflow\u002F68k-impressions-8-clicks-image-sitemap-blind-spot\u002Fimage-sitemap-blind-spot-gsc-image-performance.webp",[100,101,102,115],"table",{},[103,104,105],"thead",{},[106,107,108,112],"tr",{},[109,110,111],"th",{},"Metric",[109,113,114],{},"Value",[116,117,118,127,135,143],"tbody",{},[106,119,120,124],{},[121,122,123],"td",{},"Impressions",[121,125,126],{},"68,200",[106,128,129,132],{},[121,130,131],{},"Clicks",[121,133,134],{},"8",[106,136,137,140],{},[121,138,139],{},"CTR",[121,141,142],{},"0.012%",[106,144,145,148],{},[121,146,147],{},"Average Position",[121,149,150],{},"47.9",[16,152,153],{},"Sixty-eight thousand times, Google showed one of my images somewhere in its image search results. Eight people clicked. That's not a traffic problem — that's a signal problem. Google had already decided my images were relevant enough to show. It just didn't rank them high enough for anyone to actually see them.",[16,155,156],{},"Average position 47.9 means my images were sitting on page 5 or beyond. In image search, that's effectively invisible.",[16,158,159],{},"The question was: why?",[28,161],{},[11,163,165],{"id":164},"investigation-how-google-actually-understands-an-image","Investigation: How Google Actually Understands an Image",[16,167,168],{},"Before I could fix anything, I needed to understand what signals Google uses to rank images. It's not the same as web search, and I had been treating them the same way — which is to say, not thinking about it at all.",[16,170,171],{},"Here's what I pieced together:",[16,173,174],{},[60,175,176],{},"The signals Google uses to understand an image, roughly in order of impact:",[178,179,180,187,193,203,217,226],"ol",{},[181,182,183,186],"li",{},[60,184,185],{},"Surrounding text context"," — The paragraphs immediately around the image. This is often the strongest signal and the most ignored one. An image placed next to a paragraph about \"bulk JPEG compression\" is understood very differently than the same image placed in an unrelated section.",[181,188,189,192],{},[60,190,191],{},"Page title and H1"," — Gives the image a topic to belong to.",[181,194,195,202],{},[60,196,197,198,201],{},"Image ",[52,199,200],{},"alt"," attribute"," — An explicit, author-provided label. Google says this is the single most important on-page signal for images. It's not just for accessibility.",[181,204,205,208,209,212,213,216],{},[60,206,207],{},"Image filename"," — ",[52,210,211],{},"compress-jpg-result.webp"," gives Google something to work with. ",[52,214,215],{},"IMG_4521.jpg"," gives it nothing.",[181,218,219,225],{},[60,220,221,224],{},[52,222,223],{},"image:title"," in the Sitemap"," — A declaration at the sitemap level, separate from the page HTML. Most developers don't know this exists.",[181,227,228,231],{},[60,229,230],{},"Visual content recognition"," — Google's own computer vision can interpret what's in the image, but it still weights the textual signals heavily.",[16,233,234],{},"The key insight: these aren't alternatives. Each signal reinforces the others. Missing even two or three of them leaves Google with a weak, uncertain understanding of what your image is about — so it ranks it conservatively.",[16,236,237],{},"For BulkPicTools: my tool pages had almost no images to begin with (tools are mostly UI, not image-heavy). The few images I did have were screenshots with generic filenames and no alt text on the tool pages. I had only written proper alt text on blog posts. The sitemap had zero image nodes.",[16,239,240],{},"I was providing almost no signal across any of these dimensions. The 47.9 average ranking was accurate.",[28,242],{},[11,244,246],{"id":245},"the-diagnosis-what-high-impressions-low-ranking-actually-means","The Diagnosis: What \"High Impressions, Low Ranking\" Actually Means",[16,248,249],{},"This combination — lots of impressions, position in the 40s — is actually a specific and identifiable state. It's worth understanding because it tells you exactly what's wrong and what to do about it.",[16,251,252,255,256,260],{},[60,253,254],{},"What high impressions means:"," Google has already decided your images are ",[257,258,259],"em",{},"topically related"," to certain search queries. You're in the game.",[16,262,263,266,267,270],{},[60,264,265],{},"What position 47 means:"," Your relevance ",[257,268,269],{},"signal strength"," isn't competitive. Other sites have clearer, stronger signals for the same queries, so they rank above you.",[16,272,273],{},"Compare this to two worse situations:",[275,276,277,283],"ul",{},[181,278,279,282],{},[60,280,281],{},"Low impressions + low position",": Google hasn't connected your images to any meaningful queries. The issue is discoverability, not signal strength.",[181,284,285,288],{},[60,286,287],{},"Low impressions + high position",": You're ranking well for very low-volume queries. Not a problem, but not much opportunity either.",[16,290,291],{},"High impressions + low position is the most actionable state. The audience is there. The queries are real. You just need to strengthen the signal.",[16,293,294],{},"One caveat: a position-1 result with 0 clicks doesn't automatically mean something is broken. Google runs experiments — sometimes an image appears in a narrow test, collects no user data, and disappears. I had a handful of those in my Bing keyword data too. Don't over-interpret individual data points. Look at the pattern across many queries.",[28,296],{},[11,298,300],{"id":299},"solution-four-dimensions-applied-in-order","Solution: Four Dimensions, Applied in Order",[16,302,303],{},"I didn't try to fix everything at once. I focused on the highest-impact changes first.",[305,306,308,309,312],"h3",{"id":307},"_1-add-imageimage-nodes-to-the-sitemap","1. Add ",[52,310,311],{},"image:image"," Nodes to the Sitemap",[16,314,315,316,319,320,323],{},"This was the most obvious gap. My sitemap had ",[52,317,318],{},"\u003Cloc>"," and ",[52,321,322],{},"\u003Clastmod>"," for each page and nothing else. I added image nodes for each tool page's hero image.",[16,325,326,327,330],{},"The Nuxt Sitemap module (",[52,328,329],{},"@nuxtjs\u002Fsitemap",") supports this natively. Here's the structure I ended up with for each tool page entry:",[332,333,338],"pre",{"className":334,"code":335,"language":336,"meta":337,"style":337},"language-json shiki shiki-themes github-light github-light","{\n  \"loc\": \"\u002Ftools\u002Fcompress\u002Fimage-compressor\",\n  \"lastmod\": \"2026-06-26T06:29:11.589Z\",\n  \"images\": [\n    {\n      \"loc\": \"https:\u002F\u002Fbulkpictools.com\u002Fog\u002Fimage-compressor\u002Fen.png\",\n      \"title\": \"Free bulk image compressor — compress JPG PNG WebP locally, no upload needed\"\n    }\n  ]\n}\n","json","",[52,339,340,349,366,379,388,394,407,418,424,430],{"__ignoreMap":337},[341,342,345],"span",{"class":343,"line":344},"line",1,[341,346,348],{"class":347},"sKWpL","{\n",[341,350,352,356,359,363],{"class":343,"line":351},2,[341,353,355],{"class":354},"sMN4m","  \"loc\"",[341,357,358],{"class":347},": ",[341,360,362],{"class":361},"sOTlB","\"\u002Ftools\u002Fcompress\u002Fimage-compressor\"",[341,364,365],{"class":347},",\n",[341,367,369,372,374,377],{"class":343,"line":368},3,[341,370,371],{"class":354},"  \"lastmod\"",[341,373,358],{"class":347},[341,375,376],{"class":361},"\"2026-06-26T06:29:11.589Z\"",[341,378,365],{"class":347},[341,380,382,385],{"class":343,"line":381},4,[341,383,384],{"class":354},"  \"images\"",[341,386,387],{"class":347},": [\n",[341,389,391],{"class":343,"line":390},5,[341,392,393],{"class":347},"    {\n",[341,395,397,400,402,405],{"class":343,"line":396},6,[341,398,399],{"class":354},"      \"loc\"",[341,401,358],{"class":347},[341,403,404],{"class":361},"\"https:\u002F\u002Fbulkpictools.com\u002Fog\u002Fimage-compressor\u002Fen.png\"",[341,406,365],{"class":347},[341,408,410,413,415],{"class":343,"line":409},7,[341,411,412],{"class":354},"      \"title\"",[341,414,358],{"class":347},[341,416,417],{"class":361},"\"Free bulk image compressor — compress JPG PNG WebP locally, no upload needed\"\n",[341,419,421],{"class":343,"line":420},8,[341,422,423],{"class":347},"    }\n",[341,425,427],{"class":343,"line":426},9,[341,428,429],{"class":347},"  ]\n",[341,431,433],{"class":343,"line":432},10,[341,434,435],{"class":347},"}\n",[16,437,438,439,442],{},"The ",[52,440,441],{},"title"," field is the critical part. It's a natural language sentence, not a keyword list. It covers the functional description (\"bulk image compressor\"), the supported formats (\"JPG PNG WebP\"), and the key differentiator (\"locally, no upload needed\") — which also happen to be long-tail search terms.",[16,444,445,448],{},[60,446,447],{},"Why not use the primary keyword \"image compressor\"?"," Because that's already dominated by TinyPNG, iLoveIMG, Squoosh, and similar tools with years of domain authority. A new site cannot compete on that term directly in image search any more than in web search. Long-tail descriptive phrases — the kind of thing someone types when they've already tried the obvious tools and want something specific — are where a smaller site can actually appear in the top 20.",[16,450,451],{},"The long-tail terms I used came from GSC and Bing Webmaster Tools keyword data. I exported the reports and had Claude analyze them to surface the queries where I already had impressions but low ranking. Those are the queries where Google is already associating my site with the intent — I just needed to reinforce the signal. (I'll write a separate post on that keyword analysis workflow.)",[305,453,455,456,458],{"id":454},"_2-add-videovideo-nodes","2. Add ",[52,457,54],{}," Nodes",[16,460,461],{},"After the aifindr.org discovery, I added video nodes to the tool pages where I plan to add demo videos. The video content isn't live yet, but the node structure is ready:",[332,463,465],{"className":334,"code":464,"language":336,"meta":337,"style":337},"{\n  \"videos\": [\n    {\n      \"title\": \"How to batch compress images using BulkPicTools\",\n      \"description\": \"Compress unlimited JPG, PNG, and WebP images at once. Reduce file size by 80–90% or to specific targets (200KB, 1MB) locally without losing quality.\",\n      \"thumbnail_loc\": \"https:\u002F\u002Fbulkpictools.com\u002Fog\u002Fimage-compressor\u002Fen-video-thumb.png\",\n      \"content_loc\": \"https:\u002F\u002Fbulkpictools.com\u002Fvideos\u002Fimage-compressor\u002Fen.mp4\"\n    }\n  ]\n}\n",[52,466,467,471,478,482,493,505,517,527,531,535],{"__ignoreMap":337},[341,468,469],{"class":343,"line":344},[341,470,348],{"class":347},[341,472,473,476],{"class":343,"line":351},[341,474,475],{"class":354},"  \"videos\"",[341,477,387],{"class":347},[341,479,480],{"class":343,"line":368},[341,481,393],{"class":347},[341,483,484,486,488,491],{"class":343,"line":381},[341,485,412],{"class":354},[341,487,358],{"class":347},[341,489,490],{"class":361},"\"How to batch compress images using BulkPicTools\"",[341,492,365],{"class":347},[341,494,495,498,500,503],{"class":343,"line":390},[341,496,497],{"class":354},"      \"description\"",[341,499,358],{"class":347},[341,501,502],{"class":361},"\"Compress unlimited JPG, PNG, and WebP images at once. Reduce file size by 80–90% or to specific targets (200KB, 1MB) locally without losing quality.\"",[341,504,365],{"class":347},[341,506,507,510,512,515],{"class":343,"line":396},[341,508,509],{"class":354},"      \"thumbnail_loc\"",[341,511,358],{"class":347},[341,513,514],{"class":361},"\"https:\u002F\u002Fbulkpictools.com\u002Fog\u002Fimage-compressor\u002Fen-video-thumb.png\"",[341,516,365],{"class":347},[341,518,519,522,524],{"class":343,"line":409},[341,520,521],{"class":354},"      \"content_loc\"",[341,523,358],{"class":347},[341,525,526],{"class":361},"\"https:\u002F\u002Fbulkpictools.com\u002Fvideos\u002Fimage-compressor\u002Fen.mp4\"\n",[341,528,529],{"class":343,"line":420},[341,530,423],{"class":347},[341,532,533],{"class":343,"line":426},[341,534,429],{"class":347},[341,536,537],{"class":343,"line":432},[341,538,435],{"class":347},[16,540,541],{},"Two things I learned the hard way about the video node:",[16,543,544,550],{},[60,545,546,549],{},[52,547,548],{},"thumbnail_loc"," cannot reuse the OG Image."," Google's video indexing requires a thumbnail that actually represents the video content. Using your page's OG Image as the video thumbnail risks the submission being rejected — Google may determine the thumbnail isn't related to the video. Create a separate thumbnail that's a still from the actual video.",[16,552,553,563,564,566,567,569],{},[60,554,555,558,559,562],{},[52,556,557],{},"content_loc"," vs ",[52,560,561],{},"player_loc",":"," If your video is self-hosted and directly accessible via HTTP, use ",[52,565,557],{},". If it's on YouTube or Vimeo, use ",[52,568,561],{}," with the embed URL instead. If you later move a self-hosted video to YouTube, you need to update the node — these aren't interchangeable.",[305,571,573],{"id":572},"_3-separate-og-image-from-sitemap-image","3. Separate OG Image from Sitemap Image",[16,575,576],{},"This is the one that surprised me most. I had been using the same image for both — the OG Image (social sharing) and the sitemap image submission. They're not the same thing and shouldn't be treated the same way.",[100,578,579,591],{},[103,580,581],{},[106,582,583,585,588],{},[109,584],{},[109,586,587],{},"OG Image",[109,589,590],{},"Sitemap Image",[116,592,593,604,615,626],{},[106,594,595,598,601],{},[121,596,597],{},"Primary audience",[121,599,600],{},"Social media platforms",[121,602,603],{},"Google Image Search",[106,605,606,609,612],{},[121,607,608],{},"Standard ratio",[121,610,611],{},"1200×630 (16:9)",[121,613,614],{},"Closer to square (4:3 or 1:1)",[106,616,617,620,623],{},[121,618,619],{},"Optimization goal",[121,621,622],{},"Visual appeal, brand recognition, social CTR",[121,624,625],{},"Search intent match, ranking signal clarity",[106,627,628,631,634],{},[121,629,630],{},"Content priority",[121,632,633],{},"Brand feel, product overview",[121,635,636],{},"Functional description, specific use case",[16,638,639],{},"The ratio issue is practical: Google image search displays thumbnails in a near-square crop. A 1200×630 wide image gets cropped on the sides, potentially cutting off the most important visual information. A square or 4:3 image fills the thumbnail properly.",[16,641,642],{},"I now maintain a separate hero image designed specifically for sitemap submission — same content, different composition centered for square display.",[305,644,646],{"id":645},"_4-fix-alt-text-on-tool-pages","4. Fix Alt Text on Tool Pages",[16,648,649],{},"My blog posts had carefully written alt text. My tool pages had almost none. This is backwards — tool pages are the pages I actually care about ranking.",[16,651,652],{},"The fix is simple in principle: every image on a tool page gets a descriptive alt that explains what the image shows in the context of the page.",[16,654,655],{},"For a screenshot of the compression result interface:",[332,657,661],{"className":658,"code":659,"language":660,"meta":337,"style":337},"language-html shiki shiki-themes github-light github-light","\u003C!-- Before -->\n\u003Cimg src=\"result.png\" alt=\"\" \u002F>\n\n\u003C!-- After -->\n\u003Cimg src=\"compress-jpg-result-before-after.png\" \n     alt=\"Bulk image compressor result showing original 4.2MB JPG compressed to 380KB locally in browser\" \u002F>\n","html",[52,662,663,669,698,704,709,725],{"__ignoreMap":337},[341,664,665],{"class":343,"line":344},[341,666,668],{"class":667},"sJ8bj","\u003C!-- Before -->\n",[341,670,671,674,677,681,684,687,690,692,695],{"class":343,"line":351},[341,672,673],{"class":347},"\u003C",[341,675,67],{"class":676},"sMDDv",[341,678,680],{"class":679},"se37E"," src",[341,682,683],{"class":347},"=",[341,685,686],{"class":361},"\"result.png\"",[341,688,689],{"class":679}," alt",[341,691,683],{"class":347},[341,693,694],{"class":361},"\"\"",[341,696,697],{"class":347}," \u002F>\n",[341,699,700],{"class":343,"line":368},[341,701,703],{"emptyLinePlaceholder":702},true,"\n",[341,705,706],{"class":343,"line":381},[341,707,708],{"class":667},"\u003C!-- After -->\n",[341,710,711,713,715,717,719,722],{"class":343,"line":390},[341,712,673],{"class":347},[341,714,67],{"class":676},[341,716,680],{"class":679},[341,718,683],{"class":347},[341,720,721],{"class":361},"\"compress-jpg-result-before-after.png\"",[341,723,724],{"class":347}," \n",[341,726,727,730,732,735],{"class":343,"line":396},[341,728,729],{"class":679},"     alt",[341,731,683],{"class":347},[341,733,734],{"class":361},"\"Bulk image compressor result showing original 4.2MB JPG compressed to 380KB locally in browser\"",[341,736,697],{"class":347},[16,738,739],{},"The alt doesn't need to be long. It needs to be specific to what's actually in the image.",[28,741],{},[11,743,745],{"id":744},"my-take","My Take",[16,747,748],{},"Three things I'm still thinking about:",[16,750,751,754],{},[60,752,753],{},"\"Already discovered\" is not \"indexed.\""," The aifindr.org video node showing up as \"discovered\" felt like a win, but discovered and indexed are different states. Google found it; it hasn't committed to showing it in results yet. I was initially excited by how fast Google responded. But I'm being careful not to treat a crawl confirmation as a ranking improvement.",[16,756,757,760],{},[60,758,759],{},"The 68,000 impressions were hiding in plain sight."," The Image tab in GSC is easy to miss — it's a dropdown, not a separate menu. I looked at my Search Console regularly and never noticed I was sitting on nearly 70,000 image impressions going nowhere. If you haven't checked your own Image performance tab recently, that's the first thing I'd do after reading this.",[16,762,763,766],{},[60,764,765],{},"This is free traffic that most indie developers aren't competing for."," The sites that dominate image search for generic terms like \"image compressor\" are large, well-funded products. But specific tool functionality, specific formats, specific use cases — those long-tail image queries are less contested. A tool site with clear, specific alt text and proper sitemap declarations has a real shot at appearing in the top 20 for the queries that matter to its actual users.",[28,768],{},[11,770,772],{"id":771},"result","Result",[16,774,775,776,778],{},"As of publishing, the ",[52,777,311],{}," nodes are live in the sitemap. The video node structure is in place, waiting for video content. I've also updated alt text across the main tool pages.",[16,780,781],{},"The GSC data won't reflect these changes immediately — Google needs to recrawl the sitemap and reprocess the images. I'll check back in 4–6 weeks and update this post with what changed.",[16,783,784],{},[257,785,786],{},"Update pending — will revisit once GSC image performance data reflects the new sitemap submissions.",[28,788],{},[11,790,792],{"id":791},"lessons-learned","Lessons Learned",[178,794,795,801,807,818,824],{},[181,796,797,800],{},[60,798,799],{},"Open the Image tab in Google Search Console."," It's under Performance → Search Type: Image. Most developers have never looked at it. If you have any images on your site, the data is probably there.",[181,802,803,806],{},[60,804,805],{},"High impressions + low position = signal problem, not content problem."," Google already knows your images are relevant. You just haven't given it enough evidence to rank them higher. The fix is adding signal, not replacing images.",[181,808,809,817],{},[60,810,811,813,814,816],{},[52,812,223],{}," in your sitemap is a separate signal from ",[52,815,200],{},"."," One lives in HTML, one lives in the sitemap. They work together. Most image SEO guides only mention alt text and miss the sitemap dimension entirely.",[181,819,820,823],{},[60,821,822],{},"OG Image and Sitemap Image should be designed separately."," Different aspect ratios, different optimization goals. Reusing one for the other is a compromise on both.",[181,825,826,829],{},[60,827,828],{},"An accidental discovery on a side project can reframe how you think about your main one."," The aifindr.org video node was a throwaway placeholder. It turned out to be the thing that made me look at BulkPicTools' image data for the first time. Keep running experiments on smaller projects — the learnings transfer.",[28,831],{},[11,833,835],{"id":834},"frequently-asked-questions","Frequently Asked Questions",[305,837,839],{"id":838},"does-adding-images-to-a-sitemap-guarantee-theyll-rank-in-google-image-search","Does adding images to a sitemap guarantee they'll rank in Google Image Search?",[16,841,842],{},"No. The sitemap tells Google where your images are and gives it metadata to evaluate them. Whether they rank — and how high — depends on the relevance signals across alt text, surrounding content, filename, and the image itself. The sitemap is one input, not a guarantee.",[305,844,846,847,849,850,852],{"id":845},"whats-the-difference-between-imagetitle-in-a-sitemap-and-the-alt-attribute-in-html","What's the difference between ",[52,848,223],{}," in a sitemap and the ",[52,851,200],{}," attribute in HTML?",[16,854,855,856,858,859,861],{},"They're separate signals that Google reads independently. The ",[52,857,200],{}," attribute lives in your page HTML and describes the image in its page context. The ",[52,860,223],{}," in your sitemap is a declaration at the crawl level — it helps Google understand the image before it even processes the page. You need both.",[305,863,865],{"id":864},"should-i-submit-a-separate-image-sitemap-or-add-image-nodes-to-my-existing-sitemap","Should I submit a separate image sitemap or add image nodes to my existing sitemap?",[16,867,868,869,871,872,875],{},"Either works. If you already have a sitemap, adding ",[52,870,311],{}," nodes inside your existing ",[52,873,874],{},"\u003Curl>"," entries is simpler and keeps everything in one file. A separate image sitemap only makes sense if you have a very large image library (thousands of images) and want to manage it independently.",[305,877,879],{"id":878},"what-happened-when-you-added-an-empty-video-node-to-your-sitemap","What happened when you added an empty video node to your sitemap?",[16,881,882],{},"Google picked it up within a few days and showed it as \"Video discovered — currently not indexed\" in the Video Indexing report. It didn't index the video (there was no video), but it confirmed Google responds quickly to sitemap declarations. When the actual video goes live, the node is already in place.",[305,884,886,887,889],{"id":885},"how-do-you-choose-what-to-write-in-imagetitle","How do you choose what to write in ",[52,888,223],{},"?",[16,891,892],{},"I start from my existing GSC and Bing keyword data — specifically queries where I already have impressions but rank in the 20–50 range. Those are queries where Google has already associated my content with the intent. I write a natural language sentence that includes 2–3 of those long-tail phrases without making it read like a keyword list. The goal is something a human would write to describe the image, that also happens to match what searchers are actually typing.",[894,895,896],"style",{},"html pre.shiki code .sKWpL, html code.shiki .sKWpL{--shiki-default:#24292E;--shiki-dark:#24292E}html pre.shiki code .sMN4m, html code.shiki .sMN4m{--shiki-default:#005CC5;--shiki-dark:#005CC5}html pre.shiki code .sOTlB, html code.shiki .sOTlB{--shiki-default:#032F62;--shiki-dark:#032F62}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sMDDv, html code.shiki .sMDDv{--shiki-default:#22863A;--shiki-dark:#22863A}html pre.shiki code .se37E, html code.shiki .se37E{--shiki-default:#6F42C1;--shiki-dark:#6F42C1}",{"title":337,"searchDepth":351,"depth":351,"links":898},[899,900,901,902,903,904,912,913,914,915],{"id":13,"depth":351,"text":14},{"id":32,"depth":351,"text":33},{"id":81,"depth":351,"text":82},{"id":164,"depth":351,"text":165},{"id":245,"depth":351,"text":246},{"id":299,"depth":351,"text":300,"children":905},[906,908,910,911],{"id":307,"depth":368,"text":907},"1. Add image:image Nodes to the Sitemap",{"id":454,"depth":368,"text":909},"2. Add video:video Nodes",{"id":572,"depth":368,"text":573},{"id":645,"depth":368,"text":646},{"id":744,"depth":351,"text":745},{"id":771,"depth":351,"text":772},{"id":791,"depth":351,"text":792},{"id":834,"depth":351,"text":835,"children":916},[917,918,920,921,922],{"id":838,"depth":368,"text":839},{"id":845,"depth":368,"text":919},"What's the difference between image:title in a sitemap and the alt attribute in HTML?",{"id":864,"depth":368,"text":865},{"id":878,"depth":368,"text":879},{"id":885,"depth":368,"text":923},"How do you choose what to write in image:title?","I had 68,000 image search impressions and only 8 clicks. Here's the blind spot I discovered about image and video sitemap indexing, and what I actually did about it.","md",{"date":927,"category":928,"readTime":929,"tags":930,"image":934,"draft":935,"series":936,"seriesOrder":936},"2026-06-28","tools-workflow","10mins",[931,932,933],"#seo","#growth","#cloudflare","[待补充：文章内最能代表核心内容的那张截图，建议使用 GSC 图片搜索数据截图]",false,null,"\u002Fposts\u002F68k-impressions-8-clicks-image-sitemap-blind-spot",{"title":939,"description":940,"keywords":941},"68K Image Impressions, 8 Clicks: Fixing My Sitemap Blind Spot","How I discovered image and video sitemap indexing by accident, diagnosed 68,000 wasted impressions, and what I changed to fix it.",[942,943,944,945,946],"image sitemap indexing indie developer","google image search impressions no clicks","sitemap image video nodes seo","image title long tail keyword strategy","google search console image performance","posts\u002F68k-impressions-8-clicks-image-sitemap-blind-spot","FugTWvXOdULU8NE279HSkhzHgtrPhW1Sz2mCj7qVfwc",{"id":950,"title":951,"body":952,"description":4868,"extension":925,"meta":4869,"navigation":702,"path":4879,"seo":4880,"stem":4887,"__hash__":4888},"posts\u002Fposts\u002Ffixing-adsense-responsive-mobile-square.md","Why My Responsive AdSense Ad Went Square on Mobile (and Tall on Desktop) — and How I Fixed It With a Reusable Vue Component",{"type":8,"value":953,"toc":4856},[954,956,970,974,981,984,1016,1026,1030,1033,1039,1046,1052,1056,1059,1066,1104,1119,1122,1172,1186,1190,1206,1215,1405,4667,4670,4673,4699,4712,4716,4726,4734,4751,4777,4789,4791,4800,4806,4809,4811,4814,4820,4823,4829,4831,4853],[11,955,14],{"id":13},[16,957,958,959,962,963,319,966,969],{},"A ",[52,960,961],{},"type=\"responsive\""," AdSense ad on my site rendered as a square block on mobile and grew taller than expected on desktop. The root cause was the combination of ",[52,964,965],{},"data-ad-format=\"auto\"",[52,967,968],{},"data-full-width-responsive=\"true\""," — both hand control of the ad's final size to Google. I ended up not fixing this with JavaScript resize detection, but by following Google's own documented CSS media query approach and baking it into a single reusable Vue component with built-in size presets per breakpoint.",[11,971,973],{"id":972},"background","Background",[16,975,976,977,980],{},"This particular placement isn't on this blog — it's on ",[20,978,25],{"href":22,"rel":979},[24],", another site of mine that's a batch image-processing toolchain. I'm running this ad in two spots there: in the file upload area on the tool pages, and on the output\u002Fresults page. I wired it up about a week before writing this.",[16,982,983],{},"I originally wired up the ad with:",[332,985,989],{"className":986,"code":987,"language":988,"meta":337,"style":337},"language-vue shiki shiki-themes github-light github-light","\u003CAdAdsense type=\"responsive\" adsense-slot-id=\"xxxxxxxxxxxxxxxx\" \u002F>\n","vue",[52,990,991],{"__ignoreMap":337},[341,992,993,995,998,1001,1003,1006,1009,1011,1014],{"class":343,"line":344},[341,994,673],{"class":347},[341,996,997],{"class":676},"AdAdsense",[341,999,1000],{"class":679}," type",[341,1002,683],{"class":347},[341,1004,1005],{"class":361},"\"responsive\"",[341,1007,1008],{"class":679}," adsense-slot-id",[341,1010,683],{"class":347},[341,1012,1013],{"class":361},"\"xxxxxxxxxxxxxxxx\"",[341,1015,697],{"class":347},[16,1017,1018,1019,1021,1022,1025],{},"I assumed ",[52,1020,961],{}," meant the ad would adapt its width ",[257,1023,1024],{},"and"," height to whatever container it sat in, matching the layout I'd already designed around it. That assumption turned out to be wrong — \"responsive\" in AdSense doesn't mean \"fits your design's intended proportions,\" it means \"Google decides the final size based on available space and ad inventory,\" which is a different thing.",[11,1027,1029],{"id":1028},"the-problem","The Problem",[16,1031,1032],{},"On mobile, the ad rendered as a square block instead of the horizontal banner shape I expected:",[16,1034,1035],{},[67,1036],{"alt":1037,"src":1038},"AdSense responsive ad rendering as a square block on mobile instead of a horizontal banner","\u002Fimages\u002Fdev-practice\u002Ffixing-adsense-responsive-mobile-square\u002Ffixing-adsense-responsive-mobile-square-mobile-broken.webp",[16,1040,1041,1042,1045],{},"What surprised me more was that the ",[257,1043,1044],{},"desktop"," version wasn't stable either — the ad's height grew well past the 90px I'd designed the layout for, pushing surrounding content down:",[16,1047,1048],{},[67,1049],{"alt":1050,"src":1051},"AdSense ad height exceeding the expected 90px on desktop, pushing page content down","\u002Fimages\u002Fdev-practice\u002Ffixing-adsense-responsive-mobile-square\u002Ffixing-adsense-responsive-mobile-square-desktop-height-broken.webp",[11,1053,1055],{"id":1054},"investigation","Investigation",[16,1057,1058],{},"I started by checking whether this was a container-width problem — my first guess was that the parent element wasn't wide enough for Google to calculate a sensible size, which is one of AdSense's own documented failure modes for responsive units. That wasn't it; the container had a defined width.",[16,1060,1061,1062,1065],{},"Inspecting the actual ",[52,1063,1064],{},"\u003Cins>"," element in the rendered DOM pointed at the real cause: the component was setting",[332,1067,1071],{"className":1068,"code":1069,"language":1070,"meta":337,"style":337},"language-js shiki shiki-themes github-light github-light","ins.style.width = '100%'\nins.dataset.adFormat = 'auto'\nins.dataset.fullWidthResponsive = 'true'\n","js",[52,1072,1073,1084,1094],{"__ignoreMap":337},[341,1074,1075,1078,1081],{"class":343,"line":344},[341,1076,1077],{"class":347},"ins.style.width ",[341,1079,683],{"class":1080},"sCydW",[341,1082,1083],{"class":361}," '100%'\n",[341,1085,1086,1089,1091],{"class":343,"line":351},[341,1087,1088],{"class":347},"ins.dataset.adFormat ",[341,1090,683],{"class":1080},[341,1092,1093],{"class":361}," 'auto'\n",[341,1095,1096,1099,1101],{"class":343,"line":368},[341,1097,1098],{"class":347},"ins.dataset.fullWidthResponsive ",[341,1100,683],{"class":1080},[341,1102,1103],{"class":361}," 'true'\n",[16,1105,1106,1108,1109,1111,1112,1114,1115,1118],{},[52,1107,965],{}," combined with ",[52,1110,968],{}," tells Google to calculate both dimensions itself, expanding to the full width of the screen on mobile and choosing whatever height fits the available ad inventory. On mobile that frequently meant falling back to a 300×250 or 250×250 rectangle slot rather than a horizontal banner. On desktop, with no explicit height constraint on either the ",[52,1113,1064],{}," tag or its parent, the calculated height wasn't pinned to 90px the way a ",[52,1116,1117],{},"728×90"," banner unit would be.",[16,1120,1121],{},"I considered three different ways to fix this, in this order:",[178,1123,1124,1146,1161],{},[181,1125,1126,1129,1130,1133,1134,1137,1138,1141,1142,1145],{},[60,1127,1128],{},"JavaScript resize detection"," — track ",[52,1131,1132],{},"window.innerWidth",", swap between a ",[52,1135,1136],{},"banner"," (728×90) and ",[52,1139,1140],{},"rectangle"," (300×250) ad type\u002Fslot pair on the client, with a ",[52,1143,1144],{},"resize"," listener to re-trigger the swap when the breakpoint is crossed.",[181,1147,1148,1154,1155,1158,1159,816],{},[60,1149,1150,1151,1153],{},"Force a fixed-size ",[52,1152,1136],{}," unit everywhere"," — drop ",[52,1156,1157],{},"responsive"," entirely and hardcode ",[52,1160,1117],{},[181,1162,1163,1166,1167,816],{},[60,1164,1165],{},"Google's own documented CSS media query approach"," — keep a single responsive ad unit, but constrain its container's width\u002Fheight per breakpoint using plain CSS, exactly as described in ",[20,1168,1171],{"href":1169,"rel":1170},"https:\u002F\u002Fsupport.google.com\u002Fadsense\u002Fanswer\u002F9183363",[24],"Google's \"How to modify your responsive ad code\" guide",[16,1173,1174,1175,1177,1178,1181,1182,1185],{},"Option 2 fails immediately on a 375px-wide phone screen: a hardcoded 728px-wide container either overflows (horizontal scrollbar) or gets visually compressed by the browser, which AdSense's own policies flag as a layout problem. Option 1 works, but it means maintaining a ",[52,1176,1144],{}," event listener, a ",[52,1179,1180],{},"screenWidth"," ref, manual cleanup on unmount, and an SSR-safe check for ",[52,1183,1184],{},"window"," — a fair amount of client-side state for something Google already solves with CSS. I went with option 3.",[11,1187,1189],{"id":1188},"solution","Solution",[16,1191,1192,1193,319,1195,1197,1198,1201,1202,1205],{},"The fix itself, per Google's documented pattern, is to delete ",[52,1194,965],{},[52,1196,968],{},", and instead size the ",[257,1199,1200],{},"parent container"," with a unique class name and three ",[52,1203,1204],{},"@media"," breakpoints — Google's own example uses 320×100 below 500px, 468×60 between 500–799px, and 728×90 at 800px and above.",[16,1207,1208,1209,1211,1212,562],{},"I didn't want to hand-write that media query block every time I dropped an ad onto the site, so I folded it into the ",[52,1210,997],{}," component as a set of internal presets, one per ",[52,1213,1214],{},"type",[332,1216,1218],{"className":1068,"code":1217,"language":1070,"meta":337,"style":337},"const RESPONSIVE_CONFIG_MAP = {\n  banner: {\n    mobile:  { width: '320px', height: '50px' },   \u002F\u002F \u003C500px\n    tablet:  { width: '468px', height: '60px' },   \u002F\u002F 500–799px\n    desktop: { width: '728px', height: '90px' },   \u002F\u002F ≥800px\n  },\n  rectangle: {\n    mobile:  { width: '300px', height: '250px' },\n    tablet:  { width: '300px', height: '250px' },\n    desktop: { width: '300px', height: '250px' },\n  },\n  responsive: {\n    mobile:  { width: '320px', height: '100px' },\n    tablet:  { width: '468px', height: '60px' },\n    desktop: { width: '728px', height: '90px' },\n  },\n}\n",[52,1219,1220,1234,1239,1259,1277,1295,1300,1305,1320,1332,1344,1349,1355,1369,1382,1395,1400],{"__ignoreMap":337},[341,1221,1222,1225,1228,1231],{"class":343,"line":344},[341,1223,1224],{"class":1080},"const",[341,1226,1227],{"class":354}," RESPONSIVE_CONFIG_MAP",[341,1229,1230],{"class":1080}," =",[341,1232,1233],{"class":347}," {\n",[341,1235,1236],{"class":343,"line":351},[341,1237,1238],{"class":347},"  banner: {\n",[341,1240,1241,1244,1247,1250,1253,1256],{"class":343,"line":368},[341,1242,1243],{"class":347},"    mobile:  { width: ",[341,1245,1246],{"class":361},"'320px'",[341,1248,1249],{"class":347},", height: ",[341,1251,1252],{"class":361},"'50px'",[341,1254,1255],{"class":347}," },   ",[341,1257,1258],{"class":667},"\u002F\u002F \u003C500px\n",[341,1260,1261,1264,1267,1269,1272,1274],{"class":343,"line":381},[341,1262,1263],{"class":347},"    tablet:  { width: ",[341,1265,1266],{"class":361},"'468px'",[341,1268,1249],{"class":347},[341,1270,1271],{"class":361},"'60px'",[341,1273,1255],{"class":347},[341,1275,1276],{"class":667},"\u002F\u002F 500–799px\n",[341,1278,1279,1282,1285,1287,1290,1292],{"class":343,"line":390},[341,1280,1281],{"class":347},"    desktop: { width: ",[341,1283,1284],{"class":361},"'728px'",[341,1286,1249],{"class":347},[341,1288,1289],{"class":361},"'90px'",[341,1291,1255],{"class":347},[341,1293,1294],{"class":667},"\u002F\u002F ≥800px\n",[341,1296,1297],{"class":343,"line":396},[341,1298,1299],{"class":347},"  },\n",[341,1301,1302],{"class":343,"line":409},[341,1303,1304],{"class":347},"  rectangle: {\n",[341,1306,1307,1309,1312,1314,1317],{"class":343,"line":420},[341,1308,1243],{"class":347},[341,1310,1311],{"class":361},"'300px'",[341,1313,1249],{"class":347},[341,1315,1316],{"class":361},"'250px'",[341,1318,1319],{"class":347}," },\n",[341,1321,1322,1324,1326,1328,1330],{"class":343,"line":426},[341,1323,1263],{"class":347},[341,1325,1311],{"class":361},[341,1327,1249],{"class":347},[341,1329,1316],{"class":361},[341,1331,1319],{"class":347},[341,1333,1334,1336,1338,1340,1342],{"class":343,"line":432},[341,1335,1281],{"class":347},[341,1337,1311],{"class":361},[341,1339,1249],{"class":347},[341,1341,1316],{"class":361},[341,1343,1319],{"class":347},[341,1345,1347],{"class":343,"line":1346},11,[341,1348,1299],{"class":347},[341,1350,1352],{"class":343,"line":1351},12,[341,1353,1354],{"class":347},"  responsive: {\n",[341,1356,1358,1360,1362,1364,1367],{"class":343,"line":1357},13,[341,1359,1243],{"class":347},[341,1361,1246],{"class":361},[341,1363,1249],{"class":347},[341,1365,1366],{"class":361},"'100px'",[341,1368,1319],{"class":347},[341,1370,1372,1374,1376,1378,1380],{"class":343,"line":1371},14,[341,1373,1263],{"class":347},[341,1375,1266],{"class":361},[341,1377,1249],{"class":347},[341,1379,1271],{"class":361},[341,1381,1319],{"class":347},[341,1383,1385,1387,1389,1391,1393],{"class":343,"line":1384},15,[341,1386,1281],{"class":347},[341,1388,1284],{"class":361},[341,1390,1249],{"class":347},[341,1392,1289],{"class":361},[341,1394,1319],{"class":347},[341,1396,1398],{"class":343,"line":1397},16,[341,1399,1299],{"class":347},[341,1401,1403],{"class":343,"line":1402},17,[341,1404,435],{"class":347},[332,1406,1408],{"className":986,"code":1407,"language":988,"meta":337,"style":337},"\u003Ctemplate>\n  \u003Cdiv ref=\"containerRef\" class=\"w-full flex justify-center my-1\" :class=\"customClass\">\n    \u003Cdiv\n      class=\"relative flex items-center justify-center overflow-hidden transition-all duration-300\"\n      :style=\"wrapperStyle\"\n    >\n      \u003CTransition name=\"fade\">\n        \u003Cdiv\n          v-if=\"!isLoaded\"\n          class=\"absolute inset-0 flex flex-col items-center justify-center gap-2 select-none\"\n        >\n          \u003Cspan class=\"text-[10px] font-bold uppercase tracking-[0.2em] text-surface-400 dark:text-surface-600\">\n            Advertisement\n          \u003C\u002Fspan>\n          \u003CIcon name=\"lucide:monitor\" class=\"h-5 w-5 text-surface-300 dark:text-surface-700\" \u002F>\n        \u003C\u002Fdiv>\n      \u003C\u002FTransition>\n      \u003CTransition name=\"fade\">\n        \u003Cdiv\n          v-if=\"isLoaded && adStatus === 'unfilled'\"\n          class=\"absolute inset-0 flex flex-col items-center justify-center gap-1 select-none pointer-events-none\"\n        >\n          \u003Cspan class=\"text-[10px] font-bold uppercase tracking-[0.2em] text-surface-300 dark:text-surface-700\">\n            Advertisement\n          \u003C\u002Fspan>\n        \u003C\u002Fdiv>\n      \u003C\u002FTransition>\n      \u003Cdiv ref=\"adRef\" class=\"relative z-10 w-full h-full\" \u002F>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup>\n\u002F**\n * AdAdsense - Google AdSense 手动广告单元组件\n * \n * 基于 Google 官方方案，用 CSS media queries 处理响应式\n * 内置三档响应式配置，无需传参\n *\u002F\n\nconst props = defineProps({\n  type: {\n    type: String,\n    default: 'responsive',\n    validator: (v) => ['banner', 'rectangle', 'responsive', 'in-article', 'in-feed', 'multiplex'].includes(v),\n  },\n  width: { type: String, default: null },\n  height: { type: String, default: null },\n  customClass: { type: String, default: '' },\n  adsenseSlotId: { type: String, default: '' },\n  layoutKey: { type: String, default: '' },\n})\n\nconst runtimeConfig = useRuntimeConfig()\nconst adsenseClientId = computed(\n  () => runtimeConfig.public?.ads?.googleAdSenseId ?? ''\n)\n\n\u002F\u002F 内置响应式配置（不依赖 props.responsive）\nconst RESPONSIVE_CONFIG_MAP = {\n  banner: {\n    mobile: { width: '320px', height: '50px' },\n    tablet: { width: '468px', height: '60px' },\n    desktop: { width: '728px', height: '90px' },\n  },\n  rectangle: {\n    mobile: { width: '300px', height: '250px' },\n    tablet: { width: '300px', height: '250px' },\n    desktop: { width: '300px', height: '250px' },\n  },\n  responsive: {\n    mobile: { width: '320px', height: '100px' },\n    tablet: { width: '468px', height: '60px' },\n    desktop: { width: '728px', height: '90px' },\n  },\n}\n\nconst effectiveResponsiveConfig = computed(() => {\n  return RESPONSIVE_CONFIG_MAP[props.type] || RESPONSIVE_CONFIG_MAP.responsive\n})\n\nconst SIZE_MAP = {\n  banner:       { width: '728px', height: '90px' },\n  rectangle:    { width: '300px', height: '250px' },\n  responsive:   { width: '100%', height: '90px' },\n  'in-article': { width: '100%', height: 'auto' },\n  'in-feed':    { width: '100%', height: 'auto' },\n  multiplex:    { width: '100%', height: 'auto' },\n}\n\nconst containerWidth  = computed(() => props.width  ?? SIZE_MAP[props.type]?.width  ?? '100%')\nconst containerHeight = computed(() => props.height ?? SIZE_MAP[props.type]?.height ?? '90px')\n\nconst MIN_HEIGHT_BEFORE_LOAD = {\n  'in-article': '250px',\n  'in-feed':    '150px',\n  multiplex:    '280px',\n}\n\nconst wrapperStyle = computed(() => {\n  const style = { width: containerWidth.value, maxWidth: '100%' }\n  if (containerHeight.value === 'auto') {\n    style.minHeight = isLoaded.value ? 'auto' : (MIN_HEIGHT_BEFORE_LOAD[props.type] ?? '90px')\n  } else {\n    style.height = containerHeight.value\n  }\n  return style\n})\n\nconst adClassName = computed(() => `ad-unit-${props.adsenseSlotId || 'default'}`)\n\nconst responsiveCSS = computed(() => {\n  if (!['responsive', 'banner', 'rectangle'].includes(props.type)) return ''\n  \n  const { mobile, tablet, desktop } = effectiveResponsiveConfig.value\n  return `\n    .${adClassName.value} {\n      width: ${mobile.width};\n      height: ${mobile.height};\n    }\n    @media (min-width: 500px) {\n      .${adClassName.value} {\n        width: ${tablet.width};\n        height: ${tablet.height};\n      }\n    }\n    @media (min-width: 800px) {\n      .${adClassName.value} {\n        width: ${desktop.width};\n        height: ${desktop.height};\n      }\n    }\n  `\n})\n\nconst containerRef = ref(null)\nconst adRef = ref(null)\nconst isLoaded = ref(false)\nconst adStatus = ref('')\n\nlet intersectionObserver = null\nlet statusObserver = null\nlet fallbackTimer = null\nlet styleElement = null\n\nfunction waitForAdsbygoogle(timeout = 8000) {\n  return new Promise((resolve, reject) => {\n    if (window.adsbygoogle) return resolve()\n    const start = Date.now()\n    const timer = setInterval(() => {\n      if (window.adsbygoogle) {\n        clearInterval(timer)\n        resolve()\n      } else if (Date.now() - start > timeout) {\n        clearInterval(timer)\n        reject(new Error('adsbygoogle SDK 加载超时'))\n      }\n    }, 100)\n  })\n}\n\nfunction watchAdStatus(el) {\n  statusObserver = new MutationObserver(() => {\n    const status = el.getAttribute('data-ad-status')\n    if (status === 'filled' || status === 'unfilled') {\n      adStatus.value = status\n      isLoaded.value = true\n      statusObserver?.disconnect()\n      statusObserver = null\n      if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null }\n    }\n  })\n  statusObserver.observe(el, { attributes: true, attributeFilter: ['data-ad-status'] })\n\n  fallbackTimer = setTimeout(() => {\n    if (!isLoaded.value) isLoaded.value = true\n  }, 5000)\n}\n\nfunction buildInsElement() {\n  const ins = document.createElement('ins')\n  ins.className = `adsbygoogle ${adClassName.value}`\n  ins.style.display = 'block'\n  ins.dataset.adClient = adsenseClientId.value\n  ins.dataset.adSlot = props.adsenseSlotId\n\n  if (props.type === 'in-article') {\n    ins.style.textAlign = 'center'\n    ins.dataset.adFormat = 'fluid'\n    ins.dataset.adLayout = 'in-article'\n  } else if (props.type === 'in-feed') {\n    ins.style.display = 'block'\n    ins.dataset.adFormat = 'fluid'\n    if (props.layoutKey) {\n      ins.dataset.adLayoutKey = props.layoutKey\n    } else if (import.meta.dev) {\n      console.warn('[AdAdsense] type=\"in-feed\" 需要传入 layoutKey')\n    }\n  } else if (props.type === 'multiplex') {\n    ins.style.display = 'block'\n    ins.dataset.adFormat = 'autorelaxed'\n  } else if (props.type === 'responsive') {\n    ins.style.width = '100%'\n  } else {\n    ins.style.width = containerWidth.value\n    ins.style.height = containerHeight.value\n  }\n\n  return ins\n}\n\nasync function loadAd() {\n  if (!adsenseClientId.value || !props.adsenseSlotId) {\n    if (import.meta.dev) console.warn('[AdAdsense] adsenseClientId 或 adsenseSlotId 不能为空')\n    return\n  }\n  if (!adRef.value) return\n\n  try {\n    await waitForAdsbygoogle()\n  } catch (e) {\n    if (import.meta.dev) console.error('[AdAdsense]', e.message)\n    isLoaded.value = true\n    return\n  }\n\n  adRef.value.innerHTML = ''\n  adStatus.value = ''\n\n  const ins = buildInsElement()\n  adRef.value.appendChild(ins)\n  watchAdStatus(ins)\n\n  try {\n    ;(window.adsbygoogle = window.adsbygoogle || []).push({})\n  } catch (e) {\n    if (import.meta.dev) console.error('[AdAdsense] push 失败:', e)\n    isLoaded.value = true\n  }\n}\n\nfunction initObserver() {\n  if (!('IntersectionObserver' in window)) { loadAd(); return }\n  intersectionObserver = new IntersectionObserver(\n    (entries) => {\n      if (entries[0]?.isIntersecting) {\n        loadAd()\n        intersectionObserver?.disconnect()\n        intersectionObserver = null\n      }\n    },\n    { rootMargin: '200px 0px' },\n  )\n  if (containerRef.value) intersectionObserver.observe(containerRef.value)\n}\n\nfunction injectResponsiveCSS() {\n  if (!responsiveCSS.value) return\n  \n  styleElement = document.createElement('style')\n  styleElement.textContent = responsiveCSS.value\n  document.head.appendChild(styleElement)\n}\n\nonMounted(() => {\n  if (import.meta.client) {\n    injectResponsiveCSS()\n    initObserver()\n  }\n})\n\nonBeforeUnmount(() => {\n  intersectionObserver?.disconnect()\n  intersectionObserver = null\n  statusObserver?.disconnect()\n  statusObserver = null\n  if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null }\n  if (styleElement) {\n    styleElement.remove()\n    styleElement = null\n  }\n  if (adRef.value) adRef.value.innerHTML = ''\n})\n\u003C\u002Fscript>\n",[52,1409,1410,1420,1454,1462,1472,1482,1487,1505,1512,1522,1532,1537,1553,1558,1567,1590,1599,1608,1623,1630,1640,1650,1655,1671,1676,1685,1694,1703,1730,1740,1750,1760,1765,1778,1784,1790,1796,1802,1808,1814,1819,1835,1841,1847,1858,1917,1922,1933,1943,1954,1964,1974,1980,1985,2001,2017,2034,2040,2045,2051,2062,2067,2081,2095,2108,2113,2118,2131,2144,2157,2162,2167,2180,2193,2206,2211,2216,2221,2240,2259,2264,2269,2281,2295,2309,2324,2342,2359,2373,2378,2383,2417,2450,2455,2467,2478,2491,2502,2507,2512,2530,2549,2567,2599,2610,2621,2627,2635,2640,2645,2680,2685,2703,2738,2744,2772,2780,2797,2813,2828,2833,2839,2853,2867,2881,2887,2892,2898,2911,2924,2937,2942,2947,2953,2958,2963,2982,3000,3019,3037,3042,3056,3068,3080,3092,3097,3118,3146,3162,3181,3200,3209,3218,3226,3257,3264,3286,3291,3302,3308,3313,3318,3333,3352,3375,3400,3411,3422,3433,3443,3464,3469,3474,3497,3502,3519,3535,3546,3551,3556,3567,3590,3610,3621,3632,3643,3648,3663,3674,3685,3696,3714,3724,3733,3741,3752,3775,3791,3796,3814,3823,3833,3851,3861,3870,3880,3890,3895,3900,3908,3913,3918,3932,3952,3977,3983,3988,4003,4008,4016,4026,4037,4063,4073,4078,4083,4088,4098,4108,4113,4126,4138,4146,4151,4158,4180,4189,4214,4223,4228,4233,4238,4248,4278,4293,4308,4322,4330,4340,4350,4355,4361,4372,4378,4391,4396,4401,4411,4425,4430,4449,4460,4471,4476,4481,4493,4509,4517,4525,4530,4535,4540,4552,4562,4571,4581,4590,4607,4615,4626,4636,4641,4653,4658],{"__ignoreMap":337},[341,1411,1412,1414,1417],{"class":343,"line":344},[341,1413,673],{"class":347},[341,1415,1416],{"class":676},"template",[341,1418,1419],{"class":347},">\n",[341,1421,1422,1425,1428,1431,1433,1436,1439,1441,1444,1447,1449,1452],{"class":343,"line":351},[341,1423,1424],{"class":347},"  \u003C",[341,1426,1427],{"class":676},"div",[341,1429,1430],{"class":679}," ref",[341,1432,683],{"class":347},[341,1434,1435],{"class":361},"\"containerRef\"",[341,1437,1438],{"class":679}," class",[341,1440,683],{"class":347},[341,1442,1443],{"class":361},"\"w-full flex justify-center my-1\"",[341,1445,1446],{"class":679}," :class",[341,1448,683],{"class":347},[341,1450,1451],{"class":361},"\"customClass\"",[341,1453,1419],{"class":347},[341,1455,1456,1459],{"class":343,"line":368},[341,1457,1458],{"class":347},"    \u003C",[341,1460,1461],{"class":676},"div\n",[341,1463,1464,1467,1469],{"class":343,"line":381},[341,1465,1466],{"class":679},"      class",[341,1468,683],{"class":347},[341,1470,1471],{"class":361},"\"relative flex items-center justify-center overflow-hidden transition-all duration-300\"\n",[341,1473,1474,1477,1479],{"class":343,"line":390},[341,1475,1476],{"class":679},"      :style",[341,1478,683],{"class":347},[341,1480,1481],{"class":361},"\"wrapperStyle\"\n",[341,1483,1484],{"class":343,"line":396},[341,1485,1486],{"class":347},"    >\n",[341,1488,1489,1492,1495,1498,1500,1503],{"class":343,"line":409},[341,1490,1491],{"class":347},"      \u003C",[341,1493,1494],{"class":676},"Transition",[341,1496,1497],{"class":679}," name",[341,1499,683],{"class":347},[341,1501,1502],{"class":361},"\"fade\"",[341,1504,1419],{"class":347},[341,1506,1507,1510],{"class":343,"line":420},[341,1508,1509],{"class":347},"        \u003C",[341,1511,1461],{"class":676},[341,1513,1514,1517,1519],{"class":343,"line":426},[341,1515,1516],{"class":679},"          v-if",[341,1518,683],{"class":347},[341,1520,1521],{"class":361},"\"!isLoaded\"\n",[341,1523,1524,1527,1529],{"class":343,"line":432},[341,1525,1526],{"class":679},"          class",[341,1528,683],{"class":347},[341,1530,1531],{"class":361},"\"absolute inset-0 flex flex-col items-center justify-center gap-2 select-none\"\n",[341,1533,1534],{"class":343,"line":1346},[341,1535,1536],{"class":347},"        >\n",[341,1538,1539,1542,1544,1546,1548,1551],{"class":343,"line":1351},[341,1540,1541],{"class":347},"          \u003C",[341,1543,341],{"class":676},[341,1545,1438],{"class":679},[341,1547,683],{"class":347},[341,1549,1550],{"class":361},"\"text-[10px] font-bold uppercase tracking-[0.2em] text-surface-400 dark:text-surface-600\"",[341,1552,1419],{"class":347},[341,1554,1555],{"class":343,"line":1357},[341,1556,1557],{"class":347},"            Advertisement\n",[341,1559,1560,1563,1565],{"class":343,"line":1371},[341,1561,1562],{"class":347},"          \u003C\u002F",[341,1564,341],{"class":676},[341,1566,1419],{"class":347},[341,1568,1569,1571,1574,1576,1578,1581,1583,1585,1588],{"class":343,"line":1384},[341,1570,1541],{"class":347},[341,1572,1573],{"class":676},"Icon",[341,1575,1497],{"class":679},[341,1577,683],{"class":347},[341,1579,1580],{"class":361},"\"lucide:monitor\"",[341,1582,1438],{"class":679},[341,1584,683],{"class":347},[341,1586,1587],{"class":361},"\"h-5 w-5 text-surface-300 dark:text-surface-700\"",[341,1589,697],{"class":347},[341,1591,1592,1595,1597],{"class":343,"line":1397},[341,1593,1594],{"class":347},"        \u003C\u002F",[341,1596,1427],{"class":676},[341,1598,1419],{"class":347},[341,1600,1601,1604,1606],{"class":343,"line":1402},[341,1602,1603],{"class":347},"      \u003C\u002F",[341,1605,1494],{"class":676},[341,1607,1419],{"class":347},[341,1609,1611,1613,1615,1617,1619,1621],{"class":343,"line":1610},18,[341,1612,1491],{"class":347},[341,1614,1494],{"class":676},[341,1616,1497],{"class":679},[341,1618,683],{"class":347},[341,1620,1502],{"class":361},[341,1622,1419],{"class":347},[341,1624,1626,1628],{"class":343,"line":1625},19,[341,1627,1509],{"class":347},[341,1629,1461],{"class":676},[341,1631,1633,1635,1637],{"class":343,"line":1632},20,[341,1634,1516],{"class":679},[341,1636,683],{"class":347},[341,1638,1639],{"class":361},"\"isLoaded && adStatus === 'unfilled'\"\n",[341,1641,1643,1645,1647],{"class":343,"line":1642},21,[341,1644,1526],{"class":679},[341,1646,683],{"class":347},[341,1648,1649],{"class":361},"\"absolute inset-0 flex flex-col items-center justify-center gap-1 select-none pointer-events-none\"\n",[341,1651,1653],{"class":343,"line":1652},22,[341,1654,1536],{"class":347},[341,1656,1658,1660,1662,1664,1666,1669],{"class":343,"line":1657},23,[341,1659,1541],{"class":347},[341,1661,341],{"class":676},[341,1663,1438],{"class":679},[341,1665,683],{"class":347},[341,1667,1668],{"class":361},"\"text-[10px] font-bold uppercase tracking-[0.2em] text-surface-300 dark:text-surface-700\"",[341,1670,1419],{"class":347},[341,1672,1674],{"class":343,"line":1673},24,[341,1675,1557],{"class":347},[341,1677,1679,1681,1683],{"class":343,"line":1678},25,[341,1680,1562],{"class":347},[341,1682,341],{"class":676},[341,1684,1419],{"class":347},[341,1686,1688,1690,1692],{"class":343,"line":1687},26,[341,1689,1594],{"class":347},[341,1691,1427],{"class":676},[341,1693,1419],{"class":347},[341,1695,1697,1699,1701],{"class":343,"line":1696},27,[341,1698,1603],{"class":347},[341,1700,1494],{"class":676},[341,1702,1419],{"class":347},[341,1704,1706,1708,1710,1712,1714,1717,1719,1721,1724,1728],{"class":343,"line":1705},28,[341,1707,1491],{"class":347},[341,1709,1427],{"class":676},[341,1711,1430],{"class":679},[341,1713,683],{"class":347},[341,1715,1716],{"class":361},"\"adRef\"",[341,1718,1438],{"class":679},[341,1720,683],{"class":347},[341,1722,1723],{"class":361},"\"relative z-10 w-full h-full\"",[341,1725,1727],{"class":1726},"sPP4b"," \u002F",[341,1729,1419],{"class":347},[341,1731,1733,1736,1738],{"class":343,"line":1732},29,[341,1734,1735],{"class":347},"    \u003C\u002F",[341,1737,1427],{"class":676},[341,1739,1419],{"class":347},[341,1741,1743,1746,1748],{"class":343,"line":1742},30,[341,1744,1745],{"class":347},"  \u003C\u002F",[341,1747,1427],{"class":676},[341,1749,1419],{"class":347},[341,1751,1753,1756,1758],{"class":343,"line":1752},31,[341,1754,1755],{"class":347},"\u003C\u002F",[341,1757,1416],{"class":676},[341,1759,1419],{"class":347},[341,1761,1763],{"class":343,"line":1762},32,[341,1764,703],{"emptyLinePlaceholder":702},[341,1766,1768,1770,1773,1776],{"class":343,"line":1767},33,[341,1769,673],{"class":347},[341,1771,1772],{"class":676},"script",[341,1774,1775],{"class":679}," setup",[341,1777,1419],{"class":347},[341,1779,1781],{"class":343,"line":1780},34,[341,1782,1783],{"class":667},"\u002F**\n",[341,1785,1787],{"class":343,"line":1786},35,[341,1788,1789],{"class":667}," * AdAdsense - Google AdSense 手动广告单元组件\n",[341,1791,1793],{"class":343,"line":1792},36,[341,1794,1795],{"class":667}," * \n",[341,1797,1799],{"class":343,"line":1798},37,[341,1800,1801],{"class":667}," * 基于 Google 官方方案，用 CSS media queries 处理响应式\n",[341,1803,1805],{"class":343,"line":1804},38,[341,1806,1807],{"class":667}," * 内置三档响应式配置，无需传参\n",[341,1809,1811],{"class":343,"line":1810},39,[341,1812,1813],{"class":667}," *\u002F\n",[341,1815,1817],{"class":343,"line":1816},40,[341,1818,703],{"emptyLinePlaceholder":702},[341,1820,1822,1824,1827,1829,1832],{"class":343,"line":1821},41,[341,1823,1224],{"class":1080},[341,1825,1826],{"class":354}," props",[341,1828,1230],{"class":1080},[341,1830,1831],{"class":679}," defineProps",[341,1833,1834],{"class":347},"({\n",[341,1836,1838],{"class":343,"line":1837},42,[341,1839,1840],{"class":347},"  type: {\n",[341,1842,1844],{"class":343,"line":1843},43,[341,1845,1846],{"class":347},"    type: String,\n",[341,1848,1850,1853,1856],{"class":343,"line":1849},44,[341,1851,1852],{"class":347},"    default: ",[341,1854,1855],{"class":361},"'responsive'",[341,1857,365],{"class":347},[341,1859,1861,1864,1867,1871,1874,1877,1880,1883,1886,1889,1891,1893,1895,1898,1900,1903,1905,1908,1911,1914],{"class":343,"line":1860},45,[341,1862,1863],{"class":679},"    validator",[341,1865,1866],{"class":347},": (",[341,1868,1870],{"class":1869},"sj_tP","v",[341,1872,1873],{"class":347},") ",[341,1875,1876],{"class":1080},"=>",[341,1878,1879],{"class":347}," [",[341,1881,1882],{"class":361},"'banner'",[341,1884,1885],{"class":347},", ",[341,1887,1888],{"class":361},"'rectangle'",[341,1890,1885],{"class":347},[341,1892,1855],{"class":361},[341,1894,1885],{"class":347},[341,1896,1897],{"class":361},"'in-article'",[341,1899,1885],{"class":347},[341,1901,1902],{"class":361},"'in-feed'",[341,1904,1885],{"class":347},[341,1906,1907],{"class":361},"'multiplex'",[341,1909,1910],{"class":347},"].",[341,1912,1913],{"class":679},"includes",[341,1915,1916],{"class":347},"(v),\n",[341,1918,1920],{"class":343,"line":1919},46,[341,1921,1299],{"class":347},[341,1923,1925,1928,1931],{"class":343,"line":1924},47,[341,1926,1927],{"class":347},"  width: { type: String, default: ",[341,1929,1930],{"class":354},"null",[341,1932,1319],{"class":347},[341,1934,1936,1939,1941],{"class":343,"line":1935},48,[341,1937,1938],{"class":347},"  height: { type: String, default: ",[341,1940,1930],{"class":354},[341,1942,1319],{"class":347},[341,1944,1946,1949,1952],{"class":343,"line":1945},49,[341,1947,1948],{"class":347},"  customClass: { type: String, default: ",[341,1950,1951],{"class":361},"''",[341,1953,1319],{"class":347},[341,1955,1957,1960,1962],{"class":343,"line":1956},50,[341,1958,1959],{"class":347},"  adsenseSlotId: { type: String, default: ",[341,1961,1951],{"class":361},[341,1963,1319],{"class":347},[341,1965,1967,1970,1972],{"class":343,"line":1966},51,[341,1968,1969],{"class":347},"  layoutKey: { type: String, default: ",[341,1971,1951],{"class":361},[341,1973,1319],{"class":347},[341,1975,1977],{"class":343,"line":1976},52,[341,1978,1979],{"class":347},"})\n",[341,1981,1983],{"class":343,"line":1982},53,[341,1984,703],{"emptyLinePlaceholder":702},[341,1986,1988,1990,1993,1995,1998],{"class":343,"line":1987},54,[341,1989,1224],{"class":1080},[341,1991,1992],{"class":354}," runtimeConfig",[341,1994,1230],{"class":1080},[341,1996,1997],{"class":679}," useRuntimeConfig",[341,1999,2000],{"class":347},"()\n",[341,2002,2004,2006,2009,2011,2014],{"class":343,"line":2003},55,[341,2005,1224],{"class":1080},[341,2007,2008],{"class":354}," adsenseClientId",[341,2010,1230],{"class":1080},[341,2012,2013],{"class":679}," computed",[341,2015,2016],{"class":347},"(\n",[341,2018,2020,2023,2025,2028,2031],{"class":343,"line":2019},56,[341,2021,2022],{"class":347},"  () ",[341,2024,1876],{"class":1080},[341,2026,2027],{"class":347}," runtimeConfig.public?.ads?.googleAdSenseId ",[341,2029,2030],{"class":1080},"??",[341,2032,2033],{"class":361}," ''\n",[341,2035,2037],{"class":343,"line":2036},57,[341,2038,2039],{"class":347},")\n",[341,2041,2043],{"class":343,"line":2042},58,[341,2044,703],{"emptyLinePlaceholder":702},[341,2046,2048],{"class":343,"line":2047},59,[341,2049,2050],{"class":667},"\u002F\u002F 内置响应式配置（不依赖 props.responsive）\n",[341,2052,2054,2056,2058,2060],{"class":343,"line":2053},60,[341,2055,1224],{"class":1080},[341,2057,1227],{"class":354},[341,2059,1230],{"class":1080},[341,2061,1233],{"class":347},[341,2063,2065],{"class":343,"line":2064},61,[341,2066,1238],{"class":347},[341,2068,2070,2073,2075,2077,2079],{"class":343,"line":2069},62,[341,2071,2072],{"class":347},"    mobile: { width: ",[341,2074,1246],{"class":361},[341,2076,1249],{"class":347},[341,2078,1252],{"class":361},[341,2080,1319],{"class":347},[341,2082,2084,2087,2089,2091,2093],{"class":343,"line":2083},63,[341,2085,2086],{"class":347},"    tablet: { width: ",[341,2088,1266],{"class":361},[341,2090,1249],{"class":347},[341,2092,1271],{"class":361},[341,2094,1319],{"class":347},[341,2096,2098,2100,2102,2104,2106],{"class":343,"line":2097},64,[341,2099,1281],{"class":347},[341,2101,1284],{"class":361},[341,2103,1249],{"class":347},[341,2105,1289],{"class":361},[341,2107,1319],{"class":347},[341,2109,2111],{"class":343,"line":2110},65,[341,2112,1299],{"class":347},[341,2114,2116],{"class":343,"line":2115},66,[341,2117,1304],{"class":347},[341,2119,2121,2123,2125,2127,2129],{"class":343,"line":2120},67,[341,2122,2072],{"class":347},[341,2124,1311],{"class":361},[341,2126,1249],{"class":347},[341,2128,1316],{"class":361},[341,2130,1319],{"class":347},[341,2132,2134,2136,2138,2140,2142],{"class":343,"line":2133},68,[341,2135,2086],{"class":347},[341,2137,1311],{"class":361},[341,2139,1249],{"class":347},[341,2141,1316],{"class":361},[341,2143,1319],{"class":347},[341,2145,2147,2149,2151,2153,2155],{"class":343,"line":2146},69,[341,2148,1281],{"class":347},[341,2150,1311],{"class":361},[341,2152,1249],{"class":347},[341,2154,1316],{"class":361},[341,2156,1319],{"class":347},[341,2158,2160],{"class":343,"line":2159},70,[341,2161,1299],{"class":347},[341,2163,2165],{"class":343,"line":2164},71,[341,2166,1354],{"class":347},[341,2168,2170,2172,2174,2176,2178],{"class":343,"line":2169},72,[341,2171,2072],{"class":347},[341,2173,1246],{"class":361},[341,2175,1249],{"class":347},[341,2177,1366],{"class":361},[341,2179,1319],{"class":347},[341,2181,2183,2185,2187,2189,2191],{"class":343,"line":2182},73,[341,2184,2086],{"class":347},[341,2186,1266],{"class":361},[341,2188,1249],{"class":347},[341,2190,1271],{"class":361},[341,2192,1319],{"class":347},[341,2194,2196,2198,2200,2202,2204],{"class":343,"line":2195},74,[341,2197,1281],{"class":347},[341,2199,1284],{"class":361},[341,2201,1249],{"class":347},[341,2203,1289],{"class":361},[341,2205,1319],{"class":347},[341,2207,2209],{"class":343,"line":2208},75,[341,2210,1299],{"class":347},[341,2212,2214],{"class":343,"line":2213},76,[341,2215,435],{"class":347},[341,2217,2219],{"class":343,"line":2218},77,[341,2220,703],{"emptyLinePlaceholder":702},[341,2222,2224,2226,2229,2231,2233,2236,2238],{"class":343,"line":2223},78,[341,2225,1224],{"class":1080},[341,2227,2228],{"class":354}," effectiveResponsiveConfig",[341,2230,1230],{"class":1080},[341,2232,2013],{"class":679},[341,2234,2235],{"class":347},"(() ",[341,2237,1876],{"class":1080},[341,2239,1233],{"class":347},[341,2241,2243,2246,2248,2251,2254,2256],{"class":343,"line":2242},79,[341,2244,2245],{"class":1080},"  return",[341,2247,1227],{"class":354},[341,2249,2250],{"class":347},"[props.type] ",[341,2252,2253],{"class":1080},"||",[341,2255,1227],{"class":354},[341,2257,2258],{"class":347},".responsive\n",[341,2260,2262],{"class":343,"line":2261},80,[341,2263,1979],{"class":347},[341,2265,2267],{"class":343,"line":2266},81,[341,2268,703],{"emptyLinePlaceholder":702},[341,2270,2272,2274,2277,2279],{"class":343,"line":2271},82,[341,2273,1224],{"class":1080},[341,2275,2276],{"class":354}," SIZE_MAP",[341,2278,1230],{"class":1080},[341,2280,1233],{"class":347},[341,2282,2284,2287,2289,2291,2293],{"class":343,"line":2283},83,[341,2285,2286],{"class":347},"  banner:       { width: ",[341,2288,1284],{"class":361},[341,2290,1249],{"class":347},[341,2292,1289],{"class":361},[341,2294,1319],{"class":347},[341,2296,2298,2301,2303,2305,2307],{"class":343,"line":2297},84,[341,2299,2300],{"class":347},"  rectangle:    { width: ",[341,2302,1311],{"class":361},[341,2304,1249],{"class":347},[341,2306,1316],{"class":361},[341,2308,1319],{"class":347},[341,2310,2312,2315,2318,2320,2322],{"class":343,"line":2311},85,[341,2313,2314],{"class":347},"  responsive:   { width: ",[341,2316,2317],{"class":361},"'100%'",[341,2319,1249],{"class":347},[341,2321,1289],{"class":361},[341,2323,1319],{"class":347},[341,2325,2327,2330,2333,2335,2337,2340],{"class":343,"line":2326},86,[341,2328,2329],{"class":361},"  'in-article'",[341,2331,2332],{"class":347},": { width: ",[341,2334,2317],{"class":361},[341,2336,1249],{"class":347},[341,2338,2339],{"class":361},"'auto'",[341,2341,1319],{"class":347},[341,2343,2345,2348,2351,2353,2355,2357],{"class":343,"line":2344},87,[341,2346,2347],{"class":361},"  'in-feed'",[341,2349,2350],{"class":347},":    { width: ",[341,2352,2317],{"class":361},[341,2354,1249],{"class":347},[341,2356,2339],{"class":361},[341,2358,1319],{"class":347},[341,2360,2362,2365,2367,2369,2371],{"class":343,"line":2361},88,[341,2363,2364],{"class":347},"  multiplex:    { width: ",[341,2366,2317],{"class":361},[341,2368,1249],{"class":347},[341,2370,2339],{"class":361},[341,2372,1319],{"class":347},[341,2374,2376],{"class":343,"line":2375},89,[341,2377,435],{"class":347},[341,2379,2381],{"class":343,"line":2380},90,[341,2382,703],{"emptyLinePlaceholder":702},[341,2384,2386,2388,2391,2394,2396,2398,2400,2403,2405,2407,2410,2412,2415],{"class":343,"line":2385},91,[341,2387,1224],{"class":1080},[341,2389,2390],{"class":354}," containerWidth",[341,2392,2393],{"class":1080},"  =",[341,2395,2013],{"class":679},[341,2397,2235],{"class":347},[341,2399,1876],{"class":1080},[341,2401,2402],{"class":347}," props.width  ",[341,2404,2030],{"class":1080},[341,2406,2276],{"class":354},[341,2408,2409],{"class":347},"[props.type]?.width  ",[341,2411,2030],{"class":1080},[341,2413,2414],{"class":361}," '100%'",[341,2416,2039],{"class":347},[341,2418,2420,2422,2425,2427,2429,2431,2433,2436,2438,2440,2443,2445,2448],{"class":343,"line":2419},92,[341,2421,1224],{"class":1080},[341,2423,2424],{"class":354}," containerHeight",[341,2426,1230],{"class":1080},[341,2428,2013],{"class":679},[341,2430,2235],{"class":347},[341,2432,1876],{"class":1080},[341,2434,2435],{"class":347}," props.height ",[341,2437,2030],{"class":1080},[341,2439,2276],{"class":354},[341,2441,2442],{"class":347},"[props.type]?.height ",[341,2444,2030],{"class":1080},[341,2446,2447],{"class":361}," '90px'",[341,2449,2039],{"class":347},[341,2451,2453],{"class":343,"line":2452},93,[341,2454,703],{"emptyLinePlaceholder":702},[341,2456,2458,2460,2463,2465],{"class":343,"line":2457},94,[341,2459,1224],{"class":1080},[341,2461,2462],{"class":354}," MIN_HEIGHT_BEFORE_LOAD",[341,2464,1230],{"class":1080},[341,2466,1233],{"class":347},[341,2468,2470,2472,2474,2476],{"class":343,"line":2469},95,[341,2471,2329],{"class":361},[341,2473,358],{"class":347},[341,2475,1316],{"class":361},[341,2477,365],{"class":347},[341,2479,2481,2483,2486,2489],{"class":343,"line":2480},96,[341,2482,2347],{"class":361},[341,2484,2485],{"class":347},":    ",[341,2487,2488],{"class":361},"'150px'",[341,2490,365],{"class":347},[341,2492,2494,2497,2500],{"class":343,"line":2493},97,[341,2495,2496],{"class":347},"  multiplex:    ",[341,2498,2499],{"class":361},"'280px'",[341,2501,365],{"class":347},[341,2503,2505],{"class":343,"line":2504},98,[341,2506,435],{"class":347},[341,2508,2510],{"class":343,"line":2509},99,[341,2511,703],{"emptyLinePlaceholder":702},[341,2513,2515,2517,2520,2522,2524,2526,2528],{"class":343,"line":2514},100,[341,2516,1224],{"class":1080},[341,2518,2519],{"class":354}," wrapperStyle",[341,2521,1230],{"class":1080},[341,2523,2013],{"class":679},[341,2525,2235],{"class":347},[341,2527,1876],{"class":1080},[341,2529,1233],{"class":347},[341,2531,2533,2536,2539,2541,2544,2546],{"class":343,"line":2532},101,[341,2534,2535],{"class":1080},"  const",[341,2537,2538],{"class":354}," style",[341,2540,1230],{"class":1080},[341,2542,2543],{"class":347}," { width: containerWidth.value, maxWidth: ",[341,2545,2317],{"class":361},[341,2547,2548],{"class":347}," }\n",[341,2550,2552,2555,2558,2561,2564],{"class":343,"line":2551},102,[341,2553,2554],{"class":1080},"  if",[341,2556,2557],{"class":347}," (containerHeight.value ",[341,2559,2560],{"class":1080},"===",[341,2562,2563],{"class":361}," 'auto'",[341,2565,2566],{"class":347},") {\n",[341,2568,2570,2573,2575,2578,2580,2582,2585,2588,2591,2593,2595,2597],{"class":343,"line":2569},103,[341,2571,2572],{"class":347},"    style.minHeight ",[341,2574,683],{"class":1080},[341,2576,2577],{"class":347}," isLoaded.value ",[341,2579,889],{"class":1080},[341,2581,2563],{"class":361},[341,2583,2584],{"class":1080}," :",[341,2586,2587],{"class":347}," (",[341,2589,2590],{"class":354},"MIN_HEIGHT_BEFORE_LOAD",[341,2592,2250],{"class":347},[341,2594,2030],{"class":1080},[341,2596,2447],{"class":361},[341,2598,2039],{"class":347},[341,2600,2602,2605,2608],{"class":343,"line":2601},104,[341,2603,2604],{"class":347},"  } ",[341,2606,2607],{"class":1080},"else",[341,2609,1233],{"class":347},[341,2611,2613,2616,2618],{"class":343,"line":2612},105,[341,2614,2615],{"class":347},"    style.height ",[341,2617,683],{"class":1080},[341,2619,2620],{"class":347}," containerHeight.value\n",[341,2622,2624],{"class":343,"line":2623},106,[341,2625,2626],{"class":347},"  }\n",[341,2628,2630,2632],{"class":343,"line":2629},107,[341,2631,2245],{"class":1080},[341,2633,2634],{"class":347}," style\n",[341,2636,2638],{"class":343,"line":2637},108,[341,2639,1979],{"class":347},[341,2641,2643],{"class":343,"line":2642},109,[341,2644,703],{"emptyLinePlaceholder":702},[341,2646,2648,2650,2653,2655,2657,2659,2661,2664,2667,2669,2672,2675,2678],{"class":343,"line":2647},110,[341,2649,1224],{"class":1080},[341,2651,2652],{"class":354}," adClassName",[341,2654,1230],{"class":1080},[341,2656,2013],{"class":679},[341,2658,2235],{"class":347},[341,2660,1876],{"class":1080},[341,2662,2663],{"class":361}," `ad-unit-${",[341,2665,2666],{"class":347},"props",[341,2668,816],{"class":361},[341,2670,2671],{"class":347},"adsenseSlotId",[341,2673,2674],{"class":1080}," ||",[341,2676,2677],{"class":361}," 'default'}`",[341,2679,2039],{"class":347},[341,2681,2683],{"class":343,"line":2682},111,[341,2684,703],{"emptyLinePlaceholder":702},[341,2686,2688,2690,2693,2695,2697,2699,2701],{"class":343,"line":2687},112,[341,2689,1224],{"class":1080},[341,2691,2692],{"class":354}," responsiveCSS",[341,2694,1230],{"class":1080},[341,2696,2013],{"class":679},[341,2698,2235],{"class":347},[341,2700,1876],{"class":1080},[341,2702,1233],{"class":347},[341,2704,2706,2708,2710,2713,2716,2718,2720,2722,2724,2726,2728,2730,2733,2736],{"class":343,"line":2705},113,[341,2707,2554],{"class":1080},[341,2709,2587],{"class":347},[341,2711,2712],{"class":1080},"!",[341,2714,2715],{"class":347},"[",[341,2717,1855],{"class":361},[341,2719,1885],{"class":347},[341,2721,1882],{"class":361},[341,2723,1885],{"class":347},[341,2725,1888],{"class":361},[341,2727,1910],{"class":347},[341,2729,1913],{"class":679},[341,2731,2732],{"class":347},"(props.type)) ",[341,2734,2735],{"class":1080},"return",[341,2737,2033],{"class":361},[341,2739,2741],{"class":343,"line":2740},114,[341,2742,2743],{"class":347},"  \n",[341,2745,2747,2749,2752,2755,2757,2760,2762,2764,2767,2769],{"class":343,"line":2746},115,[341,2748,2535],{"class":1080},[341,2750,2751],{"class":347}," { ",[341,2753,2754],{"class":354},"mobile",[341,2756,1885],{"class":347},[341,2758,2759],{"class":354},"tablet",[341,2761,1885],{"class":347},[341,2763,1044],{"class":354},[341,2765,2766],{"class":347}," } ",[341,2768,683],{"class":1080},[341,2770,2771],{"class":347}," effectiveResponsiveConfig.value\n",[341,2773,2775,2777],{"class":343,"line":2774},116,[341,2776,2245],{"class":1080},[341,2778,2779],{"class":361}," `\n",[341,2781,2783,2786,2789,2791,2794],{"class":343,"line":2782},117,[341,2784,2785],{"class":361},"    .${",[341,2787,2788],{"class":347},"adClassName",[341,2790,816],{"class":361},[341,2792,2793],{"class":347},"value",[341,2795,2796],{"class":361},"} {\n",[341,2798,2800,2803,2805,2807,2810],{"class":343,"line":2799},118,[341,2801,2802],{"class":361},"      width: ${",[341,2804,2754],{"class":347},[341,2806,816],{"class":361},[341,2808,2809],{"class":347},"width",[341,2811,2812],{"class":361},"};\n",[341,2814,2816,2819,2821,2823,2826],{"class":343,"line":2815},119,[341,2817,2818],{"class":361},"      height: ${",[341,2820,2754],{"class":347},[341,2822,816],{"class":361},[341,2824,2825],{"class":347},"height",[341,2827,2812],{"class":361},[341,2829,2831],{"class":343,"line":2830},120,[341,2832,423],{"class":361},[341,2834,2836],{"class":343,"line":2835},121,[341,2837,2838],{"class":361},"    @media (min-width: 500px) {\n",[341,2840,2842,2845,2847,2849,2851],{"class":343,"line":2841},122,[341,2843,2844],{"class":361},"      .${",[341,2846,2788],{"class":347},[341,2848,816],{"class":361},[341,2850,2793],{"class":347},[341,2852,2796],{"class":361},[341,2854,2856,2859,2861,2863,2865],{"class":343,"line":2855},123,[341,2857,2858],{"class":361},"        width: ${",[341,2860,2759],{"class":347},[341,2862,816],{"class":361},[341,2864,2809],{"class":347},[341,2866,2812],{"class":361},[341,2868,2870,2873,2875,2877,2879],{"class":343,"line":2869},124,[341,2871,2872],{"class":361},"        height: ${",[341,2874,2759],{"class":347},[341,2876,816],{"class":361},[341,2878,2825],{"class":347},[341,2880,2812],{"class":361},[341,2882,2884],{"class":343,"line":2883},125,[341,2885,2886],{"class":361},"      }\n",[341,2888,2890],{"class":343,"line":2889},126,[341,2891,423],{"class":361},[341,2893,2895],{"class":343,"line":2894},127,[341,2896,2897],{"class":361},"    @media (min-width: 800px) {\n",[341,2899,2901,2903,2905,2907,2909],{"class":343,"line":2900},128,[341,2902,2844],{"class":361},[341,2904,2788],{"class":347},[341,2906,816],{"class":361},[341,2908,2793],{"class":347},[341,2910,2796],{"class":361},[341,2912,2914,2916,2918,2920,2922],{"class":343,"line":2913},129,[341,2915,2858],{"class":361},[341,2917,1044],{"class":347},[341,2919,816],{"class":361},[341,2921,2809],{"class":347},[341,2923,2812],{"class":361},[341,2925,2927,2929,2931,2933,2935],{"class":343,"line":2926},130,[341,2928,2872],{"class":361},[341,2930,1044],{"class":347},[341,2932,816],{"class":361},[341,2934,2825],{"class":347},[341,2936,2812],{"class":361},[341,2938,2940],{"class":343,"line":2939},131,[341,2941,2886],{"class":361},[341,2943,2945],{"class":343,"line":2944},132,[341,2946,423],{"class":361},[341,2948,2950],{"class":343,"line":2949},133,[341,2951,2952],{"class":361},"  `\n",[341,2954,2956],{"class":343,"line":2955},134,[341,2957,1979],{"class":347},[341,2959,2961],{"class":343,"line":2960},135,[341,2962,703],{"emptyLinePlaceholder":702},[341,2964,2966,2968,2971,2973,2975,2978,2980],{"class":343,"line":2965},136,[341,2967,1224],{"class":1080},[341,2969,2970],{"class":354}," containerRef",[341,2972,1230],{"class":1080},[341,2974,1430],{"class":679},[341,2976,2977],{"class":347},"(",[341,2979,1930],{"class":354},[341,2981,2039],{"class":347},[341,2983,2985,2987,2990,2992,2994,2996,2998],{"class":343,"line":2984},137,[341,2986,1224],{"class":1080},[341,2988,2989],{"class":354}," adRef",[341,2991,1230],{"class":1080},[341,2993,1430],{"class":679},[341,2995,2977],{"class":347},[341,2997,1930],{"class":354},[341,2999,2039],{"class":347},[341,3001,3003,3005,3008,3010,3012,3014,3017],{"class":343,"line":3002},138,[341,3004,1224],{"class":1080},[341,3006,3007],{"class":354}," isLoaded",[341,3009,1230],{"class":1080},[341,3011,1430],{"class":679},[341,3013,2977],{"class":347},[341,3015,3016],{"class":354},"false",[341,3018,2039],{"class":347},[341,3020,3022,3024,3027,3029,3031,3033,3035],{"class":343,"line":3021},139,[341,3023,1224],{"class":1080},[341,3025,3026],{"class":354}," adStatus",[341,3028,1230],{"class":1080},[341,3030,1430],{"class":679},[341,3032,2977],{"class":347},[341,3034,1951],{"class":361},[341,3036,2039],{"class":347},[341,3038,3040],{"class":343,"line":3039},140,[341,3041,703],{"emptyLinePlaceholder":702},[341,3043,3045,3048,3051,3053],{"class":343,"line":3044},141,[341,3046,3047],{"class":1080},"let",[341,3049,3050],{"class":347}," intersectionObserver ",[341,3052,683],{"class":1080},[341,3054,3055],{"class":354}," null\n",[341,3057,3059,3061,3064,3066],{"class":343,"line":3058},142,[341,3060,3047],{"class":1080},[341,3062,3063],{"class":347}," statusObserver ",[341,3065,683],{"class":1080},[341,3067,3055],{"class":354},[341,3069,3071,3073,3076,3078],{"class":343,"line":3070},143,[341,3072,3047],{"class":1080},[341,3074,3075],{"class":347}," fallbackTimer ",[341,3077,683],{"class":1080},[341,3079,3055],{"class":354},[341,3081,3083,3085,3088,3090],{"class":343,"line":3082},144,[341,3084,3047],{"class":1080},[341,3086,3087],{"class":347}," styleElement ",[341,3089,683],{"class":1080},[341,3091,3055],{"class":354},[341,3093,3095],{"class":343,"line":3094},145,[341,3096,703],{"emptyLinePlaceholder":702},[341,3098,3100,3103,3106,3108,3111,3113,3116],{"class":343,"line":3099},146,[341,3101,3102],{"class":1080},"function",[341,3104,3105],{"class":679}," waitForAdsbygoogle",[341,3107,2977],{"class":347},[341,3109,3110],{"class":1869},"timeout",[341,3112,1230],{"class":1080},[341,3114,3115],{"class":354}," 8000",[341,3117,2566],{"class":347},[341,3119,3121,3123,3126,3129,3132,3135,3137,3140,3142,3144],{"class":343,"line":3120},147,[341,3122,2245],{"class":1080},[341,3124,3125],{"class":1080}," new",[341,3127,3128],{"class":354}," Promise",[341,3130,3131],{"class":347},"((",[341,3133,3134],{"class":1869},"resolve",[341,3136,1885],{"class":347},[341,3138,3139],{"class":1869},"reject",[341,3141,1873],{"class":347},[341,3143,1876],{"class":1080},[341,3145,1233],{"class":347},[341,3147,3149,3152,3155,3157,3160],{"class":343,"line":3148},148,[341,3150,3151],{"class":1080},"    if",[341,3153,3154],{"class":347}," (window.adsbygoogle) ",[341,3156,2735],{"class":1080},[341,3158,3159],{"class":679}," resolve",[341,3161,2000],{"class":347},[341,3163,3165,3168,3171,3173,3176,3179],{"class":343,"line":3164},149,[341,3166,3167],{"class":1080},"    const",[341,3169,3170],{"class":354}," start",[341,3172,1230],{"class":1080},[341,3174,3175],{"class":347}," Date.",[341,3177,3178],{"class":679},"now",[341,3180,2000],{"class":347},[341,3182,3184,3186,3189,3191,3194,3196,3198],{"class":343,"line":3183},150,[341,3185,3167],{"class":1080},[341,3187,3188],{"class":354}," timer",[341,3190,1230],{"class":1080},[341,3192,3193],{"class":679}," setInterval",[341,3195,2235],{"class":347},[341,3197,1876],{"class":1080},[341,3199,1233],{"class":347},[341,3201,3203,3206],{"class":343,"line":3202},151,[341,3204,3205],{"class":1080},"      if",[341,3207,3208],{"class":347}," (window.adsbygoogle) {\n",[341,3210,3212,3215],{"class":343,"line":3211},152,[341,3213,3214],{"class":679},"        clearInterval",[341,3216,3217],{"class":347},"(timer)\n",[341,3219,3221,3224],{"class":343,"line":3220},153,[341,3222,3223],{"class":679},"        resolve",[341,3225,2000],{"class":347},[341,3227,3229,3232,3234,3237,3240,3242,3245,3248,3251,3254],{"class":343,"line":3228},154,[341,3230,3231],{"class":347},"      } ",[341,3233,2607],{"class":1080},[341,3235,3236],{"class":1080}," if",[341,3238,3239],{"class":347}," (Date.",[341,3241,3178],{"class":679},[341,3243,3244],{"class":347},"() ",[341,3246,3247],{"class":1080},"-",[341,3249,3250],{"class":347}," start ",[341,3252,3253],{"class":1080},">",[341,3255,3256],{"class":347}," timeout) {\n",[341,3258,3260,3262],{"class":343,"line":3259},155,[341,3261,3214],{"class":679},[341,3263,3217],{"class":347},[341,3265,3267,3270,3272,3275,3278,3280,3283],{"class":343,"line":3266},156,[341,3268,3269],{"class":679},"        reject",[341,3271,2977],{"class":347},[341,3273,3274],{"class":1080},"new",[341,3276,3277],{"class":679}," Error",[341,3279,2977],{"class":347},[341,3281,3282],{"class":361},"'adsbygoogle SDK 加载超时'",[341,3284,3285],{"class":347},"))\n",[341,3287,3289],{"class":343,"line":3288},157,[341,3290,2886],{"class":347},[341,3292,3294,3297,3300],{"class":343,"line":3293},158,[341,3295,3296],{"class":347},"    }, ",[341,3298,3299],{"class":354},"100",[341,3301,2039],{"class":347},[341,3303,3305],{"class":343,"line":3304},159,[341,3306,3307],{"class":347},"  })\n",[341,3309,3311],{"class":343,"line":3310},160,[341,3312,435],{"class":347},[341,3314,3316],{"class":343,"line":3315},161,[341,3317,703],{"emptyLinePlaceholder":702},[341,3319,3321,3323,3326,3328,3331],{"class":343,"line":3320},162,[341,3322,3102],{"class":1080},[341,3324,3325],{"class":679}," watchAdStatus",[341,3327,2977],{"class":347},[341,3329,3330],{"class":1869},"el",[341,3332,2566],{"class":347},[341,3334,3336,3339,3341,3343,3346,3348,3350],{"class":343,"line":3335},163,[341,3337,3338],{"class":347},"  statusObserver ",[341,3340,683],{"class":1080},[341,3342,3125],{"class":1080},[341,3344,3345],{"class":679}," MutationObserver",[341,3347,2235],{"class":347},[341,3349,1876],{"class":1080},[341,3351,1233],{"class":347},[341,3353,3355,3357,3360,3362,3365,3368,3370,3373],{"class":343,"line":3354},164,[341,3356,3167],{"class":1080},[341,3358,3359],{"class":354}," status",[341,3361,1230],{"class":1080},[341,3363,3364],{"class":347}," el.",[341,3366,3367],{"class":679},"getAttribute",[341,3369,2977],{"class":347},[341,3371,3372],{"class":361},"'data-ad-status'",[341,3374,2039],{"class":347},[341,3376,3378,3380,3383,3385,3388,3390,3393,3395,3398],{"class":343,"line":3377},165,[341,3379,3151],{"class":1080},[341,3381,3382],{"class":347}," (status ",[341,3384,2560],{"class":1080},[341,3386,3387],{"class":361}," 'filled'",[341,3389,2674],{"class":1080},[341,3391,3392],{"class":347}," status ",[341,3394,2560],{"class":1080},[341,3396,3397],{"class":361}," 'unfilled'",[341,3399,2566],{"class":347},[341,3401,3403,3406,3408],{"class":343,"line":3402},166,[341,3404,3405],{"class":347},"      adStatus.value ",[341,3407,683],{"class":1080},[341,3409,3410],{"class":347}," status\n",[341,3412,3414,3417,3419],{"class":343,"line":3413},167,[341,3415,3416],{"class":347},"      isLoaded.value ",[341,3418,683],{"class":1080},[341,3420,3421],{"class":354}," true\n",[341,3423,3425,3428,3431],{"class":343,"line":3424},168,[341,3426,3427],{"class":347},"      statusObserver?.",[341,3429,3430],{"class":679},"disconnect",[341,3432,2000],{"class":347},[341,3434,3436,3439,3441],{"class":343,"line":3435},169,[341,3437,3438],{"class":347},"      statusObserver ",[341,3440,683],{"class":1080},[341,3442,3055],{"class":354},[341,3444,3446,3448,3451,3454,3457,3459,3462],{"class":343,"line":3445},170,[341,3447,3205],{"class":1080},[341,3449,3450],{"class":347}," (fallbackTimer) { ",[341,3452,3453],{"class":679},"clearTimeout",[341,3455,3456],{"class":347},"(fallbackTimer); fallbackTimer ",[341,3458,683],{"class":1080},[341,3460,3461],{"class":354}," null",[341,3463,2548],{"class":347},[341,3465,3467],{"class":343,"line":3466},171,[341,3468,423],{"class":347},[341,3470,3472],{"class":343,"line":3471},172,[341,3473,3307],{"class":347},[341,3475,3477,3480,3483,3486,3489,3492,3494],{"class":343,"line":3476},173,[341,3478,3479],{"class":347},"  statusObserver.",[341,3481,3482],{"class":679},"observe",[341,3484,3485],{"class":347},"(el, { attributes: ",[341,3487,3488],{"class":354},"true",[341,3490,3491],{"class":347},", attributeFilter: [",[341,3493,3372],{"class":361},[341,3495,3496],{"class":347},"] })\n",[341,3498,3500],{"class":343,"line":3499},174,[341,3501,703],{"emptyLinePlaceholder":702},[341,3503,3505,3508,3510,3513,3515,3517],{"class":343,"line":3504},175,[341,3506,3507],{"class":347},"  fallbackTimer ",[341,3509,683],{"class":1080},[341,3511,3512],{"class":679}," setTimeout",[341,3514,2235],{"class":347},[341,3516,1876],{"class":1080},[341,3518,1233],{"class":347},[341,3520,3522,3524,3526,3528,3531,3533],{"class":343,"line":3521},176,[341,3523,3151],{"class":1080},[341,3525,2587],{"class":347},[341,3527,2712],{"class":1080},[341,3529,3530],{"class":347},"isLoaded.value) isLoaded.value ",[341,3532,683],{"class":1080},[341,3534,3421],{"class":354},[341,3536,3538,3541,3544],{"class":343,"line":3537},177,[341,3539,3540],{"class":347},"  }, ",[341,3542,3543],{"class":354},"5000",[341,3545,2039],{"class":347},[341,3547,3549],{"class":343,"line":3548},178,[341,3550,435],{"class":347},[341,3552,3554],{"class":343,"line":3553},179,[341,3555,703],{"emptyLinePlaceholder":702},[341,3557,3559,3561,3564],{"class":343,"line":3558},180,[341,3560,3102],{"class":1080},[341,3562,3563],{"class":679}," buildInsElement",[341,3565,3566],{"class":347},"() {\n",[341,3568,3570,3572,3575,3577,3580,3583,3585,3588],{"class":343,"line":3569},181,[341,3571,2535],{"class":1080},[341,3573,3574],{"class":354}," ins",[341,3576,1230],{"class":1080},[341,3578,3579],{"class":347}," document.",[341,3581,3582],{"class":679},"createElement",[341,3584,2977],{"class":347},[341,3586,3587],{"class":361},"'ins'",[341,3589,2039],{"class":347},[341,3591,3593,3596,3598,3601,3603,3605,3607],{"class":343,"line":3592},182,[341,3594,3595],{"class":347},"  ins.className ",[341,3597,683],{"class":1080},[341,3599,3600],{"class":361}," `adsbygoogle ${",[341,3602,2788],{"class":347},[341,3604,816],{"class":361},[341,3606,2793],{"class":347},[341,3608,3609],{"class":361},"}`\n",[341,3611,3613,3616,3618],{"class":343,"line":3612},183,[341,3614,3615],{"class":347},"  ins.style.display ",[341,3617,683],{"class":1080},[341,3619,3620],{"class":361}," 'block'\n",[341,3622,3624,3627,3629],{"class":343,"line":3623},184,[341,3625,3626],{"class":347},"  ins.dataset.adClient ",[341,3628,683],{"class":1080},[341,3630,3631],{"class":347}," adsenseClientId.value\n",[341,3633,3635,3638,3640],{"class":343,"line":3634},185,[341,3636,3637],{"class":347},"  ins.dataset.adSlot ",[341,3639,683],{"class":1080},[341,3641,3642],{"class":347}," props.adsenseSlotId\n",[341,3644,3646],{"class":343,"line":3645},186,[341,3647,703],{"emptyLinePlaceholder":702},[341,3649,3651,3653,3656,3658,3661],{"class":343,"line":3650},187,[341,3652,2554],{"class":1080},[341,3654,3655],{"class":347}," (props.type ",[341,3657,2560],{"class":1080},[341,3659,3660],{"class":361}," 'in-article'",[341,3662,2566],{"class":347},[341,3664,3666,3669,3671],{"class":343,"line":3665},188,[341,3667,3668],{"class":347},"    ins.style.textAlign ",[341,3670,683],{"class":1080},[341,3672,3673],{"class":361}," 'center'\n",[341,3675,3677,3680,3682],{"class":343,"line":3676},189,[341,3678,3679],{"class":347},"    ins.dataset.adFormat ",[341,3681,683],{"class":1080},[341,3683,3684],{"class":361}," 'fluid'\n",[341,3686,3688,3691,3693],{"class":343,"line":3687},190,[341,3689,3690],{"class":347},"    ins.dataset.adLayout ",[341,3692,683],{"class":1080},[341,3694,3695],{"class":361}," 'in-article'\n",[341,3697,3699,3701,3703,3705,3707,3709,3712],{"class":343,"line":3698},191,[341,3700,2604],{"class":347},[341,3702,2607],{"class":1080},[341,3704,3236],{"class":1080},[341,3706,3655],{"class":347},[341,3708,2560],{"class":1080},[341,3710,3711],{"class":361}," 'in-feed'",[341,3713,2566],{"class":347},[341,3715,3717,3720,3722],{"class":343,"line":3716},192,[341,3718,3719],{"class":347},"    ins.style.display ",[341,3721,683],{"class":1080},[341,3723,3620],{"class":361},[341,3725,3727,3729,3731],{"class":343,"line":3726},193,[341,3728,3679],{"class":347},[341,3730,683],{"class":1080},[341,3732,3684],{"class":361},[341,3734,3736,3738],{"class":343,"line":3735},194,[341,3737,3151],{"class":1080},[341,3739,3740],{"class":347}," (props.layoutKey) {\n",[341,3742,3744,3747,3749],{"class":343,"line":3743},195,[341,3745,3746],{"class":347},"      ins.dataset.adLayoutKey ",[341,3748,683],{"class":1080},[341,3750,3751],{"class":347}," props.layoutKey\n",[341,3753,3755,3758,3760,3762,3764,3767,3769,3772],{"class":343,"line":3754},196,[341,3756,3757],{"class":347},"    } ",[341,3759,2607],{"class":1080},[341,3761,3236],{"class":1080},[341,3763,2587],{"class":347},[341,3765,3766],{"class":1080},"import",[341,3768,816],{"class":347},[341,3770,3771],{"class":354},"meta",[341,3773,3774],{"class":347},".dev) {\n",[341,3776,3778,3781,3784,3786,3789],{"class":343,"line":3777},197,[341,3779,3780],{"class":347},"      console.",[341,3782,3783],{"class":679},"warn",[341,3785,2977],{"class":347},[341,3787,3788],{"class":361},"'[AdAdsense] type=\"in-feed\" 需要传入 layoutKey'",[341,3790,2039],{"class":347},[341,3792,3794],{"class":343,"line":3793},198,[341,3795,423],{"class":347},[341,3797,3799,3801,3803,3805,3807,3809,3812],{"class":343,"line":3798},199,[341,3800,2604],{"class":347},[341,3802,2607],{"class":1080},[341,3804,3236],{"class":1080},[341,3806,3655],{"class":347},[341,3808,2560],{"class":1080},[341,3810,3811],{"class":361}," 'multiplex'",[341,3813,2566],{"class":347},[341,3815,3817,3819,3821],{"class":343,"line":3816},200,[341,3818,3719],{"class":347},[341,3820,683],{"class":1080},[341,3822,3620],{"class":361},[341,3824,3826,3828,3830],{"class":343,"line":3825},201,[341,3827,3679],{"class":347},[341,3829,683],{"class":1080},[341,3831,3832],{"class":361}," 'autorelaxed'\n",[341,3834,3836,3838,3840,3842,3844,3846,3849],{"class":343,"line":3835},202,[341,3837,2604],{"class":347},[341,3839,2607],{"class":1080},[341,3841,3236],{"class":1080},[341,3843,3655],{"class":347},[341,3845,2560],{"class":1080},[341,3847,3848],{"class":361}," 'responsive'",[341,3850,2566],{"class":347},[341,3852,3854,3857,3859],{"class":343,"line":3853},203,[341,3855,3856],{"class":347},"    ins.style.width ",[341,3858,683],{"class":1080},[341,3860,1083],{"class":361},[341,3862,3864,3866,3868],{"class":343,"line":3863},204,[341,3865,2604],{"class":347},[341,3867,2607],{"class":1080},[341,3869,1233],{"class":347},[341,3871,3873,3875,3877],{"class":343,"line":3872},205,[341,3874,3856],{"class":347},[341,3876,683],{"class":1080},[341,3878,3879],{"class":347}," containerWidth.value\n",[341,3881,3883,3886,3888],{"class":343,"line":3882},206,[341,3884,3885],{"class":347},"    ins.style.height ",[341,3887,683],{"class":1080},[341,3889,2620],{"class":347},[341,3891,3893],{"class":343,"line":3892},207,[341,3894,2626],{"class":347},[341,3896,3898],{"class":343,"line":3897},208,[341,3899,703],{"emptyLinePlaceholder":702},[341,3901,3903,3905],{"class":343,"line":3902},209,[341,3904,2245],{"class":1080},[341,3906,3907],{"class":347}," ins\n",[341,3909,3911],{"class":343,"line":3910},210,[341,3912,435],{"class":347},[341,3914,3916],{"class":343,"line":3915},211,[341,3917,703],{"emptyLinePlaceholder":702},[341,3919,3921,3924,3927,3930],{"class":343,"line":3920},212,[341,3922,3923],{"class":1080},"async",[341,3925,3926],{"class":1080}," function",[341,3928,3929],{"class":679}," loadAd",[341,3931,3566],{"class":347},[341,3933,3935,3937,3939,3941,3944,3946,3949],{"class":343,"line":3934},213,[341,3936,2554],{"class":1080},[341,3938,2587],{"class":347},[341,3940,2712],{"class":1080},[341,3942,3943],{"class":347},"adsenseClientId.value ",[341,3945,2253],{"class":1080},[341,3947,3948],{"class":1080}," !",[341,3950,3951],{"class":347},"props.adsenseSlotId) {\n",[341,3953,3955,3957,3959,3961,3963,3965,3968,3970,3972,3975],{"class":343,"line":3954},214,[341,3956,3151],{"class":1080},[341,3958,2587],{"class":347},[341,3960,3766],{"class":1080},[341,3962,816],{"class":347},[341,3964,3771],{"class":354},[341,3966,3967],{"class":347},".dev) console.",[341,3969,3783],{"class":679},[341,3971,2977],{"class":347},[341,3973,3974],{"class":361},"'[AdAdsense] adsenseClientId 或 adsenseSlotId 不能为空'",[341,3976,2039],{"class":347},[341,3978,3980],{"class":343,"line":3979},215,[341,3981,3982],{"class":1080},"    return\n",[341,3984,3986],{"class":343,"line":3985},216,[341,3987,2626],{"class":347},[341,3989,3991,3993,3995,3997,4000],{"class":343,"line":3990},217,[341,3992,2554],{"class":1080},[341,3994,2587],{"class":347},[341,3996,2712],{"class":1080},[341,3998,3999],{"class":347},"adRef.value) ",[341,4001,4002],{"class":1080},"return\n",[341,4004,4006],{"class":343,"line":4005},218,[341,4007,703],{"emptyLinePlaceholder":702},[341,4009,4011,4014],{"class":343,"line":4010},219,[341,4012,4013],{"class":1080},"  try",[341,4015,1233],{"class":347},[341,4017,4019,4022,4024],{"class":343,"line":4018},220,[341,4020,4021],{"class":1080},"    await",[341,4023,3105],{"class":679},[341,4025,2000],{"class":347},[341,4027,4029,4031,4034],{"class":343,"line":4028},221,[341,4030,2604],{"class":347},[341,4032,4033],{"class":1080},"catch",[341,4035,4036],{"class":347}," (e) {\n",[341,4038,4040,4042,4044,4046,4048,4050,4052,4055,4057,4060],{"class":343,"line":4039},222,[341,4041,3151],{"class":1080},[341,4043,2587],{"class":347},[341,4045,3766],{"class":1080},[341,4047,816],{"class":347},[341,4049,3771],{"class":354},[341,4051,3967],{"class":347},[341,4053,4054],{"class":679},"error",[341,4056,2977],{"class":347},[341,4058,4059],{"class":361},"'[AdAdsense]'",[341,4061,4062],{"class":347},", e.message)\n",[341,4064,4066,4069,4071],{"class":343,"line":4065},223,[341,4067,4068],{"class":347},"    isLoaded.value ",[341,4070,683],{"class":1080},[341,4072,3421],{"class":354},[341,4074,4076],{"class":343,"line":4075},224,[341,4077,3982],{"class":1080},[341,4079,4081],{"class":343,"line":4080},225,[341,4082,2626],{"class":347},[341,4084,4086],{"class":343,"line":4085},226,[341,4087,703],{"emptyLinePlaceholder":702},[341,4089,4091,4094,4096],{"class":343,"line":4090},227,[341,4092,4093],{"class":347},"  adRef.value.innerHTML ",[341,4095,683],{"class":1080},[341,4097,2033],{"class":361},[341,4099,4101,4104,4106],{"class":343,"line":4100},228,[341,4102,4103],{"class":347},"  adStatus.value ",[341,4105,683],{"class":1080},[341,4107,2033],{"class":361},[341,4109,4111],{"class":343,"line":4110},229,[341,4112,703],{"emptyLinePlaceholder":702},[341,4114,4116,4118,4120,4122,4124],{"class":343,"line":4115},230,[341,4117,2535],{"class":1080},[341,4119,3574],{"class":354},[341,4121,1230],{"class":1080},[341,4123,3563],{"class":679},[341,4125,2000],{"class":347},[341,4127,4129,4132,4135],{"class":343,"line":4128},231,[341,4130,4131],{"class":347},"  adRef.value.",[341,4133,4134],{"class":679},"appendChild",[341,4136,4137],{"class":347},"(ins)\n",[341,4139,4141,4144],{"class":343,"line":4140},232,[341,4142,4143],{"class":679},"  watchAdStatus",[341,4145,4137],{"class":347},[341,4147,4149],{"class":343,"line":4148},233,[341,4150,703],{"emptyLinePlaceholder":702},[341,4152,4154,4156],{"class":343,"line":4153},234,[341,4155,4013],{"class":1080},[341,4157,1233],{"class":347},[341,4159,4161,4164,4166,4169,4171,4174,4177],{"class":343,"line":4160},235,[341,4162,4163],{"class":347},"    ;(window.adsbygoogle ",[341,4165,683],{"class":1080},[341,4167,4168],{"class":347}," window.adsbygoogle ",[341,4170,2253],{"class":1080},[341,4172,4173],{"class":347}," []).",[341,4175,4176],{"class":679},"push",[341,4178,4179],{"class":347},"({})\n",[341,4181,4183,4185,4187],{"class":343,"line":4182},236,[341,4184,2604],{"class":347},[341,4186,4033],{"class":1080},[341,4188,4036],{"class":347},[341,4190,4192,4194,4196,4198,4200,4202,4204,4206,4208,4211],{"class":343,"line":4191},237,[341,4193,3151],{"class":1080},[341,4195,2587],{"class":347},[341,4197,3766],{"class":1080},[341,4199,816],{"class":347},[341,4201,3771],{"class":354},[341,4203,3967],{"class":347},[341,4205,4054],{"class":679},[341,4207,2977],{"class":347},[341,4209,4210],{"class":361},"'[AdAdsense] push 失败:'",[341,4212,4213],{"class":347},", e)\n",[341,4215,4217,4219,4221],{"class":343,"line":4216},238,[341,4218,4068],{"class":347},[341,4220,683],{"class":1080},[341,4222,3421],{"class":354},[341,4224,4226],{"class":343,"line":4225},239,[341,4227,2626],{"class":347},[341,4229,4231],{"class":343,"line":4230},240,[341,4232,435],{"class":347},[341,4234,4236],{"class":343,"line":4235},241,[341,4237,703],{"emptyLinePlaceholder":702},[341,4239,4241,4243,4246],{"class":343,"line":4240},242,[341,4242,3102],{"class":1080},[341,4244,4245],{"class":679}," initObserver",[341,4247,3566],{"class":347},[341,4249,4251,4253,4255,4257,4259,4262,4265,4268,4271,4274,4276],{"class":343,"line":4250},243,[341,4252,2554],{"class":1080},[341,4254,2587],{"class":347},[341,4256,2712],{"class":1080},[341,4258,2977],{"class":347},[341,4260,4261],{"class":361},"'IntersectionObserver'",[341,4263,4264],{"class":1080}," in",[341,4266,4267],{"class":347}," window)) { ",[341,4269,4270],{"class":679},"loadAd",[341,4272,4273],{"class":347},"(); ",[341,4275,2735],{"class":1080},[341,4277,2548],{"class":347},[341,4279,4281,4284,4286,4288,4291],{"class":343,"line":4280},244,[341,4282,4283],{"class":347},"  intersectionObserver ",[341,4285,683],{"class":1080},[341,4287,3125],{"class":1080},[341,4289,4290],{"class":679}," IntersectionObserver",[341,4292,2016],{"class":347},[341,4294,4296,4299,4302,4304,4306],{"class":343,"line":4295},245,[341,4297,4298],{"class":347},"    (",[341,4300,4301],{"class":1869},"entries",[341,4303,1873],{"class":347},[341,4305,1876],{"class":1080},[341,4307,1233],{"class":347},[341,4309,4311,4313,4316,4319],{"class":343,"line":4310},246,[341,4312,3205],{"class":1080},[341,4314,4315],{"class":347}," (entries[",[341,4317,4318],{"class":354},"0",[341,4320,4321],{"class":347},"]?.isIntersecting) {\n",[341,4323,4325,4328],{"class":343,"line":4324},247,[341,4326,4327],{"class":679},"        loadAd",[341,4329,2000],{"class":347},[341,4331,4333,4336,4338],{"class":343,"line":4332},248,[341,4334,4335],{"class":347},"        intersectionObserver?.",[341,4337,3430],{"class":679},[341,4339,2000],{"class":347},[341,4341,4343,4346,4348],{"class":343,"line":4342},249,[341,4344,4345],{"class":347},"        intersectionObserver ",[341,4347,683],{"class":1080},[341,4349,3055],{"class":354},[341,4351,4353],{"class":343,"line":4352},250,[341,4354,2886],{"class":347},[341,4356,4358],{"class":343,"line":4357},251,[341,4359,4360],{"class":347},"    },\n",[341,4362,4364,4367,4370],{"class":343,"line":4363},252,[341,4365,4366],{"class":347},"    { rootMargin: ",[341,4368,4369],{"class":361},"'200px 0px'",[341,4371,1319],{"class":347},[341,4373,4375],{"class":343,"line":4374},253,[341,4376,4377],{"class":347},"  )\n",[341,4379,4381,4383,4386,4388],{"class":343,"line":4380},254,[341,4382,2554],{"class":1080},[341,4384,4385],{"class":347}," (containerRef.value) intersectionObserver.",[341,4387,3482],{"class":679},[341,4389,4390],{"class":347},"(containerRef.value)\n",[341,4392,4394],{"class":343,"line":4393},255,[341,4395,435],{"class":347},[341,4397,4399],{"class":343,"line":4398},256,[341,4400,703],{"emptyLinePlaceholder":702},[341,4402,4404,4406,4409],{"class":343,"line":4403},257,[341,4405,3102],{"class":1080},[341,4407,4408],{"class":679}," injectResponsiveCSS",[341,4410,3566],{"class":347},[341,4412,4414,4416,4418,4420,4423],{"class":343,"line":4413},258,[341,4415,2554],{"class":1080},[341,4417,2587],{"class":347},[341,4419,2712],{"class":1080},[341,4421,4422],{"class":347},"responsiveCSS.value) ",[341,4424,4002],{"class":1080},[341,4426,4428],{"class":343,"line":4427},259,[341,4429,2743],{"class":347},[341,4431,4433,4436,4438,4440,4442,4444,4447],{"class":343,"line":4432},260,[341,4434,4435],{"class":347},"  styleElement ",[341,4437,683],{"class":1080},[341,4439,3579],{"class":347},[341,4441,3582],{"class":679},[341,4443,2977],{"class":347},[341,4445,4446],{"class":361},"'style'",[341,4448,2039],{"class":347},[341,4450,4452,4455,4457],{"class":343,"line":4451},261,[341,4453,4454],{"class":347},"  styleElement.textContent ",[341,4456,683],{"class":1080},[341,4458,4459],{"class":347}," responsiveCSS.value\n",[341,4461,4463,4466,4468],{"class":343,"line":4462},262,[341,4464,4465],{"class":347},"  document.head.",[341,4467,4134],{"class":679},[341,4469,4470],{"class":347},"(styleElement)\n",[341,4472,4474],{"class":343,"line":4473},263,[341,4475,435],{"class":347},[341,4477,4479],{"class":343,"line":4478},264,[341,4480,703],{"emptyLinePlaceholder":702},[341,4482,4484,4487,4489,4491],{"class":343,"line":4483},265,[341,4485,4486],{"class":679},"onMounted",[341,4488,2235],{"class":347},[341,4490,1876],{"class":1080},[341,4492,1233],{"class":347},[341,4494,4496,4498,4500,4502,4504,4506],{"class":343,"line":4495},266,[341,4497,2554],{"class":1080},[341,4499,2587],{"class":347},[341,4501,3766],{"class":1080},[341,4503,816],{"class":347},[341,4505,3771],{"class":354},[341,4507,4508],{"class":347},".client) {\n",[341,4510,4512,4515],{"class":343,"line":4511},267,[341,4513,4514],{"class":679},"    injectResponsiveCSS",[341,4516,2000],{"class":347},[341,4518,4520,4523],{"class":343,"line":4519},268,[341,4521,4522],{"class":679},"    initObserver",[341,4524,2000],{"class":347},[341,4526,4528],{"class":343,"line":4527},269,[341,4529,2626],{"class":347},[341,4531,4533],{"class":343,"line":4532},270,[341,4534,1979],{"class":347},[341,4536,4538],{"class":343,"line":4537},271,[341,4539,703],{"emptyLinePlaceholder":702},[341,4541,4543,4546,4548,4550],{"class":343,"line":4542},272,[341,4544,4545],{"class":679},"onBeforeUnmount",[341,4547,2235],{"class":347},[341,4549,1876],{"class":1080},[341,4551,1233],{"class":347},[341,4553,4555,4558,4560],{"class":343,"line":4554},273,[341,4556,4557],{"class":347},"  intersectionObserver?.",[341,4559,3430],{"class":679},[341,4561,2000],{"class":347},[341,4563,4565,4567,4569],{"class":343,"line":4564},274,[341,4566,4283],{"class":347},[341,4568,683],{"class":1080},[341,4570,3055],{"class":354},[341,4572,4574,4577,4579],{"class":343,"line":4573},275,[341,4575,4576],{"class":347},"  statusObserver?.",[341,4578,3430],{"class":679},[341,4580,2000],{"class":347},[341,4582,4584,4586,4588],{"class":343,"line":4583},276,[341,4585,3338],{"class":347},[341,4587,683],{"class":1080},[341,4589,3055],{"class":354},[341,4591,4593,4595,4597,4599,4601,4603,4605],{"class":343,"line":4592},277,[341,4594,2554],{"class":1080},[341,4596,3450],{"class":347},[341,4598,3453],{"class":679},[341,4600,3456],{"class":347},[341,4602,683],{"class":1080},[341,4604,3461],{"class":354},[341,4606,2548],{"class":347},[341,4608,4610,4612],{"class":343,"line":4609},278,[341,4611,2554],{"class":1080},[341,4613,4614],{"class":347}," (styleElement) {\n",[341,4616,4618,4621,4624],{"class":343,"line":4617},279,[341,4619,4620],{"class":347},"    styleElement.",[341,4622,4623],{"class":679},"remove",[341,4625,2000],{"class":347},[341,4627,4629,4632,4634],{"class":343,"line":4628},280,[341,4630,4631],{"class":347},"    styleElement ",[341,4633,683],{"class":1080},[341,4635,3055],{"class":354},[341,4637,4639],{"class":343,"line":4638},281,[341,4640,2626],{"class":347},[341,4642,4644,4646,4649,4651],{"class":343,"line":4643},282,[341,4645,2554],{"class":1080},[341,4647,4648],{"class":347}," (adRef.value) adRef.value.innerHTML ",[341,4650,683],{"class":1080},[341,4652,2033],{"class":361},[341,4654,4656],{"class":343,"line":4655},283,[341,4657,1979],{"class":347},[341,4659,4661,4663,4665],{"class":343,"line":4660},284,[341,4662,1755],{"class":347},[341,4664,1772],{"class":676},[341,4666,1419],{"class":347},[16,4668,4669],{},"The component also detects the visitor's region and switches between a domestic and an international ad slot\u002Fstyle accordingly, rather than always rendering the same placement regardless of where the request is coming from.",[16,4671,4672],{},"With this in place, the call site didn't need to change at all:",[332,4674,4676],{"className":986,"code":4675,"language":988,"meta":337,"style":337},"\u003CAdAdsense type=\"responsive\" adsense-slot-id=\"xxxxxxxxxx\" \u002F>\n",[52,4677,4678],{"__ignoreMap":337},[341,4679,4680,4682,4684,4686,4688,4690,4692,4694,4697],{"class":343,"line":344},[341,4681,673],{"class":347},[341,4683,997],{"class":676},[341,4685,1000],{"class":679},[341,4687,683],{"class":347},[341,4689,1005],{"class":361},[341,4691,1008],{"class":679},[341,4693,683],{"class":347},[341,4695,4696],{"class":361},"\"xxxxxxxxxx\"",[341,4698,697],{"class":347},[16,4700,4701,4702,1885,4705,4708,4709,4711],{},"— ",[52,4703,4704],{},"type=\"banner\"",[52,4706,4707],{},"type=\"rectangle\"",", and ",[52,4710,961],{}," each resolve to their own breakpoint table automatically, no extra props required.",[305,4713,4715],{"id":4714},"a-bug-along-the-way","A bug along the way",[16,4717,4718,4719,4722,4723,4725],{},"After an earlier iteration that ",[257,4720,4721],{},"did"," accept an external ",[52,4724,1157],{}," prop, removing that prop left one leftover reference behind, which surfaced at runtime as:",[332,4727,4732],{"className":4728,"code":4730,"language":4731},[4729],"language-text","TypeError: Cannot destructure property 'mobile' of 'props.responsive' as it is undefined.\n","text",[52,4733,4730],{"__ignoreMap":337},[16,4735,4736,4737,4740,4741,4744,4745,4747,4748,816],{},"The error message itself pointed straight at the cause: ",[52,4738,4739],{},"Cannot destructure property 'mobile' of 'props.responsive' as it is undefined"," names ",[52,4742,4743],{},"props.responsive"," directly as the object being destructured for its ",[52,4746,2754],{}," field, and that object was ",[52,4749,4750],{},"undefined",[16,4752,4753,4754,4756,4757,4760,4761,4763,4764,4767,4768,4770,4771,4773,4774,4776],{},"Tracing back through the component's history explained why: an earlier iteration accepted an external ",[52,4755,1157],{}," prop, letting callers pass their own breakpoint config. Once the component moved to the internal ",[52,4758,4759],{},"RESPONSIVE_CONFIG_MAP"," presets and stopped needing that prop, the cleanup wasn't complete — one function body still had a line destructuring ",[52,4762,4743],{},", while ",[52,4765,4766],{},"defineProps"," no longer declared ",[52,4769,1157],{}," at all. At runtime that left ",[52,4772,4743],{}," as ",[52,4775,4750],{},", and the destructure failed immediately.",[16,4778,4779,4780,4782,4783,4785,4786,4788],{},"Rather than patch that one line, I rewrote the component from a clean copy: removed the ",[52,4781,1157],{}," prop declaration entirely, removed every remaining reference to ",[52,4784,4743],{},", and made the internal ",[52,4787,4759],{}," the only source of sizing — no external size config accepted at all.",[11,4790,745],{"id":744},[16,4792,4793,4794,4796,4797,4799],{},"I didn't go with the JavaScript resize-detection approach even though it technically also works, because the cost wasn't worth it for what is, in the end, a pure CSS layout problem — ",[52,4795,1132],{}," tracking, listener cleanup, and SSR guards are real maintenance surface for something ",[52,4798,1204],{}," already handles natively, and Google's own docs explicitly recommend the CSS route as the policy-safe modification.",[16,4801,4802,4803,4805],{},"The trade-off I made instead is baking the breakpoint sizes into the component as fixed presets rather than exposing them as a prop. That's a deliberate loss of flexibility: if a future ad placement needs a non-standard breakpoint or size, I have to edit the component source rather than just pass a different value at the call site. For a one-person blog with a handful of ad placements, that's a trade I'm fine with — it keeps every call site to a single ",[52,4804,1214],{}," attribute. It wouldn't be the right call on a site with many ad placements needing different sizing per page.",[16,4807,4808],{},"The component also splits behavior by visitor region — domestic vs. international — which is a separate decision with its own reasoning that I'll cover properly in a dedicated post later, since the \"why\" there deserves more than a side note here.",[11,4810,772],{"id":771},[16,4812,4813],{},"After the fix, the desktop placement holds at a fixed 728×90 regardless of how the surrounding layout reflows:",[16,4815,4816],{},[67,4817],{"alt":4818,"src":4819},"AdSense ad rendering at a fixed 728x90 on desktop after the CSS media query fix","\u002Fimages\u002Fdev-practice\u002Ffixing-adsense-responsive-mobile-square\u002Ffixing-adsense-responsive-mobile-square-desktop-fixed.webp",[16,4821,4822],{},"On mobile, the ad now renders at the breakpoint-appropriate size instead of falling back to a square:",[16,4824,4825],{},[67,4826],{"alt":4827,"src":4828},"AdSense ad rendering at the correct breakpoint size on mobile after the fix","\u002Fimages\u002Fdev-practice\u002Ffixing-adsense-responsive-mobile-square\u002Ffixing-adsense-responsive-mobile-square-mobile-fixed.webp",[11,4830,792],{"id":791},[275,4832,4833,4838,4844,4847,4850],{},[181,4834,4835,4837],{},[52,4836,968],{}," is Google's own recommended setting for maximizing revenue, but the trade-off is giving up control over the final rendered size — read that as \"Google decides,\" not \"fits your layout.\"",[181,4839,4840,4841,4843],{},"Setting ",[52,4842,965],{}," lets Google pick between horizontal, rectangle, and other shapes based on inventory; if you need a guaranteed shape, you need to constrain it yourself.",[181,4845,4846],{},"CSS media queries on the ad's container, per Google's own documentation, are an explicitly approved way to control responsive ad sizing — no policy risk, unlike some other modifications.",[181,4848,4849],{},"A JS-based resize listener can solve the same problem, but it's strictly more code and more state to maintain than letting CSS handle a CSS problem.",[181,4851,4852],{},"Baking configuration into component presets versus exposing it as props is a real maintainability trade-off, not a style preference — know which one you're choosing and why.",[894,4854,4855],{},"html pre.shiki code .sKWpL, html code.shiki .sKWpL{--shiki-default:#24292E;--shiki-dark:#24292E}html pre.shiki code .sMDDv, html code.shiki .sMDDv{--shiki-default:#22863A;--shiki-dark:#22863A}html pre.shiki code .se37E, html code.shiki .se37E{--shiki-default:#6F42C1;--shiki-dark:#6F42C1}html pre.shiki code .sOTlB, html code.shiki .sOTlB{--shiki-default:#032F62;--shiki-dark:#032F62}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 .sCydW, html code.shiki .sCydW{--shiki-default:#D73A49;--shiki-dark:#D73A49}html pre.shiki code .sMN4m, html code.shiki .sMN4m{--shiki-default:#005CC5;--shiki-dark:#005CC5}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sPP4b, html code.shiki .sPP4b{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-dark:#B31D28;--shiki-dark-font-style:italic}html pre.shiki code .sj_tP, html code.shiki .sj_tP{--shiki-default:#E36209;--shiki-dark:#E36209}",{"title":337,"searchDepth":351,"depth":351,"links":4857},[4858,4859,4860,4861,4862,4865,4866,4867],{"id":13,"depth":351,"text":14},{"id":972,"depth":351,"text":973},{"id":1028,"depth":351,"text":1029},{"id":1054,"depth":351,"text":1055},{"id":1188,"depth":351,"text":1189,"children":4863},[4864],{"id":4714,"depth":368,"text":4715},{"id":744,"depth":351,"text":745},{"id":771,"depth":351,"text":772},{"id":791,"depth":351,"text":792},"A real debugging walkthrough of AdSense's data-full-width-responsive behavior, why I moved away from JS-based resize detection, and the Vue component I ended up with.",{"date":4870,"category":4871,"readTime":4872,"tags":4873,"image":4878,"draft":935,"series":936,"seriesOrder":936},"2026-06-24","dev-practice","6mins",[4874,4875,4876,4877],"#vue","#adsense","#frontend","#debugging","https:\u002F\u002Fassets.kbmjj123.cc\u002Fblog\u002Fdev-practice\u002Ffixing-adsense-responsive-mobile-square\u002Ffixing-adsense-responsive-mobile-square-desktop-fixed.png","\u002Fposts\u002Ffixing-adsense-responsive-mobile-square",{"title":4881,"description":4882,"keywords":4883},"Fix AdSense Responsive Ad Going Square on Mobile (Vue Component Guide)","How to stop AdSense responsive ads from collapsing into a square on mobile and growing too tall on desktop, with a reusable Vue component using Google's official CSS media query approach.",[4884,4885,4886],"adsense responsive ad square mobile","data-full-width-responsive fix","vue adsense component","posts\u002Ffixing-adsense-responsive-mobile-square","C0K_dgTWpVDDM06suK__DV9jlSU3dv7n8RTASQORA9k",{"id":4890,"title":4891,"body":4892,"description":5269,"extension":925,"meta":5270,"navigation":702,"path":5279,"seo":5280,"stem":5289,"__hash__":5290},"posts\u002Fposts\u002Ffrom-zero-to-2-dollar-6-months-tool-site.md","From Zero to $2\u002FDay: 6 Months of Running a Tool Site as an Indie Developer",{"type":8,"value":4893,"toc":5257},[4894,4896,4899,4901,4905,4912,4915,4920,4923,4933,4935,4939,4942,4945,4951,4958,4961,4967,4974,4977,4988,4995,4997,5001,5004,5011,5025,5032,5034,5038,5045,5052,5054,5058,5061,5067,5074,5077,5085,5095,5098,5101,5103,5107,5110,5130,5137,5143,5145,5149,5152,5158,5165,5168,5170,5174,5184,5194,5203,5209,5219,5221,5223,5255],[11,4895,14],{"id":13},[16,4897,4898],{},"On January 1, 2026, I launched a tool site as an SEO learning project. Six months later: 52 tools, 480+ pages, 6 languages, $2–$5\u002Fday AdSense revenue, $27 in BuyMeACoffee donations, a few RMB from WeChat, and one rejection from Google followed by approval 24 hours after resubmission. The only cost so far: the domain. This isn't a success story—it's a record of what happens when you actually finish something and keep adjusting.",[28,4900],{},[11,4902,4904],{"id":4903},"background-why-i-built-it","Background — Why I Built It",[16,4906,4907,4908,4911],{},"I didn't build this site to make money on day one. I built it to learn—to run through the entire SEO lifecycle from start to finish: domain registration, site setup, content creation, link building, search console integration, and eventually ad monetization. I wanted to ",[257,4909,4910],{},"know"," how each piece worked, not just read about it.",[16,4913,4914],{},"I picked iLoveIMG as my reference. It's one of the most established players in the tool space—clean UX, massive traffic, clear value prop. Copying a proven model wasn't about lacking creativity; it was about removing variables. If the market already validated the demand, my job was execution, not market validation.",[16,4916,4917],{},[60,4918,4919],{},"Tech choice: Nuxt static site + Cloudflare (Pages + R2). Zero infrastructure cost, only the domain.",[16,4921,4922],{},"Why this stack over a VPS? Because I'm not full-time. Every minute I spend managing servers is a minute I'm not working on the product. Static sites don't need半夜运维, don't crash under traffic spikes, and don't require database backups. Cloudflare's free tier handles everything a small tool site needs.",[16,4924,4925,4926],{},"The principle: ",[60,4927,4928,4929,4932],{},"choose the stack that costs the least ",[257,4930,4931],{},"non-product"," time, not the one that's \"best.\"",[28,4934],{},[11,4936,4938],{"id":4937},"the-beginning-ai-garbage","The Beginning — \"AI Garbage\"",[16,4940,4941],{},"I used AI to generate content. A lot of it. I was honest with myself—I was lazy and wanted to see if AI could carry the content side.",[16,4943,4944],{},"Then I got this email:",[16,4946,4947],{},[67,4948],{"alt":4949,"src":4950},"Screenshot of the \"AI Slop Factory\" email","\u002Fimages\u002Findie-mindset\u002Ffrom-zero-to-2-dollar-6-months-tool-site\u002Fai-garbage-email.webp",[16,4952,4953],{},[257,4954,4955],{},[341,4956,4957],{},"Replace with your actual screenshot — alt text: \"Email from a user calling the site AI-generated garbage\"",[16,4959,4960],{},"The subject line wasn't polite. The content wasn't either. Someone took the time to write and tell me my site was AI-generated garbage.",[16,4962,4963,4964],{},"My first reaction was defensive. Then I asked myself: ",[257,4965,4966],{},"is he wrong?",[16,4968,4969,4970,4973],{},"I went back and looked at my own pages: templated structure, repetitive phrasing, no screenshots, no real usage notes, no \"I used this and here's what happened\" details. AI can draft structure, but it can't add ",[257,4971,4972],{},"specificity"," —the stuff that comes from actually using the tool and noticing where users get stuck.",[16,4975,4976],{},"That email changed how I approached content. From then on, every page got:",[275,4978,4979,4982,4985],{},[181,4980,4981],{},"Real screenshots from actual usage",[181,4983,4984],{},"A \"things I ran into\" section",[181,4986,4987],{},"Specific use-case descriptions (not generic \"this tool is useful\")",[16,4989,4990,4991,4994],{},"AI is a drafting tool. Content value comes from what ",[257,4992,4993],{},"you"," add on top of it.",[28,4996],{},[11,4998,5000],{"id":4999},"seo-as-the-real-work","SEO as the Real Work",[16,5002,5003],{},"By March, I had traffic. Not much—but enough to know Google was paying attention.",[16,5005,5006,5007,5010],{},"I don't have a magical SEO formula. What I learned was simple: ",[60,5008,5009],{},"if you make pages that actually help someone complete a task, search engines eventually notice."," I focused on:",[275,5012,5013,5016,5019,5022],{},[181,5014,5015],{},"Keyword research (what are people actually typing?)",[181,5017,5018],{},"Internal linking structure",[181,5020,5021],{},"Page-level content depth",[181,5023,5024],{},"A few external backlinks (I reached out to relevant directories)",[16,5026,5027,5028,5031],{},"What clicked for me was reframing SEO: it's not about tricking Google. It's about making your content so useful that Google ",[257,5029,5030],{},"wants"," to show it.",[28,5033],{},[11,5035,5037],{"id":5036},"the-workflow-upgrade-bulk-image-toolchain","The Workflow Upgrade — Bulk Image Toolchain",[16,5039,5040,5041,5044],{},"The turning point was building a complete image processing workflow: ",[60,5042,5043],{},"local compression, conversion, cropping, and resizing—all in one place."," Instead of having a collection of disconnected tools, users could move through a logical flow: upload → process → download.",[16,5046,5047,5048,5051],{},"This changed the site from a random set of pages into a coherent ",[257,5049,5050],{},"user path",". People weren't just visiting one tool page and leaving; they started moving between tools.",[28,5053],{},[11,5055,5057],{"id":5056},"adsense-rejection-and-how-i-found-the-root-cause","AdSense Rejection — And How I Found the Root Cause",[16,5059,5060],{},"In May, I applied for AdSense. It came back as \"low-value content.\"",[16,5062,5063],{},[67,5064],{"alt":5065,"src":5066},"AdSense rejection email screenshot","\u002Fimages\u002Findie-mindset\u002Ffrom-zero-to-2-dollar-6-months-tool-site\u002Fadsense-rejection.webp",[16,5068,5069],{},[257,5070,5071],{},[341,5072,5073],{},"Replace with your actual screenshot — alt text: \"AdSense low-value content rejection email\"",[16,5075,5076],{},"I didn't panic. I started investigating:",[178,5078,5079,5082],{},[181,5080,5081],{},"I read through forums for similar rejection reasons. Most advice said \"add more words\" — but that felt like cargo culting.",[181,5083,5084],{},"I manually compared different language versions of the same tool page. English pages had full descriptions, screenshots, usage notes. Spanish and French versions (added recently) had only the title and a one-line description—empty.",[16,5086,5087,5088,5091,5092,5094],{},"That was it. The problem wasn't \"too little content.\" The problem was ",[60,5089,5090],{},"inconsistent content quality across languages."," I'd added multi-language support without actually translating the ",[257,5093,2793],{},"—just the titles and structure. The new language pages were thin, and AdSense flagged the entire site as low-value.",[16,5096,5097],{},"I spent two weeks filling in every language version with real content. No automation, just manual editing and translation for each tool page.",[16,5099,5100],{},"One month later, I resubmitted. 24 hours later: approved.",[28,5102],{},[11,5104,5106],{"id":5105},"revenue-small-but-real","Revenue — Small, but Real",[16,5108,5109],{},"After AdSense approval, the numbers started coming in:",[275,5111,5112,5118,5124],{},[181,5113,5114,5117],{},[60,5115,5116],{},"BuyMeACoffee: $27"," from an overseas user",[181,5119,5120,5123],{},[60,5121,5122],{},"WeChat: a few RMB"," from domestic users",[181,5125,5126,5129],{},[60,5127,5128],{},"AdSense: from $2\u002Fday to $5\u002Fday"," within the first two weeks",[16,5131,5132,5133,5136],{},"These amounts mean nothing to a full-time founder. But for a side project built in spare hours, they mean something different: ",[60,5134,5135],{},"validation."," Someone found this useful enough to pay. Someone clicked an ad. Advertisers value the traffic. All three signals point in the same direction—the site is solving a real need, even if it's small.",[16,5138,5139],{},[67,5140],{"alt":5141,"src":5142},"earn from Buy me a coffee","\u002Fimages\u002Findie-mindset\u002Ffrom-zero-to-2-dollar-6-months-tool-site\u002Fbuymeacoffee-earn.webp",[28,5144],{},[11,5146,5148],{"id":5147},"the-morning-of-july-1","The Morning of July 1",[16,5150,5151],{},"On June 27, Google Search Console reported 305 clicks. On June 30: 623 clicks. A 2x increase in three days.",[16,5153,5154],{},[67,5155],{"alt":5156,"src":5157},"GSC clicks chart June 27–30, 2026","\u002Fimages\u002Findie-mindset\u002Ffrom-zero-to-2-dollar-6-months-tool-site\u002Fgsc-clicks-june-2026.webp",[16,5159,5160],{},[257,5161,5162],{},[341,5163,5164],{},"Replace with your actual screenshot — alt text: \"Google Search Console clicks: 305 on June 27, 623 on June 30\"",[16,5166,5167],{},"I woke up on July 1—exactly six months after launch—to that number. It's not a million. But it's a sign that the work is compounding. That was the moment I felt like maybe, just maybe, I'd built something that wasn't going to fade away.",[28,5169],{},[11,5171,5173],{"id":5172},"my-take-what-i-actually-learned","My Take — What I Actually Learned",[16,5175,5176,5179,5180,5183],{},[60,5177,5178],{},"On tech selection:"," The best stack is the one that frees your brain. Nuxt + Cloudflare isn't the fastest or the most flexible—but it lets me ",[257,5181,5182],{},"forget about infrastructure",". For someone with limited attention (and even more limited evening hours), that's the killer feature. Static sites mean no servers to patch, no database to migrate, no \"why is this down at 2 AM.\" It's boring, and that's exactly what I need.",[16,5185,5186,5189,5190,5193],{},[60,5187,5188],{},"On copying:"," For an indie developer, the first priority isn't \"innovation.\" It's ",[257,5191,5192],{},"finishing a complete cycle",". Copying a proven market avoids the hardest part—validating demand—so you can focus on execution. Novelty is for after you know how to ship.",[16,5195,5196,5199,5200,5202],{},[60,5197,5198],{},"On AI and content:"," The email that called my site \"AI garbage\" was uncomfortable but necessary. AI-generated content isn't inherently bad—but it's not done. The value comes from what you personally add: screenshots, notes, edge cases, honest limitations. AI writes the first draft; ",[257,5201,4993],{}," make it useful.",[16,5204,5205,5208],{},[60,5206,5207],{},"On side projects:"," It's slow. It's easy to doubt yourself. Some weeks I don't open the code. But that's also the upside—there's no urgency, no investors, no \"must succeed\" pressure. You can adjust direction quietly. You can afford to be wrong.",[16,5210,5211,5214,5215,5218],{},[60,5212,5213],{},"The core principle:"," ",[257,5216,5217],{},"Make the user feel like they gained something."," On every page, ask: \"What did the user get from this that they didn't have before?\" If you can't answer, rewrite it until you can.",[28,5220],{},[11,5222,792],{"id":791},[178,5224,5225,5231,5237,5243,5249],{},[181,5226,5227,5230],{},[60,5228,5229],{},"AI is a starting point, not the output."," Getting called out early saved me from building a bigger problem later.",[181,5232,5233,5236],{},[60,5234,5235],{},"Multi-language is not a free win."," Thin translations drag your whole site down. If you add languages, actually fill them.",[181,5238,5239,5242],{},[60,5240,5241],{},"Users pay when they feel helped."," $27 isn't a living—but it's proof that someone values what you built.",[181,5244,5245,5248],{},[60,5246,5247],{},"Part-time projects are harder to sustain—but also easier to course-correct."," You have time to reflect.",[181,5250,5251,5254],{},[60,5252,5253],{},"The right stack doesn't add features; it subtracts distractions."," If your tech keeps you from thinking about your product, switch.",[28,5256],{},{"title":337,"searchDepth":351,"depth":351,"links":5258},[5259,5260,5261,5262,5263,5264,5265,5266,5267,5268],{"id":13,"depth":351,"text":14},{"id":4903,"depth":351,"text":4904},{"id":4937,"depth":351,"text":4938},{"id":4999,"depth":351,"text":5000},{"id":5036,"depth":351,"text":5037},{"id":5056,"depth":351,"text":5057},{"id":5105,"depth":351,"text":5106},{"id":5147,"depth":351,"text":5148},{"id":5172,"depth":351,"text":5173},{"id":791,"depth":351,"text":792},"A transparent six-month retrospective on building a tool site with Nuxt + Cloudflare: getting called out for AI garbage, fixing thin content after AdSense rejection, and making $2–$5\u002Fday in ad revenue.",{"date":5271,"category":5272,"readTime":5273,"tags":5274,"image":5278,"draft":935,"series":936,"seriesOrder":936},"2026-07-03","indie-mindset","12mins",[5275,931,5276,5277,933],"#indiedeveloper","#sideproject","#nuxt","https:\u002F\u002Fassets.kbmjj123.cc\u002Fblog\u002Findie-mindset\u002Ffrom-zero-to-2-dollar-6-months-tool-site\u002Fcover.webp","\u002Fposts\u002Ffrom-zero-to-2-dollar-6-months-tool-site",{"title":5281,"description":5282,"keywords":5283},"6 Months Running a Tool Site: From Zero to $2\u002FDay | Indie Developer Retrospective","A frank look at building a Nuxt + Cloudflare tool site: AI content backlash, AdSense rejection and fix, and small but real revenue milestones.",[5284,5285,5286,5287,5288],"indie developer tool site","6 months review","AdSense low value content fix","Nuxt Cloudflare stack","side project revenue","posts\u002Ffrom-zero-to-2-dollar-6-months-tool-site","3pODQOgEyDN2g6N5NltZ6dsl4z49Fuhgg_1mdOeZPDc",{"id":5292,"title":5293,"body":5294,"description":5495,"extension":925,"meta":5496,"navigation":702,"path":5504,"seo":5505,"stem":5512,"__hash__":5513},"posts\u002Fposts\u002Fgoing-serverless-part-1-why-cloudflare.md","Why I Bet My Indie Project on Cloudflare Instead of a Server",{"type":8,"value":5295,"toc":5483},[5296,5298,5301,5305,5308,5311,5314,5318,5321,5324,5328,5331,5334,5338,5341,5361,5364,5368,5371,5385,5388,5392,5395,5401,5427,5430,5434,5437,5443,5446,5450,5453,5455,5472,5474],[11,5297,14],{"id":13},[16,5299,5300],{},"No server. No on-call rotation. No \"what if the disk fills up at 3am.\" I run every one of my indie projects on Cloudflare — D1, Workers, KV, and R2 — instead of renting and maintaining a server. This post is about why, and what I'm honestly giving up by doing it.",[11,5302,5304],{"id":5303},"ive-run-servers-before-thats-exactly-why-im-walking-away-from-them","I've Run Servers Before — That's Exactly Why I'm Walking Away From Them",[16,5306,5307],{},"This isn't a \"I've never touched a server so I'm scared of one\" post. I have run servers. Regular maintenance, patching, the occasional 2am restart because something silently died — I've done that work, on real projects, more than once.",[16,5309,5310],{},"That experience is the reason I'm avoiding it now, not the reason I'm avoiding it out of ignorance. Once you've actually been responsible for keeping a box alive — security patches, disk space, log rotation, the slow creep of \"temporary\" manual fixes that never get cleaned up — you start to see how much of that time has nothing to do with the product you're trying to build. It's upkeep on the thing that runs the product.",[16,5312,5313],{},"As a solo developer, every hour spent on server upkeep is an hour not spent on the actual thing I'm trying to ship. That trade-off used to be invisible to me. After running servers, it isn't anymore.",[11,5315,5317],{"id":5316},"where-im-coming-from","Where I'm Coming From",[16,5319,5320],{},"I want to be clear this isn't a \"couldn't figure out servers, so I gave up\" story. I've built and maintained mid-to-large Vue 2 projects in production. I've also deliberately spent time learning Node.js and TypeScript properly, not just enough to copy-paste a tutorial.",[16,5322,5323],{},"I'm bringing that background up front because the choice to move away from traditional servers wasn't a skill gap — it was a decision made with the skills already in hand, after weighing what the server route actually costs a one-person team.",[11,5325,5327],{"id":5326},"my-principle-ship-the-simplest-thing-that-works","My Principle: Ship the Simplest Thing That Works",[16,5329,5330],{},"If I had to summarize my approach to building software as an indie developer in one line, it's this: the simplest thing that satisfies the actual need wins, every time, over the more \"complete\" or \"correct\" architecture.",[16,5332,5333],{},"That's not laziness — it's a resource allocation decision. I don't have a platform team, an SRE, or even a co-founder to split infrastructure work with. Every layer of complexity I add to my stack is a layer only I will ever debug, at the time I'm least prepared for it — usually while also trying to ship a feature.",[11,5335,5337],{"id":5336},"why-not-a-traditional-server","Why Not a Traditional Server",[16,5339,5340],{},"Once I actually weighed it out, the case against a traditional server came down to a few concrete things, not vague discomfort:",[275,5342,5343,5349,5355],{},[181,5344,5345,5348],{},[60,5346,5347],{},"Ongoing operational load."," Patching, monitoring, backups — work that exists whether or not I have a single user.",[181,5350,5351,5354],{},[60,5352,5353],{},"Fixed cost regardless of traffic."," A server bills the same on a day with zero visitors as it does on launch day.",[181,5356,5357,5360],{},[60,5358,5359],{},"Scaling is a decision I have to make manually."," If something unexpectedly gets traffic, I'm the one who has to notice and react.",[16,5362,5363],{},"None of these are dealbreakers in isolation. Together, for a solo developer, they add up to a constant low-level tax on attention — and attention is the scarcest resource I have.",[11,5365,5367],{"id":5366},"why-cloudflare-specifically","Why Cloudflare, Specifically",[16,5369,5370],{},"Cloudflare wasn't the only \"serverless\" option I could have picked, but two things made it the obvious one for me:",[178,5372,5373,5379],{},[181,5374,5375,5378],{},[60,5376,5377],{},"The free tier is genuinely generous",", not a 30-day trial dressed up as a free tier. It's realistic to run an early-stage indie project on it for a long time without thinking about billing at all.",[181,5380,5381,5384],{},[60,5382,5383],{},"The pieces are designed to work together."," D1, Workers, KV, and R2 aren't separate vendors I'm stitching together with glue code — they share a platform, a deployment story, and a local development workflow.",[16,5386,5387],{},"For where I am right now — pre-revenue, validating ideas, shipping fast — \"doesn't cost me anything to start\" and \"doesn't require me to integrate five different vendors\" mattered more than raw scalability ceiling.",[11,5389,5391],{"id":5390},"the-stack-briefly","The Stack, Briefly",[16,5393,5394],{},"I'm not going to go deep into implementation here — that's what the rest of this series is for. At a glance, this is how the pieces map to what I actually need:",[16,5396,5397],{},[67,5398],{"alt":5399,"src":5400},"Cloudflare stack overview: a client request hits Workers, which routes to D1 for relational data, KV for fast key-value lookups, and R2 for object storage","\u002Fimages\u002Fgoing-serverless\u002Fpart-1-why-cloudflare\u002Fgoing-serverless-part-1-stack-diagram.svg",[275,5402,5403,5409,5415,5421],{},[181,5404,5405,5408],{},[60,5406,5407],{},"Workers"," — the compute layer, where my application logic runs",[181,5410,5411,5414],{},[60,5412,5413],{},"D1"," — relational data, for anything that needs real queries and structure",[181,5416,5417,5420],{},[60,5418,5419],{},"KV"," — fast key-value lookups for things that don't need SQL",[181,5422,5423,5426],{},[60,5424,5425],{},"R2"," — object storage for images and static assets, referenced directly from my blog and apps",[16,5428,5429],{},"Together, that's enough to cover what most of my early-stage products actually need, without provisioning a database server, a file server, and an app server separately.",[11,5431,5433],{"id":5432},"what-im-trading-away","What I'm Trading Away",[16,5435,5436],{},"This is the part most \"why I switched to X\" posts skip, so I want to be specific instead of vague.",[16,5438,5439,5440,816],{},"Right now, neither of my two live projects is under real production load. One is a fully client-side, zero-backend tool — it puts essentially no load on Cloudflare at all. The second is still in validation, with low traffic and a small number of pages. And even in that state, with no real users yet, I'm already sitting at roughly ",[60,5441,5442],{},"10% of my Workers free-tier usage",[16,5444,5445],{},"That number doesn't worry me today. But I'm not going to pretend it's nothing, either. If that second project gets real traffic and more pages — which is the whole point of building it — I expect to cross into paid usage at some point, and probably sooner than I'd assume from the marketing copy around \"generous free tiers.\" I'd rather say that plainly now than discover it the hard way later and write a more frustrated post about it.",[11,5447,5449],{"id":5448},"whats-next","What's Next",[16,5451,5452],{},"This decision didn't come for free, even setting aside future billing. Getting to the point where I could actually use this stack day to day surfaced two very different, very real problems — one with GitHub OAuth failing silently during local development, and one that had nothing to do with code at all: a piece of decade-old hardware that flatly refused to run the tooling I needed. Both are getting their own posts in this series.",[11,5454,792],{"id":791},[275,5456,5457,5460,5463,5466,5469],{},[181,5458,5459],{},"Choosing the \"simple\" path isn't free — it just moves the cost somewhere else, and it's worth knowing where before you commit.",[181,5461,5462],{},"Having actually run servers before gave me a real number to compare against — not \"servers are annoying\" in the abstract, but specific recurring tasks (patching, backups, restarts) I could weigh against Cloudflare's free tier before switching.",[181,5464,5465],{},"A generous free tier is still a tier — track your usage early, even before you have real traffic, so the eventual transition to paid isn't a surprise.",[181,5467,5468],{},"Picking a stack where the pieces are designed together (Workers + D1 + KV + R2) removes a category of integration problems before they happen.",[181,5470,5471],{},"Being honest about what you don't know yet (will this scale? will it stay free?) is more useful to future-you than confidence you haven't earned.",[28,5473],{},[16,5475,5476],{},[257,5477,5478,5479],{},"Part of the \"Going Serverless\" series. Next: ",[20,5480,5482],{"href":5481},"\u002Fgoing-serverless-part-2-github-oauth-local-callback","Why GitHub OAuth Login Failed Locally (And How I Faked My Way Around It)",{"title":337,"searchDepth":351,"depth":351,"links":5484},[5485,5486,5487,5488,5489,5490,5491,5492,5493,5494],{"id":13,"depth":351,"text":14},{"id":5303,"depth":351,"text":5304},{"id":5316,"depth":351,"text":5317},{"id":5326,"depth":351,"text":5327},{"id":5336,"depth":351,"text":5337},{"id":5366,"depth":351,"text":5367},{"id":5390,"depth":351,"text":5391},{"id":5432,"depth":351,"text":5433},{"id":5448,"depth":351,"text":5449},{"id":791,"depth":351,"text":792},"An indie developer's honest case for choosing Cloudflare's D1, Workers, KV, and R2 over a traditional server — and what it's actually costing me so far.",{"date":5497,"category":928,"readTime":4872,"tags":5498,"image":5502,"draft":935,"series":5503,"seriesOrder":344},"2026-06-23",[933,5499,5500,5501],"#bootstrapping","#mvp","#deployment","https:\u002F\u002Fassets.kbmjj123.cc\u002Fimages\u002Fgoing-serverless\u002Fpart-1-why-cloudflare\u002Fgoing-serverless-part-1-stack-diagram.svg","going-serverless","\u002Fposts\u002Fgoing-serverless-part-1-why-cloudflare",{"title":5506,"description":5507,"keywords":5508},"Why I Chose Cloudflare Over a Server as an Indie Developer","A solo developer's real reasoning for picking Cloudflare D1, Workers, KV, and R2 instead of running a server, including the trade-offs being accepted along the way.",[5509,5510,5511],"cloudflare indie developer","why choose cloudflare over server","cloudflare workers d1 r2 kv solo developer","posts\u002Fgoing-serverless-part-1-why-cloudflare","r472TEfeS0nTw-T-vQ73z7K-iVDzLPOywnnfOvVeR3M",{"id":5515,"title":5482,"body":5516,"description":5755,"extension":925,"meta":5756,"navigation":702,"path":5762,"seo":5763,"stem":5770,"__hash__":5771},"posts\u002Fposts\u002Fgoing-serverless-part-2-github-oauth-local-callback.md",{"type":8,"value":5517,"toc":5745},[5518,5520,5523,5525,5528,5530,5533,5535,5538,5541,5555,5558,5561,5567,5569,5572,5692,5695,5697,5700,5703,5705,5708,5710,5727,5729,5742],[11,5519,14],{"id":13},[16,5521,5522],{},"GitHub's OAuth callback wasn't reaching my local dev server — a network environment problem, not a code bug. Instead of reaching for a tunneling tool, I built a single dev-only endpoint that returns a fake \"logged in\" response, so the rest of the app could keep moving.",[11,5524,973],{"id":972},[16,5526,5527],{},"My second project needs GitHub login. Straightforward in theory: redirect to GitHub, user authorizes, GitHub calls back to my app with a code, I exchange it for a token. I'd built this flow before. The problem wasn't the flow — it was the environment I was building it in.",[11,5529,1029],{"id":1028},[16,5531,5532],{},"Locally, the redirect to GitHub's authorization page worked fine. The user could see the GitHub consent screen, approve access — and then nothing. The callback that's supposed to land back on my local dev server never arrived in any usable way. No error in my app, no obvious stack trace pointing at \"this line is broken.\" From the app's point of view, it just stalled at the point where it should have received the user back.",[11,5534,1055],{"id":1054},[16,5536,5537],{},"The first thing I ruled out was my own code. The callback route existed, it was wired up correctly, and the same logic worked once deployed. That narrowed it down fast: this wasn't a logic bug, it was a reachability problem between GitHub's servers and my local machine, specific to developing from inside China's network environment.",[16,5539,5540],{},"At that point I looked at the two realistic options:",[178,5542,5543,5549],{},[181,5544,5545,5548],{},[60,5546,5547],{},"A network tunnel"," (something like ngrok or frp) to expose my local dev server with a public URL GitHub's callback could actually reach.",[181,5550,5551,5554],{},[60,5552,5553],{},"A mock login endpoint"," — skip the real OAuth round trip entirely during local development, and short-circuit straight to \"user is logged in\" with fake data.",[16,5556,5557],{},"I didn't spend long evaluating the tunnel option in depth, and that was a deliberate call, not an oversight: setting one up means installing or configuring an extra tool, keeping it running alongside my dev server, and trusting that traffic flows reliably enough not to introduce a second source of flakiness on top of the original problem. For a feature where I just need \"is the user considered logged in or not\" to build the rest of the app, that's a lot of moving parts for the actual question I'm trying to answer.",[16,5559,5560],{},"Here's the difference between the three states laid out side by side — what works in production, where it breaks locally, and where the mock endpoint steps in:",[16,5562,5563],{},[67,5564],{"alt":5565,"src":5566},"OAuth callback flow comparison: production succeeds, local development fails to reach the callback, and a dev-only mock endpoint bypasses the real exchange to return a fake session","\u002Fimages\u002Fgoing-serverless\u002Fpart-2-github-oauth-local-callback\u002Fgoing-serverless-part-2-oauth-flow-diagram.svg",[11,5568,1189],{"id":1188},[16,5570,5571],{},"I added a single endpoint that only exists in development, gated behind an environment variable check, that returns a mocked \"successful login\" payload — the same shape my real callback handler would produce after a successful GitHub exchange.",[332,5573,5577],{"className":5574,"code":5575,"language":5576,"meta":337,"style":337},"language-ts shiki shiki-themes github-light github-light","\u002F\u002F pages\u002Fapi\u002Fauth\u002Fdev-mock-login.ts\n\u002F\u002F Only registered when running in a development environment.\n\u002F\u002F Returns the same session shape the real GitHub OAuth callback would produce.\n\nexport default defineEventHandler((event) => {\n  if (process.env.NODE_ENV !== 'development') {\n    throw createError({ statusCode: 404 })\n  }\n\n  \u002F\u002F \u003CPLACEHOLDER: real mock user payload + session creation logic>\n  \u002F\u002F e.g. setUserSession(event, { id: 'dev-user', name: 'Local Dev', ... })\n\n  return { ok: true }\n})\n","ts",[52,5578,5579,5584,5589,5594,5598,5620,5638,5655,5659,5663,5668,5673,5677,5688],{"__ignoreMap":337},[341,5580,5581],{"class":343,"line":344},[341,5582,5583],{"class":667},"\u002F\u002F pages\u002Fapi\u002Fauth\u002Fdev-mock-login.ts\n",[341,5585,5586],{"class":343,"line":351},[341,5587,5588],{"class":667},"\u002F\u002F Only registered when running in a development environment.\n",[341,5590,5591],{"class":343,"line":368},[341,5592,5593],{"class":667},"\u002F\u002F Returns the same session shape the real GitHub OAuth callback would produce.\n",[341,5595,5596],{"class":343,"line":381},[341,5597,703],{"emptyLinePlaceholder":702},[341,5599,5600,5603,5606,5609,5611,5614,5616,5618],{"class":343,"line":390},[341,5601,5602],{"class":1080},"export",[341,5604,5605],{"class":1080}," default",[341,5607,5608],{"class":679}," defineEventHandler",[341,5610,3131],{"class":347},[341,5612,5613],{"class":1869},"event",[341,5615,1873],{"class":347},[341,5617,1876],{"class":1080},[341,5619,1233],{"class":347},[341,5621,5622,5624,5627,5630,5633,5636],{"class":343,"line":396},[341,5623,2554],{"class":1080},[341,5625,5626],{"class":347}," (process.env.",[341,5628,5629],{"class":354},"NODE_ENV",[341,5631,5632],{"class":1080}," !==",[341,5634,5635],{"class":361}," 'development'",[341,5637,2566],{"class":347},[341,5639,5640,5643,5646,5649,5652],{"class":343,"line":409},[341,5641,5642],{"class":1080},"    throw",[341,5644,5645],{"class":679}," createError",[341,5647,5648],{"class":347},"({ statusCode: ",[341,5650,5651],{"class":354},"404",[341,5653,5654],{"class":347}," })\n",[341,5656,5657],{"class":343,"line":420},[341,5658,2626],{"class":347},[341,5660,5661],{"class":343,"line":426},[341,5662,703],{"emptyLinePlaceholder":702},[341,5664,5665],{"class":343,"line":432},[341,5666,5667],{"class":667},"  \u002F\u002F \u003CPLACEHOLDER: real mock user payload + session creation logic>\n",[341,5669,5670],{"class":343,"line":1346},[341,5671,5672],{"class":667},"  \u002F\u002F e.g. setUserSession(event, { id: 'dev-user', name: 'Local Dev', ... })\n",[341,5674,5675],{"class":343,"line":1351},[341,5676,703],{"emptyLinePlaceholder":702},[341,5678,5679,5681,5684,5686],{"class":343,"line":1357},[341,5680,2245],{"class":1080},[341,5682,5683],{"class":347}," { ok: ",[341,5685,3488],{"class":354},[341,5687,2548],{"class":347},[341,5689,5690],{"class":343,"line":1371},[341,5691,1979],{"class":347},[16,5693,5694],{},"With that in place, the rest of my app — anything depending on \"the user is logged in\" — could be built and tested locally without ever touching GitHub's real OAuth flow during development.",[11,5696,745],{"id":744},[16,5698,5699],{},"I chose the mock endpoint over a tunnel because of what I actually needed versus what a tunnel gives you. A tunnel solves \"make my local server reachable from the internet\" — which is a real solution, but it's solving a more general problem than the one in front of me. I didn't need GitHub to be able to reach my machine in general; I needed the rest of my app to behave correctly when a user is logged in.",[16,5701,5702],{},"There's also a timing argument here that matters more than the technical one: I'm working with limited time, trying to get a working product out, not trying to build the most architecturally faithful local dev environment. A tunnel is the right tool when the integration itself is what you're testing — for example, I expect to reach for exactly that approach later when integrating ads locally, where the actual request\u002Fresponse behavior of a third party matters. For OAuth login, where the third-party exchange itself isn't what I'm debugging, faking the outcome was the faster, lower-risk path to the same result.",[11,5704,772],{"id":771},[16,5706,5707],{},"Local development stopped blocking on the OAuth callback entirely. Everything downstream of \"user is logged in\" — protected routes, user-specific data fetching, UI states — could be built and verified without depending on GitHub's servers or my network path to them.",[11,5709,792],{"id":791},[275,5711,5712,5715,5718,5721,5724],{},[181,5713,5714],{},"When a third-party callback doesn't reach your local environment, check whether it's a network\u002Fenvironment issue before assuming it's your code.",[181,5716,5717],{},"A tunneling tool isn't free even when it's free to install — it's another moving part to maintain and trust.",[181,5719,5720],{},"If what you actually need is \"pretend this dependency succeeded,\" a dev-only mock is often cheaper than making the real dependency reachable.",[181,5722,5723],{},"Save tunneling for cases where the integration behavior itself is what you're testing — not every local-dev blocker needs the same fix.",[181,5725,5726],{},"Gate dev-only endpoints behind an environment check so they can never accidentally ship to production.",[28,5728],{},[16,5730,5731],{},[257,5732,5733,5734,5737,5738],{},"Part of the \"Going Serverless\" series. Previous: ",[20,5735,5293],{"href":5736},"\u002Fgoing-serverless-part-1-why-cloudflare"," · Next: ",[20,5739,5741],{"href":5740},"\u002Fgoing-serverless-part-3-macos-upgrade-wrangler","Two Days, One Broken Monitor, and a Forced macOS Upgrade Just to Run Wrangler",[894,5743,5744],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sCydW, html code.shiki .sCydW{--shiki-default:#D73A49;--shiki-dark:#D73A49}html pre.shiki code .se37E, html code.shiki .se37E{--shiki-default:#6F42C1;--shiki-dark:#6F42C1}html pre.shiki code .sKWpL, html code.shiki .sKWpL{--shiki-default:#24292E;--shiki-dark:#24292E}html pre.shiki code .sj_tP, html code.shiki .sj_tP{--shiki-default:#E36209;--shiki-dark:#E36209}html pre.shiki code .sMN4m, html code.shiki .sMN4m{--shiki-default:#005CC5;--shiki-dark:#005CC5}html pre.shiki code .sOTlB, html code.shiki .sOTlB{--shiki-default:#032F62;--shiki-dark:#032F62}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":337,"searchDepth":351,"depth":351,"links":5746},[5747,5748,5749,5750,5751,5752,5753,5754],{"id":13,"depth":351,"text":14},{"id":972,"depth":351,"text":973},{"id":1028,"depth":351,"text":1029},{"id":1054,"depth":351,"text":1055},{"id":1188,"depth":351,"text":1189},{"id":744,"depth":351,"text":745},{"id":771,"depth":351,"text":772},{"id":791,"depth":351,"text":792},"How a GitHub OAuth callback silently failed during local development behind China's network environment, and why I chose a dev-only mock endpoint over a tunneling tool to fix it.",{"date":5497,"category":4871,"readTime":5757,"tags":5758,"image":5761,"draft":935,"series":5503,"seriesOrder":351},"5mins",[5759,5760,5501],"#api","#typescript","https:\u002F\u002Fassets.kbmjj123.cc\u002Fblog\u002Fdev-practice\u002Fgoing-serverless-part-2\u002Fgoing-serverless-part-2-oauth-flow-diagram.svg","\u002Fposts\u002Fgoing-serverless-part-2-github-oauth-local-callback",{"title":5764,"description":5765,"keywords":5766},"Fix GitHub OAuth Callback Failing in Local Development","A practical fix for GitHub OAuth login failing during local development, using a dev-only mock login endpoint instead of a network tunnel.",[5767,5768,5769],"github oauth callback not working locally","github oauth local development china","mock oauth login local development","posts\u002Fgoing-serverless-part-2-github-oauth-local-callback","Yd0mpy843kzwN12QRaODbJaBeK0tOYXJdX0AaU1ixRA",{"id":5773,"title":5741,"body":5774,"description":5935,"extension":925,"meta":5936,"navigation":702,"path":5942,"seo":5943,"stem":5950,"__hash__":5951},"posts\u002Fposts\u002Fgoing-serverless-part-3-macos-upgrade-wrangler.md",{"type":8,"value":5775,"toc":5925},[5776,5778,5781,5785,5788,5795,5801,5805,5808,5822,5828,5842,5845,5849,5852,5858,5864,5870,5876,5880,5883,5886,5890,5893,5895,5898,5900,5917,5919],[11,5777,14],{"id":13},[16,5779,5780],{},"Wrangler requires macOS 14 or newer. My Mac is from 2015, and Apple stopped offering an official upgrade path for it years ago. I didn't check this before committing to the Cloudflare stack in Part 1 — I'm writing this so you can check it before you do.",[11,5782,5784],{"id":5783},"what-happened-briefly","What Happened, Briefly",[16,5786,5787],{},"I set up the Cloudflare stack described in Part 1 of this series, went to install Wrangler, and hit a hard wall: macOS 14+ required, and my machine couldn't get there through any official Apple upgrade path. I ended up paying for a forced upgrade service, which got Wrangler running but broke my external monitor in the process — two days lost before I could write a single line of code against Cloudflare.",[16,5789,5790,5791,5794],{},"The point of this post isn't the story. It's the checklist I should have run ",[257,5792,5793],{},"before"," committing to a tool, and the actual trade-off behind the option I picked once I hit the wall.",[16,5796,5797],{},[67,5798],{"alt":5799,"src":5800},"External monitor showing display issue after forced macOS upgrade","\u002Fimages\u002Fgoing-serverless\u002Fpart-3-macos-upgrade-wrangler\u002Fgoing-serverless-part-3-monitor-broken.webp",[11,5802,5804],{"id":5803},"the-pre-flight-checklist-confirm-this-before-you-adopt-a-cli-tool","The Pre-Flight Checklist: Confirm This Before You Adopt a CLI Tool",[16,5806,5807],{},"If you're an indie developer on hardware that's more than a few years old, run these checks before you build your workflow around a new CLI tool, not after:",[178,5809,5810,5816],{},[181,5811,5812,5815],{},[60,5813,5814],{},"Find the tool's actual minimum OS version, not just \"works on modern Mac.\""," For Wrangler specifically, the requirement is macOS 14+ — check this exact number on Cloudflare's own Wrangler installation docs, not from a tutorial that might be a year or two old and no longer accurate.",[181,5817,5818,5821],{},[60,5819,5820],{},"Check whether your specific Mac model can reach that OS version at all."," Apple publishes the list of Macs supported by each macOS release. Look up your exact model year and number, not just \"is my Mac old.\"",[16,5823,5824],{},[67,5825],{"alt":5826,"src":5827},"Apple's official list of Mac models supported by the target macOS version","\u002Fimages\u002Fgoing-serverless\u002Fpart-3-macos-upgrade-wrangler\u002Fgoing-serverless-part-3-apple-supported-devices.webp",[178,5829,5830,5836],{"start":368},[181,5831,5832,5835],{},[60,5833,5834],{},"If your model isn't listed, that's a different problem than \"a few versions behind.\""," It means there's no official upgrade path, and any upgrade requires a third-party method — which carries its own risk (see below).",[181,5837,5838,5841],{},[60,5839,5840],{},"Check this before you've already built things assuming the tool will be available."," I checked it after I'd already decided Cloudflare was my stack, which removed \"pick a different tool\" from my options. Checking earlier keeps that option open.",[16,5843,5844],{},"This takes ten minutes. The forced upgrade I eventually did took two days. The math on doing this check first is not close.",[11,5846,5848],{"id":5847},"the-options-i-actually-had","The Options I Actually Had",[16,5850,5851],{},"When I hit the wall, here's what was realistically on the table — and what I'd weigh differently if I were deciding again:",[16,5853,5854,5857],{},[60,5855,5856],{},"1. Forced\u002Funofficial OS upgrade on the existing machine"," (what I did)\nCheapest in dollar terms, no new hardware to manage. The real risk is exactly what happened to me: unofficial upgrades can introduce side effects — driver issues, peripheral incompatibility — that have nothing to do with the OS version number and everything to do with hardware the upgrade was never tested against. You're trading a known cost (money) for an unknown one (time spent on whatever breaks).",[16,5859,5860,5863],{},[60,5861,5862],{},"2. A cloud-based development environment"," (e.g., GitHub Codespaces or similar)\nI did consider this — it sidesteps your local OS version completely, since Wrangler would run in the cloud container instead of on your laptop. What ruled it out for me was setup friction: getting the environment configured the way I needed, and then resetting or reconfiguring it repeatedly instead of working in a stable setup I fully control, looked like its own ongoing time cost rather than a one-time fix. For someone who can tolerate that friction, or who already works in a cloud-first setup, this removes the hardware variable entirely — it just wasn't the lower-friction path for me.",[16,5865,5866,5869],{},[60,5867,5868],{},"3. Borrowing or temporarily using a machine that already meets the requirement","\nIf you know someone with newer hardware, this gets you unblocked immediately with zero cost and zero risk to your own machine. Not always available, but worth ruling out before spending money or time on the other two options.",[16,5871,5872,5875],{},[60,5873,5874],{},"4. Buying new hardware","\nThe most expensive option up front, the most permanent fix. I ruled this out for cost reasons, but it's the option with the least ongoing risk — no unofficial upgrade side effects, no recurring cloud costs.",[11,5877,5879],{"id":5878},"why-i-picked-the-forced-upgrade-anyway","Why I Picked the Forced Upgrade Anyway",[16,5881,5882],{},"I did weigh the cloud-based dev environment option before deciding — I didn't just default to the forced upgrade without thinking. What ruled it out wasn't cost, it was setup friction. Getting a cloud environment configured the way I actually needed it — the right runtime, the right tools, my project synced and working — was its own time sink, and on top of that, I'd be resetting or reconfiguring that environment repeatedly rather than working in a stable, persistent setup I fully control. For a problem I just wanted to be done with, trading \"fix my own machine once\" for \"manage a cloud environment on an ongoing basis\" didn't look like the faster path, even though it avoided the OS version problem entirely.",[16,5884,5885],{},"That's the actual trade-off, not a hypothetical one: the forced upgrade had an unknown one-time risk (which turned into a real cost, see below), while the cloud environment had a known, recurring setup-and-reset cost. I picked the option with the unknown risk because I expected to deal with it once and be done, not on every session.",[11,5887,5889],{"id":5888},"the-cost","The Cost",[16,5891,5892],{},"The upgrade itself went through. The external monitor stopped working correctly afterward — unrelated to Wrangler or Cloudflare, a direct side effect of forcing an unsupported OS jump onto older hardware. Diagnosing and fixing that, on top of the upgrade itself, brought the total to two full days before I could start actual development work.",[11,5894,745],{"id":744},[16,5896,5897],{},"This doesn't change my decision from Part 1 — I'd still choose Cloudflare over running my own server. But the first real cost of \"going serverless\" had nothing to do with serverless architecture at all. It was a tooling requirement I hadn't checked against hardware I'd already decided to keep using. That's a planning gap, not a Cloudflare problem, and it's exactly the kind of cost that's easy to skip in a write-up unless you go looking for it on purpose.",[11,5899,792],{"id":791},[275,5901,5902,5905,5908,5911,5914],{},[181,5903,5904],{},"Before adopting any CLI tool, look up its minimum OS\u002Fruntime requirement directly from the vendor's docs — not from a tutorial that may already be outdated.",[181,5906,5907],{},"Cross-check your specific hardware model against the vendor's official supported-device list before assuming \"old but upgradable.\"",[181,5909,5910],{},"If your hardware has no official upgrade path, weigh a cloud-based dev environment against an unofficial upgrade on the actual axis that matters: one-time unknown risk versus ongoing setup-and-reset friction — not just which one is cheaper.",[181,5912,5913],{},"Run this check before committing to a stack, not after — it keeps \"pick a different tool\" on the table as an option.",[181,5915,5916],{},"An unofficial upgrade's real cost isn't the fee — it's the unknown side effects on hardware the upgrade was never tested against. Budget time for that possibility, not just money for the upgrade itself.",[28,5918],{},[16,5920,5921],{},[257,5922,5733,5923],{},[20,5924,5482],{"href":5481},{"title":337,"searchDepth":351,"depth":351,"links":5926},[5927,5928,5929,5930,5931,5932,5933,5934],{"id":13,"depth":351,"text":14},{"id":5783,"depth":351,"text":5784},{"id":5803,"depth":351,"text":5804},{"id":5847,"depth":351,"text":5848},{"id":5878,"depth":351,"text":5879},{"id":5888,"depth":351,"text":5889},{"id":744,"depth":351,"text":745},{"id":791,"depth":351,"text":792},"Wrangler required macOS 14+ and my 2015 Mac had been abandoned by Apple. Here's the pre-flight checklist I wish I'd run first, and the real options for developers stuck on old hardware.",{"date":5497,"category":5937,"readTime":5938,"tags":5939,"image":5941,"draft":935,"series":5503,"seriesOrder":368},"startup-diary","4mins",[933,5499,5940],"#productivity","https:\u002F\u002Fassets.kbmjj123.cc\u002Fblog\u002Fstartup-diary\u002Fgoing-serverless-part-3\u002Fcover.png","\u002Fposts\u002Fgoing-serverless-part-3-macos-upgrade-wrangler",{"title":5944,"description":5945,"keywords":5946},"Wrangler macOS Requirement: What to Check Before You're Blocked","A pre-flight checklist for confirming your hardware can run Wrangler before you commit to it, plus the real options when your Mac is too old to upgrade.",[5947,5948,5949],"wrangler macos version requirement","old mac unsupported macos upgrade","cloudflare wrangler system requirements","posts\u002Fgoing-serverless-part-3-macos-upgrade-wrangler","uNTcMTjy2DW2R4sKP5If6byihNdEM3djDI6xmOmaDkg",{"id":5953,"title":5954,"body":5955,"description":9694,"extension":925,"meta":9695,"navigation":702,"path":9699,"seo":9700,"stem":9709,"__hash__":9710},"posts\u002Fposts\u002Fplatform-agnostic-ad-component-nuxt4-ssg.md","A Platform-Agnostic Ad Component for Nuxt 4 SSG: Swapping AdSense and wwads Without Touching Page Code",{"type":8,"value":5956,"toc":9680},[5957,5959,5974,5995,5997,5999,6002,6009,6020,6029,6031,6035,6038,6043,6064,6305,6315,6460,6465,6474,6479,6529,6540,6549,6555,6557,6561,6569,6573,6576,6582,6585,6598,6603,6665,6681,6685,6702,6712,6715,6896,6899,6903,6916,6938,7136,7142,7152,7156,9512,9514,9516,9521,9531,9534,9545,9558,9563,9575,9578,9583,9606,9608,9610,9613,9619,9625,9627,9629,9639,9654,9671,9677],[11,5958,14],{"id":13},[16,5960,5961,5962,5965,5966,5969,5970,5973],{},"Most guides for adding ads to a Nuxt site tell you to install ",[52,5963,5964],{},"@nuxtjs\u002Fgoogle-adsense"," and drop ",[52,5967,5968],{},"\u003CAdsbygoogle \u002F>"," into your pages. That works fine until you need to serve different ad platforms to different locales, or until you navigate back to a page and see ",[52,5971,5972],{},"TagError: adsbygoogle.push() error: All ins elements in the DOM with class=adsbygoogle already have ads in them."," in the console.",[16,5975,5976,5977,5980,5981,5984,5985,5988,5989,1885,5991,5994],{},"This post covers how I built a two-layer ad system for ",[20,5978,40],{"href":22,"rel":5979},[24]," — a multilingual Nuxt 4 SSG site deployed on Cloudflare. The outer layer (",[52,5982,5983],{},"AdPlaceholder"," + ",[52,5986,5987],{},"useAdPlatform",") handles platform routing and kill switches. The inner layer (",[52,5990,997],{},[52,5992,5993],{},"AdWwads",") handles each platform's SDK independently. Page code never touches platform logic.",[28,5996],{},[11,5998,973],{"id":972},[16,6000,6001],{},"BulkPicTools is a browser-only image processing tool — Canvas + WebWorkers, zero server uploads, deployed as a static site on Cloudflare Pages. It supports six languages and gets traffic from both Chinese-speaking users and the rest of the world.",[16,6003,6004,6005,6008],{},"When I decided to add ads, the naive approach would have been to hardcode ",[52,6006,6007],{},"\u003Cins class=\"adsbygoogle\">"," wherever I needed a placement. That falls apart the moment you need:",[275,6010,6011,6014,6017],{},[181,6012,6013],{},"Different platforms for different locales (wwads for Chinese users, AdSense for everyone else)",[181,6015,6016],{},"A global kill switch that cuts all ads without hunting through every page component",[181,6018,6019],{},"The ability to swap platforms later without touching page code",[16,6021,438,6022,6024,6025,6028],{},[52,6023,5964],{}," module doesn't offer any of this. It also doesn't support Nuxt 4's module format cleanly, and its SPA navigation workaround — randomising ",[52,6026,6027],{},"data-ad-region"," on every route change — causes SSG build output to differ between runs, which breaks Cloudflare's cache diffing. So I wrote the components from scratch.",[28,6030],{},[11,6032,6034],{"id":6033},"architecture","Architecture",[16,6036,6037],{},"Two layers with strictly separated responsibilities.",[16,6039,6040],{},[60,6041,6042],{},"Layer 1: platform logic",[16,6044,6045,6047,6048,6051,6052,6055,6056,6059,6060,6063],{},[52,6046,5987],{}," is a composable that reads ",[52,6049,6050],{},"locale"," from ",[52,6053,6054],{},"useI18n()"," and the ",[52,6057,6058],{},"ads"," config from ",[52,6061,6062],{},"useRuntimeConfig()",". It computes two things: which platform to use, and whether that platform is currently enabled.",[332,6065,6067],{"className":5574,"code":6066,"language":5576,"meta":337,"style":337},"export type AdPlatform = 'wwads' | 'adsense'\n\nexport function useAdPlatform() {\n  const { locale } = useI18n()\n  const config = useRuntimeConfig()\n  const ads = config.public.ads\n\n  const platform = computed\u003CAdPlatform>(() =>\n    locale.value === 'zh' ? 'wwads' : 'adsense'\n  )\n\n  \u002F\u002F Master switch AND platform switch must both be true\n  const platformEnabled = computed(() => {\n    if (!ads.enable) return false\n    if (platform.value === 'wwads')   return !!ads.wwadsOpen\n    if (platform.value === 'adsense') return !!ads.googleAdSenseId\n    return false\n  })\n\n  return { platform, platformEnabled }\n}\n",[52,6068,6069,6089,6093,6104,6121,6134,6146,6150,6172,6191,6195,6199,6204,6221,6237,6259,6279,6286,6290,6294,6301],{"__ignoreMap":337},[341,6070,6071,6073,6075,6078,6080,6083,6086],{"class":343,"line":344},[341,6072,5602],{"class":1080},[341,6074,1000],{"class":1080},[341,6076,6077],{"class":679}," AdPlatform",[341,6079,1230],{"class":1080},[341,6081,6082],{"class":361}," 'wwads'",[341,6084,6085],{"class":1080}," |",[341,6087,6088],{"class":361}," 'adsense'\n",[341,6090,6091],{"class":343,"line":351},[341,6092,703],{"emptyLinePlaceholder":702},[341,6094,6095,6097,6099,6102],{"class":343,"line":368},[341,6096,5602],{"class":1080},[341,6098,3926],{"class":1080},[341,6100,6101],{"class":679}," useAdPlatform",[341,6103,3566],{"class":347},[341,6105,6106,6108,6110,6112,6114,6116,6119],{"class":343,"line":381},[341,6107,2535],{"class":1080},[341,6109,2751],{"class":347},[341,6111,6050],{"class":354},[341,6113,2766],{"class":347},[341,6115,683],{"class":1080},[341,6117,6118],{"class":679}," useI18n",[341,6120,2000],{"class":347},[341,6122,6123,6125,6128,6130,6132],{"class":343,"line":390},[341,6124,2535],{"class":1080},[341,6126,6127],{"class":354}," config",[341,6129,1230],{"class":1080},[341,6131,1997],{"class":679},[341,6133,2000],{"class":347},[341,6135,6136,6138,6141,6143],{"class":343,"line":396},[341,6137,2535],{"class":1080},[341,6139,6140],{"class":354}," ads",[341,6142,1230],{"class":1080},[341,6144,6145],{"class":347}," config.public.ads\n",[341,6147,6148],{"class":343,"line":409},[341,6149,703],{"emptyLinePlaceholder":702},[341,6151,6152,6154,6157,6159,6161,6163,6166,6169],{"class":343,"line":420},[341,6153,2535],{"class":1080},[341,6155,6156],{"class":354}," platform",[341,6158,1230],{"class":1080},[341,6160,2013],{"class":679},[341,6162,673],{"class":347},[341,6164,6165],{"class":679},"AdPlatform",[341,6167,6168],{"class":347},">(() ",[341,6170,6171],{"class":1080},"=>\n",[341,6173,6174,6177,6179,6182,6185,6187,6189],{"class":343,"line":426},[341,6175,6176],{"class":347},"    locale.value ",[341,6178,2560],{"class":1080},[341,6180,6181],{"class":361}," 'zh'",[341,6183,6184],{"class":1080}," ?",[341,6186,6082],{"class":361},[341,6188,2584],{"class":1080},[341,6190,6088],{"class":361},[341,6192,6193],{"class":343,"line":432},[341,6194,4377],{"class":347},[341,6196,6197],{"class":343,"line":1346},[341,6198,703],{"emptyLinePlaceholder":702},[341,6200,6201],{"class":343,"line":1351},[341,6202,6203],{"class":667},"  \u002F\u002F Master switch AND platform switch must both be true\n",[341,6205,6206,6208,6211,6213,6215,6217,6219],{"class":343,"line":1357},[341,6207,2535],{"class":1080},[341,6209,6210],{"class":354}," platformEnabled",[341,6212,1230],{"class":1080},[341,6214,2013],{"class":679},[341,6216,2235],{"class":347},[341,6218,1876],{"class":1080},[341,6220,1233],{"class":347},[341,6222,6223,6225,6227,6229,6232,6234],{"class":343,"line":1371},[341,6224,3151],{"class":1080},[341,6226,2587],{"class":347},[341,6228,2712],{"class":1080},[341,6230,6231],{"class":347},"ads.enable) ",[341,6233,2735],{"class":1080},[341,6235,6236],{"class":354}," false\n",[341,6238,6239,6241,6244,6246,6248,6251,6253,6256],{"class":343,"line":1384},[341,6240,3151],{"class":1080},[341,6242,6243],{"class":347}," (platform.value ",[341,6245,2560],{"class":1080},[341,6247,6082],{"class":361},[341,6249,6250],{"class":347},")   ",[341,6252,2735],{"class":1080},[341,6254,6255],{"class":1080}," !!",[341,6257,6258],{"class":347},"ads.wwadsOpen\n",[341,6260,6261,6263,6265,6267,6270,6272,6274,6276],{"class":343,"line":1397},[341,6262,3151],{"class":1080},[341,6264,6243],{"class":347},[341,6266,2560],{"class":1080},[341,6268,6269],{"class":361}," 'adsense'",[341,6271,1873],{"class":347},[341,6273,2735],{"class":1080},[341,6275,6255],{"class":1080},[341,6277,6278],{"class":347},"ads.googleAdSenseId\n",[341,6280,6281,6284],{"class":343,"line":1402},[341,6282,6283],{"class":1080},"    return",[341,6285,6236],{"class":354},[341,6287,6288],{"class":343,"line":1610},[341,6289,3307],{"class":347},[341,6291,6292],{"class":343,"line":1625},[341,6293,703],{"emptyLinePlaceholder":702},[341,6295,6296,6298],{"class":343,"line":1632},[341,6297,2245],{"class":1080},[341,6299,6300],{"class":347}," { platform, platformEnabled }\n",[341,6302,6303],{"class":343,"line":1642},[341,6304,435],{"class":347},[16,6306,6307,6310,6311,6314],{},[52,6308,6309],{},"AdPlaceholder.vue"," consumes the composable and routes to the correct slot. It renders nothing at all when ",[52,6312,6313],{},"platformEnabled"," is false — no empty containers, no layout impact.",[332,6316,6318],{"className":986,"code":6317,"language":988,"meta":337,"style":337},"\u003Ctemplate>\n  \u003Ctemplate v-if=\"platformEnabled\">\n    \u003Cslot v-if=\"platform === 'wwads'\" name=\"zh\" \u002F>\n    \u003Cslot v-else name=\"en\" \u002F>\n  \u003C\u002Ftemplate>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup lang=\"ts\">\nconst { platform, platformEnabled } = useAdPlatform()\n\u003C\u002Fscript>\n",[52,6319,6320,6328,6348,6373,6393,6401,6409,6413,6431,6452],{"__ignoreMap":337},[341,6321,6322,6324,6326],{"class":343,"line":344},[341,6323,673],{"class":347},[341,6325,1416],{"class":676},[341,6327,1419],{"class":347},[341,6329,6330,6332,6334,6337,6339,6342,6344,6346],{"class":343,"line":351},[341,6331,1424],{"class":347},[341,6333,1416],{"class":676},[341,6335,6336],{"class":1080}," v-if",[341,6338,683],{"class":347},[341,6340,6341],{"class":361},"\"",[341,6343,6313],{"class":347},[341,6345,6341],{"class":361},[341,6347,1419],{"class":347},[341,6349,6350,6352,6355,6357,6359,6362,6364,6366,6369,6371],{"class":343,"line":368},[341,6351,1458],{"class":347},[341,6353,6354],{"class":676},"slot",[341,6356,6336],{"class":679},[341,6358,683],{"class":347},[341,6360,6361],{"class":361},"\"platform === 'wwads'\"",[341,6363,1497],{"class":679},[341,6365,683],{"class":347},[341,6367,6368],{"class":361},"\"zh\"",[341,6370,1727],{"class":1726},[341,6372,1419],{"class":347},[341,6374,6375,6377,6379,6382,6384,6386,6389,6391],{"class":343,"line":381},[341,6376,1458],{"class":347},[341,6378,6354],{"class":676},[341,6380,6381],{"class":679}," v-else",[341,6383,1497],{"class":679},[341,6385,683],{"class":347},[341,6387,6388],{"class":361},"\"en\"",[341,6390,1727],{"class":1726},[341,6392,1419],{"class":347},[341,6394,6395,6397,6399],{"class":343,"line":390},[341,6396,1745],{"class":347},[341,6398,1416],{"class":676},[341,6400,1419],{"class":347},[341,6402,6403,6405,6407],{"class":343,"line":396},[341,6404,1755],{"class":347},[341,6406,1416],{"class":676},[341,6408,1419],{"class":347},[341,6410,6411],{"class":343,"line":409},[341,6412,703],{"emptyLinePlaceholder":702},[341,6414,6415,6417,6419,6421,6424,6426,6429],{"class":343,"line":420},[341,6416,673],{"class":347},[341,6418,1772],{"class":676},[341,6420,1775],{"class":679},[341,6422,6423],{"class":679}," lang",[341,6425,683],{"class":347},[341,6427,6428],{"class":361},"\"ts\"",[341,6430,1419],{"class":347},[341,6432,6433,6435,6437,6440,6442,6444,6446,6448,6450],{"class":343,"line":426},[341,6434,1224],{"class":1080},[341,6436,2751],{"class":347},[341,6438,6439],{"class":354},"platform",[341,6441,1885],{"class":347},[341,6443,6313],{"class":354},[341,6445,2766],{"class":347},[341,6447,683],{"class":1080},[341,6449,6101],{"class":679},[341,6451,2000],{"class":347},[341,6453,6454,6456,6458],{"class":343,"line":432},[341,6455,1755],{"class":347},[341,6457,1772],{"class":676},[341,6459,1419],{"class":347},[16,6461,6462],{},[60,6463,6464],{},"Layer 2: platform implementations",[16,6466,6467,319,6470,6473],{},[52,6468,6469],{},"AdWwads.vue",[52,6471,6472],{},"AdAdsense.vue"," each handle their own SDK loading, lifecycle, and rendering. They know nothing about locale routing — that's already been decided by the time they're mounted.",[16,6475,6476],{},[60,6477,6478],{},"How a page uses this:",[332,6480,6482],{"className":986,"code":6481,"language":988,"meta":337,"style":337},"\u003CAdPlaceholder>\n  \u003Ctemplate #zh>\n    \u003CAdWwads slot-name=\"sticky\" \u002F>\n  \u003C\u002Ftemplate>\n  \u003Ctemplate #en>\n    \u003CAdAdsense type=\"responsive\" adsense-slot-id=\"4691275817\" \u002F>\n  \u003C\u002Ftemplate>\n\u003C\u002FAdPlaceholder>\n",[52,6483,6484,6492,6497,6502,6507,6512,6517,6521],{"__ignoreMap":337},[341,6485,6486,6488,6490],{"class":343,"line":344},[341,6487,673],{"class":347},[341,6489,5983],{"class":676},[341,6491,1419],{"class":347},[341,6493,6494],{"class":343,"line":351},[341,6495,6496],{"class":347},"  \u003Ctemplate #zh>\n",[341,6498,6499],{"class":343,"line":368},[341,6500,6501],{"class":347},"    \u003CAdWwads slot-name=\"sticky\" \u002F>\n",[341,6503,6504],{"class":343,"line":381},[341,6505,6506],{"class":347},"  \u003C\u002Ftemplate>\n",[341,6508,6509],{"class":343,"line":390},[341,6510,6511],{"class":347},"  \u003Ctemplate #en>\n",[341,6513,6514],{"class":343,"line":396},[341,6515,6516],{"class":347},"    \u003CAdAdsense type=\"responsive\" adsense-slot-id=\"4691275817\" \u002F>\n",[341,6518,6519],{"class":343,"line":409},[341,6520,6506],{"class":347},[341,6522,6523,6525,6527],{"class":343,"line":420},[341,6524,1755],{"class":347},[341,6526,5983],{"class":676},[341,6528,1419],{"class":347},[16,6530,6531,6532,6535,6536,6539],{},"The page component is completely unaware of platform logic. Switching locales swaps the rendered slot. Turning off ads entirely means flipping ",[52,6533,6534],{},"ads.enable"," in ",[52,6537,6538],{},"runtimeConfig"," — no page changes needed.",[16,6541,6542,6543,6545,6546,6548],{},"Adding a third platform later means: one new branch in ",[52,6544,5987],{},", one new slot name in ",[52,6547,5983],{},", one new implementation component. Existing page code stays unchanged.",[16,6550,6551],{},[67,6552],{"alt":6553,"src":6554},"Two-layer ad architecture: useAdPlatform composable routes locale to AdPlaceholder, which renders either AdWwads or AdAdsense","\u002Fimages\u002Fdev-practice\u002Fplatform-agnostic-ad-component-nuxt4-ssg\u002Fplatform-agnostic-ad-architecture-diagram.svg",[28,6556],{},[11,6558,6560],{"id":6559},"the-adsense-implementation-three-problems-worth-documenting","The AdSense Implementation: Three Problems Worth Documenting",[16,6562,6563,6565,6566,6568],{},[52,6564,6472],{}," is more involved than a wrapper around ",[52,6567,1064],{},". Here's why.",[305,6570,6572],{"id":6571},"problem-1-already-have-ads-on-spa-navigation","Problem 1: already-have-ads on SPA navigation",[16,6574,6575],{},"The error message is:",[332,6577,6580],{"className":6578,"code":6579,"language":4731},[4729],"TagError: adsbygoogle.push() error: All ins elements in the DOM\nwith class=adsbygoogle already have ads in them.\n",[52,6581,6579],{"__ignoreMap":337},[16,6583,6584],{},"Reproduction path on BulkPicTools: open a tool page → process some images → navigate to the result page → navigate back to the tool page. The second time the tool page mounts, the error fires and the ad slot stays blank.",[16,6586,6587,6588,6591,6592,6594,6595,6597],{},"The cause: Nuxt's SPA navigation doesn't reload the page. ",[52,6589,6590],{},"window.adsbygoogle"," persists for the entire browser session. When AdSense's SDK processes an ",[52,6593,1064],{}," element, it marks it internally. Vue unmounting the component doesn't clear that mark — it just removes the DOM node. When Vue remounts the component and creates a fresh ",[52,6596,1064],{},", AdSense doesn't see a new element; it sees a recycled DOM subtree and refuses to push again.",[16,6599,6600,6601,562],{},"The fix is to clear the container in ",[52,6602,4545],{},[332,6604,6606],{"className":5574,"code":6605,"language":5576,"meta":337,"style":337},"onBeforeUnmount(() => {\n  intersectionObserver?.disconnect()\n  statusObserver?.disconnect()\n  if (fallbackTimer) clearTimeout(fallbackTimer)\n  \u002F\u002F This is the critical line — clears AdSense's internal mark\n  if (adRef.value) adRef.value.innerHTML = ''\n})\n",[52,6607,6608,6618,6626,6634,6646,6651,6661],{"__ignoreMap":337},[341,6609,6610,6612,6614,6616],{"class":343,"line":344},[341,6611,4545],{"class":679},[341,6613,2235],{"class":347},[341,6615,1876],{"class":1080},[341,6617,1233],{"class":347},[341,6619,6620,6622,6624],{"class":343,"line":351},[341,6621,4557],{"class":347},[341,6623,3430],{"class":679},[341,6625,2000],{"class":347},[341,6627,6628,6630,6632],{"class":343,"line":368},[341,6629,4576],{"class":347},[341,6631,3430],{"class":679},[341,6633,2000],{"class":347},[341,6635,6636,6638,6641,6643],{"class":343,"line":381},[341,6637,2554],{"class":1080},[341,6639,6640],{"class":347}," (fallbackTimer) ",[341,6642,3453],{"class":679},[341,6644,6645],{"class":347},"(fallbackTimer)\n",[341,6647,6648],{"class":343,"line":390},[341,6649,6650],{"class":667},"  \u002F\u002F This is the critical line — clears AdSense's internal mark\n",[341,6652,6653,6655,6657,6659],{"class":343,"line":396},[341,6654,2554],{"class":1080},[341,6656,4648],{"class":347},[341,6658,683],{"class":1080},[341,6660,2033],{"class":361},[341,6662,6663],{"class":343,"line":409},[341,6664,1979],{"class":347},[16,6666,6667,6668,6670,6671,6673,6674,6676,6677,6680],{},"I considered ",[52,6669,5964],{},"'s approach of randomising ",[52,6672,6027],{}," on every route change. The problem with that on an SSG site: the random value gets baked into the built HTML, so every build produces different output. Cloudflare's cache invalidation treats changed files as new deploys — the ",[52,6675,6027],{}," churn would cause unnecessary full-site cache busts. Clearing ",[52,6678,6679],{},"innerHTML"," has no build-time side effects.",[305,6682,6684],{"id":6683},"problem-2-sdk-not-ready-on-mount","Problem 2: SDK not ready on mount",[16,6686,438,6687,6690,6691,6694,6695,6698,6699,6701],{},[52,6688,6689],{},"adsbygoogle.js"," script is loaded globally via ",[52,6692,6693],{},"useHead"," \u002F ",[52,6696,6697],{},"customScripts"," in the app config. It's async. When a component mounts — especially on a fast navigation or a cold load — ",[52,6700,6590],{}," may not exist yet.",[16,6703,6704,6705,6708,6709,6711],{},"Calling ",[52,6706,6707],{},"push()"," against an undefined ",[52,6710,6590],{}," silently fails. No error, no ad, no indication of what happened.",[16,6713,6714],{},"The defensive approach is to poll until the SDK is available, with a timeout:",[332,6716,6718],{"className":5574,"code":6717,"language":5576,"meta":337,"style":337},"function waitForAdsbygoogle(timeout = 8000): Promise\u003Cvoid> {\n  return new Promise((resolve, reject) => {\n    if (window.adsbygoogle) return resolve()\n    const start = Date.now()\n    const timer = setInterval(() => {\n      if (window.adsbygoogle) {\n        clearInterval(timer)\n        resolve()\n      } else if (Date.now() - start > timeout) {\n        clearInterval(timer)\n        reject(new Error('adsbygoogle SDK load timeout'))\n      }\n    }, 100)\n  })\n}\n",[52,6719,6720,6749,6771,6783,6797,6813,6819,6825,6831,6853,6859,6876,6880,6888,6892],{"__ignoreMap":337},[341,6721,6722,6724,6726,6728,6730,6732,6734,6737,6739,6741,6743,6746],{"class":343,"line":344},[341,6723,3102],{"class":1080},[341,6725,3105],{"class":679},[341,6727,2977],{"class":347},[341,6729,3110],{"class":1869},[341,6731,1230],{"class":1080},[341,6733,3115],{"class":354},[341,6735,6736],{"class":347},")",[341,6738,562],{"class":1080},[341,6740,3128],{"class":679},[341,6742,673],{"class":347},[341,6744,6745],{"class":354},"void",[341,6747,6748],{"class":347},"> {\n",[341,6750,6751,6753,6755,6757,6759,6761,6763,6765,6767,6769],{"class":343,"line":351},[341,6752,2245],{"class":1080},[341,6754,3125],{"class":1080},[341,6756,3128],{"class":354},[341,6758,3131],{"class":347},[341,6760,3134],{"class":1869},[341,6762,1885],{"class":347},[341,6764,3139],{"class":1869},[341,6766,1873],{"class":347},[341,6768,1876],{"class":1080},[341,6770,1233],{"class":347},[341,6772,6773,6775,6777,6779,6781],{"class":343,"line":368},[341,6774,3151],{"class":1080},[341,6776,3154],{"class":347},[341,6778,2735],{"class":1080},[341,6780,3159],{"class":679},[341,6782,2000],{"class":347},[341,6784,6785,6787,6789,6791,6793,6795],{"class":343,"line":381},[341,6786,3167],{"class":1080},[341,6788,3170],{"class":354},[341,6790,1230],{"class":1080},[341,6792,3175],{"class":347},[341,6794,3178],{"class":679},[341,6796,2000],{"class":347},[341,6798,6799,6801,6803,6805,6807,6809,6811],{"class":343,"line":390},[341,6800,3167],{"class":1080},[341,6802,3188],{"class":354},[341,6804,1230],{"class":1080},[341,6806,3193],{"class":679},[341,6808,2235],{"class":347},[341,6810,1876],{"class":1080},[341,6812,1233],{"class":347},[341,6814,6815,6817],{"class":343,"line":396},[341,6816,3205],{"class":1080},[341,6818,3208],{"class":347},[341,6820,6821,6823],{"class":343,"line":409},[341,6822,3214],{"class":679},[341,6824,3217],{"class":347},[341,6826,6827,6829],{"class":343,"line":420},[341,6828,3223],{"class":679},[341,6830,2000],{"class":347},[341,6832,6833,6835,6837,6839,6841,6843,6845,6847,6849,6851],{"class":343,"line":426},[341,6834,3231],{"class":347},[341,6836,2607],{"class":1080},[341,6838,3236],{"class":1080},[341,6840,3239],{"class":347},[341,6842,3178],{"class":679},[341,6844,3244],{"class":347},[341,6846,3247],{"class":1080},[341,6848,3250],{"class":347},[341,6850,3253],{"class":1080},[341,6852,3256],{"class":347},[341,6854,6855,6857],{"class":343,"line":432},[341,6856,3214],{"class":679},[341,6858,3217],{"class":347},[341,6860,6861,6863,6865,6867,6869,6871,6874],{"class":343,"line":1346},[341,6862,3269],{"class":679},[341,6864,2977],{"class":347},[341,6866,3274],{"class":1080},[341,6868,3277],{"class":679},[341,6870,2977],{"class":347},[341,6872,6873],{"class":361},"'adsbygoogle SDK load timeout'",[341,6875,3285],{"class":347},[341,6877,6878],{"class":343,"line":1351},[341,6879,2886],{"class":347},[341,6881,6882,6884,6886],{"class":343,"line":1357},[341,6883,3296],{"class":347},[341,6885,3299],{"class":354},[341,6887,2039],{"class":347},[341,6889,6890],{"class":343,"line":1371},[341,6891,3307],{"class":347},[341,6893,6894],{"class":343,"line":1384},[341,6895,435],{"class":347},[16,6897,6898],{},"8 seconds is conservative. In practice on BulkPicTools the SDK loads in under 500ms on a normal connection. The timeout matters for users on very slow connections — rather than the component hanging indefinitely, it fails gracefully and hides the skeleton.",[305,6900,6902],{"id":6901},"problem-3-skeleton-screen-timing","Problem 3: skeleton screen timing",[16,6904,6905,6906,6909,6910,6912,6913,6915],{},"The first version of the component set ",[52,6907,6908],{},"isLoaded = true"," immediately after ",[52,6911,6707],{}," returned. ",[52,6914,6707],{}," is synchronous and returns before AdSense has actually fetched or rendered anything. The result: the skeleton screen disappears, then there's a noticeable blank gap, then the ad appears. On slower connections this gap is several seconds wide.",[16,6917,6918,6919,6922,6923,6925,6926,6929,6930,6933,6934,6937],{},"The right signal is ",[52,6920,6921],{},"data-ad-status",", an attribute AdSense sets on the ",[52,6924,1064],{}," element once it has determined the outcome — either ",[52,6927,6928],{},"filled"," (an ad was served) or ",[52,6931,6932],{},"unfilled"," (no ad available for this slot right now). Watching for that attribute with a ",[52,6935,6936],{},"MutationObserver"," gives the precise moment to hide the skeleton:",[332,6939,6941],{"className":5574,"code":6940,"language":5576,"meta":337,"style":337},"function watchAdStatus(ins: HTMLElement) {\n  statusObserver = new MutationObserver(() => {\n    const status = ins.getAttribute('data-ad-status')\n    if (status === 'filled' || status === 'unfilled') {\n      adStatus.value = status as AdStatus\n      isLoaded.value = true\n      statusObserver?.disconnect()\n      if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null }\n    }\n  })\n  statusObserver.observe(ins, { attributes: true, attributeFilter: ['data-ad-status'] })\n\n  \u002F\u002F Fallback: if AdSense never sets the attribute, give up after 5s\n  fallbackTimer = setTimeout(() => {\n    if (!isLoaded.value) isLoaded.value = true\n  }, 5000)\n}\n",[52,6942,6943,6961,6977,6996,7016,7030,7038,7046,7062,7066,7070,7087,7091,7096,7110,7124,7132],{"__ignoreMap":337},[341,6944,6945,6947,6949,6951,6954,6956,6959],{"class":343,"line":344},[341,6946,3102],{"class":1080},[341,6948,3325],{"class":679},[341,6950,2977],{"class":347},[341,6952,6953],{"class":1869},"ins",[341,6955,562],{"class":1080},[341,6957,6958],{"class":679}," HTMLElement",[341,6960,2566],{"class":347},[341,6962,6963,6965,6967,6969,6971,6973,6975],{"class":343,"line":351},[341,6964,3338],{"class":347},[341,6966,683],{"class":1080},[341,6968,3125],{"class":1080},[341,6970,3345],{"class":679},[341,6972,2235],{"class":347},[341,6974,1876],{"class":1080},[341,6976,1233],{"class":347},[341,6978,6979,6981,6983,6985,6988,6990,6992,6994],{"class":343,"line":368},[341,6980,3167],{"class":1080},[341,6982,3359],{"class":354},[341,6984,1230],{"class":1080},[341,6986,6987],{"class":347}," ins.",[341,6989,3367],{"class":679},[341,6991,2977],{"class":347},[341,6993,3372],{"class":361},[341,6995,2039],{"class":347},[341,6997,6998,7000,7002,7004,7006,7008,7010,7012,7014],{"class":343,"line":381},[341,6999,3151],{"class":1080},[341,7001,3382],{"class":347},[341,7003,2560],{"class":1080},[341,7005,3387],{"class":361},[341,7007,2674],{"class":1080},[341,7009,3392],{"class":347},[341,7011,2560],{"class":1080},[341,7013,3397],{"class":361},[341,7015,2566],{"class":347},[341,7017,7018,7020,7022,7024,7027],{"class":343,"line":390},[341,7019,3405],{"class":347},[341,7021,683],{"class":1080},[341,7023,3392],{"class":347},[341,7025,7026],{"class":1080},"as",[341,7028,7029],{"class":679}," AdStatus\n",[341,7031,7032,7034,7036],{"class":343,"line":396},[341,7033,3416],{"class":347},[341,7035,683],{"class":1080},[341,7037,3421],{"class":354},[341,7039,7040,7042,7044],{"class":343,"line":409},[341,7041,3427],{"class":347},[341,7043,3430],{"class":679},[341,7045,2000],{"class":347},[341,7047,7048,7050,7052,7054,7056,7058,7060],{"class":343,"line":420},[341,7049,3205],{"class":1080},[341,7051,3450],{"class":347},[341,7053,3453],{"class":679},[341,7055,3456],{"class":347},[341,7057,683],{"class":1080},[341,7059,3461],{"class":354},[341,7061,2548],{"class":347},[341,7063,7064],{"class":343,"line":426},[341,7065,423],{"class":347},[341,7067,7068],{"class":343,"line":432},[341,7069,3307],{"class":347},[341,7071,7072,7074,7076,7079,7081,7083,7085],{"class":343,"line":1346},[341,7073,3479],{"class":347},[341,7075,3482],{"class":679},[341,7077,7078],{"class":347},"(ins, { attributes: ",[341,7080,3488],{"class":354},[341,7082,3491],{"class":347},[341,7084,3372],{"class":361},[341,7086,3496],{"class":347},[341,7088,7089],{"class":343,"line":1351},[341,7090,703],{"emptyLinePlaceholder":702},[341,7092,7093],{"class":343,"line":1357},[341,7094,7095],{"class":667},"  \u002F\u002F Fallback: if AdSense never sets the attribute, give up after 5s\n",[341,7097,7098,7100,7102,7104,7106,7108],{"class":343,"line":1371},[341,7099,3507],{"class":347},[341,7101,683],{"class":1080},[341,7103,3512],{"class":679},[341,7105,2235],{"class":347},[341,7107,1876],{"class":1080},[341,7109,1233],{"class":347},[341,7111,7112,7114,7116,7118,7120,7122],{"class":343,"line":1384},[341,7113,3151],{"class":1080},[341,7115,2587],{"class":347},[341,7117,2712],{"class":1080},[341,7119,3530],{"class":347},[341,7121,683],{"class":1080},[341,7123,3421],{"class":354},[341,7125,7126,7128,7130],{"class":343,"line":1397},[341,7127,3540],{"class":347},[341,7129,3543],{"class":354},[341,7131,2039],{"class":347},[341,7133,7134],{"class":343,"line":1402},[341,7135,435],{"class":347},[16,7137,7138,7139,7141],{},"The 5-second fallback handles edge cases where the attribute never gets set — ad blockers that partially intercept the SDK, certain browser extensions, or network errors that leave ",[52,7140,6707],{}," in a half-executed state.",[16,7143,7144,7145,7148,7149,7151],{},"When ",[52,7146,7147],{},"adStatus"," is ",[52,7150,6932],{},", the component keeps a faint \"Advertisement\" placeholder visible instead of collapsing to zero height. A sudden height collapse causes layout shift for content below the ad — a CLS hit that's worse than showing an empty placeholder.",[305,7153,7155],{"id":7154},"complete-adadsensevue","Complete AdAdsense.vue",[332,7157,7159],{"className":986,"code":7158,"language":988,"meta":337,"style":337},"\u003Ctemplate>\n  \u003Cdiv ref=\"containerRef\" class=\"w-full flex justify-center\" :class=\"customClass\">\n    \u003Cdiv\n      class=\"relative flex items-center justify-center overflow-hidden rounded-lg border border-dashed\n             transition-all duration-300 bg-surface-100 border-surface-200\n             dark:bg-surface-900 dark:border-surface-700\u002F50\"\n      :style=\"wrapperStyle\"\n    >\n      \u003CTransition name=\"fade\">\n        \u003Cdiv\n          v-if=\"!isLoaded\"\n          class=\"absolute inset-0 flex flex-col items-center justify-center gap-2 select-none\"\n        >\n          \u003Cspan class=\"text-[10px] font-bold uppercase tracking-[0.2em] text-surface-400 dark:text-surface-600\">\n            Advertisement\n          \u003C\u002Fspan>\n          \u003CIcon name=\"lucide:monitor\" class=\"h-5 w-5 text-surface-300 dark:text-surface-700\" \u002F>\n        \u003C\u002Fdiv>\n      \u003C\u002FTransition>\n      \u003CTransition name=\"fade\">\n        \u003Cdiv\n          v-if=\"isLoaded && adStatus === 'unfilled'\"\n          class=\"absolute inset-0 flex flex-col items-center justify-center gap-1 select-none pointer-events-none\"\n        >\n          \u003Cspan class=\"text-[10px] font-bold uppercase tracking-[0.2em] text-surface-300 dark:text-surface-700\">\n            Advertisement\n          \u003C\u002Fspan>\n        \u003C\u002Fdiv>\n      \u003C\u002FTransition>\n      \u003Cdiv ref=\"adRef\" class=\"relative z-10 w-full h-full\" \u002F>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup lang=\"ts\">\ntype AdType = 'banner' | 'rectangle' | 'responsive' | 'in-article' | 'in-feed' | 'multiplex'\ntype AdStatus = 'filled' | 'unfilled' | ''\n\nconst props = defineProps\u003C{\n  type?: AdType\n  width?: string\n  height?: string\n  customClass?: string\n  adsenseSlotId: string\n  layoutKey?: string  \u002F\u002F required for in-feed only\n}>()\n\nwithDefaults(defineProps\u003C{ type?: AdType }>(), { type: 'responsive' })\n\nconst runtimeConfig = useRuntimeConfig()\nconst adsenseClientId = computed(() => runtimeConfig.public?.ads?.googleAdSenseId ?? '')\n\nconst SIZE_MAP: Record\u003CAdType, { width: string; height: string }> = {\n  banner:      { width: '728px', height: '90px' },\n  rectangle:   { width: '300px', height: '250px' },\n  responsive:  { width: '100%',  height: 'auto' },\n  'in-article': { width: '100%', height: 'auto' },\n  'in-feed':   { width: '100%',  height: 'auto' },\n  multiplex:   { width: '100%',  height: 'auto' },\n}\n\nconst MIN_HEIGHT: Partial\u003CRecord\u003CAdType, string>> = {\n  responsive:   '90px',\n  'in-article': '250px',\n  'in-feed':    '150px',\n  multiplex:    '280px',\n}\n\nconst containerWidth  = computed(() => props.width  ?? SIZE_MAP[props.type ?? 'responsive'].width)\nconst containerHeight = computed(() => props.height ?? SIZE_MAP[props.type ?? 'responsive'].height)\n\nconst wrapperStyle = computed(() => {\n  const style: Record\u003Cstring, string> = { width: containerWidth.value, maxWidth: '100%' }\n  if (containerHeight.value === 'auto') {\n    style.minHeight = isLoaded.value ? 'auto' : (MIN_HEIGHT[props.type ?? 'responsive'] ?? '90px')\n  } else {\n    style.height = containerHeight.value\n  }\n  return style\n})\n\nconst containerRef = ref\u003CHTMLElement | null>(null)\nconst adRef        = ref\u003CHTMLElement | null>(null)\nconst isLoaded     = ref(false)\nconst adStatus     = ref\u003CAdStatus>('')\n\nlet intersectionObserver: IntersectionObserver | null = null\nlet statusObserver: MutationObserver | null = null\nlet fallbackTimer: ReturnType\u003Ctypeof setTimeout> | null = null\n\nfunction waitForAdsbygoogle(timeout = 8000): Promise\u003Cvoid> {\n  return new Promise((resolve, reject) => {\n    if ((window as any).adsbygoogle) return resolve()\n    const start = Date.now()\n    const timer = setInterval(() => {\n      if ((window as any).adsbygoogle) { clearInterval(timer); resolve() }\n      else if (Date.now() - start > timeout) { clearInterval(timer); reject(new Error('adsbygoogle SDK load timeout')) }\n    }, 100)\n  })\n}\n\nfunction watchAdStatus(ins: HTMLElement) {\n  statusObserver = new MutationObserver(() => {\n    const status = ins.getAttribute('data-ad-status') as AdStatus\n    if (status === 'filled' || status === 'unfilled') {\n      adStatus.value = status\n      isLoaded.value = true\n      statusObserver?.disconnect(); statusObserver = null\n      if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null }\n    }\n  })\n  statusObserver.observe(ins, { attributes: true, attributeFilter: ['data-ad-status'] })\n  fallbackTimer = setTimeout(() => { if (!isLoaded.value) isLoaded.value = true }, 5000)\n}\n\nfunction buildIns(): HTMLElement {\n  const ins = document.createElement('ins')\n  ins.className = 'adsbygoogle'\n  ins.style.display = 'block'\n  ins.dataset.adClient = adsenseClientId.value\n  ins.dataset.adSlot   = props.adsenseSlotId\n\n  const t = props.type ?? 'responsive'\n  if (t === 'in-article') {\n    ins.style.textAlign  = 'center'\n    ins.dataset.adFormat = 'fluid'\n    ins.dataset.adLayout = 'in-article'\n  } else if (t === 'in-feed') {\n    ins.dataset.adFormat    = 'fluid'\n    ins.dataset.adLayoutKey = props.layoutKey ?? ''\n    if (import.meta.dev && !props.layoutKey) console.warn('[AdAdsense] layoutKey is required for in-feed ads')\n  } else if (t === 'multiplex') {\n    ins.dataset.adFormat = 'autorelaxed'\n  } else if (t === 'responsive') {\n    ins.style.width = '100%'\n    ins.dataset.adFormat            = 'auto'\n    ins.dataset.fullWidthResponsive = 'true'\n  } else {\n    ins.style.width  = containerWidth.value\n    ins.style.height = containerHeight.value\n  }\n  return ins\n}\n\nasync function loadAd() {\n  if (!adsenseClientId.value || !props.adsenseSlotId) {\n    if (import.meta.dev) console.warn('[AdAdsense] missing clientId or slotId')\n    return\n  }\n  if (!adRef.value) return\n\n  try {\n    await waitForAdsbygoogle()\n  } catch (e) {\n    if (import.meta.dev) console.error('[AdAdsense]', (e as Error).message)\n    isLoaded.value = true\n    return\n  }\n\n  adRef.value.innerHTML = ''\n  adStatus.value = ''\n\n  const ins = buildIns()\n  adRef.value.appendChild(ins)\n  watchAdStatus(ins)\n\n  try {\n    ;((window as any).adsbygoogle = (window as any).adsbygoogle || []).push({})\n  } catch (e) {\n    if (import.meta.dev) console.error('[AdAdsense] push failed:', e)\n    isLoaded.value = true\n  }\n}\n\nfunction initObserver() {\n  if (!('IntersectionObserver' in window)) { loadAd(); return }\n  intersectionObserver = new IntersectionObserver(\n    entries => {\n      if (entries[0]?.isIntersecting) {\n        loadAd()\n        intersectionObserver?.disconnect(); intersectionObserver = null\n      }\n    },\n    { rootMargin: '200px 0px' }\n  )\n  if (containerRef.value) intersectionObserver.observe(containerRef.value)\n}\n\nonMounted(() => { if (import.meta.client) initObserver() })\n\nonBeforeUnmount(() => {\n  intersectionObserver?.disconnect(); intersectionObserver = null\n  statusObserver?.disconnect(); statusObserver = null\n  if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null }\n  if (adRef.value) adRef.value.innerHTML = ''\n})\n\u003C\u002Fscript>\n\n\u003Cstyle scoped>\n.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }\n.fade-enter-from, .fade-leave-to { opacity: 0; }\n\u003C\u002Fstyle>\n",[52,7160,7161,7169,7196,7202,7211,7216,7221,7229,7233,7247,7253,7261,7269,7273,7287,7291,7299,7319,7327,7335,7349,7355,7363,7371,7375,7389,7393,7401,7409,7417,7439,7447,7455,7463,7467,7483,7517,7536,7540,7553,7564,7574,7583,7592,7601,7614,7619,7623,7648,7652,7664,7687,7691,7732,7745,7758,7772,7786,7801,7814,7818,7822,7855,7864,7874,7884,7892,7896,7900,7930,7959,7963,7979,8008,8020,8054,8062,8070,8074,8080,8084,8088,8114,8139,8156,8177,8181,8200,8219,8248,8252,8278,8300,8321,8335,8351,8375,8416,8424,8428,8432,8436,8452,8468,8490,8510,8518,8526,8539,8555,8559,8563,8579,8614,8618,8622,8638,8656,8665,8673,8681,8690,8694,8711,8724,8733,8741,8749,8765,8774,8788,8820,8836,8844,8860,8868,8877,8886,8894,8903,8911,8915,8921,8925,8929,8939,8955,8978,8982,8986,8998,9002,9008,9016,9024,9054,9062,9066,9070,9074,9082,9090,9094,9106,9114,9120,9124,9130,9161,9169,9192,9200,9204,9208,9212,9220,9244,9256,9266,9276,9282,9295,9299,9303,9311,9315,9325,9329,9333,9362,9366,9376,9388,9400,9416,9426,9430,9438,9442,9453,9483,9504],{"__ignoreMap":337},[341,7162,7163,7165,7167],{"class":343,"line":344},[341,7164,673],{"class":347},[341,7166,1416],{"class":676},[341,7168,1419],{"class":347},[341,7170,7171,7173,7175,7177,7179,7181,7183,7185,7188,7190,7192,7194],{"class":343,"line":351},[341,7172,1424],{"class":347},[341,7174,1427],{"class":676},[341,7176,1430],{"class":679},[341,7178,683],{"class":347},[341,7180,1435],{"class":361},[341,7182,1438],{"class":679},[341,7184,683],{"class":347},[341,7186,7187],{"class":361},"\"w-full flex justify-center\"",[341,7189,1446],{"class":679},[341,7191,683],{"class":347},[341,7193,1451],{"class":361},[341,7195,1419],{"class":347},[341,7197,7198,7200],{"class":343,"line":368},[341,7199,1458],{"class":347},[341,7201,1461],{"class":676},[341,7203,7204,7206,7208],{"class":343,"line":381},[341,7205,1466],{"class":679},[341,7207,683],{"class":347},[341,7209,7210],{"class":361},"\"relative flex items-center justify-center overflow-hidden rounded-lg border border-dashed\n",[341,7212,7213],{"class":343,"line":390},[341,7214,7215],{"class":361},"             transition-all duration-300 bg-surface-100 border-surface-200\n",[341,7217,7218],{"class":343,"line":396},[341,7219,7220],{"class":361},"             dark:bg-surface-900 dark:border-surface-700\u002F50\"\n",[341,7222,7223,7225,7227],{"class":343,"line":409},[341,7224,1476],{"class":679},[341,7226,683],{"class":347},[341,7228,1481],{"class":361},[341,7230,7231],{"class":343,"line":420},[341,7232,1486],{"class":347},[341,7234,7235,7237,7239,7241,7243,7245],{"class":343,"line":426},[341,7236,1491],{"class":347},[341,7238,1494],{"class":676},[341,7240,1497],{"class":679},[341,7242,683],{"class":347},[341,7244,1502],{"class":361},[341,7246,1419],{"class":347},[341,7248,7249,7251],{"class":343,"line":432},[341,7250,1509],{"class":347},[341,7252,1461],{"class":676},[341,7254,7255,7257,7259],{"class":343,"line":1346},[341,7256,1516],{"class":679},[341,7258,683],{"class":347},[341,7260,1521],{"class":361},[341,7262,7263,7265,7267],{"class":343,"line":1351},[341,7264,1526],{"class":679},[341,7266,683],{"class":347},[341,7268,1531],{"class":361},[341,7270,7271],{"class":343,"line":1357},[341,7272,1536],{"class":347},[341,7274,7275,7277,7279,7281,7283,7285],{"class":343,"line":1371},[341,7276,1541],{"class":347},[341,7278,341],{"class":676},[341,7280,1438],{"class":679},[341,7282,683],{"class":347},[341,7284,1550],{"class":361},[341,7286,1419],{"class":347},[341,7288,7289],{"class":343,"line":1384},[341,7290,1557],{"class":347},[341,7292,7293,7295,7297],{"class":343,"line":1397},[341,7294,1562],{"class":347},[341,7296,341],{"class":676},[341,7298,1419],{"class":347},[341,7300,7301,7303,7305,7307,7309,7311,7313,7315,7317],{"class":343,"line":1402},[341,7302,1541],{"class":347},[341,7304,1573],{"class":676},[341,7306,1497],{"class":679},[341,7308,683],{"class":347},[341,7310,1580],{"class":361},[341,7312,1438],{"class":679},[341,7314,683],{"class":347},[341,7316,1587],{"class":361},[341,7318,697],{"class":347},[341,7320,7321,7323,7325],{"class":343,"line":1610},[341,7322,1594],{"class":347},[341,7324,1427],{"class":676},[341,7326,1419],{"class":347},[341,7328,7329,7331,7333],{"class":343,"line":1625},[341,7330,1603],{"class":347},[341,7332,1494],{"class":676},[341,7334,1419],{"class":347},[341,7336,7337,7339,7341,7343,7345,7347],{"class":343,"line":1632},[341,7338,1491],{"class":347},[341,7340,1494],{"class":676},[341,7342,1497],{"class":679},[341,7344,683],{"class":347},[341,7346,1502],{"class":361},[341,7348,1419],{"class":347},[341,7350,7351,7353],{"class":343,"line":1642},[341,7352,1509],{"class":347},[341,7354,1461],{"class":676},[341,7356,7357,7359,7361],{"class":343,"line":1652},[341,7358,1516],{"class":679},[341,7360,683],{"class":347},[341,7362,1639],{"class":361},[341,7364,7365,7367,7369],{"class":343,"line":1657},[341,7366,1526],{"class":679},[341,7368,683],{"class":347},[341,7370,1649],{"class":361},[341,7372,7373],{"class":343,"line":1673},[341,7374,1536],{"class":347},[341,7376,7377,7379,7381,7383,7385,7387],{"class":343,"line":1678},[341,7378,1541],{"class":347},[341,7380,341],{"class":676},[341,7382,1438],{"class":679},[341,7384,683],{"class":347},[341,7386,1668],{"class":361},[341,7388,1419],{"class":347},[341,7390,7391],{"class":343,"line":1687},[341,7392,1557],{"class":347},[341,7394,7395,7397,7399],{"class":343,"line":1696},[341,7396,1562],{"class":347},[341,7398,341],{"class":676},[341,7400,1419],{"class":347},[341,7402,7403,7405,7407],{"class":343,"line":1705},[341,7404,1594],{"class":347},[341,7406,1427],{"class":676},[341,7408,1419],{"class":347},[341,7410,7411,7413,7415],{"class":343,"line":1732},[341,7412,1603],{"class":347},[341,7414,1494],{"class":676},[341,7416,1419],{"class":347},[341,7418,7419,7421,7423,7425,7427,7429,7431,7433,7435,7437],{"class":343,"line":1742},[341,7420,1491],{"class":347},[341,7422,1427],{"class":676},[341,7424,1430],{"class":679},[341,7426,683],{"class":347},[341,7428,1716],{"class":361},[341,7430,1438],{"class":679},[341,7432,683],{"class":347},[341,7434,1723],{"class":361},[341,7436,1727],{"class":1726},[341,7438,1419],{"class":347},[341,7440,7441,7443,7445],{"class":343,"line":1752},[341,7442,1735],{"class":347},[341,7444,1427],{"class":676},[341,7446,1419],{"class":347},[341,7448,7449,7451,7453],{"class":343,"line":1762},[341,7450,1745],{"class":347},[341,7452,1427],{"class":676},[341,7454,1419],{"class":347},[341,7456,7457,7459,7461],{"class":343,"line":1767},[341,7458,1755],{"class":347},[341,7460,1416],{"class":676},[341,7462,1419],{"class":347},[341,7464,7465],{"class":343,"line":1780},[341,7466,703],{"emptyLinePlaceholder":702},[341,7468,7469,7471,7473,7475,7477,7479,7481],{"class":343,"line":1786},[341,7470,673],{"class":347},[341,7472,1772],{"class":676},[341,7474,1775],{"class":679},[341,7476,6423],{"class":679},[341,7478,683],{"class":347},[341,7480,6428],{"class":361},[341,7482,1419],{"class":347},[341,7484,7485,7487,7490,7492,7495,7497,7500,7502,7504,7506,7508,7510,7512,7514],{"class":343,"line":1792},[341,7486,1214],{"class":1080},[341,7488,7489],{"class":679}," AdType",[341,7491,1230],{"class":1080},[341,7493,7494],{"class":361}," 'banner'",[341,7496,6085],{"class":1080},[341,7498,7499],{"class":361}," 'rectangle'",[341,7501,6085],{"class":1080},[341,7503,3848],{"class":361},[341,7505,6085],{"class":1080},[341,7507,3660],{"class":361},[341,7509,6085],{"class":1080},[341,7511,3711],{"class":361},[341,7513,6085],{"class":1080},[341,7515,7516],{"class":361}," 'multiplex'\n",[341,7518,7519,7521,7524,7526,7528,7530,7532,7534],{"class":343,"line":1798},[341,7520,1214],{"class":1080},[341,7522,7523],{"class":679}," AdStatus",[341,7525,1230],{"class":1080},[341,7527,3387],{"class":361},[341,7529,6085],{"class":1080},[341,7531,3397],{"class":361},[341,7533,6085],{"class":1080},[341,7535,2033],{"class":361},[341,7537,7538],{"class":343,"line":1804},[341,7539,703],{"emptyLinePlaceholder":702},[341,7541,7542,7544,7546,7548,7550],{"class":343,"line":1810},[341,7543,1224],{"class":1080},[341,7545,1826],{"class":354},[341,7547,1230],{"class":1080},[341,7549,1831],{"class":679},[341,7551,7552],{"class":347},"\u003C{\n",[341,7554,7555,7558,7561],{"class":343,"line":1816},[341,7556,7557],{"class":1869},"  type",[341,7559,7560],{"class":1080},"?:",[341,7562,7563],{"class":679}," AdType\n",[341,7565,7566,7569,7571],{"class":343,"line":1821},[341,7567,7568],{"class":1869},"  width",[341,7570,7560],{"class":1080},[341,7572,7573],{"class":354}," string\n",[341,7575,7576,7579,7581],{"class":343,"line":1837},[341,7577,7578],{"class":1869},"  height",[341,7580,7560],{"class":1080},[341,7582,7573],{"class":354},[341,7584,7585,7588,7590],{"class":343,"line":1843},[341,7586,7587],{"class":1869},"  customClass",[341,7589,7560],{"class":1080},[341,7591,7573],{"class":354},[341,7593,7594,7597,7599],{"class":343,"line":1849},[341,7595,7596],{"class":1869},"  adsenseSlotId",[341,7598,562],{"class":1080},[341,7600,7573],{"class":354},[341,7602,7603,7606,7608,7611],{"class":343,"line":1860},[341,7604,7605],{"class":1869},"  layoutKey",[341,7607,7560],{"class":1080},[341,7609,7610],{"class":354}," string",[341,7612,7613],{"class":667},"  \u002F\u002F required for in-feed only\n",[341,7615,7616],{"class":343,"line":1919},[341,7617,7618],{"class":347},"}>()\n",[341,7620,7621],{"class":343,"line":1924},[341,7622,703],{"emptyLinePlaceholder":702},[341,7624,7625,7628,7630,7632,7635,7637,7639,7641,7644,7646],{"class":343,"line":1935},[341,7626,7627],{"class":679},"withDefaults",[341,7629,2977],{"class":347},[341,7631,4766],{"class":679},[341,7633,7634],{"class":347},"\u003C{ ",[341,7636,1214],{"class":1869},[341,7638,7560],{"class":1080},[341,7640,7489],{"class":679},[341,7642,7643],{"class":347}," }>(), { type: ",[341,7645,1855],{"class":361},[341,7647,5654],{"class":347},[341,7649,7650],{"class":343,"line":1945},[341,7651,703],{"emptyLinePlaceholder":702},[341,7653,7654,7656,7658,7660,7662],{"class":343,"line":1956},[341,7655,1224],{"class":1080},[341,7657,1992],{"class":354},[341,7659,1230],{"class":1080},[341,7661,1997],{"class":679},[341,7663,2000],{"class":347},[341,7665,7666,7668,7670,7672,7674,7676,7678,7680,7682,7685],{"class":343,"line":1966},[341,7667,1224],{"class":1080},[341,7669,2008],{"class":354},[341,7671,1230],{"class":1080},[341,7673,2013],{"class":679},[341,7675,2235],{"class":347},[341,7677,1876],{"class":1080},[341,7679,2027],{"class":347},[341,7681,2030],{"class":1080},[341,7683,7684],{"class":361}," ''",[341,7686,2039],{"class":347},[341,7688,7689],{"class":343,"line":1976},[341,7690,703],{"emptyLinePlaceholder":702},[341,7692,7693,7695,7697,7699,7702,7704,7707,7710,7712,7714,7716,7719,7721,7723,7725,7728,7730],{"class":343,"line":1982},[341,7694,1224],{"class":1080},[341,7696,2276],{"class":354},[341,7698,562],{"class":1080},[341,7700,7701],{"class":679}," Record",[341,7703,673],{"class":347},[341,7705,7706],{"class":679},"AdType",[341,7708,7709],{"class":347},", { ",[341,7711,2809],{"class":1869},[341,7713,562],{"class":1080},[341,7715,7610],{"class":354},[341,7717,7718],{"class":347},"; ",[341,7720,2825],{"class":1869},[341,7722,562],{"class":1080},[341,7724,7610],{"class":354},[341,7726,7727],{"class":347}," }> ",[341,7729,683],{"class":1080},[341,7731,1233],{"class":347},[341,7733,7734,7737,7739,7741,7743],{"class":343,"line":1987},[341,7735,7736],{"class":347},"  banner:      { width: ",[341,7738,1284],{"class":361},[341,7740,1249],{"class":347},[341,7742,1289],{"class":361},[341,7744,1319],{"class":347},[341,7746,7747,7750,7752,7754,7756],{"class":343,"line":2003},[341,7748,7749],{"class":347},"  rectangle:   { width: ",[341,7751,1311],{"class":361},[341,7753,1249],{"class":347},[341,7755,1316],{"class":361},[341,7757,1319],{"class":347},[341,7759,7760,7763,7765,7768,7770],{"class":343,"line":2019},[341,7761,7762],{"class":347},"  responsive:  { width: ",[341,7764,2317],{"class":361},[341,7766,7767],{"class":347},",  height: ",[341,7769,2339],{"class":361},[341,7771,1319],{"class":347},[341,7773,7774,7776,7778,7780,7782,7784],{"class":343,"line":2036},[341,7775,2329],{"class":361},[341,7777,2332],{"class":347},[341,7779,2317],{"class":361},[341,7781,1249],{"class":347},[341,7783,2339],{"class":361},[341,7785,1319],{"class":347},[341,7787,7788,7790,7793,7795,7797,7799],{"class":343,"line":2042},[341,7789,2347],{"class":361},[341,7791,7792],{"class":347},":   { width: ",[341,7794,2317],{"class":361},[341,7796,7767],{"class":347},[341,7798,2339],{"class":361},[341,7800,1319],{"class":347},[341,7802,7803,7806,7808,7810,7812],{"class":343,"line":2047},[341,7804,7805],{"class":347},"  multiplex:   { width: ",[341,7807,2317],{"class":361},[341,7809,7767],{"class":347},[341,7811,2339],{"class":361},[341,7813,1319],{"class":347},[341,7815,7816],{"class":343,"line":2053},[341,7817,435],{"class":347},[341,7819,7820],{"class":343,"line":2064},[341,7821,703],{"emptyLinePlaceholder":702},[341,7823,7824,7826,7829,7831,7834,7836,7839,7841,7843,7845,7848,7851,7853],{"class":343,"line":2069},[341,7825,1224],{"class":1080},[341,7827,7828],{"class":354}," MIN_HEIGHT",[341,7830,562],{"class":1080},[341,7832,7833],{"class":679}," Partial",[341,7835,673],{"class":347},[341,7837,7838],{"class":679},"Record",[341,7840,673],{"class":347},[341,7842,7706],{"class":679},[341,7844,1885],{"class":347},[341,7846,7847],{"class":354},"string",[341,7849,7850],{"class":347},">> ",[341,7852,683],{"class":1080},[341,7854,1233],{"class":347},[341,7856,7857,7860,7862],{"class":343,"line":2083},[341,7858,7859],{"class":347},"  responsive:   ",[341,7861,1289],{"class":361},[341,7863,365],{"class":347},[341,7865,7866,7868,7870,7872],{"class":343,"line":2097},[341,7867,2329],{"class":361},[341,7869,358],{"class":347},[341,7871,1316],{"class":361},[341,7873,365],{"class":347},[341,7875,7876,7878,7880,7882],{"class":343,"line":2110},[341,7877,2347],{"class":361},[341,7879,2485],{"class":347},[341,7881,2488],{"class":361},[341,7883,365],{"class":347},[341,7885,7886,7888,7890],{"class":343,"line":2115},[341,7887,2496],{"class":347},[341,7889,2499],{"class":361},[341,7891,365],{"class":347},[341,7893,7894],{"class":343,"line":2120},[341,7895,435],{"class":347},[341,7897,7898],{"class":343,"line":2133},[341,7899,703],{"emptyLinePlaceholder":702},[341,7901,7902,7904,7906,7908,7910,7912,7914,7916,7918,7920,7923,7925,7927],{"class":343,"line":2146},[341,7903,1224],{"class":1080},[341,7905,2390],{"class":354},[341,7907,2393],{"class":1080},[341,7909,2013],{"class":679},[341,7911,2235],{"class":347},[341,7913,1876],{"class":1080},[341,7915,2402],{"class":347},[341,7917,2030],{"class":1080},[341,7919,2276],{"class":354},[341,7921,7922],{"class":347},"[props.type ",[341,7924,2030],{"class":1080},[341,7926,3848],{"class":361},[341,7928,7929],{"class":347},"].width)\n",[341,7931,7932,7934,7936,7938,7940,7942,7944,7946,7948,7950,7952,7954,7956],{"class":343,"line":2159},[341,7933,1224],{"class":1080},[341,7935,2424],{"class":354},[341,7937,1230],{"class":1080},[341,7939,2013],{"class":679},[341,7941,2235],{"class":347},[341,7943,1876],{"class":1080},[341,7945,2435],{"class":347},[341,7947,2030],{"class":1080},[341,7949,2276],{"class":354},[341,7951,7922],{"class":347},[341,7953,2030],{"class":1080},[341,7955,3848],{"class":361},[341,7957,7958],{"class":347},"].height)\n",[341,7960,7961],{"class":343,"line":2164},[341,7962,703],{"emptyLinePlaceholder":702},[341,7964,7965,7967,7969,7971,7973,7975,7977],{"class":343,"line":2169},[341,7966,1224],{"class":1080},[341,7968,2519],{"class":354},[341,7970,1230],{"class":1080},[341,7972,2013],{"class":679},[341,7974,2235],{"class":347},[341,7976,1876],{"class":1080},[341,7978,1233],{"class":347},[341,7980,7981,7983,7985,7987,7989,7991,7993,7995,7997,8000,8002,8004,8006],{"class":343,"line":2182},[341,7982,2535],{"class":1080},[341,7984,2538],{"class":354},[341,7986,562],{"class":1080},[341,7988,7701],{"class":679},[341,7990,673],{"class":347},[341,7992,7847],{"class":354},[341,7994,1885],{"class":347},[341,7996,7847],{"class":354},[341,7998,7999],{"class":347},"> ",[341,8001,683],{"class":1080},[341,8003,2543],{"class":347},[341,8005,2317],{"class":361},[341,8007,2548],{"class":347},[341,8009,8010,8012,8014,8016,8018],{"class":343,"line":2195},[341,8011,2554],{"class":1080},[341,8013,2557],{"class":347},[341,8015,2560],{"class":1080},[341,8017,2563],{"class":361},[341,8019,2566],{"class":347},[341,8021,8022,8024,8026,8028,8030,8032,8034,8036,8039,8041,8043,8045,8048,8050,8052],{"class":343,"line":2208},[341,8023,2572],{"class":347},[341,8025,683],{"class":1080},[341,8027,2577],{"class":347},[341,8029,889],{"class":1080},[341,8031,2563],{"class":361},[341,8033,2584],{"class":1080},[341,8035,2587],{"class":347},[341,8037,8038],{"class":354},"MIN_HEIGHT",[341,8040,7922],{"class":347},[341,8042,2030],{"class":1080},[341,8044,3848],{"class":361},[341,8046,8047],{"class":347},"] ",[341,8049,2030],{"class":1080},[341,8051,2447],{"class":361},[341,8053,2039],{"class":347},[341,8055,8056,8058,8060],{"class":343,"line":2213},[341,8057,2604],{"class":347},[341,8059,2607],{"class":1080},[341,8061,1233],{"class":347},[341,8063,8064,8066,8068],{"class":343,"line":2218},[341,8065,2615],{"class":347},[341,8067,683],{"class":1080},[341,8069,2620],{"class":347},[341,8071,8072],{"class":343,"line":2223},[341,8073,2626],{"class":347},[341,8075,8076,8078],{"class":343,"line":2242},[341,8077,2245],{"class":1080},[341,8079,2634],{"class":347},[341,8081,8082],{"class":343,"line":2261},[341,8083,1979],{"class":347},[341,8085,8086],{"class":343,"line":2266},[341,8087,703],{"emptyLinePlaceholder":702},[341,8089,8090,8092,8094,8096,8098,8100,8103,8105,8107,8110,8112],{"class":343,"line":2271},[341,8091,1224],{"class":1080},[341,8093,2970],{"class":354},[341,8095,1230],{"class":1080},[341,8097,1430],{"class":679},[341,8099,673],{"class":347},[341,8101,8102],{"class":679},"HTMLElement",[341,8104,6085],{"class":1080},[341,8106,3461],{"class":354},[341,8108,8109],{"class":347},">(",[341,8111,1930],{"class":354},[341,8113,2039],{"class":347},[341,8115,8116,8118,8120,8123,8125,8127,8129,8131,8133,8135,8137],{"class":343,"line":2283},[341,8117,1224],{"class":1080},[341,8119,2989],{"class":354},[341,8121,8122],{"class":1080},"        =",[341,8124,1430],{"class":679},[341,8126,673],{"class":347},[341,8128,8102],{"class":679},[341,8130,6085],{"class":1080},[341,8132,3461],{"class":354},[341,8134,8109],{"class":347},[341,8136,1930],{"class":354},[341,8138,2039],{"class":347},[341,8140,8141,8143,8145,8148,8150,8152,8154],{"class":343,"line":2297},[341,8142,1224],{"class":1080},[341,8144,3007],{"class":354},[341,8146,8147],{"class":1080},"     =",[341,8149,1430],{"class":679},[341,8151,2977],{"class":347},[341,8153,3016],{"class":354},[341,8155,2039],{"class":347},[341,8157,8158,8160,8162,8164,8166,8168,8171,8173,8175],{"class":343,"line":2311},[341,8159,1224],{"class":1080},[341,8161,3026],{"class":354},[341,8163,8147],{"class":1080},[341,8165,1430],{"class":679},[341,8167,673],{"class":347},[341,8169,8170],{"class":679},"AdStatus",[341,8172,8109],{"class":347},[341,8174,1951],{"class":361},[341,8176,2039],{"class":347},[341,8178,8179],{"class":343,"line":2326},[341,8180,703],{"emptyLinePlaceholder":702},[341,8182,8183,8185,8188,8190,8192,8194,8196,8198],{"class":343,"line":2344},[341,8184,3047],{"class":1080},[341,8186,8187],{"class":347}," intersectionObserver",[341,8189,562],{"class":1080},[341,8191,4290],{"class":679},[341,8193,6085],{"class":1080},[341,8195,3461],{"class":354},[341,8197,1230],{"class":1080},[341,8199,3055],{"class":354},[341,8201,8202,8204,8207,8209,8211,8213,8215,8217],{"class":343,"line":2361},[341,8203,3047],{"class":1080},[341,8205,8206],{"class":347}," statusObserver",[341,8208,562],{"class":1080},[341,8210,3345],{"class":679},[341,8212,6085],{"class":1080},[341,8214,3461],{"class":354},[341,8216,1230],{"class":1080},[341,8218,3055],{"class":354},[341,8220,8221,8223,8226,8228,8231,8233,8236,8239,8242,8244,8246],{"class":343,"line":2375},[341,8222,3047],{"class":1080},[341,8224,8225],{"class":347}," fallbackTimer",[341,8227,562],{"class":1080},[341,8229,8230],{"class":679}," ReturnType",[341,8232,673],{"class":347},[341,8234,8235],{"class":1080},"typeof",[341,8237,8238],{"class":347}," setTimeout> ",[341,8240,8241],{"class":1080},"|",[341,8243,3461],{"class":354},[341,8245,1230],{"class":1080},[341,8247,3055],{"class":354},[341,8249,8250],{"class":343,"line":2380},[341,8251,703],{"emptyLinePlaceholder":702},[341,8253,8254,8256,8258,8260,8262,8264,8266,8268,8270,8272,8274,8276],{"class":343,"line":2385},[341,8255,3102],{"class":1080},[341,8257,3105],{"class":679},[341,8259,2977],{"class":347},[341,8261,3110],{"class":1869},[341,8263,1230],{"class":1080},[341,8265,3115],{"class":354},[341,8267,6736],{"class":347},[341,8269,562],{"class":1080},[341,8271,3128],{"class":679},[341,8273,673],{"class":347},[341,8275,6745],{"class":354},[341,8277,6748],{"class":347},[341,8279,8280,8282,8284,8286,8288,8290,8292,8294,8296,8298],{"class":343,"line":2419},[341,8281,2245],{"class":1080},[341,8283,3125],{"class":1080},[341,8285,3128],{"class":354},[341,8287,3131],{"class":347},[341,8289,3134],{"class":1869},[341,8291,1885],{"class":347},[341,8293,3139],{"class":1869},[341,8295,1873],{"class":347},[341,8297,1876],{"class":1080},[341,8299,1233],{"class":347},[341,8301,8302,8304,8307,8309,8312,8315,8317,8319],{"class":343,"line":2452},[341,8303,3151],{"class":1080},[341,8305,8306],{"class":347}," ((window ",[341,8308,7026],{"class":1080},[341,8310,8311],{"class":354}," any",[341,8313,8314],{"class":347},").adsbygoogle) ",[341,8316,2735],{"class":1080},[341,8318,3159],{"class":679},[341,8320,2000],{"class":347},[341,8322,8323,8325,8327,8329,8331,8333],{"class":343,"line":2457},[341,8324,3167],{"class":1080},[341,8326,3170],{"class":354},[341,8328,1230],{"class":1080},[341,8330,3175],{"class":347},[341,8332,3178],{"class":679},[341,8334,2000],{"class":347},[341,8336,8337,8339,8341,8343,8345,8347,8349],{"class":343,"line":2469},[341,8338,3167],{"class":1080},[341,8340,3188],{"class":354},[341,8342,1230],{"class":1080},[341,8344,3193],{"class":679},[341,8346,2235],{"class":347},[341,8348,1876],{"class":1080},[341,8350,1233],{"class":347},[341,8352,8353,8355,8357,8359,8361,8364,8367,8370,8372],{"class":343,"line":2480},[341,8354,3205],{"class":1080},[341,8356,8306],{"class":347},[341,8358,7026],{"class":1080},[341,8360,8311],{"class":354},[341,8362,8363],{"class":347},").adsbygoogle) { ",[341,8365,8366],{"class":679},"clearInterval",[341,8368,8369],{"class":347},"(timer); ",[341,8371,3134],{"class":679},[341,8373,8374],{"class":347},"() }\n",[341,8376,8377,8380,8382,8384,8386,8388,8390,8392,8394,8397,8399,8401,8403,8405,8407,8409,8411,8413],{"class":343,"line":2493},[341,8378,8379],{"class":1080},"      else",[341,8381,3236],{"class":1080},[341,8383,3239],{"class":347},[341,8385,3178],{"class":679},[341,8387,3244],{"class":347},[341,8389,3247],{"class":1080},[341,8391,3250],{"class":347},[341,8393,3253],{"class":1080},[341,8395,8396],{"class":347}," timeout) { ",[341,8398,8366],{"class":679},[341,8400,8369],{"class":347},[341,8402,3139],{"class":679},[341,8404,2977],{"class":347},[341,8406,3274],{"class":1080},[341,8408,3277],{"class":679},[341,8410,2977],{"class":347},[341,8412,6873],{"class":361},[341,8414,8415],{"class":347},")) }\n",[341,8417,8418,8420,8422],{"class":343,"line":2504},[341,8419,3296],{"class":347},[341,8421,3299],{"class":354},[341,8423,2039],{"class":347},[341,8425,8426],{"class":343,"line":2509},[341,8427,3307],{"class":347},[341,8429,8430],{"class":343,"line":2514},[341,8431,435],{"class":347},[341,8433,8434],{"class":343,"line":2532},[341,8435,703],{"emptyLinePlaceholder":702},[341,8437,8438,8440,8442,8444,8446,8448,8450],{"class":343,"line":2551},[341,8439,3102],{"class":1080},[341,8441,3325],{"class":679},[341,8443,2977],{"class":347},[341,8445,6953],{"class":1869},[341,8447,562],{"class":1080},[341,8449,6958],{"class":679},[341,8451,2566],{"class":347},[341,8453,8454,8456,8458,8460,8462,8464,8466],{"class":343,"line":2569},[341,8455,3338],{"class":347},[341,8457,683],{"class":1080},[341,8459,3125],{"class":1080},[341,8461,3345],{"class":679},[341,8463,2235],{"class":347},[341,8465,1876],{"class":1080},[341,8467,1233],{"class":347},[341,8469,8470,8472,8474,8476,8478,8480,8482,8484,8486,8488],{"class":343,"line":2601},[341,8471,3167],{"class":1080},[341,8473,3359],{"class":354},[341,8475,1230],{"class":1080},[341,8477,6987],{"class":347},[341,8479,3367],{"class":679},[341,8481,2977],{"class":347},[341,8483,3372],{"class":361},[341,8485,1873],{"class":347},[341,8487,7026],{"class":1080},[341,8489,7029],{"class":679},[341,8491,8492,8494,8496,8498,8500,8502,8504,8506,8508],{"class":343,"line":2612},[341,8493,3151],{"class":1080},[341,8495,3382],{"class":347},[341,8497,2560],{"class":1080},[341,8499,3387],{"class":361},[341,8501,2674],{"class":1080},[341,8503,3392],{"class":347},[341,8505,2560],{"class":1080},[341,8507,3397],{"class":361},[341,8509,2566],{"class":347},[341,8511,8512,8514,8516],{"class":343,"line":2623},[341,8513,3405],{"class":347},[341,8515,683],{"class":1080},[341,8517,3410],{"class":347},[341,8519,8520,8522,8524],{"class":343,"line":2629},[341,8521,3416],{"class":347},[341,8523,683],{"class":1080},[341,8525,3421],{"class":354},[341,8527,8528,8530,8532,8535,8537],{"class":343,"line":2637},[341,8529,3427],{"class":347},[341,8531,3430],{"class":679},[341,8533,8534],{"class":347},"(); statusObserver ",[341,8536,683],{"class":1080},[341,8538,3055],{"class":354},[341,8540,8541,8543,8545,8547,8549,8551,8553],{"class":343,"line":2642},[341,8542,3205],{"class":1080},[341,8544,3450],{"class":347},[341,8546,3453],{"class":679},[341,8548,3456],{"class":347},[341,8550,683],{"class":1080},[341,8552,3461],{"class":354},[341,8554,2548],{"class":347},[341,8556,8557],{"class":343,"line":2647},[341,8558,423],{"class":347},[341,8560,8561],{"class":343,"line":2682},[341,8562,3307],{"class":347},[341,8564,8565,8567,8569,8571,8573,8575,8577],{"class":343,"line":2687},[341,8566,3479],{"class":347},[341,8568,3482],{"class":679},[341,8570,7078],{"class":347},[341,8572,3488],{"class":354},[341,8574,3491],{"class":347},[341,8576,3372],{"class":361},[341,8578,3496],{"class":347},[341,8580,8581,8583,8585,8587,8589,8591,8593,8596,8598,8600,8602,8604,8607,8610,8612],{"class":343,"line":2705},[341,8582,3507],{"class":347},[341,8584,683],{"class":1080},[341,8586,3512],{"class":679},[341,8588,2235],{"class":347},[341,8590,1876],{"class":1080},[341,8592,2751],{"class":347},[341,8594,8595],{"class":1080},"if",[341,8597,2587],{"class":347},[341,8599,2712],{"class":1080},[341,8601,3530],{"class":347},[341,8603,683],{"class":1080},[341,8605,8606],{"class":354}," true",[341,8608,8609],{"class":347}," }, ",[341,8611,3543],{"class":354},[341,8613,2039],{"class":347},[341,8615,8616],{"class":343,"line":2740},[341,8617,435],{"class":347},[341,8619,8620],{"class":343,"line":2746},[341,8621,703],{"emptyLinePlaceholder":702},[341,8623,8624,8626,8629,8632,8634,8636],{"class":343,"line":2774},[341,8625,3102],{"class":1080},[341,8627,8628],{"class":679}," buildIns",[341,8630,8631],{"class":347},"()",[341,8633,562],{"class":1080},[341,8635,6958],{"class":679},[341,8637,1233],{"class":347},[341,8639,8640,8642,8644,8646,8648,8650,8652,8654],{"class":343,"line":2782},[341,8641,2535],{"class":1080},[341,8643,3574],{"class":354},[341,8645,1230],{"class":1080},[341,8647,3579],{"class":347},[341,8649,3582],{"class":679},[341,8651,2977],{"class":347},[341,8653,3587],{"class":361},[341,8655,2039],{"class":347},[341,8657,8658,8660,8662],{"class":343,"line":2799},[341,8659,3595],{"class":347},[341,8661,683],{"class":1080},[341,8663,8664],{"class":361}," 'adsbygoogle'\n",[341,8666,8667,8669,8671],{"class":343,"line":2815},[341,8668,3615],{"class":347},[341,8670,683],{"class":1080},[341,8672,3620],{"class":361},[341,8674,8675,8677,8679],{"class":343,"line":2830},[341,8676,3626],{"class":347},[341,8678,683],{"class":1080},[341,8680,3631],{"class":347},[341,8682,8683,8686,8688],{"class":343,"line":2835},[341,8684,8685],{"class":347},"  ins.dataset.adSlot   ",[341,8687,683],{"class":1080},[341,8689,3642],{"class":347},[341,8691,8692],{"class":343,"line":2841},[341,8693,703],{"emptyLinePlaceholder":702},[341,8695,8696,8698,8701,8703,8706,8708],{"class":343,"line":2855},[341,8697,2535],{"class":1080},[341,8699,8700],{"class":354}," t",[341,8702,1230],{"class":1080},[341,8704,8705],{"class":347}," props.type ",[341,8707,2030],{"class":1080},[341,8709,8710],{"class":361}," 'responsive'\n",[341,8712,8713,8715,8718,8720,8722],{"class":343,"line":2869},[341,8714,2554],{"class":1080},[341,8716,8717],{"class":347}," (t ",[341,8719,2560],{"class":1080},[341,8721,3660],{"class":361},[341,8723,2566],{"class":347},[341,8725,8726,8729,8731],{"class":343,"line":2883},[341,8727,8728],{"class":347},"    ins.style.textAlign  ",[341,8730,683],{"class":1080},[341,8732,3673],{"class":361},[341,8734,8735,8737,8739],{"class":343,"line":2889},[341,8736,3679],{"class":347},[341,8738,683],{"class":1080},[341,8740,3684],{"class":361},[341,8742,8743,8745,8747],{"class":343,"line":2894},[341,8744,3690],{"class":347},[341,8746,683],{"class":1080},[341,8748,3695],{"class":361},[341,8750,8751,8753,8755,8757,8759,8761,8763],{"class":343,"line":2900},[341,8752,2604],{"class":347},[341,8754,2607],{"class":1080},[341,8756,3236],{"class":1080},[341,8758,8717],{"class":347},[341,8760,2560],{"class":1080},[341,8762,3711],{"class":361},[341,8764,2566],{"class":347},[341,8766,8767,8770,8772],{"class":343,"line":2913},[341,8768,8769],{"class":347},"    ins.dataset.adFormat    ",[341,8771,683],{"class":1080},[341,8773,3684],{"class":361},[341,8775,8776,8779,8781,8784,8786],{"class":343,"line":2926},[341,8777,8778],{"class":347},"    ins.dataset.adLayoutKey ",[341,8780,683],{"class":1080},[341,8782,8783],{"class":347}," props.layoutKey ",[341,8785,2030],{"class":1080},[341,8787,2033],{"class":361},[341,8789,8790,8792,8794,8796,8798,8800,8803,8806,8808,8811,8813,8815,8818],{"class":343,"line":2939},[341,8791,3151],{"class":1080},[341,8793,2587],{"class":347},[341,8795,3766],{"class":1080},[341,8797,816],{"class":347},[341,8799,3771],{"class":354},[341,8801,8802],{"class":347},".dev ",[341,8804,8805],{"class":1080},"&&",[341,8807,3948],{"class":1080},[341,8809,8810],{"class":347},"props.layoutKey) console.",[341,8812,3783],{"class":679},[341,8814,2977],{"class":347},[341,8816,8817],{"class":361},"'[AdAdsense] layoutKey is required for in-feed ads'",[341,8819,2039],{"class":347},[341,8821,8822,8824,8826,8828,8830,8832,8834],{"class":343,"line":2944},[341,8823,2604],{"class":347},[341,8825,2607],{"class":1080},[341,8827,3236],{"class":1080},[341,8829,8717],{"class":347},[341,8831,2560],{"class":1080},[341,8833,3811],{"class":361},[341,8835,2566],{"class":347},[341,8837,8838,8840,8842],{"class":343,"line":2949},[341,8839,3679],{"class":347},[341,8841,683],{"class":1080},[341,8843,3832],{"class":361},[341,8845,8846,8848,8850,8852,8854,8856,8858],{"class":343,"line":2955},[341,8847,2604],{"class":347},[341,8849,2607],{"class":1080},[341,8851,3236],{"class":1080},[341,8853,8717],{"class":347},[341,8855,2560],{"class":1080},[341,8857,3848],{"class":361},[341,8859,2566],{"class":347},[341,8861,8862,8864,8866],{"class":343,"line":2960},[341,8863,3856],{"class":347},[341,8865,683],{"class":1080},[341,8867,1083],{"class":361},[341,8869,8870,8873,8875],{"class":343,"line":2965},[341,8871,8872],{"class":347},"    ins.dataset.adFormat            ",[341,8874,683],{"class":1080},[341,8876,1093],{"class":361},[341,8878,8879,8882,8884],{"class":343,"line":2984},[341,8880,8881],{"class":347},"    ins.dataset.fullWidthResponsive ",[341,8883,683],{"class":1080},[341,8885,1103],{"class":361},[341,8887,8888,8890,8892],{"class":343,"line":3002},[341,8889,2604],{"class":347},[341,8891,2607],{"class":1080},[341,8893,1233],{"class":347},[341,8895,8896,8899,8901],{"class":343,"line":3021},[341,8897,8898],{"class":347},"    ins.style.width  ",[341,8900,683],{"class":1080},[341,8902,3879],{"class":347},[341,8904,8905,8907,8909],{"class":343,"line":3039},[341,8906,3885],{"class":347},[341,8908,683],{"class":1080},[341,8910,2620],{"class":347},[341,8912,8913],{"class":343,"line":3044},[341,8914,2626],{"class":347},[341,8916,8917,8919],{"class":343,"line":3058},[341,8918,2245],{"class":1080},[341,8920,3907],{"class":347},[341,8922,8923],{"class":343,"line":3070},[341,8924,435],{"class":347},[341,8926,8927],{"class":343,"line":3082},[341,8928,703],{"emptyLinePlaceholder":702},[341,8930,8931,8933,8935,8937],{"class":343,"line":3094},[341,8932,3923],{"class":1080},[341,8934,3926],{"class":1080},[341,8936,3929],{"class":679},[341,8938,3566],{"class":347},[341,8940,8941,8943,8945,8947,8949,8951,8953],{"class":343,"line":3099},[341,8942,2554],{"class":1080},[341,8944,2587],{"class":347},[341,8946,2712],{"class":1080},[341,8948,3943],{"class":347},[341,8950,2253],{"class":1080},[341,8952,3948],{"class":1080},[341,8954,3951],{"class":347},[341,8956,8957,8959,8961,8963,8965,8967,8969,8971,8973,8976],{"class":343,"line":3120},[341,8958,3151],{"class":1080},[341,8960,2587],{"class":347},[341,8962,3766],{"class":1080},[341,8964,816],{"class":347},[341,8966,3771],{"class":354},[341,8968,3967],{"class":347},[341,8970,3783],{"class":679},[341,8972,2977],{"class":347},[341,8974,8975],{"class":361},"'[AdAdsense] missing clientId or slotId'",[341,8977,2039],{"class":347},[341,8979,8980],{"class":343,"line":3148},[341,8981,3982],{"class":1080},[341,8983,8984],{"class":343,"line":3164},[341,8985,2626],{"class":347},[341,8987,8988,8990,8992,8994,8996],{"class":343,"line":3183},[341,8989,2554],{"class":1080},[341,8991,2587],{"class":347},[341,8993,2712],{"class":1080},[341,8995,3999],{"class":347},[341,8997,4002],{"class":1080},[341,8999,9000],{"class":343,"line":3202},[341,9001,703],{"emptyLinePlaceholder":702},[341,9003,9004,9006],{"class":343,"line":3211},[341,9005,4013],{"class":1080},[341,9007,1233],{"class":347},[341,9009,9010,9012,9014],{"class":343,"line":3220},[341,9011,4021],{"class":1080},[341,9013,3105],{"class":679},[341,9015,2000],{"class":347},[341,9017,9018,9020,9022],{"class":343,"line":3228},[341,9019,2604],{"class":347},[341,9021,4033],{"class":1080},[341,9023,4036],{"class":347},[341,9025,9026,9028,9030,9032,9034,9036,9038,9040,9042,9044,9047,9049,9051],{"class":343,"line":3259},[341,9027,3151],{"class":1080},[341,9029,2587],{"class":347},[341,9031,3766],{"class":1080},[341,9033,816],{"class":347},[341,9035,3771],{"class":354},[341,9037,3967],{"class":347},[341,9039,4054],{"class":679},[341,9041,2977],{"class":347},[341,9043,4059],{"class":361},[341,9045,9046],{"class":347},", (e ",[341,9048,7026],{"class":1080},[341,9050,3277],{"class":679},[341,9052,9053],{"class":347},").message)\n",[341,9055,9056,9058,9060],{"class":343,"line":3266},[341,9057,4068],{"class":347},[341,9059,683],{"class":1080},[341,9061,3421],{"class":354},[341,9063,9064],{"class":343,"line":3288},[341,9065,3982],{"class":1080},[341,9067,9068],{"class":343,"line":3293},[341,9069,2626],{"class":347},[341,9071,9072],{"class":343,"line":3304},[341,9073,703],{"emptyLinePlaceholder":702},[341,9075,9076,9078,9080],{"class":343,"line":3310},[341,9077,4093],{"class":347},[341,9079,683],{"class":1080},[341,9081,2033],{"class":361},[341,9083,9084,9086,9088],{"class":343,"line":3315},[341,9085,4103],{"class":347},[341,9087,683],{"class":1080},[341,9089,2033],{"class":361},[341,9091,9092],{"class":343,"line":3320},[341,9093,703],{"emptyLinePlaceholder":702},[341,9095,9096,9098,9100,9102,9104],{"class":343,"line":3335},[341,9097,2535],{"class":1080},[341,9099,3574],{"class":354},[341,9101,1230],{"class":1080},[341,9103,8628],{"class":679},[341,9105,2000],{"class":347},[341,9107,9108,9110,9112],{"class":343,"line":3354},[341,9109,4131],{"class":347},[341,9111,4134],{"class":679},[341,9113,4137],{"class":347},[341,9115,9116,9118],{"class":343,"line":3377},[341,9117,4143],{"class":679},[341,9119,4137],{"class":347},[341,9121,9122],{"class":343,"line":3402},[341,9123,703],{"emptyLinePlaceholder":702},[341,9125,9126,9128],{"class":343,"line":3413},[341,9127,4013],{"class":1080},[341,9129,1233],{"class":347},[341,9131,9132,9135,9137,9139,9142,9144,9147,9149,9151,9153,9155,9157,9159],{"class":343,"line":3424},[341,9133,9134],{"class":347},"    ;((window ",[341,9136,7026],{"class":1080},[341,9138,8311],{"class":354},[341,9140,9141],{"class":347},").adsbygoogle ",[341,9143,683],{"class":1080},[341,9145,9146],{"class":347}," (window ",[341,9148,7026],{"class":1080},[341,9150,8311],{"class":354},[341,9152,9141],{"class":347},[341,9154,2253],{"class":1080},[341,9156,4173],{"class":347},[341,9158,4176],{"class":679},[341,9160,4179],{"class":347},[341,9162,9163,9165,9167],{"class":343,"line":3435},[341,9164,2604],{"class":347},[341,9166,4033],{"class":1080},[341,9168,4036],{"class":347},[341,9170,9171,9173,9175,9177,9179,9181,9183,9185,9187,9190],{"class":343,"line":3445},[341,9172,3151],{"class":1080},[341,9174,2587],{"class":347},[341,9176,3766],{"class":1080},[341,9178,816],{"class":347},[341,9180,3771],{"class":354},[341,9182,3967],{"class":347},[341,9184,4054],{"class":679},[341,9186,2977],{"class":347},[341,9188,9189],{"class":361},"'[AdAdsense] push failed:'",[341,9191,4213],{"class":347},[341,9193,9194,9196,9198],{"class":343,"line":3466},[341,9195,4068],{"class":347},[341,9197,683],{"class":1080},[341,9199,3421],{"class":354},[341,9201,9202],{"class":343,"line":3471},[341,9203,2626],{"class":347},[341,9205,9206],{"class":343,"line":3476},[341,9207,435],{"class":347},[341,9209,9210],{"class":343,"line":3499},[341,9211,703],{"emptyLinePlaceholder":702},[341,9213,9214,9216,9218],{"class":343,"line":3504},[341,9215,3102],{"class":1080},[341,9217,4245],{"class":679},[341,9219,3566],{"class":347},[341,9221,9222,9224,9226,9228,9230,9232,9234,9236,9238,9240,9242],{"class":343,"line":3521},[341,9223,2554],{"class":1080},[341,9225,2587],{"class":347},[341,9227,2712],{"class":1080},[341,9229,2977],{"class":347},[341,9231,4261],{"class":361},[341,9233,4264],{"class":1080},[341,9235,4267],{"class":347},[341,9237,4270],{"class":679},[341,9239,4273],{"class":347},[341,9241,2735],{"class":1080},[341,9243,2548],{"class":347},[341,9245,9246,9248,9250,9252,9254],{"class":343,"line":3537},[341,9247,4283],{"class":347},[341,9249,683],{"class":1080},[341,9251,3125],{"class":1080},[341,9253,4290],{"class":679},[341,9255,2016],{"class":347},[341,9257,9258,9261,9264],{"class":343,"line":3548},[341,9259,9260],{"class":1869},"    entries",[341,9262,9263],{"class":1080}," =>",[341,9265,1233],{"class":347},[341,9267,9268,9270,9272,9274],{"class":343,"line":3553},[341,9269,3205],{"class":1080},[341,9271,4315],{"class":347},[341,9273,4318],{"class":354},[341,9275,4321],{"class":347},[341,9277,9278,9280],{"class":343,"line":3558},[341,9279,4327],{"class":679},[341,9281,2000],{"class":347},[341,9283,9284,9286,9288,9291,9293],{"class":343,"line":3569},[341,9285,4335],{"class":347},[341,9287,3430],{"class":679},[341,9289,9290],{"class":347},"(); intersectionObserver ",[341,9292,683],{"class":1080},[341,9294,3055],{"class":354},[341,9296,9297],{"class":343,"line":3592},[341,9298,2886],{"class":347},[341,9300,9301],{"class":343,"line":3612},[341,9302,4360],{"class":347},[341,9304,9305,9307,9309],{"class":343,"line":3623},[341,9306,4366],{"class":347},[341,9308,4369],{"class":361},[341,9310,2548],{"class":347},[341,9312,9313],{"class":343,"line":3634},[341,9314,4377],{"class":347},[341,9316,9317,9319,9321,9323],{"class":343,"line":3645},[341,9318,2554],{"class":1080},[341,9320,4385],{"class":347},[341,9322,3482],{"class":679},[341,9324,4390],{"class":347},[341,9326,9327],{"class":343,"line":3650},[341,9328,435],{"class":347},[341,9330,9331],{"class":343,"line":3665},[341,9332,703],{"emptyLinePlaceholder":702},[341,9334,9335,9337,9339,9341,9343,9345,9347,9349,9351,9353,9356,9359],{"class":343,"line":3676},[341,9336,4486],{"class":679},[341,9338,2235],{"class":347},[341,9340,1876],{"class":1080},[341,9342,2751],{"class":347},[341,9344,8595],{"class":1080},[341,9346,2587],{"class":347},[341,9348,3766],{"class":1080},[341,9350,816],{"class":347},[341,9352,3771],{"class":354},[341,9354,9355],{"class":347},".client) ",[341,9357,9358],{"class":679},"initObserver",[341,9360,9361],{"class":347},"() })\n",[341,9363,9364],{"class":343,"line":3687},[341,9365,703],{"emptyLinePlaceholder":702},[341,9367,9368,9370,9372,9374],{"class":343,"line":3698},[341,9369,4545],{"class":679},[341,9371,2235],{"class":347},[341,9373,1876],{"class":1080},[341,9375,1233],{"class":347},[341,9377,9378,9380,9382,9384,9386],{"class":343,"line":3716},[341,9379,4557],{"class":347},[341,9381,3430],{"class":679},[341,9383,9290],{"class":347},[341,9385,683],{"class":1080},[341,9387,3055],{"class":354},[341,9389,9390,9392,9394,9396,9398],{"class":343,"line":3726},[341,9391,4576],{"class":347},[341,9393,3430],{"class":679},[341,9395,8534],{"class":347},[341,9397,683],{"class":1080},[341,9399,3055],{"class":354},[341,9401,9402,9404,9406,9408,9410,9412,9414],{"class":343,"line":3735},[341,9403,2554],{"class":1080},[341,9405,3450],{"class":347},[341,9407,3453],{"class":679},[341,9409,3456],{"class":347},[341,9411,683],{"class":1080},[341,9413,3461],{"class":354},[341,9415,2548],{"class":347},[341,9417,9418,9420,9422,9424],{"class":343,"line":3743},[341,9419,2554],{"class":1080},[341,9421,4648],{"class":347},[341,9423,683],{"class":1080},[341,9425,2033],{"class":361},[341,9427,9428],{"class":343,"line":3754},[341,9429,1979],{"class":347},[341,9431,9432,9434,9436],{"class":343,"line":3777},[341,9433,1755],{"class":347},[341,9435,1772],{"class":676},[341,9437,1419],{"class":347},[341,9439,9440],{"class":343,"line":3793},[341,9441,703],{"emptyLinePlaceholder":702},[341,9443,9444,9446,9448,9451],{"class":343,"line":3798},[341,9445,673],{"class":347},[341,9447,894],{"class":676},[341,9449,9450],{"class":679}," scoped",[341,9452,1419],{"class":347},[341,9454,9455,9458,9460,9463,9465,9468,9471,9474,9477,9480],{"class":343,"line":3816},[341,9456,9457],{"class":679},".fade-enter-active",[341,9459,1885],{"class":347},[341,9461,9462],{"class":679},".fade-leave-active",[341,9464,2751],{"class":347},[341,9466,9467],{"class":354},"transition",[341,9469,9470],{"class":347},": opacity ",[341,9472,9473],{"class":354},"0.3",[341,9475,9476],{"class":1080},"s",[341,9478,9479],{"class":354}," ease",[341,9481,9482],{"class":347},"; }\n",[341,9484,9485,9488,9490,9493,9495,9498,9500,9502],{"class":343,"line":3825},[341,9486,9487],{"class":679},".fade-enter-from",[341,9489,1885],{"class":347},[341,9491,9492],{"class":679},".fade-leave-to",[341,9494,2751],{"class":347},[341,9496,9497],{"class":354},"opacity",[341,9499,358],{"class":347},[341,9501,4318],{"class":354},[341,9503,9482],{"class":347},[341,9505,9506,9508,9510],{"class":343,"line":3835},[341,9507,1755],{"class":347},[341,9509,894],{"class":676},[341,9511,1419],{"class":347},[28,9513],{},[11,9515,745],{"id":744},[16,9517,9518],{},[60,9519,9520],{},"Why locale code instead of IP detection",[16,9522,9523,9524,9527,9528,9530],{},"An SSG site has no server. There's no request context where you could read a header like ",[52,9525,9526],{},"CF-IPCountry",". The locale is already available client-side via ",[52,9529,6054],{}," the moment the component hydrates — no extra network request, no edge function dependency.",[16,9532,9533],{},"The trade-off: a user who has selected the Chinese locale but is physically outside China will see wwads instead of AdSense. In practice that's a small population and an acceptable misclassification. Introducing a server dependency — even a Cloudflare Worker — to get IP-based routing would add latency, complexity, and a new failure mode, none of which are worth it for this edge case.",[16,9535,9536],{},[60,9537,9538,9539,9541,9542,9544],{},"Why clean ",[52,9540,6679],{}," instead of ",[52,9543,6027],{}," randomisation",[16,9546,438,9547,9549,9550,9552,9553,6535,9555,9557],{},[52,9548,5964],{}," module handles the SPA navigation problem by generating a random ",[52,9551,6027],{}," value each time an ad is requested, which causes AdSense to treat it as a new slot. That works for SSR sites, but on an SSG site the random value is generated at build time and baked into the HTML. Two builds produce two different files, which invalidates Cloudflare's cache for files that haven't meaningfully changed. Clearing ",[52,9554,6679],{},[52,9556,4545],{}," solves the same problem with no build-time side effects.",[16,9559,9560],{},[60,9561,9562],{},"The real limitation of this architecture",[16,9564,9565,9566,9568,9569,9571,9572,9574],{},"Every new ad platform requires a new component. That component has to handle its own SDK loading, lifecycle cleanup, and error states — there's no shared base class. The ",[52,9567,6469],{}," implementation is structurally similar to ",[52,9570,6472],{}," but can't share code with it because each SDK has its own idioms (wwads uses a script tag with a site ID attribute; AdSense uses ",[52,9573,6707],{}," on a global array). The abstraction boundary is at the slot level, not at the implementation level.",[16,9576,9577],{},"That's a real cost. Before adding a third platform, it's worth evaluating whether you actually need the platform to be swappable, or whether a simpler direct integration would be less maintenance.",[16,9579,9580],{},[60,9581,9582],{},"Debugging in production only",[16,9584,9585,9586,9589,9590,9593,9594,9596,9597,9599,9600,9602,9603,9605],{},"AdSense and wwads both refuse to serve real ads on ",[52,9587,9588],{},"localhost",". The component behaves correctly — the ",[52,9591,9592],{},"waitForAdsbygoogle"," polling resolves, ",[52,9595,6707],{}," fires, ",[52,9598,6921],{}," eventually gets set — but you get ",[52,9601,6932],{}," every time. There's no way to test the ",[52,9604,6928],{}," code path without deploying to your actual domain. This makes iteration slow: change component logic → commit → push → wait for Cloudflare Pages build → verify in production. For anything involving ad rendering state, plan for 15–20 minutes per feedback loop.",[28,9607],{},[11,9609,772],{"id":771},[16,9611,9612],{},"Both platforms running in production, routing correctly by locale. Chinese locale (left) shows a wwads unit. English locale (right) shows an AdSense unit at the same page position.",[16,9614,9615],{},[67,9616],{"alt":9617,"src":9618},"wwads ad unit showing on the Chinese locale version of BulkPicTools homepage","\u002Fimages\u002Fdev-practice\u002Fplatform-agnostic-ad-component-nuxt4-ssg\u002Fad-platform-zh-wwads-filled.webp",[16,9620,9621],{},[67,9622],{"alt":9623,"src":9624},"AdSense ad unit showing on the English locale version of BulkPicTools homepage","\u002Fimages\u002Fdev-practice\u002Fplatform-agnostic-ad-component-nuxt4-ssg\u002Fad-platform-en-adsense-filled.webp",[28,9626],{},[11,9628,792],{"id":791},[16,9630,9631,9634,9635,9638],{},[60,9632,9633],{},"1. Separate platform routing from platform rendering at the component boundary, not inside a single component."," A single component with an ",[52,9636,9637],{},"if (platform === 'adsense')"," branch eventually becomes unmanageable. Two lean implementation components behind a routing shell stays readable and independently testable.",[16,9640,9641,9647,9648,9650,9651,9653],{},[60,9642,9643,9644,9646],{},"2. ",[52,9645,6707],{}," succeeding is not the same as an ad rendering."," Use ",[52,9649,6936],{}," on ",[52,9652,6921],{}," to know when AdSense has actually committed to filling or not filling the slot. Anything that depends on ad presence — hiding skeletons, adjusting layout — should wait for that signal, not for the SDK call.",[16,9655,9656,9663,9664,9666,9667,9670],{},[60,9657,9658,9659,9662],{},"3. The ",[52,9660,9661],{},"already-have-ads"," error is a lifecycle mismatch, not an AdSense bug."," Vue's component lifecycle and AdSense's internal DOM marking don't know about each other. ",[52,9665,4545],{}," is the right place to bridge that gap. One line of ",[52,9668,9669],{},"innerHTML = ''"," is enough.",[16,9672,9673,9676],{},[60,9674,9675],{},"4. On SSG sites, plan for production-only ad debugging from the start."," Build a skeleton\u002Funfilled UI you're confident in locally, then accept that any state involving real ad fill requires a production deployment to verify. Trying to approximate this locally with mock data is possible but rarely worth the effort — the SDK's own behaviour is what you need to test against.",[894,9678,9679],{},"html pre.shiki code .sCydW, html code.shiki .sCydW{--shiki-default:#D73A49;--shiki-dark:#D73A49}html pre.shiki code .se37E, html code.shiki .se37E{--shiki-default:#6F42C1;--shiki-dark:#6F42C1}html pre.shiki code .sOTlB, html code.shiki .sOTlB{--shiki-default:#032F62;--shiki-dark:#032F62}html pre.shiki code .sKWpL, html code.shiki .sKWpL{--shiki-default:#24292E;--shiki-dark:#24292E}html pre.shiki code .sMN4m, html code.shiki .sMN4m{--shiki-default:#005CC5;--shiki-dark:#005CC5}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .sMDDv, html code.shiki .sMDDv{--shiki-default:#22863A;--shiki-dark:#22863A}html pre.shiki code .sPP4b, html code.shiki .sPP4b{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-dark:#B31D28;--shiki-dark-font-style:italic}html pre.shiki code .sj_tP, html code.shiki .sj_tP{--shiki-default:#E36209;--shiki-dark:#E36209}",{"title":337,"searchDepth":351,"depth":351,"links":9681},[9682,9683,9684,9685,9691,9692,9693],{"id":13,"depth":351,"text":14},{"id":972,"depth":351,"text":973},{"id":6033,"depth":351,"text":6034},{"id":6559,"depth":351,"text":6560,"children":9686},[9687,9688,9689,9690],{"id":6571,"depth":368,"text":6572},{"id":6683,"depth":368,"text":6684},{"id":6901,"depth":368,"text":6902},{"id":7154,"depth":368,"text":7155},{"id":744,"depth":351,"text":745},{"id":771,"depth":351,"text":772},{"id":791,"depth":351,"text":792},"How I built a two-layer ad component architecture for a multilingual Nuxt 4 SSG site — separating platform routing logic from SDK implementation, and fixing the adsbygoogle already-have-ads error that fires on SPA navigation.",{"date":9696,"category":4871,"readTime":929,"tags":9697,"image":9698,"draft":935,"series":936,"seriesOrder":936},"2026-06-29",[5277,4874,5760,5501],"https:\u002F\u002Fassets.kbmjj123.cc\u002Fblog\u002Fdev-practice\u002Fplatform-agnostic-ad-component-nuxt4-ssg\u002Fad-platform-both-filled.png","\u002Fposts\u002Fplatform-agnostic-ad-component-nuxt4-ssg",{"title":9701,"description":9702,"keywords":9703},"Platform-Agnostic AdSense Component for Nuxt 4 SSG (adsbygoogle already-have-ads fix)","Build a locale-aware, swappable ad component for Nuxt 4 SSG sites. Covers the two-layer architecture, adsbygoogle SPA navigation fix, MutationObserver skeleton timing, and wwads\u002FAdSense routing by locale.",[9704,9705,9706,9707,9708],"nuxt 4 adsense component","adsbygoogle already have ads fix","nuxt ssg google adsense","platform agnostic ad component vue","nuxt adsense spa navigation","posts\u002Fplatform-agnostic-ad-component-nuxt4-ssg","va-6IZbJSCi5G5nvcHutYEuJU5wOap1CZzHTbrdnUXA",{"id":9712,"title":9713,"body":9714,"description":9919,"extension":925,"meta":9920,"navigation":702,"path":9928,"seo":9929,"stem":9937,"__hash__":9938},"posts\u002Fposts\u002Fself-hosting-umami-part-1.md","When Your Free Analytics Hit the Ceiling: Why I Left Umami Cloud",{"type":8,"value":9715,"toc":9910},[9716,9718,9721,9723,9725,9731,9734,9736,9738,9745,9752,9759,9765,9768,9771,9773,9777,9780,9783,9793,9796,9802,9808,9811,9818,9821,9823,9827,9830,9836,9842,9848,9854,9857,9859,9861,9864,9867,9870,9873,9875,9877,9880,9894,9896],[11,9717,14],{"id":13},[16,9719,9720],{},"My Umami Cloud free plan hit the 100k events\u002Fmonth ceiling and data collection silently stopped. I tried Cloudflare Web Analytics as a zero-cost drop-in — it showed 4x the traffic Umami did, which turned out to be mostly bots. That wasn't good enough. So I decided to self-host Umami on Vercel + Supabase. This post is about the decision; the actual deployment nightmare gets its own two posts.",[28,9722],{},[11,9724,973],{"id":972},[16,9726,9727,9730],{},[20,9728,25],{"href":22,"rel":9729},[24]," is a side project I've been running for a while — bulk image processing that runs entirely in the browser, no uploads, no server. Traffic had been growing steadily. I was on Umami Cloud's Hobby free tier because it checked all the boxes: clean UI, privacy-respecting, no cookie banners, and a dashboard that shows exactly the signal I care about — real human visitors, where they came from, which tools they used.",[16,9732,9733],{},"One day in late May I opened the dashboard and it was showing zero visitors for the past few days. Not a dip. Zero.",[28,9735],{},[11,9737,1029],{"id":1028},[16,9739,9740,9741,9744],{},"My first instinct was that the tracking script had gotten blocked, or something on the site had broken. I opened DevTools and checked the network tab — the ",[52,9742,9743],{},"\u002Fapi\u002Fsend"," request was going out fine, returning 200. The script was loading. I spent close to fifteen minutes chasing that theory: checking whether an ad blocker was involved, whether a browser extension was interfering, reloading in incognito. Nothing.",[16,9746,9747,9748,9751],{},"Then I checked the Usage page at ",[52,9749,9750],{},"cloud.umami.is\u002Fsettings\u002Fusage",", and the actual cause was staring back at me.",[16,9753,9754,9755,9758],{},"The Hobby free plan caps out at ",[60,9756,9757],{},"100,000 events per month",". I had blown past it.",[16,9760,9761],{},[67,9762],{"alt":9763,"src":9764},"Umami Cloud usage dashboard showing monthly event limit exceeded on the free Hobby plan","\u002Fimages\u002Fstartup-diary\u002Fself-hosting-umami\u002Fself-hosting-umami-part-1-usage-exceeded.webp",[16,9766,9767],{},"Umami Cloud doesn't warn you when you're approaching the limit. No email, no dashboard alert, no degraded-mode banner. Data collection just stops. If I hadn't happened to check the dashboard during that zero-visitor streak, I could have gone weeks thinking my traffic had collapsed.",[16,9769,9770],{},"100k events a month sounds generous until you realize it isn't just page views. Every custom event — tool usage, button clicks, anything you track — counts toward that ceiling. A site with real engagement burns through it faster than a simple blog does.",[28,9772],{},[11,9774,9776],{"id":9775},"what-i-tried-first-cloudflare-web-analytics","What I Tried First: Cloudflare Web Analytics",[16,9778,9779],{},"Since bulkpictools.com is already behind Cloudflare, enabling Web Analytics was a one-click affair. No new scripts, no DNS changes — Cloudflare injects the tracker automatically at the edge. I had it running within two minutes.",[16,9781,9782],{},"The numbers didn't match at all.",[16,9784,9785,9786,9789,9790,816],{},"Umami had been showing around ",[60,9787,9788],{},"1,000 real visitors per day",". Cloudflare was showing ",[60,9791,9792],{},"4,000+",[16,9794,9795],{},"That 4x gap isn't measurement noise. It comes down to what each tool actually counts:",[16,9797,9798,9801],{},[60,9799,9800],{},"Umami counts humans."," It relies on a JavaScript snippet that runs in the browser. If a visitor's ad blocker kills the script, that visit is invisible to Umami. If a bot doesn't execute JavaScript (most don't), it doesn't register. If someone closes the tab before the script loads, nothing is recorded. Umami systematically undercounts, but what it does count is almost entirely real human traffic.",[16,9803,9804,9807],{},[60,9805,9806],{},"Cloudflare counts everything that touches your server."," It operates at the network layer, before JavaScript ever runs. Every request — crawlers, scrapers, health checks, RSS readers, monitoring services, and yes, real visitors — gets tallied. No filtering unless you configure it explicitly.",[16,9809,9810],{},"The 3,000-visit gap was real, and most of it was machines — crawl rate is useful information on its own, just not the signal I actually needed. When I'm trying to understand whether a new tool page is getting real traction, I need the Umami number, not the Cloudflare one.",[16,9812,9813,9814,9817],{},"There was another issue: Cloudflare Web Analytics only retains data for ",[60,9815,9816],{},"30 days",". Umami had been giving me month-over-month trend lines I could use to evaluate whether a content push was working. Losing that history wasn't acceptable.",[16,9819,9820],{},"So Cloudflare as a full replacement was off the table.",[28,9822],{},[11,9824,9826],{"id":9825},"the-decision-self-host-vs-pay","The Decision: Self-Host vs Pay",[16,9828,9829],{},"Here's what I was actually choosing between:",[16,9831,9832,9835],{},[60,9833,9834],{},"Upgrade Umami Cloud to Pro"," ($20\u002Fmonth, 1M events\u002Fmonth, 2-year retention). Cleanest path. Zero configuration, data migrates automatically. But $20\u002Fmonth is $240\u002Fyear for analytics on a side project that hasn't hit meaningful revenue yet. Hard to justify on principle alone.",[16,9837,9838,9841],{},[60,9839,9840],{},"Switch to a different cloud tool"," (Plausible, Fathom, etc.). Similar pricing tier, similar constraints. I'd just be picking a different ceiling.",[16,9843,9844,9847],{},[60,9845,9846],{},"Self-host Umami on a VPS",". Full control, no limits, but adds operational overhead — a server to maintain, backups to set up, uptime to care about.",[16,9849,9850,9853],{},[60,9851,9852],{},"Self-host on Vercel + Supabase",". Both have free tiers. Vercel handles deployments automatically. Supabase provides the PostgreSQL database. If the free tier holds, the ongoing cost is zero. The risk is operational complexity during setup, and potentially hitting another free tier limit down the road.",[16,9855,9856],{},"I went with Vercel + Supabase. It wasn't the easiest path — Parts 2 and 3 of this series cover exactly how much it wasn't — but I wanted to understand the full deployment stack, not just click a few buttons and hope it keeps working. If I'm going to depend on this infrastructure, I should know exactly how it runs. And if Supabase's free tier ever becomes a bottleneck, I'll have the knowledge to migrate the database layer without touching the Vercel deployment.",[28,9858],{},[11,9860,745],{"id":744},[16,9862,9863],{},"The free tier ceiling was predictable, and I just hadn't done the math. 1,000 active daily users, multiple trackable events per session — bulkpictools.com tracks tool usage on top of page views — and 100k events a month runs out fast. I signed up for the Hobby plan without ever multiplying those numbers together.",[16,9865,9866],{},"Free tiers on analytics tools are sized for projects that haven't taken off yet. Once a project gets real traffic, you're a paying customer whether you've accepted that or not. Umami's pricing is fair by SaaS standards — $20\u002Fmonth for 1M events isn't unreasonable — I just hadn't priced in my own growth.",[16,9868,9869],{},"Cloudflare Web Analytics is genuinely good at what it does, and if you're already on Cloudflare, turning it on costs nothing. If you need a quick pulse on traffic, it's the right tool. The 30-day retention is the real constraint. If your workflow is purely \"how many people visited this week,\" Cloudflare is all you need and it will never cost you anything.",[16,9871,9872],{},"For me, the missing piece was longitudinal data — watching a specific URL climb or fall in traffic over two to three months. That's where Umami's data model, which stores every event with full timestamp and metadata, wins.",[28,9874],{},[11,9876,5449],{"id":5448},[16,9878,9879],{},"The actual Vercel + Supabase deployment turned out to be more complicated than the tutorials suggest. There are connection string traps, a Prisma migration that silently hangs due to the wrong port, and a 66MB GeoIP database that can silently kill your Vercel build if you don't handle it correctly.",[16,9881,9882,9883,9886,9887,558,9890,9893],{},"Part 2 covers the connection string issues and why ",[52,9884,9885],{},"prisma migrate deploy"," kept hanging — specifically the ",[52,9888,9889],{},"DATABASE_URL",[52,9891,9892],{},"DIRECT_DATABASE_URL"," distinction that the official docs mention but don't fully explain.",[28,9895],{},[16,9897,9898],{},[257,9899,9900,9901,9905,9906],{},"Part of the \"Self-Hosting Umami on Vercel + Supabase\" series. · ",[20,9902,9904],{"href":9903},"\u002Fself-hosting-umami-part-2","Part 2: The Connection String Traps"," · ",[20,9907,9909],{"href":9908},"\u002Fself-hosting-umami-part-3","Part 3: GeoIP, Migration Bypass, and Git LFS",{"title":337,"searchDepth":351,"depth":351,"links":9911},[9912,9913,9914,9915,9916,9917,9918],{"id":13,"depth":351,"text":14},{"id":972,"depth":351,"text":973},{"id":1028,"depth":351,"text":1029},{"id":9775,"depth":351,"text":9776},{"id":9825,"depth":351,"text":9826},{"id":744,"depth":351,"text":745},{"id":5448,"depth":351,"text":5449},"My Umami Cloud free plan hit 100k events and data collection stopped. Here's what I tried — Cloudflare Web Analytics, why it wasn't enough, and why I ended up self-hosting.",{"date":9921,"category":5937,"readTime":9922,"tags":9923,"image":9926,"draft":935,"series":9927,"seriesOrder":344},"2026-06-30","8mins",[9924,933,9925,5499,932],"#saas","#vercel","https:\u002F\u002Fassets.kbmjj123.cc\u002Fblog\u002Fstartup-diary\u002Fself-hosting-umami-part-1\u002Fpart-1-usage-exceeded.png","self-hosting-umami-on-vercel-supabase","\u002Fposts\u002Fself-hosting-umami-part-1",{"title":9930,"description":9931,"keywords":9932},"Umami Cloud Free Plan Exceeded — What To Do Next","Hit Umami Cloud's 100k event free tier limit? See how one indie dev compared Cloudflare Web Analytics vs self-hosting and what the data gap actually looked like.",[9933,9934,9935,9936],"umami cloud free plan limit exceeded","umami cloud alternative self-hosting","cloudflare web analytics vs umami","self-host umami vercel supabase","posts\u002Fself-hosting-umami-part-1","ClLk6aOxCW89_2DcZVCb5tZardTtFN0EF5i_iJNCf98",{"id":9940,"title":9941,"body":9942,"description":10905,"extension":925,"meta":10906,"navigation":702,"path":10913,"seo":10914,"stem":10923,"__hash__":10924},"posts\u002Fposts\u002Fself-hosting-umami-part-2.md","Deploying Umami on Vercel + Supabase: The Connection String Traps",{"type":8,"value":9943,"toc":10886},[9944,9946,9957,9959,9961,9976,9979,9982,9984,9988,9991,10039,10042,10057,10063,10065,10067,10074,10077,10086,10092,10105,10108,10114,10118,10121,10180,10185,10192,10198,10201,10207,10210,10216,10291,10312,10318,10395,10407,10411,10420,10436,10443,10453,10479,10486,10495,10507,10544,10571,10573,10575,10581,10655,10660,10740,10760,10762,10764,10776,10787,10790,10792,10794,10803,10809,10815,10818,10820,10822,10834,10848,10863,10869,10871,10883],[11,9945,14],{"id":13},[16,9947,9948,9949,9952,9953,9956],{},"Deploying Umami to Vercel with a Supabase database requires two separate connection strings — not one. The official docs mention this, but they don't explain ",[257,9950,9951],{},"why",", which meant I spent hours debugging a build that silently hung with no error output. The root cause was Prisma's migration command running against the connection pool port instead of the direct connection port, combined with a ",[52,9954,9955],{},"prisma.config.ts"," file that quietly overrode my environment variables.",[28,9958],{},[11,9960,973],{"id":972},[16,9962,9963,9964,9968,9969,9971,9972,9975],{},"After deciding to self-host Umami (covered in ",[20,9965,9967],{"href":9966},"#","Part 1","), the deployment path looked straightforward: fork the official repo, import to Vercel, set ",[52,9970,9889],{},", deploy. The official documentation at ",[52,9973,9974],{},"docs.umami.is\u002Fdocs\u002Finstall"," lists the environment variables and the expected connection string format. I had a Supabase project ready. Should have been 20 minutes.",[16,9977,9978],{},"It ate most of an evening instead.",[16,9980,9981],{},"The problems weren't random — they were all symptoms of the same underlying tension between how Prisma handles database migrations and how Supabase exposes its PostgreSQL connection. Once I understood that tension, the actual fix was two lines of config. Getting to that understanding meant working through three failures first that each looked like a completely separate problem.",[28,9983],{},[11,9985,9987],{"id":9986},"the-problem-build-hangs-after-database-check","The Problem: Build Hangs After Database Check",[16,9989,9990],{},"The Vercel build log would reach a point and stop:",[332,9992,9996],{"className":9993,"code":9994,"language":9995,"meta":337,"style":337},"language-bash shiki shiki-themes github-light github-light","✓ DATABASE_URL is defined.\n✓ Database connection successful.\n✓ Database version check successful.\n","bash",[52,9997,9998,10012,10025],{"__ignoreMap":337},[341,9999,10000,10003,10006,10009],{"class":343,"line":344},[341,10001,10002],{"class":679},"✓",[341,10004,10005],{"class":361}," DATABASE_URL",[341,10007,10008],{"class":361}," is",[341,10010,10011],{"class":361}," defined.\n",[341,10013,10014,10016,10019,10022],{"class":343,"line":351},[341,10015,10002],{"class":679},[341,10017,10018],{"class":361}," Database",[341,10020,10021],{"class":361}," connection",[341,10023,10024],{"class":361}," successful.\n",[341,10026,10027,10029,10031,10034,10037],{"class":343,"line":368},[341,10028,10002],{"class":679},[341,10030,10018],{"class":361},[341,10032,10033],{"class":361}," version",[341,10035,10036],{"class":361}," check",[341,10038,10024],{"class":361},[16,10040,10041],{},"Three green checkmarks, then nothing. No error, no stack trace, just a build that stopped responding until Vercel killed the job.",[16,10043,10044,10045,10048,10049,10052,10053,10056],{},"I added ",[52,10046,10047],{},"DEBUG=\"prisma:*\""," to get verbose Prisma output and confirmed the process was entering ",[52,10050,10051],{},"applyMigration()"," inside ",[52,10054,10055],{},"check-db.js"," and not coming back.",[16,10058,10059,10060,10062],{},"The script was calling ",[52,10061,9885],{}," internally. That command was the one hanging.",[28,10064],{},[11,10066,1055],{"id":1054},[305,10068,10070,10071,10073],{"id":10069},"why-prisma-migrate-deploy-cant-use-the-connection-pool","Why ",[52,10072,9885],{}," Can't Use the Connection Pool",[16,10075,10076],{},"Supabase exposes your PostgreSQL database through two different endpoints:",[16,10078,10079,10082,10083,10085],{},[60,10080,10081],{},"Transaction pooler — port 6543","\nThis is PgBouncer sitting in front of your database. It manages a pool of persistent connections and hands them out to clients on demand. It's optimized for short-lived queries from serverless functions, where opening a new raw TCP connection for every request would be catastrophically slow. This is what ",[52,10084,9889],{}," should point to at runtime.",[16,10087,10088,10091],{},[60,10089,10090],{},"Direct connection — port 5432","\nThis bypasses PgBouncer entirely and connects straight to the PostgreSQL process. It's slower to establish but supports the full PostgreSQL wire protocol without restrictions.",[16,10093,10094,10095,5214,10098,10100,10101,10104],{},"The critical constraint: ",[60,10096,10097],{},"Prisma's migration engine requires a direct connection.",[52,10099,9885],{}," needs to hold a transaction open across multiple DDL statements (CREATE TABLE, ALTER TABLE, index creation), acquire advisory locks, and write migration state to the ",[52,10102,10103],{},"_prisma_migrations"," table atomically. PgBouncer in transaction pooling mode does not support advisory locks, and it can drop the connection mid-migration if a statement takes too long. The result is a migration that hangs waiting for a lock that will never be granted, or silently fails partway through.",[16,10106,10107],{},"So the problem was structural: the build was trying to run database migrations through the connection pool, which is architecturally wrong regardless of credentials or network conditions.",[16,10109,10110],{},[67,10111],{"alt":10112,"src":10113},"Diagram showing two Supabase connection paths: runtime queries through PgBouncer on port 6543, and Prisma migrations requiring direct connection on port 5432","\u002Fimages\u002Fstartup-umami\u002Fself-hosting-umami\u002Fsupabase_connection_paths_diagram.svg",[305,10115,10117],{"id":10116},"splitting-database_url-from-direct_database_url-stopped-the-hang-almost","Splitting DATABASE_URL From DIRECT_DATABASE_URL Stopped the Hang — Almost",[16,10119,10120],{},"The correct setup requires both:",[332,10122,10124],{"className":9993,"code":10123,"language":9995,"meta":337,"style":337},"# Runtime queries — goes through PgBouncer connection pool\nDATABASE_URL=postgresql:\u002F\u002Fpostgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543\u002Fpostgres?pgbouncer=true&connection_limit=1\n\n# Schema migrations — bypasses PgBouncer, direct to PostgreSQL\nDIRECT_DATABASE_URL=postgresql:\u002F\u002Fpostgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:5432\u002Fpostgres\n",[52,10125,10126,10131,10160,10164,10169],{"__ignoreMap":337},[341,10127,10128],{"class":343,"line":344},[341,10129,10130],{"class":667},"# Runtime queries — goes through PgBouncer connection pool\n",[341,10132,10133,10135,10137,10140,10143,10145,10148,10150,10152,10155,10157],{"class":343,"line":351},[341,10134,9889],{"class":347},[341,10136,683],{"class":1080},[341,10138,10139],{"class":361},"postgresql:\u002F\u002Fpostgres.[project-ref",[341,10141,10142],{"class":347},"]:[password]@aws-0-[region].pooler.supabase.com:6543\u002Fpostgres",[341,10144,889],{"class":1080},[341,10146,10147],{"class":347},"pgbouncer",[341,10149,683],{"class":1080},[341,10151,3488],{"class":361},[341,10153,10154],{"class":347},"&connection_limit",[341,10156,683],{"class":1080},[341,10158,10159],{"class":361},"1\n",[341,10161,10162],{"class":343,"line":368},[341,10163,703],{"emptyLinePlaceholder":702},[341,10165,10166],{"class":343,"line":381},[341,10167,10168],{"class":667},"# Schema migrations — bypasses PgBouncer, direct to PostgreSQL\n",[341,10170,10171,10173,10175,10177],{"class":343,"line":390},[341,10172,9892],{"class":347},[341,10174,683],{"class":1080},[341,10176,10139],{"class":361},[341,10178,10179],{"class":347},"]:[password]@aws-0-[region].pooler.supabase.com:5432\u002Fpostgres\n",[16,10181,10182,10183,816],{},"Both environment variables need to be set in Vercel under Settings → Environment Variables, not just ",[52,10184,9889],{},[16,10186,10187,10188,10191],{},"Where to find these strings in Supabase: navigate to your project, click the ",[60,10189,10190],{},"Connect"," button (green, near the top), then look for the \"Transaction pooler\" and \"Direct connection\" sections. Each one has a copy button that gives you the complete string with your project reference already filled in — you only need to substitute your database password.",[16,10193,10194],{},[67,10195],{"alt":10196,"src":10197},"Supabase Connect page showing Transaction pooler connection string on port 6543 and Direct connection string on port 5432","\u002Fimages\u002Fstartup-umami\u002Fself-hosting-umami\u002Fself-hosting-umami-part-2-supabase-connect-page.webp",[16,10199,10200],{},"I set both variables, redeployed, and watched the build hang at the exact same spot.",[305,10202,10204,10206],{"id":10203},"prismaconfigts-was-silently-overriding-my-direct_database_url",[52,10205,9955],{}," Was Silently Overriding My DIRECT_DATABASE_URL",[16,10208,10209],{},"Setting both environment variables wasn't enough. The build still hung.",[16,10211,10212,10213,10215],{},"The reason: Umami's repository includes a ",[52,10214,9955],{}," file that Prisma reads before it looks at environment variables. In the version I forked, it looked like this:",[332,10217,10219],{"className":5574,"code":10218,"language":5576,"meta":337,"style":337},"import 'dotenv\u002Fconfig';\nimport { defineConfig, env } from 'prisma\u002Fconfig';\n\nexport default defineConfig({\n  datasource: {\n    url: env('DATABASE_URL'),\n  },\n});\n",[52,10220,10221,10231,10246,10250,10261,10266,10282,10286],{"__ignoreMap":337},[341,10222,10223,10225,10228],{"class":343,"line":344},[341,10224,3766],{"class":1080},[341,10226,10227],{"class":361}," 'dotenv\u002Fconfig'",[341,10229,10230],{"class":347},";\n",[341,10232,10233,10235,10238,10241,10244],{"class":343,"line":351},[341,10234,3766],{"class":1080},[341,10236,10237],{"class":347}," { defineConfig, env } ",[341,10239,10240],{"class":1080},"from",[341,10242,10243],{"class":361}," 'prisma\u002Fconfig'",[341,10245,10230],{"class":347},[341,10247,10248],{"class":343,"line":368},[341,10249,703],{"emptyLinePlaceholder":702},[341,10251,10252,10254,10256,10259],{"class":343,"line":381},[341,10253,5602],{"class":1080},[341,10255,5605],{"class":1080},[341,10257,10258],{"class":679}," defineConfig",[341,10260,1834],{"class":347},[341,10262,10263],{"class":343,"line":390},[341,10264,10265],{"class":347},"  datasource: {\n",[341,10267,10268,10271,10274,10276,10279],{"class":343,"line":396},[341,10269,10270],{"class":347},"    url: ",[341,10272,10273],{"class":679},"env",[341,10275,2977],{"class":347},[341,10277,10278],{"class":361},"'DATABASE_URL'",[341,10280,10281],{"class":347},"),\n",[341,10283,10284],{"class":343,"line":409},[341,10285,1299],{"class":347},[341,10287,10288],{"class":343,"line":420},[341,10289,10290],{"class":347},"});\n",[16,10292,10293,10294,10297,10298,10300,10301,10304,10305,10307,10308,10311],{},"Notice what's missing: there's no ",[52,10295,10296],{},"directUrl"," field. This means Prisma was reading ",[52,10299,9889],{}," (the pooler, port 6543) for ",[257,10302,10303],{},"everything",", including migrations — overriding the ",[52,10306,9892],{}," environment variable that the schema's ",[52,10309,10310],{},"datasource"," block would otherwise use.",[16,10313,10314,10315,10317],{},"The fix is to add the ",[52,10316,10296],{}," mapping explicitly:",[332,10319,10321],{"className":5574,"code":10320,"language":5576,"meta":337,"style":337},"import 'dotenv\u002Fconfig';\nimport { defineConfig, env } from 'prisma\u002Fconfig';\n\nexport default defineConfig({\n  datasource: {\n    url: env('DATABASE_URL'),\n    directUrl: env('DIRECT_DATABASE_URL'),\n  },\n});\n",[52,10322,10323,10331,10343,10347,10357,10361,10373,10387,10391],{"__ignoreMap":337},[341,10324,10325,10327,10329],{"class":343,"line":344},[341,10326,3766],{"class":1080},[341,10328,10227],{"class":361},[341,10330,10230],{"class":347},[341,10332,10333,10335,10337,10339,10341],{"class":343,"line":351},[341,10334,3766],{"class":1080},[341,10336,10237],{"class":347},[341,10338,10240],{"class":1080},[341,10340,10243],{"class":361},[341,10342,10230],{"class":347},[341,10344,10345],{"class":343,"line":368},[341,10346,703],{"emptyLinePlaceholder":702},[341,10348,10349,10351,10353,10355],{"class":343,"line":381},[341,10350,5602],{"class":1080},[341,10352,5605],{"class":1080},[341,10354,10258],{"class":679},[341,10356,1834],{"class":347},[341,10358,10359],{"class":343,"line":390},[341,10360,10265],{"class":347},[341,10362,10363,10365,10367,10369,10371],{"class":343,"line":396},[341,10364,10270],{"class":347},[341,10366,10273],{"class":679},[341,10368,2977],{"class":347},[341,10370,10278],{"class":361},[341,10372,10281],{"class":347},[341,10374,10375,10378,10380,10382,10385],{"class":343,"line":409},[341,10376,10377],{"class":347},"    directUrl: ",[341,10379,10273],{"class":679},[341,10381,2977],{"class":347},[341,10383,10384],{"class":361},"'DIRECT_DATABASE_URL'",[341,10386,10281],{"class":347},[341,10388,10389],{"class":343,"line":420},[341,10390,1299],{"class":347},[341,10392,10393],{"class":343,"line":426},[341,10394,10290],{"class":347},[16,10396,10397,10398,10400,10401,10403,10404,10406],{},"With this in place, Prisma uses ",[52,10399,9889],{}," for runtime queries and automatically switches to ",[52,10402,9892],{}," when running migrations. The config file takes precedence over the schema's ",[52,10405,10310],{}," block, so this is the right place to set it.",[305,10408,10410],{"id":10409},"ipv6-preference-caused-ehostunreach-on-my-local-network","IPv6 Preference Caused EHOSTUNREACH on My Local Network",[16,10412,10413,10414,10416,10417,10419],{},"Even after fixing ",[52,10415,9955],{},", running ",[52,10418,9885],{}," locally hit another wall:",[332,10421,10423],{"className":9993,"code":10422,"language":9995,"meta":337,"style":337},"connect EHOSTUNREACH 2600:1f16:1ce4:1c00:525c:bf25:5b8d:fff3:5432\n",[52,10424,10425],{"__ignoreMap":337},[341,10426,10427,10430,10433],{"class":343,"line":344},[341,10428,10429],{"class":679},"connect",[341,10431,10432],{"class":361}," EHOSTUNREACH",[341,10434,10435],{"class":361}," 2600:1f16:1ce4:1c00:525c:bf25:5b8d:fff3:5432\n",[16,10437,10438,10439,10442],{},"Supabase's pooler domain resolves to both IPv4 and IPv6 addresses. Node.js (and therefore Prisma) will prefer IPv6 when it's available in the DNS response. My local network doesn't support IPv6 routing to external hosts, so the connection attempt to the IPv6 address fails immediately with ",[52,10440,10441],{},"EHOSTUNREACH"," — host unreachable.",[16,10444,10445,10446,10449,10450,10452],{},"The fix is to append ",[52,10447,10448],{},"?family=4"," to ",[52,10451,9892],{}," to force IPv4 resolution:",[332,10454,10456],{"className":9993,"code":10455,"language":9995,"meta":337,"style":337},"DIRECT_DATABASE_URL=postgresql:\u002F\u002Fpostgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:5432\u002Fpostgres?family=4\n",[52,10457,10458],{"__ignoreMap":337},[341,10459,10460,10462,10464,10466,10469,10471,10474,10476],{"class":343,"line":344},[341,10461,9892],{"class":347},[341,10463,683],{"class":1080},[341,10465,10139],{"class":361},[341,10467,10468],{"class":347},"]:[password]@aws-0-[region].pooler.supabase.com:5432\u002Fpostgres",[341,10470,889],{"class":1080},[341,10472,10473],{"class":347},"family",[341,10475,683],{"class":1080},[341,10477,10478],{"class":361},"4\n",[16,10480,10481,10482,10485],{},"This is a local development fix only. On Vercel's build infrastructure, IPv6 routing works correctly, so this parameter isn't needed there — but it's harmless to include if you want a single ",[52,10483,10484],{},".env"," file that works in both environments.",[305,10487,10489,10490,319,10492,10494],{"id":10488},"a-password-with-and-broke-the-url-parser","A Password With ",[52,10491,2712],{},[52,10493,9966],{}," Broke the URL Parser",[16,10496,10497,10498,1885,10500,10503,10504,10506],{},"One more trap: if your Supabase database password contains special characters like ",[52,10499,2712],{},[52,10501,10502],{},"@",", or ",[52,10505,9966],{},", the URL parser will break on them. The error looks like this:",[332,10508,10510],{"className":9993,"code":10509,"language":9995,"meta":337,"style":337},"TypeError: Invalid URL\n    at new URL (node:internal\u002Furl:819:25)\ninput: 'postgres:\u002F\u002Fpostgres:yourPass!word#123@...'\n",[52,10511,10512,10523,10536],{"__ignoreMap":337},[341,10513,10514,10517,10520],{"class":343,"line":344},[341,10515,10516],{"class":679},"TypeError:",[341,10518,10519],{"class":361}," Invalid",[341,10521,10522],{"class":361}," URL\n",[341,10524,10525,10528,10530,10533],{"class":343,"line":351},[341,10526,10527],{"class":679},"    at",[341,10529,3125],{"class":361},[341,10531,10532],{"class":361}," URL",[341,10534,10535],{"class":347}," (node:internal\u002Furl:819:25)\n",[341,10537,10538,10541],{"class":343,"line":368},[341,10539,10540],{"class":679},"input:",[341,10542,10543],{"class":361}," 'postgres:\u002F\u002Fpostgres:yourPass!word#123@...'\n",[16,10545,10546,10547,5214,10549,5214,10551,10553,10554,10556,10557,1885,10560,10556,10562,1885,10565,10556,10567,10570],{},"The characters ",[52,10548,2712],{},[52,10550,10502],{},[52,10552,9966],{}," have reserved meaning in URIs. The safest fix is to reset your Supabase database password to one using only alphanumeric characters. The alternative — percent-encoding each character (",[52,10555,2712],{}," → ",[52,10558,10559],{},"%21",[52,10561,10502],{},[52,10563,10564],{},"%40",[52,10566,9966],{},[52,10568,10569],{},"%23",") — works but makes the string brittle to edit later.",[28,10572],{},[11,10574,1189],{"id":1188},[16,10576,10577,10578,10580],{},"The complete ",[52,10579,9955],{}," after all fixes:",[332,10582,10583],{"className":5574,"code":10320,"language":5576,"meta":337,"style":337},[52,10584,10585,10593,10605,10609,10619,10623,10635,10647,10651],{"__ignoreMap":337},[341,10586,10587,10589,10591],{"class":343,"line":344},[341,10588,3766],{"class":1080},[341,10590,10227],{"class":361},[341,10592,10230],{"class":347},[341,10594,10595,10597,10599,10601,10603],{"class":343,"line":351},[341,10596,3766],{"class":1080},[341,10598,10237],{"class":347},[341,10600,10240],{"class":1080},[341,10602,10243],{"class":361},[341,10604,10230],{"class":347},[341,10606,10607],{"class":343,"line":368},[341,10608,703],{"emptyLinePlaceholder":702},[341,10610,10611,10613,10615,10617],{"class":343,"line":381},[341,10612,5602],{"class":1080},[341,10614,5605],{"class":1080},[341,10616,10258],{"class":679},[341,10618,1834],{"class":347},[341,10620,10621],{"class":343,"line":390},[341,10622,10265],{"class":347},[341,10624,10625,10627,10629,10631,10633],{"class":343,"line":396},[341,10626,10270],{"class":347},[341,10628,10273],{"class":679},[341,10630,2977],{"class":347},[341,10632,10278],{"class":361},[341,10634,10281],{"class":347},[341,10636,10637,10639,10641,10643,10645],{"class":343,"line":409},[341,10638,10377],{"class":347},[341,10640,10273],{"class":679},[341,10642,2977],{"class":347},[341,10644,10384],{"class":361},[341,10646,10281],{"class":347},[341,10648,10649],{"class":343,"line":420},[341,10650,1299],{"class":347},[341,10652,10653],{"class":343,"line":426},[341,10654,10290],{"class":347},[16,10656,10577,10657,10659],{},[52,10658,10484],{}," \u002F Vercel environment variable set:",[332,10661,10663],{"className":9993,"code":10662,"language":9995,"meta":337,"style":337},"# Vercel runtime — connection pool, serverless-safe\nDATABASE_URL=postgresql:\u002F\u002Fpostgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543\u002Fpostgres?pgbouncer=true&connection_limit=1\n\n# Prisma migrations — direct connection, bypasses PgBouncer\nDIRECT_DATABASE_URL=postgresql:\u002F\u002Fpostgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:5432\u002Fpostgres?family=4\n\n# Required by Umami\nAPP_SECRET=any-random-string-you-choose\n",[52,10664,10665,10670,10694,10698,10703,10721,10725,10730],{"__ignoreMap":337},[341,10666,10667],{"class":343,"line":344},[341,10668,10669],{"class":667},"# Vercel runtime — connection pool, serverless-safe\n",[341,10671,10672,10674,10676,10678,10680,10682,10684,10686,10688,10690,10692],{"class":343,"line":351},[341,10673,9889],{"class":347},[341,10675,683],{"class":1080},[341,10677,10139],{"class":361},[341,10679,10142],{"class":347},[341,10681,889],{"class":1080},[341,10683,10147],{"class":347},[341,10685,683],{"class":1080},[341,10687,3488],{"class":361},[341,10689,10154],{"class":347},[341,10691,683],{"class":1080},[341,10693,10159],{"class":361},[341,10695,10696],{"class":343,"line":368},[341,10697,703],{"emptyLinePlaceholder":702},[341,10699,10700],{"class":343,"line":381},[341,10701,10702],{"class":667},"# Prisma migrations — direct connection, bypasses PgBouncer\n",[341,10704,10705,10707,10709,10711,10713,10715,10717,10719],{"class":343,"line":390},[341,10706,9892],{"class":347},[341,10708,683],{"class":1080},[341,10710,10139],{"class":361},[341,10712,10468],{"class":347},[341,10714,889],{"class":1080},[341,10716,10473],{"class":347},[341,10718,683],{"class":1080},[341,10720,10478],{"class":361},[341,10722,10723],{"class":343,"line":396},[341,10724,703],{"emptyLinePlaceholder":702},[341,10726,10727],{"class":343,"line":409},[341,10728,10729],{"class":667},"# Required by Umami\n",[341,10731,10732,10735,10737],{"class":343,"line":420},[341,10733,10734],{"class":347},"APP_SECRET",[341,10736,683],{"class":1080},[341,10738,10739],{"class":361},"any-random-string-you-choose\n",[16,10741,10742,10743,1885,10746,4708,10749,10752,10753,10755,10756,10759],{},"Substitute ",[52,10744,10745],{},"[project-ref]",[52,10747,10748],{},"[region]",[52,10750,10751],{},"[password]"," with values from your Supabase Connect page. The ",[52,10754,10745],{}," looks like ",[52,10757,10758],{},"abcdefghijklmnop"," and is already embedded in the strings Supabase gives you — you don't need to find it separately.",[28,10761],{},[11,10763,745],{"id":744},[16,10765,10766,10767,319,10769,10771,10772,10775],{},"The official Umami documentation does mention both ",[52,10768,9889],{},[52,10770,9892],{},". What it doesn't explain is the ",[257,10773,10774],{},"reason"," — that PgBouncer in transaction mode blocks the advisory locks Prisma's migration engine depends on. Without understanding why, the two-URL requirement looks like an arbitrary configuration detail you might skip. That's exactly what I did on the first attempt.",[16,10777,438,10778,10780,10781,10783,10784,10786],{},[52,10779,9955],{}," override is the nastier trap because it's a file in the repository that silently wins over your environment variables. If you set ",[52,10782,9892],{}," correctly in Vercel but the config file doesn't map it to ",[52,10785,10296],{},", Prisma ignores your variable entirely. The fix is two lines, but you have to know to look for the file — and nothing in the build output points you there.",[16,10788,10789],{},"This architecture — pooler for runtime, direct for migrations — isn't Umami-specific. Any application using Prisma with Supabase will hit the same constraint. Needing two database URLs is becoming standard for serverless Prisma deployments, but the tooling doesn't make that obvious at setup time.",[28,10791],{},[11,10793,772],{"id":771},[16,10795,10796,10797,10799,10800,816],{},"With both URLs set correctly and ",[52,10798,9955],{}," updated, the local development server started cleanly and the Umami login page loaded at ",[52,10801,10802],{},"localhost:3000",[16,10804,10805],{},[67,10806],{"alt":10807,"src":10808},"Umami login page running locally at localhost:3000 after successful database connection","\u002Fimages\u002Fstartup-diary\u002Fself-hosting-umami\u002Fself-hosting-umami-part-2-local-login.webp",[16,10810,10811,10812,10814],{},"The database connection was stable, migrations ran cleanly through ",[52,10813,9892],{},", and runtime queries routed through the PgBouncer pool on port 6543 as intended.",[16,10816,10817],{},"Vercel deployment, however, still had one more blocker — not related to the database connection at all. That's the subject of Part 3.",[28,10819],{},[11,10821,792],{"id":791},[16,10823,10824,10827,10828,10830,10831,10833],{},[60,10825,10826],{},"PgBouncer in transaction mode blocks Prisma migrations."," This isn't a bug or a misconfiguration — it's an architectural incompatibility. Prisma's migration engine uses PostgreSQL advisory locks that PgBouncer doesn't forward in transaction pooling mode. Always use a direct connection (port 5432) for ",[52,10829,9885],{},", regardless of what your runtime ",[52,10832,9889],{}," points to.",[16,10835,10836,10841,10842,10844,10845,10847],{},[60,10837,10838,10840],{},[52,10839,9955],{}," wins over environment variables."," If this file exists in your project and doesn't map ",[52,10843,10296],{},", your ",[52,10846,9892],{}," variable is effectively ignored. Check this file first when Prisma migration behavior doesn't match what your env vars suggest.",[16,10849,10850,10853,10854,10856,10857,10859,10860,10862],{},[60,10851,10852],{},"IPv6 preference is a local network issue, not a Supabase issue."," The ",[52,10855,10448],{}," parameter forces IPv4 and resolves the ",[52,10858,10441],{}," error without touching anything else. It's safe to include in your ",[52,10861,9892],{}," for both local and Vercel environments.",[16,10864,10865,10868],{},[60,10866,10867],{},"Supabase passwords with special characters will break URL parsing."," Set a clean alphanumeric password before you start. Fixing it after you've already embedded the encoded version in multiple places is tedious.",[28,10870],{},[16,10872,10873],{},[257,10874,10875,10876,9905,10880],{},"Part of the \"Self-Hosting Umami on Vercel + Supabase\" series. ",[20,10877,10879],{"href":10878},"\u002Fself-hosting-umami-part-1","← Part 1: Why I Left Umami Cloud",[20,10881,10882],{"href":9908},"Part 3: GeoIP, Migration Bypass, and Git LFS →",[894,10884,10885],{},"html pre.shiki code .se37E, html code.shiki .se37E{--shiki-default:#6F42C1;--shiki-dark:#6F42C1}html pre.shiki code .sOTlB, html code.shiki .sOTlB{--shiki-default:#032F62;--shiki-dark:#032F62}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sKWpL, html code.shiki .sKWpL{--shiki-default:#24292E;--shiki-dark:#24292E}html pre.shiki code .sCydW, html code.shiki .sCydW{--shiki-default:#D73A49;--shiki-dark:#D73A49}",{"title":337,"searchDepth":351,"depth":351,"links":10887},[10888,10889,10890,10891,10901,10902,10903,10904],{"id":13,"depth":351,"text":14},{"id":972,"depth":351,"text":973},{"id":9986,"depth":351,"text":9987},{"id":1054,"depth":351,"text":1055,"children":10892},[10893,10895,10896,10898,10899],{"id":10069,"depth":368,"text":10894},"Why prisma migrate deploy Can't Use the Connection Pool",{"id":10116,"depth":368,"text":10117},{"id":10203,"depth":368,"text":10897},"prisma.config.ts Was Silently Overriding My DIRECT_DATABASE_URL",{"id":10409,"depth":368,"text":10410},{"id":10488,"depth":368,"text":10900},"A Password With ! and # Broke the URL Parser",{"id":1188,"depth":351,"text":1189},{"id":744,"depth":351,"text":745},{"id":771,"depth":351,"text":772},{"id":791,"depth":351,"text":792},"The official docs say set DATABASE_URL and deploy. What they don't tell you: two different connection strings, a silent port mismatch, and a prisma.config.ts that overrides everything.",{"date":10907,"category":4871,"readTime":10908,"tags":10909,"image":10912,"draft":935,"series":9927,"seriesOrder":351},"2026-07-01","9mins",[5501,10910,9925,10911],"#database","#opensource","https:\u002F\u002Fassets.kbmjj123.cc\u002Fblog\u002Fdev-practice\u002Fself-hosting-umami-part-2\u002Fpart-2-prisma-config.png","\u002Fposts\u002Fself-hosting-umami-part-2",{"title":10915,"description":10916,"keywords":10917},"Umami Vercel Supabase Deployment: DATABASE_URL vs DIRECT_DATABASE_URL","Fix the silent build hang when deploying Umami to Vercel with Supabase. Covers the two-URL requirement, port 6543 vs 5432, prisma.config.ts override, and IPv6 fallback errors.",[10918,10919,10920,10921,10922],"umami vercel supabase deployment","umami DATABASE_URL DIRECT_DATABASE_URL","prisma migrate deploy hanging vercel","supabase transaction pooler direct connection","prisma config ts datasource url","posts\u002Fself-hosting-umami-part-2","LbJGj0WtzrlLX9l6De2LB2PUhJDD4L7CePntfl6aFBg",{"id":10926,"title":10927,"body":10928,"description":12041,"extension":925,"meta":12042,"navigation":702,"path":12047,"seo":12048,"stem":12057,"__hash__":12058},"posts\u002Fposts\u002Fself-hosting-umami-part-3.md","The Last Two Blockers: GeoIP Download and Manual Migration on Vercel",{"type":8,"value":10929,"toc":12025},[10930,10932,10946,10948,10950,10959,10962,10998,11001,11003,11007,11018,11021,11024,11029,11032,11062,11069,11073,11076,11079,11084,11111,11114,11133,11140,11163,11174,11182,11209,11222,11227,11230,11283,11292,11297,11331,11334,11367,11373,11375,11382,11391,11394,11403,11407,11410,11424,11435,11514,11520,11523,11527,11534,11537,11542,11545,11706,11709,11714,11721,11732,11738,11741,11746,11749,11762,11775,11781,11783,11787,11790,11803,11806,11867,11873,11880,11882,11884,11887,11890,11896,11903,11915,11917,11919,11925,11931,11957,11965,11971,11973,11977,11980,12001,12004,12011,12013,12022],[11,10931,14],{"id":13},[16,10933,10934,10935,10938,10939,10941,10942,10945],{},"After fixing the database connection strings in ",[20,10936,10937],{"href":9966},"Part 2",", the Vercel build still hung — this time silently, with no error output at all. The cause was two separate issues: a 66MB GeoIP database file that the build tried to download at compile time and couldn't, and the ",[52,10940,9885],{}," step that couldn't reach Supabase's direct connection port from the build environment. The solution was to pre-bundle the GeoIP file in the repository using Git LFS, skip the build-time migration entirely with ",[52,10943,10944],{},"SKIP_DB_MIGRATION=1",", and run the database schema manually through Supabase's SQL Editor.",[28,10947],{},[11,10949,973],{"id":972},[16,10951,10952,10953,10955,10956,10958],{},"At the end of Part 2, the local development server was running cleanly. Both connection strings were correct, ",[52,10954,9955],{}," was updated, and Umami loaded at ",[52,10957,10802],{},". The logical next step was to push to GitHub and let Vercel handle the deployment.",[16,10960,10961],{},"The Vercel build log reached the same three green checkmarks as before:",[332,10963,10964],{"className":9993,"code":9994,"language":9995,"meta":337,"style":337},[52,10965,10966,10976,10986],{"__ignoreMap":337},[341,10967,10968,10970,10972,10974],{"class":343,"line":344},[341,10969,10002],{"class":679},[341,10971,10005],{"class":361},[341,10973,10008],{"class":361},[341,10975,10011],{"class":361},[341,10977,10978,10980,10982,10984],{"class":343,"line":351},[341,10979,10002],{"class":679},[341,10981,10018],{"class":361},[341,10983,10021],{"class":361},[341,10985,10024],{"class":361},[341,10987,10988,10990,10992,10994,10996],{"class":343,"line":368},[341,10989,10002],{"class":679},[341,10991,10018],{"class":361},[341,10993,10033],{"class":361},[341,10995,10036],{"class":361},[341,10997,10024],{"class":361},[16,10999,11000],{},"And then stopped. Same symptom as the connection string problem from Part 2, but the database connection was now confirmed working. Something else was hanging the build.",[28,11002],{},[11,11004,11006],{"id":11005},"problem-1-the-66mb-geoip-file","Problem 1: The 66MB GeoIP File",[16,11008,11009,11010,11013,11014,11017],{},"Umami uses MaxMind's GeoLite2-City database to resolve visitor IP addresses to geographic locations. It's a 66MB binary file in MaxMind's ",[52,11011,11012],{},".mmdb"," format. In Umami's build process, if this file isn't already present in the ",[52,11015,11016],{},"geo\u002F"," directory, the build script attempts to download it at compile time.",[16,11019,11020],{},"On a local machine with a fast connection, this download completes in a few seconds and you never notice it. On Vercel's build environment, the download either times out silently or gets blocked by network policy — the build just hangs waiting for a file that never arrives, with no timeout error surfaced to the log.",[16,11022,11023],{},"The build log shows nothing after the database checks because the hang happens inside a network request with no surrounding log output. You only find this by going looking for it specifically — nothing points you here on its own.",[16,11025,11026],{},[60,11027,11028],{},"How to confirm this is the issue:",[16,11030,11031],{},"Look for the GeoIP download logic in the source. In the version I forked, the relevant code lives in the build scripts. A quick search confirms it:",[332,11033,11035],{"className":9993,"code":11034,"language":9995,"meta":337,"style":337},"grep -r \"GeoLite2\\|mmdb\\|geo\u002F\" --include=\"*.ts\" --include=\"*.js\" -l\n",[52,11036,11037],{"__ignoreMap":337},[341,11038,11039,11042,11045,11048,11051,11054,11056,11059],{"class":343,"line":344},[341,11040,11041],{"class":679},"grep",[341,11043,11044],{"class":354}," -r",[341,11046,11047],{"class":361}," \"GeoLite2\\|mmdb\\|geo\u002F\"",[341,11049,11050],{"class":354}," --include=",[341,11052,11053],{"class":361},"\"*.ts\"",[341,11055,11050],{"class":354},[341,11057,11058],{"class":361},"\"*.js\"",[341,11060,11061],{"class":354}," -l\n",[16,11063,11064,11065,11068],{},"If you see files referencing ",[52,11066,11067],{},"GeoLite2-City.mmdb"," and a download URL, this is your blocker.",[305,11070,11072],{"id":11071},"pre-bundling-geolite2-citymmdb-via-git-lfs-eliminated-the-download","Pre-bundling GeoLite2-City.mmdb via Git LFS Eliminated the Download",[16,11074,11075],{},"The fix is to download the file once locally and commit it to the repository. Vercel clones your repo at build time, so the file is already present — no download needed.",[16,11077,11078],{},"The catch: at 66MB, the file exceeds GitHub's 50MB soft limit for regular git objects. Pushing it without Git LFS will fail or produce a warning, and on subsequent clones the file won't transfer correctly.",[16,11080,11081],{},[60,11082,11083],{},"Step 1 — Install Git LFS",[332,11085,11087],{"className":9993,"code":11086,"language":9995,"meta":337,"style":337},"brew install git-lfs\ngit lfs install\n",[52,11088,11089,11100],{"__ignoreMap":337},[341,11090,11091,11094,11097],{"class":343,"line":344},[341,11092,11093],{"class":679},"brew",[341,11095,11096],{"class":361}," install",[341,11098,11099],{"class":361}," git-lfs\n",[341,11101,11102,11105,11108],{"class":343,"line":351},[341,11103,11104],{"class":679},"git",[341,11106,11107],{"class":361}," lfs",[341,11109,11110],{"class":361}," install\n",[16,11112,11113],{},"Verify it's in your PATH before proceeding:",[332,11115,11117],{"className":9993,"code":11116,"language":9995,"meta":337,"style":337},"git lfs version\n# git-lfs\u002F3.x.x (GitHub; darwin arm64; go 1.x.x)\n",[52,11118,11119,11128],{"__ignoreMap":337},[341,11120,11121,11123,11125],{"class":343,"line":344},[341,11122,11104],{"class":679},[341,11124,11107],{"class":361},[341,11126,11127],{"class":361}," version\n",[341,11129,11130],{"class":343,"line":351},[341,11131,11132],{"class":667},"# git-lfs\u002F3.x.x (GitHub; darwin arm64; go 1.x.x)\n",[16,11134,11135,11136,11139],{},"If ",[52,11137,11138],{},"git lfs version"," returns \"command not found\" after installation, your shell's PATH doesn't include Homebrew's bin directory. Fix it:",[332,11141,11143],{"className":9993,"code":11142,"language":9995,"meta":337,"style":337},"export PATH=\"\u002Fopt\u002Fhomebrew\u002Fbin:$PATH\"\n",[52,11144,11145],{"__ignoreMap":337},[341,11146,11147,11149,11152,11154,11157,11160],{"class":343,"line":344},[341,11148,5602],{"class":1080},[341,11150,11151],{"class":347}," PATH",[341,11153,683],{"class":1080},[341,11155,11156],{"class":361},"\"\u002Fopt\u002Fhomebrew\u002Fbin:",[341,11158,11159],{"class":347},"$PATH",[341,11161,11162],{"class":361},"\"\n",[16,11164,11165,11166,11169,11170,11173],{},"Add this line to your ",[52,11167,11168],{},"~\u002F.zshrc"," or ",[52,11171,11172],{},"~\u002F.bash_profile"," to make it permanent.",[16,11175,11176],{},[60,11177,11178,11179,11181],{},"Step 2 — Track ",[52,11180,11012],{}," files",[332,11183,11185],{"className":9993,"code":11184,"language":9995,"meta":337,"style":337},"git lfs track \"*.mmdb\"\ngit add .gitattributes\n",[52,11186,11187,11199],{"__ignoreMap":337},[341,11188,11189,11191,11193,11196],{"class":343,"line":344},[341,11190,11104],{"class":679},[341,11192,11107],{"class":361},[341,11194,11195],{"class":361}," track",[341,11197,11198],{"class":361}," \"*.mmdb\"\n",[341,11200,11201,11203,11206],{"class":343,"line":351},[341,11202,11104],{"class":679},[341,11204,11205],{"class":361}," add",[341,11207,11208],{"class":361}," .gitattributes\n",[16,11210,11211,11212,11215,11216,11218,11219,11221],{},"This creates or updates ",[52,11213,11214],{},".gitattributes"," to tell Git LFS to handle all ",[52,11217,11012],{}," files. Commit ",[52,11220,11214],{}," first — if you add the binary before telling LFS to track it, the file gets committed as a regular git object and you'll have to undo it.",[16,11223,11224],{},[60,11225,11226],{},"Step 3 — Download the GeoIP file",[16,11228,11229],{},"MaxMind requires a free account to download GeoLite2 databases. Once you have a license key:",[332,11231,11233],{"className":9993,"code":11232,"language":9995,"meta":337,"style":337},"mkdir -p geo\ncurl -L \"https:\u002F\u002Fdownload.maxmind.com\u002Fapp\u002Fgeoip_download?edition_id=GeoLite2-City&license_key=YOUR_KEY&suffix=tar.gz\" \\\n  | tar -xz --strip-components=1 -C geo \"*\u002FGeoLite2-City.mmdb\"\n",[52,11234,11235,11246,11260],{"__ignoreMap":337},[341,11236,11237,11240,11243],{"class":343,"line":344},[341,11238,11239],{"class":679},"mkdir",[341,11241,11242],{"class":354}," -p",[341,11244,11245],{"class":361}," geo\n",[341,11247,11248,11251,11254,11257],{"class":343,"line":351},[341,11249,11250],{"class":679},"curl",[341,11252,11253],{"class":354}," -L",[341,11255,11256],{"class":361}," \"https:\u002F\u002Fdownload.maxmind.com\u002Fapp\u002Fgeoip_download?edition_id=GeoLite2-City&license_key=YOUR_KEY&suffix=tar.gz\"",[341,11258,11259],{"class":354}," \\\n",[341,11261,11262,11265,11268,11271,11274,11277,11280],{"class":343,"line":368},[341,11263,11264],{"class":1080},"  |",[341,11266,11267],{"class":679}," tar",[341,11269,11270],{"class":354}," -xz",[341,11272,11273],{"class":354}," --strip-components=1",[341,11275,11276],{"class":354}," -C",[341,11278,11279],{"class":361}," geo",[341,11281,11282],{"class":361}," \"*\u002FGeoLite2-City.mmdb\"\n",[16,11284,11285,11286,11288,11289,11291],{},"Or if you downloaded it manually, just place the ",[52,11287,11012],{}," file in the ",[52,11290,11016],{}," directory at the project root.",[16,11293,11294],{},[60,11295,11296],{},"Step 4 — Commit and push",[332,11298,11300],{"className":9993,"code":11299,"language":9995,"meta":337,"style":337},"git add geo\u002FGeoLite2-City.mmdb\ngit commit -m \"add GeoLite2-City database for IP geolocation\"\ngit push\n",[52,11301,11302,11311,11324],{"__ignoreMap":337},[341,11303,11304,11306,11308],{"class":343,"line":344},[341,11305,11104],{"class":679},[341,11307,11205],{"class":361},[341,11309,11310],{"class":361}," geo\u002FGeoLite2-City.mmdb\n",[341,11312,11313,11315,11318,11321],{"class":343,"line":351},[341,11314,11104],{"class":679},[341,11316,11317],{"class":361}," commit",[341,11319,11320],{"class":354}," -m",[341,11322,11323],{"class":361}," \"add GeoLite2-City database for IP geolocation\"\n",[341,11325,11326,11328],{"class":343,"line":368},[341,11327,11104],{"class":679},[341,11329,11330],{"class":361}," push\n",[16,11332,11333],{},"Watch the push output. With Git LFS correctly configured, you'll see something like:",[332,11335,11337],{"className":9993,"code":11336,"language":9995,"meta":337,"style":337},"Uploading LFS objects: 100% (1\u002F1), 66 MB | 2.3 MB\u002Fs, done.\n",[52,11338,11339],{"__ignoreMap":337},[341,11340,11341,11344,11347,11350,11353,11356,11358,11361,11364],{"class":343,"line":344},[341,11342,11343],{"class":679},"Uploading",[341,11345,11346],{"class":361}," LFS",[341,11348,11349],{"class":361}," objects:",[341,11351,11352],{"class":361}," 100%",[341,11354,11355],{"class":347}," (1\u002F1), 66 MB ",[341,11357,8241],{"class":1080},[341,11359,11360],{"class":679}," 2.3",[341,11362,11363],{"class":361}," MB\u002Fs,",[341,11365,11366],{"class":361}," done.\n",[16,11368,11369,11370,11372],{},"If you see the regular git progress bar instead of the LFS upload line, LFS isn't tracking the file — go back and verify ",[52,11371,11214],{}," was committed before the binary.",[28,11374],{},[11,11376,11378,11379,11381],{"id":11377},"problem-2-prisma-migrate-deploy-blocked-from-vercels-build-environment","Problem 2: ",[52,11380,9885],{}," Blocked from Vercel's Build Environment",[16,11383,11384,11385,11387,11388,11390],{},"Even with the GeoIP file pre-bundled, the ",[52,11386,10055],{}," build script still tried to run ",[52,11389,9885],{}," — which required a direct database connection on port 5432. From Vercel's build environment, this connection was blocked: either by network policy, or because Supabase's direct connection endpoint wasn't reachable from Vercel's build infrastructure at the time.",[16,11392,11393],{},"This is a different network context from Vercel's runtime. The serverless functions that run your app have different network access than the build container that compiles it. The direct connection worked fine locally (with a proxy), but the build environment is not the same environment as your laptop.",[16,11395,11396,11397,11399,11400,11402],{},"The result: ",[52,11398,9885],{}," hung inside ",[52,11401,10055],{},", and the build timed out.",[305,11404,11406],{"id":11405},"skip_db_migration1-got-the-build-past-the-hang","SKIP_DB_MIGRATION=1 Got the Build Past the Hang",[16,11408,11409],{},"Add this environment variable to Vercel:",[332,11411,11413],{"className":9993,"code":11412,"language":9995,"meta":337,"style":337},"SKIP_DB_MIGRATION=1\n",[52,11414,11415],{"__ignoreMap":337},[341,11416,11417,11420,11422],{"class":343,"line":344},[341,11418,11419],{"class":347},"SKIP_DB_MIGRATION",[341,11421,683],{"class":1080},[341,11423,10159],{"class":361},[16,11425,11426,11427,11429,11430,11432,11433,562],{},"In ",[52,11428,10055],{},", the ",[52,11431,10051],{}," function checks for this variable before running ",[52,11434,9885],{},[332,11436,11438],{"className":5574,"code":11437,"language":5576,"meta":337,"style":337},"async function applyMigration() {\n  if (!process.env.SKIP_DB_MIGRATION) {\n    console.log(execSync('prisma migrate deploy').toString());\n    success('Database is up to date.');\n  }\n}\n",[52,11439,11440,11451,11466,11493,11506,11510],{"__ignoreMap":337},[341,11441,11442,11444,11446,11449],{"class":343,"line":344},[341,11443,3923],{"class":1080},[341,11445,3926],{"class":1080},[341,11447,11448],{"class":679}," applyMigration",[341,11450,3566],{"class":347},[341,11452,11453,11455,11457,11459,11462,11464],{"class":343,"line":351},[341,11454,2554],{"class":1080},[341,11456,2587],{"class":347},[341,11458,2712],{"class":1080},[341,11460,11461],{"class":347},"process.env.",[341,11463,11419],{"class":354},[341,11465,2566],{"class":347},[341,11467,11468,11471,11474,11476,11479,11481,11484,11487,11490],{"class":343,"line":368},[341,11469,11470],{"class":347},"    console.",[341,11472,11473],{"class":679},"log",[341,11475,2977],{"class":347},[341,11477,11478],{"class":679},"execSync",[341,11480,2977],{"class":347},[341,11482,11483],{"class":361},"'prisma migrate deploy'",[341,11485,11486],{"class":347},").",[341,11488,11489],{"class":679},"toString",[341,11491,11492],{"class":347},"());\n",[341,11494,11495,11498,11500,11503],{"class":343,"line":381},[341,11496,11497],{"class":679},"    success",[341,11499,2977],{"class":347},[341,11501,11502],{"class":361},"'Database is up to date.'",[341,11504,11505],{"class":347},");\n",[341,11507,11508],{"class":343,"line":390},[341,11509,2626],{"class":347},[341,11511,11512],{"class":343,"line":396},[341,11513,435],{"class":347},[16,11515,11516,11517,11519],{},"With ",[52,11518,10944],{}," set, this function exits immediately. The build completes. Vercel deploys successfully.",[16,11521,11522],{},"The trade-off: schema migrations and deployments are now separate steps. That's a deliberate pattern, not a compromise — but it does mean running migrations manually every time the schema changes.",[305,11524,11526],{"id":11525},"running-the-concatenated-migration-sql-manually-in-supabase-created-the-missing-tables","Running the Concatenated Migration SQL Manually in Supabase Created the Missing Tables",[16,11528,11529,11530,11533],{},"With the build-time migration bypassed, the database tables don't exist yet. Umami will fail on login because the ",[52,11531,11532],{},"user"," table (and everything else) hasn't been created.",[16,11535,11536],{},"The approach: concatenate all of Umami's migration SQL files into one script and execute it directly in Supabase's SQL Editor.",[16,11538,11539],{},[60,11540,11541],{},"Step 1 — Concatenate all migration files",[16,11543,11544],{},"From the project root:",[332,11546,11548],{"className":9993,"code":11547,"language":9995,"meta":337,"style":337},"cat prisma\u002Fmigrations\u002F01_init\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F02_report_schema_session_data\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F03_metric_performance_index\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F04_team_redesign\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F05_add_visit_id\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F06_session_data\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F07_add_tag\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F08_add_utm_clid\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F09_update_hostname_region\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F10_add_distinct_id\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F11_add_segment\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F12_update_report_parameter\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F13_add_revenue\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F14_add_link_and_pixel\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F15_add_share\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F16_boards\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F17_remove_duplicate_key\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F18_add_performance\u002Fmigration.sql \\\n    prisma\u002Fmigrations\u002F19_add_session_replay\u002Fmigration.sql \\\n    > \u002Ftmp\u002Fumami_all.sql\n\necho \"Done. File at \u002Ftmp\u002Fumami_all.sql\"\n",[52,11549,11550,11560,11567,11574,11581,11588,11595,11602,11609,11616,11623,11630,11637,11644,11651,11658,11665,11672,11679,11686,11694,11698],{"__ignoreMap":337},[341,11551,11552,11555,11558],{"class":343,"line":344},[341,11553,11554],{"class":679},"cat",[341,11556,11557],{"class":361}," prisma\u002Fmigrations\u002F01_init\u002Fmigration.sql",[341,11559,11259],{"class":354},[341,11561,11562,11565],{"class":343,"line":351},[341,11563,11564],{"class":361},"    prisma\u002Fmigrations\u002F02_report_schema_session_data\u002Fmigration.sql",[341,11566,11259],{"class":354},[341,11568,11569,11572],{"class":343,"line":368},[341,11570,11571],{"class":361},"    prisma\u002Fmigrations\u002F03_metric_performance_index\u002Fmigration.sql",[341,11573,11259],{"class":354},[341,11575,11576,11579],{"class":343,"line":381},[341,11577,11578],{"class":361},"    prisma\u002Fmigrations\u002F04_team_redesign\u002Fmigration.sql",[341,11580,11259],{"class":354},[341,11582,11583,11586],{"class":343,"line":390},[341,11584,11585],{"class":361},"    prisma\u002Fmigrations\u002F05_add_visit_id\u002Fmigration.sql",[341,11587,11259],{"class":354},[341,11589,11590,11593],{"class":343,"line":396},[341,11591,11592],{"class":361},"    prisma\u002Fmigrations\u002F06_session_data\u002Fmigration.sql",[341,11594,11259],{"class":354},[341,11596,11597,11600],{"class":343,"line":409},[341,11598,11599],{"class":361},"    prisma\u002Fmigrations\u002F07_add_tag\u002Fmigration.sql",[341,11601,11259],{"class":354},[341,11603,11604,11607],{"class":343,"line":420},[341,11605,11606],{"class":361},"    prisma\u002Fmigrations\u002F08_add_utm_clid\u002Fmigration.sql",[341,11608,11259],{"class":354},[341,11610,11611,11614],{"class":343,"line":426},[341,11612,11613],{"class":361},"    prisma\u002Fmigrations\u002F09_update_hostname_region\u002Fmigration.sql",[341,11615,11259],{"class":354},[341,11617,11618,11621],{"class":343,"line":432},[341,11619,11620],{"class":361},"    prisma\u002Fmigrations\u002F10_add_distinct_id\u002Fmigration.sql",[341,11622,11259],{"class":354},[341,11624,11625,11628],{"class":343,"line":1346},[341,11626,11627],{"class":361},"    prisma\u002Fmigrations\u002F11_add_segment\u002Fmigration.sql",[341,11629,11259],{"class":354},[341,11631,11632,11635],{"class":343,"line":1351},[341,11633,11634],{"class":361},"    prisma\u002Fmigrations\u002F12_update_report_parameter\u002Fmigration.sql",[341,11636,11259],{"class":354},[341,11638,11639,11642],{"class":343,"line":1357},[341,11640,11641],{"class":361},"    prisma\u002Fmigrations\u002F13_add_revenue\u002Fmigration.sql",[341,11643,11259],{"class":354},[341,11645,11646,11649],{"class":343,"line":1371},[341,11647,11648],{"class":361},"    prisma\u002Fmigrations\u002F14_add_link_and_pixel\u002Fmigration.sql",[341,11650,11259],{"class":354},[341,11652,11653,11656],{"class":343,"line":1384},[341,11654,11655],{"class":361},"    prisma\u002Fmigrations\u002F15_add_share\u002Fmigration.sql",[341,11657,11259],{"class":354},[341,11659,11660,11663],{"class":343,"line":1397},[341,11661,11662],{"class":361},"    prisma\u002Fmigrations\u002F16_boards\u002Fmigration.sql",[341,11664,11259],{"class":354},[341,11666,11667,11670],{"class":343,"line":1402},[341,11668,11669],{"class":361},"    prisma\u002Fmigrations\u002F17_remove_duplicate_key\u002Fmigration.sql",[341,11671,11259],{"class":354},[341,11673,11674,11677],{"class":343,"line":1610},[341,11675,11676],{"class":361},"    prisma\u002Fmigrations\u002F18_add_performance\u002Fmigration.sql",[341,11678,11259],{"class":354},[341,11680,11681,11684],{"class":343,"line":1625},[341,11682,11683],{"class":361},"    prisma\u002Fmigrations\u002F19_add_session_replay\u002Fmigration.sql",[341,11685,11259],{"class":354},[341,11687,11688,11691],{"class":343,"line":1632},[341,11689,11690],{"class":1080},"    >",[341,11692,11693],{"class":361}," \u002Ftmp\u002Fumami_all.sql\n",[341,11695,11696],{"class":343,"line":1642},[341,11697,703],{"emptyLinePlaceholder":702},[341,11699,11700,11703],{"class":343,"line":1652},[341,11701,11702],{"class":354},"echo",[341,11704,11705],{"class":361}," \"Done. File at \u002Ftmp\u002Fumami_all.sql\"\n",[16,11707,11708],{},"The order matters — these migrations build on each other. Run them in sequence, which is what this command does by concatenating them in numbered order.",[16,11710,11711],{},[60,11712,11713],{},"Step 2 — Execute in Supabase SQL Editor",[16,11715,11716,11717,11720],{},"Open ",[52,11718,11719],{},"\u002Ftmp\u002Fumami_all.sql"," in a text editor, select all, copy. Then:",[275,11722,11723,11726,11729],{},[181,11724,11725],{},"Go to your Supabase project → SQL Editor → New query",[181,11727,11728],{},"Paste the SQL",[181,11730,11731],{},"Click Run",[16,11733,11734],{},[67,11735],{"alt":11736,"src":11737},"Flow diagram comparing the original failing build path versus the final working solution: skip migration in Vercel build, run SQL manually in Supabase","\u002Fimages\u002Fstartup-diary\u002Fself-hosting-umami\u002Fself-hosting-umami-part-3-migration-bypass-diagram.svg",[16,11739,11740],{},"If the SQL runs without errors, you'll see a success message and the table count in Supabase's Table Editor should jump from 0 to around 15 tables.",[16,11742,11743],{},[60,11744,11745],{},"Step 3 — Verify locally before deploying",[16,11747,11748],{},"Before pushing to Vercel, confirm the tables exist and the app can use them:",[332,11750,11752],{"className":9993,"code":11751,"language":9995,"meta":337,"style":337},"pnpm dev\n",[52,11753,11754],{"__ignoreMap":337},[341,11755,11756,11759],{"class":343,"line":344},[341,11757,11758],{"class":679},"pnpm",[341,11760,11761],{"class":361}," dev\n",[16,11763,11764,11765,11767,11768,6694,11771,11774],{},"Navigate to ",[52,11766,10802],{},", log in with the default credentials (",[52,11769,11770],{},"admin",[52,11772,11773],{},"umami","), and change the password immediately. If login succeeds, the schema is complete and the app is functional.",[16,11776,11777],{},[67,11778],{"alt":11779,"src":11780},"Umami dashboard after successful login, showing the analytics interface with a website added","\u002Fimages\u002Fstartup-diary\u002Fself-hosting-umami\u002Fself-hosting-umami-part-3-umami-dashboard.webp",[28,11782],{},[11,11784,11786],{"id":11785},"the-final-vercel-deployment","The Final Vercel Deployment",[16,11788,11789],{},"With both blockers resolved:",[275,11791,11792,11795,11800],{},[181,11793,11794],{},"GeoLite2-City.mmdb committed to the repo via Git LFS",[181,11796,11797,11799],{},[52,11798,10944],{}," set in Vercel environment variables",[181,11801,11802],{},"Database schema applied manually via Supabase SQL Editor",[16,11804,11805],{},"Push to GitHub and trigger a Vercel deployment. The build log now runs through cleanly:",[332,11807,11809],{"className":9993,"code":11808,"language":9995,"meta":337,"style":337},"✓ DATABASE_URL is defined.\n✓ Database connection successful.\n✓ Database version check successful.\nSkipping database migration.\n✓ Build completed successfully.\n",[52,11810,11811,11821,11831,11843,11854],{"__ignoreMap":337},[341,11812,11813,11815,11817,11819],{"class":343,"line":344},[341,11814,10002],{"class":679},[341,11816,10005],{"class":361},[341,11818,10008],{"class":361},[341,11820,10011],{"class":361},[341,11822,11823,11825,11827,11829],{"class":343,"line":351},[341,11824,10002],{"class":679},[341,11826,10018],{"class":361},[341,11828,10021],{"class":361},[341,11830,10024],{"class":361},[341,11832,11833,11835,11837,11839,11841],{"class":343,"line":368},[341,11834,10002],{"class":679},[341,11836,10018],{"class":361},[341,11838,10033],{"class":361},[341,11840,10036],{"class":361},[341,11842,10024],{"class":361},[341,11844,11845,11848,11851],{"class":343,"line":381},[341,11846,11847],{"class":679},"Skipping",[341,11849,11850],{"class":361}," database",[341,11852,11853],{"class":361}," migration.\n",[341,11855,11856,11858,11861,11864],{"class":343,"line":390},[341,11857,10002],{"class":679},[341,11859,11860],{"class":361}," Build",[341,11862,11863],{"class":361}," completed",[341,11865,11866],{"class":361}," successfully.\n",[16,11868,11869],{},[67,11870],{"alt":11871,"src":11872},"Vercel deployment dashboard showing successful build with Ready status","\u002Fimages\u002Fstartup-umami\u002Fself-hosting-umami\u002Fself-hosting-umami-part-3-vercel-deploy-success.webp",[16,11874,11875,11876,11879],{},"Replace the tracking script in your website's ",[52,11877,11878],{},"\u003Chead>"," with the new one from your self-hosted Umami instance. Within a few minutes, data starts flowing in — identical dashboard, no event limits, no monthly ceiling.",[28,11881],{},[11,11883,745],{"id":744},[16,11885,11886],{},"Looking back at the full three-part journey, what stands out is that none of the individual problems were particularly hard. The connection string issue has a documented fix. The GeoIP download is a known constraint. Git LFS is a standard tool. But they're sequential — you can't discover problem 3 until you've solved problems 1 and 2 — and each one presents the same symptom: a silent hang with no actionable error output.",[16,11888,11889],{},"That's the real difficulty. When a build fails with an error, you fix the error. When a build hangs silently, you're debugging a ghost. The mental model I developed by the end: any time a Vercel build hangs after the database checks pass, the next question isn't \"what's wrong with my code\" — it's \"what network request is this build environment failing to complete.\"",[16,11891,11892,11893,11895],{},"On the ",[52,11894,11419],{}," approach specifically: I treated this as a hack at first. It isn't — decoupling schema migrations from deployments is standard practice on teams where a bad migration could take down a live service. Running migrations manually (or through a separate CI step) means you can verify the SQL before it touches the live database. The Vercel build being blocked just forced me into a pattern I should have been using anyway.",[16,11897,11898,11899,11902],{},"One honest limitation of this setup: ",[60,11900,11901],{},"Supabase's free tier pauses inactive projects after one week of inactivity."," With 1,000+ daily visitors on bulkpictools.com, the database has constant activity and this won't trigger. But if you're setting this up for a low-traffic site, be aware that the first request after a pause will cold-start the database and fail — subsequent requests will be fine. The workaround is a cron job that pings the database every few days, or upgrading to Supabase's Pro plan ($25\u002Fmonth).",[16,11904,11905,11906,11911,11912,11914],{},"The GitHub repository for my self-hosted Umami setup: ",[20,11907,11910],{"href":11908,"rel":11909},"https:\u002F\u002Fgithub.com\u002Fkbmjj123\u002Fumami-serve",[24],"kbmjj123\u002Fumami-serve",". The key changes from the upstream repo are the updated ",[52,11913,9955],{}," and the committed GeoIP database.",[28,11916],{},[11,11918,792],{"id":791},[16,11920,11921,11924],{},[60,11922,11923],{},"Silent build hangs almost always mean a blocked network request."," Vercel's build environment has different network access than its runtime environment, and different again from your local machine. When the build stops responding after a successful step, look for HTTP requests or TCP connections in the subsequent code — one of them isn't completing.",[16,11926,11927,11930],{},[60,11928,11929],{},"Pre-bundle large static assets instead of downloading them at build time."," The 66MB GeoIP database could in principle be downloaded fresh on every build, but that's fragile — dependent on MaxMind's servers, your network, and Vercel's build timeout. Committing it to the repo via Git LFS makes the build deterministic. The same principle applies to any build-time asset that isn't generated from source code.",[16,11932,11933,11936,11937,11939,11940,11942,11943,10556,11946,10556,11949,10556,11952,10556,11955,816],{},[60,11934,11935],{},"Git LFS setup order matters."," Track the file extension in ",[52,11938,11214],{}," and commit that file ",[257,11941,5793],{}," adding the binary. If you add the binary first, git commits it as a regular object, and you'll need to rewrite history to move it into LFS properly. The correct sequence: ",[52,11944,11945],{},"git lfs track \"*.mmdb\"",[52,11947,11948],{},"git add .gitattributes",[52,11950,11951],{},"git commit",[52,11953,11954],{},"git add geo\u002F*.mmdb",[52,11956,11951],{},[16,11958,11959,5214,11962,11964],{},[60,11960,11961],{},"Separating migrations from deployments is a feature, not a workaround.",[52,11963,10944],{}," sounds like a hack, but running schema changes as a deliberate manual step (or a separate CI job) is safer than bundling them into the deploy. You get to review the SQL, run it on a test database first, and roll back independently of the application code if something goes wrong.",[16,11966,11967,11970],{},[60,11968,11969],{},"Check Supabase's free tier pause policy if your site has low traffic."," The free tier pauses projects with no activity for 7 days. 1,000 daily visitors won't trigger this, but a side project in early stages might. A simple keep-alive cron job solves it without needing to upgrade.",[28,11972],{},[11,11974,11976],{"id":11975},"series-wrap-up","Series Wrap-Up",[16,11978,11979],{},"Three posts, three problem categories:",[275,11981,11982,11987,11995],{},[181,11983,11984,11986],{},[60,11985,9967],{}," — The decision: why Umami Cloud's free tier stopped being free for a growing project, and how the alternatives actually compare",[181,11988,11989,11991,11992,11994],{},[60,11990,10937],{}," — The connection layer: PgBouncer vs direct connection, the ",[52,11993,9955],{}," override, and the IPv6 fallback trap",[181,11996,11997,12000],{},[60,11998,11999],{},"Part 3"," — The build layer: GeoIP download blocking the build, manual schema migration, and getting a large binary into GitHub without breaking the push",[16,12002,12003],{},"The self-hosted setup has been running cleanly since. No event ceiling, no monthly billing surprises, and the Supabase free tier's 500MB database storage gives plenty of headroom for the current traffic level.",[16,12005,12006,12007,12010],{},"If you're setting this up yourself and hit a step that isn't covered here, the repository at ",[20,12008,11910],{"href":11908,"rel":12009},[24]," has the final working configuration.",[28,12012],{},[16,12014,12015],{},[257,12016,10875,12017,9905,12019],{},[20,12018,10879],{"href":10878},[20,12020,12021],{"href":9903},"← Part 2: The Connection String Traps",[894,12023,12024],{},"html pre.shiki code .se37E, html code.shiki .se37E{--shiki-default:#6F42C1;--shiki-dark:#6F42C1}html pre.shiki code .sOTlB, html code.shiki .sOTlB{--shiki-default:#032F62;--shiki-dark:#032F62}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 .sMN4m, html code.shiki .sMN4m{--shiki-default:#005CC5;--shiki-dark:#005CC5}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sCydW, html code.shiki .sCydW{--shiki-default:#D73A49;--shiki-dark:#D73A49}html pre.shiki code .sKWpL, html code.shiki .sKWpL{--shiki-default:#24292E;--shiki-dark:#24292E}",{"title":337,"searchDepth":351,"depth":351,"links":12026},[12027,12028,12029,12032,12037,12038,12039,12040],{"id":13,"depth":351,"text":14},{"id":972,"depth":351,"text":973},{"id":11005,"depth":351,"text":11006,"children":12030},[12031],{"id":11071,"depth":368,"text":11072},{"id":11377,"depth":351,"text":12033,"children":12034},"Problem 2: prisma migrate deploy Blocked from Vercel's Build Environment",[12035,12036],{"id":11405,"depth":368,"text":11406},{"id":11525,"depth":368,"text":11526},{"id":11785,"depth":351,"text":11786},{"id":744,"depth":351,"text":745},{"id":791,"depth":351,"text":792},{"id":11975,"depth":351,"text":11976},"Vercel build silently hangs downloading a 66MB GeoIP database. Here's how to pre-bundle GeoLite2, bypass the migration step, run SQL manually in Supabase, and push a large binary with Git LFS.",{"date":10907,"category":4871,"readTime":9922,"tags":12043,"image":12046,"draft":935,"series":9927,"seriesOrder":368},[5501,12044,12045,9925,933],"#cicd","#github","https:\u002F\u002Fassets.kbmjj123.cc\u002Fblog\u002Fdev-practice\u002Fself-hosting-umami-part-3\u002Fpart-3-vercel-deploy-success.png","\u002Fposts\u002Fself-hosting-umami-part-3",{"title":12049,"description":12050,"keywords":12051},"Umami Vercel Build Hanging: GeoIP Download and Migration Fix","Fix Umami's Vercel build hanging on GeoLite2-City.mmdb download. Pre-bundle the 66MB file with Git LFS, run migrations manually in Supabase SQL Editor, and skip the build-time check.",[12052,12053,12054,12055,12056],"umami vercel build hanging geolite2","umami GeoLite2-City.mmdb vercel","umami SKIP_DB_MIGRATION vercel","prisma migrate deploy supabase manual sql","git lfs large file github push","posts\u002Fself-hosting-umami-part-3","NGZiOF7wshguyqeooenMHgXoz7pAaIc_SjEJRunojy8",{"id":12060,"title":12061,"body":12062,"description":12644,"extension":925,"meta":12645,"navigation":702,"path":12650,"seo":12651,"stem":12659,"__hash__":12660},"posts\u002Fposts\u002Fself-hosting-umami-part-4.md","Why Chinese Visitors Vanished from Umami — And the Custom Domain Fix",{"type":8,"value":12063,"toc":12624},[12064,12066,12080,12082,12084,12087,12090,12093,12095,12097,12101,12112,12202,12205,12263,12272,12278,12282,12285,12301,12304,12311,12313,12319,12325,12335,12342,12352,12355,12361,12363,12365,12369,12374,12377,12383,12387,12397,12403,12406,12413,12416,12474,12484,12488,12497,12499,12501,12510,12513,12524,12537,12543,12545,12547,12550,12556,12558,12560,12566,12580,12589,12595,12606,12608,12621],[11,12065,14],{"id":13},[16,12067,12068,12069,12072,12073,12075,12076,12079],{},"After the self-hosted Umami setup was running cleanly, I noticed that mainland China was nearly absent from the visitor map — despite bulkpictools.com having Chinese users. The initial instinct was wrong: it looked like an IP parsing bug, but it was actually simpler and more fundamental. The ",[52,12070,12071],{},"*.vercel.app"," domain is blocked in mainland China. Chinese users' browsers were sending the ",[52,12074,9743],{}," tracking request, hitting a timeout, and silently dropping it. The fix was binding a custom domain to the Vercel project and updating ",[52,12077,12078],{},"data-host-url"," in the frontend.",[28,12081],{},[11,12083,973],{"id":972},[16,12085,12086],{},"China had one or two data points a day. That's the number that made me stop and look — for a site like bulkpictools.com, mainland China should be a meaningful slice of traffic, not a rounding error.",[16,12088,12089],{},"A few weeks into running the self-hosted Umami setup from Parts 1–3, the dashboard otherwise looked healthy: accurate visitor counts, correct geographic breakdowns everywhere else, no event ceiling. Japan, South Korea, and Singapore all showed up normally. China didn't.",[16,12091,12092],{},"The first assumption was that something was wrong with the GeoIP database or the IP parsing logic from Part 2. That turned out to be the wrong direction entirely.",[28,12094],{},[11,12096,1055],{"id":1054},[305,12098,12100],{"id":12099},"the-wrong-diagnosis-ip-parsing","The Wrong Diagnosis: IP Parsing",[16,12102,12103,12104,12107,12108,12111],{},"The natural place to look first was the geolocation stack. Umami reads ",[52,12105,12106],{},"x-vercel-ip-country"," to get the visitor's country without having to resolve the IP through MaxMind. I added a temporary log to ",[52,12109,12110],{},"getClientInfo"," to see what headers were actually arriving:",[332,12113,12115],{"className":5574,"code":12114,"language":5576,"meta":337,"style":337},"console.log('[geo-debug]', {\n  'x-vercel-ip-country': request.headers.get('x-vercel-ip-country'),\n  'x-vercel-ip-city': request.headers.get('x-vercel-ip-city'),\n  'cf-ipcountry': request.headers.get('cf-ipcountry'),\n  'x-forwarded-for': request.headers.get('x-forwarded-for'),\n});\n",[52,12116,12117,12132,12150,12166,12182,12198],{"__ignoreMap":337},[341,12118,12119,12122,12124,12126,12129],{"class":343,"line":344},[341,12120,12121],{"class":347},"console.",[341,12123,11473],{"class":679},[341,12125,2977],{"class":347},[341,12127,12128],{"class":361},"'[geo-debug]'",[341,12130,12131],{"class":347},", {\n",[341,12133,12134,12137,12140,12143,12145,12148],{"class":343,"line":351},[341,12135,12136],{"class":361},"  'x-vercel-ip-country'",[341,12138,12139],{"class":347},": request.headers.",[341,12141,12142],{"class":679},"get",[341,12144,2977],{"class":347},[341,12146,12147],{"class":361},"'x-vercel-ip-country'",[341,12149,10281],{"class":347},[341,12151,12152,12155,12157,12159,12161,12164],{"class":343,"line":368},[341,12153,12154],{"class":361},"  'x-vercel-ip-city'",[341,12156,12139],{"class":347},[341,12158,12142],{"class":679},[341,12160,2977],{"class":347},[341,12162,12163],{"class":361},"'x-vercel-ip-city'",[341,12165,10281],{"class":347},[341,12167,12168,12171,12173,12175,12177,12180],{"class":343,"line":381},[341,12169,12170],{"class":361},"  'cf-ipcountry'",[341,12172,12139],{"class":347},[341,12174,12142],{"class":679},[341,12176,2977],{"class":347},[341,12178,12179],{"class":361},"'cf-ipcountry'",[341,12181,10281],{"class":347},[341,12183,12184,12187,12189,12191,12193,12196],{"class":343,"line":390},[341,12185,12186],{"class":361},"  'x-forwarded-for'",[341,12188,12139],{"class":347},[341,12190,12142],{"class":679},[341,12192,2977],{"class":347},[341,12194,12195],{"class":361},"'x-forwarded-for'",[341,12197,10281],{"class":347},[341,12199,12200],{"class":343,"line":396},[341,12201,10290],{"class":347},[16,12203,12204],{},"The output from a test request:",[332,12206,12208],{"className":334,"code":12207,"language":336,"meta":337,"style":337},"{\n  \"x-vercel-ip-country\": \"SG\",\n  \"x-vercel-ip-city\": \"Singapore\",\n  \"cf-ipcountry\": null,\n  \"x-forwarded-for\": \"3.0.91.193\"\n}\n",[52,12209,12210,12214,12226,12238,12249,12259],{"__ignoreMap":337},[341,12211,12212],{"class":343,"line":344},[341,12213,348],{"class":347},[341,12215,12216,12219,12221,12224],{"class":343,"line":351},[341,12217,12218],{"class":354},"  \"x-vercel-ip-country\"",[341,12220,358],{"class":347},[341,12222,12223],{"class":361},"\"SG\"",[341,12225,365],{"class":347},[341,12227,12228,12231,12233,12236],{"class":343,"line":368},[341,12229,12230],{"class":354},"  \"x-vercel-ip-city\"",[341,12232,358],{"class":347},[341,12234,12235],{"class":361},"\"Singapore\"",[341,12237,365],{"class":347},[341,12239,12240,12243,12245,12247],{"class":343,"line":381},[341,12241,12242],{"class":354},"  \"cf-ipcountry\"",[341,12244,358],{"class":347},[341,12246,1930],{"class":354},[341,12248,365],{"class":347},[341,12250,12251,12254,12256],{"class":343,"line":390},[341,12252,12253],{"class":354},"  \"x-forwarded-for\"",[341,12255,358],{"class":347},[341,12257,12258],{"class":361},"\"3.0.91.193\"\n",[341,12260,12261],{"class":343,"line":396},[341,12262,435],{"class":347},[16,12264,12265,12268,12269,12271],{},[52,12266,12267],{},"3.0.91.193"," is an AWS Singapore IP. This looked suspicious — why was a visitor's request showing an AWS node as the origin? The working theory at this point was that Vercel's Serverless Function region (deployed in Singapore) was somehow causing ",[52,12270,12106],{}," to return the Function's region rather than the actual visitor's location.",[16,12273,12274,12275,816],{},"That theory was plausible but wrong. The key signal I was ignoring: ",[60,12276,12277],{},"every other country was reporting correctly",[305,12279,12281],{"id":12280},"every-other-country-was-fine-only-china-wasnt","Every Other Country Was Fine — Only China Wasn't",[16,12283,12284],{},"If this were a geolocation parsing bug, it would affect all visitors, not just Chinese ones. Japan showed correctly. South Korea showed correctly. Singapore showed correctly. The problem was specific to mainland China — which means the requests weren't being misclassified. They weren't arriving at all.",[16,12286,12287,12288,12293,12294,12296,12297,12300],{},"The real explanation is straightforward: ",[60,12289,12290,12292],{},[52,12291,12071],{}," is blocked in mainland China",". It's been this way for years, and it affects every project hosted on Vercel's default domain. When a Chinese user's browser fires the ",[52,12295,9743],{}," request to ",[52,12298,12299],{},"umami-serve.vercel.app",", the DNS resolution either fails or the TCP connection times out. The browser makes one attempt, gets no response, and moves on. No data reaches Umami.",[16,12302,12303],{},"The Singapore AWS IP in the logs wasn't from a Chinese user at all — it was from some other request (monitoring service, bot, or a user routing through a VPN) that happened to be logged around the same time I was looking.",[16,12305,12306,12307,12310],{},"This is a common confusion point: when you see strange IPs in ",[52,12308,12309],{},"x-forwarded-for",", the instinct is to debug the IP parsing. But in this case, the strange IP was a red herring. The actual Chinese traffic was invisible because it never made it through.",[28,12312],{},[11,12314,10070,12316,12318],{"id":12315},"why-vercelapp-is-blocked-but-a-custom-domain-isnt",[52,12317,12071],{}," Is Blocked but a Custom Domain Isn't",[16,12320,12321,12322,12324],{},"The fix only makes sense once you understand why ",[52,12323,12071],{}," is blocked in the first place, so here's the mechanism.",[16,12326,12327,12328,12330,12331,12334],{},"Vercel's default ",[52,12329,12071],{}," domain resolves to Vercel's shared infrastructure. This shared IP space has been blocked by China's Great Firewall — not because of anything specific to your project, but because the IP ranges are associated with a large volume of content that's collectively blocked. It's the same reason ",[52,12332,12333],{},"*.github.io"," has historically had connectivity issues from China: shared infrastructure gets painted with a broad brush.",[16,12336,12337,12338,12341],{},"A custom domain like ",[52,12339,12340],{},"umami.bulkpictools.com"," is different. It points to the same Vercel infrastructure, but it resolves through DNS that isn't pre-blocked. The GFW primarily operates on IP ranges and domain names that have been explicitly added to blocklists. A fresh domain that hasn't been flagged will pass through — at least until it does.",[16,12343,12344,12345,12347,12348,12351],{},"The critical detail in this setup: ",[52,12346,12340],{}," is configured in Cloudflare with ",[60,12349,12350],{},"DNS-only mode (gray cloud)",", not proxied. This means Cloudflare is acting purely as a DNS resolver — the CNAME points directly to Vercel's servers, and user requests go straight from their browser to Vercel with no intermediate proxy. There's no Cloudflare CDN hop, no Worker, no page rule involved.",[16,12353,12354],{},"Cloudflare isn't doing anything special here — DNS-only mode just resolves the name. The fix works because that name itself isn't blocked.",[16,12356,12357],{},[67,12358],{"alt":12359,"src":12360},"Diagram showing blocked path from China to *.vercel.app versus working path through custom domain umami.bulkpictools.com with DNS-only Cloudflare configuration","\u002Fimages\u002Fstartup-umami\u002Fself-hosting-umami\u002Fself-hosting-umami-part-4-gfw-domain-paths.svg",[28,12362],{},[11,12364,1189],{"id":1188},[305,12366,12368],{"id":12367},"step-1-bind-the-custom-domain-in-vercel","Step 1 — Bind the custom domain in Vercel",[16,12370,12371,12372,816],{},"In the Vercel dashboard, go to your project → Settings → Domains → Add Domain. Enter the subdomain you want to use — in this case ",[52,12373,12340],{},[16,12375,12376],{},"Vercel will give you a CNAME record to add:",[332,12378,12381],{"className":12379,"code":12380,"language":4731},[4729],"umami.bulkpictools.com  CNAME  cname.vercel-dns.com\n",[52,12382,12380],{"__ignoreMap":337},[305,12384,12386],{"id":12385},"step-2-add-the-dns-record-in-cloudflare","Step 2 — Add the DNS record in Cloudflare",[16,12388,12389,12390,12392,12393,12396],{},"In Cloudflare DNS settings for ",[52,12391,25],{},", add the CNAME record Vercel provided. Leave the proxy status as ",[60,12394,12395],{},"DNS only (gray cloud)",". Proxied mode would route traffic through Cloudflare's CDN, which introduces a different IP layer — for this use case, DNS-only is simpler and sufficient.",[332,12398,12401],{"className":12399,"code":12400,"language":4731},[4729],"Type    Name    Content                  Proxy status\nCNAME   umami   cname.vercel-dns.com     DNS only\n",[52,12402,12400],{"__ignoreMap":337},[16,12404,12405],{},"Vercel will automatically provision an SSL certificate for the domain via Let's Encrypt. This usually completes within a minute or two of DNS propagating.",[305,12407,12409,12410,12412],{"id":12408},"step-3-update-data-host-url-in-the-frontend","Step 3 — Update ",[52,12411,12078],{}," in the frontend",[16,12414,12415],{},"Change the Umami script configuration to point to the new domain:",[332,12417,12419],{"className":5574,"code":12418,"language":5576,"meta":337,"style":337},"customScripts.push({\n  src: `https:\u002F\u002Fcdn.bulkpictools.com\u002Fscript\u002Fscript.js`,\n  defer: true,\n  'data-website-id': umamiAnalyticsId,\n  'data-host-url': 'https:\u002F\u002Fumami.bulkpictools.com'  \u002F\u002F was: umami-serve.vercel.app\n})\n",[52,12420,12421,12430,12440,12449,12457,12470],{"__ignoreMap":337},[341,12422,12423,12426,12428],{"class":343,"line":344},[341,12424,12425],{"class":347},"customScripts.",[341,12427,4176],{"class":679},[341,12429,1834],{"class":347},[341,12431,12432,12435,12438],{"class":343,"line":351},[341,12433,12434],{"class":347},"  src: ",[341,12436,12437],{"class":361},"`https:\u002F\u002Fcdn.bulkpictools.com\u002Fscript\u002Fscript.js`",[341,12439,365],{"class":347},[341,12441,12442,12445,12447],{"class":343,"line":368},[341,12443,12444],{"class":347},"  defer: ",[341,12446,3488],{"class":354},[341,12448,365],{"class":347},[341,12450,12451,12454],{"class":343,"line":381},[341,12452,12453],{"class":361},"  'data-website-id'",[341,12455,12456],{"class":347},": umamiAnalyticsId,\n",[341,12458,12459,12462,12464,12467],{"class":343,"line":390},[341,12460,12461],{"class":361},"  'data-host-url'",[341,12463,358],{"class":347},[341,12465,12466],{"class":361},"'https:\u002F\u002Fumami.bulkpictools.com'",[341,12468,12469],{"class":667},"  \u002F\u002F was: umami-serve.vercel.app\n",[341,12471,12472],{"class":343,"line":396},[341,12473,1979],{"class":347},[16,12475,12476,12477,12479,12480,9541,12482,816],{},"Deploy the frontend change. From this point, all ",[52,12478,9743],{}," requests from visitors go to ",[52,12481,12340],{},[52,12483,12299],{},[305,12485,12487],{"id":12486},"step-4-verify","Step 4 — Verify",[16,12489,12490,12491,12493,12494,816],{},"The quickest verification is checking Vercel Function Logs immediately after deploying the change. Within a few minutes you should see ",[52,12492,9743],{}," requests arriving with Chinese IP addresses and ",[52,12495,12496],{},"x-vercel-ip-country: CN",[28,12498],{},[11,12500,745],{"id":744},[16,12502,12503,12504,12506,12507,12509],{},"The frustrating part of this diagnosis was spending time on IP parsing — looking at ",[52,12505,12309],{}," values, checking ",[52,12508,9955],{},", adding debug logs — when the actual problem had nothing to do with any of that. The Chinese requests weren't being misattributed. They didn't exist.",[16,12511,12512],{},"The lesson I've internalized: when data from a specific geography is missing entirely rather than incorrect, the first question should be \"are requests from this region reaching the server at all?\" — not \"is the server parsing the data correctly?\" Missing data and wrong data have different root causes. I jumped to the wrong branch of the debugging tree.",[16,12514,11892,12515,12517,12518,1885,12520,12523],{},[52,12516,12071],{}," blocking: this isn't a Vercel-specific problem. Any hosting platform that serves its customers under a shared wildcard domain faces the same exposure in markets with aggressive content filtering. ",[52,12519,12333],{},[52,12521,12522],{},"*.netlify.app",", and similar domains have all had episodes of partial or full blocking. If you're building something where mainland China traffic matters, a custom domain isn't optional — it's baseline infrastructure, the same as having HTTPS.",[16,12525,12526,12527,12530,12531,12533,12534,12536],{},"One honest caveat about this fix: ",[60,12528,12529],{},"a custom domain reduces the risk of being blocked, but doesn't eliminate it",". If ",[52,12532,25],{}," itself were ever added to a blocklist, ",[52,12535,12340],{}," would go with it. The domain-based fix works because the domain is currently unlisted — it's not a structural bypass of the GFW. For most independent developers running normal web products, this is fine. For anything in a higher-risk content category, the calculus is different.",[16,12538,12539,12540,12542],{},"The DNS-only configuration also means there's no Cloudflare CDN caching in front of the Umami API — which is appropriate, since ",[52,12541,9743],{}," should never be cached. If you were serving static assets this way, you'd want the orange cloud. For an analytics endpoint, gray is correct.",[28,12544],{},[11,12546,772],{"id":771},[16,12548,12549],{},"After deploying the custom domain change, mainland China data started appearing in the Umami dashboard within minutes.",[16,12551,12552],{},[67,12553],{"alt":12554,"src":12555},"Umami analytics dashboard showing China appearing in the countries list after custom domain fix","\u002Fimages\u002Fstartup-diary\u002Fself-hosting-umami\u002Fself-hosting-umami-part-4-china-data-restored.webp",[28,12557],{},[11,12559,792],{"id":791},[16,12561,12562,12565],{},[60,12563,12564],{},"Missing data and wrong data have different root causes."," When an entire geography shows zero rather than incorrect values, debug connectivity before parsing. The question is \"are requests arriving?\" not \"are requests being misread?\"",[16,12567,12568,12573,12574,12576,12577,12579],{},[60,12569,12570,12572],{},[52,12571,12071],{}," is blocked in mainland China."," So is ",[52,12575,12522],{},", and periodically ",[52,12578,12333],{},". If China traffic matters to your project, a bound custom domain belongs in the initial setup checklist, not the backlog.",[16,12581,12582,12585,12586,12588],{},[60,12583,12584],{},"DNS-only is the right Cloudflare setting for an analytics endpoint."," Proxied mode adds Cloudflare's CDN layer, which is useful for cacheable assets but wrong for a write endpoint like ",[52,12587,9743],{},". Gray cloud keeps the path simple: DNS resolution, then a direct connection to Vercel.",[16,12590,12591,12594],{},[60,12592,12593],{},"Custom domain fixes the access problem, not the blocking risk."," The domain works today because it isn't listed. That's a different thing from being guaranteed to work. That distinction rarely matters for indie projects — but knowing the mechanism means you won't be blindsided if the situation changes.",[16,12596,12597,12600,12601,319,12603,12605],{},[60,12598,12599],{},"Debug logs should target the right layer."," Adding ",[52,12602,12309],{},[52,12604,12106],{}," logs was useful for ruling out the IP parsing hypothesis, but it couldn't reveal the actual problem because the actual problem was happening before any request reached the server. When logs show nothing unusual, sometimes the issue is that the right requests aren't in the logs at all.",[28,12607],{},[16,12609,12610],{},[257,12611,10875,12612,9905,12615,9905,12618],{},[20,12613,12614],{"href":10878},"← Part 1",[20,12616,12617],{"href":9903},"← Part 2",[20,12619,12620],{"href":9908},"← Part 3",[894,12622,12623],{},"html pre.shiki code .sKWpL, html code.shiki .sKWpL{--shiki-default:#24292E;--shiki-dark:#24292E}html pre.shiki code .se37E, html code.shiki .se37E{--shiki-default:#6F42C1;--shiki-dark:#6F42C1}html pre.shiki code .sOTlB, html code.shiki .sOTlB{--shiki-default:#032F62;--shiki-dark:#032F62}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 .sMN4m, html code.shiki .sMN4m{--shiki-default:#005CC5;--shiki-dark:#005CC5}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":337,"searchDepth":351,"depth":351,"links":12625},[12626,12627,12628,12632,12634,12641,12642,12643],{"id":13,"depth":351,"text":14},{"id":972,"depth":351,"text":973},{"id":1054,"depth":351,"text":1055,"children":12629},[12630,12631],{"id":12099,"depth":368,"text":12100},{"id":12280,"depth":368,"text":12281},{"id":12315,"depth":351,"text":12633},"Why *.vercel.app Is Blocked but a Custom Domain Isn't",{"id":1188,"depth":351,"text":1189,"children":12635},[12636,12637,12638,12640],{"id":12367,"depth":368,"text":12368},{"id":12385,"depth":368,"text":12386},{"id":12408,"depth":368,"text":12639},"Step 3 — Update data-host-url in the frontend",{"id":12486,"depth":368,"text":12487},{"id":744,"depth":351,"text":745},{"id":771,"depth":351,"text":772},{"id":791,"depth":351,"text":792},"After weeks of running cleanly, I noticed zero data from China. The culprit wasn't IP parsing or GeoIP — it was that *.vercel.app is blocked in mainland China. Here's the diagnosis and the fix.",{"date":12646,"category":4871,"readTime":12647,"tags":12648,"image":12649,"draft":935,"series":9927,"seriesOrder":381},"2026-07-02","7mins",[5501,9925,933,9924],"https:\u002F\u002Fassets.kbmjj123.cc\u002Fblog\u002Fdev-practice\u002Fself-hosting-umami-part-4\u002Fpart-4-china-data-restored.png","\u002Fposts\u002Fself-hosting-umami-part-4",{"title":12652,"description":12653,"keywords":12654},"Umami Missing China Traffic on Vercel — Custom Domain Fix","*.vercel.app is blocked in mainland China. Learn how binding a custom domain to your Vercel-hosted Umami instance restores Chinese visitor data, and why DNS-only mode is all you need.",[12655,12656,12657,12658],"umami vercel china traffic missing","vercel app blocked china mainland","umami custom domain vercel china","umami x-vercel-ip-country wrong country","posts\u002Fself-hosting-umami-part-4","Uizq8IAdlQrtSBEgPPQZTu-TJ1Alg0bBC5wbBBFYVBw",{"id":4890,"title":4891,"body":12662,"description":5269,"extension":925,"meta":12920,"navigation":702,"path":5279,"seo":12922,"stem":5289,"__hash__":5290},{"type":8,"value":12663,"toc":12908},[12664,12666,12668,12670,12672,12676,12678,12682,12684,12690,12692,12694,12696,12698,12702,12708,12710,12714,12718,12720,12728,12732,12734,12736,12738,12742,12752,12756,12758,12760,12764,12768,12770,12772,12774,12778,12784,12786,12792,12798,12800,12802,12804,12806,12808,12822,12826,12830,12832,12834,12836,12840,12846,12848,12850,12852,12858,12864,12870,12874,12880,12882,12884,12906],[11,12665,14],{"id":13},[16,12667,4898],{},[28,12669],{},[11,12671,4904],{"id":4903},[16,12673,4907,12674,4911],{},[257,12675,4910],{},[16,12677,4914],{},[16,12679,12680],{},[60,12681,4919],{},[16,12683,4922],{},[16,12685,4925,12686],{},[60,12687,4928,12688,4932],{},[257,12689,4931],{},[28,12691],{},[11,12693,4938],{"id":4937},[16,12695,4941],{},[16,12697,4944],{},[16,12699,12700],{},[67,12701],{"alt":4949,"src":4950},[16,12703,12704],{},[257,12705,12706],{},[341,12707,4957],{},[16,12709,4960],{},[16,12711,4963,12712],{},[257,12713,4966],{},[16,12715,4969,12716,4973],{},[257,12717,4972],{},[16,12719,4976],{},[275,12721,12722,12724,12726],{},[181,12723,4981],{},[181,12725,4984],{},[181,12727,4987],{},[16,12729,4990,12730,4994],{},[257,12731,4993],{},[28,12733],{},[11,12735,5000],{"id":4999},[16,12737,5003],{},[16,12739,5006,12740,5010],{},[60,12741,5009],{},[275,12743,12744,12746,12748,12750],{},[181,12745,5015],{},[181,12747,5018],{},[181,12749,5021],{},[181,12751,5024],{},[16,12753,5027,12754,5031],{},[257,12755,5030],{},[28,12757],{},[11,12759,5037],{"id":5036},[16,12761,5040,12762,5044],{},[60,12763,5043],{},[16,12765,5047,12766,5051],{},[257,12767,5050],{},[28,12769],{},[11,12771,5057],{"id":5056},[16,12773,5060],{},[16,12775,12776],{},[67,12777],{"alt":5065,"src":5066},[16,12779,12780],{},[257,12781,12782],{},[341,12783,5073],{},[16,12785,5076],{},[178,12787,12788,12790],{},[181,12789,5081],{},[181,12791,5084],{},[16,12793,5087,12794,5091,12796,5094],{},[60,12795,5090],{},[257,12797,2793],{},[16,12799,5097],{},[16,12801,5100],{},[28,12803],{},[11,12805,5106],{"id":5105},[16,12807,5109],{},[275,12809,12810,12814,12818],{},[181,12811,12812,5117],{},[60,12813,5116],{},[181,12815,12816,5123],{},[60,12817,5122],{},[181,12819,12820,5129],{},[60,12821,5128],{},[16,12823,5132,12824,5136],{},[60,12825,5135],{},[16,12827,12828],{},[67,12829],{"alt":5141,"src":5142},[28,12831],{},[11,12833,5148],{"id":5147},[16,12835,5151],{},[16,12837,12838],{},[67,12839],{"alt":5156,"src":5157},[16,12841,12842],{},[257,12843,12844],{},[341,12845,5164],{},[16,12847,5167],{},[28,12849],{},[11,12851,5173],{"id":5172},[16,12853,12854,5179,12856,5183],{},[60,12855,5178],{},[257,12857,5182],{},[16,12859,12860,5189,12862,5193],{},[60,12861,5188],{},[257,12863,5192],{},[16,12865,12866,5199,12868,5202],{},[60,12867,5198],{},[257,12869,4993],{},[16,12871,12872,5208],{},[60,12873,5207],{},[16,12875,12876,5214,12878,5218],{},[60,12877,5213],{},[257,12879,5217],{},[28,12881],{},[11,12883,792],{"id":791},[178,12885,12886,12890,12894,12898,12902],{},[181,12887,12888,5230],{},[60,12889,5229],{},[181,12891,12892,5236],{},[60,12893,5235],{},[181,12895,12896,5242],{},[60,12897,5241],{},[181,12899,12900,5248],{},[60,12901,5247],{},[181,12903,12904,5254],{},[60,12905,5253],{},[28,12907],{},{"title":337,"searchDepth":351,"depth":351,"links":12909},[12910,12911,12912,12913,12914,12915,12916,12917,12918,12919],{"id":13,"depth":351,"text":14},{"id":4903,"depth":351,"text":4904},{"id":4937,"depth":351,"text":4938},{"id":4999,"depth":351,"text":5000},{"id":5036,"depth":351,"text":5037},{"id":5056,"depth":351,"text":5057},{"id":5105,"depth":351,"text":5106},{"id":5147,"depth":351,"text":5148},{"id":5172,"depth":351,"text":5173},{"id":791,"depth":351,"text":792},{"date":5271,"category":5272,"readTime":5273,"tags":12921,"image":5278,"draft":935,"series":936,"seriesOrder":936},[5275,931,5276,5277,933],{"title":5281,"description":5282,"keywords":12923},[5284,5285,5286,5287,5288],1783067268724]