Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/preview/tieflang/0.1.0/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 Lena Tauchner

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
208 changes: 208 additions & 0 deletions packages/preview/tieflang/0.1.0/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# TiefLang

TiefLang is a namespaced, stack-based language resolver with dictionary-backed keys. Essentially a modular translation engine for Typst templates. If your template is multilingual, you need TiefLang. Otherwise, you're out here writing your own translation system, in which case... Wowie. Gz. Please use a library ;)

## Setup

Import the library:

```typst
#import "@preview/tieflang:0.1.0": (
configure-translations, tr, // These you'll always need
pop-lang, push-lang, trk, // These are optional
select-language, // You should only import this if you plan to expose select-language. See the common pitfalls section.
)
```

First, create a dictionary with your translations like so:

```typst
#let translations = (
de-DE: (
key1: [Bahnhofsstraße 1],
key2: (
subkey1: [Wohnung 1],
subkey2: [Wohnung 2],
),
),
de-CH: (
key1: [Bahnhofsstrasse 1],
key2: (
subkey1: [Top 1],
subkey2: [Top 2],
),
),
en-US: (
key1: [Bahnhof Street 1],
key2: (
subkey1: [Flat 1],
subkey2: [Flat 2],
),
),
)
```

Then simply call `configure-translations(translations)`. This is sufficient for mono-templates that don't call other libraries using TiefLang.

The language codes used here can be anything and are not bound to 'xx-XX'. There are currently no fallback mechanisms. Be sure to document your available language codes, best practice is to create a dictionary with the available ones like so:

```typst
#let languages = (
de-DE: "de-DE",
de-CH: "de-CH",
en-US: "en-US",
german: "de-DE",
german-germany: "de-DE",
german-switzerland: "de-CH",
english-united-states: "en-US",
)
```

This way, users have a human readable and type friendly interface for interacting with the internationalizations.

## Usage

Access your translations using the `tr` command:

```typst
// These produce the same output!
#tr().key1
#tr("key1")
#trk("key1")
```

That is the basic usecase, probably enough for most templates. `trk()` works identically to `tr(key)`, the latter being syntactic sugar.

## User facing

You have two options when it comes to user facing APIs. Either expose a `lang` parameter in your template and call `push-lang(lang)` or instruct the user to select their language using the `push-lang`/`select-lang` methods (these are currently aliases).

If you chose to call push-lang yourself, I recommend calling `pop-lang(lang)` after it as to allow nested language changes to work.

*In the background, the languages are a stack. Do not call `pop-lang` without first pushing a language.*

## Advanced

### `tr` vs `trk`

`tr()` with no arguments returns the translation dictionary for the current language in the namespace. `trk("key")` is always a direct key lookup.

However, `tr("key")` also works and simply delegates the lookup to the `trk()` function.

```typst
// All produce the same output
#tr().key1
#tr("key1")
#trk("key1")
```

### Nested keys and dot notation

Keys can be nested in dictionaries and accessed using dot notation.

```typst
#trk("key2.subkey1")
```

Similarly, `tr()` can be used with nested keys:

```typst
#tr().key2.subkey1
```

### Function values and arguments

Translations can be functions. When the value is a function, `tr`/`trk` call it with any extra arguments you pass.

```typst
#let translations = (
en-US: (
welcome: (name) => [Hello #name],
),
)

#configure-translations(translations)
#trk("welcome", "Lena") // Outputs Hello Lena
#(tr().welcome)("Lena") // Also outputs Hello Lena
```

### Namespaces

If you have multiple libraries using TiefLang, keep their translations separate with namespaces. Each `configure-translations` call stores data under a namespace, and you then pass that namespace to `tr`/`trk`.

```typst
#configure-translations(core, namespace: "core")
#configure-translations(ui, namespace: "ui")

#tr("title", namespace: "ui")
```

### Language stack semantics

Languages are a stack. `push-lang` pushes a language on top, and `pop-lang` removes the top entry. `select-language` and `restore-language` are aliases.

```typst
#push-lang("de-DE")
#tr("key1")
#pop-lang()
```

### Strict mode and missing keys

By default, missing keys return a bold red placeholder like `??? key ???`. If you want missing keys to be hard errors, enable strict mode. **Strict mode is recommended for production templates, but will break builds on missing keys.**

```typst
#configure-translations(translations, strict: true)
```

### Multiple configuration calls

Calling `configure-translations` multiple times is fine as long as each namespace is distinct. Each namespace keeps its own language list, default language, and translation dictionary.

### Default language and fallback

You can set a default language per namespace. If the language stack is empty, this default is used.

```typst
#configure-translations(translations, default: "de-CH")
#tr("key1") // uses de-CH if no language was pushed
```

### Unknown languages

If you try to use a language that doesn't exist in the current namespace, TiefLang throws an error: `Language definition for 'xx-YY' does not exist.`

## Common pitfalls

There's pitfalls I have to document because otherwise, someone is going to make an issue. Excuse the sass, you try figuring out what all can break on your 5th coffee.

### "How did I do the language setting???" ~ Some user

You don't *have* to set languages in your template. Sometimes, not always, it's better if you don't. But then, you need to do one of the following:

