npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ryegaspar/wirevue

v0.1.2

Published

Vue 3 helpers for Laravel back ends.

Readme

@ryegaspar/wirevue

Vue 3 helpers for Laravel back ends.

Every runtime helper is available from the package root as a named export:

import { useForm, useDatatable, useFilters, iconOptions } from '@ryegaspar/wirevue'

Each helper is also published as its own subpath, and those are the narrower surface: naming a module directly resolves only that module's graph, so importing one never pulls in the dependencies of another. Use them when you want one helper and not the rest.

import useForm from '@ryegaspar/wirevue/form'
import useDatatable from '@ryegaspar/wirevue/datatable'
import useFilters from '@ryegaspar/wirevue/filters'
import iconOptions from '@ryegaspar/wirevue/icons'

Both spellings resolve to the same bindings — useForm from the root is the same function as the default export of @ryegaspar/wirevue/form, and a RequestError caught from either is the same class, so an instanceof check holds whichever way the helper was imported.

The root entry has no default export: four modules each have one and a barrel can carry a single default, so every name it exports is a named export. The Vite plugin is not on it either — @ryegaspar/wirevue/icons/vite runs in Node, and reaching it through an entry the browser bundle imports would pull a build-time module into that bundle. It stays on its own subpath.

Requirements

axios and vue are peer dependencies — install them in the consuming application:

npm install axios vue

They are peers rather than dependencies on purpose. A nested copy of axios would be a different object from the one the application configured, and the guarantee below would quietly stop holding.

The Font Awesome packages are peers too, and optional — only @ryegaspar/wirevue/icons uses them, and an application that does not is not asked to install them. See @ryegaspar/wirevue/icons.

Requests go through your default axios instance

Every request this package makes — a form submit, a table's page of rows — is sent on axios itself — the default instance, the same one you get from import axios from 'axios'. Your interceptors already cover them. A 401/419 handler registered in the application's bootstrap applies to form submits without being told to, as do axios.defaults, the XSRF cookie handling, and any request or response interceptor.

This package never writes to axios.defaults. Anything a request needs goes in that request's own config, so nothing it does can change requests it did not make.

Every submit carries two headers, in its own config rather than on the instance:

Accept: application/json
X-Requested-With: XMLHttpRequest

They are what a Laravel back end reads to answer with a 422 body instead of a redirect, so a brand-new application needs no bootstrap of its own for validation errors to arrive. A request interceptor still has the last word if you want to change them.

To send on a different instance — another baseURL, an isolated interceptor chain, or a double under test — pass one:

const form = useForm({ email: '' }, { client: myAxiosInstance })
const dt = useDatatable(route, {}, 'id', { client: myAxiosInstance })

@ryegaspar/wirevue/form

import useForm from '@ryegaspar/wirevue/form'

const form = useForm({
	name: '',
	email: ''
})

Every field is spliced onto the form as its own property, so form.email and form.post(...) sit on the same object and v-model="form.email" works directly.

<input v-model="form.email">
<small v-if="form.errors.email">{{ form.errors.email }}</small>

<button :disabled="form.processing || !form.isDirty">Save</button>

Submitting

get, post, put, patch and delete all take a route and return a handle to attach handlers to. get and delete send the fields as query parameters; the rest send a body.

form.post('/users')
	.onSuccess(data => close(data.id))
	.onValidation(() => focusFirstError())
	.onFailure(error => toast(error.message))
	.onFinally(() => refresh())

Each handler is named for the outcome it answers, so none of them has to test what it was given:

| Handler | Runs when | | --- | --- | | onSuccess(data) | The request succeeded. Receives the response body. | | onValidation(error) | A 422 carrying field errors. They are already recorded on form.errors before this runs, so register it only to do something further. | | onFailure(error) | Anything the form cannot fix by retyping: a conflict, a refused business rule, a 5xx, a request that never reached the server. A 422 that carries only a message lands here too. | | onFinally() | Every outcome, after the handler for that outcome. |

All four are optional and chainable. A failure with no onFailure attached is reported rather than lost, and a handler that throws does not stop the ones behind it.

