# Harlan Wilton > Open source developer, contributing to the Vue, Nuxt, and Vite ecosystems. Canonical Origin: https://harlanzw.com/ ## LLM Resources - [Full Content](https://harlanzw.com/llms-full.txt): Complete page content in markdown format. --- - **Page:** Open Source Developer - **Source:** https://harlanzw.com/ - **Description:** Independently funded open-source developer at Nuxt & UnJS. ::home-duality #human :::prose # Harlan Wilton Independently funded [open-source](https://github.com/harlan-zw){title="Visit my GitHub"} at [:icon{name="logos:nuxt-icon" .w-5.h-5.inline} Nuxt](https://nuxt.com/){title="Open Nuxt.com"} & [:unjs-logo{.mx-1 alt="UnJS icon"} UnJS](https://unjs.io/){title="Open UnJS.io" .inline-flex.items-center.whitespace-nowrap}. Based in Melbourne, Australia. Author of [Unlighthouse](https://github.com/harlan-zw/unlighthouse), [Unhead](https://github.com/harlan-zw/unhead) and [Nuxt SEO](https://nuxtseo.com). ::: :::home-updates ::: #agent :::prose ## Harlan GitHub Agent I triage issues, review code and prepare pull requests. Built with [Harlan Agent Kit](https://github.com/harlan-zw/harlan-agent-kit). Running on [🐷 Hogwild](https://hogwild.harlanzw.com/). ::: :: --- - **Page:** Talks - **Source:** https://harlanzw.com/talks - **Description:** This is a directory of the talks I've given either in video or slide format. ## Videos - :icon{name="logos:youtube-icon" .mr-2} **Nuxt Nation 2024**: [An Introduction to Nuxt Scripts](https://www.youtube.com/watch?v=V-vPRhUyhDY) - :icon{name="logos:youtube-icon" .mr-2} **Vue Fes Japan 2023**: [Getting your head around your \](https://www.youtube.com/watch?v=gwYFwUcxia8) - :icon{name="logos:youtube-icon" .mr-2} **Nuxt Nation 2022**: [Supercharged SEO head management and practical SEO tips](https://www.youtube.com/watch?v=UC-U0NJowS4) - :icon{name="logos:youtube-icon" .mr-2} **Laravel Meetup Sydney 2019**: [Laravel 5.8, Laradoc and Bref.sh](https://www.youtube.com/watch?v=nZomdslY9y8) ## Slides - **Vue Fes Japan 2023**: [Getting your head around your \](https://vuefes23.harlanzw.com/1) - **Nuxt Nation 2022**: [Supercharged SEO head management and practical SEO tips](https://talk-supercharged-head-management.vercel.app/1) - **Laravel Meetup Sydney 2022**: [To Vite and Beyond: A history and future of bundling](https://to-vite-and-beyond-a-history-and-future-of-bundling.vercel.app/) --- - **Page:** Fighting GitHub CI bills with my own homelab - **Source:** https://harlanzw.com/blog/fighting-github-ci-bills-with-my-own-homelab - **Description:** Moving GitHub Actions onto Hogwild, with live runner cost estimates, memory limits and the homelab maintenance that followed. Getting agents to write more code gave me more CI to run. Every proposed fix needed checks. Review found something, another commit arrived, and the checks ran again. Multiply that by several projects and a service that keeps working after I've closed my laptop. I wanted that work off my GitHub bill. I also wanted it off my desktop. So I bought a small computer, named it Hogwild, and gave myself a second job maintaining it. ## What the runners would have cost This reads the completed job totals from Hogwild. The amount updates as the runner records more work. ::article-runner-savings :: I call this estimated hosted cost avoided because I haven't reconciled it against an invoice. It prices the recorded runtime at GitHub's standard Linux x64 rate. A job on GitHub's machine may take a different amount of time. The history also includes earlier self-hosted runner work, before the move to Hogwild. It isn't all work this new machine completed. [GitHub includes hosted minutes for private repositories, and standard hosted runners are free for public repositories](https://docs.github.com/en/billing/concepts/product-billing/github-actions). My open-source projects already benefit from that. Private project builds were the reason to move more work home. The calculation rounds each job up to a whole minute before adding the total. Two jobs lasting 61 seconds each count as four minutes. Rounding their combined runtime would count three. Here's the equivalent TypeScript for the calculation used by the runner's job history: ```ts function billableMinutes( startedAt: number, completedAt: number, ) { return Math.ceil((completedAt - startedAt) / 60_000) } const hostedCostUsd = jobs.reduce((total, job) => { const { startedAt, completedAt } = job return total + billableMinutes(startedAt, completedAt) }, 0) * 0.006 ``` The [US$0.006 rate](https://docs.github.com/en/billing/reference/actions-runner-pricing) is for GitHub's standard two-core Linux x64 runner, checked in September 2026. This gives me a consistent comparison. It doesn't tell me when the computer pays for itself. ## The computer Hogwild is a Minisforum MS-02 Ultra with an Intel Core Ultra 9 285HX. I bought a 2 TB Kingston KC3000 SSD and 32 GB of Crucial DDR5 RAM for it. ::article-hogwild-hardware :: Those are my purchase figures in Australian dollars. The runner estimate above is in US dollars. I haven't measured electricity use well enough to add a credible running cost. The machine also runs [my GitHub agent](/blog/building-my-software-factory-on-github), the [Hogwild status page](https://hogwild.harlanzw.com/), and services like Jellyfin. Charging the entire purchase to CI would be a choice, too. ## What about electricity? I don't have a wall-meter reading for Hogwild yet. CPU utilisation can't tell me what the whole machine draws. [ServeTheHome measured roughly 19–21 W at idle and 145 W under sustained load](https://www.servethehome.com/minisforum-ms-02-ultra-review-intel-new-home-lab-king/4/) on an MS-02 Ultra. My services, operating system and network cards can change that. I've used 50 W average as a scenario, rather than treating an idle measurement as a day of CI. At 50 W, running around the clock uses 438 kWh a year. I use 28.2¢/kWh as a local benchmark, the simple average of the [five Victorian Default Offer residential flat tariffs for 2026–27](https://www.esc.vic.gov.au/sites/default/files/documents/Victorian%20Default%20Offer%202026-27%20Price%20Determination.pdf), including GST. That puts electricity at about A$124 a year. This is a tariff benchmark, rather than a measured average of Melbourne household bills. It excludes the daily supply charge I already pay to connect my house. ::article-runner-projection :: The projection uses the last seven complete days of runner activity. Today's partial total stays out of the average. Seven days is a short sample, so a busy release week can make the result look much better than a quiet month. The chart charges the full A$2,773 purchase to CI, converts hosted charges to AUD, then adds five years of electricity. It also subtracts an allowance for included GitHub minutes. The controls let you change the allowance and exclude work that would already run free on GitHub. I wouldn't buy the machine on this projection alone. I want to see a few months of work and a measured power average before calling it my actual saving. ## One container, one job The runner supervisor watches the configured repositories for queued work. A signed `workflow_job` webhook can wake it early; polling catches work if that notification doesn't arrive. Before it registers a runner, it checks whether the host has capacity. Then it creates a just-in-time runner in a fresh container. The runner takes one job and exits. The job container runs as a non-root user. It gets no Docker socket or host filesystem mounts. The supervisor creates the registration configuration on the host and passes it through standard input. That leaves the workflow change fairly small. A job selects one of the labels my supervisor serves: ```yaml jobs: test: runs-on: [self-hosted, harlan-desktop-ci] steps: - uses: actions/checkout@v4 - run: corepack enable - run: pnpm install --frozen-lockfile - run: pnpm test:run ``` That's a shortened example, assuming the runner image already has the project's Node version. The `harlan-desktop-ci` label survived the move. I haven't renamed it yet. Fresh containers help keep one job's files out of the next job. I still have to maintain the runner image and decide which repositories may execute code on my hardware. ## The first budget was RAM An earlier desktop runner measurement put a Nuxt SEO deploy at 10.7 GiB of memory. A few builds arriving together could consume the host before CPU became the problem. The supervisor now reserves memory before starting a job. A reservation spends part of the host's available capacity. A container limit caps how much that individual job can use. Here is a row from Hogwild's runner configuration, wrapped for reading: ```text repository: harlan-zw/nuxtseo.com labels: harlan-desktop-deploy,nuxtseo-deploy warm runners: 0 maximum runners: 1 CPUs: 12 memory reservation: 13g memory limit: 16g memory plus swap: 20g ``` The reservation and limit deliberately differ. The supervisor budgets 13 GiB before accepting the deploy, while the container has room to exceed that estimate. I also give waiting deploys the next available capacity. Otherwise a steady stream of smaller checks can keep taking the space a deploy needs. Getting the limits wrong produces misleading failures. A `gscdump.com` cold build measured 7.82 GiB, close enough to its old 8 GiB limit that the container could kill it mid-bundle. The same commit passed on the desktop, where the job had more memory. I had to fix the runner configuration before another code change could tell me anything useful. ## Giving an agent the right keys Hogwild runs an agent that reads public issue text. It also runs a supervisor that can create containers. I don't want those to share an account with the same permissions. The account split gives the agent ordinary user access. A separate admin account owns host administration and Docker access. [PR #250 in Harlan Agent Kit](https://github.com/harlan-zw/harlan-agent-kit/pull/250) adds a Hogwild skill and an `hw` command to make that distinction explicit. The proposed commands include: ```sh hw status hw logs runner -n 100 hw runners ``` For commands on the machine, the caller must choose an account: ```sh hw run agent whoami hw run admin whoami ``` The agent route refuses `sudo`. The useful part of that PR is the account map: a failed privileged command should send administrative work to the admin account. It shouldn't become a reason to give the agent more permissions. That is still a proposed change as I write this. The pull request has the implementation and review history. The live figures in this article use a separate read path. Hogwild's collector reads local services and returns a small summary to this site's server, authenticated with a dedicated token. The browser gets the summary. It never gets the agent's dashboard password. For the homepage's latest work, the server checks each reference against GitHub without authentication before exposing a title or link. Private work stays out of that list. ## I own the failures now On 10 September, a build stalled fetching a font from `fonts.gstatic.com`. Docker was trying a DNS server that the firewall blocked. The delay then collided with the HTTP client's timeout. There was nothing wrong with the font import. There was something wrong with my network. Runner updates are another small tax. The runner version in the image can fall behind GitHub's version, leaving each fresh runner to download an update before doing useful work. Updating the image removes that repeated setup. Even restarting the supervisor needs care. It drains running jobs before stopping. I have to leave it time to finish, or decide that interrupting those builds is worth it. I still use GitHub-hosted runners where they make sense. Hogwild gives the private builds somewhere else to run, and I can see their memory use and cost comparison on a page I control. I still need to put a meter on Hogwild. That will replace the largest electricity assumption in the chart. --- - **Page:** Building my software factory on GitHub - **Source:** https://harlanzw.com/blog/building-my-software-factory-on-github - **Description:** How I built a software factory around GitHub issues, AI agents and pull requests, and the work it still leaves for me. I wanted my weekends back. For several weeks I'd been running the same health-check skills against my projects. Check Sentry. Look at what broke overnight. Work out which issues needed attention. The procedures improved as I used them. Eventually I was changing them less, but I still had to open my laptop and start them. That was the reason to build my software factory. I call it Harlan GitHub Agent. It lives in [Harlan Agent Kit](https://github.com/harlan-zw/harlan-agent-kit), runs on my home server, Hogwild, and uses GitHub issues and pull requests to keep the work visible. It produces PRs. I still review most of them. ## What I mean by a software factory The frame around this map is me. I decide what to work on and what ships, while the agents move changes through the loops. ::article-figure ```yaml [props] src: /blog/ai-workflow/original-factory-map.webp display-width: '1000' alt: Factory map with Harlan owning the code, review, close off and monitoring loops caption: Code, review, close off and monitoring, with human ownership across the whole process. width: '1696' height: '716' ``` :: The actual service adds a poller, SQLite journal and scheduler to keep work moving between agents and GitHub. I ended up maintaining all of those pieces too. ::article-figure ```yaml [props] src: /blog/ai-workflow/original-factory-architecture.webp display-width: '1200' embed: /blog/ai-workflow/factory/index.html alt: Interactive Harlan GitHub Agent architecture caption: Follow Intake, Tasks and Agents, or Publication. Select a node to inspect its source references. Use the zoom controls to read the details. width: '1920' height: '1080' ``` :: This describes the service in September 2026. By the time you read it, I will probably have changed parts of it again. If you're still dealing with broken agent output or conflicting edits, I'd start with [optimizing your development workflow with AI](/blog/improving-your-development-workflow-with-ai). Those problems follow you into a factory. ## Why GitHub? My projects already had issues, branches, pull requests and CI on GitHub. That gave the factory somewhere to get work and somewhere to put the result. It also left me a familiar place to inspect it. I can open a PR, read the diff, see the checks and decide what to do. The service reads issues, PRs, reviews and check runs. A poller records what it sees in a SQLite journal. The scheduler turns that state into tasks and gives available agents work. An agent takes one task in an isolated worktree. It might triage an issue, implement a change, review a PR or repair a finding. Those are different jobs, with different instructions. The result returns through the publication gate. That part of the service owns the GitHub writes and rechecks the relevant commit before publishing a result. That last check matters. While an agent is reviewing a PR, another commit can arrive. A review of the old code must not become a claim about the new code. The [September architecture snapshot](https://github.com/harlan-zw/harlan-agent-kit/tree/ca1cb363a452ecadc03648df9613f38e4585560e/packages/harlan-github-agent) separates reading GitHub state, doing the work and publishing the result. GitHub gives me the shared record; the service handles the work between observations. ## Following one issue An issue first needs enough information to act on. Triage checks the premise and decides whether it is ready for implementation, needs a specification, needs more information or should wait. If it is ready, an agent gets a worktree and starts the task. For a bug, I want a reproduction and a failing test before the fix. The output should be a change someone can review. A separate Review agent challenges that change. If it finds a defect the system may repair, a fresh Repair agent takes the finding. The new commit goes through review again. I don't want the author marking its own homework. Separating the roles also gives the reviewer a smaller job: find what's wrong and show the evidence. The review result names the commit it examined and records uncertainty. This READY comment shows what that looks like. Even at 95/100, it explicitly asks for a human merge decision. ::article-figure ```yaml [props] src: /blog/ai-workflow/ready-review.webp display-width: '900' alt: Agent review marked READY at 95 out of 100, stating that human merge approval is still required caption: A READY review with evidence, confidence and an explicit request for human approval. width: '1830' height: '624' ``` :: ### Routine changes and judgement My factory policy permits selective auto-merge. The label delegates that authority for work that needs no judgement. ::article-figure ```yaml [props] src: /blog/ai-workflow/original-review-ideas.webp display-width: '1000' alt: Review diagram showing independent reviewers, selective auto-merge and a bounded review queue caption: Independent review, selective auto-merge and a bounded queue. The services and queue slots are illustrative. width: '1760' height: '597' ``` :: A high confidence score isn't permission. Neither is the fact that a previous version of the PR passed review. If the scope changes, the decision needs to change with it. I still review most PRs. I also own the consequences of the ones I allow the service to merge. ## The boring handoff that broke everything One of the more useful failures had very little to do with generated code. The service triaged an issue, then looked up that triage session when implementation started. The lookup included the default branch's commit. If main advanced in between, the lookup no longer matched. The issue hadn't changed. Its title and body were the same. Work still stopped with: ```text The issue changed before work started. ``` Batch planning made the timing worse. By the time a task started, main had often moved. Re-triaging sent the same work around again. The [fix in PR #183](https://github.com/harlan-zw/harlan-agent-kit/pull/183) keyed the triage session on the issue's own state, independently of the default branch tip. The lookup changed from this: ::expand{width="1100"} ```diff - const scopeDigest = issueSnapshotDigest({ ...snapshot.value, baseSha: prepared.value.defaultBranchSha }) + const scopeDigest = issueSnapshotDigest(snapshot.value) const sessionId = options.store.getWorkerSession( task.repository, task.issueNumber, 'issue_triage', scopeDigest, ) ``` :: That excerpt is from the implementation worker, with the lookup wrapped for readability. The same change also had to happen when triage stored the session. Fixing only one side would leave the keys disagreeing. Implementation needs a checkout of the code it will change. The triage decision needs to identify the issue it examined. An unrelated merge should not invalidate it. I had modelled the application state incorrectly. No prompt was going to repair the lookup. The controller also needed recovery for expired worker ownership and limits on repair rounds. An unattended service has to know what work is still owned, what can resume, and when to stop trying. ## Giving the agents somewhere to work The service uses Worktrunk to give each task an isolated checkout. Main stays clean. Setup prepares the dependency graph and local state before the agent starts. The pnpm store can be shared, but each checkout needs its own `node_modules` and generated files. Writable state belongs to the task too. I use the same standing instructions and task skills across my interactive work and the factory. Those instructions cover things like error handling, tests, review evidence and how a task finishes. That gives me one place to improve a procedure after a failure. It also gives me another thing to verify: which version of the instructions and tools is a worker actually using? Shared instructions don't make untrusted input safe. Issues, comments and source files are still input to an agent. The service needs explicit authority for its actions, and an agent's proposed result still needs checking before publication. I wouldn't treat a synced `AGENTS.md` as a security boundary. For example, the service checks repository permission when a caller asks for a write credential. The [write gate](https://github.com/harlan-zw/harlan-agent-kit/blob/ca1cb363a452ecadc03648df9613f38e4585560e/packages/harlan-github-agent/src/github-write-gate.ts) contains this check: ```ts getToken(repository, access, signal) { if (!writeAccess.has(access) || options.mayWrite(repository)) return options.source.getToken(repository, access, signal) return Promise.resolve(err({ repository, message: repositoryQuarantineReason(repository), })) } ``` `writeAccess` contains the contents, issue and workflow write permissions. Read requests can pass through. A write request for a repository I haven't enabled returns an error before the caller gets a token. ## Hogwild 🐷 Hogwild runs the factory and my self-hosted GitHub Actions runners. These are current readings from the machine and the agent service. ::article-live-stats :: I wrote more about [moving CI onto my homelab](/blog/fighting-github-ci-bills-with-my-own-homelab), including a live runner cost comparison. CI cost helped motivate the move, but putting runners on a machine introduced a different problem. Heavy builds competed for the same memory. An earlier runner configuration recorded a Nuxt SEO deploy peaking at 10.7 GiB. Starting more containers didn't create more physical RAM. I needed admission based on available capacity. The supervisor reserves memory before starting heavy work, releases it afterwards, and leaves room for waiting deploys. A container's hard limit and a reservation do different jobs. The limit stops one job using too much. The reservation helps stop the host accepting too many jobs at once. There is a [Hogwild status page](https://hogwild.harlanzw.com/) for seeing the machine's activity. Its runner cost estimate compares that activity with equivalent hosted Linux runner charges. It doesn't subtract hardware or power, and included minutes or public repository allowances can change what I'd actually have paid. I don't have a measured payback claim to make here. The machine gives me capacity I can manage, and another system I have to maintain. ## The queue needs a limit The September example configuration sets the open PR cap to eight: ```yaml # No new issue work starts when open pull requests # reach this limit. max_open_pull_requests: 8 ``` That is an excerpt from the [versioned configuration](https://github.com/harlan-zw/harlan-agent-kit/blob/ca1cb363a452ecadc03648df9613f38e4585560e/packages/harlan-github-agent/config.example.yml). The cap pauses new issue work. Review and repair still need to run, otherwise the service cannot clear the queue. Once the queue is waiting on my review, more agents mostly add PRs for me to read. Repair needs a limit for the same reason. An agent can keep attempting a problem without getting closer to a change I'd accept. I want the service to stop and surface the blocked work. Cost has to include that failed work too. A cheap model request tells me very little about the cost of a completed task if it needs repeated attempts and repairs. I left provider prices out of this article. They change quickly, and a subscription allowance isn't the same thing as API billing. The number I'd want to compare is what useful, reviewed work costs under the execution model I'm actually using. ## Work can arrive without me Scheduled routines are the part that solved my original problem. Sentry check-ins look for production errors. Daily check-ins combine runtime and usage signals. Their findings can become issues or proposed repairs, which feed back into GitHub for the factory to handle. I also built a feedback form on a clone of the MelbJS site that files GitHub issues. Feedback enters the same queue as everything else. An issue may need clarification, or its PR may wait for review. There is even a Factory review routine that proposes changes to the factory itself. Those proposals join the work I need to judge. ## What I still do I decide which projects the factory may work on. I set the constraints, review most changes and deal with the consequences when something goes wrong. For Unhead, a technically correct change can still create an unwanted breaking change or affect an integration the agent hasn't considered. That judgement doesn't disappear because the checks are green. The factory has taken over starting and following a lot of maintenance work. It has also given me controller bugs, runner capacity and agent instructions to maintain. I wanted to stop opening my laptop just to tell an agent to check the same things again. Now those checks can start without me. Deciding what to do with the results is still my work. --- - **Page:** Optimizing your development workflow with AI - **Source:** https://harlanzw.com/blog/improving-your-development-workflow-with-ai - **Description:** What I changed when AI agents wrote broken code, shared the same files, and produced more pull requests than I could review. I use AI heavily across my open-source projects and Nuxt SEO Pro. For a while, my workflow was pretty simple. Give an agent a task, let it work on main, commit everything and push. Fast. Also a good way to send bugs to people using your product. I knew pull requests would help. But adopting them brought more work: separate environments, conflicts, reviews I had to remember to start, and CI that couldn't keep up. Every improvement let the agents do more. Then something else became the problem. It's tempting to look at someone running a room full of agents and start there. I've ended up [building my own software factory on GitHub](/blog/building-my-software-factory-on-github), but most of the useful changes happened before that. They came from three questions. ## Can I trust my agent's code? On 28 August I merged an agent-written Stripe integration into Nuxt SEO. It touched 23 files. The checks were green and the description said the agent had implemented and verified it. I merged it without reading the code. ::article-figure ```yaml [props] src: /blog/ai-workflow/stripe-pr.webp display-width: '760' alt: Merged Stripe integration PR 636, showing 23 changed files and the agent disclosure caption: The PR I merged on 28 August. The payment failure below is a separate teaching example. width: '1792' height: '1492' ``` :: The description looked convincing. Checking every claim would have taken me longer than the agent spent writing it. ### Where did the error go? One recurring problem was silent error handling. An older version of my admin code turned a failed Stripe balance request into `null`: ```ts const balance = await stripe.balance.retrieve() .catch(() => null) ``` The request failed, but the cause disappeared. Whatever happened next had to work with `null`. Take this simplified payment handler: ```ts try { await stripe.paymentIntents.confirm(paymentIntentId) } catch { // Payment failed, let the user try again. } ``` This is an illustration, not the code from that merged PR. The comment sounds reasonable. It doesn't tell the caller what failed or leave anything useful to investigate. There is another detail hiding in this tiny example. A resolved confirmation request can still require customer authentication. The caller needs to inspect the PaymentIntent status. [Stripe's confirmation documentation](https://docs.stripe.com/api/payment_intents/confirm) describes those transitions. I started by writing down a standing rule: no silent catches. Expected failures should have an explicit result. Unexpected failures should propagate or be reported with enough context to investigate. The next agent could still write the same bad code. So I added a lint rule. It catches shapes like `.catch(() => null)` and empty catch blocks. A hook runs lint after edits so the agent gets feedback while it is still working. The limits matter here. My rule allows comment-only catches, including the illustrated one above. It can reject a code pattern. It can't decide whether a payment failure reached the right person. ### Give failure a shape My TypeScript preferences borrow from Effect, without requiring Effect as a dependency. Expected failures become tagged values that the caller must handle. Unexpected infrastructure failures remain errors. For a payment, that means distinguishing a card decline from a service failure. The caller needs to know whether to ask the customer for another payment method or report a problem. Here is the same handler with explicit results for a declined card and a rate limit. ::article-disclosure{summary="Show the complete error handling"} Illustrative excerpt. The request-bound logger, Stripe client and result helpers are set up outside this block. The PaymentIntent already exists with its payment method configured. A returned payment still needs its status handled. :::expand{width="960"} ```ts try { const payment = await stripe.paymentIntents.confirm(paymentIntentId) // The caller must still check the returned payment status. // A successful request can require customer authentication. log.context({ paymentStatus: payment.status }) return ok(payment) } catch (error) { log.level('error') log.context({ paymentStatus: 'failed' }) // A declined card becomes a result the caller can handle. if (error instanceof Stripe.errors.StripeCardError) { log.warn('payment.declined', error) return err({ _tag: 'PaymentDeclined' as const, code: error.code }) } // Keep retry information when Stripe provides it. if (error instanceof Stripe.errors.StripeRateLimitError) { log.warn('payment.rate_limited', error) return err({ _tag: 'RateLimited' as const, retryAfter: error.headers?.['retry-after'] }) } // Record unexpected failures, then let them propagate. log.error('payment.failed', error) throw error } ``` ::: :: I want sensitive fields redacted before errors leave the application. Nuxt SEO has shared logging rules for names, redaction and where events go. That gives each handler something consistent to use. Then there is production. Sentry can surface an exception, but some broken journeys never throw. A user may retry several times and give up. Logs and usage signals can help find those cases. A drop in completed payments is a reason to investigate. It doesn't tell you whether the cause is a bug, confusing UI or something else. By this point, fixing a catch had taken me from an instruction file to production monitoring. ### More tests didn't give me more confidence Agents are very happy to write tests. Some of mine checked that a file contained a string, or that the source had the structure the agent had just written. Those tests gave me more code to maintain. They didn't tell me much about whether the feature worked. My testing skill now asks for concrete input, a call to an exported function, and an observable result. For a bug fix, I want to see the test fail before the fix. I also use gitignored scratch tests. An agent can write a probe to understand a problem, run it, then throw it away. If it captures behaviour that needs protecting, it belongs in the maintained suite. A rank-check bug in Nuxt SEO made that distinction painfully concrete. The data provider's spending limit stopped a request. My code treated the missing result as a missing ranking and emailed a customer about a drop that hadn't been measured. This regression test starts with a keyword at position 21. When the fetch returns nothing, the saved position must stay at 21. This is an excerpt from the test, with its fixture helpers left out: ```ts const db = await seed(21) const outcome = await recordRankCheck(db, { keywordId: KEYWORD, siteUrl: 'https://ranknm.example', competitorDomains: [], fetchSerp: async () => undefined, now: NOW, }) expect(outcome).toEqual({ _tag: 'not_measured' }) expect(await snapshots(db)).toEqual([ { day: '2026-08-27', position: 21 }, ]) ``` Checking for a string in the source wouldn't have caught that email. This test checks what the application writes. ## Do my agents work effectively in parallel? Asking four agents to work at once is easy. Giving them four usable environments took more effort. A branch alone doesn't give an agent a separate directory. A worktree does. Each task gets its own checkout, while the primary checkout stays clean on main. ::article-figure ```yaml [props] src: /blog/ai-workflow/original-worktrees.webp display-width: '1100' alt: Worktree diagram showing four agents, shared package files and private task state caption: Each agent gets a checkout and private task state. Branches and preview names are illustrative. width: '1808' height: '649' ``` :: I use [Worktrunk](https://github.com/max-sixty/worktrunk) to manage that setup. Before a task starts, the checkout needs its dependencies, local configuration and writable state ready. For example, I can start a task from the current main branch like this: ```sh wt switch --create fix/payment-errors --base origin/main pnpm install --frozen-lockfile pnpm exec nuxt prepare ``` Worktrunk creates the checkout. The next two commands prepare that checkout's dependencies and Nuxt files. Project-specific setup still has to provide local configuration and a private database. Otherwise, the agent spends the first part of the task fixing its environment. Sometimes it changes application code to compensate for an environment that was wrong to begin with. ### Share the package store The useful distinction is between sharing package files and sharing an installed application. [pnpm's package layout](https://pnpm.io/symlinked-node-modules-structure) lets worktrees share package data while keeping a separate dependency graph in each checkout. Each task gets its own `node_modules`. Nuxt generates `.nuxt` inside that task's checkout too, because generated files can contain paths specific to it. Local databases need their own writable state. Sharing a database between two tasks makes it much harder to know what either agent actually tested. There are less interesting details that still make a difference. I use [Portless](https://github.com/vercel-labs/portless) for stable local preview names, and named browser pages for each task. I also built a JetBrains worktree plugin so I could see the checkouts in my IDE. I needed the separate checkouts to be easy to find, otherwise I'd work around the setup. ::article-figure ```yaml [props] src: /blog/ai-workflow/worktree-ide.webp display-width: '700' alt: JetBrains worktree panel listing task branches and their latest activity caption: My JetBrains worktree panel. The 86 entries are worktrees, not concurrent agents. width: '1282' height: '1042' ``` :: ### Separate checkouts still meet at the same files Worktrees didn't fix my architecture. Independent features still passed through shared configuration and registration points. Two tasks could work perfectly well in isolation, then arrive with competing edits to the same block. I had a real pair of Nuxt SEO PRs that both rewrote the sitemap-failure handling in one crawl job. They needed an explicit landing order and a rebase. PR #728 added a retry flag. PR #733 changed the event name on the same line. The repository is private, so I've included shortened excerpts to show the overlap: ::expand{width="1100"} ```diff # PR #728 - logWarn('crawl_audit.degraded', error, { stage, ...context }) + logWarn('crawl_audit.degraded', error, { stage, ...context, retryable: sitemap.retryable }) # PR #733 - logWarn('crawl_audit.degraded', error, { stage, ...context, retryable: sitemap.retryable }) + logWarn('crawl_audit.target_site_refused', error, { stage, ...context, retryable: sitemap.retryable }) ``` :: Sometimes that's unavoidable. If it keeps happening, I look at the boundary. Can the feature own its configuration? Can the shared module expose a smaller interface? Are the tasks actually independent? For related work, stacked PRs can make the dependency explicit. For repeated mechanical edits, I use Ripast to preview and apply structured renames and moves, then check the result. Repeating a more detailed prompt wasn't fixing broken refactors in large Vue files. The same applies to context. `VISION.md` explains what the product is trying to do. `GLOSSARY.md` keeps the names consistent. `DESIGN.md` gives agents shared UI decisions to follow. Two agents can use different files and still make incompatible product decisions. Separate directories won't catch that. ## Can I keep up with what they produce? Eventually the agents could produce work faster than I could land it. You can see the current workload below: agents doing work, PRs still open, and CI jobs running or waiting. ::article-live-stats :: Some of the delay was CI. I was paying for repeated setup and waiting for checks on commits I had already replaced. ::article-figure ```yaml [props] src: /blog/ai-workflow/original-ci-ideas.webp display-width: '1100' alt: CI diagram showing fast PR checks, cheap checks first, superseded runs and scratch tests caption: Four ways to reduce CI waiting. A smaller PR gate moves some failure detection until after merge. width: '1760' height: '560' ``` :: Lint and type checks can reject a change before an expensive build starts. [GitHub Actions concurrency](https://docs.github.com/en/actions/concepts/workflows-and-actions/concurrency) can cancel superseded checks for the same PR. A deployment that needs to finish requires a different policy. For a PR-only validation workflow, this is enough to cancel an older run when a new commit arrives: ```yaml name: Validate on: pull_request concurrency: group: validate-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: validate: runs-on: ubuntu-latest steps: # Checkout and install dependencies first. - run: pnpm lint - run: pnpm typecheck - run: pnpm build ``` This is a configuration excerpt. Checkout and dependency setup depend on the project. The order means a failed lint step stops the job before the build starts. A smaller PR gate is also a tradeoff. Moving checks until after merge means some failures reach main before you discover them. I need to know what I'm giving up and how those failures get reported. Caching needs the same scrutiny. I removed pnpm caching from one CI setup because it was slower there. A shared package store on my machine and downloading an archive on a hosted runner have different costs. But faster CI only solves part of it. My attention is still finite. ### Make the review worth reading I want an agent review to try to disprove the change. Check the premise, follow the failure cases, and show what was actually verified. This review comment shows the actual navigation the agent checked in two layouts. It also leaves a request error visible in one screenshot. ::article-figure ```yaml [props] src: /blog/ai-workflow/browser-check.webp display-width: '1000' alt: Browser verification comment with fleet and one-site navigation screenshots caption: The one-site screenshot contains a request error. The comment shows what the agent checked and what still needs attention. width: '1784' height: '1038' ``` :: A diagram helps when the change crosses several boundaries. This PR shows where the Bing data comes from and where credentials enter the request. ::article-figure ```yaml [props] src: /blog/ai-workflow/bing-flow.webp display-width: '1000' alt: Bing PR diagram connecting views, Site credentials, bounded requests and the public Bing API caption: The Bing integration PR. The diagram gives the reviewer a route through the change. width: '1626' height: '864' ``` :: A confidence score helps only if it says what remains untested. It doesn't authorize a merge. With Unhead, I still need to think about framework integrations, breaking changes and bundle overhead. An agent may correctly implement its task while missing a reason I don't want the change at all. If review is full, another running agent can just produce another waiting PR. ::article-figure ```yaml [props] src: /blog/ai-workflow/original-review-ideas.webp display-width: '1000' alt: Review diagram showing independent reviewers, selective auto-merge and a bounded review queue caption: Independent review, selective auto-merge and a bounded queue. The services and queue slots are illustrative. width: '1760' height: '597' ``` :: ## Start with the problem you keep having If you keep correcting the same mistake, write down the rule. If the mistake has a detectable shape, make a check. If two agents keep editing the same block, look at the task split and the architecture. You can do any of that with one agent and a normal development workflow. I built the factory when I wanted trusted maintenance work to start without opening my laptop. I still have to decide what is worth doing and what should ship. --- - **Page:** Projects - **Source:** https://harlanzw.com/projects - **Description:** This is a directory of the open-source packages and tools I've released that are actively maintained. ::prose This is a directory of the open-source packages and tools I've released that are actively maintained. You can find all of my projects on [GitHub](https://github.com/harlan-zw). :: --- - **Page:** Blog - **Source:** https://harlanzw.com/blog - **Description:** This is a directory of the articles I have written and published that are sometimes updated. --- - **Page:** How I use AI in open source - **Source:** https://harlanzw.com/blog/ai-in-open-source - **Description:** My policy for AI-written code, pull request descriptions and automated reviews across the projects I maintain. AI is here, and it is rightfully carving out how open source works. Being an engineer means adapting to the underlying technology, so I'll be using AI heavily in my open-source work. There's already enough AI slop in open source. AI-generated PR descriptions generally suck. I don't want to add to it. [Daniel Roe's post on using AI in open source](https://roe.dev/blog/using-ai-in-open-source) helped me settle my policy on working with AI in open source. ## The Rules - AI never speaks for me. Every public action says it came from an agent. - Agents may act autonomously only in selected repositories I own (`harlan-zw/*`) or am the only core contributor. - Other open-source projects including [Nuxt](https://github.com/nuxt/nuxt) will never receive an agent generated PR. - [AI-written pull request descriptions](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/pr/SKILL.md) stay concise, link to this page and include the disclosure below. - [Automated reviews](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/adversarial-review/SKILL.md) use one labelled bot comment. It names the reviewed commit and any uncertainty. - Agents never approve or merge pull requests. - Fixes use normal commits. Agents never hide or rewrite history. - Anything ambiguous comes back to me. ### Sites I own - The [PR owner agent](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/take-ownership/SKILL.md) watches the deployment and runs relevant smoke tests against production. - Agents may [repair post-merge CI](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/take-ownership/SKILL.md) on the default branch. ## Workflow: Issue Triaging On selected projects, Harlan Agent Kit [checks new issues](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/issue-triage/SKILL.md) before touching code. It tries to reproduce the problem, checks for duplicates and decides whether there is enough information to act. If the issue looks valid, the agent starts in an isolated worktree. It writes a regression test, fixes the problem and [opens a pull request](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/pr/SKILL.md). Anything ambiguous comes back to me. ## Workflow: Opening Pull Requests On projects I maintain myself, I use AI to help write code and pull request descriptions. The descriptions are [tuned for my preferences](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/pr/SKILL.md#step-3-build-pr-content): explain why the change exists, capture useful context and cut the noise. When Harlan Agent Kit drafts or edits a description, the pull request links back to this page and includes this: > 🤖 AI disclosure: [Harlan Agent Kit](https://github.com/harlan-zw/harlan-agent-kit) modified this description. [My AI open-source policy](https://harlanzw.com/blog/ai-in-open-source). The description still needs to help someone review the change. AI involvement is no excuse for a wall of text or claims that nobody checked. ## Workflow: Adversarial Review When I or someone else opens a pull request on a selected project, my agent runs an [adversarial review](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/adversarial-review/SKILL.md). Codex and Claude Code try to disprove the change. They read the full diff, look for missing tests and follow failures instead of stopping at the first green check. If the agent finds a real problem and has permission to fix it, it pushes a normal commit. The history stays visible. The final status is posted as one bot comment. It names the exact commit reviewed, says what remains uncertain and starts with: > 🤖 Bot review: [Harlan Agent Kit](https://github.com/harlan-zw/harlan-agent-kit) posted this comment. It is not Harlan's personal review or approval. [My AI open-source policy](https://harlanzw.com/blog/ai-in-open-source). Later runs update the same comment instead of adding more noise. The bot never approves or merges a pull request. That decision stays with a human. ## Misbehaving AI AI does not like following instructions, so I know it will get this wrong eventually. AI can write a convincing explanation for broken code. A passing test can miss the thing that matters. A confident review can still be wrong. I still own the systems I choose to run and the changes they make to my projects. If a bot gets something wrong, email me or open an issue. I'll work out what failed and make the process better. --- - **Page:** August 2023: Türkiye, Bulgaria, North Macedonia and Albania - **Source:** https://harlanzw.com/blog/2023-august - **Description:** What I got up to in August in my open-source work and my travels. In August me and my girlfriend continued travelling, getting to 4 new countries: Türkiye, Bulgaria, North Macedonia and Albania. A typically morning was waking up and trying to piece together the details of where we even where, we were moving fast after Greece. We did a quick detour in Türkiye, then the many cramped and sweaty bus getting around the Balkans. I was surprised to still manage to get 109 hours to work on open-source, but I was starting to feel the burnout of managing work and travel. ## Open-source ::div{.grid.sm:grid-cols-2.gap-3.sm:gap-8.sm:my-15.my-5} :::score-card{label="⭐ GitHub stars" :before="6295" :after="6543"} ::: :::score-card{label="⌛ Hours Worked" :before="204" :after="109"} ::: :::score-card ```yaml [props] label: 💸 MRR AUD currency: true :before: '1212' :after: '1240' ``` ::: :::score-card ```yaml [props] label: 💸 Per Hour AUD currency: true :before: '6.23' :after: '11.76' ``` ::: :: The highlight of the month was getting out [Unhead v1.3](https://github.com/unjs/unhead/releases/tag/v1.3.0) which included some significant performance improvements. I continued work on Nuxt Scripts, trying to figure out the scope of the project and how it would work. The rest of my time went to maintenance of [Nuxt SEO](https://nuxtseo.com). ## 🇹🇷 Türkiye Our first day in İstanbul was massive, exploring all the stunning Mosque's. You can easily hit up 5 mosques and only walked a few kms. ::expand :::div{.grid.grid-cols-2.gap-5.my-15} ::::prose-img{src="/august-mosque-1" alt="Fatih Mosque, İstanbul" no-margin} :::: ::::prose-img{src="/august-mosque-2" alt="The Blue Mosque, İstanbul" no-margin} :::: ::: :: We stayed in a locals neighbourhood and hit up the family-run restaurants. I didn't know at that point, but I'd be eating some form of "kebab" and drinking Turkish coffee every day for the next month. Safe to say, I also took advantage of the Turkish sweets. ::expand :::div{.grid.grid-cols-2.gap-5.my-15} ::::prose-img{src="/august-kebab" alt="Kebab, Lahmacun, Pide" no-margin} :::: ::::prose-img{src="/august-sweets" alt="İstanbul sweet shop" no-margin} :::: ::: :: After being thoroughly mosque'd out, we headed to the famous "balloon" town Göreme within the Cappadocia region. Göreme is stunningly scenic little town that is an Instagram tourist haven and in turn is a bit of a tourist trap. But we still enjoyed it! ::expand :::div{.grid.grid-cols-2.gap-5.my-15} ::::prose-img{src="/august-rock-formation" alt="Well-endowed rock formations, Gorkundere Valley" no-margin} :::: ::::prose-img{src="/august-goreme" alt="Sunset at Göreme" no-margin} :::: ::: :: We did a full-day hike through Rose valley, coming across impressively well-endowed rock formations, historical abandoned cave churches and a friendly dog who guided us. This was one of my favourite hikes, we had the trail almost entirely to ourselves, it was scenic and exploring the caves with our new dog friend was fun. ::expand :::div{.grid.grid-cols-2.gap-5.my-15} ::::prose-img{src="/august-hike.png" alt="Start of the Rose Valley hike" no-margin} :::: ::::prose-img{src="/august-cave-outside.png" alt="One of the many abandoned cave dwellings" no-margin} :::: ::::prose-img{src="/august-hike-rope.png" alt="Rope climb featuring a friendly dog" no-margin} :::: ::::prose-img{src="/august-cave-church-2.png" alt="Walking around inside an abandoned Cave Church" no-margin} :::: ::: :: The balloons were expensive, so I decided to skip. But I did hear good things and the ground view itself was pretty impressive. ::prose-img{src="/august-balloons" alt="Göreme Sunrise Balloons" no-margin} :: ## 🇧🇬 Bulgaria Bulgaria was the start of the Balkans for us, which meant I was in for a lot of cheese pies and more kebabs. We did a quick couple of days in Plovid, which had a nice old town and some interesting Roman ruins. Then we were off to the capital city Sofia. It had some interesting architecture and a nice park. ::expand :::div{.grid.grid-cols-2.gap-5.my-15} ::::prose-img{src="/august-sofia-2" alt="Government of Bulgaria, Sofia" no-margin} :::: ::::prose-img{src="/august-sofia-3.png" label="St. Alexander Nevsky Cathedral, Sofia" no-margin} :::: ::: :: From Sofia, we went to do Seven Rila Lakes hike, which was one of our highlights. The hike is a chair lift, followed by a 3-hour ascent to the peek. Along the way you'll see 7 lakes, each at a different altitude. ::expand :::div{.grid.grid-cols-2.gap-5.my-15} ::::prose-img{src="/august-rila-lake-start.png" alt="Start of the Seven Rila Lake hike" no-margin} :::: ::::prose-img{src="/august-rila-lake-2.png" label="About half way up" no-margin} :::: ::::prose-img ```yaml [props] src: /august-rila-lake-3.png label: One of the 7 lakes alt: One of the lakes of the Sven Rila Lake Hike no-margin: true ``` :::: ::: :: It was a fun hike, everyone had brought their dogs along and had picnics at the lakes. You get to the peak, and it's a stunning view - if the clouds aren't blocking it. ::expand :::div{.grid.grid-cols-2.gap-5.my-15} ::::prose-img ```yaml [props] src: /august-rila-lake.png label: Some nice waterfalls near the top alt: Seven Rila Lake Hike Waterfall no-margin: true ``` :::: ::::prose-img ```yaml [props] src: /august-rila-lake-fog.png label: A bit too foggy at the peak alt: Seven Rila Lake Hike Top Very Foggy no-margin: true ``` :::: ::: :: ## 🇲🇰 North Macedonia We spent a couple of days in Skopje, a city characterised by an excess of modern statues and monuments. Many of them artificially made to look historical. ::prose-img{src="/august-skopje" alt="Archaeological Museum, Skopje"} :: A nice day trip from Skopje is Matka Canyon, you can do some nice hiking, lake swims and kayaking. ::prose-img{src="/august-canyon-hike" alt="Matka Canyon Hike"} :: The rest of our time in North Macedonia was spent in Ohrid, a beautiful lake town. It seems to be the holiday destination of choice for anyone in the Balkans who doesn't have easy access to the coast. Every night we were there, there was a festival on the lake, with fireworks, music and rides. ::prose-img{src="/august-ohrid" label="Quintessential shot of Lake Ohrid"} :: ## 🇦🇱 Albania We didn't know much about Albania beforehand, we had heard good things from other travellers though and we were excited. They told us about the friendly locals, and it was beautiful nature. Our first stop was Gjirokastër, Albania. Gjirokastër is a UNESCO world heritage site, with a classic old town and castle. We got a good workout climbing up to the castle, which had some nice views. ::prose-img{src="/august-girokaster.png" label="Sunset Gjirokastër, Albania"} :: We also did a day trip to the Blue Eye, which is a natural spring that is a stunning blue colour. You can jump from a few metres up into the water, which was fun. ::prose-img{src="/august-blue-eye.png" label="Blue Eye Lake, Albania"} :: From there, we hit the coastal town of Himarë. Himarë is one of the remaining chill beach towns in the Balkans, that hasn't been completely overrun with tourism development. ::prose-img{src="/august-himare.png" label="Himarë sunset"} :: Our last stop for Albania was the Northern town of Shkodër. From Shkodër you can go and stay in the Albanian Alp town of Theth. ::expand :::div{.grid.grid-cols-2.gap-5.my-15} ::::prose-img ```yaml [props] src: /august-theth.png label: The view from where we were staying alt: Theth Mountains no-margin: true ``` :::: ::::prose-img ```yaml [props] src: /august-theth-2.png label: Another view from where we were staying alt: Theth Mountains no-margin: true ``` :::: ::::prose-img ```yaml [props] src: /august-theth-3.png label: Mountainside from a hike alt: Theth Mountains no-margin: true ``` :::: ::::prose-img ```yaml [props] src: /august-theth-4.png label: One more - I really liked them! alt: Theth Mountains no-margin: true ``` :::: ::: :: We spent 4 days in Theth, doing the Volbona pass hike and chasing waterfalls. It was really cosy and relaxing to be surrounded by the mountains, it was a good recovery from the fast pace of the Balkans. ::expand :::div{.grid.grid-cols-2.gap-5.my-15} ::::prose-img ```yaml [props] src: /august-theth-mountain-cow.png label: A mountain cow towards the peak! alt: Cow in Theth Mountains no-margin: true ``` :::: ::::prose-img{src="/august-theth-mountain-2.png" label="View from the peak, it was really foggy." no-margin} :::: ::: :: ## September Plan - 🚌 Montenegro, Bosnia & Herzegovina, Croatia, Slovenia, Hungary, Czechia --- - **Page:** July 2023: Nuxt Release Week, Turning 29 and Exploring Greece - **Source:** https://harlanzw.com/blog/2023-july - **Description:** What I got up to in July in my open-source work and my travels. ## Open-source Metrics ::div{.grid.sm:grid-cols-2.gap-3.sm:gap-8.sm:my-15.my-5} :::score-card{label="⭐ GitHub stars" :before="6112" :after="6295"} ::: :::score-card{label="⌛ Hours Worked" :before="87" :after="195"} ::: :::score-card ```yaml [props] label: 💸 MRR AUD currency: true :before: '867' :after: '966' ``` ::: :::score-card ```yaml [props] label: 💸 Per Hour AUD currency: true :before: '10.31' :after: '4.9' ``` ::: :: ## What I Got Up To ### Nuxt Release Week While working and travelling it's difficult to build momentum. I have a constant pulse of things requiring attention: GitHub issues, [Discord](https://discord.com/invite/5jDAMswWwX) support questions, client issues etc. I like helping people, it's rewarding in of-itself. But going for long periods without releasing new projects is demotivating. That's why I was excited for the start of July. I had an entire week free in Sydney to focus on shipping. With this excitement, I naively announced that I'd get something out every day on Twitter. ::tweet{#01675466770029879298} :: The v2 release of Nuxt SEO Kit has been something bouncing around in my mind for many months, so I decided to focus on that. It started off really fun with overwhelmingly positive feedback. I even received my first Pizza sponsorship. An extra special shout out to Omar McPizza ::tweet{#01676178825603076096} :: Each night I was staying up progressively later and later. By the Friday release at 7am, I was done. I had burnt the candle at both ends and was ready to enjoy the weekend. ::tweet{#01677574499078930432} :: I wasn't able to get Nuxt SEO Kit v2 out, but I was happy with what I had achieved. A month of work finished in one week. Even though it was a grind, it was really fun, and I'm grateful to the community for their support. ### Turning 29 On the 10th July, my girlfriend and I flew to Greece, starting the Europe leg of our travels. This also happened to be one of my more unique birthdays. I spent the entire day in airplanes and airports, ending with a premium sleep on the floor of Singapore airport. Being 28 was a good time with many memories and accomplishments while still trying to find my feet in open-source. I'm especially proud of joining team Nuxt, and I'm excited for the year ahead and have plenty of exciting projects to release ### Exploring Greece We got to Athens just as they were in a heat wave, going from the Australian winter to 40 degree heat. Our plan was to get to Istanbul, exploring what we could on the way, with an obligatory Greek island stop. We had a lot of fun in Athens, especially at the archaeological sites and museums. This was also our introduction to authentic Greek food, which has become our new favourite cuisine. ::prose-img{src="/july-greek-food" alt="Moussaka, greek salad and stuff peppers." no-margin} :: After Athens, we travelled to the beautiful Greek island Naxos. We really liked exploring Halki village, the Old Town and beaches. ::expand :::div{.2xl:grid.hidden.grid-cols-3.gap-5.my-15} ::::prose-img{src="/july-naxos-2.png" alt="Temple of Apollo, Naxos" no-margin} :::: ::::prose-img{src="/july-naxos-4.png" alt="Old Byzantine Church surrounded by olive trees" no-margin} :::: ::::prose-img{src="/july-naxos-harlan" alt="Tiny passageways in the Old Town of Naxos" no-margin} :::: ::: :: ::div{.2xl:hidden} :::prose-img{src="/july-naxos-2.png" alt="Temple of Apollo, Naxos"} ::: :::prose-img{src="/july-naxos-4.png" alt="Old Byzantine Church surrounded by olive trees"} ::: :::prose-img{src="/july-naxos-harlan" alt="Tiny passageways in the Old Town of Naxos"} ::: :: After Naxos, we headed inland to Meteora, which is known for its stunning rocky outcrops and monasteries. It was quite impressive. ::expand :::div{.md:grid.hidden.grid-cols-1.gap-8.my-15} ::::prose-img{src="/july-meteroa-2.png" alt="Meteora monastery" no-margin} :::: ::: :: ::div{.2xl:hidden} :::prose-img{src="/july-meteroa-2.png" alt="Meteora monastery"} ::: :: Struggling with the heat, we decided to chill in Thessanoliki for a few days. The food, bars and museums were top tier. Our final stop in Greece was the charming town of Xanthi, where we spent time wandering the picturesque cobblestone streets. ## Other Small Wins - **VueFes Japan**: I'll be speaking at [VueFes Japan](https://vuefes.jp/2023/) on the 28th October, would love to see you there! - **Nuxt Scripts**: Started working on [Nuxt Script](https://github.com/nuxt/nuxt/discussions/22016) in collaboration with the Google Aurora team - **Nuxt Link Checker**: Released [v2](https://github.com/harlan-zw/nuxt-link-checker/releases/tag/v2.0.0) with Live Inspections. ## August Plan - Release Nuxt SEO Kit v2 (hopefully) - Nuxt Scripts private beta - 🚌 Türkiye, Bulgaria, North Macedonia, Albania, Montenegro --- - **Page:** April 2023: Thai New Year and Nuxt SEO Progress - **Source:** https://harlanzw.com/blog/2023-april - **Description:** April was spent traveling within Thailand while working through a bunch of bugs in my Nuxt SEO modules. ## Personal Updates ### A Month In Thailand We spent all of April in Thailand, 38 days total. We got lucky to sneak into Thailand just before they removed the 45-day visa on arrival. As you'll know from the [last update](/blog/2023-march), we started the month in Ko Chang. For those interested in our route afterward: - Chanthaburi - A small and quiet town with charm, known for its gemstones. - Pattaya - Caught up with some friends here to celebrate Thai New Year. - Krabi - Amazing rock formations. Would highly recommend mangrove canoeing. - Ko Phi Phi - Tiny scenic island but very touristy. Think bucket cocktails for $4. - Khao Sok - Jungle, caves, lakes, monkeys and waterfalls. One of my favourite places in Thailand. - Chiang Mai - Super-chill and relaxed city. Great food, great people, great vibes. - Pai - Quite touristy with a hippy vibe. The surrounding nature is amazing. ::expand :::div{.md:grid.grid-cols-2.gap-8.my-15} ::::prose-img{src="/april-mangrove.png" alt="Saw a monkey in the mangroves while kayaking" no-margin} :::: ::::prose-img{src="/april-koa-sok.png" alt="The jungles of Khao Sok" no-margin} :::: ::::prose-img{src="/april-phi-phi.png" alt="Ko Phi Phi" no-margin} :::: ::::prose-img{src="/april-pi.png" alt="In the Pai hole" no-margin} :::: ::: :: ### Thai New Year 🇹🇭 We were lucky enough to be in Thailand for Songkran. Songkran is a festival to mark the start of the Buddhist New Year. Water is thrown to symbolize the washing away of sins and bad luck. We spent the holiday hanging out with our friends and the Thai locals, throwing icy-cold water at passers-by on the street. ## Work Updates ### Nuxt SEO Kit v2 Module Progress Getting v2 of Nuxt SEO Kit has been weighing on me for a while. Making progress towards it meant solving some particularly annoying bugs. Most of my time was spent on [nuxt-og-image](https://github.com/harlan-zw/nuxt-og-image). It has been a challenging module. Debugging WASM code, sifting through nitro code and testing on a number of cloud providers. I went through 30 beta releases and things are looking better. The rest went into [nuxt-simple-sitemap](https://github.com/harlan-zw/nuxt-simple-sitemap). In March, I decided to add support for i18n. I18n is complicated. While there is still some more work to do it's a lot more stable and the improvements will be incremental. ## Numbers / Financials - ⭐ 3779 GitHub stars (+199) ::prose-img{src="/april-numbers.png" alt="My WakaTime numbers for April"} :: ::prose-img{src="/april-fathom.png" alt="My April Fathom Traffic"} :: ### Open-Source - ⌛ 70.75 hrs (-17.25 hrs) - 💸 $1,013 AUD (+$226) - Had a payout from the Windi CSS project as it was sunset. - $14.32 AUD / hour (+$5.38 / hour) - Highest yet! Although this was mostly a one-off. **Main projects** - **34 hours [nuxt-og-image](https://github.com/harlan-zw/nuxt-og-image)** - **16.5 hours [nuxt-simple-sitemap](https://github.com/harlan-zw/nuxt-simple-sitemap)** - **6 hours [Nuxt](https://github.com/nuxt/nuxt)** ### Freelancing - ⌛ 2.25 hrs (-4.75 hrs) - 💸 $225 AUD (-$420) - $100 AUD / hour (-$7 / hour) April's focus was on open-source work. I had two organic leads come in. I decided to push the meetings to May where I had more availability while traveling. ## May plans In April, I wanted to get Nuxt SEO Kit v2 out. This was optimistic and unrealistic, unfortunately. May's plan is to continue the work towards it. The estimate for a Nuxt SEO Kit v2 stable release is still a few months away. ## Final thoughts It was a really fun month in Thailand, I had a good balance of work and traveling. It's never satisfying just working on bugs and issues, there's not much to show for it. But this is the way open-source works it seems. Periods of rapid development and then periods of slow but steady maintenance to get things stable. Thanks for reading and thanks to my amazing sponsors. --- - **Page:** Introduction: My open-source journey - **Source:** https://harlanzw.com/blog/my-open-source-journey - **Description:** Discover how you can build a modern package development through how I created the Unlighthouse project. ## My Open Source Journey At the end of 2020, I finished up my full-time job at a [startup](https://kintell.com/){title="Visit kintell.com"}. I wanted to explore other ways of working, with the idea of building a SaaS product. Though, without an idea, I was passionate about and no audience. It felt like a waste of time. Instead, I decided to focus on learning as much as I could within the Vue ecosystem. I started building my [old blog](https://old.harlanzw.com){title="Visit my old blog site"} in the earliest version of VitePress and did an article comparing [Vite to webpack](/blog/how-the-heck-does-vite-work){title="Visit the article I wrote about Vite"}. In setting up Twitter and sharing that article, I was hooked. I started writing more, contributing issues and building my own [Vue plugin](/blog/vue-automatic-component-imports){title="See the article I wrote on Vue Automatic Component Imports"}. Through these I grew my audience and started focusing on [Nuxt modules](/projects) and joined the [WindiCSS](https://windicss.org) team. Since then, I've been working on all sorts of projects. While progress has been slow while I've been learning, I'm proud of what I've accomplished, and I'm excited to share more with the community. --- - **Page:** May 2023: South Korea Mountains and Unlighthouse Goes Viral - **Source:** https://harlanzw.com/blog/2023-may - **Description:** May was spent traveling to South Korea and Indonesia while working on Unlighthosue going viral. ## Personal Updates ### South Korea Travel In May, we finished up our time in Thailand and traveled to South Korea for 2 weeks. - Seoul - Amazing city with so much to do. The culture is very unique and the mountains surrounding the city are beautiful. - Busan - Coastal city, kind of reminded me of the Gold Coast in Australia. - Jeju - Island off the south coast of South Korea. Beautiful beaches and hiking. We really loved the food, especially the Korean BBQ and bipimbap. They had surprisingly good coffee, craft beer and pastries, which I partook in generously. Hiking Bukhansan Mountain in Seoul and Hallasan Mountain in Jeju were definitely the highlights. I would love to return one day and explore more of the country. ::expand :::div{.md:grid.hidden.grid-cols-2.gap-8.my-15} ::::prose-img{src="/may-jeju-coffee.png" alt="Open-source and coffee on Jeju" no-margin} :::: ::::prose-img{src="/may-black-pork.png" alt="Black pork and mungbean on Jeju" no-margin} :::: ::::prose-img{src="/may-bukhansan.png" alt="Top of Buhkansan Mountain" no-margin} :::: ::::prose-img{src="/may-seoul-exploring.png" alt="Exploring Seoul At Night" no-margin} :::: ::: :: ::div{.md:hidden.block} :::prose-img{src="/may-jeju-coffee.png" alt="Open-source and coffee on Jeju"} ::: :::prose-img{src="/may-black-pork.png" alt="Black pork and mungbean on Jeju"} ::: :::prose-img{src="/may-bukhansan.png" alt="Top of Buhkansan Mountain"} ::: :::prose-img{src="/may-seoul-exploring.png" alt="Exploring Seoul At Night"} ::: :: ### Indonesia Travel After South Korea, we flew to Jakarta, Indonesia with a quick stop over in Singapore. We'll spend the next month here, until mid-June when we head back to Australia. We're taking Indonesia pretty slow. It's nice to have a bit of a break after the busy itinerary in South Korea and Thailand. - Yogyakarta - University town, lots of culture, art and history. Hostels here have a really nice, social vibe. - Bali - Ubud and Canggu. Canggu is very touristy, but the food is worth it. Melbourne Cafe tier. Ubud is quite touristy too, but you can see some amazing sights around like rice fields, temples and waterfalls. - Gili Islands - Good snorkeling and chilling. (currently here) - Flores - Planning lots of hikes for here. We're looking forward to Komodo National Park and Kelimutu volcano. ::expand :::div{.md:grid.hidden.grid-cols-2.gap-8.my-15} ::::prose-img{src="/may-indo-temple" alt="Borobudur temple in Yogyakarta" no-margin} :::: ::::prose-img{src="/may-sate.png" alt="Top tier sate" no-margin} :::: ::::prose-img{src="/may-jungle.png" alt="Jungles around Sideman" no-margin} :::: ::::prose-img{src="/may-cave.png" alt="Goa Gajah temple" no-margin} :::: ::: :: ::div{.md:hidden.block} :::prose-img{src="/may-indo-temple" alt="Borobudur temple in Yogyakarta"} ::: :::prose-img{src="/may-sate.png" alt="Top tier sate"} ::: :::prose-img{src="/may-jungle.png" alt="Jungles around Sideman"} ::: :::prose-img{src="/may-cave.png" alt="Goa Gajah temple"} ::: :: ## Work Updates ### Unlighthouse Goes Viral Last month I had a DM from someone telling me that they had put Unlighthouse front of Jeff from Fireship. I thought that was pretty cool in of itself, and I didn't think much more about it. Next thing I knew, though, the Unlighthouse stars started going bananas. ::prose-img{src="/may-unlighthouse-stars.png" alt="Unlighthouse stars going bananas"} :: I did some quick research and found the culprit. Very cool. And with that, I had a massive influx of issues to deal with through GitHub and my Discord. ::prose-img{src="/may-unlighthouse-issues.png" alt="Unlighthouse issues also going bananas"} :: I started working through them, I would solve one, and two more would appear. It took me the rest of my May to get control of them. It wasn't a bad problem to have though. I'm grateful to have Unlighthouse being used by so many people, and I'm glad it's helping people improve their sites. It's given me a lot of ideas for how to improve it, and I'm excited to work on it more in the future. ## Numbers / Financials - ⭐ 5407 GitHub stars (+1628) ::prose-img{src="/may-wakatime.png" alt="My WakaTime numbers for May 2023"} :: ::prose-img{src="/may-fathom.png" alt="My May 2023 Fathom Traffic"} :: ### Open-Source - ⌛ 84 hrs (+13.25 hrs) - 💸 $744 AUD (-$269) - Payout from Windi CSS the month before. - $8.85 AUD / hour (-$5.46 / hour) **Main projects** - **27 hours [Unlighthouse](https://github.com/harlan-zw/unlighthouse)** Some important improvements around reporting, authentication, how the chrome binary is used and Docker. - **23 hours [nuxt-seo-kit](https://github.com/nuxt/nuxt)** Extracting the breadcrumbs and site config logic into separate modules in preparation of v2. These will be released soon. - **8 hours [nuxt-og-image](https://github.com/harlan-zw/nuxt-og-image)** Getting the final issues solved so the v2 release can happen. - **7.5 hours [nuxt-simple-sitemap](https://github.com/harlan-zw/nuxt-simple-sitemap)** Some outstanding issues around runtime sitemaps and i18n. ### Freelancing - ⌛ 8.25 hrs (+0.75 hrs) - 💸 $1,025 AUD (+$800) - $124.24 AUD / hour (+24.24 / hour) I started some SEO work for a new client. The work was to fix site-wide technical issues from Google Lighthouse. This is exactly why I originally built Unlighthouse, so it was perfect. ## June plans I'll be landing back in Australia toward the middle of June. I look forward to having a few weeks of routine. I'll use this time to focus on getting Nuxt SEO Kit v2 released. ## Final thoughts I'm really grateful to see the growth in Unlighthouse this month, and I'm excited to finally release what I've been working on the for the last few months. Thanks as always to my amazing sponsors. See you next month. . --- - **Page:** June 2023: Flores, Australia and preparing for major releases - **Source:** https://harlanzw.com/blog/2023-june - **Description:** Traveling to Flores, Indonesia and coming back home to Australia while preparing for major releases. ## Personal Updates ### Flores We spent the last week of our South East Asia trip in Flores, an island in Indonesia, east of Bali. Flores is best known for its Komodo dragons and the glowing lakes of Mt Kelimutu volcano. ::prose-img{src="/june-flores-volcano.png" alt="Volcano coming in hot." no-margin} :: We did a road trip from East (Maumere) to West (Labuan Bajo), weaving along "spaghetti roads" through the mountains and dense jungle. We passed tiny villages as we went that offered a unique glimpse into the life of the Floresian and the Manggarai people. Nearly everyone lived off the land, with rice being grown as the main source of food. Occasionally we'd see someone roasting their coffee beans which I also had a go at. ::prose-img{src="/june-cooling-beans.png" alt="Drying coffee beans post roast." no-margin} :: When inspecting the jungle, we'd always see something growing: mangoes, bananas, passion-fruit, cocoa, vanilla pods, taro, etc. Our highlights were: - Kelimutu volcano, which has 3 crater lakes at the top. These lakes change colour over time, and we were lucky enough to see them in their blue, green and black state. - Rinca Island, seeing the Komodo dragons in their natural habitat. - Bena Traditional Village, walking amongst amazing thatched roof houses. ::expand :::div{.md:grid.hidden.grid-cols-2.gap-8.my-15} ::::prose-img{src="/june-flores-island.png" alt="The view of Padar Island." no-margin} :::: ::::prose-img{src="/june-flores-jungle.png" alt="Random spot we passed on the road trip." no-margin} :::: ::::prose-img{src="/june-kelimutu-2.png" alt="Kelimutu volcano at sunrise." no-margin} :::: ::::prose-img{src="/june-flores-komoto.png" alt="A Komoto dragon cooling off in the shade." no-margin} :::: ::: :: ::div{.md:hidden.block} :::prose-img{src="/june-flores-island.png" alt="The view of Padar Island." no-margin} ::: :::prose-img{src="/june-flores-jungle.png" alt="Random spot we passed on the road trip." no-margin} ::: :::prose-img{src="/june-kelimutu-2.png" alt="Kelimutu volcano at sunrise." no-margin} ::: :::prose-img{src="/june-flores-komoto.png" alt="A Komoto dragon cooling off in the shade." no-margin} ::: :: ### Australia: A wedding and camping After Flores, we flew back to Australia for a wedding. It's winter time here, so it was a bit of a shock to the system after 3 months of high heat and humidity. After the wedding, we took a trip to the Warrumbungles with some friends. ::expand :::div{.md:grid.hidden.grid-cols-5.gap-8.my-15} ::::prose-img ```yaml [props] src: /june-warrumbungles.png alt: View of the Warrumbungles. no-margin: true figure-class: col-span-3 span-3 ``` :::: ::::prose-img ```yaml [props] src: /june-knife.png alt: Breadknife and high tops hike. figure-class: col-span-2 span-2 no-margin: true ``` :::: ::: :: ::div{.md:hidden.block} :::prose-img ```yaml [props] src: /june-warrumbungles.png alt: View of the Warrumbungles. no-margin: true figure-class: col-span-3 span-3 ``` ::: :::prose-img ```yaml [props] src: /june-knife.png alt: Breadknife and high tops hike. figure-class: col-span-2 span-2 no-margin: true ``` ::: :: The Warrumbungles is a national park north-east of Sydney which is best known for its amazing night sky views. We camped there for a few days, doing some hikes and exploring the area. Afterwards we spent time in the Blue Mountains and Sydney catching up with family and friends, and planning the Europe leg of our trip. ::expand :::div{.md:grid.hidden.grid-cols-2.gap-8.my-15} ::::prose-img ```yaml [props] src: /june-blue-mountains.png alt: Blue Mountains lookout swimming in fog. no-margin: true figure-class: col-span-2 span-2 ``` :::: ::::prose-img{src="/june-blue-mountains-hike.png" alt="Grand Canyon walk in Blackheath, Blue Mountains." no-margin} :::: ::::prose-img{src="/june-blue-mountains-hike-2.png" alt="More grand views." no-margin} :::: ::: :: ::div{.md:hidden.block} :::prose-img ```yaml [props] src: /june-blue-mountains.png alt: Blue Mountains lookout swimming in fog. no-margin: true figure-class: col-span-2 span-2 ``` ::: :::prose-img{src="/june-blue-mountains-hike.png" alt="Grand Canyon walk in Blackheath, Blue Mountains." no-margin} ::: :::prose-img{src="/june-blue-mountains-hike-2.png" alt="More grand views." no-margin} ::: :: ## Work Updates June was an important month of work, getting things ready for the next major releases of my Nuxt modules. The main blocker was Nuxt OG Image which had continued to plague me with issues. With some luck and *many* hours of work, I was able to get it to a state where it's mostly working correctly on all major runtimes and was able to unblock myself. This was a huge relief, and I'm looking forward to pushing out all Nuxt SEO modules in the coming months. ## Numbers / Financials - ⭐ 6112 GitHub stars (+705) ::prose-img{src="/june-wakatime.png" alt="My WakaTime numbers for June 2023"} :: ::prose-img{src="/june-fathom.png" alt="My June 2023 Fathom Traffic"} :: ### Open-Source - ⌛ 87 hrs (+3 hrs) - 💸 $867 AUD (+$123) - $10.36 AUD / hour (+$1.51 / hour) **Main projects** - **Nuxt Modules Audit** (13.5 hours) Some preliminary work to audit the state of Nuxt modules and authors. Still need some time to finish this off. - **Nuxt SEO Modules** (45 hours) Fixing outstanding issues to unblock work on the new majors deveolopment. Mostly involved Nuxt OG Image, Nuxt Simple Sitemap and Nuxt Simple Robots. Work also going into the initial versions of the new Nuxt Site Config module. - **6 hours [Unlighthouse](https://github.com/harlan-zw/unlighthouse)** Adding new CSV reporting feature and fixing some minor bugs. ### Freelancing - ⌛ 10.25 hrs (+1.5 hrs) - 💸 $1,025 AUD - $100.00 AUD / hour (-24.24 / hour) Small bit of adhoc bug fixing work for one of my clients. Fun with AWS SNS notifications... ## Final thoughts It's really nice ce to be back in Australia and to have some more time to dedicate to work. There are exciting things planned for July, some have already been released, and there's much more to come. Thanks as always to my amazing sponsors. See you next month! --- - **Page:** February 2023: GitHub Sponsor perks and firing a freelance client - **Source:** https://harlanzw.com/blog/2023-february - **Description:** I share my updates for February covering my open-source development, financials and personal updates. Hello, my amazing sponsors! Welcome to first ever newsletter. I have some exciting things to share with you. ## GitHub Sponsors Perk Updates Going forward, I'll be improving your sponsor perks in three ways. **Early Access Monthly Newsletter** At the start of each month, I'll be sending a newsletter (just like this one). This will cover my achievements, financials, and plans for the month ahead. I'll post these publicly towards the end of the month on my blog. **New Discord Role / Channels** I have a Discord server that I use to provide support to many of my open-source packages. If you have any technical issues you'd like help on, feel free to ask here. I have the following new perks within discord: - New role: "sponsor" - New discussion channel: 💎・priority-support. Link to join: **Bi-monthly Video Call** Every 2 months, I'm going to schedule a time to do a video call with any sponsors who would like to join. In it, you can ask me any questions or just hang out, no pressure smile - 30-60 minutes - Held in discord (link above) - First call is scheduled for 9pm AEDT, calendar invite link ## Month Review ### Achievements *My personal, open-source and freelancing achievements.* **OSS** - ⭐ 3431 GitHub stars (+231) - Unhead v1.1 release, many performance and security improvements - Nuxt OG Image Satori Support and OG Image Playground **Personal** - 🍴 I started the month with a 120-hour fast (5 days)! Went pretty well, and lost 4kg - ✈️ Gave notice to move out of our apartment at the end of March **Sponsorship Impressions (sites only)** The analytics of people who see your generous sponsorship on my doc / personal sites. 👨 5.7k visitors (-22) 👀 26.3k views (+4k) ### Financials How sustainable is my current open-source work My financial goal for February was saving $10k, as I have some travel plans coming up. I planned to spend 50% of my time on open-source. I had a \~100-hour project booked with one of my clients. Around 40 hours into the project it was clear that I couldn't work with them anymore on it. I decided to fire them and ended up refunding them 40 hours of work to be done with it. While this was quite a hit, the peace of mind of not having to work with them anymore on it was worth it. I'll avoid any bad-mouthing, I'll just say that they weren't pleasant to work with. **OSS** - ⌛ 111 hrs - 💸 A$617.06 - A$5.55 / hour **Freelancing** - ⌛ 78.25 hrs (+ 35.25 hrs) - 💸 A$3015 (- $517.5) - A$38.53 / hour (-$42.67) ### March plans By the end of this month, I'll be starting my travels for 3 months in Asia, going from Thailand, South Korea, and Indonesia. During this time, I'll continue to work on open-source, just while sitting on a beach drinking a coconut. Until then I have some goals: - Significantly improve the Unhead performance in Nuxt (feat: server/client only composables, feat(head): improved Unhead integration) - Docs site for Nuxt SEO Kit - Make Nuxt OG Image an official Nuxt module, have a few bugs to fix - Land a few more Nuxt PRs ### Summary I hope you've enjoyed the first newsletter! Again, please join me for the first sponsors call on the 7th if you're interested. Best wishes to all of you and I hope you have an amazing month ahead. --- - **Page:** March 2023: Planning a year of travel and moving out - **Source:** https://harlanzw.com/blog/2023-march - **Description:** I share my updates for March covering my open-source development, financials and personal updates. ## Personal Updates ### Moving out Since becoming a freelancer and focusing on open-source, my days are mostly relaxed. I can choose what I want to work on and actively avoid any hard deadlines. March was the complete opposite. Deadlines on moving out, on planning and preparing for a year of travel, finishing off freelancing commitments and staying on top of my open-source work. But it all got done. As of the end of March, my girlfriend (Alina) and I handed over the keys to our leafy-green apartment in Canberra, Australia. With no where in particular to now call home. ### Travel We started our travel last week, landing in Bangkok, Thailand. Bangkok is a hectic city, not recommended for relaxing. But I enjoyed the city, the food and the people. It has been interesting trying to work on open-source while travelling. Any down-time I have I whip out the laptop. Thailand's internet has been surprisingly good which as made that easier. As of writing this we're in Ko Chang, Thailand. Ko Chang is a dense mountainous jungle island with deep-blue water paired with complex orange gradient sunsets. We're staying in a bungalow near the beach for $30 AUD a night. ## Numbers - ⭐ 3580 GitHub stars (+149) ## Work Updates / Financials With the cost of living in Thailand much cheaper than Australia, I'm spending less money while travelling than I would be if I was still living in Canberra. ### Open-Source - ⌛ 88 hrs (-23 hrs) - 💸 $787 AUD (+$168) - $8.94 AUD / hour (+$3.39 / hour) Most of my open-source time last month went into Nuxt modules and Nuxt itself. My current focus is trying to get all the Nuxt SEO Kit modules to a v2 and release v2 of Nuxt SEO Kit. **Main projects** - **24 hours [Nuxt](https://github.com/nuxt/nuxt)** - working on better Unhead integration, a couple of minor bugs and triage - **22 hours [nuxt-simple-sitemap](https://github.com/harlan-zw/nuxt-simple-sitemap)** - v2 with many SSR improvements and bug fixes. Is now closer to being feature-complete. - **14 hours [Unhead](https://github.com/unjs/unhead)** - working towards a v2 which will significantly reduce the bundle size. - **12 hours [nuxt-og-image](https://github.com/harlan-zw/nuxt-og-image)** - v2 is out under a beta tag, with many bug fixes and improvements. Still want to add a couple of features for the v2 release. - **3 hours [nuxt-simple-robots](https://github.com/harlan-zw/nuxt-simple-robots)** - v2 is out and now feature-complete. ### Freelancing - ⌛ 6 hrs (-72.25 hrs) - 💸 $645 AUD (-$2370) - $107 AUD / hour (+$68 / hour) Didn't have too much freelancing to do. Finished off some important projects and now have a bit of a break. ## April plans Figuring out what I can actually finish while travelling is a challenge. I'm trying to keep my goals small and achievable. I would like to get Nuxt SEO Kit v2 out by the end of April. This will be quite a bit of work as each dependent module needs to be updated to v2, which I have many plans for. It also includes a proper documentation site. I would also like to get the Unhead v2 update out. The bundle size improvements will be a win for Nuxt and the Vue ecosystem. ## Final thoughts I'm really enjoying travelling and working while travelling. I hope to sustain it as long as I can and I can't thank you, the sponsors, enough for your support. While the amount I can get done will take a hit, I'm still dedicated to providing as much value as I can for your contributions. Please get in touch with my if you have any questions, feedback or suggestions. Would love to chat! --- - **Page:** Nuxt 3 "Hydration Mismatch" Errors - **Source:** https://harlanzw.com/blog/nuxt3-hydration-node-mismatch - **Description:** Learn about the causes and solutions for the Hydration Children Mismatch error in Nuxt 3, including the use of client-only components, the component, and the @nuxtjs/html-validator module. ## The "Hydration Mismatch" Error A hydration mismatch error is a disagreement between the server and the client on what the Vue rendered HTML should look like. When it occurs when you see one of the following errors in your browser console: ```bash [Vue Warn]: Hydration node mismatch... [Vue Warn]: Hydration children mismatch... ``` ### How Server-Side Rendering Works When a user requests your Nuxt 3 site, Nuxt has one important job: Responding with the full rendered HTML of the page and all client-side scripts needed to bring it to life. It does this by getting Vue to run its server-side rendering (SSR) code. This code is responsible for taking your Vue components and turning them into HTML. When Vue renders the HTML, it is rendering DOM nodes in a way that may not match how a browser itself would render them. For example, it's quite possible to generate invalid HTML with Vue whereas the browser would complain. ### How Client-Side Hydration Works When the user's browser receives the HTML, it loads the client-side scripts. These scripts are responsible for bringing initializing your Vue app, rendering all of your components with the goal of producing the same HTML that the server rendered. In doing so, it has the full state needed to create new interactivity. ### Consequences of a Hydration Mismatch When a hydration mismatch occurs, the client-side Vue app can have quite unpredictable results. Leading to interactivity not working, or even worse, the page not working at all. Most commonly, this error will lead to a poor user experience and SEO issues. ## Solving the Hydration Mismatch Error There are a number of ways to solve this error. The first step is to identify the cause of the mismatch. This can be quite tricky in Nuxt 3 as the error messages can sometimes be quite cryptic. ### Ensure Pages Have a Single Root Element As a quick check, if your error is pointing to a page component, it's possible that the error is from multiple root elements. While not technically required in Vue 3, it's required in Nuxt 3 to have a root element in your page components. **Bad - Has 2 root elements** ```vue ``` **Good - 1 root element** ```vue ``` ### Check the HTML Because the trigger for the error is the HTML not matching, this can be a good starting point to debugging. There are many ways to produce invalid HTML in Vue. It's not always intuitive to know these what is invalid though. The easiest way to check the HTML is to use the [@nuxtjs/html-validator](https://html-validator.nuxtjs.org/) module. This module will validate the HTML and throw an error if it is invalid. ### Use Client Only Components If you notice that the error is coming from a component that is only needed on the client-side, you can wrap it with `` component, alternatively. you can prefix your component name with `.client.vue`. For example `MyWidget.client.vue`. This is quite common components which use code coming from a third-party library. For example, a component which uses a Google Maps library. In these instances, the third-party library does not have support to server-side render. ```vue ``` ## Conclusion Hydration mismatch errors can be quite frustrating and tricky to debug. Having a better understanding of the root cause of the error and how to solve it can help you avoid these issues in the future. --- - **Page:** Learning Modern Package Development: Monorepos and Backends - Part 1 - **Source:** https://harlanzw.com/blog/modern-package-development - **Description:** Discover how you can build a modern package development through how I created the Unlighthouse project. ::tip This article is a work in progress. Feel free to read it, but some sections are incomplete. :: ## Introduction [Unlighthouse](https://github.com/harlan-zw/unlighthouse) is an open-source package I built to scan your entire site using Google Lighthouse. Building it was chaotic, with day-long bugs, constant refactoring and endless documentation reading. Through building it, I learnt modern development practices, making use of the vast ecosystem of packages and tools. ## Background: Why build Unlighthouse? As a freelancer I keep on top of my clients organic growth with Google Search Console. Was a day like any other, looking at one of my clients' dashboard. Seemingly out of nowhere, I saw the trend of page position, clicks and page views in free fall. My clients' income was based on organic traffic, not good. ![Trending down Google Search Console](/numbers-go-down){height="416" width="1552" max-height="242"} Isolating the reason for the falling page rank wasn't easy. The site had issues, but what was causing the free fall? There was no easy way to know. To diagnose the issue, I used Google Lighthouse. I went through all pages of the site, noticing quite a number of issues. I spent a couple of days fixing them all up and improving the general performance of the site. What happened next? Things started turning around. I was able to invert the graph. Organic growth doubled in the next few months. Happy client. ![Trending up Google Search Console](/numbers-go-up){height="399" width="1554" max-height="242"} Now that was out of the way, how could I make it easier to stay on top of the health of the sites I manage? ## Deciding on the stack I needed to build a tool that would run Google Lighthouse on an entire site with just the home page URL. I had a plan of attack for the build. The backend would be build using Typescript and Node. The frontend client would be built using Vue and Vite. But how would I be able to design this in a way that was easy to build and maintain? I had seen the amazing work coming out of the [UnJS](https://github.com/unjs) ecosystem and knew that they could solve some of my problems. With that, the package would be known as **Un** (inspired by Unjs) **Lighthouse**. Keeping a keen eye on other modern packages coming out, I took some of the best practices and tools I saw implemented. The stack was split into three core parts: - Monorepo: containing the dependencies to build, test and deploy the code - Frontend: displaying searchable, filtering and sortable results - Backend: generating the frontend, running the scans and providing an API for the frontend ## Monorepo Implementing a monorepo is a keystone for a large project which will ship multiple packages. It allows you to group up logic, dependencies and documentation into a single repository. ### PNPM [PNPM](https://pnpm.io/) is the new kid on the block of node package managers and has gained a large following quickly, for good reason. It is the most performant package manager and has first class support for monorepos. There are many benefits to using a monorepo for a package. My personal favourite is it allows me to easily isolate logic and dependencies for your package, letting you write simpler code. Allowing end users to pull any specific part of your package that they want to use. ![Unlighthouse monorepo](/pnpm-monorepo){height="507" width="942" max-height="400"} ### Vitest [Vitest](https://vitest.dev/) is also the new kid on the block of testing. It's original aim was to be a testing framework specifically for Vite, but it has ended up being a possible replacement for Jest entirely. Vitest makes writing your logic and tests a breeze and I'd recommend checking it out for any project. ### Unbuild ![unbuild](https://opengraph.githubassets.com/a12c58f21ffef9686653c51d0203ab07a995d7450d3d690a1c8bd51e975df35c/unjs/unbuild){height="350"} This package is described as a "A unified javascript build system". In reality, it's a minimal config way to build your package code to ESM and CJS. One of the amazing features of unbuild is stubbing. This allows you can run source code from your dist folder, meaning it transpiles just-in-time. This allows you to completely cut out the build step when you're iterating and testing integrations on your package. It's as simple as `unbuild --stub`. ```ts [build.config.ts] import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ entries: [ { input: 'src/index' }, { input: 'src/process', outDir: 'dist/process', builder: 'mkdist', declaration: false }, ], }) ``` [GitHub Repo Link](https://github.com/unjs/unbuild) ## Server ### Lighthouse Binary Unlighthouse wouldn't be possible if Google hadn't published Lighthouse as its own [NPM binary](https://github.com/GoogleChrome/lighthouse). To make Unlighthouse fast, I combined the binary with the package [puppeteer-cluster](https://github.com/thomasdondorf/puppeteer-cluster), which allows for multi-threaded lighthouse scans. ### Unctx ![unctx](https://opengraph.githubassets.com/16b7d5bc9f0ddbbbc59c5f8fd24c1f1f311d2ab6bb835be58fb0698631d4b624/unjs/unctx){height="350"} It's amazing that a simple pattern like composition has evaded Node packages for so long. With the introduction of Vue 3, composition became cool. And with that, unctx is composition for your own package. unctx allows you to define a scope where there's only a single instance of something that is globally accessible. This is incredibly useful for building packages, as you no longer need to be juggling core state. You can build your logic out as composables that interact with the core. ```ts import { createContext } from 'unctx' const engineContext = createContext() export const useUnlighthouse = engineContext.use as () => UnlighthouseContext export async function createUnlighthouse(userConfig: UserConfig, provider?: Provider) { // ... engineContext.set(ctx, true) } ``` - [unctx GitHub Repo](https://github.com/unjs/unctx) ### Hookable ![hookable](https://opengraph.githubassets.com/6fd14beca1b9a811c7b46e4e61ec4ef8870a3f1407e21d47724b38cb6128e0b3/unjs/hookable){height="350"} For Nuxt.js users, you might be familiar with the concept of frameworks hooks. A way for you to modify or do something with the internal logic of Nuxt. Building a package, I knew that this was a useful feature, not just for end-users, but for me as a way to organise logic. Having a core which is hookable means you can avoid baking logic in that may be better suited elsewhere. For example, I wanted to make sure that Unlighthouse didn't start for integrations until they visited the page. I simply set a hook for it to start only when they visit the client. ```ts hooks.hookOnce('visited-client', () => { ctx.start() }) ``` - [Hookable GitHub Repo](https://github.com/unjs/hookable) ### Unconfig ![Unconfig](https://opengraph.githubassets.com/f8bdfdebc7f32dd928adc557ea624fe7e4e4003400e90b3c41b414d818432b21/antfu/unconfig){height="350"} Unconfig is a universal solution for loading configurations. This let me allow the package to load in a configuration from `unlighthouse.config.ts` or a custom path, with barely any code. ```ts import { loadConfig } from 'unconfig' const configDefinition = await loadConfig({ cwd: userConfig.root, sources: [ { files: [ 'unlighthouse.config', // may provide the config file as an argument ...(userConfig.configFile ? [userConfig.configFile] : []), ], // default extensions extensions: ['ts', 'js'], }, ], }) if (configDefinition.sources?.[0]) { configFile = configDefinition.sources[0] userConfig = defu(configDefinition.config, userConfig) } ``` - [Unconfig GitHub Repo](https://github.com/antfu/unconfig) ### ufo ![UFO](https://repository-images.githubusercontent.com/318601574/674ca800-3a4a-11eb-901f-31ab7e452816){height="350"} Dealing with URLs in Node isn't nice. For Unlighthouse I needed to deal with many URLS, I needed to make sure they were standardised no matter how they were formed. This meant using the ufo package heavily. The slash trimming came in handy and the origin detection. ```ts export const trimSlashes = (s: string) => withoutLeadingSlash(withoutTrailingSlash(s)) ``` ```ts const site = new $URL(url).origin ``` - [ufo GitHub Repo](https://github.com/unjs/ufo) ### Unrouted ![unrouted](https://repository-images.githubusercontent.com/432034546/262d14fd-f00c-46b9-8b72-423a07dca06f){height="350"} I needed an API for the client to communicate with the Node server to fetch the status of the scan and submit re-scans. The current JS offerings were a bit lackluster. I wanted something that just worked and had a nice way to use it. I ended up building unrouted as a way to solve that. ```ts group('/api', () => { group('/reports', () => { post('/rescan', () => { // ... return true }) post('/:id/rescan', () => { const report = useReport() const { worker } = useUnlighthouse() if (report) worker.requeueReport(report) }) }) get('__launch', () => { const { file } = useQuery<{ file: string }>() if (!file) { setStatusCode(400) return false } const path = file.replace(resolvedConfig.root, '') const resolved = join(resolvedConfig.root, path) logger.info(`Launching file in editor: \`${path}\``) launch(resolved) }) get('ws', req => ws.serve(req)) get('reports', () => { const { worker } = useUnlighthouse() return worker.reports().filter(r => r.tasks.inspectHtmlTask === 'completed') }) get('scan-meta', () => createScanMeta()) }) ``` - [Unrouted GitHub Repo](https://github.com/harlan-zw/unrouted) ## Client The code that what went into building the package. ### Vue 3 / Vite client The beloved [Vite](https://github.com/vitejs/vite) was to be used to make the development of the client as easy and fast as possible. Vue v3 used to make use of the vast collection of utilities available at [VueUse](https://vueuse.org/). ## Putting It Together - Part 2 Part 2 of this article will be coming soon where I go over some technical feats in putting together the above packages. ## Conclusion Thanks for reading Part 1. I hope you at least found it interesting or some of the links useful. --- - **Page:** VueUse Head v1 release - **Source:** https://harlanzw.com/blog/vue-use-head-v1 - **Description:** Learn about the new @vueuse/head v1 release including new features and breaking changes This post aims to provide some insight into the v1 release, what's new and what's changed. If you prefer, feel free to jump straight to the [release notes](#v100-release). ## Taking over as maintainer A few months ago I was decided to fix a quick bug on [@vueuse/head](https://github.com/vueuse/head). I fixed that bug and decided to fix another. At the time there was no one actively maintaining the package, besides [antfu](https://twitter.com/antfu7), kindly reviewing any PRs which got sent. [EGOIST](https://github.com/sponsors/egoist) did an awesome job on it initially, but he was now busy with other projects. I decided that taking over as maintainer would be a good way to contribute more directly to the Vue ecosystem. A quick discussion with Anthony later and I was given the role. Taking over maintenance my goal was to close all open issues, improve performance, documentation and overall developer experience. All I hope have now been achieved with this release. ::tip{type="info"} The v1 release is now shipped in [Nuxt](https://nuxtjs.org).

