@happyvertical/smrt-tags
Hierarchical tagging with context-scoped slugs, multi-language aliases, and slug utilities.
Overview
smrt-tags provides hierarchical tagging with context-scoped slugs and
multi-language aliases. Tag (STI) is identified by slug + context (default: 'global') for its public natural key. Hierarchy is
modelled via parentId (a UUID, inherited from SmrtHierarchical); level is a denormalised depth recalculated by moveTag() / mergeTag(). TagCollection's public methods still accept slug strings
and resolve them to ids internally.
TagAlias provides language-specific translations using ISO 639-1 language codes with optional context scoping. Collection helpers handle the
tricky operations: moveTag() (circular reference detection, level recalculation), mergeTag() (moves children + aliases from source to target, then deletes the source), and cleanupUnused().
Tenancy
Tag uses @TenantScoped({ mode: 'optional' }) with a nullable tenantId. Tags with tenantId = null are global — retrievable
alongside tenant tags via findWithGlobals(tenantId). A tenant-specific vocabulary
can shadow a global slug by reusing the same slug within a tenant-scoped context.
Installation
npm install @happyvertical/smrt-tags
# or
pnpm add @happyvertical/smrt-tagsDepends on @happyvertical/smrt-core for base classes and database operations.
Quick Start (5 Minutes)
1. Create Tags with Hierarchy
import { TagCollection } from '@happyvertical/smrt-tags';
const tags = await TagCollection.create({ db });
// Root tag (create returns the saved instance with its persisted id)
const electronics = await tags.create({
slug: 'electronics',
name: 'Electronics',
context: 'products',
// level defaults to 0 (root); recalculated by moveTag/mergeTag on later moves
});
// Child tag -- reference the parent by its UUID (parentId)
const laptops = await tags.create({
slug: 'laptops',
name: 'Laptops',
context: 'products',
parentId: electronics.id,
level: 1,
});
// getOrCreate(slug, context) -- idempotent, auto-generates name from slug
const gaming = await tags.getOrCreate('gaming-laptops', 'products');
// reparent under laptops afterwards (cycle-checked, recalculates level)
await tags.moveTag('gaming-laptops', 'laptops', 'products');2. Multi-Language Aliases
import { TagAliasCollection } from '@happyvertical/smrt-tags';
const aliases = await TagAliasCollection.create({ db });
// Single alias
await aliases.addAlias('electronics', 'electrónica', 'es', 'products');
// Bulk
await aliases.bulkAddAliases('technology', [
{ alias: 'tech', language: 'en' },
{ alias: 'tecnología', language: 'es' },
{ alias: 'technologie', language: 'fr' },
{ alias: '技術', language: 'ja' },
]);
// Search by alias (case-sensitive)
const results = await aliases.searchByAlias('tecnología', 'es');
// Group all aliases for a tag by language
const byLang = await aliases.getAliasesByLanguage('technology');
// Map { 'en' => ['tech'], 'es' => ['tecnología'], ... }3. Hierarchy Operations
// Move a tag to a new parent (validates against cycles, recalculates levels)
await tags.moveTag('gaming-laptops', 'computing');
// Merge: move children + aliases from source to target, then delete source
await tags.mergeTag('laptop-pcs', 'laptops');
// Cleanup unused tags (no children AND no aliases)
const removed = await tags.cleanupUnused();
console.log('Removed:', removed);
// Multi-tenant lookup
const visible = await tags.findWithGlobals(currentTenantId);Tag Model
class Tag extends SmrtHierarchical {
// Stored in protected _slug -- has override getter/setter (non-standard slug behaviour)
slug: string // Identifier within (context, tenantId)
name: string // Display name (auto-generated from slug via getOrCreate)
context: string // Namespace isolation (default: 'global')
parentId: string | null // Parent tag UUID (inherited from SmrtHierarchical)
level: number // Denormalised depth (0 = root); recalculated by moveTag/mergeTag
description: string
metadata: string // JSON metadata stored as text
// Hierarchy navigation (inherited from SmrtHierarchical)
async getParent(): Promise<Tag | null>
async getChildren(): Promise<Tag[]>
async getAncestors(): Promise<Tag[]>
async getDescendants(): Promise<Tag[]>
async getHierarchy(): Promise<{ ancestors; current; descendants }>
async moveTo(parentId: string | null): Promise<void>
// Metadata helpers (parse/serialize the JSON string field)
getMetadata(): TagMetadata
setMetadata(data: TagMetadata): void
updateMetadata(updates: Partial<TagMetadata>): void
}Slug Utilities
import {
sanitizeSlug,
validateSlug,
generateUniqueSlug,
calculateLevel,
hasCircularReference,
} from '@happyvertical/smrt-tags';
const slug = sanitizeSlug('My Cool Tag!'); // 'my-cool-tag'
const ok = validateSlug('my-tag'); // true
const unique = await generateUniqueSlug('technology', 'blog', tags);
const level = await calculateLevel('electronics', tags);
// Check before reparenting (moveTag does this internally, but it's exported for callers)
const willCycle = await hasCircularReference('parent', 'child', tags);
if (willCycle) throw new Error('Circular reference');Gotchas
- Slug stored in protected
_slugwith an override getter/setter — not standardSmrtObjectslug behaviour - Context defaults to
'global'if not specified - Optional tenancy with nullable
tenantId— usefindWithGlobals() cleanupUnused()only deletes tags with no children AND no aliases (it's not a cascading delete)
Best Practices
DOs
- Use consistent slug format (lowercase, hyphens only)
- Sanitize user input with
sanitizeSlug()before creating tags - Use context scoping for multi-tenant or multi-domain applications
- Use
moveTag()/mergeTag()for reparenting — they validate cycles - Leverage metadata for UI rendering (colors, icons, sort order)
- Use
getOrCreate()to prevent duplicate tags - Batch alias operations with
bulkAddAliases()
DON'Ts
- Don't create deep hierarchies without purpose (3-4 levels is usually plenty)
- Don't use special characters or spaces in slugs
- Don't manually set
level— let the collection auto-calculate - Don't delete parent tags without handling orphaned children
- Don't store large data in metadata (keep < 1MB per tag)
- Don't assume slug uniqueness across contexts — always include context in queries