error is a FormError: status and data are always present, so a request that never reached the server carries status 0 and an empty body rather than nothing at all.

It extends RequestError, the normalised failure every module in this package reports — the datatable's dtError is one too — so error instanceof RequestError holds whichever module the failure came from, and a shared handler can take either. @ryegaspar/wirevue/form exports both names.

To drive a request by hand, form.post('/users').promise settles for every outcome, validation included.

form.wasSuccessful is the same answer as a value rather than a callback, for rendering a success rather than acting on one:

<p v-if="form.wasSuccessful">Saved.</p>

It clears as the next submit starts, so the message comes down when the user submits again rather than a round trip later, and form.reset() clears it too.

Typing the response

Name the response body on the verb and both onSuccess and promise carry it:

interface CreatedUser {
	id: number
	name: string
}

form.post<CreatedUser>('/users')
	.onSuccess(user => close(user.id))   // user is CreatedUser

const user = await form.put<CreatedUser>('/users/1').promise

The type argument is optional and defaults to any, so a call site that reaches into the payload without naming a type keeps working.

Double submits

A submit made while one is already in flight is dropped, not queued and not cancelled: no second request goes out. The handle you get back runs onFinally and no outcome handler — the submit still in flight will report its own result — and its promise rejects, so anything awaiting it resumes rather than hanging.

Cancelling instead would not help. Aborting only stops the browser listening; the handler on the server runs to completion either way, so the two would overlap and both take effect while you saw only the second one's answer.

Options

form.post('/users', {
	resetAfterSuccess: true,
	dataOverrides: { role: 'admin' }
})

resetAfterSuccess restores the values the form was built with; by default it keeps them and takes what was submitted as the new baseline. dataOverrides is merged over the fields for that one request. An option set to undefined means "not specified", so forwarding an optional variable leaves the default in place.

only sends a subset — one input saved on blur, or one section of a longer form — and leaves every other field where it is:

form.patch(`/users/${id}`, { only: [ 'email' ] })

The names are typed against the form's own fields, so a misspelt one is a compile error, and a name the form has no field for is refused at the call rather than sent as a payload quietly missing it. It applies to that request alone: there is no partial mode to leave switched on and narrow the next full save.

Two further options, forceFormData and onUploadProgress, belong to file uploads and are covered below.

Reshaping the payload

transform rewrites the body on its way out without touching the fields your inputs are bound to, for the endpoint whose shape is not the form's:

form.transform(data => ({
	name: `${data.firstName} ${data.lastName}`,
	role: data.role ?? 'member'
})).post('/users')

The callback receives a detached copy of the values, so editing it in place cannot reach the fields. Those go on rendering, and isDirty goes on answering, for what the user actually sees.

It stays installed until another call replaces it — it describes the endpoint, not one request — and reset() leaves it alone. Assembling the body at the call site instead costs you the file detection, the null handling and the _method spoofing that only happen on the way through a submit.

File uploads

Put a File on a field and submit as usual. A file anywhere in the payload — on a field, in an array, nested inside an object — sends the body as multipart/form-data instead of JSON, and axios does the encoding: nested fields arrive as a[b], arrays as a[], dates as ISO strings.

<input type="file" @change="form.photo = $event.target.files[0]">
form.put(`/people/${id}`)   // sent as POST with _method=put, see below
	.onSuccess(() => toast('Saved'))

Four things happen that are worth knowing about:

  • A put or patch carrying a file goes out as a POST with _method in the body. PHP fills $_POST and $_FILES from the body of a POST and of nothing else, so a multipart PUT reaches Laravel with every field empty. Laravel reads _method and routes it as the verb you asked for. A put with no file is left alone — a JSON body parses on any verb.
  • A null field is sent as an empty string. axios drops nulls from a multipart body entirely, which would make "clear my bio" silently keep it. Laravel's ConvertEmptyStringsToNull middleware turns the empty string back into a null on arrival, so the submit means the same thing either way.
  • get and delete refuse a file rather than serialising one into a query string, which has nowhere to put it. The submit throws, naming the fields that hold one.
  • A file field's dirtiness is decided by what the file is — its name, size, type and modification date — because the baseline holds a clone rather than the same object. Choosing a different file marks the form dirty; choosing the same one again does not.

