[{"data":1,"prerenderedAt":2649},["ShallowReactive",2],{"blog-list":3},[4,1849,1901,2413],{"id":5,"title":6,"body":7,"date":1839,"description":1840,"draft":1841,"extension":1842,"lang":1843,"meta":1844,"navigation":295,"path":1845,"seo":1846,"stem":1847,"__hash__":1848},"blog\u002Fblog\u002Fbuilding-ali-nuxt-toolkit.md","Building ali-nuxt-toolkit — a tour of the internals",{"type":8,"value":9,"toc":1816},"minimark",[10,19,22,27,33,69,72,98,101,105,108,440,456,465,474,485,591,598,605,608,648,651,756,763,767,789,825,828,832,835,900,907,925,932,937,1103,1121,1128,1131,1244,1251,1317,1324,1331,1334,1341,1508,1526,1533,1638,1645,1649,1652,1660,1663,1667,1677,1680,1716,1720,1731,1739,1754,1758,1761,1801,1812],[11,12,13,14,18],"p",{},"A while back I decided to pull the patterns I kept rewriting at work into a small set of Nuxt modules. Nothing novel — just the stuff that everyone on every SaaS team eventually writes: a typed HTTP client, layout-scoped middleware, and a crypto service for locally-stored secrets. Packaging them properly turned into its own project: ",[15,16,17],"strong",{},"ali-nuxt-toolkit",".",[11,20,21],{},"This post is a tour of what's inside and why certain pieces are shaped the way they are. I'll skip the obvious parts and spend most of the words on the details I'd want to read if someone else had written it.",[23,24,26],"h2",{"id":25},"the-shape-of-the-repo","The shape of the repo",[11,28,29,32],{},[30,31,17],"code",{}," is a pnpm monorepo. The top level is roughly:",[34,35,36,47,57,63],"ul",{},[37,38,39,42,43,46],"li",{},[30,40,41],{},"packages\u002F"," — three independently published modules under the ",[30,44,45],{},"@alikhalilll"," scope.",[37,48,49,52,53,56],{},[30,50,51],{},"apps\u002Fdocs\u002F"," — a Nuxt 4 + ",[30,54,55],{},"@nuxt\u002Fcontent"," site, prerendered to static HTML.",[37,58,59,62],{},[30,60,61],{},"playgrounds\u002Fnuxt\u002F"," — a minimal app that wires all three modules together; handy for kicking the tires locally.",[37,64,65,68],{},[30,66,67],{},".github\u002Fworkflows\u002F"," — CI (lint, typecheck, matrix build on Node 20 + 22) and a Changesets-driven release pipeline.",[11,70,71],{},"The three packages:",[34,73,74,82,90],{},[37,75,76,81],{},[15,77,78],{},[30,79,80],{},"@alikhalilll\u002Fnuxt-api-provider"," — strongly-typed fetch client with an interceptor chain, retry\u002Fbackoff, timeouts, and upload\u002Fdownload progress.",[37,83,84,89],{},[15,85,86],{},[30,87,88],{},"@alikhalilll\u002Fnuxt-auto-middleware"," — layout-scoped route middleware with glob patterns, named groups, and per-page overrides.",[37,91,92,97],{},[15,93,94],{},[30,95,96],{},"@alikhalilll\u002Fnuxt-crypto"," — AES-256-GCM + PBKDF2 built on Web Crypto, with an LRU key cache and pluggable algorithms.",[11,99,100],{},"They're deliberately small and focused. Each one works standalone. Each one also has a framework-agnostic \"core\" that can run in Node, Bun, Deno, or a test — no Nuxt required.",[23,102,104],{"id":103},"the-module-skeleton","The module skeleton",[11,106,107],{},"All three modules follow the same Nuxt 4 shape:",[109,110,115],"pre",{"className":111,"code":112,"language":113,"meta":114,"style":114},"language-typescript shiki shiki-themes github-light github-dark","export default defineNuxtModule\u003COptions>({\n  meta: {\n    name,\n    configKey,\n    compatibility: { nuxt: '>=3.0.0' },\n  },\n  defaults: { \u002F* ... *\u002F },\n  setup(options, nuxt) {\n    \u002F\u002F 1. Write a serialized config file into .nuxt\n    addTemplate({\n      filename: 'my-module-config.mjs',\n      getContents: () => `export default ${JSON.stringify(config)};\\n`,\n    });\n\n    \u002F\u002F 2. Register the runtime plugin\n    addPlugin({ src: resolver.resolve('.\u002Fruntime\u002Fplugin'), mode: 'all' });\n\n    \u002F\u002F 3. Augment Nuxt's types so $myModule shows up everywhere\n    const typesTemplate = addTemplate({\n      filename: 'types\u002Fmy-module.d.ts',\n      getContents: () => typeDeclarations,\n    });\n    nuxt.hook('prepare:types', ({ references }) => {\n      references.push({ path: typesTemplate.dst });\n    });\n  },\n});\n","typescript","",[30,116,117,143,149,155,161,174,180,192,214,220,229,241,284,290,297,303,329,334,340,357,367,379,384,412,424,429,434],{"__ignoreMap":114},[118,119,122,126,129,133,137,140],"span",{"class":120,"line":121},"line",1,[118,123,125],{"class":124},"szBVR","export",[118,127,128],{"class":124}," default",[118,130,132],{"class":131},"sScJk"," defineNuxtModule",[118,134,136],{"class":135},"sVt8B","\u003C",[118,138,139],{"class":131},"Options",[118,141,142],{"class":135},">({\n",[118,144,146],{"class":120,"line":145},2,[118,147,148],{"class":135},"  meta: {\n",[118,150,152],{"class":120,"line":151},3,[118,153,154],{"class":135},"    name,\n",[118,156,158],{"class":120,"line":157},4,[118,159,160],{"class":135},"    configKey,\n",[118,162,164,167,171],{"class":120,"line":163},5,[118,165,166],{"class":135},"    compatibility: { nuxt: ",[118,168,170],{"class":169},"sZZnC","'>=3.0.0'",[118,172,173],{"class":135}," },\n",[118,175,177],{"class":120,"line":176},6,[118,178,179],{"class":135},"  },\n",[118,181,183,186,190],{"class":120,"line":182},7,[118,184,185],{"class":135},"  defaults: { ",[118,187,189],{"class":188},"sJ8bj","\u002F* ... *\u002F",[118,191,173],{"class":135},[118,193,195,198,201,205,208,211],{"class":120,"line":194},8,[118,196,197],{"class":131},"  setup",[118,199,200],{"class":135},"(",[118,202,204],{"class":203},"s4XuR","options",[118,206,207],{"class":135},", ",[118,209,210],{"class":203},"nuxt",[118,212,213],{"class":135},") {\n",[118,215,217],{"class":120,"line":216},9,[118,218,219],{"class":188},"    \u002F\u002F 1. Write a serialized config file into .nuxt\n",[118,221,223,226],{"class":120,"line":222},10,[118,224,225],{"class":131},"    addTemplate",[118,227,228],{"class":135},"({\n",[118,230,232,235,238],{"class":120,"line":231},11,[118,233,234],{"class":135},"      filename: ",[118,236,237],{"class":169},"'my-module-config.mjs'",[118,239,240],{"class":135},",\n",[118,242,244,247,250,253,256,260,262,265,267,270,273,276,279,282],{"class":120,"line":243},12,[118,245,246],{"class":131},"      getContents",[118,248,249],{"class":135},": () ",[118,251,252],{"class":124},"=>",[118,254,255],{"class":169}," `export default ${",[118,257,259],{"class":258},"sj4cs","JSON",[118,261,18],{"class":169},[118,263,264],{"class":131},"stringify",[118,266,200],{"class":169},[118,268,269],{"class":135},"config",[118,271,272],{"class":169},")",[118,274,275],{"class":169},"};",[118,277,278],{"class":258},"\\n",[118,280,281],{"class":169},"`",[118,283,240],{"class":135},[118,285,287],{"class":120,"line":286},13,[118,288,289],{"class":135},"    });\n",[118,291,293],{"class":120,"line":292},14,[118,294,296],{"emptyLinePlaceholder":295},true,"\n",[118,298,300],{"class":120,"line":299},15,[118,301,302],{"class":188},"    \u002F\u002F 2. Register the runtime plugin\n",[118,304,306,309,312,315,317,320,323,326],{"class":120,"line":305},16,[118,307,308],{"class":131},"    addPlugin",[118,310,311],{"class":135},"({ src: resolver.",[118,313,314],{"class":131},"resolve",[118,316,200],{"class":135},[118,318,319],{"class":169},"'.\u002Fruntime\u002Fplugin'",[118,321,322],{"class":135},"), mode: ",[118,324,325],{"class":169},"'all'",[118,327,328],{"class":135}," });\n",[118,330,332],{"class":120,"line":331},17,[118,333,296],{"emptyLinePlaceholder":295},[118,335,337],{"class":120,"line":336},18,[118,338,339],{"class":188},"    \u002F\u002F 3. Augment Nuxt's types so $myModule shows up everywhere\n",[118,341,343,346,349,352,355],{"class":120,"line":342},19,[118,344,345],{"class":124},"    const",[118,347,348],{"class":258}," typesTemplate",[118,350,351],{"class":124}," =",[118,353,354],{"class":131}," addTemplate",[118,356,228],{"class":135},[118,358,360,362,365],{"class":120,"line":359},20,[118,361,234],{"class":135},[118,363,364],{"class":169},"'types\u002Fmy-module.d.ts'",[118,366,240],{"class":135},[118,368,370,372,374,376],{"class":120,"line":369},21,[118,371,246],{"class":131},[118,373,249],{"class":135},[118,375,252],{"class":124},[118,377,378],{"class":135}," typeDeclarations,\n",[118,380,382],{"class":120,"line":381},22,[118,383,289],{"class":135},[118,385,387,390,393,395,398,401,404,407,409],{"class":120,"line":386},23,[118,388,389],{"class":135},"    nuxt.",[118,391,392],{"class":131},"hook",[118,394,200],{"class":135},[118,396,397],{"class":169},"'prepare:types'",[118,399,400],{"class":135},", ({ ",[118,402,403],{"class":203},"references",[118,405,406],{"class":135}," }) ",[118,408,252],{"class":124},[118,410,411],{"class":135}," {\n",[118,413,415,418,421],{"class":120,"line":414},24,[118,416,417],{"class":135},"      references.",[118,419,420],{"class":131},"push",[118,422,423],{"class":135},"({ path: typesTemplate.dst });\n",[118,425,427],{"class":120,"line":426},25,[118,428,289],{"class":135},[118,430,432],{"class":120,"line":431},26,[118,433,179],{"class":135},[118,435,437],{"class":120,"line":436},27,[118,438,439],{"class":135},"});\n",[11,441,442,443,447,448,451,452,455],{},"The interesting part is ",[444,445,446],"em",{},"what I'm not doing",". I'm not passing the config object through provide\u002Finject at runtime, and I'm not importing user code directly from ",[30,449,450],{},"module.ts",". Everything flows through generated files in ",[30,453,454],{},".nuxt",". This has two benefits:",[457,458,459,462],"ol",{},[37,460,461],{},"The runtime plugin stays tiny — it just imports a plain JS object from a virtual path. No work at boot.",[37,463,464],{},"Tree-shaking works. If a feature isn't used, its template contents aren't referenced, and the bundle drops it.",[466,467,469,470,473],"h3",{"id":468},"virtual-modules-and-how-to-keep-tsc-happy","Virtual modules, and how to keep ",[30,471,472],{},"tsc"," happy",[11,475,476,477,480,481,484],{},"Generated templates don't exist on disk when the type-checker runs. Without extra work, ",[30,478,479],{},"import config from '#build\u002Fapi-provider-config.mjs'"," would be flagged as missing. Each package has a ",[30,482,483],{},"nuxt-virtual.d.ts"," declaring stubs:",[109,486,488],{"className":111,"code":487,"language":113,"meta":114,"style":114},"declare module '#build\u002Fapi-provider-config.mjs' {\n  const config: {\n    baseURL: string;\n    defaultTimeoutMs: number;\n    retry: { attempts: number; baseDelayMs: number };\n  };\n  export default config;\n}\n",[30,489,490,503,516,529,541,571,576,586],{"__ignoreMap":114},[118,491,492,495,498,501],{"class":120,"line":121},[118,493,494],{"class":124},"declare",[118,496,497],{"class":124}," module",[118,499,500],{"class":169}," '#build\u002Fapi-provider-config.mjs'",[118,502,411],{"class":135},[118,504,505,508,511,514],{"class":120,"line":145},[118,506,507],{"class":124},"  const",[118,509,510],{"class":258}," config",[118,512,513],{"class":124},":",[118,515,411],{"class":135},[118,517,518,521,523,526],{"class":120,"line":151},[118,519,520],{"class":203},"    baseURL",[118,522,513],{"class":124},[118,524,525],{"class":258}," string",[118,527,528],{"class":135},";\n",[118,530,531,534,536,539],{"class":120,"line":157},[118,532,533],{"class":203},"    defaultTimeoutMs",[118,535,513],{"class":124},[118,537,538],{"class":258}," number",[118,540,528],{"class":135},[118,542,543,546,548,551,554,556,558,561,564,566,568],{"class":120,"line":163},[118,544,545],{"class":203},"    retry",[118,547,513],{"class":124},[118,549,550],{"class":135}," { ",[118,552,553],{"class":203},"attempts",[118,555,513],{"class":124},[118,557,538],{"class":258},[118,559,560],{"class":135},"; ",[118,562,563],{"class":203},"baseDelayMs",[118,565,513],{"class":124},[118,567,538],{"class":258},[118,569,570],{"class":135}," };\n",[118,572,573],{"class":120,"line":176},[118,574,575],{"class":135},"  };\n",[118,577,578,581,583],{"class":120,"line":182},[118,579,580],{"class":124},"  export",[118,582,128],{"class":124},[118,584,585],{"class":135}," config;\n",[118,587,588],{"class":120,"line":194},[118,589,590],{"class":135},"}\n",[11,592,593,594,597],{},"Now ",[30,595,596],{},"tsc --noEmit"," passes, and editor autocomplete still works on fields of the generated config.",[23,599,601,604],{"id":600},"nuxt-api-provider-chainable-interceptors-and-two-transports",[30,602,603],{},"nuxt-api-provider",": chainable interceptors and two transports",[11,606,607],{},"The public surface of the API client is deliberately flat. You call it like a function:",[109,609,611],{"className":111,"code":610,"language":113,"meta":114,"style":114},"const users = await $apiProvider\u003CUser[]>('\u002Fusers', { method: 'GET' });\n",[30,612,613],{"__ignoreMap":114},[118,614,615,618,621,623,626,629,631,634,637,640,643,646],{"class":120,"line":121},[118,616,617],{"class":124},"const",[118,619,620],{"class":258}," users",[118,622,351],{"class":124},[118,624,625],{"class":124}," await",[118,627,628],{"class":131}," $apiProvider",[118,630,136],{"class":135},[118,632,633],{"class":131},"User",[118,635,636],{"class":135},"[]>(",[118,638,639],{"class":169},"'\u002Fusers'",[118,641,642],{"class":135},", { method: ",[118,644,645],{"class":169},"'GET'",[118,647,328],{"class":135},[11,649,650],{},"You add cross-cutting behavior through three chains:",[109,652,654],{"className":111,"code":653,"language":113,"meta":114,"style":114},"$apiProvider.useRequest((ctx) => {\n  ctx.headers.Authorization = `Bearer ${token}`;\n});\n\n$apiProvider.useResponse((ctx, response) => { \u002F* ... *\u002F });\n$apiProvider.useError((ctx, err) => { \u002F* ... *\u002F });\n",[30,655,656,677,696,700,704,730],{"__ignoreMap":114},[118,657,658,661,664,667,670,673,675],{"class":120,"line":121},[118,659,660],{"class":135},"$apiProvider.",[118,662,663],{"class":131},"useRequest",[118,665,666],{"class":135},"((",[118,668,669],{"class":203},"ctx",[118,671,672],{"class":135},") ",[118,674,252],{"class":124},[118,676,411],{"class":135},[118,678,679,682,685,688,691,694],{"class":120,"line":145},[118,680,681],{"class":135},"  ctx.headers.Authorization ",[118,683,684],{"class":124},"=",[118,686,687],{"class":169}," `Bearer ${",[118,689,690],{"class":135},"token",[118,692,693],{"class":169},"}`",[118,695,528],{"class":135},[118,697,698],{"class":120,"line":151},[118,699,439],{"class":135},[118,701,702],{"class":120,"line":157},[118,703,296],{"emptyLinePlaceholder":295},[118,705,706,708,711,713,715,717,720,722,724,726,728],{"class":120,"line":163},[118,707,660],{"class":135},[118,709,710],{"class":131},"useResponse",[118,712,666],{"class":135},[118,714,669],{"class":203},[118,716,207],{"class":135},[118,718,719],{"class":203},"response",[118,721,672],{"class":135},[118,723,252],{"class":124},[118,725,550],{"class":135},[118,727,189],{"class":188},[118,729,328],{"class":135},[118,731,732,734,737,739,741,743,746,748,750,752,754],{"class":120,"line":176},[118,733,660],{"class":135},[118,735,736],{"class":131},"useError",[118,738,666],{"class":135},[118,740,669],{"class":203},[118,742,207],{"class":135},[118,744,745],{"class":203},"err",[118,747,672],{"class":135},[118,749,252],{"class":124},[118,751,550],{"class":135},[118,753,189],{"class":188},[118,755,328],{"class":135},[11,757,758,759,762],{},"Each ",[30,760,761],{},"use*"," returns an unsubscribe function, which matters if you register interceptors from a component and want to clean up on unmount.",[466,764,766],{"id":765},"two-transports-one-api","Two transports, one API",[11,768,769,770,773,774,776,777,780,781,784,785,788],{},"Most requests go through ",[30,771,772],{},"fetch",". But ",[30,775,772],{}," doesn't expose upload progress — the ",[30,778,779],{},"ReadableStream"," side of the Request body is fine for streams but browsers don't give you byte-level ",[30,782,783],{},"progress"," events the way XHR does. So when a caller passes ",[30,786,787],{},"onRequestProgress",", the client swaps transports:",[109,790,792],{"className":111,"code":791,"language":113,"meta":114,"style":114},"const transport = ctx.options.onRequestProgress\n  ? createXhrFetch(ctx.options.onRequestProgress)\n  : defaultFetch;\n",[30,793,794,806,817],{"__ignoreMap":114},[118,795,796,798,801,803],{"class":120,"line":121},[118,797,617],{"class":124},[118,799,800],{"class":258}," transport",[118,802,351],{"class":124},[118,804,805],{"class":135}," ctx.options.onRequestProgress\n",[118,807,808,811,814],{"class":120,"line":145},[118,809,810],{"class":124},"  ?",[118,812,813],{"class":131}," createXhrFetch",[118,815,816],{"class":135},"(ctx.options.onRequestProgress)\n",[118,818,819,822],{"class":120,"line":151},[118,820,821],{"class":124},"  :",[118,823,824],{"class":135}," defaultFetch;\n",[11,826,827],{},"The XHR wrapper returns a Response-shaped object so the rest of the pipeline doesn't care how the bytes came back. That kind of \"one API, swap the engine under it\" has been my favorite pattern for two years — it keeps optional features optional without branching the whole code path.",[466,829,831],{"id":830},"interceptors-by-path-not-by-function","Interceptors by path, not by function",[11,833,834],{},"The module options look like this:",[109,836,838],{"className":111,"code":837,"language":113,"meta":114,"style":114},"{\n  baseURL: 'https:\u002F\u002Fapi.example.com',\n  onRequestPath:  '~\u002Fapi\u002Fon-request.ts',\n  onSuccessPath:  '~\u002Fapi\u002Fon-response.ts',\n  onErrorPath:    '~\u002Fapi\u002Fon-error.ts',\n}\n",[30,839,840,845,858,871,883,896],{"__ignoreMap":114},[118,841,842],{"class":120,"line":121},[118,843,844],{"class":135},"{\n",[118,846,847,850,853,856],{"class":120,"line":145},[118,848,849],{"class":131},"  baseURL",[118,851,852],{"class":135},": ",[118,854,855],{"class":169},"'https:\u002F\u002Fapi.example.com'",[118,857,240],{"class":135},[118,859,860,863,866,869],{"class":120,"line":151},[118,861,862],{"class":131},"  onRequestPath",[118,864,865],{"class":135},":  ",[118,867,868],{"class":169},"'~\u002Fapi\u002Fon-request.ts'",[118,870,240],{"class":135},[118,872,873,876,878,881],{"class":120,"line":157},[118,874,875],{"class":131},"  onSuccessPath",[118,877,865],{"class":135},[118,879,880],{"class":169},"'~\u002Fapi\u002Fon-response.ts'",[118,882,240],{"class":135},[118,884,885,888,891,894],{"class":120,"line":163},[118,886,887],{"class":131},"  onErrorPath",[118,889,890],{"class":135},":    ",[118,892,893],{"class":169},"'~\u002Fapi\u002Fon-error.ts'",[118,895,240],{"class":135},[118,897,898],{"class":120,"line":176},[118,899,590],{"class":135},[11,901,902,903,906],{},"Interceptors are resolved as ",[444,904,905],{},"file paths",", not inline functions. The generated template dynamically imports them, and the runtime plugin wires whatever's exported into the chain. Two reasons:",[34,908,909,919],{},[37,910,911,914,915,918],{},[15,912,913],{},"No circular config."," Users often want to import types from the api-provider module inside their interceptor. If the interceptor lived inside ",[30,916,917],{},"nuxt.config.ts",", the config file would depend on the module it's configuring.",[37,920,921,924],{},[15,922,923],{},"Code-splitting."," The interceptor becomes its own chunk, which matters on cold loads.",[466,926,928,929],{"id":927},"error-branding-without-instanceof","Error branding without ",[30,930,931],{},"instanceof",[11,933,934,936],{},[30,935,931],{}," fails the moment you have two copies of the same class — which happens with duplicated deps, iframes, web workers, or pnpm hoisting quirks. The client's error type uses a Symbol brand instead:",[109,938,940],{"className":111,"code":939,"language":113,"meta":114,"style":114},"const API_ERROR_BRAND: unique symbol = Symbol.for(\n  '@alikhalilll\u002Fnuxt-api-provider.ApiError'\n);\n\nexport class ApiError extends Error {\n  readonly [API_ERROR_BRAND] = true;\n\n  static is(e: unknown): e is ApiError {\n    return typeof e === 'object' && e !== null && API_ERROR_BRAND in e;\n  }\n}\n",[30,941,942,968,973,978,982,1000,1021,1025,1056,1094,1099],{"__ignoreMap":114},[118,943,944,946,949,951,954,957,959,962,965],{"class":120,"line":121},[118,945,617],{"class":124},[118,947,948],{"class":258}," API_ERROR_BRAND",[118,950,513],{"class":124},[118,952,953],{"class":131}," unique",[118,955,956],{"class":258}," symbol",[118,958,351],{"class":124},[118,960,961],{"class":135}," Symbol.",[118,963,964],{"class":131},"for",[118,966,967],{"class":135},"(\n",[118,969,970],{"class":120,"line":145},[118,971,972],{"class":169},"  '@alikhalilll\u002Fnuxt-api-provider.ApiError'\n",[118,974,975],{"class":120,"line":151},[118,976,977],{"class":135},");\n",[118,979,980],{"class":120,"line":157},[118,981,296],{"emptyLinePlaceholder":295},[118,983,984,986,989,992,995,998],{"class":120,"line":163},[118,985,125],{"class":124},[118,987,988],{"class":124}," class",[118,990,991],{"class":131}," ApiError",[118,993,994],{"class":124}," extends",[118,996,997],{"class":131}," Error",[118,999,411],{"class":135},[118,1001,1002,1005,1008,1011,1014,1016,1019],{"class":120,"line":176},[118,1003,1004],{"class":124},"  readonly",[118,1006,1007],{"class":135}," [",[118,1009,1010],{"class":258},"API_ERROR_BRAND",[118,1012,1013],{"class":135},"] ",[118,1015,684],{"class":124},[118,1017,1018],{"class":258}," true",[118,1020,528],{"class":135},[118,1022,1023],{"class":120,"line":182},[118,1024,296],{"emptyLinePlaceholder":295},[118,1026,1027,1030,1033,1035,1038,1040,1043,1045,1047,1050,1052,1054],{"class":120,"line":194},[118,1028,1029],{"class":124},"  static",[118,1031,1032],{"class":131}," is",[118,1034,200],{"class":135},[118,1036,1037],{"class":203},"e",[118,1039,513],{"class":124},[118,1041,1042],{"class":258}," unknown",[118,1044,272],{"class":135},[118,1046,513],{"class":124},[118,1048,1049],{"class":203}," e",[118,1051,1032],{"class":124},[118,1053,991],{"class":131},[118,1055,411],{"class":135},[118,1057,1058,1061,1064,1067,1070,1073,1076,1078,1081,1084,1086,1088,1091],{"class":120,"line":216},[118,1059,1060],{"class":124},"    return",[118,1062,1063],{"class":124}," typeof",[118,1065,1066],{"class":135}," e ",[118,1068,1069],{"class":124},"===",[118,1071,1072],{"class":169}," 'object'",[118,1074,1075],{"class":124}," &&",[118,1077,1066],{"class":135},[118,1079,1080],{"class":124},"!==",[118,1082,1083],{"class":258}," null",[118,1085,1075],{"class":124},[118,1087,948],{"class":258},[118,1089,1090],{"class":124}," in",[118,1092,1093],{"class":135}," e;\n",[118,1095,1096],{"class":120,"line":222},[118,1097,1098],{"class":135},"  }\n",[118,1100,1101],{"class":120,"line":231},[118,1102,590],{"class":135},[11,1104,1105,1108,1109,1112,1113,1116,1117,1120],{},[30,1106,1107],{},"Symbol.for"," gives you the ",[444,1110,1111],{},"same"," symbol across module copies. ",[30,1114,1115],{},"ApiError.is(err)"," works where ",[30,1118,1119],{},"err instanceof ApiError"," doesn't. This has saved me on every project that ever crossed a realm boundary.",[23,1122,1124,1127],{"id":1123},"nuxt-auto-middleware-compile-time-regex-runtime-dispatch",[30,1125,1126],{},"nuxt-auto-middleware",": compile-time regex, runtime dispatch",[11,1129,1130],{},"The module takes rules like this:",[109,1132,1134],{"className":111,"code":1133,"language":113,"meta":114,"style":114},"autoMiddleware: {\n  groups: {\n    adminOnly: ['auth', 'require-admin'],\n  },\n  rules: [\n    { layouts: ['admin-*'], middlewares: ['@adminOnly'] },\n    { layouts: [\u002F^workspace\\\u002F.*\u002F], middlewares: ['auth', 'workspace'] },\n  ],\n}\n",[30,1135,1136,1144,1151,1170,1174,1182,1199,1235,1240],{"__ignoreMap":114},[118,1137,1138,1141],{"class":120,"line":121},[118,1139,1140],{"class":131},"autoMiddleware",[118,1142,1143],{"class":135},": {\n",[118,1145,1146,1149],{"class":120,"line":145},[118,1147,1148],{"class":131},"  groups",[118,1150,1143],{"class":135},[118,1152,1153,1156,1159,1162,1164,1167],{"class":120,"line":151},[118,1154,1155],{"class":131},"    adminOnly",[118,1157,1158],{"class":135},": [",[118,1160,1161],{"class":169},"'auth'",[118,1163,207],{"class":135},[118,1165,1166],{"class":169},"'require-admin'",[118,1168,1169],{"class":135},"],\n",[118,1171,1172],{"class":120,"line":157},[118,1173,179],{"class":135},[118,1175,1176,1179],{"class":120,"line":163},[118,1177,1178],{"class":131},"  rules",[118,1180,1181],{"class":135},": [\n",[118,1183,1184,1187,1190,1193,1196],{"class":120,"line":176},[118,1185,1186],{"class":135},"    { layouts: [",[118,1188,1189],{"class":169},"'admin-*'",[118,1191,1192],{"class":135},"], middlewares: [",[118,1194,1195],{"class":169},"'@adminOnly'",[118,1197,1198],{"class":135},"] },\n",[118,1200,1201,1203,1206,1209,1213,1217,1219,1222,1224,1226,1228,1230,1233],{"class":120,"line":182},[118,1202,1186],{"class":135},[118,1204,1205],{"class":169},"\u002F",[118,1207,1208],{"class":124},"^",[118,1210,1212],{"class":1211},"sA_wV","workspace",[118,1214,1216],{"class":1215},"snhLl","\\\u002F",[118,1218,18],{"class":258},[118,1220,1221],{"class":124},"*",[118,1223,1205],{"class":169},[118,1225,1192],{"class":135},[118,1227,1161],{"class":169},[118,1229,207],{"class":135},[118,1231,1232],{"class":169},"'workspace'",[118,1234,1198],{"class":135},[118,1236,1237],{"class":120,"line":194},[118,1238,1239],{"class":135},"  ],\n",[118,1241,1242],{"class":120,"line":216},[118,1243,590],{"class":135},[11,1245,1246,1247,1250],{},"At module setup time, each glob gets compiled to a RegExp. Each group reference gets expanded. The resulting rules get ",[444,1248,1249],{},"serialized"," into a generated template:",[109,1252,1254],{"className":111,"code":1253,"language":113,"meta":114,"style":114},"export const rules = [\n  { patterns: ['^admin-.*$'], middlewares: ['auth', 'require-admin'] },\n  { patterns: ['^workspace\\\\\u002F.*'], middlewares: ['auth', 'workspace'] },\n];\n",[30,1255,1256,1271,1289,1312],{"__ignoreMap":114},[118,1257,1258,1260,1263,1266,1268],{"class":120,"line":121},[118,1259,125],{"class":124},[118,1261,1262],{"class":124}," const",[118,1264,1265],{"class":258}," rules",[118,1267,351],{"class":124},[118,1269,1270],{"class":135}," [\n",[118,1272,1273,1276,1279,1281,1283,1285,1287],{"class":120,"line":145},[118,1274,1275],{"class":135},"  { patterns: [",[118,1277,1278],{"class":169},"'^admin-.*$'",[118,1280,1192],{"class":135},[118,1282,1161],{"class":169},[118,1284,207],{"class":135},[118,1286,1166],{"class":169},[118,1288,1198],{"class":135},[118,1290,1291,1293,1296,1299,1302,1304,1306,1308,1310],{"class":120,"line":151},[118,1292,1275],{"class":135},[118,1294,1295],{"class":169},"'^workspace",[118,1297,1298],{"class":258},"\\\\",[118,1300,1301],{"class":169},"\u002F.*'",[118,1303,1192],{"class":135},[118,1305,1161],{"class":169},[118,1307,207],{"class":135},[118,1309,1232],{"class":169},[118,1311,1198],{"class":135},[118,1313,1314],{"class":120,"line":157},[118,1315,1316],{"class":135},"];\n",[11,1318,1319,1320,1323],{},"At runtime, the plugin rehydrates the patterns with ",[30,1321,1322],{},"new RegExp(source)"," and matches against the current layout. The client never sees a glob parser — it's been pre-compiled away. On a typical app this saves a few KB, but more importantly it means adding more rules doesn't cost more bundle size beyond the rule strings themselves.",[23,1325,1327,1330],{"id":1326},"nuxt-crypto-the-lru-that-caches-promises",[30,1328,1329],{},"nuxt-crypto",": the LRU that caches promises",[11,1332,1333],{},"The crypto service is the part I'm happiest with. It wraps Web Crypto's AES-GCM + PBKDF2 primitives with a clean encrypt\u002Fdecrypt API. The trick is the key cache.",[11,1335,1336,1337,1340],{},"PBKDF2 with 100,000 iterations is slow on purpose — that's the whole point. But when your UI tries to decrypt three fields from IndexedDB in parallel, doing three separate key derivations is both wasteful and slow. The cache fixes that, but the detail that actually matters is ",[444,1338,1339],{},"what"," it caches:",[109,1342,1344],{"className":111,"code":1343,"language":113,"meta":114,"style":114},"const getDerivedKey = async (salt, fingerprint?) => {\n  const key = KeyCache.key(salt, iterations, fingerprint);\n  const cached = cache.get(key);\n  if (cached) return cached;\n\n  \u002F\u002F Cache the promise, not the settled key\n  const pending = algorithm.deriveKey({\n    subtle,\n    passphrase,\n    fingerprint,\n    salt,\n    iterations,\n  });\n  cache.set(key, pending);\n  return pending;\n};\n",[30,1345,1346,1378,1396,1414,1428,1432,1437,1454,1459,1464,1469,1474,1479,1484,1495,1503],{"__ignoreMap":114},[118,1347,1348,1350,1353,1355,1358,1361,1364,1366,1369,1372,1374,1376],{"class":120,"line":121},[118,1349,617],{"class":124},[118,1351,1352],{"class":131}," getDerivedKey",[118,1354,351],{"class":124},[118,1356,1357],{"class":124}," async",[118,1359,1360],{"class":135}," (",[118,1362,1363],{"class":203},"salt",[118,1365,207],{"class":135},[118,1367,1368],{"class":203},"fingerprint",[118,1370,1371],{"class":124},"?",[118,1373,672],{"class":135},[118,1375,252],{"class":124},[118,1377,411],{"class":135},[118,1379,1380,1382,1385,1387,1390,1393],{"class":120,"line":145},[118,1381,507],{"class":124},[118,1383,1384],{"class":258}," key",[118,1386,351],{"class":124},[118,1388,1389],{"class":135}," KeyCache.",[118,1391,1392],{"class":131},"key",[118,1394,1395],{"class":135},"(salt, iterations, fingerprint);\n",[118,1397,1398,1400,1403,1405,1408,1411],{"class":120,"line":151},[118,1399,507],{"class":124},[118,1401,1402],{"class":258}," cached",[118,1404,351],{"class":124},[118,1406,1407],{"class":135}," cache.",[118,1409,1410],{"class":131},"get",[118,1412,1413],{"class":135},"(key);\n",[118,1415,1416,1419,1422,1425],{"class":120,"line":157},[118,1417,1418],{"class":124},"  if",[118,1420,1421],{"class":135}," (cached) ",[118,1423,1424],{"class":124},"return",[118,1426,1427],{"class":135}," cached;\n",[118,1429,1430],{"class":120,"line":163},[118,1431,296],{"emptyLinePlaceholder":295},[118,1433,1434],{"class":120,"line":176},[118,1435,1436],{"class":188},"  \u002F\u002F Cache the promise, not the settled key\n",[118,1438,1439,1441,1444,1446,1449,1452],{"class":120,"line":182},[118,1440,507],{"class":124},[118,1442,1443],{"class":258}," pending",[118,1445,351],{"class":124},[118,1447,1448],{"class":135}," algorithm.",[118,1450,1451],{"class":131},"deriveKey",[118,1453,228],{"class":135},[118,1455,1456],{"class":120,"line":194},[118,1457,1458],{"class":135},"    subtle,\n",[118,1460,1461],{"class":120,"line":216},[118,1462,1463],{"class":135},"    passphrase,\n",[118,1465,1466],{"class":120,"line":222},[118,1467,1468],{"class":135},"    fingerprint,\n",[118,1470,1471],{"class":120,"line":231},[118,1472,1473],{"class":135},"    salt,\n",[118,1475,1476],{"class":120,"line":243},[118,1477,1478],{"class":135},"    iterations,\n",[118,1480,1481],{"class":120,"line":286},[118,1482,1483],{"class":135},"  });\n",[118,1485,1486,1489,1492],{"class":120,"line":292},[118,1487,1488],{"class":135},"  cache.",[118,1490,1491],{"class":131},"set",[118,1493,1494],{"class":135},"(key, pending);\n",[118,1496,1497,1500],{"class":120,"line":299},[118,1498,1499],{"class":124},"  return",[118,1501,1502],{"class":135}," pending;\n",[118,1504,1505],{"class":120,"line":305},[118,1506,1507],{"class":135},"};\n",[11,1509,1510,1511,1514,1515,1518,1519,1522,1523,1525],{},"The cache holds ",[30,1512,1513],{},"Promise\u003CCryptoKey>",", not ",[30,1516,1517],{},"CryptoKey",". If three ",[30,1520,1521],{},"decrypt()"," calls arrive in the same tick with the same salt, they all await the ",[444,1524,1111],{}," pending promise. PBKDF2 runs once. The naive version — cache the settled key — leaves a window where two calls both see \"not cached\" and start duplicate work.",[11,1527,1528,1529,1532],{},"The LRU itself uses the fact that JavaScript ",[30,1530,1531],{},"Map"," preserves insertion order:",[109,1534,1536],{"className":111,"code":1535,"language":113,"meta":114,"style":114},"get(key: string): Promise\u003CCryptoKey> | undefined {\n  const value = this.map.get(key);\n  if (!value) return undefined;\n  this.map.delete(key);   \u002F\u002F delete + re-insert moves to newest\n  this.map.set(key, value);\n  return value;\n}\n",[30,1537,1538,1563,1582,1600,1616,1627,1634],{"__ignoreMap":114},[118,1539,1540,1542,1545,1548,1550,1552,1555,1558,1561],{"class":120,"line":121},[118,1541,1410],{"class":131},[118,1543,1544],{"class":135},"(key: string): ",[118,1546,1547],{"class":258},"Promise",[118,1549,136],{"class":124},[118,1551,1517],{"class":135},[118,1553,1554],{"class":124},">",[118,1556,1557],{"class":124}," |",[118,1559,1560],{"class":258}," undefined",[118,1562,411],{"class":135},[118,1564,1565,1567,1570,1572,1575,1578,1580],{"class":120,"line":145},[118,1566,507],{"class":124},[118,1568,1569],{"class":258}," value",[118,1571,351],{"class":124},[118,1573,1574],{"class":258}," this",[118,1576,1577],{"class":135},".map.",[118,1579,1410],{"class":131},[118,1581,1413],{"class":135},[118,1583,1584,1586,1588,1591,1594,1596,1598],{"class":120,"line":151},[118,1585,1418],{"class":124},[118,1587,1360],{"class":135},[118,1589,1590],{"class":124},"!",[118,1592,1593],{"class":135},"value) ",[118,1595,1424],{"class":124},[118,1597,1560],{"class":258},[118,1599,528],{"class":135},[118,1601,1602,1605,1607,1610,1613],{"class":120,"line":157},[118,1603,1604],{"class":258},"  this",[118,1606,1577],{"class":135},[118,1608,1609],{"class":131},"delete",[118,1611,1612],{"class":135},"(key);   ",[118,1614,1615],{"class":188},"\u002F\u002F delete + re-insert moves to newest\n",[118,1617,1618,1620,1622,1624],{"class":120,"line":163},[118,1619,1604],{"class":258},[118,1621,1577],{"class":135},[118,1623,1491],{"class":131},[118,1625,1626],{"class":135},"(key, value);\n",[118,1628,1629,1631],{"class":120,"line":176},[118,1630,1499],{"class":124},[118,1632,1633],{"class":135}," value;\n",[118,1635,1636],{"class":120,"line":182},[118,1637,590],{"class":135},[11,1639,1640,1641,1644],{},"Eviction is then just ",[30,1642,1643],{},"this.map.keys().next().value"," — the oldest.",[466,1646,1648],{"id":1647},"versioned-payloads","Versioned payloads",[11,1650,1651],{},"Encrypt output carries a version byte:",[109,1653,1658],{"className":1654,"code":1656,"language":1657},[1655],"language-text","[version: 1B] [salt: 16B] [iv: 12B] [ciphertext: …]\n","text",[30,1659,1656],{"__ignoreMap":114},[11,1661,1662],{},"On decrypt, mismatched versions fail early. This is the kind of thing you add before you need it — the day I want to rotate from AES-GCM to something post-quantum, the old payloads will still decrypt through the old algorithm while new ones use the new one. Without a version byte you'd be stuck guessing.",[23,1664,1666],{"id":1665},"the-docs-site-as-a-playground-for-the-modules","The docs site as a playground for the modules",[11,1668,1669,1672,1673,1676],{},[30,1670,1671],{},"apps\u002Fdocs"," uses all three modules. That sounds obvious, but it's load-bearing — the docs are ",[444,1674,1675],{},"how I notice breakage"," before users do. If an update to api-provider breaks the doc site's JSON Placeholder demo, CI fails on the build step. No test suite needed for that particular class of regression.",[11,1678,1679],{},"Other things that earned their keep:",[34,1681,1682,1694,1708],{},[37,1683,1684,1689,1690,1693],{},[15,1685,1686],{},[30,1687,1688],{},"@tailwindcss\u002Fvite"," — no PostCSS, no ",[30,1691,1692],{},"tailwind.config.js",", just a Vite plugin. One less config file in my life.",[37,1695,1696,1699,1700,1703,1704,1707],{},[15,1697,1698],{},"Static prerender to GitHub Pages"," — Nitro's ",[30,1701,1702],{},"github-pages"," preset gives you a ",[30,1705,1706],{},"\u002F.output\u002Fpublic\u002F"," that Actions uploads straight to Pages. No servers, no cache invalidation, no surprises.",[37,1709,1710,1715],{},[15,1711,1712],{},[30,1713,1714],{},"NUXT_PUBLIC_CF_ANALYTICS_TOKEN"," — optional Cloudflare Web Analytics. No cookies, no banner, no opinion.",[23,1717,1719],{"id":1718},"release-changesets-all-the-way-down","Release: Changesets all the way down",[11,1721,1722,1723,1726,1727,1730],{},"Every PR that changes a package includes a ",[30,1724,1725],{},".changeset\u002F\u003Cid>.md"," describing the bump and changelog entry. On master, a GitHub Action runs ",[30,1728,1729],{},"changesets version"," and either:",[457,1732,1733,1736],{},[37,1734,1735],{},"Opens a \"Version Packages\" PR with the bumped versions, or",[37,1737,1738],{},"Publishes to npm if versions are already bumped.",[11,1740,1741,1742,1745,1746,1749,1750,1753],{},"This is the least-effort setup I've found that gives honest changelogs, semver discipline, and no-touch npm publishing. The CI pipeline itself is three files: ",[30,1743,1744],{},"ci.yml"," (lint + typecheck + matrix build), ",[30,1747,1748],{},"release.yml"," (changesets), and ",[30,1751,1752],{},"commitlint.yml"," (conventional commits on PRs).",[23,1755,1757],{"id":1756},"the-parts-id-reuse-tomorrow","The parts I'd reuse tomorrow",[11,1759,1760],{},"If I were starting a new Nuxt module from scratch, I'd lift these patterns without thinking:",[34,1762,1763,1773,1783,1789,1795],{},[37,1764,1765,1772],{},[15,1766,1767,1768,1771],{},"Serialize config into a generated ",[30,1769,1770],{},".mjs"," file."," Avoids re-evaluating at runtime, and the runtime plugin stays 15 lines.",[37,1774,1775,1782],{},[15,1776,1777,1778,1781],{},"Virtual-module ",[30,1779,1780],{},".d.ts"," stubs."," Offline typecheck without running the Nuxt prepare step.",[37,1784,1785,1788],{},[15,1786,1787],{},"Symbol-branded errors."," Free for low-traffic modules, life-saving for the ones that cross realms.",[37,1790,1791,1794],{},[15,1792,1793],{},"Promise-keyed caches."," Any time the underlying operation is async and expensive, cache the promise, not the result.",[37,1796,1797,1800],{},[15,1798,1799],{},"Framework-agnostic core + thin Nuxt wrapper."," Makes the code easier to test, easier to reuse, and easier to delete.",[11,1802,1803,1804,1811],{},"The repo is MIT-licensed and lives on ",[1805,1806,1810],"a",{"href":1807,"rel":1808},"https:\u002F\u002Fgithub.com\u002Falikhalilll",[1809],"nofollow","GitHub",". If you spot something that could be better, open an issue — I'd genuinely rather be corrected than comfortable.",[1813,1814,1815],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .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 .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}html pre.shiki code .snhLl, html code.shiki .snhLl{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}",{"title":114,"searchDepth":145,"depth":145,"links":1817},[1818,1819,1823,1830,1832,1836,1837,1838],{"id":25,"depth":145,"text":26},{"id":103,"depth":145,"text":104,"children":1820},[1821],{"id":468,"depth":151,"text":1822},"Virtual modules, and how to keep tsc happy",{"id":600,"depth":145,"text":1824,"children":1825},"nuxt-api-provider: chainable interceptors and two transports",[1826,1827,1828],{"id":765,"depth":151,"text":766},{"id":830,"depth":151,"text":831},{"id":927,"depth":151,"text":1829},"Error branding without instanceof",{"id":1123,"depth":145,"text":1831},"nuxt-auto-middleware: compile-time regex, runtime dispatch",{"id":1326,"depth":145,"text":1833,"children":1834},"nuxt-crypto: the LRU that caches promises",[1835],{"id":1647,"depth":151,"text":1648},{"id":1665,"depth":145,"text":1666},{"id":1718,"depth":145,"text":1719},{"id":1756,"depth":145,"text":1757},"2026-04-18","Three Nuxt modules, one monorepo. The design choices, the tricks, and the pieces I'd happily lift into another project.",false,"md",null,{},"\u002Fblog\u002Fbuilding-ali-nuxt-toolkit",{"title":6,"description":1840},"blog\u002Fbuilding-ali-nuxt-toolkit","lmT3bUyFQkKs0I6943NluOQU1gZ4sXzNiS55tlTABIo",{"id":1850,"title":1851,"body":1852,"date":1839,"description":1895,"draft":1841,"extension":1842,"lang":1843,"meta":1896,"navigation":295,"path":1897,"seo":1898,"stem":1899,"__hash__":1900},"blog\u002Fblog\u002Fhello-world.md","Hello, world",{"type":8,"value":1853,"toc":1892},[1854,1857,1860,1864,1884],[11,1855,1856],{},"Every developer ends up building their own site sooner or later. This one is mine.",[11,1858,1859],{},"I'll post here when I have something worth saying — mostly short notes about frontend craft, Nuxt, TypeScript, performance work, and the small things I learn leading teams. I'll try to skip the think-piece takes and stick to the kind of writing I'd want to read: specific, testable, and short enough to finish on a coffee break.",[23,1861,1863],{"id":1862},"what-youll-find-here","What you'll find here",[34,1865,1866,1872,1878],{},[37,1867,1868,1871],{},[15,1869,1870],{},"Notes on Nuxt and Vue"," — things that tripped me up, and how I fixed them.",[37,1873,1874,1877],{},[15,1875,1876],{},"Performance work"," — the unglamorous stuff that actually moves LCP and CLS.",[37,1879,1880,1883],{},[15,1881,1882],{},"Leading a small frontend team"," — code review habits, mentorship, and setting the bar for DX.",[11,1885,1886,1887,1891],{},"If any of that sounds useful, thanks for reading. If something is wrong, ",[1805,1888,1890],{"href":1889},"mailto:alikhalilll.dev@gmail.com","email me"," — I'd genuinely rather be corrected than comfortable.",{"title":114,"searchDepth":145,"depth":145,"links":1893},[1894],{"id":1862,"depth":145,"text":1863},"A short note on why I started this site and what to expect.",{},"\u002Fblog\u002Fhello-world",{"title":1851,"description":1895},"blog\u002Fhello-world","_AnECpytUGS1Crr_aFYGNXn_kVHWmKdSXhSTbk_tNHc",{"id":1902,"title":1903,"body":1904,"date":2406,"description":2407,"draft":1841,"extension":1842,"lang":1843,"meta":2408,"navigation":295,"path":2409,"seo":2410,"stem":2411,"__hash__":2412},"blog\u002Fblog\u002Fmastering-dynamic-data-tables-icolumn-class.md","Mastering dynamic data tables with the IColumn class",{"type":8,"value":1905,"toc":2400},[1906,1909,1916,1920,1962,1964,2133,2137,2147,2368,2372,2375,2386,2389,2397],[11,1907,1908],{},"Building scalable and reusable table components is a common challenge. Every table wants slightly different headers, cells, props, and sorting rules — and before long the component that was supposed to save you time has grown a new prop for each new screen.",[11,1910,1911,1912,1915],{},"The ",[30,1913,1914],{},"IColumn"," class is my attempt at a different approach: describe each column as an object, let the table component render it. The configuration moves out of the table and into plain data, which is much easier to generate, reuse, and test.",[23,1917,1919],{"id":1918},"key-features","Key features",[34,1921,1922,1928,1934,1944],{},[37,1923,1924,1927],{},[15,1925,1926],{},"Customizable headers and rows."," Any Vue component can render a header or a cell, with props and events wired in.",[37,1929,1930,1933],{},[15,1931,1932],{},"Flexible props and events."," Pass a static object, or a function that receives the row and returns one.",[37,1935,1936,1939,1940,1943],{},[15,1937,1938],{},"Built-in sorting."," Strings and numbers work out of the box; supply a ",[30,1941,1942],{},"sorter"," for anything else.",[37,1945,1946,1949,1950,1953,1954,1957,1958,1961],{},[15,1947,1948],{},"Special column types."," ",[30,1951,1952],{},"default"," for normal data, ",[30,1955,1956],{},"actions"," for row buttons at the end, ",[30,1959,1960],{},"selection"," for checkboxes at the start.",[23,1963,139],{"id":204},[1965,1966,1967,1983],"table",{},[1968,1969,1970],"thead",{},[1971,1972,1973,1977,1980],"tr",{},[1974,1975,1976],"th",{},"Property",[1974,1978,1979],{},"Type",[1974,1981,1982],{},"Description",[1984,1985,1986,2002,2016,2031,2046,2060,2075,2090,2105,2119],"tbody",{},[1971,1987,1988,1994,1999],{},[1989,1990,1991],"td",{},[30,1992,1993],{},"title",[1989,1995,1996],{},[30,1997,1998],{},"string | function",[1989,2000,2001],{},"Column header text",[1971,2003,2004,2009,2013],{},[1989,2005,2006],{},[30,2007,2008],{},"rowKey",[1989,2010,2011],{},[30,2012,1998],{},[1989,2014,2015],{},"Data property key for the row",[1971,2017,2018,2023,2028],{},[1989,2019,2020],{},[30,2021,2022],{},"props",[1989,2024,2025],{},[30,2026,2027],{},"Object | function",[1989,2029,2030],{},"Component props (static or dynamic)",[1971,2032,2033,2038,2043],{},[1989,2034,2035],{},[30,2036,2037],{},"rowComponent",[1989,2039,2040],{},[30,2041,2042],{},"Object | undefined",[1989,2044,2045],{},"Cell rendering component",[1971,2047,2048,2053,2057],{},[1989,2049,2050],{},[30,2051,2052],{},"headerComponent",[1989,2054,2055],{},[30,2056,2042],{},[1989,2058,2059],{},"Header rendering component",[1971,2061,2062,2067,2072],{},[1989,2063,2064],{},[30,2065,2066],{},"type",[1989,2068,2069],{},[30,2070,2071],{},"'default' | 'actions' | 'selection'",[1989,2073,2074],{},"Column behavior type",[1971,2076,2077,2082,2087],{},[1989,2078,2079],{},[30,2080,2081],{},"disabled",[1989,2083,2084],{},[30,2085,2086],{},"boolean | function",[1989,2088,2089],{},"Disables column functionality",[1971,2091,2092,2097,2102],{},[1989,2093,2094],{},[30,2095,2096],{},"sortable",[1989,2098,2099],{},[30,2100,2101],{},"boolean",[1989,2103,2104],{},"Enables column sorting",[1971,2106,2107,2111,2116],{},[1989,2108,2109],{},[30,2110,1942],{},[1989,2112,2113],{},[30,2114,2115],{},"function",[1989,2117,2118],{},"Custom comparison function",[1971,2120,2121,2126,2130],{},[1989,2122,2123],{},[30,2124,2125],{},"sortValue",[1989,2127,2128],{},[30,2129,2042],{},[1989,2131,2132],{},"Tracks sorting state",[23,2134,2136],{"id":2135},"the-constructor","The constructor",[11,2138,2139,2140,2142,2143,2146],{},"The constructor pulls the options apart, fills in sensible defaults, and normalizes ",[30,2141,2022],{}," and ",[30,2144,2145],{},"events"," so the table never has to care whether they were static or functions.",[109,2148,2152],{"className":2149,"code":2150,"language":2151,"meta":114,"style":114},"language-javascript shiki shiki-themes github-light github-dark","export class IColumn {\n  constructor(options) {\n    const {\n      title,\n      rowKey,\n      type = 'default',\n      sortable = false,\n      sorter = undefined,\n      headerComponent = undefined,\n      rowComponent = undefined,\n      disabled = false,\n      ...extras\n    } = options;\n\n    this.title = title;\n    this.rowKey = rowKey;\n    this.type = type;\n    this.sortable = sortable;\n    this.sorter =\n      sorter || ((row1, row2) => row1[this.rowKey] > row2[this.rowKey]);\n    this.headerComponent = this.processComponent(headerComponent);\n    this.rowComponent = this.processComponent(rowComponent);\n    this.disabled = disabled;\n    this.extras = extras;\n  }\n\n  processComponent(component) {\n    if (!component) return undefined;\n    return {\n      is: component.is || '',\n      props:\n        typeof component.props === 'function'\n          ? component.props\n          : () => component.props,\n      events:\n        typeof component.events === 'function'\n          ? component.events\n          : () => component.events,\n    };\n  }\n}\n","javascript",[30,2153,2154,2159,2164,2169,2174,2179,2184,2189,2194,2199,2204,2209,2214,2219,2223,2228,2233,2238,2243,2248,2253,2258,2263,2268,2273,2277,2281,2286,2292,2298,2304,2310,2316,2322,2328,2334,2340,2346,2352,2358,2363],{"__ignoreMap":114},[118,2155,2156],{"class":120,"line":121},[118,2157,2158],{},"export class IColumn {\n",[118,2160,2161],{"class":120,"line":145},[118,2162,2163],{},"  constructor(options) {\n",[118,2165,2166],{"class":120,"line":151},[118,2167,2168],{},"    const {\n",[118,2170,2171],{"class":120,"line":157},[118,2172,2173],{},"      title,\n",[118,2175,2176],{"class":120,"line":163},[118,2177,2178],{},"      rowKey,\n",[118,2180,2181],{"class":120,"line":176},[118,2182,2183],{},"      type = 'default',\n",[118,2185,2186],{"class":120,"line":182},[118,2187,2188],{},"      sortable = false,\n",[118,2190,2191],{"class":120,"line":194},[118,2192,2193],{},"      sorter = undefined,\n",[118,2195,2196],{"class":120,"line":216},[118,2197,2198],{},"      headerComponent = undefined,\n",[118,2200,2201],{"class":120,"line":222},[118,2202,2203],{},"      rowComponent = undefined,\n",[118,2205,2206],{"class":120,"line":231},[118,2207,2208],{},"      disabled = false,\n",[118,2210,2211],{"class":120,"line":243},[118,2212,2213],{},"      ...extras\n",[118,2215,2216],{"class":120,"line":286},[118,2217,2218],{},"    } = options;\n",[118,2220,2221],{"class":120,"line":292},[118,2222,296],{"emptyLinePlaceholder":295},[118,2224,2225],{"class":120,"line":299},[118,2226,2227],{},"    this.title = title;\n",[118,2229,2230],{"class":120,"line":305},[118,2231,2232],{},"    this.rowKey = rowKey;\n",[118,2234,2235],{"class":120,"line":331},[118,2236,2237],{},"    this.type = type;\n",[118,2239,2240],{"class":120,"line":336},[118,2241,2242],{},"    this.sortable = sortable;\n",[118,2244,2245],{"class":120,"line":342},[118,2246,2247],{},"    this.sorter =\n",[118,2249,2250],{"class":120,"line":359},[118,2251,2252],{},"      sorter || ((row1, row2) => row1[this.rowKey] > row2[this.rowKey]);\n",[118,2254,2255],{"class":120,"line":369},[118,2256,2257],{},"    this.headerComponent = this.processComponent(headerComponent);\n",[118,2259,2260],{"class":120,"line":381},[118,2261,2262],{},"    this.rowComponent = this.processComponent(rowComponent);\n",[118,2264,2265],{"class":120,"line":386},[118,2266,2267],{},"    this.disabled = disabled;\n",[118,2269,2270],{"class":120,"line":414},[118,2271,2272],{},"    this.extras = extras;\n",[118,2274,2275],{"class":120,"line":426},[118,2276,1098],{},[118,2278,2279],{"class":120,"line":431},[118,2280,296],{"emptyLinePlaceholder":295},[118,2282,2283],{"class":120,"line":436},[118,2284,2285],{},"  processComponent(component) {\n",[118,2287,2289],{"class":120,"line":2288},28,[118,2290,2291],{},"    if (!component) return undefined;\n",[118,2293,2295],{"class":120,"line":2294},29,[118,2296,2297],{},"    return {\n",[118,2299,2301],{"class":120,"line":2300},30,[118,2302,2303],{},"      is: component.is || '',\n",[118,2305,2307],{"class":120,"line":2306},31,[118,2308,2309],{},"      props:\n",[118,2311,2313],{"class":120,"line":2312},32,[118,2314,2315],{},"        typeof component.props === 'function'\n",[118,2317,2319],{"class":120,"line":2318},33,[118,2320,2321],{},"          ? component.props\n",[118,2323,2325],{"class":120,"line":2324},34,[118,2326,2327],{},"          : () => component.props,\n",[118,2329,2331],{"class":120,"line":2330},35,[118,2332,2333],{},"      events:\n",[118,2335,2337],{"class":120,"line":2336},36,[118,2338,2339],{},"        typeof component.events === 'function'\n",[118,2341,2343],{"class":120,"line":2342},37,[118,2344,2345],{},"          ? component.events\n",[118,2347,2349],{"class":120,"line":2348},38,[118,2350,2351],{},"          : () => component.events,\n",[118,2353,2355],{"class":120,"line":2354},39,[118,2356,2357],{},"    };\n",[118,2359,2361],{"class":120,"line":2360},40,[118,2362,1098],{},[118,2364,2366],{"class":120,"line":2365},41,[118,2367,590],{},[23,2369,2371],{"id":2370},"why-bother","Why bother?",[11,2373,2374],{},"Because column definitions become data, you get three things for free:",[34,2376,2377,2380,2383],{},[37,2378,2379],{},"Dynamic behavior per row without branching inside the table.",[37,2381,2382],{},"A single place to reason about rendering and interactivity.",[37,2384,2385],{},"Cross-cutting features — sorting, actions, selection — that slot in once and work everywhere.",[11,2387,2388],{},"That's the whole idea: treat each column as a structured, dynamic, reusable object, and let the table stay boring.",[11,2390,2391,2392,18],{},"Originally published on ",[1805,2393,2396],{"href":2394,"rel":2395},"https:\u002F\u002Fwww.linkedin.com\u002Fpulse\u002Fmastering-dynamic-data-tables-icolumn-class-ali-abdelbaqy-dzytc\u002F",[1809],"LinkedIn",[1813,2398,2399],{},"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":114,"searchDepth":145,"depth":145,"links":2401},[2402,2403,2404,2405],{"id":1918,"depth":145,"text":1919},{"id":204,"depth":145,"text":139},{"id":2135,"depth":145,"text":2136},{"id":2370,"depth":145,"text":2371},"2024-12-04","A small utility class that turns table columns into reusable, dynamic, sortable building blocks.",{},"\u002Fblog\u002Fmastering-dynamic-data-tables-icolumn-class",{"title":1903,"description":2407},"blog\u002Fmastering-dynamic-data-tables-icolumn-class","UVMV8RcMnlCwvQxgHfZjn5pE6g13595Z3W7JHV6nRIc",{"id":2414,"title":2415,"body":2416,"date":2642,"description":2643,"draft":1841,"extension":1842,"lang":1843,"meta":2644,"navigation":295,"path":2645,"seo":2646,"stem":2647,"__hash__":2648},"blog\u002Fblog\u002Fvalidating-image-urls-without-complex-error-handling.md","Validating image URLs without complex error handling",{"type":8,"value":2417,"toc":2637},[2418,2428,2435,2439,2442,2471,2475,2493,2518,2521,2525,2528,2599,2625,2628,2634],[11,2419,2420,2421,2424,2425],{},"If you've ever rendered a list of images from untrusted data, you know the pattern: most URLs are fine, a few are dead, and the broken-image icon ruins the layout. You can wrap everything in ",[30,2422,2423],{},"try\u002Fcatch",", or you can ask the browser directly: ",[444,2426,2427],{},"can you load this?",[11,2429,2430,2431,2434],{},"That's what ",[30,2432,2433],{},"checkUrl"," does. It returns a Promise that resolves when the image loads and rejects when it doesn't — no fetch, no CORS fiddling, no manual HEAD requests.",[23,2436,2438],{"id":2437},"the-idea","The idea",[11,2440,2441],{},"A Promise that resolves or rejects based on whether the image loads:",[109,2443,2445],{"className":2149,"code":2444,"language":2151,"meta":114,"style":114},"\u002F\u002F success\nconst promise = new Promise\u003Cvoid>((resolve, reject) => { resolve() })\n\n\u002F\u002F failure\nconst promise = new Promise\u003Cvoid>((resolve, reject) => { reject() })\n",[30,2446,2447,2452,2457,2461,2466],{"__ignoreMap":114},[118,2448,2449],{"class":120,"line":121},[118,2450,2451],{},"\u002F\u002F success\n",[118,2453,2454],{"class":120,"line":145},[118,2455,2456],{},"const promise = new Promise\u003Cvoid>((resolve, reject) => { resolve() })\n",[118,2458,2459],{"class":120,"line":151},[118,2460,296],{"emptyLinePlaceholder":295},[118,2462,2463],{"class":120,"line":157},[118,2464,2465],{},"\u002F\u002F failure\n",[118,2467,2468],{"class":120,"line":163},[118,2469,2470],{},"const promise = new Promise\u003Cvoid>((resolve, reject) => { reject() })\n",[23,2472,2474],{"id":2473},"using-the-built-in-image-object","Using the built-in Image object",[11,2476,2477,2478,2481,2482,2142,2485,2488,2489,2492],{},"Inside the callback, create an ",[30,2479,2480],{},"Image",", wire up ",[30,2483,2484],{},"onload",[30,2486,2487],{},"onerror",", then set ",[30,2490,2491],{},"src"," to kick off the request.",[109,2494,2496],{"className":2149,"code":2495,"language":2151,"meta":114,"style":114},"const img = new Image();\nimg.onload = () => resolve();\nimg.onerror = () => reject();\nimg.src = url;\n",[30,2497,2498,2503,2508,2513],{"__ignoreMap":114},[118,2499,2500],{"class":120,"line":121},[118,2501,2502],{},"const img = new Image();\n",[118,2504,2505],{"class":120,"line":145},[118,2506,2507],{},"img.onload = () => resolve();\n",[118,2509,2510],{"class":120,"line":151},[118,2511,2512],{},"img.onerror = () => reject();\n",[118,2514,2515],{"class":120,"line":157},[118,2516,2517],{},"img.src = url;\n",[11,2519,2520],{},"That's the whole mechanism. The browser does the work; the Promise is just a thin wrapper around two events.",[23,2522,2524],{"id":2523},"using-it-with-a-fallback","Using it with a fallback",[11,2526,2527],{},"The most common use case is swapping to a fallback when the original URL is broken.",[109,2529,2531],{"className":2149,"code":2530,"language":2151,"meta":114,"style":114},"const fallbackImage = 'https:\u002F\u002Fexample.com\u002Ffallback.png';\nlet url: string = anonymousOBJECT.image;\n\nconst checkImage = async () => {\n  try {\n    await checkUrl(url);\n    \u002F\u002F valid — keep the original\n  } catch {\n    \u002F\u002F invalid — swap in the fallback\n    url = fallbackImage;\n  }\n};\n\ncheckImage();\n",[30,2532,2533,2538,2543,2547,2552,2557,2562,2567,2572,2577,2582,2586,2590,2594],{"__ignoreMap":114},[118,2534,2535],{"class":120,"line":121},[118,2536,2537],{},"const fallbackImage = 'https:\u002F\u002Fexample.com\u002Ffallback.png';\n",[118,2539,2540],{"class":120,"line":145},[118,2541,2542],{},"let url: string = anonymousOBJECT.image;\n",[118,2544,2545],{"class":120,"line":151},[118,2546,296],{"emptyLinePlaceholder":295},[118,2548,2549],{"class":120,"line":157},[118,2550,2551],{},"const checkImage = async () => {\n",[118,2553,2554],{"class":120,"line":163},[118,2555,2556],{},"  try {\n",[118,2558,2559],{"class":120,"line":176},[118,2560,2561],{},"    await checkUrl(url);\n",[118,2563,2564],{"class":120,"line":182},[118,2565,2566],{},"    \u002F\u002F valid — keep the original\n",[118,2568,2569],{"class":120,"line":194},[118,2570,2571],{},"  } catch {\n",[118,2573,2574],{"class":120,"line":216},[118,2575,2576],{},"    \u002F\u002F invalid — swap in the fallback\n",[118,2578,2579],{"class":120,"line":222},[118,2580,2581],{},"    url = fallbackImage;\n",[118,2583,2584],{"class":120,"line":231},[118,2585,1098],{},[118,2587,2588],{"class":120,"line":243},[118,2589,1507],{},[118,2591,2592],{"class":120,"line":286},[118,2593,296],{"emptyLinePlaceholder":295},[118,2595,2596],{"class":120,"line":292},[118,2597,2598],{},"checkImage();\n",[109,2600,2604],{"className":2601,"code":2602,"language":2603,"meta":114,"style":114},"language-html shiki shiki-themes github-light github-dark","\u003Cimg :src=\"url\" \u002F>\n","html",[30,2605,2606],{"__ignoreMap":114},[118,2607,2608,2610,2614,2617,2619,2622],{"class":120,"line":121},[118,2609,136],{"class":135},[118,2611,2613],{"class":2612},"s9eBZ","img",[118,2615,2616],{"class":131}," :src",[118,2618,684],{"class":135},[118,2620,2621],{"class":169},"\"url\"",[118,2623,2624],{"class":135}," \u002F>\n",[11,2626,2627],{},"No custom error handling in the template, no flicker of a broken image — just a resolved URL by the time it hits the DOM.",[11,2629,2391,2630,18],{},[1805,2631,2396],{"href":2632,"rel":2633},"https:\u002F\u002Fwww.linkedin.com\u002Fpulse\u002Fwithout-having-write-complex-error-handling-code-image-ali-abdelbaqy\u002F",[1809],[1813,2635,2636],{},"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 .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":114,"searchDepth":145,"depth":145,"links":2638},[2639,2640,2641],{"id":2437,"depth":145,"text":2438},{"id":2473,"depth":145,"text":2474},{"id":2523,"depth":145,"text":2524},"2023-07-11","A tiny async helper that uses the browser's Image object to tell you whether a URL actually resolves to a loadable image.",{},"\u002Fblog\u002Fvalidating-image-urls-without-complex-error-handling",{"title":2415,"description":2643},"blog\u002Fvalidating-image-urls-without-complex-error-handling","ffLP9MBciLrFjNznnAAd7TWSAfXCbXAIajWg1NQeaW4",1776556160832]