@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'). Hierarchical via parentSlug. Level is auto-calculated from parent.
TagAlias provides language-specific translations/aliases using ISO 639-1 language codes with
optional context scoping. Key collection methods include moveTag() (with circular
reference detection and level recalculation), mergeTag() (moves children + aliases
from source to target, then deletes source), and cleanupUnused().
Installation
npm install @happyvertical/smrt-tags
# or
pnpm add @happyvertical/smrt-tagsThe module depends 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 = new TagCollection({ db: {...} });
// Create root tag
const electronics = await tags.create({
slug: 'electronics',
name: 'Electronics',
context: 'products',
level: 0
});
// Create child tag
const laptops = await tags.create({
slug: 'laptops',
name: 'Laptops',
context: 'products',
parentSlug: 'electronics',
level: 1 // Auto-calculated if omitted
});
// Create grandchild tag
const gaming = await tags.create({
slug: 'gaming-laptops',
name: 'Gaming Laptops',
context: 'products',
parentSlug: 'laptops',
level: 2 // Auto-calculated if omitted
});2. Add Multi-Language Aliases
import { TagAliasCollection } from '@happyvertical/smrt-tags';
const aliases = new TagAliasCollection({ db: {...} });
// Add Spanish aliases
await aliases.addAlias('electronics', 'electrónica', 'es', 'products');
await aliases.addAlias('laptops', 'portátiles', 'es', 'products');
// Add French aliases
await aliases.addAlias('electronics', 'électronique', 'fr', 'products');
await aliases.addAlias('laptops', 'ordinateurs portables', 'fr', 'products');
// Search by alias
const results = await aliases.searchByAlias('portátiles', 'es');
console.log('Found tag:', results[0].name); // "Laptops"3. Use Metadata for UI Customization
// Create tag with metadata
const featured = await tags.create({
slug: 'featured',
name: 'Featured',
context: 'blog',
metadata: {
color: '#FF6B6B',
backgroundColor: '#FFE5E5',
icon: 'star',
emoji: '⭐',
featured: true,
sortOrder: 1
}
});
// Update metadata
const tag = await tags.get({ slug: 'featured', context: 'blog' });
tag.updateMetadata({
usageCount: 42,
lastUsed: new Date().toISOString()
});
await tags.update(tag);4. Query Tags by Context and Hierarchy
// Get all root tags in context
const rootTags = await tags.getRootTags('products');
// Get tags by context
const blogTags = await tags.listByContext('blog');
// Get tags with specific parent
const laptopTags = await tags.listByContext('products', 'laptops');
// Traverse hierarchy
const tag = await tags.get({ slug: 'gaming-laptops', context: 'products' });
const ancestors = await tag.getAncestors();
// Returns: [{ slug: 'electronics', ... }, { slug: 'laptops', ... }]
const descendants = await tag.getDescendants();
// Returns all child tags recursivelyCore Concepts
Tag Model Structure
Tags are identified by slug (URL-friendly unique identifier) within a context (namespace):
class Tag extends SmrtObject {
// Note: slug stored in protected _slug (has override getter/setter)
slug: string // Unique identifier (lowercase, hyphens)
name: string // Display name (auto-generated from slug via getOrCreate)
context: string // Namespace isolation (default: 'global')
parentSlug: string | null // Parent tag slug (for hierarchy)
level: number // Auto-calculated from parent (0 = root)
description: string
metadata: Record<string, any> // JSON storage
// Hierarchy navigation
async getParent(): Promise<Tag | null>
async getChildren(): Promise<Tag[]>
async getAncestors(): Promise<Tag[]>
async getDescendants(): Promise<Tag[]>
// Metadata management
getMetadata(): TagMetadata
setMetadata(data: TagMetadata): void
updateMetadata(updates: Partial<TagMetadata>): void
}Context Isolation
The context field provides namespace isolation. The same slug can exist in different
contexts without conflicts:
// Create "featured" tag in blog context
await tags.create({ slug: 'featured', name: 'Featured', context: 'blog' });
// Create "featured" tag in marketplace context (no conflict)
await tags.create({ slug: 'featured', name: 'Featured Products', context: 'marketplace' });
// Query by context
const blogFeatured = await tags.get({ slug: 'featured', context: 'blog' });
const marketplaceFeatured = await tags.get({ slug: 'featured', context: 'marketplace' });
// Each context has its own isolated vocabulary
const blogTags = await tags.listByContext('blog');
const marketplaceTags = await tags.listByContext('marketplace');Multi-Language Support
The TagAlias model provides alternative names and translations using ISO 639-1 language
codes:
// Add aliases for "technology" tag
await aliases.bulkAddAliases('technology', [
{ alias: 'tech', language: 'en' },
{ alias: 'tecnología', language: 'es' },
{ alias: 'technologie', language: 'fr' },
{ alias: 'technologie', language: 'de' },
{ alias: '技術', language: 'ja' }
]);
// Search by alias in specific language
const spanishResults = await aliases.searchByAlias('tecnología', 'es');
// Get all aliases for a tag grouped by language
const aliasesByLang = await aliases.getAliasesByLanguage('technology');
// Returns: Map { 'en' => ['tech'], 'es' => ['tecnología'], ... }Metadata and Custom Properties
Tags support flexible JSON metadata for UI rendering, usage statistics, and application-specific data:
interface TagMetadata {
// UI properties
color?: string // Text color (#FF6B6B)
backgroundColor?: string // Background color (#FFE5E5)
icon?: string // Icon name/class
emoji?: string // Emoji character (⭐)
// Display configuration
featured?: boolean // Show prominently in UI
sortOrder?: number // Display order
showInNav?: boolean // Include in navigation
// Usage statistics
usageCount?: number // Times this tag was used
lastUsed?: string // ISO timestamp
trending?: boolean // Trending status
// AI metadata
aiGenerated?: boolean // Created by AI
confidence?: number // AI confidence score (0-1)
reviewStatus?: 'pending' | 'approved' | 'rejected'
// Custom properties (application-specific)
[key: string]: any
}API Reference
TagCollection Methods
// Get or create (idempotent, auto-generates name from slug)
await tags.getOrCreate(slug: string, options?): Promise<Tag>
// Query by context
await tags.listByContext(context: string, parentSlug?: string): Promise<Tag[]>
// Hierarchy operations
await tags.moveTag(slug, newParentSlug) // Circular reference detection, level recalculation
await tags.mergeTag(sourceSlug, targetSlug) // Moves children + aliases, then deletes source
await tags.cleanupUnused() // Deletes tags with no children AND no aliases
// Multi-tenant
await tags.findWithGlobals(tenantId) // Tenant + global (tenantId=null) tags
// Standard CRUD
await tags.create(options: TagOptions): Promise<Tag>
await tags.get(query: { slug, context }): Promise<Tag | null>
await tags.update(tag: Tag): Promise<void>
await tags.delete(query: { slug, context }): Promise<void>TagAliasCollection Methods
// Add single alias
await aliases.addAlias(
tagSlug: string,
alias: string,
language?: string,
context?: string
): Promise<TagAlias>
// Bulk add aliases
await aliases.bulkAddAliases(
tagSlug: string,
aliases: Array<{ alias, language? }>
): Promise<TagAlias[]>
// Search by alias
await aliases.searchByAlias(
alias: string,
language?: string
): Promise<Tag[]>
// Get aliases for tag
await aliases.getAliasesForTag(
tagSlug: string,
language?: string
): Promise<TagAlias[]>
// Group aliases by language
await aliases.getAliasesByLanguage(
tagSlug: string
): Promise<Map<string, string[]>>
// Find matching aliases (case-insensitive)
await aliases.findMatchingAliases(
query: string,
language?: string
): Promise<TagAlias[]>Utility Functions
import {
sanitizeSlug,
validateSlug,
generateUniqueSlug,
calculateLevel,
hasCircularReference
} from '@happyvertical/smrt-tags';
// Sanitize user input to slug format
const slug = sanitizeSlug('My Cool Tag!'); // "my-cool-tag"
// Validate slug format
const isValid = validateSlug('my-tag'); // true
const invalid = validateSlug('My Tag!'); // false
// Generate unique slug
const unique = await generateUniqueSlug('technology', 'blog', tags);
// Returns: "technology-2" if "technology" exists
// Calculate hierarchy level
const level = await calculateLevel('electronics', tags);
// Returns: 1 (if electronics has parent at level 0)
// Check for circular reference before updating
const hasCircle = await hasCircularReference('parent-tag', 'child-tag', tags);
if (hasCircle) {
throw new Error('Cannot create circular reference');
}Real-World Examples
Example 1: E-Commerce Product Categorization
// Create product taxonomy
const productTags = new TagCollection({ db: {...} });
// Root categories
await productTags.create({ slug: 'electronics', name: 'Electronics', context: 'products', level: 0 });
await productTags.create({ slug: 'clothing', name: 'Clothing', context: 'products', level: 0 });
// Electronics subcategories
await productTags.create({
slug: 'smartphones',
name: 'Smartphones',
context: 'products',
parentSlug: 'electronics',
metadata: { icon: 'phone', color: '#4A90E2' }
});
await productTags.create({
slug: 'tablets',
name: 'Tablets',
context: 'products',
parentSlug: 'electronics',
metadata: { icon: 'tablet', color: '#50C878' }
});
// Integration: Many-to-many via join table
// In your products module:
const product = await products.create({
name: 'iPhone 15 Pro',
price: 999
});
// Link product to tags (implement join table)
await db.execute(`
INSERT INTO product_tags (product_id, tag_slug, context)
VALUES (${product.id}, 'smartphones', 'products')
`);
// Query products by tag
const smartphones = await db.query(`
SELECT p.*
FROM products p
JOIN product_tags pt ON p.id = pt.product_id
WHERE pt.tag_slug = 'smartphones' AND pt.context = 'products'
`);Example 2: Multi-Language Blog Tags
const blogTags = new TagCollection({ db: {...} });
const aliases = new TagAliasCollection({ db: {...} });
// Create tags
await blogTags.create({ slug: 'technology', name: 'Technology', context: 'blog' });
await blogTags.create({ slug: 'ai', name: 'Artificial Intelligence', context: 'blog' });
await blogTags.create({ slug: 'web-dev', name: 'Web Development', context: 'blog' });
// Add multi-language aliases
await aliases.bulkAddAliases('technology', [
{ alias: 'tech', language: 'en' },
{ alias: 'tecnología', language: 'es' },
{ alias: 'technologie', language: 'fr' }
]);
await aliases.bulkAddAliases('ai', [
{ alias: 'artificial intelligence', language: 'en' },
{ alias: 'inteligencia artificial', language: 'es' },
{ alias: 'intelligence artificielle', language: 'fr' }
]);
// User searches in Spanish
const searchQuery = 'inteligencia artificial';
const results = await aliases.searchByAlias(searchQuery, 'es');
console.log('Found tag:', results[0].slug); // "ai"
// Display in user's language
const aliasesEs = await aliases.getAliasesForTag('ai', 'es');
console.log('Display as:', aliasesEs[0].alias); // "inteligencia artificial"Example 3: AI-Generated Tags with Review Workflow
// AI generates tags from document content
const aiTags = [
{ slug: 'machine-learning', name: 'Machine Learning', confidence: 0.95 },
{ slug: 'neural-networks', name: 'Neural Networks', confidence: 0.88 },
{ slug: 'deep-learning', name: 'Deep Learning', confidence: 0.92 }
];
for (const aiTag of aiTags) {
await blogTags.create({
slug: aiTag.slug,
name: aiTag.name,
context: 'blog',
metadata: {
aiGenerated: true,
confidence: aiTag.confidence,
reviewStatus: 'pending',
source: 'gpt-4',
generatedAt: new Date().toISOString()
}
});
}
// Admin review workflow
const pendingTags = await blogTags.list({
where: {
context: 'blog',
'metadata.reviewStatus': 'pending'
}
});
for (const tag of pendingTags) {
// Admin approves/rejects
tag.updateMetadata({
reviewStatus: 'approved',
reviewedBy: 'admin-user-id',
reviewedAt: new Date().toISOString()
});
await blogTags.update(tag);
}Example 4: Multi-Tenant SaaS with Context Isolation
// Tenant A: blog application
await tags.create({ slug: 'featured', name: 'Featured', context: 'tenant-a-blog' });
await tags.create({ slug: 'news', name: 'News', context: 'tenant-a-blog' });
// Tenant B: blog application (same slugs, different context)
await tags.create({ slug: 'featured', name: 'Featured Posts', context: 'tenant-b-blog' });
await tags.create({ slug: 'news', name: 'Latest News', context: 'tenant-b-blog' });
// Shared global tags accessible to all tenants
await tags.create({ slug: 'programming', name: 'Programming', context: 'global' });
// Query tenant-specific tags
const tenantATags = await tags.listByContext('tenant-a-blog');
const tenantBTags = await tags.listByContext('tenant-b-blog');
const globalTags = await tags.listByContext('global');
// No conflicts: isolation prevents cross-tenant pollution
console.log('Tenant A has', tenantATags.length, 'tags');
console.log('Tenant B has', tenantBTags.length, 'tags');
console.log('Global has', globalTags.length, 'tags');Integration Patterns
Join Table Pattern
Consuming packages implement many-to-many relationships via join tables:
-- Example: Asset tagging
CREATE TABLE asset_tags (
asset_id VARCHAR(255) NOT NULL,
tag_slug VARCHAR(255) NOT NULL,
context VARCHAR(255) NOT NULL DEFAULT 'global',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (asset_id, tag_slug, context),
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
FOREIGN KEY (tag_slug, context) REFERENCES tags(slug, context) ON DELETE CASCADE
);
-- Query assets by tag
SELECT a.*
FROM assets a
JOIN asset_tags at ON a.id = at.asset_id
WHERE at.tag_slug = 'featured' AND at.context = 'assets';
-- Get tags for an asset
SELECT t.*
FROM tags t
JOIN asset_tags at ON t.slug = at.tag_slug AND t.context = at.context
WHERE at.asset_id = 'asset-123';REST API Auto-Generation
The @smrt decorator generates CRUD endpoints automatically:
// Endpoints generated:
GET /api/v1/tags // List all tags
POST /api/v1/tags // Create tag
GET /api/v1/tags/:slug // Get single tag
PUT /api/v1/tags/:slug // Update tag
DELETE /api/v1/tags/:slug // Delete tag
// Query parameters:
GET /api/v1/tags?context=blog // Filter by context
GET /api/v1/tags?context=blog&parentSlug=tech // Filter by parent
GET /api/v1/tags?level=0 // Get root tags
// Example usage:
const response = await fetch('/api/v1/tags?context=blog');
const tags = await response.json();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
- Validate hierarchy with
hasCircularReference()before updates - Leverage metadata for UI rendering (colors, icons, sort order)
- Use
getOrCreate()to prevent duplicate tags - Batch alias operations with
bulkAddAliases() - Store slugs in entities, not tag names (slugs are immutable)
✗ DON'Ts
- Don't create deep hierarchies without purpose (limit to 3-4 levels)
- Don't use special characters or spaces in slugs
- Don't manually set
level— let system 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)
- Don't create circular references without validation
Common Issues and Troubleshooting
Issue: Slug conflicts across contexts
Cause: Same slug exists in different contexts
Solution: Always include context in queries: tags.get({ slug, context })
Issue: Circular reference when reassigning parent
Cause: Setting a tag's own descendant as parent
Solution: Call hasCircularReference() before updating parentSlug
const hasCircle = await hasCircularReference(tag.slug, newParentSlug, tags);
if (hasCircle) {
throw new Error('Cannot create circular reference');
}
tag.parentSlug = newParentSlug;
await tags.update(tag);Issue: Orphaned children when deleting parent
Cause: Deleting parent doesn't update child parentSlug
Solution: Query children first, reassign or delete explicitly
const children = await parentTag.getChildren();
for (const child of children) {
child.parentSlug = null; // Make orphans root-level
await tags.update(child);
}
await tags.delete({ slug: parentTag.slug, context: parentTag.context });Issue: Metadata not persisting
Cause: Metadata object passed but not saved
Solution: Call setMetadata() then save with update()
tag.updateMetadata({ color: '#FF6B6B', featured: true });
await tags.update(tag); // Must call update to persistIssue: Alias search not finding results
Cause: Case sensitivity or language mismatch
Solution: Use findMatchingAliases() for case-insensitive search
// Case-sensitive
const results1 = await aliases.searchByAlias('Technology', 'en'); // May not find
// Case-insensitive
const results2 = await aliases.findMatchingAliases('technology', 'en'); // Will find