forceFormData: true sends a multipart body with no file in the payload, for an endpoint that expects one either way:

form.post('/people', { forceFormData: true })

Progress

form.progress is null unless a multipart submit is in flight, and 0 to 100 while one is:

<progress v-if="form.progress !== null" :value="form.progress" max="100" />

Only a multipart submit moves it. A JSON body is gone in a single chunk, and a bar that flashed 0 to 100 on every ordinary submit would be reporting nothing. For the bytes themselves, pass onUploadProgress and receive axios's own event:

form.post('/people', { onUploadProgress: e => console.log(e.loaded, e.total) })

Uploads are a browser feature here. In Node — an SSR pass, a test outside a browser environment — axios builds its multipart body with the form-data package, which takes streams and buffers rather than web Files, and rejects one.

Errors

form.errors.email          // first message for the field, or undefined
form.errors.get('email')   // the same, for a key a property cannot reach
form.errors.first('photos')// the message for the field or for anything beneath it
form.errors.any()          // is anything recorded
form.errors.all()          // every recorded message, keyed by field
form.errors.clear()        // discard them

Laravel's 422 field errors are recorded against the fields that produced them. Each submit clears what the last one recorded.

The messages are held as properties of form.errors and nowhere else, so one you assign yourself — form.errors.email = 'Already taken.' — renders and counts in any() and all() exactly like a recorded one, and is cleared by the same clear().

A 422 only reaches onValidation if it left a message on at least one field. One that names no field, or names fields with no message, is a failure carrying the response's own message.

Messages about a value inside an array

Laravel names a message after the value that failed, so a rule on an array arrives under a dotted key — photos.2, or items.0.name — and that is the key it is stored under. Read one directly when the index is in hand, which is what a v-for has:

<small>{{ form.errors[`photos.${i}`] }}</small>

When it is not — the server decides which entry failed — ask the field for it. first() answers with the message for the field itself or for anything beneath it, and takes the dot into account, so photos never answers for photos_url:

form.errors.first('photos')   // 'photos', 'photos.0', 'photos.2.size', …

Dotted keys are typed by their shape rather than listed, since the index only exists at runtime. A field you declared is still spell-checked: form.errors.emial is a compile error, photos.0 is not.

Dirtiness

form.isDirty             // does anything differ from the baseline
form.reset()             // back to the values the form was built with
form.setInitialData({ name: 'Ada' })

The baseline is what the form was constructed with, re-taken after every successful submit. The comparison is structural and handles Dates, nested objects and arrays, and values that refer back to themselves.

isDirty is cached and recomputed whenever a field changes — as long as something writes to the field. A component that only writes its model on blur will leave the form clean while the user is still typing, which is a property of that component rather than of the form. A write that steps around reactivity on purpose, through toRaw(form), is the same case: nothing marks the cached answer stale, so it stands until the next ordinary change.

A field cannot be named after the API

Fields are set on the form itself — that is what lets form.email and form.post(...) be reads of one object — so a field named after a member would be that member. useForm throws on the collision rather than letting it through:

[Form] A field cannot be named after a member of the form or its errors: data. …

The names it refuses, which are every member of the form and of its error bag:

data, defaultData, initialData, rawData, errors, processing, progress, isDirty, wasSuccessful, reset, setInitialData, setCurrentDataAsInitial, transform, submit, bodyConfig, get, post, put, patch, delete, record, clear, first, any, all, constructor, and the internals _client, _processing, _dirtyMemo, _progress, _transform and _wasSuccessful.

The error bag's names are in there because Laravel keys its messages by field name and the bag stores each message as a property under that key, so a field named clear would leave form.errors unable to clear itself.

Only keys present at construction are ever submitted, so add every field up front, even the empty ones.