- Import and expose `select-language` from your template and tell the user to use it. This is the preferred way if you're building a standalone template, as it's easier on the user.
- Let the user `#import "@preview/tieflang:0.1.0": select-language`. This is preferred if you are building a package that isn't a standalone template, as it does not contaminate the exports.

For more information, you may contact me for typst best practices.

### pop-lang is pulling from a stack

Calling `pop-lang` without a matching `push-lang` will throw an error.

### Language codes must exist

Using a language code that is not in your translations will error immediately.

### Default locale is en-US

Forgetting to set `default` means TiefLang falls back to `en-US` for that namespace.

Remember to either set default or accept that `en-US` is your default locale and don't forget that it is then essentially required.

### Nested keys are not magic

Nested keys require dot notation; `"key2.subkey1"` is not the same as `"key2"` or `"subkey1"`.

## Strict mode is kinda important

In non-strict mode, missing keys render as `??? key ???` instead of failing fast.
49 changes: 49 additions & 0 deletions packages/preview/tieflang/0.1.0/core/setup.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#import "state.typ": available-langs, current-lang-stack, default-lang, is-strict-mode-enabled, stored-translations

// CORE FUNCTIONS

#let configure-translations = (
translations,
namespace: "default",
strict: false,
default: none,
) => {
stored-translations.update(t => (..t, (namespace): translations))
default-lang.update(d => (..d, (namespace): default))
is-strict-mode-enabled.update(s => if strict == none or type(strict) != bool { return s } else { return strict })
available-langs.update(l => (..l, (namespace): translations.keys()))
current-lang-stack.update(c => {
if c == none {
(default)
} else {
c
}
})
}

#let push-lang = lang => {
context {
current-lang-stack.update(c => {
(
lang,
..c,
)
})
}
}

#let pop-lang = () => {
context {
assert(current-lang-stack.get().len() > 0, message: "There was no language left to pop.")

current-lang-stack.update(c => {
c.slice(1)
})
}
}

// ALIASES

#let select-language = push-lang

#let restore-language = pop-lang
5 changes: 5 additions & 0 deletions packages/preview/tieflang/0.1.0/core/state.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#let stored-translations = state("stored-translations", (default: (:)))
#let default-lang = state("default-lang", (default: "en-US"))
#let is-strict-mode-enabled = state("is-strict-mode-enabled", false)
#let available-langs = state("available-langs", (default: ()))
#let current-lang-stack = state("current-lang", ())
78 changes: 78 additions & 0 deletions packages/preview/tieflang/0.1.0/core/tr.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#import "state.typ": current-lang-stack, default-lang, is-strict-mode-enabled, stored-translations

#let get-translations = namespace => {
let translations-in-namespace = stored-translations.get().at(namespace)
let lang = current-lang-stack.get().first(default: default-lang.get().at(namespace))

assert(
translations-in-namespace.keys().contains(lang),
message: "Language definition for '" + lang + "' does not exist.",
)
translations-in-namespace.at(lang)
}

#let resolve-key = key => key.split(".")

#let get-translation = (translations, key) => {
let key-parts = resolve-key(key)

let latest-elem = translations

for (i, key-part) in key-parts.enumerate() {
latest-elem = latest-elem.at(key-part, default: none)

assert(
i < key-parts.len() or type(latest-elem) == dictionary,
message: "Requested element '"
+ key-part
+ "' was not accessible, as its type was not a dictionary but '"
+ str(type(latest-elem))
+ "'.",
)
if latest-elem == none {
assert(
not is-strict-mode-enabled.get(),
message: "Translation '" + key + "' could not be found.",
)

return [#text(fill: red, weight: "bold")[??? #key ???]]
}
}

latest-elem
}

#let trk = (
key,
..args,
namespace: "default",
) => {
let translations = get-translations(namespace)

assert(key != none, message: "Cannot translate a key of 'none'.")

let result = get-translation(translations, key)

if type(result) == function and args.pos().len() > 0 {
result(..args)
} else {
result
}
}

#let tr = (
..args,
namespace: "default",
) => {
let translations = get-translations(namespace)

let key = args.pos().first(default: none)

if key == none {
assert(args.pos().len() == 0, message: "Cannot translate a key of 'none' with arguments.")

return translations
}

return trk(key, ..args.pos().slice(1))
}
2 changes: 2 additions & 0 deletions packages/preview/tieflang/0.1.0/lib.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#import "core/setup.typ": configure-translations, pop-lang, push-lang, restore-language, select-language
#import "core/tr.typ": tr, trk
13 changes: 13 additions & 0 deletions packages/preview/tieflang/0.1.0/typst.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "tieflang"
version = "0.1.0"
compiler = "0.14.0"
authors = ["Lena Tauchner"]
entrypoint = "lib.typ"
license = "MIT"
description = "Translation Library for Tief* Templates"
keywords = ["i18n", "translations"]
categories = ["languages", "utility"]
disciplines = []
repository = "https://github.com/Tiefseetauchner/TiefLang"
homepage = "https://www.lukechriswalker.at"
Loading
Loading