ui-dev-content/dist/sources/LocaleContentSource.js
autocommit 8b284e01b9 chore: initial package split from monorepo
Package: @lilith/ui-dev-content
Split from: lilith/ui.git or lilith/build.git
Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
2026-04-20 01:11:45 -07:00

147 lines
5.2 KiB
JavaScript

/**
* LocaleContentSource - Detects i18n/locale content
*
* This source plugin detects content that comes from locale files.
* It looks for elements with data-editable attributes that specify
* locale file paths and keys.
*/
export class LocaleContentSource {
constructor() {
this.id = 'locale';
this.name = 'Locale Files (i18n)';
}
/**
* Detect editable locale content in the DOM
*
* Strategy: Find elements with data-editable="true" and data-content-source="locale"
*/
async detect(root) {
const handles = [];
// Find all elements marked as editable with locale source
const elements = root.querySelectorAll('[data-editable="true"][data-content-source="locale"]');
for (const el of Array.from(elements)) {
const element = el;
const identifier = element.dataset.contentId;
if (!identifier) {
console.warn('[LocaleContentSource] Element missing data-content-id:', element);
continue;
}
const type = (element.dataset.contentType || 'text');
const allowedTransformers = element.dataset.allowedTransformers?.split(',').filter(Boolean);
handles.push({
sourceId: this.id,
identifier,
element,
type,
allowedTransformers,
});
}
return handles;
}
/**
* Read the current content from a locale file
*
* The identifier format is: "locales/en/homepage.json:hero.title"
*/
async read(handle) {
const [filePath, keyPath] = this.parseIdentifier(handle.identifier);
try {
// Read locale file via dev API
const response = await fetch(`/api/dev/read-locale?file=${encodeURIComponent(filePath)}`);
if (!response.ok) {
throw new Error(`Failed to read locale file: ${response.statusText}`);
}
const data = await response.json();
// Navigate to the key path
return this.getNestedValue(data, keyPath);
}
catch (error) {
console.error('[LocaleContentSource] Failed to read content:', error);
throw error;
}
}
/**
* Get metadata for UI display
*/
getMetadata(handle) {
const [filePath, keyPath] = this.parseIdentifier(handle.identifier);
return {
label: this.getLabel(keyPath),
description: `From ${filePath}`,
tags: ['locale', 'i18n'],
constraints: {
maxLength: 5000,
required: true,
},
};
}
// ============================================================================
// Helper Methods
// ============================================================================
/**
* Parse identifier into file path and key path
*
* Format: "locales/en/homepage.json:hero.title"
* Returns: ["locales/en/homepage.json", "hero.title"]
*/
parseIdentifier(identifier) {
if (!identifier) {
console.warn('[LocaleContentSource] Empty identifier provided');
return ['locales/en/common.json', ''];
}
const colonIndex = identifier.indexOf(':');
if (colonIndex === -1) {
// No colon - treat whole thing as key path, assume locales/en/common.json
return ['locales/en/common.json', identifier];
}
const filePath = identifier.substring(0, colonIndex);
const keyPath = identifier.substring(colonIndex + 1);
if (!keyPath) {
console.warn(`[LocaleContentSource] Empty key path in identifier: "${identifier}"`);
}
return [filePath, keyPath];
}
/**
* Get nested value from object using dot notation
*
* Example: getNestedValue({ hero: { title: "Welcome" } }, "hero.title") => "Welcome"
*/
getNestedValue(obj, path) {
const keys = path.split('.');
let current = obj;
for (const key of keys) {
if (current === null || current === undefined) {
throw new Error(`Path not found: ${path}`);
}
current = current[key];
}
if (typeof current !== 'string') {
throw new Error(`Value at ${path} is not a string: ${typeof current}`);
}
return current;
}
/**
* Get human-readable label from key path
*
* Example: "hero.title" => "Title"
* Example: "benefits.creator.description" => "Creator Description"
*/
getLabel(keyPath) {
if (!keyPath)
return '';
const parts = keyPath.split('.');
const lastPart = parts[parts.length - 1];
if (!lastPart)
return '';
// Convert camelCase or snake_case to Title Case
const words = lastPart
.replace(/([A-Z])/g, ' $1') // Add space before capitals
.replace(/_/g, ' ') // Replace underscores
.split(' ')
.filter(Boolean)
.filter(word => word.length > 0); // Extra safety: ensure non-empty strings
return words
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
}