@ryegaspar/wirevue/datatable

A server-driven list: it holds the query parameters, asks for a page, and keeps the rows it got back so the call site can edit them in place after a save.

import useDatatable from '@ryegaspar/wirevue/datatable'
import { index } from '@/routes/bookings'

const dt = useDatatable(index, { sort: '-created_at' }, 'id')

| Argument | Type | What it is | |------------|-------------------------------|--------------------------------------------------------| | route | { url(options?) => string } | Anything that builds a URL from { mergeQuery } | | args | Record<string, any> | Query parameters the first request starts from | | identity | keyof T | The field that tells one row from another, e.g. 'id' | | options | { client? } | An axios instance, if not the default one |

The route argument is a shape, not a type imported from anywhere: a Wayfinder route object satisfies it, and so does { url: params => '/bookings?' + new URLSearchParams(params.mergeQuery) }.

No component library is required. The event type the table passes to change is declared by this package, wide enough that a PrimeVue DataTable's @page, @sort and @filter can all be bound straight to it, and plain enough that anything else can hand over an object.

<DataTable
	lazy
	:value="dt.dtData.value"
	:loading="dt.dtLoading.value"
	:totalRecords="dt.dtTotalRecords.value"
	:rows="dt.dtNumRows.value"
	@page="dt.change"
	@sort="dt.change"
	@filter="dt.change"
/>

The request

The first load happens on mount, and every later one is triggered by change or by calling loadData() yourself. What goes out is dtParams: per_page, page, whatever args held, and a filter[name] key per filter.

The response is read as Laravel's paginated resource envelope — data for the rows, meta.total for the count. Anything else in the body lands in dtExtra untouched, which is where an endpoint's totals or summary counts arrive. An endpoint that answers without meta leaves the total null rather than undefined.

Only the newest request is allowed to write. Two loads started close together settle in whatever order the server answers, so an older answer arriving late is discarded instead of replacing rows the user has already moved on from.

A request that fails leaves the rows as they were, lowers dtLoading, and records why in dtError. It is not rethrown: nothing awaits a load, so a rejection could only surface as an unhandled one, and by then the application's own interceptors have already seen the status.

<DataTable v-if="!dt.dtError.value || dt.dtData.value.length" :value="dt.dtData.value" />
<Message v-else severity="error">
	{{ dt.dtError.value.message }}
	<Button label="Try again" @click="dt.loadData()" />
</Message>

dtError is null until something goes wrong, and null again as soon as the next attempt starts — so it describes the newest attempt rather than the last one to have gone wrong, and a retry never shows a spinner and a failure at the same time. It matters most on the opening load: without it a table that never heard back looks exactly like a table with no rows, and says so.

The value is a RequestError, the same normalised failure the form module's FormError extends: status is 0 when the request never reached the server, data is the response body or {}, and cause is the rejection it was built from. One class serves both modules, so a call site holding failures from a submit and a load can test them against a single type.

State

Every one of these is readonly — the composable writes them, the template reads them.

| Name | Type | What it holds | |------------------|-------------------------|-----------------------------------------| | dtData | Ref<T[]> | The rows currently on screen | | dtLoading | Ref<boolean> | Whether a request is in flight | | dtTotalRecords | Ref<number \| null> | The count the last response reported | | dtNumRows | Ref<number> | The page size the next request will use | | dtParams | Record<string, any> | The live query parameters | | dtExtra | Ref<object> | Whatever the envelope carried beside the page | | dtAppending | Ref<boolean> | Whether the load in flight is adding rather than replacing | | dtHasMore | Ref<boolean> | Whether there are rows left to fetch | | dtError | Ref<RequestError \| null> | Why the last load failed, or null |

loadMoreTrigger comes back too, and is the one thing here that is not readonly: it is a template ref for the element at the end of the list.

Methods