I'll be doing a talk on this release at the upcoming [NuxtNation](https://nuxtnation.com/), make sure you catch it! :: ## Pre v1 Achievements Taking over maintenance was pretty daunting. There were a number of tricky problems to solve. I started with some low-hanging fruit: `useHead` TypeScript support ([6f919c7](https://github.com/vueuse/head/pull/85)). This one was pretty tedious, but well worth it for developer experience. In creating the types, I made the [zhead](https://github.com/harlan-zw/zhead) package to share the types with the ecosystem and some other small utils. Next up was computed getter support ([b6d74dbeb](https://github.com/vueuse/head/commit/b6d74dbebd32f772923f722c399091c73e21b6ed)). With VueUse 9, the recommended way to deal with computed data was with a computed getter, which is simply a function. However, `useHead` only let you use `computed` or `ref`. This was a pretty easy fix, however it illuminated a major performance bottleneck. So my full attention went into performance ([e1bc8d2](https://github.com/vueuse/head/commit/e1bc8d2e35a9104adbd3a62c29bdadb89181b3fc), [691bcc8](https://github.com/vueuse/head/commit/691bcc88ae3526879238995866daeec39e8bc4c6)). 🏎️ And with that the package was now \~5x faster in patching the DOM. ## Why a major bump? While most issues were closed and easy to solve, there was a major outstanding issue that I wanted to address, *Server Only Tags* (removed, `nuxt/framework` discussions were retired after the Nuxt 3 merge into `nuxt/nuxt`). There was also a nasty issue with tags disappearing unexpectedly when hydrating. Both of these were blocked by how the DOM patching was designed. The old strategy was to add state to the DOM and use this to determine what tags to remove. ```html ``` You can see in the above, we're tracking the attributes being added and the number of tags. The `head:count` element is used as an anchor to start rendering tags from. It would take all tags to be rendered, check the dom elements upwards from the `head:count` meta tag if any needed to be inserted or if they already existed, otherwise delete them. This had a issues when you look closely: ### Deleting DOM elements it doesn't own It would modify whatever element is in the position regardless if it's an element created from `@vueuse/head`. This led to issues with a third-party script inserting something in between these DOM elements, and it getting deleted. It also blocked server only tags, as the client would overwrite them when hydrating. ### Performance Previous DOM patching would delete and re-render tags which shared dedupe keys. Not noticeable functionally but a performance consideration. To solve these issues, a new DOM patching algorithm was needed that tracked side effects gracefully. ### State in DOM isn't so nice Purely from a DX perspective, it's not nice to have state in the DOM that isn't needed. It's not a big deal, but it's not ideal. ## Unhead This was a major piece of work, and I figured if I'm going to have to refactor the entire package, I should aim to also solve another outstanding issue, *Universal Head Management*. So I started on [Unhead](https://github.com/harlan-zw/unhead), a Universal document tag manager. My initial concern was just feature parity with `@vueuse/head` while supporting server tags, but I quickly realised that I could do better. So Unhead was born. It has first-party support for Vue, but there is planned work to support any and all other frameworks (eventually). @vueuse/head v1 is now a thin wrapper for unhead, while being fully backwards compatible. There are too many enhancements and feature improvements to mention here, check the below release notes or jump straight to the [docs](https://unhead.unjs.io). I'm excited about this new package, and I hope you are too! I'll be writing a dedicated blog post about it soon. ## v1.0.0 Release ### 🤖 Core Now powered by [unhead](https://github.com/harlan-zw/unhead). Featuring: - a new DOM patching algorithm that tracks side effects gracefully, less aggressive removal of tags and attributes - ⚡ DOM rendering optimisations, 5x faster (\~10ms for an avg site), async for quicker initial main thread load. - 🧹 No more non-essential state in DOM ### ✨ Enhancements - Vue 2.7 Support ([docs](https://unhead.unjs.io)) - Options API Support ([docs](https://unhead.unjs.io)) #### htmlAttrs / bodyAttrs merging [Documentation](https://unhead.unjs.io) Now merged by default instead of replace. ```ts useHead({ htmlAttrs: { class: 'my-class', }, }) // we don't want that class to be on a specific page, instead we want a new class useHead({ htmlAttrs: { class: 'another-class', }, }) // ``` #### Array / Object Classes [Documentation](https://unhead.unjs.io) When using the htmlAttrs or bodyAttrs options, you can use the class attribute to add classes to the html or body elements. ```ts const darkMode = false useHead({ htmlAttrs: { class: { // will be rendered dark: darkMode, // will not be rendered light: !darkMode, } }, bodyAttrs: { class: ['layout-id', 'page-id'] } }) ``` #### Better deduping [Documentation](https://unhead.unjs.io) Tag deduping is now vastly improved. It's likely you won't need `key` anymore. Includes support for meta content array support, reduces boilerplate by using arrays for meta tags. ```ts useHead({ meta: [ { name: 'og:image', content: [ 'https://example.com/image.png', 'https://example.com/image2.png', ], }, ], }) ``` #### Prop promises You can provide a promise to props and it will be resolved when rendering the tags. ```ts useHead({ script: [ { children: new Promise(resolve => resolve('console.log("hello world")')) }, ], }) ``` ### 🚀 New Features #### useServerHead [Documentation](https://unhead.unjs.io) Lets you render tags on the server only. This has the same API as `useHead`. ```ts useServerHead({ scripts: [ { // this wouldn't work on the client, so we use useServerHead src: import('~/assets/my-script.js?url'), } ] }) ``` #### useSeoMeta [Documentation](https://unhead.unjs.io) Define meta tags in a flat object, fully typed. ```ts useSeoMeta({ description: 'My about page', ogDescription: 'Still about my about page', ogTitle: 'About', ogImage: 'https://example.com/image.png', twitterCard: 'summary_large_image', }) ``` #### tagPosition [Documentation](https://unhead.unjs.io) Lets you define the position of a tag in the DOM. ```ts useHead({ script: [ { src: 'https://example.com/script.js', tagPosition: 'bodyOpen', } ] }) ``` #### tagPriority [Documentation](https://unhead.unjs.io) Lets you define the priority of a tag with a number or string. ```ts useHead({ script: [{ key: 'not-important', src: '/not-important-script.js', },], }) useHead({ script: [ { // script is the tag name to target, `not-important` is the key we're targeting tagPriority: 'before:script:not-important', src: '/must-be-first-script.js', }, ], }) ``` #### tagDuplicateStrategy [Documentation](https://unhead.unjs.io) #### DOM Event Handlers [Documentation](https://unhead.unjs.io) Function support for DOM event handlers. ```ts useHead({ bodyAttrs: { onresize: (e) => { console.log('resized', e) } }, script: [ { src: 'https://example.com/analytics.js', onload: (el) => { console.log('loaded', el) } } ] }) ``` #### Hooks Engine is now powered by hooks, provided by [hookable](https://github.com/unjs/hookable). This allows you to hook into any of the core functionality. See [API hooks](https://unhead.unjs.io) and [Infer SEO MetaTags](https://unhead.unjs.io), not documented properly yet. #### New shortcut composables Same API as `useHead`, but targeted as a specific tag type. - useTagTitle - useTagBase - useTagMeta - useTagLink - useTagScript - useTagStyle - useTagNoscript - useHtmlAttrs - useBodyAttrs - useTitleTemplate ## Migration Guide ⚠️ Breaking changes are minimal, but there are some changes to be aware of. Please report any issues you find and they will fixed be promptly. You may consider using [@unhead/vue](https://unhead.unjs.io) directly if you [don't need @vueuse/head](https://unhead.unjs.io). ### Verify your tags The new DOM patching algorithm has not been tested in all possible scenarios, it's possible that there are unforeseen edge cases. ### `htmlAttrs` and `bodyAttrs` merge strategy If you had built your code around with the assumption that setting `htmlAttrs` or `bodyAttrs` would clear the old tags, this is now different. ```ts // old useHead({ htmlAttrs: { class: 'my-class', }, }) useHead({ htmlAttrs: { class: 'new-class', }, }) // ``` ```ts // new useHead({ htmlAttrs: { class: 'my-class', }, }) useHead({ htmlAttrs: { class: 'new-class', }, }) // ``` Check the [documentation](https://unhead.unjs.io) to learn more. ### Duplicate tags in `useHead` To make duplicate tag handling more intuitive, duplicate tags are now allowed in the same `useHead` call. Previously you would have to use `key` to differentiate them. ```ts // old - key is required useHead({ meta: [ { name: 'og:locale:alternate', content: 'es_ES', key: 'locale_es' }, { name: 'og:locale:alternate', content: 'fr_FR', key: 'locale_fr' }, ], }) // // ``` You can safely remove `key` now. ```ts // new - key is no longer required useHead({ meta: [ { name: 'og:locale:alternate', content: 'es_ES' }, { name: 'og:locale:alternate', content: 'fr_FR' }, ], }) // // ``` It's worth checking that if you did have duplicate tags in the same entry, you will now be rendering both of them potentially. ## Next Steps If you have any issues and/or questions related to v1, please comment in the [discussion](https://github.com/vueuse/head/discussions/161). If you have any ideas on the future of unhead and @vueuse/head please get in touch with me through an issue, Discord or Twitter. Thanks for reading! --- - **Page:** Nuxt 3 Migration Simplified: A Cheat Sheet - **Source:** https://harlanzw.com/blog/nuxt-3-migration-cheatsheet - **Description:** A simplified cheat sheet for migrating from Nuxt 2 to Nuxt 3. Includes a list of all the changes you need to make to your Nuxt 2 project to get it working with Nuxt 3. ## Introduction Upgrading to Nuxt 3 from Nuxt 2 can be a little intimidating. There are a ton of changes to keep track of, and it's easy to feel like you're lost in a maze of code. But don't worry, I'm here to help! I've started this cheat sheet to simplify the upgrade process for myself and for others who are brave enough to tackle it. It's a work in progress, and not intended to be exhaustive. I'll be updating it as I migrate more sites. Just remember that the migration process will take some time, patience, and persistence, depending on the size of your project and your prior experience. But hey, at least you'll learn a bunch. And if you're just looking to upgrade some modules, I've written an [upgrade guide](#2-modules) for 23 of them. Let's do this! ## Resources ### Links **Docs** - [Nuxt 3 - Docs](https://nuxt.com/docs/getting-started/introduction) - [Nuxt 3 - Official Migration Guide](https://nuxt.com/docs/migration/overview) - Nuxt 3 - Migration guide discussion (removed, `nuxt/framework` discussions were retired after the Nuxt 3 merge into `nuxt/nuxt`) - [Vue 3 - Migration Guide](https://v3-migration.vuejs.org/) **Tech** - [UnJS](https://unjs.io/) – A collection of packages which make up the Nuxt core. - [VueUse](https://vueuse.org/) – Essential Vue Composition API utilities. - [Vite.js](https://vitejs.dev) and [Rollup](https://rollupjs.org/) – Backbone of Nuxt 3 bundling. **Nuxt 3 Examples** - [Nuxt Examples](https://nuxt.com/docs/examples/essentials/hello-world) - Minimal scoped examples - [NuxtBnB](https://web.archive.org/web/20230106223315/https://github.com/MasteringNuxt/NuxtBnB) - Complex app (repo removed; archived snapshot) - [Nuxt Movies](https://github.com/nuxt/movies) - Complex app - [harlanzw.com](https://github.com/harlan-zw/harlanzw.com) - @nuxt/content app ### Education - Free: NuxtNation 2022 [YouTube playlist](https://www.youtube.com/playlist?list=PLxddmVXxb3Hu4yg6jEUBcQ6xix6QM0ews) - Paid: [Mastering Nuxt](https://masteringnuxt.com/) ### Getting help - [Discord](https://discord.com/invite/ps2h6QT): create a post in `#nuxt3-help` if you get stuck - Twitter Nuxt Community (removed, the community no longer exists) - Post on StackOverflow with the tag [Nuxt](https://stackoverflow.com/questions/tagged/nuxt), [kissu](https://stackoverflow.com/users/8816585/kissu) may help you if you're lucky - [Reddit Nuxt.js](https://www.reddit.com/r/nuxtjs/) ::tip Need quicker help? Try providing a [reproduction repo](https://nuxt.com/docs/community/reporting-bugs#create-a-minimal-reproduction).

