@happyvertical/smrt-tags

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

v0.29.34TaxonomyMulti-LanguageESM

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

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

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 = 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

typescript
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

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

typescript
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

typescript
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 _slug with an override getter/setter — not standard SmrtObject slug behaviour
  • Context defaults to 'global' if not specified
  • Optional tenancy with nullable tenantId — use findWithGlobals()
  • 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

Related Modules