| Name | Signature | What it does | |------------------------|----------------------------------------|--------------------------------------------------| | loadData | (append?) => Promise<boolean> | Fetch again with the current parameters; answers whether it landed | | loadMore | () => Promise<void> | Fetch the next page and add it to the rows | | change | (event) => void | Apply a page, sort or filter event, then fetch | | setFilters | (filters) => void | Write filters into the parameters without fetching | | changeRowAt | (index, data, animate?) => void | Merge fields into one row, marking it by default | | insertRowAt | (index, row, animate?) => void | Add a row, unmarked by default | | removeRowAt | (index) => void | Take a row out immediately | | updateExtra | (data) => void | Merge into dtExtra | | rowEffectClass | (row) => string[] | The effect classes active on a row | | flashRowAt | (index, severity, duration?) => void | Mark a row by severity for a moment | | removeRowAtAnimated | (index) => void | Mark a row on its way out, then remove it | | flashThenRemoveRowAt | (index, severity) => void | Flash, then remove |

change starts again at page one for anything that is not a page event, because the row that was at offset 200 is not the same row once the ordering or the filtering changed.

Editing rows after a save

The point of holding the rows is that a save does not need a reload. Find the row, write the answer into it, and the table updates with the change marked:

form.put(`/bookings/${booking.id}`)
	.onSuccess(data => {
		const index = dt.dtData.value.findIndex(row => row.id === booking.id)

		if (index !== -1) dt.changeRowAt(index, data)
	})

If the save takes the row out of the current view — approving something in a "pending" filter — flash it and let it go instead:

dt.flashThenRemoveRowAt(index, 'success')

An effect is scheduled against the row rather than the index it was at, so rows arriving or leaving while an animation runs cannot make it hit the wrong one.

Row effects need your CSS

rowEffectClass(row) returns class names and nothing else — this package ships no stylesheet. Bind it and write the animations yourself:

<tr v-for="row in dt.dtData.value" :key="row.id" :class="dt.rowEffectClass(row)">

| Class | Applied by | Removed after | |---------------------|----------------------------------|--------------------------| | row-glow | changeRowAt / insertRowAt | 2000 ms | | row-flash-<severity> | flashRowAt(i, severity) | 700 ms, or the duration you pass | | row-removing | removeRowAtAnimated | 380 ms, then the row goes |

Severities are success, danger, warn and info. The class comes off the row when the timer ends, cutting off anything still animating — so a stylesheet whose animation runs longer than the figure above will snap rather than finish.

A server-rendered first page

Pass the page the server already produced as initial, and the table starts populated and skips its opening request:

const dt = useDatatable(index, { initial: props.bookings }, 'id')

It wants { data, total, per_page }. The page size comes from the server rather than the default so that the page it rendered and the page the next request asks for cannot disagree and skip or repeat rows. initial is held out of the query parameters, so it never reaches the URL.

Filters

change accepts either a table's filter model or bare values, and writes each one as filter[name]:

dt.change({ filters: { status: { value: 'pending' } } })
dt.change({ filters: { status: 'pending' } })

A filter set to null stays in the parameters as null rather than being dropped from them, which is what clears it: a key simply left out is indistinguishable from one that was never set, and the previous value would go on being sent.

setFilters does the same without fetching, for seeding defaults during setup — going through change there would fire a second request before the first has landed.

Both are what @ryegaspar/wirevue/filters drives; see below if you want the filter state held for you.

Infinite scroll

Turn it on with infiniteScroll, and bind loadMoreTrigger to an element at the end of the list. When that element comes on screen the next page is fetched and added to the rows already there.

<script setup>
const {
	dtData, dtLoading, dtAppending, dtHasMore, loadMoreTrigger
} = useDatatable(index, { infiniteScroll: true }, 'id')
</script>

<template>
	<article v-for="row in dtData" :key="row.id">{{ row.title }}</article>

	<div v-if="dtHasMore" ref="loadMoreTrigger" class="h-20">
		<Spinner v-if="dtLoading && dtAppending" />
	</div>
</template>

Destructured like that, the template unwraps each ref for you and ref="loadMoreTrigger" binds the element to the one that is writable.

infiniteScroll is a setting rather than a query parameter, so — like initial — it is held out of the URL.

