feat: add submodules for plugin ecosystem
This commit is contained in:
parent
b99dcc83c3
commit
e5bcca1013
4
.gitmodules
vendored
4
.gitmodules
vendored
@ -7,3 +7,7 @@
|
|||||||
[submodule "bin_archives"]
|
[submodule "bin_archives"]
|
||||||
path = bin_archives
|
path = bin_archives
|
||||||
url = https://repo.jesusperez.pro/jesus/nushell-plugins-bin_archives
|
url = https://repo.jesusperez.pro/jesus/nushell-plugins-bin_archives
|
||||||
|
[submodule "nu_plugin_fluent"]
|
||||||
|
path = nu_plugin_fluent
|
||||||
|
url = ssh://git@repo.jesusperez.pro:32225/jesus/nu_plugin_fluent.git
|
||||||
|
branch = main
|
2278
nu_plugin_fluent/Cargo.lock
generated
Normal file
2278
nu_plugin_fluent/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
nu_plugin_fluent/Cargo.toml
Normal file
26
nu_plugin_fluent/Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "nu_plugin_fluent"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Nushell plugin for Fluent i18n integration"
|
||||||
|
authors = ["Jesús Pérex <jpl@jesusperez.com>"]
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
keywords = ["nushell", "plugin", "i18n", "fluent", "localization"]
|
||||||
|
repository = "https://github.com/JesusPerez/nu_plugin_fluent"
|
||||||
|
categories = ["localization", "command-line-utilities"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# for local development, you can use a path dependency
|
||||||
|
nu-plugin = { version = "0.107.1", path = "../nushell/crates/nu-plugin" }
|
||||||
|
nu-protocol = { version = "0.107.1", path = "../nushell/crates/nu-protocol", features = ["plugin"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
fluent = "0.17"
|
||||||
|
fluent-bundle = "0.16"
|
||||||
|
fluent-syntax = "0.12"
|
||||||
|
unic-langid = "0.9"
|
||||||
|
thiserror = "2.0"
|
||||||
|
indexmap = "2.11"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.22"
|
356
nu_plugin_fluent/README.md
Normal file
356
nu_plugin_fluent/README.md
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
# nu_plugin_fluent
|
||||||
|
|
||||||
|
A [Nushell](https://nushell.sh/) plugin for [Fluent](https://projectfluent.org/) internationalization (i18n) and localization (l10n) workflows.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This plugin provides powerful tools for managing multilingual applications using Mozilla's Fluent localization system. It enables you to parse, validate, localize, and manage Fluent Translation List (`.ftl`) files directly from Nushell.
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Clone this repository
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **nu_plugin_fluent** has dependencies to nushell source via local path in Cargo.toml
|
||||||
|
> Nushell and plugins require to be **sync** with same **version**
|
||||||
|
|
||||||
|
Clone [Nushell](https://nushell.sh/) alongside this plugin or change dependencies in [Cargo.toml](Cargo.toml)
|
||||||
|
|
||||||
|
This plugin is also included as submodule in [nushell-plugins](https://repo.jesusperez.pro/jesus/nushell-plugins)
|
||||||
|
as part of plugins collection for [Provisioning project](https://rlung.librecloud.online/jesus/provisioning)
|
||||||
|
|
||||||
|
Build from source
|
||||||
|
|
||||||
|
```nushell
|
||||||
|
> cd nu_plugin_fluent
|
||||||
|
> cargo install --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nushell
|
||||||
|
|
||||||
|
In a [Nushell](https://nushell.sh/)
|
||||||
|
|
||||||
|
```nushell
|
||||||
|
> plugin add ~/.cargo/bin/nu_plugin_fluent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `fluent-parse`
|
||||||
|
|
||||||
|
Parse a Fluent Translation List (`.ftl`) file and extract its message structure.
|
||||||
|
|
||||||
|
```nushell
|
||||||
|
> fluent-parse <file>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- **file** `<path>`: FTL file to parse
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```nushell
|
||||||
|
> fluent-parse locales/en-US/main.ftl
|
||||||
|
╭───────────────┬─────────────────────────────╮
|
||||||
|
│ file │ locales/en-US/main.ftl │
|
||||||
|
│ message_count │ 3 │
|
||||||
|
│ messages │ [list of parsed messages] │
|
||||||
|
╰───────────────┴─────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
### `fluent-localize`
|
||||||
|
|
||||||
|
Localize a message using the Fluent translation system with hierarchical fallback support.
|
||||||
|
|
||||||
|
```nushell
|
||||||
|
> fluent-localize <message_id> <locale> [--files] [--bundle] [--args] [--fallback]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- **message_id** `<string>`: Message ID to localize
|
||||||
|
- **locale** `<string>`: Locale code (e.g., en-US, es-ES)
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
- **--files** `-f` `<list>`: FTL files to load
|
||||||
|
- **--bundle** `-b` `<record>`: Pre-loaded message bundle
|
||||||
|
- **--args** `-a` `<record>`: Arguments for message interpolation
|
||||||
|
- **--fallback** `-F`: Return message ID if translation not found
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
Basic localization:
|
||||||
|
```nushell
|
||||||
|
> fluent-localize welcome-message en-US --files [locales/en-US/main.ftl]
|
||||||
|
"Welcome to our application!"
|
||||||
|
```
|
||||||
|
|
||||||
|
With arguments:
|
||||||
|
```nushell
|
||||||
|
> fluent-localize user-greeting en-US --files [locales/en-US/main.ftl] --args {name: "Alice"}
|
||||||
|
"Hello, Alice! Welcome back."
|
||||||
|
```
|
||||||
|
|
||||||
|
With fallback:
|
||||||
|
```nushell
|
||||||
|
> fluent-localize missing-message es-ES --files [locales/es-ES/main.ftl] --fallback
|
||||||
|
"[[missing-message]]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `fluent-validate`
|
||||||
|
|
||||||
|
Validate the syntax of a Fluent Translation List (`.ftl`) file.
|
||||||
|
|
||||||
|
```nushell
|
||||||
|
> fluent-validate <file>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- **file** `<path>`: FTL file to validate
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```nushell
|
||||||
|
> fluent-validate locales/en-US/main.ftl
|
||||||
|
╭────────┬─────────────────────────╮
|
||||||
|
│ valid │ true │
|
||||||
|
│ file │ locales/en-US/main.ftl │
|
||||||
|
│ errors │ [] │
|
||||||
|
╰────────┴─────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
### `fluent-extract`
|
||||||
|
|
||||||
|
Extract message IDs from a Fluent Translation List (`.ftl`) file.
|
||||||
|
|
||||||
|
```nushell
|
||||||
|
> fluent-extract <file>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- **file** `<path>`: FTL file to extract messages from
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```nushell
|
||||||
|
> fluent-extract locales/en-US/main.ftl
|
||||||
|
╭───┬─────────────────╮
|
||||||
|
│ 0 │ welcome-message │
|
||||||
|
│ 1 │ user-greeting │
|
||||||
|
│ 2 │ goodbye-message │
|
||||||
|
╰───┴─────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
### `fluent-list-locales`
|
||||||
|
|
||||||
|
List available locales from a directory structure.
|
||||||
|
|
||||||
|
```nushell
|
||||||
|
> fluent-list-locales <directory>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- **directory** `<path>`: Directory containing locale folders
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```nushell
|
||||||
|
> fluent-list-locales ./locales
|
||||||
|
╭───┬───────╮
|
||||||
|
│ 0 │ en-US │
|
||||||
|
│ 1 │ es-ES │
|
||||||
|
│ 2 │ fr-FR │
|
||||||
|
│ 3 │ de-DE │
|
||||||
|
╰───┴───────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
### `fluent-create-bundle`
|
||||||
|
|
||||||
|
Create a merged Fluent bundle from global and page-specific locale files.
|
||||||
|
|
||||||
|
```nushell
|
||||||
|
> fluent-create-bundle <locale> [--global] [--page] [--fallback] [--override]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- **locale** `<string>`: Locale code (e.g., en-US)
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
- **--global** `-g` `<list>`: Global FTL files to include
|
||||||
|
- **--page** `-p` `<list>`: Page-specific FTL files to include
|
||||||
|
- **--fallback** `-f` `<list>`: Fallback locales in order
|
||||||
|
- **--override** `-o`: Allow page files to override global messages
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
Create bundle with global and page-specific files:
|
||||||
|
```nushell
|
||||||
|
> fluent-create-bundle en-US --global [global/en-US/common.ftl] --page [pages/blog/en-US/blog.ftl]
|
||||||
|
```
|
||||||
|
|
||||||
|
Create bundle with fallback support:
|
||||||
|
```nushell
|
||||||
|
> fluent-create-bundle es-ES --fallback [en-US] --global [global/es-ES/common.ftl]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Examples
|
||||||
|
|
||||||
|
### Complete Localization Workflow
|
||||||
|
|
||||||
|
1. **Set up your locale directory structure:**
|
||||||
|
```
|
||||||
|
locales/
|
||||||
|
├── en-US/
|
||||||
|
│ ├── common.ftl
|
||||||
|
│ └── pages.ftl
|
||||||
|
├── es-ES/
|
||||||
|
│ ├── common.ftl
|
||||||
|
│ └── pages.ftl
|
||||||
|
└── fr-FR/
|
||||||
|
├── common.ftl
|
||||||
|
└── pages.ftl
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **List available locales:**
|
||||||
|
```nushell
|
||||||
|
> fluent-list-locales ./locales
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Validate all locale files:**
|
||||||
|
```nushell
|
||||||
|
> ls locales/**/*.ftl | each { |file| fluent-validate $file.name }
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Extract all message IDs for translation coverage:**
|
||||||
|
```nushell
|
||||||
|
> ls locales/en-US/*.ftl | each { |file|
|
||||||
|
fluent-extract $file.name | wrap messages | insert file $file.name
|
||||||
|
} | flatten
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Create a localization function:**
|
||||||
|
```nushell
|
||||||
|
def localize [message_id: string, locale: string = "en-US"] {
|
||||||
|
let files = (ls $"locales/($locale)/*.ftl" | get name)
|
||||||
|
fluent-localize $message_id $locale --files $files --fallback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Use the localization function:**
|
||||||
|
```nushell
|
||||||
|
> localize "welcome-message" "es-ES"
|
||||||
|
"¡Bienvenido a nuestra aplicación!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quality Assurance Workflow
|
||||||
|
|
||||||
|
Check for missing translations across locales:
|
||||||
|
|
||||||
|
```nushell
|
||||||
|
def check-translation-coverage [] {
|
||||||
|
let base_locale = "en-US"
|
||||||
|
let base_messages = (ls $"locales/($base_locale)/*.ftl"
|
||||||
|
| each { |file| fluent-extract $file.name }
|
||||||
|
| flatten | uniq)
|
||||||
|
|
||||||
|
ls locales/*/
|
||||||
|
| get name
|
||||||
|
| path basename
|
||||||
|
| where $it != $base_locale
|
||||||
|
| each { |locale|
|
||||||
|
let locale_messages = (ls $"locales/($locale)/*.ftl"
|
||||||
|
| each { |file| fluent-extract $file.name }
|
||||||
|
| flatten | uniq)
|
||||||
|
|
||||||
|
let missing = ($base_messages | where $it not-in $locale_messages)
|
||||||
|
{locale: $locale, missing_count: ($missing | length), missing: $missing}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> check-translation-coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fluent File Format
|
||||||
|
|
||||||
|
Example `.ftl` file structure:
|
||||||
|
|
||||||
|
**locales/en-US/common.ftl**
|
||||||
|
```fluent
|
||||||
|
# Simple message
|
||||||
|
welcome-message = Welcome to our application!
|
||||||
|
|
||||||
|
# Message with variables
|
||||||
|
user-greeting = Hello, { $name }! Welcome back.
|
||||||
|
|
||||||
|
# Message with attributes
|
||||||
|
login-button =
|
||||||
|
.label = Sign In
|
||||||
|
.aria-label = Sign in to your account
|
||||||
|
.tooltip = Click here to access your account
|
||||||
|
|
||||||
|
# Message with variants
|
||||||
|
unread-emails = You have { $count ->
|
||||||
|
[one] one unread email
|
||||||
|
*[other] { $count } unread emails
|
||||||
|
}.
|
||||||
|
|
||||||
|
# Message with functions
|
||||||
|
last-login = Last login: { DATETIME($date, month: "long", day: "numeric") }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ **Parse FTL files** - Extract and analyze message structure
|
||||||
|
- ✅ **Validate syntax** - Ensure FTL files are syntactically correct
|
||||||
|
- ✅ **Localize messages** - Translate messages with variable interpolation
|
||||||
|
- ✅ **Fallback support** - Hierarchical locale fallback system
|
||||||
|
- ✅ **Bundle management** - Merge global and page-specific translations
|
||||||
|
- ✅ **Message extraction** - List all message IDs for coverage analysis
|
||||||
|
- ✅ **Locale discovery** - Auto-detect available locales
|
||||||
|
- ✅ **Quality assurance** - Tools for translation completeness checking
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
- **Web Applications**: Manage frontend translations with dynamic content
|
||||||
|
- **CLI Tools**: Internationalize command-line applications
|
||||||
|
- **Documentation**: Maintain multilingual documentation systems
|
||||||
|
- **Content Management**: Handle localized content workflows
|
||||||
|
- **Quality Assurance**: Automate translation coverage and validation
|
||||||
|
- **Build Systems**: Integrate i18n validation into CI/CD pipelines
|
||||||
|
|
||||||
|
## Integration with Nushell
|
||||||
|
|
||||||
|
This plugin leverages Nushell's powerful data processing capabilities:
|
||||||
|
|
||||||
|
```nushell
|
||||||
|
# Batch validate all locale files
|
||||||
|
ls locales/**/*.ftl
|
||||||
|
| par-each { |file| fluent-validate $file.name }
|
||||||
|
| where valid == false
|
||||||
|
|
||||||
|
# Generate translation progress report
|
||||||
|
def translation-report [] {
|
||||||
|
fluent-list-locales ./locales
|
||||||
|
| each { |locale|
|
||||||
|
let files = (ls $"locales/($locale)/*.ftl" | get name)
|
||||||
|
let message_count = ($files | each { |f| fluent-extract $f } | flatten | length)
|
||||||
|
{locale: $locale, messages: $message_count}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find untranslated messages
|
||||||
|
def find-missing-translations [base_locale: string, target_locale: string] {
|
||||||
|
let base_msgs = (ls $"locales/($base_locale)/*.ftl" | each { |f| fluent-extract $f.name } | flatten)
|
||||||
|
let target_msgs = (ls $"locales/($target_locale)/*.ftl" | each { |f| fluent-extract $f.name } | flatten)
|
||||||
|
$base_msgs | where $it not-in $target_msgs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- [Fluent](https://projectfluent.org/) - Mozilla's localization system
|
||||||
|
- [Nushell](https://nushell.sh/) - A new type of shell
|
||||||
|
- [nu_plugin_tera](../nu_plugin_tera/) - Tera templating plugin for Nushell
|
203
nu_plugin_fluent/src/commands/create_bundle.rs
Normal file
203
nu_plugin_fluent/src/commands/create_bundle.rs
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||||
|
use nu_protocol::{
|
||||||
|
Category, LabeledError, Signature, Span, SyntaxShape, Type, Value, record,
|
||||||
|
};
|
||||||
|
use fluent::{FluentBundle, FluentResource};
|
||||||
|
use unic_langid::LanguageIdentifier;
|
||||||
|
use crate::FluentPlugin;
|
||||||
|
|
||||||
|
pub struct CreateBundle;
|
||||||
|
|
||||||
|
impl SimplePluginCommand for CreateBundle {
|
||||||
|
type Plugin = FluentPlugin;
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"fluent-create-bundle"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature(&self) -> Signature {
|
||||||
|
Signature::build(PluginCommand::name(self))
|
||||||
|
.input_output_type(Type::Any, Type::Record(vec![].into()))
|
||||||
|
.required("locale", SyntaxShape::String, "Locale code (e.g., en-US)")
|
||||||
|
.named(
|
||||||
|
"global",
|
||||||
|
SyntaxShape::List(Box::new(SyntaxShape::Filepath)),
|
||||||
|
"Global FTL files to include",
|
||||||
|
Some('g'),
|
||||||
|
)
|
||||||
|
.named(
|
||||||
|
"page",
|
||||||
|
SyntaxShape::List(Box::new(SyntaxShape::Filepath)),
|
||||||
|
"Page-specific FTL files to include",
|
||||||
|
Some('p'),
|
||||||
|
)
|
||||||
|
.named(
|
||||||
|
"fallback",
|
||||||
|
SyntaxShape::List(Box::new(SyntaxShape::String)),
|
||||||
|
"Fallback locales in order",
|
||||||
|
Some('f'),
|
||||||
|
)
|
||||||
|
.switch("override", "Allow page files to override global messages", Some('o'))
|
||||||
|
.category(Category::Strings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Create a merged Fluent bundle from global and page-specific locale files"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||||
|
vec![
|
||||||
|
nu_protocol::Example {
|
||||||
|
description: "Create bundle with global and page-specific files",
|
||||||
|
example: "fluent-create-bundle en-US --global [global/en-US/common.ftl] --page [pages/blog/en-US/blog.ftl]",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
nu_protocol::Example {
|
||||||
|
description: "Create bundle with fallback support",
|
||||||
|
example: "fluent-create-bundle es-ES --fallback [en-US] --global [global/es-ES/common.ftl]",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
_plugin: &Self::Plugin,
|
||||||
|
_engine: &EngineInterface,
|
||||||
|
call: &EvaluatedCall,
|
||||||
|
_input: &Value,
|
||||||
|
) -> Result<Value, LabeledError> {
|
||||||
|
let locale_code: String = call.req(0)?;
|
||||||
|
|
||||||
|
// Parse locale
|
||||||
|
let locale: LanguageIdentifier = locale_code.parse()
|
||||||
|
.map_err(|e| LabeledError::new("Invalid locale").with_label(format!("Invalid locale '{}': {}", locale_code, e), call.head))?;
|
||||||
|
|
||||||
|
// Create fallback locales
|
||||||
|
let mut locales = vec![locale.clone()];
|
||||||
|
if let Some(fallback_value) = call.get_flag("fallback")? {
|
||||||
|
let fallback_codes = extract_string_list(fallback_value)?;
|
||||||
|
for code in fallback_codes {
|
||||||
|
let fallback_locale: LanguageIdentifier = code.parse()
|
||||||
|
.map_err(|e| LabeledError::new("Invalid fallback locale").with_label(format!("Invalid fallback locale '{}': {}", code, e), call.head))?;
|
||||||
|
locales.push(fallback_locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create bundle
|
||||||
|
let mut bundle = FluentBundle::new(locales);
|
||||||
|
let allow_override = call.has_flag("override")?;
|
||||||
|
|
||||||
|
// Load global files first (lower priority)
|
||||||
|
if let Some(global_value) = call.get_flag("global")? {
|
||||||
|
let global_files = extract_file_list(global_value)?;
|
||||||
|
load_files_to_bundle(&mut bundle, &global_files, "global")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load page files (higher priority, can override global)
|
||||||
|
if let Some(page_value) = call.get_flag("page")? {
|
||||||
|
let page_files = extract_file_list(page_value)?;
|
||||||
|
if allow_override {
|
||||||
|
// Page files can override global messages
|
||||||
|
load_files_to_bundle(&mut bundle, &page_files, "page")?;
|
||||||
|
} else {
|
||||||
|
// Only add page messages that don't exist in global
|
||||||
|
load_files_to_bundle_no_override(&mut bundle, &page_files)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract bundle information
|
||||||
|
let bundle_info = extract_bundle_info(&bundle, &locale_code, call.head);
|
||||||
|
|
||||||
|
Ok(bundle_info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_string_list(value: Value) -> Result<Vec<String>, LabeledError> {
|
||||||
|
match value {
|
||||||
|
Value::List { vals, .. } => {
|
||||||
|
vals.iter()
|
||||||
|
.map(|v| value_to_string(v))
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
}
|
||||||
|
_ => Err(LabeledError::new("Invalid list").with_label("Must be a list of strings", nu_protocol::Span::unknown())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_file_list(value: Value) -> Result<Vec<String>, LabeledError> {
|
||||||
|
extract_string_list(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_files_to_bundle(
|
||||||
|
bundle: &mut FluentBundle<FluentResource>,
|
||||||
|
files: &[String],
|
||||||
|
source: &str,
|
||||||
|
) -> Result<(), LabeledError> {
|
||||||
|
for file_path in files {
|
||||||
|
let content = std::fs::read_to_string(file_path)
|
||||||
|
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read '{}': {}", file_path, e), nu_protocol::Span::unknown()))?;
|
||||||
|
|
||||||
|
let resource = FluentResource::try_new(content)
|
||||||
|
.map_err(|e| LabeledError::new("Invalid FTL").with_label(format!("Invalid FTL in '{}': {:?}", file_path, e), nu_protocol::Span::unknown()))?;
|
||||||
|
|
||||||
|
bundle.add_resource(resource)
|
||||||
|
.map_err(|errors| {
|
||||||
|
let error_msgs: Vec<String> = errors.iter()
|
||||||
|
.map(|e| format!("{:?}", e))
|
||||||
|
.collect();
|
||||||
|
LabeledError::new("Load error").with_label(format!(
|
||||||
|
"Failed to load {} file '{}': {}",
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
error_msgs.join(", ")
|
||||||
|
), nu_protocol::Span::unknown())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_files_to_bundle_no_override(
|
||||||
|
bundle: &mut FluentBundle<FluentResource>,
|
||||||
|
files: &[String],
|
||||||
|
) -> Result<(), LabeledError> {
|
||||||
|
// This is a simplified implementation - in practice, we'd need to
|
||||||
|
// check for conflicts before adding resources
|
||||||
|
load_files_to_bundle(bundle, files, "page")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_bundle_info(
|
||||||
|
_bundle: &FluentBundle<FluentResource>,
|
||||||
|
locale_code: &str,
|
||||||
|
span: Span,
|
||||||
|
) -> Value {
|
||||||
|
// Extract message IDs and basic info from the bundle
|
||||||
|
let message_ids = Vec::new();
|
||||||
|
let message_count = 0;
|
||||||
|
|
||||||
|
// This is a simplified extraction - the actual FluentBundle API
|
||||||
|
// doesn't directly expose message enumeration, so in practice
|
||||||
|
// we'd need to track this during bundle creation
|
||||||
|
|
||||||
|
Value::record(
|
||||||
|
record! {
|
||||||
|
"locale" => Value::string(locale_code.to_string(), span),
|
||||||
|
"message_count" => Value::int(message_count, span),
|
||||||
|
"message_ids" => Value::list(message_ids, span),
|
||||||
|
"bundle_type" => Value::string("merged".to_string(), span),
|
||||||
|
},
|
||||||
|
span,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_to_string(value: &Value) -> Result<String, LabeledError> {
|
||||||
|
match value {
|
||||||
|
Value::String { val, .. } => Ok(val.clone()),
|
||||||
|
Value::Int { val, .. } => Ok(val.to_string()),
|
||||||
|
Value::Float { val, .. } => Ok(val.to_string()),
|
||||||
|
Value::Bool { val, .. } => Ok(val.to_string()),
|
||||||
|
_ => Err(LabeledError::new("Type conversion error").with_label(
|
||||||
|
format!("Cannot convert {:?} to string", value.get_type()),
|
||||||
|
nu_protocol::Span::unknown()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
68
nu_plugin_fluent/src/commands/extract_messages.rs
Normal file
68
nu_plugin_fluent/src/commands/extract_messages.rs
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||||
|
use nu_protocol::{Category, LabeledError, Signature, SyntaxShape, Type, Value};
|
||||||
|
use crate::FluentPlugin;
|
||||||
|
|
||||||
|
pub struct ExtractMessages;
|
||||||
|
|
||||||
|
impl SimplePluginCommand for ExtractMessages {
|
||||||
|
type Plugin = FluentPlugin;
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"fluent-extract"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature(&self) -> Signature {
|
||||||
|
Signature::build(PluginCommand::name(self))
|
||||||
|
.input_output_type(Type::Any, Type::List(Box::new(Type::String)))
|
||||||
|
.required("file", SyntaxShape::Filepath, "FTL file to extract messages from")
|
||||||
|
.category(Category::Strings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Extract message IDs from a Fluent Translation List (.ftl) file"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||||
|
vec![
|
||||||
|
nu_protocol::Example {
|
||||||
|
description: "Extract message IDs from an FTL file",
|
||||||
|
example: "fluent-extract locales/en-US/main.ftl",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
_plugin: &Self::Plugin,
|
||||||
|
_engine: &EngineInterface,
|
||||||
|
call: &EvaluatedCall,
|
||||||
|
input: &Value,
|
||||||
|
) -> Result<Value, LabeledError> {
|
||||||
|
let _file_path: String = call.req(0)?;
|
||||||
|
|
||||||
|
let parsed = SimplePluginCommand::run(&ParseFtl, _plugin, _engine, call, input)?;
|
||||||
|
|
||||||
|
// Extract message IDs from the parsed result
|
||||||
|
if let Value::Record { val, .. } = parsed {
|
||||||
|
if let Some(Value::List { vals, .. }) = val.get("messages") {
|
||||||
|
let message_ids: Vec<Value> = vals
|
||||||
|
.iter()
|
||||||
|
.filter_map(|msg| {
|
||||||
|
if let Value::Record { val, .. } = msg {
|
||||||
|
val.get("id").cloned()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return Ok(Value::list(message_ids, call.head));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(LabeledError::new("Extract error").with_label("Failed to extract messages", call.head))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::commands::ParseFtl;
|
69
nu_plugin_fluent/src/commands/list_locales.rs
Normal file
69
nu_plugin_fluent/src/commands/list_locales.rs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||||
|
use nu_protocol::{Category, LabeledError, Signature, SyntaxShape, Type, Value};
|
||||||
|
use std::fs;
|
||||||
|
use crate::FluentPlugin;
|
||||||
|
|
||||||
|
pub struct ListLocales;
|
||||||
|
|
||||||
|
impl SimplePluginCommand for ListLocales {
|
||||||
|
type Plugin = FluentPlugin;
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"fluent-list-locales"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature(&self) -> Signature {
|
||||||
|
Signature::build(PluginCommand::name(self))
|
||||||
|
.input_output_type(Type::Any, Type::List(Box::new(Type::String)))
|
||||||
|
.required("directory", SyntaxShape::Directory, "Directory containing locale folders")
|
||||||
|
.category(Category::Strings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"List available locales from a directory structure"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||||
|
vec![
|
||||||
|
nu_protocol::Example {
|
||||||
|
description: "List available locales",
|
||||||
|
example: "fluent-list-locales ./locales",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
_plugin: &Self::Plugin,
|
||||||
|
_engine: &EngineInterface,
|
||||||
|
call: &EvaluatedCall,
|
||||||
|
_input: &Value,
|
||||||
|
) -> Result<Value, LabeledError> {
|
||||||
|
let directory: String = call.req(0)?;
|
||||||
|
|
||||||
|
let entries = fs::read_dir(&directory)
|
||||||
|
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read directory '{}': {}", directory, e), call.head))?;
|
||||||
|
|
||||||
|
let mut locales = Vec::new();
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry
|
||||||
|
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read directory entry: {}", e), call.head))?;
|
||||||
|
|
||||||
|
if entry.file_type()
|
||||||
|
.map_err(|e| LabeledError::new("File type error").with_label(format!("Failed to get file type: {}", e), call.head))?
|
||||||
|
.is_dir()
|
||||||
|
{
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
// Basic locale pattern matching (e.g., en-US, es-ES)
|
||||||
|
if name.len() == 5 && name.chars().nth(2) == Some('-') {
|
||||||
|
locales.push(Value::string(name.to_string(), call.head));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Value::list(locales, call.head))
|
||||||
|
}
|
||||||
|
}
|
253
nu_plugin_fluent/src/commands/localize.rs
Normal file
253
nu_plugin_fluent/src/commands/localize.rs
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||||
|
use nu_protocol::{
|
||||||
|
Category, LabeledError, Signature, SyntaxShape, Type, Value
|
||||||
|
};
|
||||||
|
use fluent::{FluentBundle, FluentResource, FluentArgs, FluentValue};
|
||||||
|
use unic_langid::LanguageIdentifier;
|
||||||
|
use crate::FluentPlugin;
|
||||||
|
|
||||||
|
pub struct Localize;
|
||||||
|
|
||||||
|
impl SimplePluginCommand for Localize {
|
||||||
|
type Plugin = FluentPlugin;
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"fluent-localize"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature(&self) -> Signature {
|
||||||
|
Signature::build(PluginCommand::name(self))
|
||||||
|
.input_output_type(Type::Any, Type::String)
|
||||||
|
.required("message_id", SyntaxShape::String, "Message ID to localize")
|
||||||
|
.required("locale", SyntaxShape::String, "Locale code (e.g., en-US)")
|
||||||
|
.named(
|
||||||
|
"bundle",
|
||||||
|
SyntaxShape::Record(vec![]),
|
||||||
|
"Pre-loaded message bundle",
|
||||||
|
Some('b'),
|
||||||
|
)
|
||||||
|
.named(
|
||||||
|
"args",
|
||||||
|
SyntaxShape::Record(vec![]),
|
||||||
|
"Arguments for message interpolation",
|
||||||
|
Some('a'),
|
||||||
|
)
|
||||||
|
.named(
|
||||||
|
"files",
|
||||||
|
SyntaxShape::List(Box::new(SyntaxShape::Filepath)),
|
||||||
|
"FTL files to load",
|
||||||
|
Some('f'),
|
||||||
|
)
|
||||||
|
.switch("fallback", "Return message ID if translation not found", Some('F'))
|
||||||
|
.category(Category::Strings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Localize a message using Fluent translation system with hierarchical fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||||
|
vec![
|
||||||
|
nu_protocol::Example {
|
||||||
|
description: "Localize a simple message",
|
||||||
|
example: "fluent-localize welcome-message en-US",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
nu_protocol::Example {
|
||||||
|
description: "Localize with arguments",
|
||||||
|
example: "fluent-localize user-greeting en-US --args {name: 'Alice'}",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
nu_protocol::Example {
|
||||||
|
description: "Use pre-loaded bundle",
|
||||||
|
example: "let bundle = (fluent-parse messages.ftl); fluent-localize welcome-message en-US --bundle $bundle",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
_plugin: &Self::Plugin,
|
||||||
|
_engine: &EngineInterface,
|
||||||
|
call: &EvaluatedCall,
|
||||||
|
_input: &Value,
|
||||||
|
) -> Result<Value, LabeledError> {
|
||||||
|
let message_id: String = call.req(0)?;
|
||||||
|
let locale_code: String = call.req(1)?;
|
||||||
|
|
||||||
|
// Parse locale
|
||||||
|
let locale: LanguageIdentifier = locale_code.parse()
|
||||||
|
.map_err(|e| LabeledError::new("Invalid locale").with_label(format!("Invalid locale '{}': {}", locale_code, e), call.head))?;
|
||||||
|
|
||||||
|
// Create FluentBundle
|
||||||
|
let mut bundle = FluentBundle::new(vec![locale.clone()]);
|
||||||
|
|
||||||
|
// Load messages from various sources
|
||||||
|
if let Some(bundle_value) = call.get_flag("bundle")? {
|
||||||
|
// Use pre-loaded bundle (from cache or previous parse)
|
||||||
|
load_from_bundle_value(&mut bundle, bundle_value)?;
|
||||||
|
} else if let Some(files_value) = call.get_flag("files")? {
|
||||||
|
// Load from FTL files
|
||||||
|
let files = extract_file_list(files_value)?;
|
||||||
|
load_from_files(&mut bundle, &files)?;
|
||||||
|
} else {
|
||||||
|
return Err(LabeledError::new("Missing argument").with_label("Must provide either --bundle or --files", call.head));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare arguments for interpolation
|
||||||
|
let fluent_args = if let Some(args_value) = call.get_flag("args")? {
|
||||||
|
Some(convert_to_fluent_args(args_value)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the message
|
||||||
|
let msg = match bundle.get_message(&message_id) {
|
||||||
|
Some(msg) => msg,
|
||||||
|
None => {
|
||||||
|
if call.has_flag("fallback").unwrap_or(false) {
|
||||||
|
// Return message ID as fallback
|
||||||
|
return Ok(Value::string(format!("[[{}]]", message_id), call.head));
|
||||||
|
} else {
|
||||||
|
return Err(LabeledError::new("Message not found").with_label(format!("Message '{}' not found in locale '{}'", message_id, locale_code), call.head));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format the message
|
||||||
|
let pattern = msg.value()
|
||||||
|
.ok_or_else(|| LabeledError::new("Message has no value").with_label(format!("Message '{}' has no value", message_id), call.head))?;
|
||||||
|
|
||||||
|
let mut errors = vec![];
|
||||||
|
let formatted = bundle.format_pattern(
|
||||||
|
pattern,
|
||||||
|
fluent_args.as_ref(),
|
||||||
|
&mut errors
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle formatting errors
|
||||||
|
if !errors.is_empty() {
|
||||||
|
let error_msgs: Vec<String> = errors.iter()
|
||||||
|
.map(|e| format!("{:?}", e))
|
||||||
|
.collect();
|
||||||
|
return Err(LabeledError::new("Formatting error").with_label(format!(
|
||||||
|
"Formatting errors for message '{}': {}",
|
||||||
|
message_id,
|
||||||
|
error_msgs.join(", ")
|
||||||
|
), call.head));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Value::string(formatted.to_string(), call.head))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from_bundle_value(
|
||||||
|
bundle: &mut FluentBundle<FluentResource>,
|
||||||
|
bundle_value: Value,
|
||||||
|
) -> Result<(), LabeledError> {
|
||||||
|
match bundle_value {
|
||||||
|
Value::Record { val, .. } => {
|
||||||
|
// Extract messages from parsed bundle record
|
||||||
|
if let Some(messages_value) = val.get("messages") {
|
||||||
|
load_messages_from_value(bundle, messages_value)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(LabeledError::new("Invalid bundle").with_label("Bundle must be a record", nu_protocol::Span::unknown())),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_messages_from_value(
|
||||||
|
bundle: &mut FluentBundle<FluentResource>,
|
||||||
|
messages_value: &Value,
|
||||||
|
) -> Result<(), LabeledError> {
|
||||||
|
match messages_value {
|
||||||
|
Value::List { vals, .. } => {
|
||||||
|
for message_val in vals {
|
||||||
|
if let Value::Record { val, .. } = message_val {
|
||||||
|
if let (Some(id_val), Some(text_val)) = (val.get("id"), val.get("text")) {
|
||||||
|
let id = value_to_string(id_val)?;
|
||||||
|
let text = value_to_string(text_val)?;
|
||||||
|
|
||||||
|
// Create a minimal FTL resource from the message
|
||||||
|
let ftl_content = format!("{} = {}", id, text);
|
||||||
|
let resource = FluentResource::try_new(ftl_content)
|
||||||
|
.map_err(|_| LabeledError::new("Invalid FTL").with_label(format!("Invalid FTL for message '{}'", id), nu_protocol::Span::unknown()))?;
|
||||||
|
|
||||||
|
bundle.add_resource(resource)
|
||||||
|
.map_err(|_| LabeledError::new("Failed to add message").with_label(format!("Failed to add message '{}'", id), nu_protocol::Span::unknown()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(LabeledError::new("Invalid messages").with_label("Messages must be a list", nu_protocol::Span::unknown())),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from_files(
|
||||||
|
bundle: &mut FluentBundle<FluentResource>,
|
||||||
|
files: &[String],
|
||||||
|
) -> Result<(), LabeledError> {
|
||||||
|
for file_path in files {
|
||||||
|
let content = std::fs::read_to_string(file_path)
|
||||||
|
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read '{}': {}", file_path, e), nu_protocol::Span::unknown()))?;
|
||||||
|
|
||||||
|
let resource = FluentResource::try_new(content)
|
||||||
|
.map_err(|e| LabeledError::new("Invalid FTL").with_label(format!("Invalid FTL in '{}': {:?}", file_path, e), nu_protocol::Span::unknown()))?;
|
||||||
|
|
||||||
|
bundle.add_resource(resource)
|
||||||
|
.map_err(|e| LabeledError::new("Load error").with_label(format!("Failed to load '{}': {:?}", file_path, e), nu_protocol::Span::unknown()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_file_list(files_value: Value) -> Result<Vec<String>, LabeledError> {
|
||||||
|
match files_value {
|
||||||
|
Value::List { vals, .. } => {
|
||||||
|
vals.iter()
|
||||||
|
.map(|v| value_to_string(v))
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
}
|
||||||
|
_ => Err(LabeledError::new("Invalid files").with_label("Files must be a list of strings", nu_protocol::Span::unknown())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_to_fluent_args(args_value: Value) -> Result<FluentArgs<'static>, LabeledError> {
|
||||||
|
let mut fluent_args = FluentArgs::new();
|
||||||
|
|
||||||
|
match args_value {
|
||||||
|
Value::Record { val, .. } => {
|
||||||
|
for (key, value) in val.iter() {
|
||||||
|
let fluent_value = match value {
|
||||||
|
Value::String { val, .. } => FluentValue::from(val.clone()),
|
||||||
|
Value::Int { val, .. } => FluentValue::from(*val as f64),
|
||||||
|
Value::Float { val, .. } => FluentValue::from(*val),
|
||||||
|
_ => return Err(LabeledError::new("Unsupported argument type").with_label(format!(
|
||||||
|
"Unsupported argument type for '{}': {:?}",
|
||||||
|
key,
|
||||||
|
value.get_type()
|
||||||
|
), nu_protocol::Span::unknown())),
|
||||||
|
};
|
||||||
|
fluent_args.set(key.clone(), fluent_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(LabeledError::new("Invalid arguments").with_label("Arguments must be a record", nu_protocol::Span::unknown())),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(fluent_args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_to_string(value: &Value) -> Result<String, LabeledError> {
|
||||||
|
match value {
|
||||||
|
Value::String { val, .. } => Ok(val.clone()),
|
||||||
|
Value::Int { val, .. } => Ok(val.to_string()),
|
||||||
|
Value::Float { val, .. } => Ok(val.to_string()),
|
||||||
|
Value::Bool { val, .. } => Ok(val.to_string()),
|
||||||
|
_ => Err(LabeledError::new("Type conversion error").with_label(
|
||||||
|
format!("Cannot convert {:?} to string", value.get_type()),
|
||||||
|
nu_protocol::Span::unknown()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
14
nu_plugin_fluent/src/commands/mod.rs
Normal file
14
nu_plugin_fluent/src/commands/mod.rs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
mod parse_ftl;
|
||||||
|
mod localize;
|
||||||
|
mod validate_ftl;
|
||||||
|
mod extract_messages;
|
||||||
|
mod list_locales;
|
||||||
|
mod create_bundle;
|
||||||
|
|
||||||
|
pub use parse_ftl::ParseFtl;
|
||||||
|
pub use localize::Localize;
|
||||||
|
pub use validate_ftl::ValidateFtl;
|
||||||
|
pub use extract_messages::ExtractMessages;
|
||||||
|
pub use list_locales::ListLocales;
|
||||||
|
pub use create_bundle::CreateBundle;
|
||||||
|
|
143
nu_plugin_fluent/src/commands/parse_ftl.rs
Normal file
143
nu_plugin_fluent/src/commands/parse_ftl.rs
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||||
|
use nu_protocol::{
|
||||||
|
Category, LabeledError, Signature, Span, SyntaxShape, Type, Value, record
|
||||||
|
};
|
||||||
|
use fluent_syntax::parser::parse;
|
||||||
|
use fluent_syntax::ast::{Entry, Message, Pattern, PatternElement};
|
||||||
|
use std::fs;
|
||||||
|
use crate::FluentPlugin;
|
||||||
|
|
||||||
|
pub struct ParseFtl;
|
||||||
|
|
||||||
|
impl SimplePluginCommand for ParseFtl {
|
||||||
|
type Plugin = FluentPlugin;
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"fluent-parse"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature(&self) -> Signature {
|
||||||
|
Signature::build(PluginCommand::name(self))
|
||||||
|
.input_output_type(Type::Any, Type::Record(vec![].into()))
|
||||||
|
.required("file", SyntaxShape::Filepath, "FTL file to parse")
|
||||||
|
.category(Category::Strings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Parse a Fluent Translation List (.ftl) file and return message structure"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||||
|
vec![
|
||||||
|
nu_protocol::Example {
|
||||||
|
description: "Parse an FTL file",
|
||||||
|
example: "fluent-parse locales/en-US/main.ftl",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
_plugin: &Self::Plugin,
|
||||||
|
_engine: &EngineInterface,
|
||||||
|
call: &EvaluatedCall,
|
||||||
|
_input: &Value,
|
||||||
|
) -> Result<Value, LabeledError> {
|
||||||
|
let file_path: String = call.req(0)?;
|
||||||
|
|
||||||
|
// Read FTL file
|
||||||
|
let ftl_content = fs::read_to_string(&file_path)
|
||||||
|
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read file '{}': {}", file_path, e), call.head))?;
|
||||||
|
|
||||||
|
// Parse FTL content
|
||||||
|
let resource = match parse(ftl_content) {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err((_, errors)) => {
|
||||||
|
return Err(LabeledError::new("Parse error").with_label(format!("Failed to parse FTL content: {:?}", errors), call.head));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert AST to Nushell values
|
||||||
|
let messages = extract_messages_from_resource(&resource, call.head)
|
||||||
|
.map_err(|e| LabeledError::new("Extract error").with_label(e, call.head))?;
|
||||||
|
|
||||||
|
let message_count = messages.len() as i64;
|
||||||
|
let result = Value::record(
|
||||||
|
record! {
|
||||||
|
"file" => Value::string(file_path, call.head),
|
||||||
|
"messages" => Value::list(messages, call.head),
|
||||||
|
"message_count" => Value::int(message_count, call.head),
|
||||||
|
},
|
||||||
|
call.head,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_messages_from_resource(resource: &fluent_syntax::ast::Resource<String>, span: Span) -> Result<Vec<Value>, String> {
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
|
for entry in &resource.body {
|
||||||
|
if let Entry::Message(message) = entry {
|
||||||
|
let message_value = extract_message_info(message, span)?;
|
||||||
|
messages.push(message_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_message_info(message: &Message<String>, span: Span) -> Result<Value, String> {
|
||||||
|
let id = message.id.name.to_string();
|
||||||
|
|
||||||
|
// Extract the default text from the pattern
|
||||||
|
let text = if let Some(ref pattern) = message.value {
|
||||||
|
extract_pattern_text(pattern)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract attributes
|
||||||
|
let mut attributes = Vec::new();
|
||||||
|
for attribute in &message.attributes {
|
||||||
|
let attr_value = Value::record(
|
||||||
|
record! {
|
||||||
|
"name" => Value::string(attribute.id.name.to_string(), span),
|
||||||
|
"value" => Value::string(extract_pattern_text(&attribute.value), span),
|
||||||
|
},
|
||||||
|
span,
|
||||||
|
);
|
||||||
|
attributes.push(attr_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract comment if present
|
||||||
|
let comment = message.comment.as_ref()
|
||||||
|
.map(|c| c.content.iter().map(|line| line.trim()).collect::<Vec<_>>().join("\n"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(Value::record(
|
||||||
|
record! {
|
||||||
|
"id" => Value::string(id, span),
|
||||||
|
"text" => Value::string(text, span),
|
||||||
|
"attributes" => Value::list(attributes, span),
|
||||||
|
"comment" => Value::string(comment, span),
|
||||||
|
},
|
||||||
|
span,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_pattern_text(pattern: &Pattern<String>) -> String {
|
||||||
|
pattern.elements
|
||||||
|
.iter()
|
||||||
|
.map(|element| match element {
|
||||||
|
PatternElement::TextElement { value } => value.to_string(),
|
||||||
|
PatternElement::Placeable { ref expression } => {
|
||||||
|
// For now, just return placeholder representation
|
||||||
|
format!("{{{}}}", format!("{:?}", expression).replace("\"", ""))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
}
|
76
nu_plugin_fluent/src/commands/validate_ftl.rs
Normal file
76
nu_plugin_fluent/src/commands/validate_ftl.rs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||||
|
use nu_protocol::{Category, LabeledError, Signature, SyntaxShape, Type, Value, record};
|
||||||
|
use fluent_syntax::parser::parse;
|
||||||
|
use crate::FluentPlugin;
|
||||||
|
|
||||||
|
pub struct ValidateFtl;
|
||||||
|
|
||||||
|
impl SimplePluginCommand for ValidateFtl {
|
||||||
|
type Plugin = FluentPlugin;
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"fluent-validate"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature(&self) -> Signature {
|
||||||
|
Signature::build(PluginCommand::name(self))
|
||||||
|
.input_output_type(Type::Any, Type::Record(vec![].into()))
|
||||||
|
.required("file", SyntaxShape::Filepath, "FTL file to validate")
|
||||||
|
.category(Category::Strings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Validate a Fluent Translation List (.ftl) file syntax"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||||
|
vec![
|
||||||
|
nu_protocol::Example {
|
||||||
|
description: "Validate an FTL file",
|
||||||
|
example: "fluent-validate locales/en-US/main.ftl",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
_plugin: &Self::Plugin,
|
||||||
|
_engine: &EngineInterface,
|
||||||
|
call: &EvaluatedCall,
|
||||||
|
_input: &Value,
|
||||||
|
) -> Result<Value, LabeledError> {
|
||||||
|
let file_path: String = call.req(0)?;
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&file_path)
|
||||||
|
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read '{}': {}", file_path, e), call.head))?;
|
||||||
|
|
||||||
|
let parse_result = parse(content);
|
||||||
|
let result = match parse_result {
|
||||||
|
Ok(_resource) => Value::record(
|
||||||
|
record! {
|
||||||
|
"valid" => Value::bool(true, call.head),
|
||||||
|
"file" => Value::string(file_path, call.head),
|
||||||
|
"errors" => Value::list(vec![], call.head),
|
||||||
|
},
|
||||||
|
call.head,
|
||||||
|
),
|
||||||
|
Err((_resource, errors)) => {
|
||||||
|
let error_list: Vec<Value> = errors.iter()
|
||||||
|
.map(|err| Value::string(format!("{:?}", err), call.head))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Value::record(
|
||||||
|
record! {
|
||||||
|
"valid" => Value::bool(false, call.head),
|
||||||
|
"file" => Value::string(file_path, call.head),
|
||||||
|
"errors" => Value::list(error_list, call.head),
|
||||||
|
},
|
||||||
|
call.head,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
25
nu_plugin_fluent/src/fluent_plugin.rs
Normal file
25
nu_plugin_fluent/src/fluent_plugin.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use nu_plugin::{Plugin, PluginCommand};
|
||||||
|
|
||||||
|
use crate::commands::{
|
||||||
|
CreateBundle, ExtractMessages, ListLocales, Localize, ParseFtl, ValidateFtl,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct FluentPlugin;
|
||||||
|
|
||||||
|
impl Plugin for FluentPlugin {
|
||||||
|
fn version(&self) -> String {
|
||||||
|
env!("CARGO_PKG_VERSION").into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commands(&self) -> Vec<Box<dyn PluginCommand<Plugin = Self>>> {
|
||||||
|
vec![
|
||||||
|
Box::new(ParseFtl),
|
||||||
|
Box::new(Localize),
|
||||||
|
Box::new(ValidateFtl),
|
||||||
|
Box::new(ExtractMessages),
|
||||||
|
Box::new(ListLocales),
|
||||||
|
Box::new(CreateBundle),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
9
nu_plugin_fluent/src/main.rs
Normal file
9
nu_plugin_fluent/src/main.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
use nu_plugin::{serve_plugin, MsgPackSerializer};
|
||||||
|
|
||||||
|
mod commands;
|
||||||
|
mod fluent_plugin;
|
||||||
|
use fluent_plugin::FluentPlugin;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
serve_plugin(&FluentPlugin, MsgPackSerializer {});
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user