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:
// Plugin registration
import { SvelarTagsPlugin } from '@beeblock/svelar-tags/server';
// Core API
import { Tag, TagService, HasTags, slugify, TAGS_MIGRATION_SQL } from '@beeblock/svelar-tags';
// Server-side (controller)
import { TagController } from '@beeblock/svelar-tags/server';
// UI components
import { TagInput, TagBadge, TagList } from '@beeblock/svelar-tags/ui';
// Types
import type { TagRecord, 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/server';
export const tagsPlugin = new SvelarTagsPlugin({
prefix: '/api',
slugSeparator: '-',
});
2. Run the Migration
import { TAGS_MIGRATION_SQL } from '@beeblock/svelar-tags';
// Execute each statement
for (const sql of TAGS_MIGRATION_SQL.up) {
await connection.raw(sql);
}
Or run the SQL directly:
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL,
type TEXT,
order_column INTEGER DEFAULT 0,
created_at TEXT,
updated_at TEXT,
UNIQUE(slug, type)
);
CREATE TABLE IF NOT EXISTS taggables (
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
taggable_type TEXT NOT NULL,
taggable_id INTEGER NOT NULL,
PRIMARY KEY(tag_id, taggable_type, taggable_id)
);
CREATE INDEX IF NOT EXISTS idx_taggables_type_id ON taggables(taggable_type, taggable_id);
CREATE INDEX IF NOT EXISTS idx_tags_slug_type ON tags(slug, type);
3. Add Tags to a Model
import { Model } from '@beeblock/svelar/database';
import { HasTags } from '@beeblock/svelar-tags';
class Post extends HasTags(Model) {
static table = 'posts';
}
const post = await Post.find(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' |
API route prefix |
slugSeparator |
string |
'-' |
Character used to separate words in slugs |
Core API
Tag Class
Static methods for CRUD operations on the tags table:
import { Tag } from '@beeblock/svelar-tags';
| Method | Returns | Description |
|---|---|---|
Tag.findById(id) |
Promise<TagRecord | null> |
Find a tag by ID |
Tag.findByName(name, type?) |
Promise<TagRecord | null> |
Find by name (auto-slugified) |
Tag.findBySlug(slug, type?) |
Promise<TagRecord | null> |
Find by slug |
Tag.findOrCreate(name, type?) |
Promise<TagRecord> |
Find existing or create new |
Tag.create(name, type?, orderColumn?) |
Promise<TagRecord> |
Create a new tag |
Tag.all() |
Promise<TagRecord[]> |
Get all tags ordered by order_column, name |
Tag.ofType(type) |
Promise<TagRecord[]> |
Get all tags of a specific type |
Tag.update(id, data) |
Promise<TagRecord | 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';
| Method | Returns | Description |
|---|---|---|
TagService.all() |
Promise<TagRecord[]> |
Get all tags |
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) |
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/database';
import { HasTags } from '@beeblock/svelar-tags';
class Article extends HasTags(Model) {
static table = 'articles';
}
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 with the given list |
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();
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
Provides static methods for full tag CRUD and management API routes:
// src/routes/api/tags/+server.ts
import { TagController } from '@beeblock/svelar-tags/server';
export const GET = async (event) => TagController.index(event);
export const POST = async (event) => TagController.store(event);
// src/routes/api/tags/[id]/+server.ts
import { TagController } from '@beeblock/svelar-tags/server';
export const GET = async (event) => TagController.show(event, Number(event.params.id));
export const PUT = async (event) => TagController.update(event, Number(event.params.id));
export const DELETE = async (event) => TagController.destroy(event, Number(event.params.id));
// 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 |
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) |
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 |
class |
string |
'' |
CSS class override |
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 plugin requires two tables: tags and taggables. Use the exported TAGS_MIGRATION_SQL constant:
import { TAGS_MIGRATION_SQL } from '@beeblock/svelar-tags';
// Up migration
for (const sql of TAGS_MIGRATION_SQL.up) {
await connection.raw(sql);
}
// Down migration (rollback)
for (const sql of TAGS_MIGRATION_SQL.down) {
await connection.raw(sql);
}
Full Working Example
// src/lib/models/Post.ts
import { Model } from '@beeblock/svelar/database';
import { HasTags } from '@beeblock/svelar-tags';
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';
export const GET = async (event) => TagController.index(event);
export const POST = async (event) => TagController.store(event);
// src/routes/api/tags/[id]/+server.ts
import { TagController } from '@beeblock/svelar-tags/server';
export const GET = async (event) => TagController.show(event, Number(event.params.id));
export const PUT = async (event) => TagController.update(event, Number(event.params.id));
export const DELETE = async (event) => TagController.destroy(event, Number(event.params.id));
// src/routes/posts/[id]/+page.server.ts
import { Post } from '$lib/models/Post';
export async function load({ params }) {
const post = await Post.findOrFail(Number(params.id));
const tags = await post.tags();
return { post: post.toJSON(), tags };
}
export const actions = {
updateTags: async ({ request, params }) => {
const formData = await request.formData();
const tagNames = JSON.parse(formData.get('tags') as string);
const post = await Post.findOrFail(Number(params.id));
await post.syncTags(tagNames);
return { success: true };
},
};
<!-- src/routes/posts/[id]/+page.svelte -->
<script lang="ts">
import { TagInput, TagList } from '@beeblock/svelar-tags/ui';
import type { TagInputItem } from '@beeblock/svelar-tags';
interface Props {
data: { post: any; tags: any[] };
}
let { data }: Props = $props();
let selectedTags = $state<TagInputItem[]>(
data.tags.map((t) => ({ id: t.id, name: t.name, slug: t.slug, type: t.type }))
);
async function saveTags() {
const form = new FormData();
form.set('tags', JSON.stringify(selectedTags.map((t) => t.name)));
await fetch(`/posts/${data.post.id}?/updateTags`, {
method: 'POST',
body: form,
});
}
</script>
<h1>{data.post.title}</h1>
<h3>Tags</h3>
<TagInput
value={selectedTags}
onchange={(tags) => { selectedTags = tags; saveTags(); }}
suggestUrl="/api/tags?search="
allowCreate={true}
placeholder="Add tags..."
/>
<h3>Current Tags</h3>
<TagList tags={selectedTags} />