dtAppending is what tells the two kinds of load apart. Both raise dtLoading: one is fetching the next page, where the rows on screen are still right and a spinner belongs under them; the other is a filter or a sort, where the rows are about to be replaced and a skeleton is the honest thing to show.

The list keeps filling while the sentinel is on screen, so a first page too short to reach the bottom of the window fetches the next one immediately rather than stalling until the reader scrolls. It stops when the count is reached, and also when a page comes back empty — the total was taken by an earlier query, and a row deleted since would otherwise leave a target that can never be reached and a request that repeats forever.

Watch the sentinel through the ref rather than fetching it once at mount, which the example above does by keeping the v-if: the element is destroyed and rebuilt as the list completes and grows again, and each new one is picked up.

A page that fails puts the page number back — a page silently skipped leaves a gap in the middle of a list with no way to notice one — and stops the automatic fetching rather than retrying as fast as the loading flag clears. Scrolling away and back asks again.

Infinite scroll needs meta.total in the response. Without it there is no count to compare against, and the list ends after its first page.

@ryegaspar/wirevue/filters

Filter state for a datatable: it holds what the controls are showing, decides which filters are active, and reloads the table when one settles.

import useDatatable from '@ryegaspar/wirevue/datatable'
import useFilters from '@ryegaspar/wirevue/filters'
import { index } from '@/routes/bookings'

const dt = useDatatable(index, {}, 'id')

const filters = useFilters(dt, {
	search: { default: '', debounce: 500 },
	status: {
		default: 'all',
		transform: value => (value === 'all' ? null : value)
	},
	assignees: { default: [] }
})

Bind the values straight to your controls — everything else follows from a write to one:

<InputText v-model="filters.values.search" />
<Select v-model="filters.values.status" :options="statuses" />
<Button v-if="filters.activeCount" label="Clear" @click="filters.reset()" />

The first argument is anything with change and setFilters — a useDatatable satisfies it, and so does an object of your own with those two methods. Neither module imports the other.

Defining a filter

| Key | Type | What it does | |-------------|----------------------|-----------------------------------------------------------| | default | any | The opening value, and what reset() restores | | debounce | number | Milliseconds to wait after the last change; omit to reload on the change itself | | transform | (value) => unknown | What to send for what the control holds |

default types the field, so transform receives the value rather than unknown, and filters.values reads back the types you defined.

A filter is inactive when its value is null, undefined, an empty string or an empty array — after the transform, if there is one. An inactive filter is sent as null, which is how a cleared filter is dropped from the query rather than left behind filtering. That is what transform is usually for: an "any status" option is the absence of a parameter, not a parameter meaning anything.

status: {
	default: 'all',
	transform: value => (value === 'all' ? null : value)
}

A value that is falsy but not empty — 0, false — is an active filter, and is sent.

What you get back

| Name | Type | What it is | |---------------|----------------------------|---------------------------------------------------| | values | the shape you defined | The live values, for v-model | | activeCount | number | How many filters are carrying a value | | isActive | (key) => boolean | Whether that one is | | reset | () => Promise<void> | Every filter back to its default, one reload | | set | (values) => Promise<void>| Write several values, one reload |

reset() and set() reload once rather than once per filter, and they reload immediately whatever the debounce on any field they touch — a value your code set is not a value being typed. Both cancel a debounce still counting down, so a keystroke in flight cannot land afterwards and ask for the same page again.

The opening request

The defaults are written into the table's parameters as the composable is created, before the mount-time load, so the first page already reflects what the controls are showing. Create the filters in setup, alongside the table:

const dt = useDatatable(index, {}, 'id')
const filters = useFilters(dt, { status: { default: 'pending' } })

Created later — after the table has already loaded — the defaults still reach the parameters, but not the request that has already gone.

Debouncing

Each field has its own timer, so a slow one does not hold up a fast one, and each fires on the trailing edge: a search box sends one request from where the typing stopped, not one per keystroke. A pending timer is cancelled when the component unmounts, so a reload cannot go out for a table nobody is looking at. Outside a component there is no unmount to hook — call reset() or set(), both of which cancel, or let the timer run.

