@happyvertical/smrt-tags

Hierarchical tagging with context-scoped slugs, multi-language aliases, and slug utilities.

v0.20.44TaxonomyMulti-LanguageESM

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

bash
npm install @happyvertical/smrt-tags
# or
pnpm add @happyvertical/smrt-tags

The module depends on @happyvertical/smrt-core for base classes and database operations.

Quick Start (5 Minutes)

1. Create Tags with Hierarchy

typescript
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

typescript
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

typescript
// 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

typescript
// 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 recursively

Core Concepts

Tag Model Structure

Tags are identified by slug (URL-friendly unique identifier) within a context (namespace):

typescript
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:

typescript
// 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:

typescript
// 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:

typescript
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

typescript
// 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

typescript
// 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

typescript
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

typescript
// 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

typescript
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

typescript
// 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

typescript
// 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:

sql
-- 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:

typescript
// 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

typescript
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

typescript
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()

typescript
tag.updateMetadata({ color: '#FF6B6B', featured: true });
await tags.update(tag);  // Must call update to persist

Issue: Alias search not finding results

Cause: Case sensitivity or language mismatch

Solution: Use findMatchingAliases() for case-insensitive search

typescript
// Case-sensitive
const results1 = await aliases.searchByAlias('Technology', 'en'); // May not find

// Case-insensitive
const results2 = await aliases.findMatchingAliases('technology', 'en'); // Will find

Related Modules