At a minimal, provide the output of `npx nuxi info`. :: ## Pre-migration Before you start upgrading, it's important to familiarise yourself with the new features and changes in Nuxt 3 and prepare your project for the upgrade. ### 1. Get familiar with Nuxt 3 Learn the key differences between Nuxt 2 and Nuxt 3 and the new technologies that power it. ::checkbox #default Nuxt 3 Architecture #tip While there are many similarities to Nuxt 2, the underlying architecture is completely new, more complex and more powerful. Start by learning the differences between: - [Nuxt](https://nuxt.com/docs/guide/going-further/internals#the-nuxt-interface) (core context) - [Nuxt App](https://nuxt.com/docs/guide/going-further/internals#the-nuxtapp-interface) (app context) - [Nitro](https://nuxt.com/docs/guide/concepts/server-engine) (server engine) - Nuxi (command line interface) You may want to re-familiarise yourself with the [Rendering modes](https://nuxt.com/docs/guide/concepts/rendering) (SSR, SPA, Static). Daniel Roe's talk [What happens when you start Nuxt 3](https://www.youtube.com/watch?v=IVA_76hKEwE) is a great place to start. :: ::checkbox #default Read the official migration guide #tip Having a rough understanding of the [migration process](https://nuxt.com/docs/migration/overview) will allow you avoid common pitfalls. Keep this open as you'll reference this as you continue on with the migration process. :: ::checkbox #default Play with Nuxt 3 #tip Create a new fresh Nuxt 3 project using [nuxt.new](https://nuxt.new), play around with it. This will help you get a feel for the new features and how they work. :: ::checkbox #default Learn Vue 3 / Composition API #tip Vue 3 is a major upgrade from Vue 2. You will need to learn the new syntax and features to be able to use Nuxt 3, particularly the [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html). :: ::checkbox #default Learn the basics of TypeScript #tip Whether you like TypeScript or not is irrelevant. Nuxt 3 is written in TypeScript, trying to avoid it will only make your life harder. You don't need to be an expert, but you should be able to read and understand the Nuxt 3 code. You can learn the basics with [TypeScript in 5 minutes](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html). :: ::checkbox #default Learn about modules: CJS and ESM #tip Nuxt 3 requires ECMAScript modules, so you'll need to convert your CommonJS modules. See the Nuxt [ES modules](https://nuxt.com/docs/guide/concepts/esm) page. Many of the issues you'll come across are due to using dependencies which are CJS. So understanding the difference between them can be critical. Node modules can be a challenging subject, if you need further guidance see [this article](https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/). :: ### 2. Prepare your Nuxt 2 app Before you start migrating, you should prepare your Nuxt 2 app to make the upgrade process easier. ::checkbox #default Upgrade Nuxt 2 #tip If you are on an earlier version, the first step is upgrading. The latest Nuxt 2 uses [Vue 2.7](https://blog.vuejs.org/posts/vue-2-7-naruto.html), which supports the Composition API. ```bash npm i nuxt@^2.15.4 # yarn add nuxt@^2.15.4 ``` :: ::checkbox #default Upgrade Modules #tip Upgrade your modules to the latest version that supports Nuxt 2. This will make the upgrade process easier. Be careful not to upgrade modules which may only support Nuxt 3 in future versions. :: ::checkbox #default Enable component auto-imports #tip Nuxt 3 has auto-imports enabled by default, if you're not using this feature in Nuxt 2, you should enable it to identify any component paths that need to be updated. All manual component imports should be removed. See [Components Discovery](https://nuxtjs.org/docs/directory-structure/components/#components-discovery) for more information. ```ts [nuxt.config.ts] export default { components: true } ``` :: ::checkbox #default Audit your dependencies #tip Having hard dependencies in your project that aren't compatible with Nuxt 3 / Vue 3 is going to massively slow you down. You should go through all your node dependencies, checking the following: - Vue plugin: Supports Vue 3 - Nuxt module: Supports Nuxt 3 or has an upgrade path. See [Migration - 2. modules](#2-modules) and the [Nuxt 3 modules](https://nuxt.com/modules). - Other: Scan with [bundlephobia](https://bundlephobia.com/) to find ESM support / alternatives. You can use CJS dependencies, but you'll need to [transpile](https://nuxt.com/docs/guide/concepts/esm#transpiling-libraries) them. This is a good opportunity to drop any dependencies you don't need, migrate to dependencies which have better maintenance and / or are smaller. :: ## Migration This cheat sheet requires a fresh Nuxt 3 project and migrating things over one by one. In each step of migrating, you should test to see if everything is working. If there are bugs, then you will be able to debug them easily. ### 1. Create the new app The first step is creating the new apps boilerplate and migrate over safe config from `nuxt.config.ts`. ::checkbox #default Create a Fresh Nuxt 3 App #tip You can do so with `npx nuxi init my-app`. This will create a new Nuxt 3 project. :: ::checkbox #default Create Boilerplate #tip By default, Nuxt 3 does not provide page routing and layouts. To make the migration easier, you'll be modifying the files to be Nuxt 2 compatible. To enable them we update `app.vue` with `NuxtLayout` and `NuxtPage`. ```vue [app.vue] ``` Create an empty default layout file that will be updated later. ```vue [layouts/default.vue] ``` Create a basic index page, so we can see our changes. ```vue [pages/index.vue] ``` :: ::checkbox #default Migrate Runtime Config / env #tip Follow the [Migrate Runtime Config](https://nuxt.com/docs/bridge/overview#update-runtime-config) doc. The `env` key in `nuxt.config.ts` is no longer supported, you should use runtime config exclusively. ```ts // Nuxt 2 export default { privateRuntimeConfig: { apiKey: process.env.NUXT_API_KEY || 'super-secret-key' }, publicRuntimeConfig: { websiteURL: 'https://public-data.com' }, env: { NUXT_API_KEY: process.env.NUXT_API_KEY } } ``` ```ts // Nuxt 3 export default defineNuxtConfig({ runtimeConfig: { apiKey: process.env.NUXT_API_KEY || 'super-secret-key', // Warning: `public` is exposed on client and server public: { websiteURL: 'https://public-data.com' } } }) ``` You can safely ignore TypeScript issues this causes for now. :: ::checkbox #default Migrate head #tip If you previously had head configuration in your `nuxt.config.ts`, you should migrate it to `app.head` config. If you're using `hid` in your head config, unless you have a good reason, you should remove it. Read [@vueuse/head v1 release notes](/blog/vue-use-head-v1) to learn more. Note: Tags with `href` and `src` will not resolve relative links / aliases. You will need to reference absolute links from the public directory. If you'd like to use aliases, you can use the [nuxt-unhead](https://github.com/harlan-zw/nuxt-unhead) module. ```ts // nuxt 2 head: { meta: [ { hid: 'description', name: 'description', content: 'My custom description' }, ] } ``` ```ts // nuxt 3 app: { head: { meta: [ { name: 'description', content: 'My custom description' } ] } } ``` :: ::checkbox #default Copy Static files #tip In Nuxt 3 the `/static` folder has been renamed to `/public`. Make the `public` directory and copy all of these files over. :: ::checkbox #default Copy CSS / Assets #tip In Nuxt 3, the assets folder serves the same function. Simply copy over your assets' folder. Once done, update the `css` field. ```ts [nuxt.config.ts] css: [ '~/assets/main.css', ] ``` Most pre-processors will work with no extra config. You will just need to install the dependencies. For example, if you see an error like: ```bash Preprocessor dependency "sass" not found. Did you install it? ``` You will need to run `npm install -D sass`. If you're having issues with linked images within CSS, you may need to use absolute links to the images in the public directory. ```css /* Nuxt 2 - we have the file: assets/images/logo.png */ body { background-image: url('~/assets/images/logo.png'); } ``` ```css /* Nuxt 3 - we have the file: public/images/logo.png */ body { background-image: url('/images/logo.png'); } ``` You may have some node_module dependencies in your css entries, install each of these as needed. :: ### 2. Modules Module migration can be a challenging part of the migration process. You will need to migrate each module one by one and test your app after each module. Nuxt 2 modules are not compatible with Nuxt 3 out of the box, so many modules are not supported yet. I have tried to provide an upgrade path for the most popular modules which are not supported. ::checkbox #default Copy over modules #tip In Nuxt 3 all modules should belong under the `modules` key. You will need to copy over your modules from the `buildModules` and `modules` fields. ```ts [nuxt.config.ts] export default defineNuxtConfig({ modules: [ // your modules ] }) ``` :: ::checkbox #default Remove redundant modules #tip The following modules can be safely removed as functionality they provide is redundant. - [@nuxtjs/typescript](https://typescript.nuxtjs.org/) - [@nuxtjs/typescript-runtime](https://typescript.nuxtjs.org/) - [@nuxtjs/composition-api](https://composition-api.nuxtjs.org/) - [@nuxtjs/dotenv](https://github.com/nuxt-community/dotenv-module) - [nuxt-build-optimisations / nuxt-webpack-optimisations](https://github.com/harlan-zw/nuxt-webpack-optimisations) :: ::checkbox #default Remove Modules No Longer Recommended #tip [@nuxtjs/axios](https://axios.nuxtjs.org/) and [@nuxt/http](https://http.nuxtjs.org/) are not recommended with Nuxt 3. It's best practice to use the [$fetch / useFetch](https://nuxt.com/docs/migration/component-options/) API. If are determined to use `$http` you can use [nuxt-alt/http](https://github.com/nuxt-alt/http). See the Nuxt 3 and Axios discussion for additional context (removed, `nuxt/framework` discussions were retired after the Nuxt 3 merge into `nuxt/nuxt`). :: ::checkbox #default Use alternative modules #tip #### @nuxtjs/auth Official `@nuxt/auth` support is a [work-in-progress](https://nuxt.com/docs/community/roadmap#core-modules). In the meantime, you can use: - [sidebase/nuxt-auth](https://github.com/sidebase/nuxt-auth) - [nuxt-alt/auth](https://github.com/nuxt-alt/auth) - [@nuxtjs/supabase](https://supabase.nuxtjs.org/) #### @nuxtjs/pwa The community [nuxt-pwa-module](https://github.com/kevinmarrec/nuxt-pwa-module) module seems to be the best option. A core module for PWA seems to be undecided, if it goes ahead there's no ETA. #### @nuxtjs/i18n Official support is in beta, check the [docs](https://v8.i18n.nuxtjs.org/). #### @nuxtjs/proxy This feature is coming to nitro route rules. You can track it here: . In the meantime, you can use the [nuxt-alt/proxy](https://github.com/nuxt-alt/proxy) module. #### @nuxtjs/eslint-module The Nuxt 2 version of this module is called `@nuxtjs/eslint-config`, you should remove this. Instead, follow the [documentation](https://github.com/nuxt/eslint-config) for `@nuxt/eslint-config`. #### @nuxtjs/sitemap Not currently supporting Nuxt 3. Instead, you can use: - [nuxt-simple-sitemap](https://github.com/harlan-zw/nuxt-simple-sitemap) or [nuxt-seo-kit](https://github.com/harlan-zw/nuxt-seo-kit). - [@funken-studio/sitemap-nuxt-3](https://github.com/funkenstudio/sitemap-module-nuxt-3) #### @nuxtjs/robots Not currently supporting Nuxt 3. Instead, you can use: - [nuxt-simple-robots](https://github.com/harlan-zw/nuxt-simple-robots) or [nuxt-seo-kit](https://github.com/harlan-zw/nuxt-seo-kit). Alternatively, create your own robots.txt file at `public/robots.txt`. ```text [public/robots.txt] User-agent: * Disallow: / ``` OR you can generate robots dynamically. ```ts [server/routes/robots.txt.ts] export default defineEventHandler( e => `User-agent: *\nDisallow: /\nSitemap: https://YOUR_SITE/sitemap_index.xml` ) ``` #### @nuxtjs/vuetify Official support is coming soon. You can use [nuxt-alt/vuetify](https://github.com/nuxt-alt/vuetify) in the meantime. #### @nuxtjs/device Not supporting Nuxt 3. Create your own plugin to detect the device instead, you can use the [ua-parser-js](https://github.com/faisalman/ua-parser-js) package directly. ```ts [composables/useDevice.ts] import UAParser from 'ua-parser-js' export function useDevice() { const parser = new UAParser() const { os, browser, device } = parser.getResult() return { os, browser, device, isMobile: device.type === 'mobile', isDesktop: device.type === 'desktop', isTablet: device.type === 'tablet', // etc } } ``` ```vue [pages/index.vue] ``` ### @nuxtjs/moment Not supporting Nuxt 3. Create your own plugin to use Moment instead. ```ts [plugins/moment.ts] import moment from 'moment' import 'moment/locale/en' export default defineNuxtPlugin((nuxtApp) => { moment.locale('en') moment.tz.setDefault('UTC') return { provide: { moment, } } }) ``` ```vue [pages/index.vue] ``` #### @nuxtjs/fontawesome I'd recommend using [nuxt-icon](https://github.com/nuxt-modules/icon). Alternatively, see the Using FontAwesome in Nuxt 3 discussion for plugin code sample (removed, `nuxt/framework` discussions were retired after the Nuxt 3 merge into `nuxt/nuxt`). #### @nuxtjs/dayjs See [Nuxt3 support](https://github.com/nuxt-community/dayjs-module/issues/376) for plugin code sample. #### @nuxtjs/toast See the Examples of using the @nuxtjs/toast or any vue toast plugin? discussion for plugin code sample (removed, `nuxt/framework` discussions were retired after the Nuxt 3 merge into `nuxt/nuxt`). #### @nuxtjs/gtm You can create your own code to handle gtm. ```ts [nuxt.config.ts] export default defineNuxtConfig({ app: { head: { script: [ { innerHTML: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); })(window,document,'script','dataLayer','YOUR_GTM_ID');` }, ], }, } }) ``` #### bootstrap-vue See [nuxt3 starter?](https://github.com/cdmoro/bootstrap-vue-3/issues/232) issue for code sample. Work in progress. #### @nuxtjs/google-analytics See [Vue Plugins](https://nuxt.com/docs/guide/directory-structure/plugins/#vue-plugins) from the docs for an example of how to use Google Analytics with Nuxt 3. Alternatively, see [this comment](https://github.com/nuxt-community/gtm-module/issues/82#issuecomment-1373202592) on how you can implement it with cookie control. :: ::checkbox #default Upgrade supported modules #tip If the module you're using supports Nuxt 3, it's important to check the migration notes. It's likely that the upgrade will include some breaking changes. :: ### 3. State management If you're using Vuex 3, you will need to upgrade your state management, as Vue 3 does not support it. ::checkbox #default Consider if you need a state management library #tip Nuxt 3 ships with its own statement management with the [useState](https://nuxt.com/docs/getting-started/state-management) composable. If you're using a state management library, you should consider if you need it. Additionally, if you are dealing with data client side, you can always reference global refs. This isn't recommended for server-side rendering as state is shared between users. ```ts [composables/useCounter.client.ts] import { ref } from 'vue' // You can import this counter from any file and the data will stay in sync export const counter = ref(0) export function useCounter() { return { counter, increment() { counter.value++ }, decrement() { counter.value-- } } } ``` :: ::checkbox #default Otherwise, migrate to Pinia or Vuex 4 #tip [Pinia](https://pinia.vuejs.org/ssr/nuxt.html#nuxt-2-without-bridge) is the recommended state management solution for Nuxt 3, but you can use [Vuex 4](https://vuex.vuejs.org/guide/migrating-to-4-0-from-3-x.html) if you prefer. :: ### 4. Components Components will only be parsed if they are imported. This means it's safe to copy over all our components and mixin files from Nuxt 2. ::checkbox #default Copy components #tip The folders have the same name and will be auto-imported. You don't need to add anything to `nuxt.config.ts`. If your components aren't being found, it may be because component auto-imports are done with path-prefixing by default, see [Component Names](https://nuxt.com/docs/guide/directory-structure/components#component-names). :: ::checkbox #default Follow Component Options Migration #tip See the [Component Options Migration](https://nuxt.com/docs/migration/component-options) guide for more information. :: ### 5. Pages and dependencies You will be copying over your pages one by one and their dependencies. As you copy each page, you will run into errors. These errors may be related to breaking changes between Nuxt 2 and 3 or to missing dependencies. For example, you'll have missing layout, plugin and potentially component dependencies. It's important you reference the [Nuxt 3 Migration Guide](https://nuxt.com/docs/migration/overview) as you go. Remember to ask for help in the community channels if you get stuck. ::checkbox #default Home Page #tip Start with the home page, `pages/index.vue`. This will give you some momentum and help you get started. :: ::checkbox #default Copy static route pages #tip You'll start with routes which aren't dynamic which tend to be less complex and easier. For example: `pages/about.vue`, `pages/contact.vue`, etc. :: ::checkbox #default Copy dynamic route pages #tip Nuxt 3 uses a new file system routing system, which means that all our pages need to be converted to the new format. See [Dynamic Routes](https://nuxt.com/docs/migration/pages-and-layouts#dynamic-routes) for more information. :: ### 6. Server Routes / Middleware Nuxt 3 uses a completely new server engine and HTTP framework. This means that all our server routes and middleware need to be converted to the new format. ::checkbox #default Migrate server routes #tip See [Migrate Server Routes](https://nuxt.com/docs/migration/server). You'll likely need to completely rewrite your server routes. :: ::checkbox #default Migrate server middleware #tip See [Migrate Server Routes](https://nuxt.com/docs/migration/server). You'll likely need to completely rewrite your server middleware. :: ## Post Migration ### 1. Recommendations ::checkbox #default Migrate to Composition API #tip While this is not required, it is highly recommended. The Composition API is a much better way to write Vue code and Nuxt provides better support for it. Tips: - [vue-composition-convertor](https://github.com/miyaoka/vue-composition-converter) may give you a head start - [ChatGPT](https://chat.openai.com/) is pretty good at converting Vue 2 options API to Vue 3 composition API :: ::checkbox #default Migrate everything to TypeScript #tip Nuxt 3 is TypeScript first, it is the best practice to have all of your code in TypeScript. If you're using the Options API, you can simply add `lang="ts"` to your Vue SFCs. Otherwise, if you are using the options API, you can make use of the `defineNuxtComponent` function. ```vue [components/MyComponent.vue] ``` :: ::checkbox #default Load static assets from an absolute path #tip In Nuxt 2 it was common to load everything from `~/assets`, however with Nuxt 3 the default is to use absolute paths. This way the bundler does not need to parse them. This will save you time when migrating and improve your build performance. Simply move images which aren't going to change to `static` and load them from `/` instead of `~/assets`. :: ::checkbox #default Use Vue Macros #tip To make the migration from Vue 2 as simple as possible, you can make use of [vue-macros](https://vue-macros.sxzz.moe/guide/getting-started.html). It provides a number of useful build-time macros, which speeds up the time to convert the code. ```vue [components/MyComponent.vue] ``` :: ::checkbox #default Switch to UnJS / VueUse where appropriate #tip The [VueUse](https://vueuse.org/) and [UnJS packages](https://unjs.io/) all support Nuxt 2 and Nuxt 3. If you can migrate to using them over other dependencies, you'll save yourself a lot of time. :: ### 2. Testing Testing in Nuxt 3 is still under development. There is some documentation around it, but it is fairly minimal. ::checkbox #default Migrate to Vitest #tip Nuxt 3 uses Vitest for testing. If you're using Jest or Mocha, you'll need to migrate. See the [Why Vitest](https://vitest.dev/guide/why.html) guide for more information. :: ::checkbox #default Use @nuxt/test-utils #tip Follow the documentation on the [Testing](https://nuxt.com/docs/getting-started/testing) page, to get started. :: ::checkbox #default Add vitest-environment-nuxt (optional) #tip The [vitest-environment-nuxt](https://github.com/danielroe/vitest-environment-nuxt) package will likely take over as the official testing environment for Nuxt 3. It's still a work-in-progress with minimal documentation, but you may consider using it. :: ## Conclusion You likely still have some migration pain ahead of you, but you're well on your way, congratulations. Feel free to reach out to me directly on Twitter or Discord with any feedback or questions you may have. Keep in mind that this migration guide does not intend to be exhaustive and is being actively updated. This post took me many hours to put together. Please consider [sponsoring me](https://github.com/sponsors/harlan-zw) if this has helped you. --- - **Page:** Scaling Your Vue Components for Mid-Large Size Apps - **Source:** https://harlanzw.com/blog/scale-your-vue-components - **Description:** Working on a mid-large size app usually means hundreds of components. How do you make sure these components will scale? One of the key pieces in scaling your Vue app is having good component architecture. - How are components named? - What folder hierarchy should you use? - How is component code scoped? The cost of not having clear answers to these simple questions increases as your app grows. My previous role was the tech lead at a startup. Growing pains were frequent with pivots, design changes and new features. All pushing our total component count up. My below suggestions are what I came up with to solve *our* scaling issues. Your project will have its own requirements. ## 100+ component club Let's assume once you hit 100+ components, then you are a mid-size app and you will be feeling your own growing pains. Are you in the club? Run the following in your component folder: ```shell # cd app/components COMPONENTS=$(ls -lR **/*.vue | wc -l) && echo -e "You have ${COMPONENTS} components." ``` ### Problems you may have - Difficult to remember which component to use where - Code is being repeated - Monolithic components - New components are being built instead of leveraging existing ones - Inconsistent emits and props between components with the same functionality - Technical debt is being ignored because it is too painful ## Solving component scaling with rules Good code adheres to a set of rules. You either follow existing rules (syntax and conventions) or create new ones and make sure others follow them (documentation and code reviews). ### Rule 0. Have good dev processes There is no substitute for a good development process. You need to be following best continuous delivery, documentation and communication practices. The rest of the rules will not help you if you are not functioning like a well-oiled machine. ### Rule 1. Know the style guide You should be familiar with the official [Vue.js Style Guide](https://v3.vuejs.org/style-guide/). It gives you clear, concise instructions on what you should and shouldn't do. You should set up [eslint-plugin-vue](https://eslint.vuejs.org/) with the recommended rules. ```js [.eslintrc.js] module.exports = { extends: [ // ... 'plugin:vue/vue3-recommended', // 'plugin:vue/recommended' // Use this if you are using Vue.js 2.x. ], } ``` ### Rule 2. Use a component naming convention The bane of developers lives: how to name something. You can address that by having an easy-to-follow convention on how to name a component. The convention also tells you where to put the component in your folder hierarchy.
{prefix}-{namespace}{?-class}
#### Prefix > Base components (a.k.a. presentational, dumb, or pure components) that apply app-specific styling and conventions should all begin with a specific prefix, such as Base, App, or V. A short prefix for *all* your components is preferable to the above. Using a prefix avoids conflicts with HTML tags and third-party components. It also gives you scoped IDE autocompletion and more reusable components. Prefixing becomes especially important when working with a component library ([Vuetify](https://vuetifyjs.com/), [VueStrap](https://yuche.github.io/vue-strap/), etc) or third-party components ([algolia](https://github.com/algolia/vue-instantsearch), [google maps](https://github.com/xkjyeah/vue-google-maps), etc). You should use something which relates to your app, for example, I use `h` as the prefix because my site is [harlanzw.com](http://harlanzw.com). ```vue ``` You can use many prefixes for your components to help you with scoping code. ```vue ``` #### Namespace > Child components that are tightly coupled with their parent should include the parent component name as a prefix. The style guide recommends starting the component name with the parent component. I've found using a *namespace* after the prefix instead is more flexible. Namespaces avoid conflicts, improve IDE autocompletion and define the scope of the component. You should map namespaces to a folder, this way you can group components, making them easier to find and use. An example of a namespace is `Field`, for all our field components (text field, textarea, search, etc.). ```shell components/ |- Field/ # namespace |--- HFieldText.vue |--- HFieldTextarea.vue |--- HFieldSearch.vue |--- HFieldAutocomplete.vue |--- HFieldCheckbox.vue ``` You can then create conventions that components in a namespace should follow. For example these components should all have a `:value` prop and `$emit('input', value)`. #### Class (optional) > Component names should start with the highest-level (often most general) words and end with descriptive modifying words. The final part of the convention is, in fact, the name of the component. Thinking of it as a class name makes the distinction between the namespace easier. You still want to follow the above style guide rule, our class names should be general to descriptive. The class should be optional. Namespaces can provide a default component to reduce the name of common components. Imagine you have a project with a few buttons. Most of the time you want to use the default button, you shouldn't need to name it `HButtonDefault.vue`. ```shell components/ |- Button/ # namespace |--- HButton.vue # The namespaces default component |--- HButtonCallToAction.vue # A call to action button |--- HButtonSubmitForm.vue # A button to submit forms ``` Recommendations on naming the class: - Describe the application function of the component, rather than what it looks like. - ❌ `HButtonRainbowFlashing.vue` - ✅ `HButtonCallToAction.vue` - Choose to be verbose if it adds clarity to the scope. - ❌ `HProfileUser.vue` - ✅ `HProfileAuthenticatedUsersCard.vue` - Prefer full words over abbreviations. From the [style guide](https://v3.vuejs.org/style-guide/#full-word-component-names-strongly-recommended). ### Rule 3. Separate component scopes Defining scopes for how components behave will guide you in staying DRY. There are many ways to set this up. A good starting point is a scope for "shared" (a.k.a. base, presentational or dumb) components and "app" (a.k.a single-instance). ```shell components/ |- app # Contains application logic |- shared # Does not contain application logic ``` You could also pull out your "shared" components into their own npm package. When creating new components it's natural to couple application logic in. With this setup, you'll think about component scopes more and how code can be re-used. ![A decision graph for component folders](/blog/component-folder-flow.svg){width="750" height="1200" max-height="600"} #### "Shared" Folder - Base Components These components are re-usable and include form inputs, buttons, dialogues and modals. They should never contain application logic or state data. You should be aiming to build your own "UI kit" from these components. Copy-pasting your shared folder into a new project should work out of the box (assuming you handle dependencies). #### "App" Folder - App components App components do contain application logic and state data. If you were to copy+paste an app component into a new project, it should not work. ## Example: Newsletter Sign Up This exists as two "app" components, they contain logic for validation and posting to an API. They both contain "shared" components. ![Newsletter component example](/blog/newsletter-example.png){width="841" height="296"} ```shell components/ # application component scope |- app/ |-- Newsletter # namespace |--- HNewsletterForm.vue # validates and posts data |--- HNewsletterCard.vue # handles successful form post # shared component scope |- shared/ |-- Alert/ |--- HAlertSuccess.vue |-- Button/ |--- HButton.vue |-- Card/ |--- HCard.vue |-- Form |--- HForm.vue |-- Field/ |--- HFieldEmail.vue ``` ```vue [HNewsletterForm.vue] ``` ```vue [HNewsletterCard.vue] ``` ## Example: Forum Thread Now imagine you want to build a forum thread page. A user can see comments, upvote comments and post their own comment. ![Laravel.io Forum Thread](/blog/forum-example.png){width="950" height="847" max-height="600"} Using `F` as our component prefix, let's look at what you need. ```shell components/ # application component scope |- app/ |-- Thread # namespace |--- FThread.vue # Wraps the entire thread |--- FThreadPost.vue # A single post / reply |--- FThreadFormReply.vue # Form to submit a reply |-- Field/ |--- FFieldComment.vue # Comment box for posts |-- Button/ |--- FButtonUpvote.vue # The thumbs up button # shared component scope |- shared/ |-- Img/ |--- FImgAvatar.vue # Users photos |-- Field/ |--- FFieldWYSIWYG.vue # Comment box for posts |-- Card/ |--- FCard.vue # Gives posts a 'card' look |-- Button/ |--- FButton.vue # Reply button for the post box ``` ```vue [FThread.vue] ``` ```vue [FThreadPost.vue] ``` ```vue [FThreadFormReply.vue] ``` ## Extra and optional rules ### Use An Automatic Component Importer Being tied to import paths once you have a few hundred components is going to slow you down. Using an [automatic component imports](/blog/vue-automatic-component-imports) will clean up your code. You'll be free to tinker with the directory structure of your components in any way you want. ### Typescript Components The value of types, when you're working with objects is too good to pass up. Will save you hours down the line in developer experience. As a starting point, I'd try and get your shared components using Typescript. ```vue ``` ### Components have "one job" > Every component should have one job, any code in the component that isn't achieving that job shouldn't be there. You should be thinking when you create a component what it's one core function is. You can limit yourself with this mindset, but it's worth keeping in mind as you go. ### Create component demo pages Using a package like [Storybook](https://storybook.js.org/) is a great idea, but it comes with overhead and when you're starting out it can be a bit overkill. As a starting point, you can create pages under a `/demo` prefix and throw your components on it. You want an easy way to find components and classes that are available. Here is a rough demo page as an example: [Massive Monster](https://massivemonster.co) (the original demo page is gone). Keep it as basic as you want. ![Massive Monster Demo Page](/blog/brand-demo.png){width="939" height="464"} ### Mixins and composables This one should be pretty obvious and there are enough articles elsewhere on using these. You want to pull out common logic from components and put them in either mixins or composables. Check out [VueUse](https://github.com/antfu/vueuse) for some ideas on what that could look like. ## Thanks for reading If you like the technical side of Vue and Laravel, I'll be posting regular articles on this site. The best way to keep up to date is by following me [@harlan_zw](https://twitter.com/harlan_zw) or signing up for the newsletter below. --- - **Page:** How Does Vite Work - A Comparison to webpack - **Source:** https://harlanzw.com/blog/how-the-heck-does-vite-work - **Description:** A deep-dive into the comparisons between the earliest Vite version and webpack. Discover what I learnt digging into internals and how I, correctly, guessed Vite was the next big thing. ::tip Note: This article was written for the alpha of Vite. I'd recommend reading Patak's great article on [The Vite Ecosystem](https://patak.dev/vite/ecosystem.html). :: In rebuilding my old Nuxt.js personal site, I wanted to challenge myself to learn the latest tech, the unknown. The unknown was the new project by Evan You: [⚡ Vite](https://github.com/vitejs/vite) (/veet/). Called Fast, for the 🇫🇷 Frenchies. I'll be comparing how Vite works to the standard [webpack](https://github.com/webpack/webpack) config using [webpack-dev-server](https://github.com/webpack/webpack-dev-server), which all major Vue frameworks are using. We'll be looking at how Vite no-bundling works, by first looking at how webpack's bundling works and what the difference is. Afterwards I'll give you some recommendations for setting up Vite for yourself. Vite could the next best thing in tooling, currently, it's still in a pre-release stage though so be careful out there 🐛. ## A Recap on Vite Vite is a web development build tool which supports Vue, React and Preact. It's an experimental new direction in how build tools can work with a greenfield ecosystem. Vite's core functionality is similar to webpack + webpack-dev-server with some core improvements on developer experience: - ⌛ Less time waiting for your app to start, regardless of app size - 🔥 Hot module reloading (HMR) that is basically instant, regardless of app size - 🔨 On-demand compilation - 🙅‍♂️ Zero configuration for numerous pre-processors out of the box - 📜 Esbuild powered typescript / jsx (super quick) ### Speed Example To give you a quick idea on how much faster it is, the below comparison is for Vue CLI which uses webpack. The bigger your app is the more noticeable the speed difference will be. ::figure | | Build Time | Dev Server Start Time | Dev Page Load Time | | ------- | ----------------------------------------: | -----------------------------------------: | -----------------------------------------: | | Vue CLI | 5.14s | 2568ms | 320ms | | Vite | 2.39s | 232ms️ | 379ms |
New Vue 3 project / 10 components / no Babel / 2nd run, in development.
:: ## Vite vs webpack The main functional difference you'll notice with Vite and your webpack app, is how code is served in development and which modules are supported. Don't worry if the below terms don't make sense to you, we'll be exploring them below. ### webpack (Nuxt.js / Vue CLI / etc) - Supported Modules: [ES Modules](https://web.archive.org/web/20260511134339/https://2ality.com/2014/09/es6-modules-final.html), [CommonJS](http://wiki.commonjs.org/) and [AMD Modules](https://github.com/amdjs/amdjs-api/wiki/AMD) - Dev Server: Bundled modules served via webpack-dev-server using [Express.js](https://expressjs.com/) web server - Production Build: webpack ### Vite - Supported Modules: [ES Modules](https://web.archive.org/web/20260511134339/https://2ality.com/2014/09/es6-modules-final.html) - Dev Server: Native-ES-Modules, served via Vite using a [Koa](https://github.com/koajs/koa) web server - Production build: [Rollup](https://github.com/rollup/rollup) ::tip Check out Mozilla's article on ES Modules if they're new to you. :: ## Understanding webpack To understand how Vite works, it's best to look at how webpack works first. Even with its popularly, understanding webpack can be intimidating, so I'll try to keep it simple. webpack is versatile in what you can do with it, but at its core, it will: - Starting with an entry file, build a tree of your dependencies: all the imports, exports, requires from your code/files - Transform / compile modules: think transpiling js for older browsers, turning SCSS into CSS - Use algorithms to sort, rewrite and concatenate code - Optimise ### webpack In Development Assuming you're using one of the main Vue frameworks, when you start your app in development, it is going to do a few things: 1. Bundle all of your code 2. Start the webpack-dev-server, the Express.js web server which will serve the bundled code 3. Setup sockets which will handle the Hot Module Reloading As you may notice with your own apps, the bigger they grow, the longer you have to wait to start coding. ![The Nuxt logo is almost burnt into my monitor at this point.](/blog/nuxt-start.png){width="789" height="549"} Bundling in development is quicker because you don't need to do as much with the code, however, as your app grows, it will become painfully slow, especially on older machines. ### webpack Component Example I created a default Vue 3 [Vue CLI](https://cli.vuejs.org/) project, which has an entry `App.vue` file using the `HelloWorld.vue` component. Let's see how this component gets to my browser. HelloWorld.vue component: ```vue [HelloWorld.vue] ``` When I start my app and visit localhost I get the following HTML from the Express.js server. ```html
``` You'll notice we have 2 script files there: `chunk-vendor.js` and `app.js`. On inspecting them you'd see a lot of gibberish looking code. it helps to use the [webpack-bundle-analyzer](https://www.npmjs.com/package/webpack-bundle-analyzer) to see how it works visually. #### chunk-vendors.js These are third-party modules, usually coming from `node_modules`. The two main libraries in here are Vue itself and sockjs which is used for HMR. ![The two top libraries are Vue.js and Sock.js (For HMR)](/blog/vendor-chunk.png){width="1860" height="960" max-height="400"} #### app.js This is all the code for my application. It contains components, assets, etc. You'll notice that for an SFC it splits it into multiple modules. ![My app is two components, App.vue and HelloWorld.vue](/blog/app-chunk.png){width="1857" height="953" max-height="450"} Taking a quick look at the `app.js` file, we can find some of the `HelloWorld` component code. As you can see in the above image, all parts of the SFC are separate modules: the wrapper, CSS, template, js. The wrapper module is defining and importing the other models, some beautiful code. ```js /***/ "./src/components/HelloWorld.vue": /*!***************************************!*\ !*** ./src/components/HelloWorld.vue ***! \***************************************/ /*! exports provided: default */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _HelloWorld_vue_vue_type_template_id_469af010_scoped_true__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./HelloWorld.vue?vue&type=template&id=469af010&scoped=true */ \"./src/components/HelloWorld.vue?vue&type=template&id=469af010&scoped=true\");\n/* harmony import */ var _HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./HelloWorld.vue?vue&type=script&lang=js */ \"./src/components/HelloWorld.vue?vue&type=script&lang=js\");\n/* empty/unused harmony star reexport *//* harmony import */ var _HelloWorld_vue_vue_type_style_index_0_id_469af010_scoped_true_lang_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./HelloWorld.vue?vue&type=style&index=0&id=469af010&scoped=true&lang=css */ \"./src/components/HelloWorld.vue?vue&type=style&index=0&id=469af010&scoped=true&lang=css\");\n\n\n\n\n\n_HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"].render = _HelloWorld_vue_vue_type_template_id_469af010_scoped_true__WEBPACK_IMPORTED_MODULE_0__[\"render\"]\n_HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"].__scopeId = \"data-v-469af010\"\n/* hot reload */\nif (true) {\n _HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"].__hmrId = \"469af010\"\n const api = __VUE_HMR_RUNTIME__\n module.hot.accept()\n if (!api.createRecord('469af010', _HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"])) {\n api.reload('469af010', _HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"])\n }\n \n module.hot.accept(/*! ./HelloWorld.vue?vue&type=template&id=469af010&scoped=true */ \"./src/components/HelloWorld.vue?vue&type=template&id=469af010&scoped=true\", function(__WEBPACK_OUTDATED_DEPENDENCIES__) { /* harmony import */ _HelloWorld_vue_vue_type_template_id_469af010_scoped_true__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./HelloWorld.vue?vue&type=template&id=469af010&scoped=true */ \"./src/components/HelloWorld.vue?vue&type=template&id=469af010&scoped=true\");\n(() => {\n api.rerender('469af010', _HelloWorld_vue_vue_type_template_id_469af010_scoped_true__WEBPACK_IMPORTED_MODULE_0__[\"render\"])\n })(__WEBPACK_OUTDATED_DEPENDENCIES__); }.bind(this))\n\n}\n\n_HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"].__file = \"src/components/HelloWorld.vue\"\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (_HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"]);"); ``` The main takeaway here is that within the `app.js` file contains all modules for my app. webpack does let you chunk the bundles how you like, for Nuxt.js it chunks routes individually. The more chunks though, the more requests and more potential blocking js. You may see the problem here, we have multiple monolith files that need to be generated anytime we want to use our app. When we change a file for HMR, we need to regenerate the entire file. ## Understanding Vite Vite doesn't set out to be a new bundler. Rather, it's a pre-configured build environment using the Rollup bundler and a tool for local development. ### Vite In Development Vite makes the assumption that developers are going to be using the latest browser versions, so it can safely rely on the latest JS functionality straight from the browser - in other words, no babel transpiling! When you start Vite for the first time pre-optimisations will be done on your `node_modules`, then [Koa](https://github.com/koajs/koa), a light-weight node web server starts to serve your app. There is no bundling or compiling needed to start the dev server, so it's damn quick (< 300ms). When you open your Vite app you'll be served the `index.html` from the server. The browser is going to read the `index.html` and know how to parse the Native-ES-Module code. ```html
``` Parsing the Native-ES-Module means it will read the `export` and `import` lines from your code. It will convert those lines into HTTP requests back to the server, where it will again read the `export` and `import` lines and make new requests. It will keep going through like this with your dependencies recursively, in a waterfall process, until everything has been resolved. ![Recursive network requests triggered from the entry - VitePress.](/blog/network-requests.png){width="964" height="551" max-height="450"} ### Vite Component Example Let's take a look at how these requests are working in the browser. After I open my app at `http://localhost:3000`, the browser has fetched the following `index.js` file from the web server: ```js {5} import '/@theme/styles/main.scss?import'; import Layout from '/@theme/Layout.vue'; import NotFound from '/@theme/NotFound.vue'; import CardPost from '/@theme/components/CardPost.vue'; const theme = { Layout, NotFound, enhanceApp({ app, }) { app.component('CardPost', CardPost) } }; export default theme; ``` ::tip Normally, in webpack, you would have to transpile this code to something legacy browsers can understand. Newer browsers know what to do with it, see [es6 module dynamic import](https://caniuse.com/es6-module-dynamic-import). :: Let's drill into that highlighted line which is requesting the CardPost SFC. The browser will turn that import into a request for `http://localhost:3000/@theme/components/CardPost.vue`. ```vue [CardPost.vue] ``` Once the web server gets this request, it will need to compile the `CardPost.vue` file to javascript and send it back. Vite has many optimisations around the Vue compiling so this takes no time. Let's see what comes through: ```js [CardPost.vue - Transpiled] import '/@theme/components/CardPost.vue?type=style&index=0' import posts from '/.vitepress/posts.ts' import { render as __render } from '/@theme/components/CardPost.vue?type=template' const __script = { props: { postIndex: { type: Number, required: true, } }, computed: { post() { return posts[this.postIndex] } } } __script.__scopeId = 'data-v-287b4794' __script.render = __render __script.__hmrId = '/@theme/components/CardPost.vue' typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script) __script.__file = '/home/harlan/sites/new.harlanzw.com/app/.vitepress/theme/components/CardPost.vue' export default __script ``` Cool, so quite a bit going on here. The main thing to note here is how it's split up the SFC into different modules which will need separate requests to fetch. It hasn't bundled these imports into the SFC or some other monolith file. - Dependencies: `/.vitepress/posts.ts` - Template: `/@theme/components/CardPost.vue?type=template` - Stylesheet: `/@theme/components/CardPost.vue?type=style&index=0` If you're curious, this is what the style component response looks like, some nifty for sure. ```js [CardPost.vue - CSS] import { updateStyle } from '/vite/client' const css = '.card-post[data-v-287b4794] {\n position: relative;\n}\n.card-post .prose[data-v-287b4794] {\n max-width: 100% !important;\n}\n.card-post__link[data-v-287b4794] {\n position: absolute;\n left: 0;\n top: 0;\n width: 100%;\n height: 100%;\n content: " ";\n z-index: 1;\n}\n.card-post__content[data-v-287b4794] {\n background-color: white;\n z-index: 1;\n}\n.card-post__effect[data-v-287b4794] {\n z-index: -1;\n content: " ";\n height: 30px;\n width: 100%;\n position: absolute;\n background-color: #059669;\n transition: 0.2s;\n opacity: 0;\n top: 30px;\n}\n.card-post:hover .card-post__effect[data-v-287b4794] {\n top: -5px;\n opacity: 1;\n transform: rotate(0.25deg);\n}' updateStyle('287b4794-0', css) export default css ``` You can see how the above allows the Hot Module Replacement to work efficiently. When you have a module that is changed, say the styles within a component, instead of reloading the entire component tree, only the style module needs to be replaced. You can also imagine with the above, where Vite slows down. Imagine hundreds of HTTP requests which rely on nested HTTP requests, recursively. Fortunately, there are optimisation to avoid this situation after the first load. The server will return a 304 Unmodified HTTP Status code for modules which haven't changed, meaning they will use the browser's cached version of the file. Vite scales well for any app size because it only needs to request the modules for the route you're on. ## Production Builds Since Vite is using Rollup, pre-configured, you'd expect a similar output from Vite as webpack. Vite does boast a quicker builder and potentially a smaller artifact size, as Rollup is a more efficient bundler than webpack. The main gotcha is that Vite can still only support ES Modules in the production build, meaning you can't have any dependencies which don't have ES Module exports. Vite is also pre-configured to handle your build as a universal app. A universal app is built using a client (virtual browser) and a server (node). Allowing it to pre-render the HTML pages, so robot crawlers can fetch your page content without executing js and speeding up the initial load for users. That means SEO friendly static sites out of the box 🎉. ## Summary While I haven't touched on a lot of the complexities of Vite and webpack, I've tried to show you the main difference, how bundling and no-bundling look in action. Hopefully you've seen why Vite is promising alternative. There is so much potential in the ecosystem at the moment, watch this space, given 12-months we could see an explosion of Vite related projects. If you want to find out more about Vite, I'd watch Evan's talk on [Vite & VitePress](https://www.youtube.com/watch?v=xXrhg26VCSc&). ## Getting started with Vite I'd recommend just spinning up bare-bones Vite to get a feel for it. It's really easy, takes less than a minute. ```shell [bash] npm init vite-app ``` Once you are sold, it's worth checking out [the ecosystem](https://github.com/vitejs/awesome-vite) before you build. ### Recommendations ::tip You shouldn't be looking to replace Vue CLI or webpack with Vite for existing projects yet, but it may be valuable to check out for new smaller scoped projects. :: The Vite ecosystem isn't that mature yet, the two main projects I'd recommend checking out are [VitePress](https://vitepress.vuejs.org/) and [Vitesse](https://github.com/antfu/vitesse). If you are in need of a documentation site then VitePress is awesome, you can follow the VuePress documentation to fill in any gaps. VitePress abstracts away the Vite configuration, which will be limiting for non-documentation sites. Otherwise, I'd choose Vitesse as it's going to give you more flexible on customising your app. Vitesse offers a pre-configured `vite.config.js`, so you can easily strip anything out you don't need to add whatever you'd like to it. If you like my blog (VitePress + TailwindCSS), then you're more than welcome to [clone it](https://github.com/harlan-zw/harlanzw.com). ## Thanks for reading If you like the technical side of Vue and Laravel, I'll be posting regular articles on this site. The best way to keep up to date is by following me [@harlan_zw](https://twitter.com/harlan_zw). --- - **Page:** Building a Vue Auto Component Importer - A Better Dev Experience - **Source:** https://harlanzw.com/blog/vue-automatic-component-imports - **Description:** Components magically being imported into your app is the latest developer experience trend in Vue. Why does it exist and how does it work? When first learning Vue, you are taught you need to import and add components to `components` in the script block. ```vue ``` However, there's been a recent trend to "upgrade" the Vue developer experience (DX), having components magically import themselves at compile-time. ```vue ``` In the wild, you can find auto component imports in most popular Vue frameworks, as part of the core or a plugin. - [Nuxt Components](https://github.com/nuxt/components) - [Vuetify](https://github.com/vuetifyjs/vuetify-loader) - [Chakra](https://github.com/segunadebayo/chakra-ui) - [Vue CLI](https://github.com/harlan-zw/vue-cli-plugin-import-components) (built by me) - [Vite](https://github.com/antfu/vite-plugin-components) This article will look at: why automatic component imports exist, how you can easily build our own auto component importer using a Webpack loader and what the performance cost of using them has on your app. Finally, we'll look at some other compile-time DX upgrades that are possible. ## Why Automatic Component Imports? The *why* that comes first to my mind, is the developer experience is great. No more confusion or typos on import paths, refactoring becomes easier and there's less code overall. The unintuitive but equally great advantage is found in the problem that this feature first solved. The UI framework [Vuetify](https://vuetifyjs.com/) is a huge library of over 80 components, coming in at [99.4KB](https://cdnjs.cloudflare.com/ajax/libs/vuetify/2.3.17/vuetify.js) for their scripts. As far as I know, they were the first to introduce automatic component imports. ### Problem: UI Framework Bloat One of the complaints you'll hear about using a UI framework over something simple like [TailwindCSS](https://tailwindcss.com/), is the bloat it will add to your app. This is a valid concern. It's unlikely your application is going to need half the components that a UI framework has to offer. Forcing browsers to download code that will never run, dead code, is not ideal. Additionally, this component bloat can make import paths harder to work with and further scope for issues to pop up. So, how do Vuetify and other UI frameworks overcome their inherent bloat? ### Solution: webpack Optimisations As is the way, webpack is here to magically solve our problems with [tree shaking](https://webpack.js.org/guides/tree-shaking/) and [code splitting](https://webpack.js.org/guides/code-splitting/) optimisations. If tree shaking is new to you, you can think of it as an optimisation to remove code that isn't explicitly used. Banishing 'dead' code to the shadow realm. The tree shaking optimisation requires ES2015 module syntax, (i.e. `import` and `export`) and a production build. The code can't be compiled to CommonJS modules (i.e. `require`) for it to work. So how does all this relate to automatic component imports? With Vuetify handling the imports of your components (*[à la carte](https://vuetifyjs.com/en/features/treeshaking/)* as they call it), they can ensure webpack optimisations are running out of the box for your app with their component library. > The A la carte system enables you to pick and choose which components to import, drastically lowering your build size. > This will also make code-splitting more effective, as webpack will only load the components required for that chunk to be displayed. ## Fundamental: How Does webpack Load Vue Files? Before we jump into building our own automatic component importer, we'll need to have a basic understanding of how webpack loads Vue files. When you request a resource (such as a file) in webpack, it pushes the request through a pipeline of webpack loaders to resolve the output. A webpack loader is a piece of code which will transform a resource from one thing into another, it has an `input` and `output`. For example, the [raw-loader](https://v4.webpack.js.org/loaders/raw-loader/) will read a file and give you the string contents. The `input` is a path to a file in your filesystem, the `output` is the string contents of the file. ```js import txt from 'raw-loader!./hello.txt' // txt=HelloWorld ``` The `vue-loader` is the loader for `.vue` files. The loader compiles and bundles your component Single File Component (SFC) into code that the browser can understand and run. ### Vue Loader in Action Let's take a look at an example of input and output from the vue-loader. #### Input: App.vue This is the default entry file for Vue CLI with Vue 3. ```vue ``` #### Output: App.vue Internally, the loader parses this code using the compiler, getting an SFC descriptor object that is used to create the final string output of the loader. ```js import script from './App.vue?vue&type=script&lang=js' import { render } from './App.vue?vue&type=template&id=7ba5bd90' import './App.vue?vue&type=style&index=0&id=7ba5bd90&lang=css' export * from './App.vue?vue&type=script&lang=js' script.render = render script.__file = 'src/App.vue' export default script ``` Note: I've removed the Hot Module Reloading (HMR) code for simplicity here. The output of the loader isn't that important to understand, just know that the vue-loader has an in and out function. The output is usually parsed to another loader such as [babel-loader](https://github.com/babel/babel-loader) before being chunked. ## Building an Automatic Component Importer If you have some spare time, I'd encourage you to join along. You can use [Vue CLI](https://cli.vuejs.org/) with the Vue 3 preset. ```shell vue create auto-component-importer -p __default_vue_3__ ``` To begin, let's remove the manual import from the entry SFC, like so: ### New App.vue ```vue [App.vue] ``` When we load our `App.vue`, the `HelloWorld` doesn't work, as expected. Our goal is to get it to work without touching the Vue code. ### Step 1. Modify the webpack Configuration We need to make sure the loader we'll be making is going to run after the vue-loader. ```js [./vue.config.js] module.exports = { chainWebpack: (config) => { config.module .rules .get('vue') .use('components') .loader(require.resolve('./imports-loader')) .before('vue-loader') .end() } } ``` If you'd like to see the raw webpack config example, open the below.
webpack.config.js example
```js [webpack.config.js] module.exports = { // ... module: { rules: [ { test: /\.vue$/, loader: 'vue-loader' } ] } } ``` Knowing that webpack loaders are loaded from bottom to top, we would modify the configuration as so: ```js [webpack.config.js] module.exports = { // ... module: { rules: [ { test: /\.vue$/, use: [ { loader: require.resolve('./imports-loader'), }, { loader: 'vue-loader', } ] }, ] } } ``` ::tip Normally a webpack would handle this configuration changing for you. :: Now we create the loader called `imports-loader.js` in your apps root directory. We're going to make sure we only run it for the virtual SFC module. ```js [imports-loader.js] module.exports = function loader(source) { // only run for the virtual SFC if (this.resourceQuery) return source console.log(source) return source } ``` The `source` variable is the output of the vue-loader, the [Output: App.vue](#output-app-vue). Here we can now change anything about how our components work by modifying the vue-loader output. ### Step 2. Dumb Compile-Time Import As a proof of concept, let's try to import the `HelloWorld.vue` component so our [New App.vue](#new-app-vue) works. At this stage, we can just append the import code on to the `source`. ```js [imports-loader.js] module.exports = function loader(source) { // only run for the virtual SFC if (this.resourceQuery) return source return `${source} import HelloWorld from "@/components/HelloWorld.vue" script.components = Object.assign({ HelloWorld }, script.components) ` } ``` Your `App.vue` now knows what the HelloWorld component is and works. Try it yourself. Note: This is a *dumb* solution, as it will be modifying `HelloWorld.vue` to also import itself. ### Step 3. Making it smart A smarter solution would give us the ability to add components to our component folder and use them straight away without any imports. #### a. Scan components The first step in making it smarter is we need to create a map of the components files we want to automatically import. We recursively iterate over the components folder and do some mapping. ```js [a. Scan components] const base = './src/components/' const fileComponents = (await globby('*.vue', { cwd: base })).map((c) => { const name = path.parse(c).name const shortPath = path.resolve(base).replace(path.resolve('./src'), '@') return { name, import: `import ${name} from "${shortPath}/${c}"` } }) // [ { name: 'HelloWorld', import: 'import HelloWorld from "@/components/HelloWorld.vue"' } ] ``` #### b. Find the template tags To understand what components are being used, we need to have our new loader to compile the SFC `