s-m-r-t

@happyvertical/smrt-tags

Hierarchical tagging system with context scoping, multi-language aliases, and flexible metadata for building taxonomies across SMRT applications.

v0.19.0TaxonomyMulti-LanguageESM

Overview

smrt-tags provides a reusable hierarchical tagging system for organizing and categorizing content across SMRT applications. It supports unlimited nesting depth, context-based namespace isolation, multi-language aliases, and flexible JSON metadata for UI styling and custom properties.

The module is designed for multi-tenant SaaS applications where different contexts (blogs, products, assets) need separate but consistent tagging vocabularies. Tags use slugs as identifiers with automatic level tracking and circular reference prevention.

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 {
  slug: string              // Unique identifier (lowercase, hyphens)
  name: string              // Display name
  context: string           // Namespace isolation (e.g., "blog", "products")
  parentSlug: string | null // Parent tag slug (for hierarchy)
  level: number             // Depth in hierarchy (0 = root)
  description: string       // Optional detailed description
  metadata: Record<string, any>  // JSON storage for custom properties

  // 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
// Create tag
await tags.create(options: TagOptions): Promise<Tag>

// Get single tag
await tags.get(query: { slug, context }): Promise<Tag | null>

// List tags
await tags.list(options?: ListOptions): Promise<Tag[]>

// Update tag
await tags.update(tag: Tag): Promise<void>

// Delete tag
await tags.delete(query: { slug, context }): Promise<void>

// Get or create (idempotent)
await tags.getOrCreate(slug: string, context: string): Promise<Tag>

// Query by context
await tags.listByContext(context: string, parentSlug?: string): Promise<Tag[]>

// Get root tags
await tags.getRootTags(context: string): Promise<Tag[]>

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