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
147 lines
5.2 KiB
JavaScript
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(' ');
|
|
}
|
|
}
|