Tags Plugin
A polymorphic tagging plugin for Svelar/SvelteKit with typed tags, automatic slug generation, tag merging, popularity ranking, and pre-built UI components for tag input, display, and management. Inspired by Spatie's Laravel Tags package.
Package: @beeblock/svelar-tags
Install:
npx svelar plugin:install @beeblock/svelar-tags
Imports:
// Server-only plugin registration
import SvelarTagsPlugin from '@beeblock/svelar-tags/plugin';
// Server-only models, service, mixin, controller, and gate names
import { Tag, TagService, HasTags, TagController, tagAbilities } from '@beeblock/svelar-tags/server';
// Public utility and shared validation contracts
import { slugify } from '@beeblock/svelar-tags';
import { tagCreateSchema, tagRelationSyncSchema, tagTargetSchema } from '@beeblock/svelar-tags/schemas';
// UI components
import { TagInput, TagBadge, TagList } from '@beeblock/svelar-tags/ui';
// Types
import type { TagRecord, TaggableId, TagCreateOptions, TagWithCount, TagsPluginConfig, TagInputItem } from '@beeblock/svelar-tags';
import type { HasTagsInstance } from '@beeblock/svelar-tags';
Quick Start
1. Register the Plugin
// src/lib/plugins.ts
import SvelarTagsPlugin from '@beeblock/svelar-tags/plugin';
export const tagsPlugin = new SvelarTagsPlugin({
prefix: '/api',
slugSeparator: '-',
});
Pass this instance to your application's plugin manager with plugins.use(tagsPlugin), or enable the plugin through your configured plugin bootstrap. The public package barrel contains utilities and types; Tag, TagService, HasTags, controllers, and the plugin class belong in server-only code. The named SvelarTagsPlugin export also remains available from /server.
2. Run the Migration
npx svelar plugin:publish @beeblock/svelar-tags
npx svelar migrate
The plugin publishes separate Svelar Migration classes to src/lib/database/migrations/00000000_000101_create_tags_table.ts and 00000000_000102_create_taggables_table.ts. Run them through Svelar's migrator; table creation does not belong in routes, services, or application bootstrap. MySQL requires the collation described in Migration SQL.
3. Add Tags to a Model
import { Model } from '@beeblock/svelar/orm';
import { HasTags } from '@beeblock/svelar-tags/server';
class Post extends HasTags(Model) {
static table = 'posts';
}
const post = await Post.findOrFail(1);
await post.attachTags(['typescript', 'svelte', 'sveltekit']);
await post.syncTagsOfType('category', ['tutorial', 'guide']);
Configuration
The SvelarTagsPlugin constructor accepts:
| Option | Type | Default | Description |
|---|---|---|---|
prefix |
string |
'/api' |
Local absolute API prefix; controls registered routes and the published collection route path |
slugSeparator |
'-' |
'-' |
Stored tag slugs use a canonical hyphen; other constructor values are rejected |
Core API
Tag Class
Tag extends Svelar Model. Its helpers persist through the ORM and return model instances:
import { Tag } from '@beeblock/svelar-tags/server';
| Method | Returns | Description |
|---|---|---|
Tag.findById(id) |
Promise<Tag | null> |
Find a numeric tag ID |
Tag.findByName(name, type?) |
Promise<Tag | null> |
Find by name (auto-slugified) |
Tag.findBySlug(slug, type?) |
Promise<Tag | null> |
Find by slug |
Tag.findOrCreate(name, type?) |
Promise<Tag> |
Find existing or create new |
Tag.create(attributes) |
Promise<Tag> |
Create from { name, type?, order_column? } |
Tag.create(name, type?, orderColumn?) |
Promise<Tag> |
Retained string-based creation helper |
Tag.all() |
Promise<Tag[]> |
Get model instances; use TagService.all() for ordered records |
Tag.ofType(type) |
Promise<Tag[]> |
Get model instances ordered by order_column, name |
Tag.update(id, data) |
Promise<Tag | null> |
Update a tag (name, type, order_column) |
Tag.delete(id) |
Promise<void> |
Delete a tag by ID |
Tag.usageCount(id) |
Promise<number> |
Count models using this tag |
TagService
Higher-level operations beyond basic CRUD:
import { TagService } from '@beeblock/svelar-tags/server';
| Method | Returns | Description |
|---|---|---|
TagService.all() |
Promise<TagRecord[]> |
Get tags ordered by order_column, name |
TagService.ofType(type) |
Promise<TagRecord[]> |
Get tags of a type |
TagService.popular(limit?) |
Promise<TagWithCount[]> |
Get most-used tags with usage_count |
TagService.rename(oldName, newName, type?) |
Promise<TagRecord | null> |
Rename a tag (updates slug) |
TagService.merge(sourceNames, targetName, type?) |
Promise<TagRecord> |
Merge multiple tags into one (reassigns relationships) |
TagService.deleteUnused() |
Promise<number> |
Delete tags not attached to any model |
TagService.search(query, type?, limit?) |
Promise<TagRecord[]> |
Search tags by name (partial match, LIKE) |
TagService.attach(taggableType, taggableId, names, type?), detach(...), and sync(...) provide the same relationship operations used by HasTags. taggableId accepts string | number; the service does not authorize a host model for its caller. Use it behind your authorized FormRequest/DTO/action flow. Attach, sync, and merge run in transactions, and persistence failures propagate instead of being treated as duplicate attachments.
Tag merging example:
// Merge 'js', 'javascript', 'ecmascript' into 'javascript'
const merged = await TagService.merge(
['js', 'ecmascript'],
'javascript',
);
// All models previously tagged with 'js' or 'ecmascript' now have 'javascript'
HasTags Mixin
Adds tagging methods to any Svelar Model:
import { Model } from '@beeblock/svelar/orm';
import { HasTags } from '@beeblock/svelar-tags/server';
class Article extends HasTags(Model) {
static table = 'articles';
}
The mixin reads the host key with getAttribute(ctor.primaryKey). It supports safe integer, UUID, ULID, and other non-empty string keys up to 255 characters, including custom primary-key column names. Numbers are stored as canonical decimal strings in taggables.taggable_id; string keys retain their case, leading zeros, Unicode, and significant spaces. Tag row IDs and taggables.tag_id remain numeric. Do not convert a UUID, ULID, or numeric-looking string key with Number().
For a generated UUID on a custom key column:
import { Model, HasUuids } from '@beeblock/svelar/orm';
import { HasTags } from '@beeblock/svelar-tags/server';
class UuidArticle extends HasTags(HasUuids(Model)) {
static table = 'articles';
static primaryKey = 'article_uuid';
static uniqueIds = ['article_uuid'];
static fillable = ['article_uuid', 'title'];
}
// The app's migration declares article_uuid as its UUID primary key.
const article = await UuidArticle.findOrFail(articleUuid);
await article.attachTag('svelte');
HasUlids follows the same pattern with an app-owned ULID column. A missing, fractional, or unsafe numeric key is rejected before tags are created.
Instance methods:
| Method | Returns | Description |
|---|---|---|
attachTag(name, type?) |
Promise<void> |
Attach a single tag (creates if needed) |
attachTags(names, type?) |
Promise<void> |
Attach multiple tags |
detachTag(name, type?) |
Promise<void> |
Detach a single tag |
detachTags(names, type?) |
Promise<void> |
Detach multiple tags |
syncTags(names, type?) |
Promise<void> |
Replace all tags, or only the supplied type; [] clears that selection |
syncTagsOfType(type, names) |
Promise<void> |
Replace only tags of a specific type |
tags() |
Promise<TagRecord[]> |
Get all tags for this model |
tagsOfType(type) |
Promise<TagRecord[]> |
Get tags of a specific type |
hasTag(name, type?) |
Promise<boolean> |
Check if model has a specific tag |
hasAnyTags(names, type?) |
Promise<boolean> |
Check if model has any of the given tags |
hasAllTags(names, type?) |
Promise<boolean> |
Check if model has all of the given tags |
Static query methods (filter models by tags):
// Get posts that have any of these tags
const posts = await Post.withAnyTags(['svelte', 'typescript']).get();
// Get posts that have ALL of these tags
const posts = await Post.withAllTags(['svelte', 'typescript']).get();
// Get posts that do NOT have any of these tags
const posts = await Post.withoutTags(['draft', 'archived']).get();
// With tag type filter
const posts = await Post.withAnyTags(['tutorial'], 'category').get();
Without a type argument, these query helpers match untyped tags. Duplicate normalized names do not increase the requirement for withAllTags; an empty list matches no models for withAnyTags and leaves the model query unchanged for withAllTags and withoutTags.
TagRecord
interface TagRecord {
id: number;
name: string;
slug: string;
type: string | null;
order_column: number;
created_at: string | null;
updated_at: string | null;
}
Server-Side
TagController
TagController extends Svelar Controller; its instance handlers run shared-schema FormRequests, DTOs, actions/services, ORM persistence, and resources. Static compatibility methods remain available. The following bindings are the controller layer; add your application's reviewed capability guard in the route or hook layer as described below.
// src/routes/api/tags/+server.ts
import { TagController } from '@beeblock/svelar-tags/server';
const controller = new TagController();
export const GET = controller.handle('index');
export const POST = controller.handle('store');
// src/routes/api/tags/[id]/+server.ts
import { TagController } from '@beeblock/svelar-tags/server';
const controller = new TagController();
export const GET = controller.handle('show');
export const PUT = controller.handle('update');
export const PATCH = controller.handle('update');
export const DELETE = controller.handle('destroy');
// src/routes/api/tags/popular/+server.ts
export const GET = async (event) => TagController.popular(event);
// src/routes/api/tags/merge/+server.ts
export const POST = async (event) => TagController.merge(event);
// src/routes/api/tags/unused/+server.ts
export const DELETE = async (event) => TagController.deleteUnused(event);
// src/routes/api/tags/attach/+server.ts
export const POST = async (event) => TagController.attach(event);
// src/routes/api/tags/detach/+server.ts
export const POST = async (event) => TagController.detach(event);
// src/routes/api/tags/sync/+server.ts
export const POST = async (event) => TagController.sync(event);
API endpoints:
| Method | Route | Handler | Description |
|---|---|---|---|
GET |
/api/tags |
index |
List tags (query params: type, search, limit) |
GET |
/api/tags/:id |
show |
Get a single tag with usage count |
POST |
/api/tags |
store |
Create a tag ({ name, type?, order_column? }) |
PUT |
/api/tags/:id |
update |
Update a tag |
PATCH |
/api/tags/:id |
update |
Partially update a tag |
DELETE |
/api/tags/:id |
destroy |
Delete a tag |
GET |
/api/tags/popular |
popular |
Get popular tags (query param: limit) |
POST |
/api/tags/merge |
merge |
Merge tags ({ sources: string[], target: string, type? }) |
DELETE |
/api/tags/unused |
deleteUnused |
Delete unused tags |
POST |
/api/tags/attach |
attach |
Attach tags ({ tags, type?, taggable_type, taggable_id }) |
POST |
/api/tags/detach |
detach |
Detach tags (same body as attach) |
POST |
/api/tags/sync |
sync |
Sync tags (same body as attach) |
The :id routes refer to numeric tag IDs. Relationship request bodies use taggable_type: string and taggable_id: string | number for the host model. Attach/detach require 1–100 tag names; sync accepts 0–100, so { tags: [], taggable_type, taggable_id } can clear a model's tags. Names must produce a non-empty slug and fit 255 characters; a tag update must contain at least one supported field. HTTP limit is an integer from 1 to 100, with defaults of 100 for the list and 10 for popularity.
Success responses use resource envelopes such as { data: tag }, { data: tags, meta: { total, limit }, total }, and { data: { message } } for relationship operations. Validation errors return 422 with field errors, duplicate create/update returns 409, and internal error details are not sent to the browser.
Application authorization and CSRF:
The app must establish the authenticated user and register its literal capability manifest, route bindings, and seeded catalogs/profiles. The plugin does not grant capabilities or seed roles. Define the action-specific Gate abilities below, including host policy/ownership checks for relationship targets; absent grants fail closed.
| Controller methods | Gate ability | Subject supplied to the gate |
|---|---|---|
index, popular |
tags.viewAny |
No model |
show |
tags.view |
Persisted Tag model |
store |
tags.create |
No model |
update |
tags.update |
Persisted Tag model |
destroy |
tags.delete |
Persisted Tag model |
merge |
tags.merge |
No model |
deleteUnused |
tags.deleteUnused |
No model |
attach, detach, sync |
tags.attach, tags.detach, tags.sync |
Exact { taggable_type, taggable_id } target |
Gate callbacks also receive the request event after the subject. Catalog operations act on the plugin's tag catalog; decide their visibility and management access in the host app. A relationship gate must allow only known host types, resolve the host using its actual primary key, and authorize that model. For example, in your server bootstrap after defining your post policy/gate:
import { Gate } from '@beeblock/svelar/auth';
import { tagAbilities } from '@beeblock/svelar-tags/server';
import { tagTargetSchema } from '@beeblock/svelar-tags/schemas';
import { Post } from '$lib/models/Post.js';
Gate.define(tagAbilities.sync, async (user, target, event) => {
const parsed = tagTargetSchema.safeParse(target);
if (!parsed.success || parsed.data.taggable_type !== Post.tableName) return false;
const post = await Post.find(parsed.data.taggable_id);
return post !== null && await Gate.allows('posts.update', user, post, event);
});
Bind the functional capability through your app-owned authorizer and trusted principal resolver. These imports represent application code that must be configured and seeded, not exports supplied by the tags package:
// src/routes/api/tags/sync/+server.ts
import { TagController } from '@beeblock/svelar-tags/server';
import { capabilities, resolvePrincipal } from '$lib/shared/authorization.js';
const controller = new TagController();
export const POST = capabilities.guardEndpoint(
'posts.tags.update',
controller.handle('sync'),
resolvePrincipal,
);
Use equivalent capability bindings for other exposed routes, including routes registered by the plugin. The controller rejects anonymous requests with 401 and requests marked authenticatedVia === 'bearer' with 403. Bearer support requires a separate app-owned endpoint with verified credential ceilings and the same validated, policy-checked service flow; a capability wrapper does not enable bearer access to this controller.
Session mutations require the default XSRF-TOKEN cookie and X-CSRF-Token header (or the _csrf field); a missing/mismatched token returns 419. Svelar's same-origin apiFetch/apiFetchJson helpers attach the browser token. Keep the app's authentication, origin, and CSRF protections enabled.
UI Components
TagInput
An autocomplete tag input with suggestions, keyboard navigation, and inline tag creation:
<script lang="ts">
import { TagInput } from '@beeblock/svelar-tags/ui';
import type { TagInputItem } from '@beeblock/svelar-tags';
let selectedTags = $state<TagInputItem[]>([]);
</script>
<TagInput
value={selectedTags}
onchange={(tags) => selectedTags = tags}
suggestUrl="/api/tags"
allowCreate={true}
tagType="category"
placeholder="Add a tag..."
max={10}
debounceMs={250}
/>
| Prop | Type | Default | Description |
|---|---|---|---|
value |
TagInputItem[] |
[] |
Currently selected tags |
onchange |
(tags) => void |
undefined |
Callback when tags change |
suggestUrl |
string |
'/api/tags' |
URL to fetch suggestions (GET, expects { data: TagInputItem[] }) |
suggestions |
TagInputItem[] |
undefined |
Static list of suggestions (overrides suggestUrl) |
allowCreate |
boolean |
true |
Allow creating new tags inline |
tagType |
string | null |
null |
Tag type for newly created tags |
placeholder |
string |
'Add a tag...' |
Input placeholder text |
max |
number |
0 |
Max tags allowed (0 = unlimited) |
debounceMs |
number |
250 |
Debounce delay for search in ms |
label |
string |
'Tags' |
Accessible combobox label |
class |
string |
'' |
CSS class override |
value can also use bind:value. The label prop defaults to 'Tags' and labels the combobox. Suggestion requests are cancelled when superseded or closed, loading/failure feedback is shown inline, and Tab keeps normal focus navigation. Inline creation adds a temporary item with a negative tag ID; persist tag names through the authorized server flow before treating it as a stored tag. These temporary IDs are unrelated to host taggable_id values.
TagBadge
A single tag badge with optional remove button:
<script lang="ts">
import { TagBadge } from '@beeblock/svelar-tags/ui';
</script>
<TagBadge
name="svelte"
type="framework"
removable={true}
onremove={() => console.log('removed')}
/>
| Prop | Type | Default | Description |
|---|---|---|---|
name |
string |
required | Tag name to display |
type |
string | null |
null |
Tag type (adds a type badge and CSS modifier) |
removable |
boolean |
false |
Show remove button |
onremove |
() => void |
undefined |
Callback when remove is clicked |
class |
string |
'' |
CSS class override |
TagList
Display a list of tags as badges:
<script lang="ts">
import { TagList } from '@beeblock/svelar-tags/ui';
import type { TagInputItem } from '@beeblock/svelar-tags';
let tags: TagInputItem[] = [
{ id: 1, name: 'svelte', slug: 'svelte', type: null },
{ id: 2, name: 'typescript', slug: 'typescript', type: null },
];
</script>
<TagList
{tags}
removable={true}
onremove={(tag) => console.log('Remove:', tag.name)}
/>
| Prop | Type | Default | Description |
|---|---|---|---|
tags |
TagInputItem[] |
required | Array of tags to display |
removable |
boolean |
false |
Show remove buttons on each badge |
onremove |
(tag) => void |
undefined |
Callback when a tag is removed |
class |
string |
'' |
CSS class override |
Migration SQL
The supported installation path uses the two published Svelar migrations. TAGS_MIGRATION_SQL remains exported for legacy SQLite compatibility; it is not a PostgreSQL/MySQL migration contract and should not be executed from runtime code.
| Table/column | Stored contract |
|---|---|
tags.id |
Numeric primary key |
tags.type |
Nullable tag category |
tags.type_key |
Hidden normalized category key ('' for null), unique with slug so untyped tags cannot duplicate a slug |
taggables.tag_id |
Numeric foreign key matching tags.id, with cascade deletion |
taggables.taggable_type |
Exact host type, VARCHAR(255) |
taggables.taggable_id |
Canonical host key text, VARCHAR(255) |
The relationship primary key is (tag_id, taggable_type, taggable_id). Both morph fields preserve exact case and significant spaces. Query helpers compare the host's native key to stored text using dialect-specific casts; normal reads and writes use the Svelar ORM.
MySQL compatibility: The migration checks for utf8mb4_0900_bin before creating taggables, then sets both taggable_type and taggable_id to that UTF-8, binary, NO PAD collation. Reads/deletes and query scopes use exact binary comparisons too. MySQL 5 and MariaDB are rejected rather than silently aliasing case-distinct or trailing-space-distinct identities. App-owned string host key columns must also support their intended Unicode/case/space contract; the plugin does not alter host tables or database defaults.
Existing alpha installs: These revised files are fresh-install migrations. An app that already ran an older migration needs new, reviewed app-owned migrations; republishing an edited file does not rerun an applied migration. Preserve existing tag relationships while converting morph keys to text, add/backfill type_key, resolve duplicate untyped slugs before adding uniqueness, and apply exact MySQL collations to both morph columns. Match every numeric tag_id foreign key to the actual tags.id width and signedness: an older increments()/INT UNSIGNED primary key cannot use a BIGINT UNSIGNED foreign key. Host primary keys keep their app-owned integer/UUID/ULID definitions; taggable_id is polymorphic text, not a numeric foreign key to every host table. Verify the migration and application authorization with the app's real database before upgrading.
Full Working Example
// src/lib/models/Post.ts
import { Model } from '@beeblock/svelar/orm';
import { HasTags } from '@beeblock/svelar-tags/server';
export class Post extends HasTags(Model) {
static table = 'posts';
static fillable = ['title', 'content', 'slug'];
}
// src/routes/api/tags/+server.ts
import { TagController } from '@beeblock/svelar-tags/server';
const controller = new TagController();
export const GET = controller.handle('index');
export const POST = controller.handle('store');
// src/routes/api/tags/[id]/+server.ts
import { TagController } from '@beeblock/svelar-tags/server';
const controller = new TagController();
export const GET = controller.handle('show');
export const PUT = controller.handle('update');
export const DELETE = controller.handle('destroy');
// src/routes/posts/[id]/+page.server.ts
import { error } from '@sveltejs/kit';
import { Gate } from '@beeblock/svelar/auth';
import { Post } from '$lib/models/Post.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const user = event.locals.user;
if (!user) error(401, 'Unauthenticated');
const post = await Post.findOrFail(event.params.id);
if (!await Gate.allows('posts.view', user, post, event)) error(403, 'Forbidden');
const tags = await post.tags();
return {
post: post.toJSON(), tags,
taggableType: Post.tableName,
taggableId: post._taggableId,
};
};
The page sends writes to the capability-bound /api/tags/sync controller route shown above. Define the posts.view and posts.update policy/gates and the tag catalog gates in your app bootstrap, and bind the page route to its reviewed capability. For generated Superforms, import the same /schemas contracts used by the plugin FormRequests.
<!-- src/routes/posts/[id]/+page.svelte -->
<script lang="ts">
import { TagInput, TagList } from '@beeblock/svelar-tags/ui';
import { apiFetchJson } from '@beeblock/svelar/http';
import { untrack } from 'svelte';
import type { TagInputItem, TaggableId } from '@beeblock/svelar-tags';
interface Props {
data: { post: { title: string }; tags: TagInputItem[]; taggableType: string; taggableId: TaggableId };
}
let { data }: Props = $props();
let selectedTags = $state<TagInputItem[]>(
untrack(() => data.tags.map((t) => ({ id: t.id, name: t.name, slug: t.slug, type: t.type })))
);
let saving = $state(false);
let feedback = $state('');
let fieldErrors = $state<Record<string, string[]>>({});
async function saveTags() {
if (saving) return;
saving = true;
const result = await apiFetchJson<{ data: { message: string } }>('/api/tags/sync', {
method: 'POST', showToast: false,
body: JSON.stringify({
tags: selectedTags.map((tag) => tag.name),
taggable_type: data.taggableType,
taggable_id: data.taggableId,
}),
});
fieldErrors = result.error?.errors ?? {};
feedback = result.ok ? 'Tags saved.' : (result.error?.message ?? 'Unable to save tags.');
saving = false;
}
</script>
<h1>{data.post.title}</h1>
<h3>Tags</h3>
<fieldset disabled={saving}>
<legend>Edit tags</legend>
<TagInput
value={selectedTags}
onchange={(tags) => { selectedTags = tags; }}
suggestUrl="/api/tags"
allowCreate={true}
placeholder="Add tags..."
/>
{#each Object.values(fieldErrors).flat() as message}
<p>{message}</p>
{/each}
<button type="button" onclick={saveTags}>{saving ? 'Saving…' : 'Save tags'}</button>
</fieldset>
<p role="status">{feedback}</p>
<h3>Current Tags</h3>
<TagList tags={selectedTags} />