Values are watched deeply, so a control that mutates its array or object in place — pushing an id onto a selection rather than replacing it — reloads like any other change.

@ryegaspar/wirevue/icons

A build step for Font Awesome: name the icons an application uses, and the plugin generates the module that imports them and registers them with the library. There is no faicons.js to regenerate by hand and no list to keep in sync with it.

The Font Awesome packages are optional peer dependencies — install the ones the application draws from:

npm install @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons

Add the plugin, and name the icons:

// vite.config.js
import icons from '@ryegaspar/wirevue/icons/vite'

export default defineConfig({
	plugins: [
		icons({
			include: [
				'faShieldHalved',      // solid
				'far:faCircleCheck',   // regular
				'fab:faGithub'         // brands
			]
		})
	]
})

A bare name is solid; a prefix takes the icon from another package. A name cannot identify a package on its own — faBell is in both the solid and the regular set, and a filled bell and an outlined one are two different icons — so a bare name is never resolved by looking it up.

Import the result once per entry, for the side effect of registering the icons:

// app.js
import 'virtual:wirevue/icons'

Nothing is written to your tree. The module is generated in memory on every dev start and every build, so there is no generated file to gitignore, diff, or find stale. An include array written inline in vite.config.js is picked up as you edit it: Vite restarts the dev server when its own config changes.

Nothing is included that you did not name. The application's icon weight is a function of that list and nothing else.

A starting set

starterIcons is a general-purpose set of 263 icons — 246 solid, 15 regular, 2 brand — already in the spelling include takes. Spread it in and add what the application needs on top:

import icons, { starterIcons } from '@ryegaspar/wirevue/icons/vite'

icons({
	include: [
		...starterIcons,
		'faShieldHalved',
		'far:faCircleCheck'
	]
})

Nothing is applied on its own — leave it out and you get only the names you wrote. It ships from @ryegaspar/wirevue/icons/vite rather than @ryegaspar/wirevue/icons because it is configuration: it is read where the plugin is configured, and a browser bundle should never carry a few hundred strings it has no use for.

Types

Reference the ambient declaration once, from a file your tsconfig already includes:

// env.d.ts
/// <reference types="@ryegaspar/wirevue/icons/virtual" />

Unknown names fail the build

A name no package exports fails at the start of the build, or at dev-server start, naming the entry and the package it was looked for in — and reporting every bad entry at once rather than one per run:

Cannot build the icon library:
  - 'faShieldHalfed' is not exported by @fortawesome/free-solid-svg-icons.

Options for a picker

@ryegaspar/wirevue/icons turns the arrays the generated module exports into a list to show a person:

import { solidIcons, regularIcons, brandIcons } from 'virtual:wirevue/icons'
import iconOptions from '@ryegaspar/wirevue/icons'

const options = iconOptions(solidIcons, regularIcons, brandIcons)
[
	{ fontFull: 'fa-solid fa-bell', fontShort: 'fas fa-bell', name: 'Bell' },
	{ fontFull: 'fa-regular fa-circle-check', fontShort: 'far fa-circle-check', name: 'Circle Check' }
]

| Name | What it is | |-------------|-------------------------------------------------------------------| | fontFull | The full style name and the icon: fa-solid fa-bell | | fontShort | The same icon in Font Awesome's shorthand: fas fa-bell | | name | The icon's name, spelled for display: Circle Check |

Groups are taken in the order you pass them, and each group keeps its own order, so the list reads the way you asked for it. The result is frozen — nothing about it changes after it is built, and Vue leaves a frozen object alone rather than walking several hundred options to make them reactive.

Server-side rendering

The plugin sets ssr.noExternal for @fortawesome/*. Left external, an SSR build gives Font Awesome two copies of its library — the generated module fills one and the component reads the other, and every icon renders as "Could not find".

Development

npm test           # vitest run
npm run typecheck  # tsc --noEmit
npm run lint       # eslint .
npm run build      # ESM bundle + .d.ts into dist/