├── demo ├── public │ ├── bundles │ │ ├── bar.en.ftl │ │ ├── foo.en.ftl │ │ ├── bar.en.properties │ │ ├── foo.en.properties │ │ ├── bar.en.json │ │ └── foo.en.json │ ├── language │ │ ├── example.de.json │ │ ├── example.fr.json │ │ ├── example.en.json │ │ └── example.ja.json │ ├── intro │ │ ├── example.en.ftl │ │ ├── example.en.json │ │ └── example.en.properties │ ├── consistency │ │ ├── example.de.ftl │ │ ├── example.de.properties │ │ ├── example.en.ftl │ │ ├── example.de.json │ │ ├── example.en.properties │ │ └── example.en.json │ ├── interpolation │ │ ├── example.en.properties │ │ └── example.en.json │ ├── date-format │ │ └── example.en.ftl │ ├── terms │ │ └── example.en.ftl │ ├── plural-rules │ │ └── example.en.ftl │ ├── number-format │ │ └── example.en.ftl │ ├── case-interpolation │ │ └── example.en.ftl │ ├── flag_en.svg │ ├── flag_de.svg │ ├── html │ │ ├── example.en.ftl │ │ ├── example.en.properties │ │ └── example.en.json │ └── 404.html ├── .gitignore ├── translations │ ├── shared.de.ftl │ ├── shared.en.ftl │ ├── terms.en.ftl │ ├── terms.de.ftl │ ├── plural_rules.en.ftl │ ├── case_interpolation.en.ftl │ ├── plural_rules.de.ftl │ ├── case_interpolation.de.ftl │ ├── bundles.en.ftl │ ├── language.en.ftl │ ├── date_format.en.ftl │ ├── bundles.de.ftl │ ├── number_format.en.ftl │ ├── language.de.ftl │ ├── date_format.de.ftl │ ├── number_format.de.ftl │ ├── consistency.en.ftl │ ├── consistency.de.ftl │ ├── interpolation.en.ftl │ ├── interpolation.de.ftl │ ├── html.en.ftl │ ├── html.de.ftl │ ├── intro.en.ftl │ └── intro.de.ftl ├── vite.config.ts ├── index.js ├── package.json ├── src │ ├── InputType.elm │ ├── File.elm │ ├── Msg.elm │ ├── Pages │ │ ├── Terms.elm │ │ ├── PluralRules.elm │ │ ├── DateFormat.elm │ │ ├── NumberFormat.elm │ │ ├── CaseInterpolation.elm │ │ ├── Bundles.elm │ │ ├── Language.elm │ │ ├── Html.elm │ │ ├── Interpolation.elm │ │ ├── Consistency.elm │ │ └── Intro.elm │ ├── Accordion.elm │ ├── Model.elm │ ├── Page.elm │ └── code-component.js ├── index.html ├── elm.json ├── main.css └── tsconfig.json ├── README.md ├── gen_test_cases ├── Util │ ├── FilePort.elm │ ├── CustomHtmlAttributes.elm │ └── CustomHtml.elm ├── SingleTextCase.elm ├── SingleInterpolationCase.elm ├── DateFormatCase.elm ├── NumberFormatCase.elm ├── NamespacingCase.elm ├── SimpleHtmlCase.elm ├── HashCase.elm ├── MultiBundleCase.elm ├── MultiLanguageTextCase.elm ├── SimpleI18nFirstCase.elm ├── PluralCase.elm ├── CustomHtmlModuleCase.elm ├── FallbackCase.elm ├── InterpolationMatchCase.elm ├── InconsistentLanguageCase.elm ├── NestedInterpolationCase.elm ├── MultiInterpolationCase.elm ├── ComplexI18nFirstCase.elm ├── MultiBundleLanguageCase.elm ├── HtmlInterpolationCase.elm ├── NestedHtmlCase.elm ├── EscapeCase.elm └── HtmlIntlCase.elm ├── src ├── Types │ ├── Basic.elm │ ├── InterpolationKind.elm │ ├── ArgValue.elm │ ├── Features.elm │ ├── Translation.elm │ └── UniqueName.elm ├── CodeGen │ ├── BasicM.elm │ └── DecodeM.elm ├── Util.elm ├── elm.d.ts ├── Parser │ └── DeadEnds.elm ├── Generators │ └── Names.elm ├── Dict │ └── NonEmpty.elm ├── Ports.elm ├── Main.elm └── ContentTypes │ └── Shared.elm ├── .gitignore ├── .npmignore ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── build_and_test.yml ├── package.json ├── generate_test_cases.js ├── elm.json ├── tests ├── RequestDecoderTest.elm ├── Types │ ├── UniqueNameTest.elm │ └── ErrorTest.elm └── Dict │ └── NonEmptyTest.elm ├── index.js ├── docs └── Changelog_4.0.adoc └── tsconfig.json /demo/public/bundles/bar.en.ftl: -------------------------------------------------------------------------------- 1 | bar = Bar Value -------------------------------------------------------------------------------- /demo/public/bundles/foo.en.ftl: -------------------------------------------------------------------------------- 1 | foo = Foo Value -------------------------------------------------------------------------------- /demo/public/bundles/bar.en.properties: -------------------------------------------------------------------------------- 1 | bar = Bar Value -------------------------------------------------------------------------------- /demo/public/bundles/foo.en.properties: -------------------------------------------------------------------------------- 1 | foo = Foo Value -------------------------------------------------------------------------------- /demo/public/bundles/bar.en.json: -------------------------------------------------------------------------------- 1 | { "bar": "Bar Value" } 2 | -------------------------------------------------------------------------------- /demo/public/bundles/foo.en.json: -------------------------------------------------------------------------------- 1 | { "foo": "Foo Value" } 2 | -------------------------------------------------------------------------------- /demo/public/language/example.de.json: -------------------------------------------------------------------------------- 1 | { "hello": "Hallo" } -------------------------------------------------------------------------------- /demo/public/language/example.fr.json: -------------------------------------------------------------------------------- 1 | { "hello": "Bonjour" } -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /public/i18n 3 | /src/Translations.elm -------------------------------------------------------------------------------- /demo/public/language/example.en.json: -------------------------------------------------------------------------------- 1 | { "hello": "Hello" } 2 | -------------------------------------------------------------------------------- /demo/public/language/example.ja.json: -------------------------------------------------------------------------------- 1 | { "hello": "こんにちは" } 2 | -------------------------------------------------------------------------------- /demo/public/intro/example.en.ftl: -------------------------------------------------------------------------------- 1 | headline = Fluent example 2 | body = Hello world! -------------------------------------------------------------------------------- /demo/public/consistency/example.de.ftl: -------------------------------------------------------------------------------- 1 | # fallback-language: en 2 | key = German value -------------------------------------------------------------------------------- /demo/public/consistency/example.de.properties: -------------------------------------------------------------------------------- 1 | # fallback-language: en 2 | key = German value -------------------------------------------------------------------------------- /demo/public/consistency/example.en.ftl: -------------------------------------------------------------------------------- 1 | key = English value 2 | otherKey = other english value -------------------------------------------------------------------------------- /demo/public/intro/example.en.json: -------------------------------------------------------------------------------- 1 | { "headline": "Json Example", "body": "Hello world!" } 2 | -------------------------------------------------------------------------------- /demo/public/intro/example.en.properties: -------------------------------------------------------------------------------- 1 | headline = Properties example 2 | body = Hello world! -------------------------------------------------------------------------------- /demo/public/consistency/example.de.json: -------------------------------------------------------------------------------- 1 | { "--fallback-language": "en", "key": "German value" } 2 | -------------------------------------------------------------------------------- /demo/public/consistency/example.en.properties: -------------------------------------------------------------------------------- 1 | key = English value 2 | other.key = other english value -------------------------------------------------------------------------------- /demo/public/consistency/example.en.json: -------------------------------------------------------------------------------- 1 | { "key": "English value", "otherKey": "other english value" } 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌎 Travelm Agency 2 | 3 | Please use the README at https://github.com/andreasewering/elm-i18n. 4 | -------------------------------------------------------------------------------- /demo/public/interpolation/example.en.properties: -------------------------------------------------------------------------------- 1 | greeting = Hello {target} 2 | plan = I plan to {todo} on {day} -------------------------------------------------------------------------------- /demo/public/interpolation/example.en.json: -------------------------------------------------------------------------------- 1 | { "greeting": "Hello {target}", "plan": "I plan to {todo} on {day}" } 2 | -------------------------------------------------------------------------------- /demo/public/date-format/example.en.ftl: -------------------------------------------------------------------------------- 1 | today = Today is the { DATETIME($date) } 2 | 3 | birthday = My birthday is { DATETIME("3456-01-02", dateStyle: "full") } -------------------------------------------------------------------------------- /demo/public/terms/example.en.ftl: -------------------------------------------------------------------------------- 1 | -email = person@example.com 2 | 3 | sendMail = Send a mail to { -email } 4 | receiveMail = You received a mail from { -email } -------------------------------------------------------------------------------- /gen_test_cases/Util/FilePort.elm: -------------------------------------------------------------------------------- 1 | port module Util.FilePort exposing (sendFile) 2 | 3 | 4 | port sendFile : { path : String, content : String } -> Cmd msg 5 | -------------------------------------------------------------------------------- /src/Types/Basic.elm: -------------------------------------------------------------------------------- 1 | module Types.Basic exposing (..) 2 | 3 | 4 | type alias Identifier = 5 | String 6 | 7 | 8 | type alias Language = 9 | String 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | elm-stuff 3 | 4 | /.coverage 5 | /lib 6 | /generated/** 7 | !/generated/.gitkeep 8 | /gen_test_cases/Inline 9 | /gen_test_cases/Dynamic 10 | -------------------------------------------------------------------------------- /demo/public/plural-rules/example.en.ftl: -------------------------------------------------------------------------------- 1 | addedPhotos = {$userName} {NUMBER($photoCount) -> 2 | [one] added a new photo 3 | *[other] added {$photoCount} new photos 4 | } -------------------------------------------------------------------------------- /demo/public/number-format/example.en.ftl: -------------------------------------------------------------------------------- 1 | dpi-ratio = Your DPI ratio is { NUMBER($ratio, minimumFractionDigits: 2) } 2 | 3 | growthRate = The stocks grew by { NUMBER(0.02, style: "percent") } this year -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | elm-stuff/ 2 | node_modules/ 3 | /tests/ 4 | /demo 5 | /docs 6 | /example/ 7 | /gen_test_cases/ 8 | /.github/ 9 | /src 10 | /.coverage 11 | 12 | /build.js 13 | /generate_test_cases.js 14 | 15 | tsconfig.json 16 | elm.json -------------------------------------------------------------------------------- /src/CodeGen/BasicM.elm: -------------------------------------------------------------------------------- 1 | module CodeGen.BasicM exposing (result) 2 | 3 | import Elm.CodeGen as CG 4 | 5 | 6 | result : CG.TypeAnnotation -> CG.TypeAnnotation -> CG.TypeAnnotation 7 | result errType successType = 8 | CG.typed "Result" [ errType, successType ] 9 | -------------------------------------------------------------------------------- /demo/public/case-interpolation/example.en.ftl: -------------------------------------------------------------------------------- 1 | # eatFruit = { $fruit -> 2 | # [banana] You ate a tasty, yellow banana. +1 stamina. 3 | # [apple] You ate a fresh, red apple. +1 strength. 4 | # *[other] You ate a {$fruit}. It doesn't taste great but its better than nothing. +1 health. 5 | # } -------------------------------------------------------------------------------- /demo/translations/shared.de.ftl: -------------------------------------------------------------------------------- 1 | inputType = Eingabetyp 2 | inputTypeJson = JSON 3 | inputTypeProperties = Properties 4 | inputTypeFluent = Fluent 5 | 6 | generatorMode = Generator Mode 7 | generatorModeInline = Inline 8 | generatorModeDynamic = Dynamic 9 | 10 | syntaxHeadline = Syntax 11 | jsonHeadline = .json 12 | propertiesHeadline = .properties 13 | fluentHeadline = .ftl -------------------------------------------------------------------------------- /demo/translations/shared.en.ftl: -------------------------------------------------------------------------------- 1 | inputType = Input type 2 | inputTypeJson = JSON 3 | inputTypeProperties = Properties 4 | inputTypeFluent = Fluent 5 | 6 | generatorMode = Generator mode 7 | generatorModeInline = Inline 8 | generatorModeDynamic = Dynamic 9 | 10 | syntaxHeadline = Syntax 11 | jsonHeadline = .json 12 | propertiesHeadline = .properties 13 | fluentHeadline = .ftl -------------------------------------------------------------------------------- /demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import elmPlugin from "vite-plugin-elm"; 3 | import { name, version } from "../package.json"; 4 | 5 | export default defineConfig({ 6 | base: `/${name}/`, 7 | define: { 8 | __VERSION__: JSON.stringify(version), 9 | __BASE_PATH__: JSON.stringify(name), 10 | }, 11 | plugins: [elmPlugin({ optimize: process.env.NODE_ENV === "production" })], 12 | }); 13 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import intl_proxy from "intl-proxy"; 2 | import "./main.css"; 3 | import { registerCodeComponent } from "./src/code-component"; 4 | import { Elm } from "./src/DemoMain.elm"; 5 | 6 | registerCodeComponent(); 7 | 8 | Elm.DemoMain.init({ 9 | flags: { 10 | language: "en", 11 | version: __VERSION__, 12 | intl: intl_proxy, 13 | width: window.innerWidth, 14 | height: window.innerHeight, 15 | basePath: __BASE_PATH__, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /demo/public/flag_en.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/public/flag_de.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /gen_test_cases/Util/CustomHtmlAttributes.elm: -------------------------------------------------------------------------------- 1 | module Util.CustomHtmlAttributes exposing (Attribute, attribute, unpackAttribute) 2 | 3 | import Html 4 | import Html.Attributes 5 | 6 | 7 | type Attribute msg 8 | = CustomAttribute (Html.Attribute msg) 9 | 10 | 11 | attribute : String -> String -> Attribute msg 12 | attribute name value = 13 | Html.Attributes.attribute name value |> CustomAttribute 14 | 15 | 16 | unpackAttribute : Attribute msg -> Html.Attribute msg 17 | unpackAttribute (CustomAttribute attr) = 18 | attr 19 | -------------------------------------------------------------------------------- /demo/public/html/example.en.ftl: -------------------------------------------------------------------------------- 1 | partlyBoldText = Needs some highlighting 2 | listWithDifferentStyles =
-email
9 | is a term while the other pairs are normal messages.
10 |
11 | fluentOnlyHeadline = Fluent only
12 | fluentOnlyBody = Note that this feature is only supported in the .ftl file format, just like the next couple of
13 | features marked with the Fluent: prefix.
--------------------------------------------------------------------------------
/demo/translations/terms.de.ftl:
--------------------------------------------------------------------------------
1 | headline = Fluent: Terme
2 | preamble = Textinhalte tendieren zu viel Duplikation, welche zu copy & paste Fehlern führen können und zu Code
3 | der per Volltextsuche angepasst werden muss. Terme sind eine Lösung für dieses Problem.
4 | Vereinfacht gesagt sind Terme einfach Key-Value Paare wie jede andere Übersetzung aber sie können in anderen
5 | Übersetzungen referenziert werden werden zur Compilezeit an allen referenzierenden Stellen reinkopiert.
6 |
7 | syntaxHeadline = Syntax
8 | syntaxBody = Terme sind Key-Value Paare deren Key mit einem Bindestrich "-" beginnt.
9 | In diesem Beispiel ist -email ein Term während die anderen Paare normale Übersetzungen sind.
10 |
11 | fluentOnlyHeadline = Fluent exklusiv
12 | fluentOnlyBody = Dieses Feature ist nur in .ftl Dateien unterstützt, so wie die nächsten paar Seiten die mit einem
13 | Fluent: Präfix markiert sind.
--------------------------------------------------------------------------------
/gen_test_cases/HashCase.elm:
--------------------------------------------------------------------------------
1 | module HashCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ { dynamicOpts | addContentHash = True } ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.singleton "messages" <|
18 | Dict.NonEmpty.fromList
19 | ( ( "en"
20 | , { pairs = Dict.fromList [ ( "text", ( Text "english text", [] ) ) ]
21 | , fallback = Nothing
22 | , resources = ()
23 | }
24 | )
25 | , [ ( "de"
26 | , { pairs = Dict.fromList [ ( "text", ( Text "german text", [] ) ) ]
27 | , fallback = Nothing
28 | , resources = ()
29 | }
30 | )
31 | ]
32 | )
33 |
--------------------------------------------------------------------------------
/demo/translations/plural_rules.en.ftl:
--------------------------------------------------------------------------------
1 | headline = Fluent: Plural Rules
2 |
3 | preamble = Some languages differ in their grammatic or wording based on
4 | the amount of something. English also has this phenomenon in a very basic form:
5 | One page, Two pages. What if you could match on these plural forms?
6 |
7 | -categories-link = https://www.unicode.org/cldr/cldr-aux/charts/30/supplemental/language_plural_rules.html
8 |
9 | syntaxHeadline = Syntax
10 | syntaxBody = Well it turns out you can! Simply wrap a NUMBER
11 | function call around your variable in a case interpolation. Now you won't be matching on specific
12 | numbers but on categories. The valid categories are
13 | 'zero', 'one', 'two', 'few', 'many' and 'other'.
14 |
15 | intlHeadline = Intl API
16 | intlBody = Just a heads-up - you will need to include the intl-proxy package for this feature as well.
17 | Look at the NumberFormat/DateFormat page for reasoning and instructions.
--------------------------------------------------------------------------------
/gen_test_cases/MultiBundleCase.elm:
--------------------------------------------------------------------------------
1 | module MultiBundleCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ inlineOpts, dynamicOpts ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.fromList
18 | [ ( "bundle_1"
19 | , Dict.NonEmpty.singleton "en"
20 | { pairs = Dict.fromList [ ( "text1", ( Text "text from bundle 1", [] ) ) ]
21 | , fallback = Nothing
22 | , resources = ()
23 | }
24 | )
25 | , ( "bundle_2"
26 | , Dict.NonEmpty.singleton "en"
27 | { pairs = Dict.fromList [ ( "text2", ( Text "text from bundle 2", [] ) ) ]
28 | , fallback = Nothing
29 | , resources = ()
30 | }
31 | )
32 | ]
33 |
--------------------------------------------------------------------------------
/gen_test_cases/MultiLanguageTextCase.elm:
--------------------------------------------------------------------------------
1 | module MultiLanguageTextCase exposing (main)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ inlineOpts, dynamicOpts ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.singleton "messages" <|
18 | Dict.NonEmpty.fromList
19 | ( ( "en"
20 | , { pairs = Dict.fromList [ ( "text", ( Text "english text", [] ) ) ]
21 | , fallback = Nothing
22 | , resources = ()
23 | }
24 | )
25 | , [ ( "de"
26 | , { pairs = Dict.fromList [ ( "text", ( Text "german text", [] ) ) ]
27 | , fallback = Nothing
28 | , resources = ()
29 | }
30 | )
31 | ]
32 | )
33 |
--------------------------------------------------------------------------------
/gen_test_cases/SimpleI18nFirstCase.elm:
--------------------------------------------------------------------------------
1 | module SimpleI18nFirstCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ { inlineOpts | i18nArgFirst = True }, { dynamicOpts | i18nArgFirst = True } ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.singleton "messages" <|
18 | Dict.NonEmpty.singleton "en"
19 | { pairs =
20 | Dict.fromList
21 | [ ( "singleText", ( Text "the text", [] ) )
22 | , ( "interpolation", ( Text "Hello ", [ Interpolation "planet", Text "!" ] ) )
23 | , ( "greeting", ( Text "Good ", [ Interpolation "timeOfDay", Text ", ", Interpolation "name" ] ) )
24 | ]
25 | , fallback = Nothing
26 | , resources = ()
27 | }
28 |
--------------------------------------------------------------------------------
/src/Util.elm:
--------------------------------------------------------------------------------
1 | module Util exposing (emptyIntl, keyToName, moduleName, quoteString, resultToDecoder, safeName)
2 |
3 | import Elm.CodeGen exposing (ModuleName)
4 | import Intl exposing (Intl)
5 | import Json.Decode as D
6 | import Json.Encode
7 | import String.Extra
8 |
9 |
10 | emptyIntl : Intl
11 | emptyIntl =
12 | Json.Encode.object []
13 |
14 |
15 | keyToName : List String -> String
16 | keyToName =
17 | String.join "." >> String.Extra.classify >> String.Extra.decapitalize
18 |
19 |
20 | quoteString : String -> String
21 | quoteString str =
22 | "\"" ++ str ++ "\""
23 |
24 |
25 | safeName : String -> String
26 | safeName name =
27 | name ++ "_"
28 |
29 |
30 | moduleName : String -> ModuleName
31 | moduleName =
32 | String.split "."
33 | >> List.map String.Extra.classify
34 |
35 |
36 | resultToDecoder : Result String a -> D.Decoder a
37 | resultToDecoder result =
38 | case result of
39 | Ok ok ->
40 | D.succeed ok
41 |
42 | Err err ->
43 | D.fail err
44 |
--------------------------------------------------------------------------------
/src/Types/ArgValue.elm:
--------------------------------------------------------------------------------
1 | module Types.ArgValue exposing (..)
2 |
3 | import Elm.CodeGen as CG
4 | import Json.Encode as E
5 |
6 |
7 | type ArgValue
8 | = BoolArg Bool
9 | | StringArg String
10 | | NumberArg Float
11 |
12 |
13 | encode : ArgValue -> E.Value
14 | encode v =
15 | case v of
16 | BoolArg b ->
17 | E.bool b
18 |
19 | StringArg s ->
20 | E.string s
21 |
22 | NumberArg f ->
23 | E.float f
24 |
25 |
26 | generateEncoded : ArgValue -> CG.Expression
27 | generateEncoded v =
28 | case v of
29 | BoolArg b ->
30 | if b then
31 | CG.apply [ CG.fqFun [ "Json", "Encode" ] "bool", CG.val "True" ]
32 |
33 | else
34 | CG.apply [ CG.fqFun [ "Json", "Encode" ] "bool", CG.val "False" ]
35 |
36 | StringArg s ->
37 | CG.apply [ CG.fqFun [ "Json", "Encode" ] "string", CG.string s ]
38 |
39 | NumberArg f ->
40 | CG.apply [ CG.fqFun [ "Json", "Encode" ] "float", CG.float f ]
41 |
--------------------------------------------------------------------------------
/demo/src/Msg.elm:
--------------------------------------------------------------------------------
1 | module Msg exposing (..)
2 |
3 | import Browser
4 | import File exposing (InputFile)
5 | import Http
6 | import InputType exposing (InputType)
7 | import Ports
8 | import Translations exposing (I18n, Language)
9 | import Url
10 |
11 |
12 | type Msg
13 | = -- Navigation
14 | UrlChanged Url.Url
15 | | UrlRequested Browser.UrlRequest
16 | -- Get static resource files
17 | | LoadedTranslations (Result Http.Error (I18n -> I18n))
18 | | LoadedInputFile (Result Http.Error InputFile)
19 | -- Explanation text
20 | | ToggleAccordionElement String Int
21 | -- Code editor
22 | | EditedInput { filePath : String, newContent : String, caretPosition : Int }
23 | | ChangeInputType InputType
24 | | ChangeGeneratorMode Ports.GeneratorMode
25 | | ChangeHtmlModule String
26 | | ChangeLanguage Language
27 | | ChangeActiveInputFile String
28 | | ChangeActiveOutputFile String
29 | | AddFile InputFile
30 | | EditFileName String InputFile
31 | -- Browser-related
32 | | Resize Int Int
33 |
--------------------------------------------------------------------------------
/demo/translations/case_interpolation.en.ftl:
--------------------------------------------------------------------------------
1 | headline = Fluent: Case interpolation
2 |
3 | preamble = Also known as selectors in the Fluent handbook, we chose this name because it mimics (and compiles to)
4 | Elms case .. of statement.
5 |
6 | syntaxHeadline = Syntax
7 | syntaxBody = All you need to do to get started is take a normal interpolation statement, insert a line-break after
8 | the variable name and indent the following lines by at least one space. Then, you provide match statements, line by line.
9 | The string to match against is written in square brackets and everything after that is the value.
10 | Make sure to mark exactly one case as the default case with a leading star *.
11 |
12 | adviceHeadline = Advice
13 | adviceBody = Do not rely on this feature too much. Matching against a custom data type in Elm code is often
14 | much cleaner. In the current example, we could have also created a data type type Fruit = Apple | Banana | Other String
15 | and three seperate translation keys for the respective cases.
16 |
--------------------------------------------------------------------------------
/gen_test_cases/PluralCase.elm:
--------------------------------------------------------------------------------
1 | module PluralCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.ArgValue exposing (ArgValue(..))
7 | import Types.Segment exposing (TSegment(..))
8 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
9 |
10 |
11 | main : Generator
12 | main =
13 | buildMain [ inlineOpts, dynamicOpts ] state
14 |
15 |
16 | state : State ()
17 | state =
18 | Dict.singleton "messages" <|
19 | Dict.NonEmpty.singleton
20 | "en"
21 | { pairs =
22 | Dict.fromList
23 | [ ( "text"
24 | , ( Text "I met "
25 | , [ PluralCase "number" [] ( Text "many people.", [] ) <|
26 | Dict.singleton "one" ( Text "a single person.", [] )
27 | ]
28 | )
29 | )
30 | ]
31 | , fallback = Nothing
32 | , resources = ()
33 | }
34 |
--------------------------------------------------------------------------------
/gen_test_cases/CustomHtmlModuleCase.elm:
--------------------------------------------------------------------------------
1 | module CustomHtmlModuleCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain
13 | [ { inlineOpts | customHtmlModule = "Util.CustomHtml", customHtmlAttributesModule = "Util.CustomHtmlAttributes" }
14 | , { dynamicOpts | customHtmlModule = "Util.CustomHtml", customHtmlAttributesModule = "Util.CustomHtmlAttributes" }
15 | ]
16 | state
17 |
18 |
19 | state : State ()
20 | state =
21 | Dict.singleton "messages" <|
22 | Dict.NonEmpty.singleton "en"
23 | { pairs =
24 | Dict.fromList
25 | [ ( "html", ( Html { tag = "a", id = "link", attrs = [ ( "href", ( Text "/", [] ) ) ], content = ( Text "Click me", [] ) }, [] ) )
26 | ]
27 | , fallback = Nothing
28 | , resources = ()
29 | }
30 |
--------------------------------------------------------------------------------
/demo/src/Pages/Terms.elm:
--------------------------------------------------------------------------------
1 | module Pages.Terms exposing (..)
2 |
3 | import Html exposing (Html)
4 | import Html.Attributes exposing (class)
5 | import InputType
6 | import Model exposing (Model)
7 | import Msg exposing (Msg)
8 | import Page
9 | import Ports
10 | import Translations
11 |
12 |
13 | init : Model -> ( Model, Cmd Msg )
14 | init model =
15 | model
16 | |> Model.setInputTypeAndModeDefaults ( InputType.Fluent, Ports.Inline )
17 | |> Page.loadInputFiles { directory = "terms", files = ( { name = "example", language = "en" }, [] ) }
18 | |> Page.withTranslations Translations.loadTerms
19 |
20 |
21 | viewExplanation : Model -> List (Html Msg)
22 | viewExplanation { i18n } =
23 | [ Html.p [] [ Html.text <| Translations.termsPreamble i18n ]
24 | , Html.h2 [] [ Html.text <| Translations.termsSyntaxHeadline i18n ]
25 | , Html.p [] <| Translations.termsSyntaxBody [ class "highlighted" ] i18n
26 | , Html.h2 [] [ Html.text <| Translations.termsFluentOnlyHeadline i18n ]
27 | , Html.p [] <| Translations.termsFluentOnlyBody [ class "highlighted" ] i18n
28 | ]
29 |
--------------------------------------------------------------------------------
/demo/translations/plural_rules.de.ftl:
--------------------------------------------------------------------------------
1 | headline = Fluent: Plural Regeln
2 |
3 | preamble = Manche Sprachen unterscheiden sich in ihrer Grammatik oder im Wortlaut basierend auf der Zahl von etwas.
4 | Deutsch hat dieses Phänomen auch in einer sehr einfachen Form:
5 | Ein Baum, Zwei Bäume. Wäre es nicht cool wenn du auf diese Plural Formen matchen könntest?
6 |
7 | -categories-link = https://www.unicode.org/cldr/cldr-aux/charts/30/supplemental/language_plural_rules.html
8 |
9 | syntaxHeadline = Syntax
10 | syntaxBody = Es stellt sich heraus: klar kannst du! Verpacke einfach einen NUMBER Funktionsaufruf um eine Variable
11 | innerhalb einer Case Interpolation. Jetzt matchst du nicht auf spezifische Zahlen, sondern auf Kategorien.
12 | Die validen Kategorien sind 'zero', 'one', 'two', 'few', 'many' und 'other'.
13 |
14 | intlHeadline = Intl API
15 | intlBody = Nur eine Vorwarnung - du wirst die intl-proxy Abhängigkeit auch für diese Funktionalität brauchen.
16 | Die NumberFormat/DateFormat Seiten haben eine ausführliche Erklärung weshalb und was zu tun ist.
--------------------------------------------------------------------------------
/demo/src/Pages/PluralRules.elm:
--------------------------------------------------------------------------------
1 | module Pages.PluralRules exposing (..)
2 |
3 | import Html exposing (Html)
4 | import Html.Attributes exposing (class)
5 | import InputType
6 | import Model exposing (Model)
7 | import Msg exposing (Msg)
8 | import Page
9 | import Ports
10 | import Translations
11 |
12 |
13 | init : Model -> ( Model, Cmd Msg )
14 | init model =
15 | model
16 | |> Model.setInputTypeAndModeDefaults ( InputType.Fluent, Ports.Inline )
17 | |> Page.loadInputFiles { directory = "plural-rules", files = ( { name = "example", language = "en" }, [] ) }
18 | |> Page.withTranslations Translations.loadPluralRules
19 |
20 |
21 | viewExplanation : Model -> List (Html Msg)
22 | viewExplanation { i18n } =
23 | [ Html.p [] <| Translations.pluralRulesPreamble [] i18n
24 | , Html.h2 [] [ Html.text <| Translations.pluralRulesSyntaxHeadline i18n ]
25 | , Html.p [] <| Translations.pluralRulesSyntaxBody { a = [], code = [ class "highlighted" ] } i18n
26 | , Html.h2 [] [ Html.text <| Translations.pluralRulesIntlHeadline i18n ]
27 | , Html.p [] [ Html.text <| Translations.pluralRulesIntlBody i18n ]
28 | ]
29 |
--------------------------------------------------------------------------------
/demo/src/Pages/DateFormat.elm:
--------------------------------------------------------------------------------
1 | module Pages.DateFormat exposing (..)
2 |
3 | import Html exposing (Html)
4 | import Html.Attributes exposing (class)
5 | import InputType
6 | import Model exposing (Model)
7 | import Msg exposing (Msg)
8 | import Page
9 | import Ports
10 | import Translations
11 |
12 |
13 | init : Model -> ( Model, Cmd Msg )
14 | init model =
15 | model
16 | |> Model.setInputTypeAndModeDefaults ( InputType.Fluent, Ports.Inline )
17 | |> Page.loadInputFiles { directory = "date-format", files = ( { name = "example", language = "en" }, [] ) }
18 | |> Page.withTranslations Translations.loadDateFormat
19 |
20 |
21 | viewExplanation : Model -> List (Html Msg)
22 | viewExplanation { i18n } =
23 | [ Html.p [] [ Html.text <| Translations.dateFormatPreamble i18n ]
24 | , Html.h2 [] [ Html.text <| Translations.dateFormatIntlHeadline i18n ]
25 | , Html.p [] <| Translations.dateFormatIntlBody { a = [], code = [ class "highlighted" ] } i18n
26 | , Html.h2 [] [ Html.text <| Translations.dateFormatCompileTimeHeadline i18n ]
27 | , Html.p [] [ Html.text <| Translations.dateFormatCompileTimeBody i18n ]
28 | ]
29 |
--------------------------------------------------------------------------------
/demo/translations/case_interpolation.de.ftl:
--------------------------------------------------------------------------------
1 | headline = Fluent: Case Interpolation
2 |
3 | preamble = Auch bekannt als Selektoren im Fluent Handbook, haben wir diesen Namen gewählt, da es Elm's
4 | case .. of Statement imitiert (und auch dazu kompiliert wird).
5 |
6 | syntaxHeadline = Syntax
7 | syntaxBody = Alles was du tun musst ist ein normales Interpolations-Statement zu nehmen, einen Zeilen Umbruch nach dem
8 | Variablennamen hinzufügen und die darauf folgenden Zeilen um mindestens ein Leerzeichen einzurücken.
9 | Dann kannst du pro Zeile ein Match Statement: Der String auf den gematcht werden soll kommt in eckige Klammern
10 | und alles danach ist der assoziierte Wert.
11 | Stelle sicher dass du genau einen Fall als Default markierst, indem du die Zeile mit einem Stern * anfängst.
12 |
13 | adviceHeadline = Tipp
14 | adviceBody = Versuche dieses Feature selten zu nutzen. Gegen einen eigenen Union Type in Elm zu matchen ist oft viel sauberer.
15 | In dem aktuellen Beispiel hätten wir auch einen Datentyp type Fruit = Apple | Banana | Other String erstellen können
16 | mit drei seperaten Übersetzungen.
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "travelm-agency",
3 | "version": "3.8.0",
4 | "description": "Generate type-safe accessors and decoders for your i18n files",
5 | "main": "./lib/main.js",
6 | "bin": "index.js",
7 | "type": "module",
8 | "scripts": {
9 | "build": "tsc && cross-env NODE_ENV=production node build.js",
10 | "build:dev": "node build.js",
11 | "test": "node generate_test_cases.js && elm-test-rs"
12 | },
13 | "keywords": [
14 | "Elm",
15 | "I18n",
16 | "JSON",
17 | "Codegen"
18 | ],
19 | "author": "Andreas Molitor",
20 | "license": "ISC",
21 | "types": "./lib/main.d.ts",
22 | "dependencies": {
23 | "intl-proxy": "^1.0.1",
24 | "yargs": "^18.0.0"
25 | },
26 | "peerDependencies": {
27 | "elm-format": "^0.8.0"
28 | },
29 | "peerDependenciesMeta": {
30 | "elm-format": {
31 | "optional": true
32 | }
33 | },
34 | "devDependencies": {
35 | "@types/node": "24.0.3",
36 | "cross-env": "^7.0.3",
37 | "elm": "^0.19.1-5",
38 | "elm-test-rs": "^3.0.0-5",
39 | "node-elm-compiler": "^5.0.6",
40 | "typescript": "^5.8.3",
41 | "uglify-js": "^3.19.3"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/gen_test_cases/FallbackCase.elm:
--------------------------------------------------------------------------------
1 | module FallbackCase exposing (main)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ inlineOpts, dynamicOpts ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.singleton "messages" <|
18 | Dict.NonEmpty.fromList
19 | ( ( "en"
20 | , { pairs = Dict.fromList [ ( "text", ( Text "english text", [] ) ) ]
21 | , fallback = Just "de"
22 | , resources = ()
23 | }
24 | )
25 | , [ ( "de"
26 | , { pairs =
27 | Dict.fromList
28 | [ ( "text", ( Text "german text", [] ) )
29 | , ( "justInGerman", ( Text "more german text", [] ) )
30 | ]
31 | , fallback = Nothing
32 | , resources = ()
33 | }
34 | )
35 | ]
36 | )
37 |
--------------------------------------------------------------------------------
/gen_test_cases/InterpolationMatchCase.elm:
--------------------------------------------------------------------------------
1 | module InterpolationMatchCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ inlineOpts, dynamicOpts ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.singleton "messages" <|
18 | Dict.NonEmpty.singleton "en"
19 | { pairs =
20 | Dict.fromList
21 | [ ( "text"
22 | , ( InterpolationCase "gender"
23 | ( Text "It", [] )
24 | (Dict.fromList
25 | [ ( "female", ( Text "She", [] ) )
26 | , ( "male", ( Text "He", [] ) )
27 | ]
28 | )
29 | , [ Text " bought a cat." ]
30 | )
31 | )
32 | ]
33 | , fallback = Nothing
34 | , resources = ()
35 | }
36 |
--------------------------------------------------------------------------------
/demo/src/Pages/NumberFormat.elm:
--------------------------------------------------------------------------------
1 | module Pages.NumberFormat exposing (..)
2 |
3 | import Html exposing (Html)
4 | import Html.Attributes exposing (class)
5 | import InputType
6 | import Model exposing (Model)
7 | import Msg exposing (Msg)
8 | import Page
9 | import Ports
10 | import Translations
11 |
12 |
13 | init : Model -> ( Model, Cmd Msg )
14 | init model =
15 | model
16 | |> Model.setInputTypeAndModeDefaults ( InputType.Fluent, Ports.Inline )
17 | |> Page.loadInputFiles { directory = "number-format", files = ( { name = "example", language = "en" }, [] ) }
18 | |> Page.withTranslations Translations.loadNumberFormat
19 |
20 |
21 | viewExplanation : Model -> List (Html Msg)
22 | viewExplanation { i18n } =
23 | [ Html.p [] <| Translations.numberFormatPreamble [ class "highlighted" ] i18n
24 | , Html.h2 [] [ Html.text <| Translations.numberFormatIntlHeadline i18n ]
25 | , Html.p [] <| Translations.numberFormatIntlBody { a = [], code = [ class "highlighted" ] } i18n
26 | , Html.h2 [] [ Html.text <| Translations.numberFormatOptionsHeadline i18n ]
27 | , Html.p [] <| Translations.numberFormatOptionsBody { a = [], code = [ class "highlighted" ] } i18n
28 | ]
29 |
--------------------------------------------------------------------------------
/demo/src/Pages/CaseInterpolation.elm:
--------------------------------------------------------------------------------
1 | module Pages.CaseInterpolation exposing (..)
2 |
3 | import Html exposing (Html)
4 | import Html.Attributes exposing (class)
5 | import InputType
6 | import Model exposing (Model)
7 | import Msg exposing (Msg)
8 | import Page
9 | import Ports
10 | import Translations
11 |
12 |
13 | init : Model -> ( Model, Cmd Msg )
14 | init model =
15 | model
16 | |> Model.setInputTypeAndModeDefaults ( InputType.Fluent, Ports.Inline )
17 | |> Page.loadInputFiles { directory = "case-interpolation", files = ( { name = "example", language = "en" }, [] ) }
18 | |> Page.withTranslations Translations.loadCaseInterpolation
19 |
20 |
21 | viewExplanation : Model -> List (Html Msg)
22 | viewExplanation { i18n } =
23 | [ Html.p [] <| Translations.caseInterpolationPreamble [ class "highlighted" ] i18n
24 | , Html.h2 [] [ Html.text <| Translations.caseInterpolationSyntaxHeadline i18n ]
25 | , Html.p [] <| Translations.caseInterpolationSyntaxBody [ class "highlighted" ] i18n
26 | , Html.h2 [] [ Html.text <| Translations.caseInterpolationAdviceHeadline i18n ]
27 | , Html.p [] <| Translations.caseInterpolationAdviceBody [ class "highlighted" ] i18n
28 | ]
29 |
--------------------------------------------------------------------------------
/src/CodeGen/DecodeM.elm:
--------------------------------------------------------------------------------
1 | module CodeGen.DecodeM exposing (andThen, array, decodeString, decoder, dict, errorToString, fail, map, required, string, succeed)
2 |
3 | import Elm.CodeGen as CG
4 |
5 |
6 | succeed : CG.Expression
7 | succeed =
8 | fun "succeed"
9 |
10 |
11 | fail : CG.Expression
12 | fail =
13 | fun "fail"
14 |
15 |
16 | map : CG.Expression
17 | map =
18 | fun "map"
19 |
20 |
21 | required : CG.Expression
22 | required =
23 | CG.fqFun [ "Json", "Decode", "Pipeline" ] "required"
24 |
25 |
26 | string : CG.Expression
27 | string =
28 | fun "string"
29 |
30 |
31 | array : CG.Expression
32 | array =
33 | fun "array"
34 |
35 |
36 | andThen : CG.Expression
37 | andThen =
38 | fun "andThen"
39 |
40 |
41 | decoder : CG.TypeAnnotation -> CG.TypeAnnotation
42 | decoder t =
43 | CG.fqTyped [ "Json", "Decode" ] "Decoder" [ t ]
44 |
45 |
46 | decodeString : CG.Expression
47 | decodeString =
48 | fun "decodeString"
49 |
50 |
51 | errorToString : CG.Expression
52 | errorToString =
53 | fun "errorToString"
54 |
55 |
56 | dict : CG.Expression
57 | dict =
58 | fun "dict"
59 |
60 |
61 | fun : String -> CG.Expression
62 | fun =
63 | CG.fqFun [ "Json", "Decode" ]
64 |
--------------------------------------------------------------------------------
/gen_test_cases/InconsistentLanguageCase.elm:
--------------------------------------------------------------------------------
1 | module InconsistentLanguageCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ { inlineOpts | expectError = True }, { dynamicOpts | expectError = True } ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.singleton "messages" <|
18 | Dict.NonEmpty.fromList
19 | ( ( "en"
20 | , { pairs = Dict.fromList [ ( "text", ( Text "english text", [] ) ) ]
21 | , fallback = Nothing
22 | , resources = ()
23 | }
24 | )
25 | , [ ( "de"
26 | , { pairs =
27 | Dict.fromList
28 | [ ( "text", ( Text "german text", [] ) )
29 | , ( "justInGerman", ( Text "more german text", [] ) )
30 | ]
31 | , fallback = Nothing
32 | , resources = ()
33 | }
34 | )
35 | ]
36 | )
37 |
--------------------------------------------------------------------------------
/demo/src/Pages/Bundles.elm:
--------------------------------------------------------------------------------
1 | module Pages.Bundles exposing (..)
2 |
3 | import Html exposing (Html)
4 | import Html.Attributes exposing (class)
5 | import InputType
6 | import Model exposing (Model)
7 | import Msg exposing (Msg)
8 | import Page
9 | import Ports
10 | import Translations
11 |
12 |
13 | init : Model -> ( Model, Cmd Msg )
14 | init model =
15 | model
16 | |> Model.setInputTypeAndModeDefaults ( InputType.Json, Ports.Dynamic )
17 | |> Page.loadInputFiles
18 | { directory = "bundles"
19 | , files =
20 | ( { name = "foo", language = "en" }
21 | , [ { name = "bar", language = "en" }
22 | ]
23 | )
24 | }
25 | |> Page.withTranslations Translations.loadBundles
26 |
27 |
28 | viewExplanation : Model -> List (Html msg)
29 | viewExplanation { i18n } =
30 | [ Html.p [] <| Translations.bundlesPreamble [ class "highlighted" ] i18n
31 | , Html.h2 [] [ Html.text <| Translations.bundlesConsiderationsHeadline i18n ]
32 | , Html.p [] <| Translations.bundlesConsiderationsBody [ class "highlighted" ] i18n
33 | , Html.h2 [] [ Html.text <| Translations.bundlesExploreHeadline i18n ]
34 | , Html.p [] [ Html.text <| Translations.bundlesExploreBody i18n ]
35 | ]
36 |
--------------------------------------------------------------------------------
/gen_test_cases/NestedInterpolationCase.elm:
--------------------------------------------------------------------------------
1 | module NestedInterpolationCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ inlineOpts, dynamicOpts ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.singleton "messages" <|
18 | Dict.NonEmpty.singleton "de"
19 | { pairs =
20 | Dict.fromList
21 | [ ( "text"
22 | , ( InterpolationCase "pronoun"
23 | ( Interpolation "pronoun", [ Text " kauft ", Interpolation "objectsToBuy" ] )
24 | (Dict.fromList
25 | [ ( "Du", ( Text "Du kaufst ", [ Interpolation "objectsToBuy" ] ) )
26 | , ( "Ich", ( Text "Ich kaufe ", [ Interpolation "objectsToBuy" ] ) )
27 | ]
28 | )
29 | , [ Text "." ]
30 | )
31 | )
32 | ]
33 | , fallback = Nothing
34 | , resources = ()
35 | }
36 |
--------------------------------------------------------------------------------
/gen_test_cases/MultiInterpolationCase.elm:
--------------------------------------------------------------------------------
1 | module MultiInterpolationCase exposing (main)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ inlineOpts, dynamicOpts ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.singleton "messages" <|
18 | Dict.NonEmpty.fromList
19 | ( ( "en"
20 | , { pairs = Dict.fromList [ ( "greeting", ( Text "Good ", [ Interpolation "timeOfDay", Text ", ", Interpolation "name" ] ) ) ]
21 | , fallback = Nothing
22 | , resources = ()
23 | }
24 | )
25 | , [ ( "de"
26 | , { pairs = Dict.fromList [ ( "greeting", ( Text "Guten ", [ Interpolation "timeOfDay" ] ) ) ]
27 | , fallback = Nothing
28 | , resources = ()
29 | }
30 | )
31 | , ( "yoda"
32 | , { pairs = Dict.fromList [ ( "greeting", ( Interpolation "name", [ Text ", good ", Interpolation "timeOfDay" ] ) ) ]
33 | , fallback = Nothing
34 | , resources = ()
35 | }
36 | )
37 | ]
38 | )
39 |
--------------------------------------------------------------------------------
/generate_test_cases.js:
--------------------------------------------------------------------------------
1 | import { writeFile, mkdir, readdir } from "fs/promises";
2 | import elmCompiler from "node-elm-compiler";
3 | import { resolve, dirname, parse } from "path";
4 | import { fileURLToPath } from "url";
5 |
6 | const __filename = fileURLToPath(import.meta.url);
7 | const __dirname = dirname(__filename);
8 |
9 | const testCaseDir = resolve(__dirname, "gen_test_cases");
10 |
11 | const generate = async (pathToTestCase) => {
12 | const testCaseName = parse(pathToTestCase).name;
13 | const name = testCaseName.replace("Case", "");
14 | const worker = await elmCompiler.compileWorker(
15 | __dirname,
16 | pathToTestCase,
17 | testCaseName,
18 | { flags: { name } }
19 | );
20 | worker.ports.sendFile.subscribe(async ({ path, content }) => {
21 | const targetPath = resolve(__dirname, path);
22 | await mkdir(dirname(targetPath), { recursive: true });
23 | await writeFile(resolve(__dirname, path), content);
24 | worker.ports.sendFile.unsubscribe();
25 | });
26 | };
27 |
28 | const generateTestCases = async () => {
29 | const [, , filePath] = process.argv;
30 |
31 | if (filePath) {
32 | generate(filePath);
33 | return;
34 | }
35 | const fileNames = await readdir(testCaseDir);
36 | fileNames
37 | .filter((fileName) => fileName.endsWith("Case.elm"))
38 | .map((fileName) => generate(resolve(testCaseDir, fileName)));
39 | };
40 |
41 | generateTestCases();
42 |
--------------------------------------------------------------------------------
/src/elm.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*elm.min.js" {
2 | export type Request = TranslationRequest | FinishRequest;
3 |
4 | export interface TranslationRequest {
5 | type: "translation";
6 | fileName: string;
7 | fileContent: string;
8 | }
9 |
10 | export type GeneratorMode = "dynamic" | "inline";
11 |
12 | export interface FinishRequest {
13 | type: "finish";
14 | elmModuleName: string;
15 | generatorMode: GeneratorMode | null;
16 | addContentHash: boolean | null;
17 | i18nArgFirst: boolean | null;
18 | prefixFileIdentifier: boolean | null;
19 | customHtmlModule: string;
20 | customHtmlAttributesModule: string;
21 | }
22 |
23 | export interface Response {
24 | error?: string;
25 | content?: ResponseContent;
26 | }
27 |
28 | export interface ResponseContent {
29 | elmFile: string;
30 | optimizedJson: { filename: string; content: string }[];
31 | }
32 |
33 | export type ResponseHandler = (res: Response) => void;
34 |
35 | export interface Ports {
36 | receiveRequest: {
37 | send: (req: Request) => void;
38 | };
39 | sendResponse: {
40 | subscribe: (handler: ResponseHandler) => void;
41 | unsubscribe: (handler: ResponseHandler) => void;
42 | };
43 | }
44 |
45 | export const Elm: {
46 | Main: {
47 | init(args: { flags: { version: string; intl: {}; devMode: boolean } }): {
48 | ports: Ports;
49 | };
50 | };
51 | };
52 | }
53 |
--------------------------------------------------------------------------------
/demo/src/Pages/Language.elm:
--------------------------------------------------------------------------------
1 | module Pages.Language exposing (init, viewExplanation)
2 |
3 | import Html exposing (Html)
4 | import Html.Attributes exposing (class)
5 | import InputType
6 | import Model exposing (Model)
7 | import Msg exposing (Msg(..))
8 | import Page
9 | import Ports
10 | import Translations
11 |
12 |
13 | init : Model -> ( Model, Cmd Msg )
14 | init model =
15 | model
16 | |> Model.setInputTypeAndModeDefaults ( InputType.Json, Ports.Inline )
17 | |> Page.loadInputFiles
18 | { directory = "language"
19 | , files =
20 | ( { name = "example", language = "en" }
21 | , [ { name = "example", language = "de" }
22 | , { name = "example", language = "fr" }
23 | , { name = "example", language = "ja" }
24 | ]
25 | )
26 | }
27 | |> Page.withTranslations Translations.loadLanguage
28 |
29 |
30 | viewExplanation : Model -> List (Html Msg)
31 | viewExplanation { i18n } =
32 | [ Html.p [] [ Html.text <| Translations.languagePreamble i18n ]
33 | , Html.h2 [] [ Html.text <| Translations.languageConversionsHeadline i18n ]
34 | , Html.p [] <| Translations.languageConversionsBody [ class "highlighted" ] i18n
35 | , Html.h2 [] [ Html.text <| Translations.languageActiveLanguageHeadline i18n ]
36 | , Html.p [] <| Translations.languageActiveLanguageBody [ class "highlighted" ] i18n
37 | ]
38 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Dynamic mode, since in Inline mode,
6 | all translations are in the main Elm bundle regardless.
7 |
8 | considerationsHeadline = Considerations
9 | considerationsBody = Make sure you need this feature before using it. It weakens your compile-time guarantees,
10 | since you need to remember to include the right bundles on the right pages. If a key is not loaded yet, the respective function
11 | will return an empty string. Right now, Travelm-Agency compiles all of your translation keys into a flat list of exports,
12 | which does not really help in particular to distinguish the corresponding files where the keys are contained.
13 | Prefixing your translations might help, which is why you can run travelm-agency with --prefix_file_identifier. This will prefix the bundle name
14 | to each of the exposed accessor functions, i.e. if you have a translation "headline" in the file "summary.en.ftl", you can access that translation via "summaryHeadline".
15 | If you benchmarked and found out that bundling improves your render time a lot, let me know. I'm open for pull requests and suggestions
16 | to improve the generated code.
17 |
18 | exploreHeadline = Explore
19 | exploreBody = The editor lets you create new files on this page! Add new bundles and see how the output changes.
20 |
--------------------------------------------------------------------------------
/demo/translations/language.en.ftl:
--------------------------------------------------------------------------------
1 | headline = Languages
2 | preamble = When you build your application without travelm-agency, how would you type the current language?
3 | I would probably use a union type with one constructor for each of my languages. So this is the
4 | code that travelm-agency generates.
5 |
6 | conversionsHeadline = Conversions
7 | conversionsBody = Having a union type is nice, but you need to be able to at least serialize and deserialize
8 | from a String to do anything useful with it. Travelm-agency generates languageToString
9 | and languageFromString for you! The cool thing about the latter is that it does not match exactly but based on a prefix.
10 | So no worries, en-US will still get parsed into Just En.
11 |
12 | activeLanguageHeadline = Which language is active?
13 | activeLanguageBody = Isn't this trivial? The one which the user selected. The one whose translations I see on screen.
14 | Wait. What happens if I load the language files dynamically and the request hasn't completed yet?
15 | Usually there are two interesting languages at any given point in time.
16 | The language which we want our application to be in, and the language the application is in right now.
17 | We generate accessors for these two with currentLanguage and arrivedLanguage.
18 | You can watch the difference live when you throttle your Network Speed in the DevTools and then switch the language by clicking one of the flags in the top middle.
19 | If you need fancier logic than this, you probably need to store the language in your own model in addition
20 | (or you can open an issue, maybe other users have your problem as well).
--------------------------------------------------------------------------------
/demo/src/Model.elm:
--------------------------------------------------------------------------------
1 | module Model exposing (..)
2 |
3 | import Browser.Navigation
4 | import Dict exposing (Dict)
5 | import File exposing (InputFile, OutputFile)
6 | import InputType exposing (InputType)
7 | import Intl exposing (Intl)
8 | import Ports
9 | import Routes exposing (Route)
10 | import Translations exposing (I18n, Language)
11 |
12 |
13 | type alias Model =
14 | { -- static data
15 | key : Browser.Navigation.Key
16 | , basePath : String
17 | , version : String
18 |
19 | -- internationization
20 | , i18n : I18n
21 | , intl : Intl
22 | , language : Language
23 |
24 | -- routing
25 | , route : Route
26 | , generatorMode : Ports.GeneratorMode
27 | , inputType : InputType
28 | , customHtmlModule : String
29 |
30 | -- viewport
31 | , height : Int
32 | , width : Int
33 |
34 | -- explanation text
35 | , openAccordionElements : Dict String Int
36 |
37 | -- code editor
38 | , caretPosition : Int
39 | , inputFiles : Dict String InputFile
40 | , activeInputFilePath : String
41 | , outputFiles : Dict String OutputFile
42 | , activeOutputFilePath : String
43 | , errorMessage : Maybe String
44 | }
45 |
46 |
47 | setInputTypeAndModeDefaults : ( InputType, Ports.GeneratorMode ) -> Model -> Model
48 | setInputTypeAndModeDefaults ( defaultInputType, defaultMode ) model =
49 | let
50 | { inputType, generatorMode, customHtmlModule } =
51 | Routes.getParams model.route
52 | in
53 | { model
54 | | inputType = inputType |> Maybe.withDefault defaultInputType
55 | , generatorMode = generatorMode |> Maybe.withDefault defaultMode
56 | , customHtmlModule = customHtmlModule |> Maybe.withDefault "Html"
57 | }
58 |
--------------------------------------------------------------------------------
/demo/translations/date_format.en.ftl:
--------------------------------------------------------------------------------
1 | headline = Fluent: Formatting dates
2 |
3 | -intl-link = https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat
4 | -proxy-link = https://www.npmjs.com/package/intl-proxy
5 |
6 | preamble = Dates can be presented in even more different ways than numbers can.
7 | Order of year/month/day differs depending on the language, and if you want to actually display the full
8 | weekday/month... you are going to have a bad time.
9 |
10 | intlHeadline = Elm and the Intl APIs
11 | intlBody = Luckily the browsers Intl API helps us out once again.
12 | Once again, due to Elms restrictions, you need to npm install intl-proxy
13 | and pass it into your Elm application as a flag. The generated code will force you to pass this when creating the
14 | initial I18n instance, at which point you never need to use it explicitely in your program again.
15 |
16 | compileTimeHeadline = Compile time execution
17 | compileTimeBody = You may pass a string literal instead of a variable into the DATETIME function,
18 | which executes the function at compile-time and inlines the resulting formatted date. The date needs to be in
19 | ISO-8601 format for this to work.
20 |
21 | optionsHeadline = Additional options
22 | optionsBody = The NumberFormat.format method can take a variety of different options which are documented
23 | here. To use them, just add them as comma-seperated arguments to the DATETIME function call.
24 | For example, DATETIME($date, dateStyle: "full") will pass the argument to the Intl API, which will then
25 | show weekday and month in textual form (for example "Sunday, 20 December 2020").
--------------------------------------------------------------------------------
/gen_test_cases/MultiBundleLanguageCase.elm:
--------------------------------------------------------------------------------
1 | module MultiBundleLanguageCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ inlineOpts, dynamicOpts ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.fromList
18 | [ ( "bundle_1"
19 | , Dict.NonEmpty.fromList
20 | ( ( "en"
21 | , { pairs = Dict.fromList [ ( "text1", ( Text "english text 1", [] ) ) ]
22 | , fallback = Nothing
23 | , resources = ()
24 | }
25 | )
26 | , [ ( "de"
27 | , { pairs = Dict.fromList [ ( "text1", ( Text "german text 1", [] ) ) ]
28 | , fallback = Nothing
29 | , resources = ()
30 | }
31 | )
32 | ]
33 | )
34 | )
35 | , ( "bundle_2"
36 | , Dict.NonEmpty.fromList
37 | ( ( "en"
38 | , { pairs = Dict.fromList [ ( "text2", ( Text "english text 2", [] ) ) ]
39 | , fallback = Nothing
40 | , resources = ()
41 | }
42 | )
43 | , [ ( "de"
44 | , { pairs = Dict.fromList [ ( "text2", ( Text "german text 2", [] ) ) ]
45 | , fallback = Nothing
46 | , resources = ()
47 | }
48 | )
49 | ]
50 | )
51 | )
52 | ]
53 |
--------------------------------------------------------------------------------
/demo/translations/bundles.de.ftl:
--------------------------------------------------------------------------------
1 | headline = Übersetzungs-Bundles
2 | preamble = Bisher ging es immer um eine Übersetzungsdatei pro Sprache. In großen Applikationen könnten wir davon profitieren
3 | nicht alle Übersetzungen für eine Sprache auf einmal zu laden, sondern nur die Übersetzungen für die aktuelle Seite oder
4 | für die aktuellen Baum von Seiten zu laden. Dieses Feature ist nur in Dynamic Mode relevant,
5 | da in Inline Mode sowieso alle Übersetzungen zusammen im Elm Bundle landen.
6 |
7 | considerationsHeadline = Überlegungen
8 | considerationsBody = Stell sicher dass du das Feature brauchst bevor du es benutzt.
9 | Es verringert die Compilezeit Garantien für deine Applikation da du daran denken musst die richtigen Bundles auf den richtigen Seiten einzubinden.
10 | Wenn eine Übersetzung noch nicht geladen wurde, gibt die jeweilige Funktion einen leeren String zurück.
11 | Aktuell kompiliert Travelm-Agency alle Übersetzungsschlüssel in eine flache Liste von Exports, was nicht wirklich dabei hilft die
12 | Dateien von denen sie kommen auseinander zu halten. Präfixe können helfen, daher bietet
13 | Travelm-Agency eine --prefix_file_identifier Flag um den Bundle Name automatisch vor jede Funktion packen zu lassen.
14 | Wenn es dann eine Übersetzung "headline" in der Datei "summary.en.ftl" gibt, wird eine Funktion mit dem Namen "summaryHeadline" generiert.
15 | Falls du feststellst dass Bundling einen großen Einfluss auf eine gute Renderzeit hat, lass es mich wissen. Ich bin offen für Pull Requests und
16 | Ideen den generierten Code zu verbessern.
17 |
18 | exploreHeadline = Entdecke
19 | exploreBody = Der Editor lässt dich auf dieser Seite neue Dateien erstellen! Füge neue Bundles hinzu
20 | und beobachte wie die Ausgabe sich ändert.
21 |
--------------------------------------------------------------------------------
/demo/src/Page.elm:
--------------------------------------------------------------------------------
1 | module Page exposing (..)
2 |
3 | import Http
4 | import InputType
5 | import List.NonEmpty exposing (NonEmpty)
6 | import Model exposing (Model)
7 | import Msg exposing (Msg(..))
8 | import Translations exposing (I18n)
9 |
10 |
11 | loadInputFiles : { files : NonEmpty { name : String, language : String }, directory : String } -> Model -> ( Model, Cmd Msg )
12 | loadInputFiles { files, directory } model =
13 | let
14 | fileExtension =
15 | InputType.toString model.inputType
16 |
17 | toFileName { name, language } =
18 | String.join "." [ name, language, fileExtension ]
19 |
20 | loadOne file =
21 | Http.get
22 | { url = String.join "/" [ model.basePath, directory, toFileName file ]
23 | , expect =
24 | Http.expectString
25 | (Result.map
26 | (\content ->
27 | { name = file.name
28 | , language = file.language
29 | , extension = fileExtension
30 | , content = content
31 | }
32 | )
33 | >> LoadedInputFile
34 | )
35 | }
36 | in
37 | ( { model | activeInputFilePath = List.NonEmpty.head files |> toFileName }
38 | , List.NonEmpty.toList files |> List.map loadOne |> Cmd.batch
39 | )
40 |
41 |
42 | withTranslations :
43 | ((Result Http.Error (I18n -> I18n) -> Msg) -> I18n -> Cmd Msg)
44 | -> ( Model, Cmd Msg )
45 | -> ( Model, Cmd Msg )
46 | withTranslations load ( model, otherCmds ) =
47 | ( model, Cmd.batch [ load LoadedTranslations model.i18n, otherCmds ] )
48 |
--------------------------------------------------------------------------------
/demo/translations/number_format.en.ftl:
--------------------------------------------------------------------------------
1 | headline = Fluent: Formatting numbers
2 |
3 | -intl-link = https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat
4 | -proxy-link = https://www.npmjs.com/package/intl-proxy
5 |
6 | preamble = Did you know that different languages format numbers in different ways?
7 | Here is the number "one-thousand-two-hundred-point-five" in English: 1,200.5
8 | and in German: 1.200,5. Displaying the wrong format on your website might confuse your users.
9 |
10 | intlHeadline = Elm and the Intl APIs
11 | intlBody = Formatting numbers in a variety of different ways is a lot of work.
12 | Thankfully, the Intl API has this covered. Unfortunately, Elm does not provide
13 | first class access to this API. The tradional interop ways like Flags, Ports and Web components do not work well for
14 | this usecase either. Instead, we use the interaction between JSON decoders and ES6 proxies to provide synchronous access.
15 | For this to work, you need to npm install intl-proxy and pass it into your Elm application
16 | as a flag. The generated code will force you to pass this when creating the initial I18n instance,
17 | at which point you never need to use it explicitely in your program again.
18 |
19 | optionsHeadline = Additional options
20 | optionsBody = The NumberFormat.format method can take a variety of different options which are documented
21 | here. To use them, just add them as comma-seperated arguments to the NUMBER function call.
22 | For example, NUMBER($num, style: "percent") will pass the argument to the Intl API, which will then (in English) essentially
23 | multiply your number by 100 and add a '%' sign.
--------------------------------------------------------------------------------
/gen_test_cases/HtmlInterpolationCase.elm:
--------------------------------------------------------------------------------
1 | module HtmlInterpolationCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ inlineOpts, dynamicOpts ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | let
18 | htmlContentAdmin =
19 | ( Interpolation "username", [ Text " may click on this link." ] )
20 |
21 | hrefAdmin =
22 | ( Interpolation "adminLink", [] )
23 |
24 | adminView =
25 | ( Text "Thanks for logging in. "
26 | , [ Html
27 | { tag = "a"
28 | , id = "adminLink"
29 | , attrs = [ ( "href", hrefAdmin ) ]
30 | , content = htmlContentAdmin
31 | }
32 | ]
33 | )
34 |
35 | normalView =
36 | ( Text "You (", [ Interpolation "username", Text ") are not an admin." ] )
37 |
38 | publicView =
39 | ( Text "You are not logged in.", [] )
40 | in
41 | Dict.singleton "messages" <|
42 | Dict.NonEmpty.singleton "en"
43 | { pairs =
44 | Dict.fromList
45 | [ ( "text"
46 | , ( InterpolationCase "role"
47 | publicView
48 | <|
49 | Dict.fromList [ ( "admin", adminView ), ( "normal", normalView ) ]
50 | , []
51 | )
52 | )
53 | ]
54 | , fallback = Nothing
55 | , resources = ()
56 | }
57 |
--------------------------------------------------------------------------------
/demo/translations/language.de.ftl:
--------------------------------------------------------------------------------
1 | headline = Sprachen
2 | preamble = Wenn du deine Anwendung ohne travelm-agency bauen würdest, wie würdest du die aktuelle Sprache typisieren.
3 | Ich würde wahrscheinlich einen Union Typ nutzen mit einem Konstruktor pro Sprache. Dieser Code wird auch von
4 | Travelm-Agency generiert.
5 |
6 | conversionsHeadline = Umwandlungen
7 | conversionsBody = Ein Union Typ ist zwar nett, aber zumindest Serialisierung und Deserialisierung zu einem String ist notwendig um etwas sinnvolles damit anzufangen.
8 | Travelm-agency generiert languageToString
9 | und languageFromString für dich! Die coole Sache über letztere Methode ist, dass das Matching nicht exakt sondern präfix-basiert ist.
10 | Also keine Angst, en-US wird trotzdem zu Just En geparsed.
11 |
12 | activeLanguageHeadline = Welche Sprache ist aktiv?
13 | activeLanguageBody = Ist das nicht eine triviale Frage? Die Sprache die der User ausgewählt hat. Die Sprache deren Übersetzungen ich sehen kann.
14 | Moment. Was passiert wenn ich Übersetzungen dynamisch nachlade und der Request noch nicht fertig ist?
15 | Normalerweise gibt es zwei interessante Sprachen zu einem beliebigen Zeitpunkt.
16 | Die Sprache in die unsere Anwendung sein sollte, und die Sprache in der unsere Anwendung gerade ist.
17 | Daher werden für diese beiden Sprachen Funktionen generiert: currentLanguage and arrivedLanguage.
18 | Du kannst den Unterschied live beobachten wenn du in den DevTools die Netzwerkgeschwindigkeit drosselst und die Sprache oben mit einer der Flaggen in der Mitte änderst.
19 | Wenn mehr Logik als das notwendig ist, solltest du vermutlich die Sprache zusätzlich in deinem eigenen Model speichern
20 | (oder ein Issue aufmachen, vielleicht haben andere Nutzer das gleiche Problem).
21 |
--------------------------------------------------------------------------------
/demo/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | I18n Instanz erstellst, danach solltest du den Proxy nie wieder explizit in
15 | deiner Applikation brauchen.
16 |
17 | compileTimeHeadline = Ausführung zur Compilezeit
18 | compileTimeBody = Du kannst ein ISO-8601 formatiertes String Literal statt einer Variable in die DATETIME Funktion geben.
19 | Damit wird die Funktion zur Compilezeit ausgeführt und inlined das resultierende formatierte Datum.
20 |
21 | optionsHeadline = Zusätzliche Optionen
22 | optionsBody = Die NumberFormat.format Methode kann eine Menge verschiedener Optionen erhalten, die
23 | hier dokumentiert sind. Um sie zu benutzen, kannst du sie als kommaseparierte Argumente in
24 | den DATETIME Funktionsaufruf geben. Der Aufruf DATETIME($date, dateStyle: "full") z.B. wird das Argument
25 | an die Intl API weiterleiten, welche dann Wochentag und Monat in textueller Form anzeigt (also z.B. "Sunday, 20 December 2020").
26 |
--------------------------------------------------------------------------------
/demo/translations/number_format.de.ftl:
--------------------------------------------------------------------------------
1 | headline = Fluent: Formatting numbers
2 |
3 | -intl-link = https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat
4 | -proxy-link = https://www.npmjs.com/package/intl-proxy
5 |
6 | preamble = Wusstest du schon dass verschiedene Sprachen Zahlen unterschiedlich formatieren?
7 | Hier ist die Zahl "Ein-Tausend-Zwei-Hundert-Komma-Fünf" in Deutsch 1.200,5
8 | und in Englisch 1,200.5. Das falsche Format auf deiner Website anzuzeigen könnte deine Nutzer verwirren.
9 |
10 | intlHeadline = Elm und die Intl APIs
11 | intlBody = Zahlen in verschiedenen Arten zu formatieren ist eine Menge Arbeit.
12 | Zum Glück deckt die Intl API dies ab.
13 | Unglücklicherweise bietet Elm keinen einfachen Zugriff auf diese API.
14 | Die traditionellen Interop Wege wie Flags, Ports und Web Components funktionieren für diesen Usecase leider alle nicht besonders gut.
15 | Stattdessen verwenden wir die Interaktion zwischen JSON Decoders und ES6 Proxies um synchronen Zugiff zu erlauben.
16 | Damit dies funktioniert, wirst du intl-proxy per npm installieren und in deine Elm Applikation
17 | als Flag geben müssen. Der generierte Code wird dich dazu zwingen den Proxy zu übergeben
18 | wenn du die initiale I18n Instanz erstellst, danach solltest du den Proxy nie wieder explizit in
19 | deiner Applikation brauchen.
20 |
21 | optionsHeadline = Zusätzliche Optionen
22 | optionsBody = Die NumberFormat.format Methode kann eine Menge verschiedener Optionen erhalten, die hier
23 | dokumentiert sind. Um sie zu verwenden, Um sie zu benutzen, kannst du sie als kommaseparierte Argumente in
24 | den NUMBER Funktionsaufruf geben. Der Aufruf NUMBER($num, style: "percent") wird z.B. das Argument an die Intl API
25 | weiterleiten, welche dann (in Deutsch) im Wesentlichen deine Zahl mit 100 multipliziert und ein '%' Zeichen ergänzt.
26 |
--------------------------------------------------------------------------------
/src/Parser/DeadEnds.elm:
--------------------------------------------------------------------------------
1 | module Parser.DeadEnds exposing (..)
2 |
3 | import Parser exposing (..)
4 |
5 |
6 | deadEndsToString : List DeadEnd -> String
7 | deadEndsToString =
8 | let
9 | deadEndToString : DeadEnd -> String
10 | deadEndToString deadEnd =
11 | let
12 | position : String
13 | position =
14 | "row:" ++ String.fromInt deadEnd.row ++ " col:" ++ String.fromInt deadEnd.col
15 | in
16 | case deadEnd.problem of
17 | Expecting str ->
18 | "Expecting " ++ str ++ " at " ++ position
19 |
20 | ExpectingInt ->
21 | "ExpectingInt at " ++ position
22 |
23 | ExpectingHex ->
24 | "ExpectingHex at " ++ position
25 |
26 | ExpectingOctal ->
27 | "ExpectingOctal at " ++ position
28 |
29 | ExpectingBinary ->
30 | "ExpectingBinary at " ++ position
31 |
32 | ExpectingFloat ->
33 | "ExpectingFloat at " ++ position
34 |
35 | ExpectingNumber ->
36 | "ExpectingNumber at " ++ position
37 |
38 | ExpectingVariable ->
39 | "ExpectingVariable at " ++ position
40 |
41 | ExpectingSymbol str ->
42 | "ExpectingSymbol " ++ str ++ " at " ++ position
43 |
44 | ExpectingKeyword str ->
45 | "ExpectingKeyword " ++ str ++ " at " ++ position
46 |
47 | ExpectingEnd ->
48 | "ExpectingEnd at " ++ position
49 |
50 | UnexpectedChar ->
51 | "UnexpectedChar at " ++ position
52 |
53 | Problem str ->
54 | "Problem: " ++ str ++ " at " ++ position
55 |
56 | BadRepeat ->
57 | "BadRepeat at " ++ position
58 | in
59 | String.join "; " << List.map deadEndToString
60 |
--------------------------------------------------------------------------------
/demo/src/Pages/Interpolation.elm:
--------------------------------------------------------------------------------
1 | module Pages.Interpolation exposing (init, viewExplanation)
2 |
3 | import Accordion
4 | import Html exposing (Html)
5 | import Html.Attributes exposing (class)
6 | import InputType
7 | import Model exposing (Model)
8 | import Msg exposing (Msg(..))
9 | import Page
10 | import Ports
11 | import Translations
12 |
13 |
14 | init : Model -> ( Model, Cmd Msg )
15 | init model =
16 | model
17 | |> Model.setInputTypeAndModeDefaults ( InputType.Json, Ports.Inline )
18 | |> Page.loadInputFiles { directory = "interpolation", files = ( { name = "example", language = "en" }, [] ) }
19 | |> Page.withTranslations Translations.loadInterpolation
20 |
21 |
22 | viewExplanation : Model -> List (Html Msg)
23 | viewExplanation ({ i18n } as model) =
24 | [ Html.p [] [ Html.text <| Translations.interpolationPreamble i18n ]
25 | , Html.h2 [] [ Html.text <| Translations.sharedSyntaxHeadline i18n ]
26 | , Accordion.view
27 | { headline = Translations.sharedJsonHeadline i18n
28 | , content = Translations.interpolationJsonSyntaxBody [ class "highlighted" ] i18n
29 | , id = "json_syntax"
30 | }
31 | model
32 | , Accordion.view
33 | { headline = Translations.sharedPropertiesHeadline i18n
34 | , content = Translations.interpolationPropertiesSyntaxBody [ class "highlighted" ] i18n
35 | , id = "properties_syntax"
36 | }
37 | model
38 | , Accordion.view
39 | { headline = Translations.sharedFluentHeadline i18n
40 | , content = Translations.interpolationFluentSyntaxBody [ class "highlighted" ] i18n
41 | , id = "fluent_syntax"
42 | }
43 | model
44 | , Html.h2 [] [ Html.text <| Translations.interpolationGeneratedCodeHeadline i18n ]
45 | , Html.p [] <| Translations.interpolationGeneratedCodeBody [ class "highlighted" ] i18n
46 | , Html.h2 [] [ Html.text <| Translations.interpolationInconsistentKeysHeadline i18n ]
47 | , Html.p [] [ Html.text <| Translations.interpolationInconsistentKeysBody i18n ]
48 | ]
49 |
--------------------------------------------------------------------------------
/gen_test_cases/NestedHtmlCase.elm:
--------------------------------------------------------------------------------
1 | module NestedHtmlCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ inlineOpts, dynamicOpts ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.singleton "messages" <|
18 | Dict.NonEmpty.singleton "en"
19 | { pairs =
20 | Dict.fromList
21 | [ ( "html"
22 | , ( Html
23 | { tag = "a"
24 | , id = "link"
25 | , attrs = [ ( "href", ( Text "/", [] ) ) ]
26 | , content =
27 | ( Html
28 | { tag = "span"
29 | , id = "text"
30 | , attrs =
31 | [ ( "width", ( Text "100", [] ) )
32 | , ( "height", ( Text "50", [] ) )
33 | ]
34 | , content = ( Text "Click me", [] )
35 | }
36 | , [ Text "!"
37 | , Html
38 | { tag = "img"
39 | , id = "image"
40 | , attrs = [ ( "src", ( Text "/imgUrl.png", [] ) ) ]
41 | , content = ( Text "", [] )
42 | }
43 | ]
44 | )
45 | }
46 | , []
47 | )
48 | )
49 | ]
50 | , fallback = Nothing
51 | , resources = ()
52 | }
53 |
--------------------------------------------------------------------------------
/tests/RequestDecoderTest.elm:
--------------------------------------------------------------------------------
1 | module RequestDecoderTest exposing (..)
2 |
3 | import Expect
4 | import Json.Decode as D
5 | import Ports exposing (GeneratorMode(..), Request(..), requestDecoder)
6 | import Test exposing (Test, describe, test)
7 |
8 |
9 | suite : Test
10 | suite =
11 | describe "Request Decoder"
12 | [ test "decode translation request" <|
13 | \_ ->
14 | D.decodeString requestDecoder """{
15 | "type": "translation",
16 | "fileName": "demo.en.json",
17 | "fileContent": "{\\"demoKey\\": \\"demoValue\\"}"
18 | }"""
19 | |> Expect.equal
20 | (Ok <|
21 | AddTranslation
22 | { content = "{\"demoKey\": \"demoValue\"}"
23 | , extension = "json"
24 | , identifier = "demo"
25 | , language = "en"
26 | }
27 | )
28 | , test "decode finish request" <|
29 | \_ ->
30 | D.decodeString requestDecoder """{
31 | "type": "finish",
32 | "elmModuleName": "Test.elm",
33 | "generatorMode": "inline",
34 | "addContentHash": true,
35 | "i18nArgFirst": true,
36 | "prefixFileIdentifier": true,
37 | "customHtmlModule": "Html.Styled",
38 | "customHtmlAttributesModule": "Html.Styled.Attributes"
39 | }"""
40 | |> Expect.equal
41 | (Ok <|
42 | FinishModule
43 | { elmModuleName = "Test.elm"
44 | , generatorMode = Inline
45 | , addContentHash = True
46 | , i18nArgFirst = True
47 | , prefixFileIdentifier = True
48 | , customHtmlModule = "Html.Styled"
49 | , customHtmlAttributesModule = "Html.Styled.Attributes"
50 | }
51 | )
52 | ]
53 |
--------------------------------------------------------------------------------
/demo/src/Pages/Consistency.elm:
--------------------------------------------------------------------------------
1 | module Pages.Consistency exposing (init, viewExplanation)
2 |
3 | import Accordion
4 | import Html exposing (Html)
5 | import Html.Attributes exposing (class)
6 | import InputType
7 | import Model exposing (Model)
8 | import Msg exposing (Msg)
9 | import Page
10 | import Ports
11 | import Translations
12 |
13 |
14 | init : Model -> ( Model, Cmd Msg )
15 | init model =
16 | model
17 | |> Model.setInputTypeAndModeDefaults ( InputType.Json, Ports.Inline )
18 | |> Page.loadInputFiles
19 | { directory = "consistency"
20 | , files =
21 | ( { name = "example", language = "en" }
22 | , [ { name = "example", language = "de" }
23 | ]
24 | )
25 | }
26 | |> Page.withTranslations Translations.loadConsistency
27 |
28 |
29 | viewExplanation : Model -> List (Html Msg)
30 | viewExplanation ({ i18n } as model) =
31 | [ Html.p [] [ Html.text <| Translations.consistencyPreamble i18n ]
32 | , Html.h2 [] [ Html.text <| Translations.consistencyMissingKeysHeadline i18n ]
33 | , Html.p [] <| Translations.consistencyMissingKeysBody [ class "highlighted" ] i18n
34 | , Html.h2 [] [ Html.text <| Translations.consistencyFallbackHeadline i18n ]
35 | , Html.p [] [ Html.text <| Translations.consistencyFallbackBody i18n ]
36 | , Accordion.view
37 | { headline = Translations.sharedJsonHeadline i18n
38 | , content = Translations.consistencyFallbackSyntaxJson [ class "highlighted" ] i18n
39 | , id = "json_syntax"
40 | }
41 | model
42 | , Accordion.view
43 | { headline = Translations.sharedPropertiesHeadline i18n
44 | , content = Translations.consistencyFallbackSyntaxProperties [ class "highlighted" ] i18n
45 | , id = "properties_syntax"
46 | }
47 | model
48 | , Accordion.view
49 | { headline = Translations.sharedFluentHeadline i18n
50 | , content = Translations.consistencyFallbackSyntaxFluent [ class "highlighted" ] i18n
51 | , id = "fluent_syntax"
52 | }
53 | model
54 | ]
55 |
--------------------------------------------------------------------------------
/.github/workflows/build_and_test.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v2
15 | - name: Reconfigure git to use HTTP authentication
16 | run: >
17 | git config --global url."https://github.com/".insteadOf
18 | ssh://git@github.com/
19 | - name: Use Node.js
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: 22.x
23 | cache: "npm"
24 | registry-url: https://registry.npmjs.org
25 | - name: Install Elm
26 | run: |
27 | npm install -g elm@0.19.1
28 | - name: Cache Elm files
29 | uses: actions/cache@v3
30 | with:
31 | path: |
32 | ~/.elm
33 | elm-stuff
34 | key: ${{ runner.os }}-elm-${{ hashFiles('**/elm.json') }}
35 | restore-keys: |
36 | ${{ runner.os }}-elm
37 | - name: install
38 | run: npm ci && (cd demo && npm ci)
39 | - name: build
40 | run: npm run build && (cd demo && npm run build)
41 | - name: test
42 | run: npm test
43 | - name: npm publish
44 | if: ${{ github.event_name == 'push' }}
45 | run: |
46 | npm config set //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN
47 | npm publish --access public || true
48 | env:
49 | CI: true
50 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
51 | - name: deploy docs
52 | if: ${{ github.event_name == 'push' }}
53 | run: |
54 | cd demo/dist
55 | echo $GITHUB_ACTOR
56 | git init
57 | git config --global user.name $GITHUB_ACTOR
58 | git config --global user.email $GITHUB_ACTOR@users.noreply.github.com
59 | git checkout -b gh-pages
60 | touch .nojekyll
61 | git add -A
62 | git commit -m "Deploy"
63 | git remote add origin https://anmolitor:$GITHUB_TOKEN@github.com/anmolitor/travelm-agency.git
64 | git push -f --set-upstream origin gh-pages
65 | env:
66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
67 |
--------------------------------------------------------------------------------
/demo/translations/consistency.en.ftl:
--------------------------------------------------------------------------------
1 | headline = Consistency
2 | preamble = The time has come where we actually work with multiple input files.
3 | But what are the consequences?
4 |
5 | missingKeysHeadline = Missing keys
6 | missingKeysBody = When working with other internalization solutions, translations are usually just simple key value maps.
7 | And when you have multiple of them, there is no check that they actually contain the same keys.
8 | Things are different here - since we analyse the translations at compile time already, we might as well check for completeness.
9 | This is great to provide guarantees for production but is really annoying in development, most notably when you are using
10 | some sort of watch mode that automatically runs Travelm-Agency on file changes.
11 | The solution: The flag --devMode disables the completeness check and result in an empty string if the translation
12 | is requested.
13 |
14 | fallbackHeadline = Explicit fallbacks
15 | fallbackBody = I mentioned that it is great to provide guarantees for production. However, as good agile software engineers
16 | we want to release early and often, but translations are often an external dependency outside of the development team.
17 | To combat this scenario, you may declare a fallback language in any translation file. Missing translations will then be pulled from
18 | the referenced file (which may itself declare a fallback language).
19 | The only limitation here is that the fallback graph needs to be acyclic.
20 |
21 | fallbackSyntaxJson = Since JSON does not allow any comments, the fallback language needs to specified as a top-level,
22 | reserved key named "--fallback-language".
23 |
24 | fallbackSyntaxProperties = In the .properties format, any line beginning with "#" is considered a comment.
25 | A fallback language may be specified with a comment of the form fallback-language: en
26 | (where "en" is the language to use as a fallback in this example).
27 |
28 | fallbackSyntaxFluent = In the Fluent format, any line beginning with "#" is considered a comment.
29 | A fallback language may be specified with a comment of the form fallback-language: en
30 | (where "en" is the language to use as a fallback in this example).
31 |
32 |
--------------------------------------------------------------------------------
/demo/translations/consistency.de.ftl:
--------------------------------------------------------------------------------
1 | headline = Konsistenz
2 | preamble = Die Zeit ist gekommen mit mehreren Input Dateien zu arbeiten! Aber was sind die Konsequenzen?
3 |
4 | missingKeysHeadline = Fehlende Übersetzungen
5 | missingKeysBody = In anderen Lösungen für Internationalisierung sind Übersetzungen oft einfach Key Value Maps.
6 | Und wenn es mehrere davon gibt, gibt es keine Checks dass diese die gleichen Keys enthalten.
7 | Hier ist es anders - da wir die Übersetzungen schon zur Compilezeit analyisieren, können wir auch direkt auf Vollständigkeit
8 | prüfen. Das ist zwar super für die Produktion aber nervig für Entwicklung, besonders bei der Benutzung eines Watch Modes, der
9 | Travelm-Agency bei jeder Dateiänderung ausführt.
10 | Die Lösung: Die Flag --devMode schaltet die Vollständigkeitsprüfung aus und führt zu einem leeren String
11 | wenn die Übersetzung angefragt wird.
12 |
13 | fallbackHeadline = Explizite Fallbacks
14 | fallbackBody = Wie schon erwähnt ist es eine gute Idee Eigenschaften für die produktiv laufende Software garantieren zu können.
15 | Doch als gute agile Softwareentwickler wollen wir früh und oft Software ausliefern. Übersetzungen sind häufig auch eine externe Abhängigkeit
16 | außerhalb des Entwicklungsteams. Um diese Szenario zu behandeln gibt es die Möglichkeit in jeder Übersetzungsdatei eine Fallback Sprache zu deklarieren.
17 | Fehlende Übersetzungen werden dann von der referenzierten Datei gezogen (die selbst wieder auf eine weitere Datei verweisen kann).
18 | Die einzige Limitation ist dass der Fallback Graph azyklisch sein muss.
19 |
20 | fallbackSyntaxJson = Da JSON keine Kommentare erlaubt muss die Fallback Sprache in einem top-level Key deklariert werden:
21 | dem reservierten Key "--fallback-language".
22 |
23 | fallbackSyntaxProperties = Im .properties Format ist jede Zeile die mit "#" beginnt ein Kommentar.
24 | Eine Fallback Sprache kann mit einem Kommentar der Form fallback-language: en deklariert werden
25 | (wobei "en" die Sprache ist auf die zurückgefallen werden soll).
26 |
27 | fallbackSyntaxFluent = Im Fluent Format ist jede Zeile die mit "#" beginnt ein Kommentar.
28 | Eine Fallback Sprache kann mit einem Kommentar der Form fallback-language: en deklariert werden
29 | (wobei "en" die Sprache ist auf die zurückgefallen werden soll).
30 |
31 |
--------------------------------------------------------------------------------
/gen_test_cases/EscapeCase.elm:
--------------------------------------------------------------------------------
1 | module EscapeCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.Segment exposing (TSegment(..))
7 | import Util.Shared exposing (Generator, buildMain, dynamicOpts)
8 |
9 |
10 | main : Generator
11 | main =
12 | buildMain [ dynamicOpts ] state
13 |
14 |
15 | state : State ()
16 | state =
17 | Dict.singleton "messages" <|
18 | Dict.NonEmpty.singleton
19 | "en"
20 | { pairs =
21 | Dict.fromList
22 | [ ( "text", ( Text "escaped interpolation { $var }, actual ", [ Interpolation "interpolation" ] ) )
23 | , ( "html"
24 | , ( Text "escaped interpolation { $var }, actual "
25 | , [ Html
26 | { tag = "b"
27 | , id = "bold"
28 | , attrs = []
29 | , content = ( Interpolation "interpolation", [] )
30 | }
31 | ]
32 | )
33 | )
34 | , ( "quotationMarkAndBackslash", ( Text "just a \\ and \"quotation mark\"", [ Interpolation "a" ] ) )
35 | , ( "quotationMarkAndBackslashHtml"
36 | , ( Text "just a \\ and \"quotation mark\""
37 | , [ Html
38 | { tag = "b"
39 | , id = "bold"
40 | , attrs = []
41 | , content = ( Interpolation "interpolation", [] )
42 | }
43 | ]
44 | )
45 | )
46 | , ( "pipeOperatorInterpolationCase"
47 | , ( InterpolationCase "val" ( Text "just a | pipe", [] ) Dict.empty
48 | , []
49 | )
50 | )
51 | , ( "pipeOperatorHtml"
52 | , ( Html { tag = "div", id = "div", content = ( Text "just a | pipe", [] ), attrs = [] }
53 | , []
54 | )
55 | )
56 | ]
57 | , fallback = Nothing
58 | , resources = ()
59 | }
60 |
--------------------------------------------------------------------------------
/gen_test_cases/HtmlIntlCase.elm:
--------------------------------------------------------------------------------
1 | module HtmlIntlCase exposing (..)
2 |
3 | import Dict
4 | import Dict.NonEmpty
5 | import State exposing (State)
6 | import Types.ArgValue exposing (ArgValue(..))
7 | import Types.Segment exposing (TSegment(..))
8 | import Util.Shared exposing (Generator, buildMain, dynamicOpts, inlineOpts)
9 |
10 |
11 | main : Generator
12 | main =
13 | buildMain [ inlineOpts, dynamicOpts ] state
14 |
15 |
16 | state : State ()
17 | state =
18 | Dict.singleton "messages" <|
19 | Dict.NonEmpty.singleton
20 | "en"
21 | { pairs =
22 | Dict.fromList
23 | [ ( "formatNumber"
24 | , ( Text "Price: "
25 | , [ Html
26 | { tag = "b"
27 | , attrs = []
28 | , id = "price"
29 | , content = ( FormatNumber "price" [], [] )
30 | }
31 | ]
32 | )
33 | )
34 | , ( "formatDate"
35 | , ( Text "Today is "
36 | , [ Html
37 | { tag = "em"
38 | , attrs = []
39 | , id = "today"
40 | , content = ( FormatDate "date" [], [] )
41 | }
42 | ]
43 | )
44 | )
45 | , ( "pluralRules"
46 | , ( Html
47 | { tag = "p"
48 | , attrs = []
49 | , id = "plural"
50 | , content = ( PluralCase "amount" [] ( Interpolation "amount", [] ) Dict.empty, [] )
51 | }
52 | , []
53 | )
54 | )
55 | , ( "normalHtml"
56 | , ( Html
57 | { tag = "p"
58 | , attrs = []
59 | , id = "p"
60 | , content = ( Text "just some html", [] )
61 | }
62 | , []
63 | )
64 | )
65 | ]
66 | , fallback = Nothing
67 | , resources = ()
68 | }
69 |
--------------------------------------------------------------------------------
/src/Generators/Names.elm:
--------------------------------------------------------------------------------
1 | module Generators.Names exposing (Names, defaultNames, withUniqueNames)
2 |
3 | import Elm.CodeGen exposing (ModuleName)
4 | import Set
5 | import String.Extra
6 | import Types.Segment exposing (TKey)
7 | import Types.UniqueName as Unique
8 |
9 |
10 | type alias Names =
11 | { languageTypeName : String
12 | , languagesName : String
13 | , i18nTypeName : String
14 | , initFunName : String
15 | , loadName : String -> String
16 | , languageFromStringFunName : String
17 | , languageToStringFunName : String
18 | , decoderName : String -> String
19 | , htmlModuleName : ModuleName
20 | , htmlAttributesModuleName : ModuleName
21 | }
22 |
23 |
24 | defaultNames : Names
25 | defaultNames =
26 | { languageTypeName = "Language"
27 | , languagesName = "languages"
28 | , i18nTypeName = "I18n"
29 | , initFunName = "init"
30 | , loadName = \identifier -> "load" ++ String.Extra.classify identifier
31 | , languageFromStringFunName = "languageFromString"
32 | , languageToStringFunName = "languageToString"
33 | , decoderName = \identifier -> "decode" ++ String.Extra.classify identifier
34 | , htmlModuleName = [ "Html" ]
35 | , htmlAttributesModuleName = [ "Html", "Attributes" ]
36 | }
37 |
38 |
39 | withUniqueNames : List TKey -> Names -> (Names -> a -> b) -> Unique.UniqueNameContext a -> Unique.UniqueNameContext b
40 | withUniqueNames identifiers names doWithNames =
41 | let
42 | newNameList =
43 | List.map ((<|) names.loadName) identifiers
44 | ++ List.map ((<|) names.decoderName) identifiers
45 | ++ [ names.languageTypeName
46 | , names.languagesName
47 | , names.i18nTypeName
48 | , names.initFunName
49 | , names.languageFromStringFunName
50 | , names.languageToStringFunName
51 | ]
52 | in
53 | Unique.combineAndThen (\_ -> Set.fromList newNameList) <|
54 | \_ a lookup ->
55 | doWithNames
56 | { languageTypeName = lookup names.languageTypeName
57 | , languagesName = lookup names.languagesName
58 | , i18nTypeName = lookup names.i18nTypeName
59 | , initFunName = lookup names.initFunName
60 | , languageFromStringFunName = lookup names.languageFromStringFunName
61 | , languageToStringFunName = lookup names.languageToStringFunName
62 | , loadName = names.loadName >> lookup
63 | , decoderName = names.decoderName >> lookup
64 | , htmlModuleName = names.htmlModuleName
65 | , htmlAttributesModuleName = names.htmlAttributesModuleName
66 | }
67 | a
68 |
--------------------------------------------------------------------------------
/demo/src/Pages/Intro.elm:
--------------------------------------------------------------------------------
1 | module Pages.Intro exposing (init, viewExplanation)
2 |
3 | import Accordion
4 | import Html exposing (Html)
5 | import Html.Attributes exposing (class)
6 | import InputType
7 | import Model exposing (Model)
8 | import Msg exposing (Msg(..))
9 | import Page
10 | import Ports
11 | import Translations
12 |
13 |
14 | init : Model -> ( Model, Cmd Msg )
15 | init model =
16 | model
17 | |> Model.setInputTypeAndModeDefaults ( InputType.Json, Ports.Inline )
18 | |> Page.loadInputFiles { directory = "intro", files = ( { name = "example", language = "en" }, [] ) }
19 | |> Page.withTranslations Translations.loadIntro
20 |
21 |
22 | viewExplanation : Model -> List (Html Msg)
23 | viewExplanation ({ i18n } as model) =
24 | [ Html.p [] [ Html.text <| Translations.introPreamble i18n ]
25 | , Html.h2 [] [ Html.text <| Translations.introExplanationHeadline i18n ]
26 | , Html.p [] [ Html.text <| Translations.introExplanationBody i18n ]
27 | , Html.h2 [] [ Html.text <| Translations.introAdvantagesHeadline i18n ]
28 | , Accordion.view
29 | { headline = Translations.introAdvantageReadabilityHeadline i18n
30 | , content = Translations.introAdvantageReadabilityBody [ class "highlighted" ] i18n
31 | , id = "readability"
32 | }
33 | model
34 | , Accordion.view
35 | { headline = Translations.introAdvantageTypeSafetyHeadline i18n
36 | , content = Translations.introAdvantageTypeSafetyBody { code = [ class "highlighted" ], list = [], item = [] } i18n
37 | , id = "type_safety"
38 | }
39 | model
40 | , Accordion.view
41 | { headline = Translations.introAdvantagePerformanceHeadline i18n
42 | , content = Translations.introAdvantagePerformanceBody [ class "highlighted" ] i18n
43 | , id = "performance"
44 | }
45 | model
46 | , Html.h2 [] [ Html.text <| Translations.introDisadvantagesHeadline i18n ]
47 | , Accordion.view
48 | { headline = Translations.introDisadvantageProgrammabilityHeadline i18n
49 | , content = [ Html.text <| Translations.introDisadvantageProgrammabilityBody i18n ]
50 | , id = "programmability"
51 | }
52 | model
53 | , Accordion.view
54 | { headline = Translations.introDisadvantageToolchainHeadline i18n
55 | , content = [ Html.text <| Translations.introDisadvantageToolchainBody i18n ]
56 | , id = "toolchain"
57 | }
58 | model
59 | , Html.h2 [] [ Html.text <| Translations.introTutorialHowtoHeadline i18n ]
60 | , Html.p [] [ Html.text <| Translations.introTutorialHowtoBody i18n ]
61 | , Html.p [] [ Html.text <| Translations.introTutorialMobileAdditional i18n ]
62 | , Html.h2 [] [ Html.text <| Translations.introTextsFeatureHeadline i18n ]
63 | , Html.p [] [ Html.text <| Translations.introTextsFeatureBody i18n ]
64 | ]
65 |
--------------------------------------------------------------------------------
/src/Types/Features.elm:
--------------------------------------------------------------------------------
1 | module Types.Features exposing (Feature(..), Features, addFeature, combine, combineMap, default, fromList, isActive, isEmpty, needsIntl, oneIsActive, singleton, union)
2 |
3 | {-| Conditionals that change the output of the code generator that are inferred by the given translation files
4 |
5 | Any Intl Feature : Need dependency on the intl-proxy package. Allows for usage of the Browsers Intl API in generated code.
6 |
7 | IntlNumber: specifically the ability to format numbers
8 | IntlDate: specifically the ability to format dates
9 | IntlPlural: specifically the ability to match on plural cases
10 |
11 | Interpolation: classic string interpolation
12 | Case Interpolation: match on given string values at run time to decide which path to take
13 |
14 | Html : Will produce Html instead of String as a return value (or Element/Element.WithContext (TODO))
15 |
16 | -}
17 |
18 | import Set exposing (Set)
19 |
20 |
21 | type Feature
22 | = IntlNumber
23 | | IntlDate
24 | | IntlPlural
25 | | CaseInterpolation
26 | | Interpolation
27 | | Html
28 |
29 |
30 | serialize : Feature -> String
31 | serialize feature =
32 | case feature of
33 | IntlNumber ->
34 | "IntlNumber"
35 |
36 | IntlDate ->
37 | "IntlDate"
38 |
39 | IntlPlural ->
40 | "IntlPlural"
41 |
42 | CaseInterpolation ->
43 | "CaseInterpolation"
44 |
45 | Interpolation ->
46 | "Interpolation"
47 |
48 | Html ->
49 | "Html"
50 |
51 |
52 | type Features
53 | = Features (Set String)
54 |
55 |
56 | default : Features
57 | default =
58 | Features Set.empty
59 |
60 |
61 | singleton : Feature -> Features
62 | singleton =
63 | serialize >> Set.singleton >> Features
64 |
65 |
66 | fromList : List Feature -> Features
67 | fromList =
68 | List.map serialize >> Set.fromList >> Features
69 |
70 |
71 | isEmpty : Features -> Bool
72 | isEmpty (Features features) =
73 | Set.isEmpty features
74 |
75 |
76 | addFeature : Feature -> Features -> Features
77 | addFeature =
78 | serialize >> Set.insert >> lift
79 |
80 |
81 | isActive : Feature -> Features -> Bool
82 | isActive feature (Features features) =
83 | Set.member (serialize feature) features
84 |
85 |
86 | oneIsActive : List Feature -> Features -> Bool
87 | oneIsActive list features =
88 | List.any (\feature -> isActive feature features) list
89 |
90 |
91 | needsIntl : Features -> Bool
92 | needsIntl =
93 | oneIsActive [ IntlNumber, IntlDate, IntlPlural ]
94 |
95 |
96 | combineMap : (a -> Features) -> List a -> Features
97 | combineMap f =
98 | List.map f >> combine
99 |
100 |
101 | combine : List Features -> Features
102 | combine =
103 | List.foldl union default
104 |
105 |
106 | union : Features -> Features -> Features
107 | union (Features features) =
108 | lift <| Set.union features
109 |
110 |
111 | lift : (Set String -> Set String) -> Features -> Features
112 | lift f (Features features) =
113 | Features <| f features
114 |
--------------------------------------------------------------------------------
/src/Dict/NonEmpty.elm:
--------------------------------------------------------------------------------
1 | module Dict.NonEmpty exposing
2 | ( NonEmpty
3 | , foldl
4 | , foldl1
5 | , fromDict
6 | , fromList
7 | , get
8 | , getFirstEntry
9 | , insert
10 | , keys
11 | , map
12 | , singleton
13 | , toDict
14 | , toList
15 | , toNonEmptyList
16 | , update
17 | , values
18 | )
19 |
20 | import Dict exposing (Dict)
21 | import List.NonEmpty
22 |
23 |
24 | type NonEmpty k v
25 | = NonEmpty ( ( k, v ), Dict k v )
26 |
27 |
28 | singleton : k -> v -> NonEmpty k v
29 | singleton k v =
30 | NonEmpty ( ( k, v ), Dict.empty )
31 |
32 |
33 | fromDict : Dict comparable v -> Maybe (NonEmpty comparable v)
34 | fromDict =
35 | Dict.toList >> List.NonEmpty.fromList >> Maybe.map fromList
36 |
37 |
38 | get : comparable -> NonEmpty comparable v -> Maybe v
39 | get k (NonEmpty ( ( firstKey, firstValue ), rest )) =
40 | if k == firstKey then
41 | Just firstValue
42 |
43 | else
44 | Dict.get k rest
45 |
46 |
47 | getFirstEntry : NonEmpty comparable v -> ( comparable, v )
48 | getFirstEntry =
49 | toNonEmptyList >> List.NonEmpty.sortBy Tuple.first >> List.NonEmpty.head
50 |
51 |
52 | keys : NonEmpty k v -> List k
53 | keys (NonEmpty ( ( k, _ ), rest )) =
54 | k :: Dict.keys rest
55 |
56 |
57 | values : NonEmpty k v -> List v
58 | values (NonEmpty ( ( _, v ), rest )) =
59 | v :: Dict.values rest
60 |
61 |
62 | toDict : NonEmpty comparable v -> Dict comparable v
63 | toDict (NonEmpty ( ( k, v ), rest )) =
64 | Dict.insert k v rest
65 |
66 |
67 | fromList : List.NonEmpty.NonEmpty ( comparable, v ) -> NonEmpty comparable v
68 | fromList ( ( firstK, firstV ), tail ) =
69 | List.foldl (\( k, v ) -> insert k v) (singleton firstK firstV) tail
70 |
71 |
72 | insert : comparable -> v -> NonEmpty comparable v -> NonEmpty comparable v
73 | insert k v (NonEmpty ( ( firstKey, firstValue ), rest )) =
74 | if k == firstKey then
75 | NonEmpty ( ( firstKey, v ), rest )
76 |
77 | else
78 | NonEmpty ( ( firstKey, firstValue ), Dict.insert k v rest )
79 |
80 |
81 | update : comparable -> (Maybe v -> v) -> NonEmpty comparable v -> NonEmpty comparable v
82 | update k alter (NonEmpty ( ( firstKey, firstValue ), rest )) =
83 | if k == firstKey then
84 | NonEmpty ( ( firstKey, alter <| Just firstValue ), rest )
85 |
86 | else
87 | NonEmpty ( ( firstKey, firstValue ), Dict.update k (alter >> Just) rest )
88 |
89 |
90 | toNonEmptyList : NonEmpty k v -> List.NonEmpty.NonEmpty ( k, v )
91 | toNonEmptyList (NonEmpty ( firstPair, rest )) =
92 | ( firstPair, Dict.toList rest )
93 |
94 |
95 | toList : NonEmpty k v -> List ( k, v )
96 | toList =
97 | toNonEmptyList >> List.NonEmpty.toList
98 |
99 |
100 | map : (k -> u -> v) -> NonEmpty k u -> NonEmpty k v
101 | map f (NonEmpty ( ( k, v ), rest )) =
102 | NonEmpty ( ( k, f k v ), Dict.map f rest )
103 |
104 |
105 | foldl : (k -> v -> b -> b) -> b -> NonEmpty k v -> b
106 | foldl f acc (NonEmpty ( ( k, v ), rest )) =
107 | Dict.foldl f (f k v acc) rest
108 |
109 |
110 | foldl1 : (v -> v -> v) -> NonEmpty k v -> v
111 | foldl1 f (NonEmpty ( ( _, v ), rest )) =
112 | Dict.foldl (always f) v rest
113 |
--------------------------------------------------------------------------------
/demo/translations/interpolation.en.ftl:
--------------------------------------------------------------------------------
1 | headline = Interpolation
2 |
3 | preamble = This page is about the feature of interpolation: placing a placeholder in a string
4 | to later replace it with a runtime value.
5 |
6 | jsonSyntaxBody = JSON does not specify a placeholder syntax. We chose the familiar curly bracket syntax:
7 | If you want to insert a runtime value in "Hello, !" for the person you want to greet, you could write
8 | "Hello, { "{person}!" }" for example. If you want an actual opening curly bracket, use backslash to escape it.
9 |
10 | propertiesSyntaxBody = Properties does not specify a placeholder syntax either. Just like with JSON, we chose curly bracket syntax:
11 | If you want to insert a runtime value in "Hello, !" for the person you want to greet, you could write
12 | "Hello, { "{person}!" }" for example. If you want an actual opening curly bracket, use quotation marks to escape it.
13 |
14 | fluentSyntaxBody = Fluent uses curly bracket syntax with a twist for its interpolation syntax.
15 | Since you can use the syntax to signal other features as well, the variable you want to interpolate needs to be prefixed with a "$".
16 | If you want to insert a runtime value in "Hello, !" for the person you want to greet, you could write
17 | "Hello, { "{$person}!" }" for example. If you want an actual opening curly bracket, use string literals { "{ \"...\" }" } to escape it.
18 |
19 | generatedCodeHeadline = Generated code
20 | generatedCodeBody = When you inspect the generated code for the example input, you will notice different type signatures compared to
21 | the previous page. Instead of the usual I18n -> String signature, Travelm-Agency generated a function of type
22 | I18n -> String -> String for the greeting key and a function of type I18n -> { "{ day : String, todo : String }" } -> String
23 | for the plan key. Looking at their definitions, this makes a lot of sense. The greeting translation
24 | states that it will place one runtime value at the marked location and the plan translation does so for two.
25 | Any number of interpolations are supported. Feel free to try it out by adding new interpolations or removing them from the input file.
26 |
27 | duplicateKeysHeadline = Duplicate interpolation keys
28 | duplicateKeysBody = If you want to insert the same runtime value at multiple locations, just give them the same name.
29 | For example, "{ "{person}, {person}" }" will generate a I18n -> String -> String
30 | signature despite the fact that two placeholders have been specified and at runtime, calling the function with Evan will
31 | result in Evan, Evan
32 |
33 | inconsistentKeysHeadline = Inconsistent interpolation keys
34 | inconsistentKeysBody = Sometimes, one language needs more or less interpolated values than others, or in a different order.
35 | Travelm-Agency has no issues with that. You will always need to provide runtime values corresponding to the union of specified
36 | interpolation keys across all of your languages, the generated code will then take care of inserting the values in the correct places.
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/demo/translations/interpolation.de.ftl:
--------------------------------------------------------------------------------
1 | headline = Interpolation
2 |
3 | preamble = Diese Seite ist über das Feature Interpolation: Die Ersetzung eines Platzhalters innerhalb eines
4 | Textes durch einen Wert zur Laufzeit.
5 |
6 | jsonSyntaxBody = JSON gibt keine Syntax für Platzhalter vor. Wir haben die bekannte Curly Bracket Syntax gewählt:
7 | Wenn du beispielsweise einen Wert in "Hello, !" einfügen willst für die Person die du grüßen möchtest, kannst du
8 | "Hello, { "{person}!" }" schreiben. Für eine echte öffnende geschweifte Klammer kann man Backslash zum Escapen nutzen.
9 |
10 | propertiesSyntaxBody = Properties gibt auch keine Syntax für Platzhalter vor. Wie bei JSON haben wir Curly Bracket Syntax gewählt:
11 | Wenn du beispielsweise einen Wert in "Hello, !" einfügen willst für die Person die du grüßen möchtest, kannst du
12 | "Hello, { "{person}!" }" schreiben. Für eine echte öffnende geschweifte Klammer kann man Anführungszeichen zum Escapen nutzen.
13 |
14 | fluentSyntaxBody = Fluent nutzt als Interpolationssyntax Curly Bracket Syntax mit einem Twist.
15 | Da die Syntax auch für andere Features genutzt werden kann muss die Variable die interpoliert werden soll mit einem "$" begonnen werden.
16 | Wenn du beispielsweise einen Wert in "Hello, !" einfügen willst für die Person die du grüßen möchtest, könntest du
17 | "Hello, { "{$person}!" }" schreiben. Für eine echte öffnende geschweifte Klammer nutze Fluents String Literals { "{ \"...\" }" } zum Escapen.
18 |
19 | generatedCodeHeadline = Generierter Code
20 | generatedCodeBody = Wenn du den generierten Code für den Beispiel Input inspizierst, könnten dir die unterschiedlichen
21 | Typsignaturen verglichen mit der vorigen Seite auffallen. Statt der gewöhnlichen I18n -> String Signatur,
22 | hat Travelm-Agency eine Funktion mit dem Typ I18n -> String -> String für die Übersetzung für greeting
23 | und ein Funktion mit dem Typ I18n -> { "{ day : String, todo : String }" } -> String
24 | für die Übersetzung für plan generiert.
25 | Wenn man sich ihre Definitionen in der Inputdatei ansieht, macht das eine Menge Sinn.
26 | Die greeting Übersetzung signalisiert dass ein Wert an die markierte Stelle platziert wird und die
27 | plan platziert gleich zwei. Beliebige Anzahlen von interpolierten Werten sind unterstützt.
28 | Du kannst gerne ausprobieren wie sich der Compiler verhält wenn du Platzhalter im Eingabefenster hinzufügst oder löschst.
29 |
30 | duplicateKeysHeadline = Doppelte Platzhalter Schlüssel
31 | duplicateKeysBody = Wenn du den gleichen Wert an mehreren Stellen platzieren willst, gib ihnen einfach den gleichen Namen.
32 | Zum Beispiel: "{ "{person}, {person}" }" wird eine Funktion mit der Signatur I18n -> String -> String generieren
33 | obwohl zwei Platzhalter spezifiziert wurden. Wenn die Funktion zur Laufzeit mit Evan aufgerufen wird,
34 | kommt Evan, Evan raus.
35 |
36 | inconsistentKeysHeadline = Inkonsistente Platzhalter
37 | inconsistentKeysBody = Mnnchmal brauchen verschiedene Sprachen mehr oder weniger Platzhalter als andere, oder in einer anderen Reihenfolge.
38 | Travelm-Agency hat damit kein Problem. Für eine Übersetzung werden zur Laufzeit immer eine Anzahl Werte korrepondierend zur Vereinigung der
39 | spezifizierten Platzhalter über alle Sprachen hinweg benötigt. Der generierte Code kümmert sich dann darum die Werte an die korrekten
40 | Stellen zu platzieren.
41 |
--------------------------------------------------------------------------------
/tests/Types/UniqueNameTest.elm:
--------------------------------------------------------------------------------
1 | module Types.UniqueNameTest exposing (..)
2 |
3 | import Expect exposing (Expectation)
4 | import Fuzz
5 | import Set
6 | import Test exposing (Test, describe, fuzz, fuzz2, test)
7 | import Types.UniqueName as Unique
8 |
9 |
10 | suite : Test
11 | suite =
12 | describe "UniqueNameContext"
13 | [ fuzz Fuzz.string "gives a different name the second time when passing the same name twice" <|
14 | \name ->
15 | Unique.new ()
16 | |> Unique.andThen name (\_ _ -> identity)
17 | |> Unique.andThen name (\_ -> Tuple.pair)
18 | |> Unique.unwrap
19 | |> (\( name1, name2 ) -> name1 /= name2)
20 | |> Expect.equal True
21 | |> Expect.onFail "Expect generated names to be unique"
22 | , fuzz Fuzz.string "the new name always contains the given suggestion as a prefix" <|
23 | \name ->
24 | Unique.new ()
25 | |> Unique.andThen name (\_ _ -> identity)
26 | |> Unique.unwrap
27 | |> expectToStartWith name
28 | , -- empty list is excluded here
29 | fuzz2 Fuzz.string (Fuzz.list Fuzz.string) "the lookup function results in a similar name for all passed names" <|
30 | \firstName otherNames ->
31 | let
32 | names =
33 | firstName :: otherNames
34 | in
35 | Unique.new ()
36 | |> Unique.combineAndThen (always <| Set.fromList names) (\_ _ -> identity)
37 | |> Unique.unwrap
38 | |> Expect.all (List.map (\name lookup -> lookup name |> expectToStartWith name) names)
39 | , fuzz Fuzz.string "any invalid lookup results in an error string" <|
40 | \name ->
41 | Unique.new ()
42 | |> Unique.combineAndThen (always Set.empty) (\_ _ -> identity)
43 | |> Unique.unwrap
44 | |> ((|>) name >> String.contains "This should not happen")
45 | |> Expect.equal True
46 | |> Expect.onFail "Expected error string"
47 | , test "normal words do not need any modification" <|
48 | \_ ->
49 | Unique.new ()
50 | |> Unique.andThen "something" (\_ _ -> identity)
51 | |> Unique.unwrap
52 | |> Expect.equal "something"
53 | , test "Elm keywords are modified automatically" <|
54 | \_ ->
55 | Unique.new ()
56 | |> Unique.andThen "type" (\_ _ -> identity)
57 | |> Unique.unwrap
58 | |> Expect.notEqual "type"
59 | , fuzz Fuzz.string "Scoping works correctly" <|
60 | \name ->
61 | Unique.new ()
62 | |> Unique.scoped (Unique.andThen name <| \_ _ -> identity)
63 | |> Unique.scoped (Unique.andThen name <| \_ -> Tuple.pair)
64 | |> Unique.unwrap
65 | |> (\( n1, n2 ) -> n1 == n2)
66 | |> Expect.equal True
67 | |> Expect.onFail "The names should be equal because they are in seperate scopes"
68 | ]
69 |
70 |
71 | expectToStartWith : String -> String -> Expectation
72 | expectToStartWith prefix str =
73 | String.startsWith prefix str
74 | |> Expect.equal True
75 | |> Expect.onFail ("Expected '" ++ str ++ "' to start with '" ++ prefix ++ "'")
76 |
--------------------------------------------------------------------------------
/tests/Dict/NonEmptyTest.elm:
--------------------------------------------------------------------------------
1 | module Dict.NonEmptyTest exposing (..)
2 |
3 | import Dict exposing (Dict)
4 | import Dict.NonEmpty as DNE
5 | import Expect
6 | import Fuzz exposing (Fuzzer)
7 | import Test exposing (Test, describe, fuzz, fuzz2, fuzz3)
8 |
9 |
10 | fuzzDict : Fuzzer comparable -> Fuzzer v -> Fuzzer (Dict comparable v)
11 | fuzzDict fuzzK fuzzV =
12 | Fuzz.map Dict.fromList
13 | (Fuzz.list <| Fuzz.pair fuzzK fuzzV)
14 |
15 |
16 | fuzzNonEmptyDict : Fuzzer comparable -> Fuzzer v -> Fuzzer (DNE.NonEmpty comparable v)
17 | fuzzNonEmptyDict fuzzK fuzzV =
18 | Fuzz.map3 (\k v lst -> DNE.fromList ( ( k, v ), lst ))
19 | fuzzK
20 | fuzzV
21 | (Fuzz.list <| Fuzz.pair fuzzK fuzzV)
22 |
23 |
24 | suite : Test
25 | suite =
26 | describe "NonEmpty Dict"
27 | [ fuzz2 Fuzz.char Fuzz.int "singleton" <|
28 | \k v ->
29 | DNE.singleton k v
30 | |> DNE.toDict
31 | |> Expect.equalDicts (Dict.singleton k v)
32 | , fuzz (fuzzDict Fuzz.char Fuzz.int) "converting to and from normal dict works as expected" <|
33 | \dict ->
34 | DNE.fromDict dict
35 | |> Maybe.map DNE.toDict
36 | |> Expect.equal
37 | (if Dict.isEmpty dict then
38 | Nothing
39 |
40 | else
41 | Just dict
42 | )
43 | , fuzz3 Fuzz.char Fuzz.int (fuzzNonEmptyDict Fuzz.char Fuzz.int) "insertion works just like with a normal dict" <|
44 | \k v dict ->
45 | DNE.insert k v dict
46 | |> DNE.toDict
47 | |> Expect.equalDicts (DNE.toDict dict |> Dict.insert k v)
48 | , fuzz3 Fuzz.char Fuzz.int (fuzzNonEmptyDict Fuzz.char Fuzz.int) "updating works just like with a normal dict" <|
49 | \k v dict ->
50 | let
51 | update =
52 | Maybe.map ((+) v) >> Maybe.withDefault v
53 | in
54 | DNE.update k update dict
55 | |> DNE.toDict
56 | |> Expect.equalDicts (DNE.toDict dict |> Dict.update k (update >> Just))
57 | , fuzz (fuzzNonEmptyDict Fuzz.char Fuzz.int) "keys works just like with a normal dict" <|
58 | \dict ->
59 | DNE.keys dict
60 | |> List.sort
61 | |> Expect.equalLists (DNE.toDict dict |> Dict.keys |> List.sort)
62 | , fuzz (fuzzNonEmptyDict Fuzz.char Fuzz.int) "values works just like with a normal dict" <|
63 | \dict ->
64 | DNE.values dict
65 | |> List.sort
66 | |> Expect.equalLists (DNE.toDict dict |> Dict.values |> List.sort)
67 | , fuzz (fuzzNonEmptyDict Fuzz.char Fuzz.int) "map works just like with a normal dict" <|
68 | \dict ->
69 | let
70 | mapFun char int =
71 | Char.toCode char + int
72 | in
73 | DNE.map mapFun dict
74 | |> DNE.toDict
75 | |> Expect.equalDicts (DNE.toDict dict |> Dict.map mapFun)
76 | , fuzz (fuzzNonEmptyDict Fuzz.char Fuzz.int) "get first entry" <|
77 | \dict ->
78 | DNE.toList dict
79 | |> List.member (DNE.getFirstEntry dict)
80 | |> Expect.equal True
81 | |> Expect.onFail "Expected first entry to be included in all entries"
82 | ]
83 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | "use strict";
3 |
4 | import { run } from "./lib/main.js";
5 | import yargs from "yargs";
6 | import { hideBin } from "yargs/helpers";
7 |
8 | (async () => {
9 | const args = await yargs(hideBin(process.argv))
10 | .command(
11 | "$0 String.
3 | But sometimes there are formatting and highlighting requirements for your texts that are frustrating to
4 | solve with just Strings. Coloring or emphasizing part of a sentence come to mind. Links inside of a sentence.
5 | While you may split up pre and post html string parts, you will run into ordering issues for some languages.
6 | That's why Travelm-Agency understands HTML and generates a function returning List (Html msg) instead, if it
7 | encounters any HTML tags.
8 |
9 | basicsHeadline = Basics
10 | basicsBody = To make Travelm-Agency generate HTML, all you need to do is wrap an HTML tag around a part of your translation.
11 | Currently, self-closing tags are not allowed. You may include HTML attributes, which will be added to your HTML element at runtime
12 | via Html.Attributes.attribute. Nested HTML tags and custom elements also work as you would expect. HTML tags can be modified at runtime with more attributes, so you need to
13 | provide a list of attributes for each different html tag in your translation.
14 |
15 | idHeadline = Identification
16 | idBody = If you want to style a single html tag specifically, or multiple different html tags with the same attributes,
17 | you can use the special "_id" attribute on the respective tags.
18 |
19 | securityHeadline = Security
20 | securityBody = There is a good reason why Elm does not generate HTML from a String at runtime - cross-site scripting
21 | and similar techniques are a thing. In inline mode, the code is static and unchangeable, nullifying the security risks, in dynamic mode
22 | however, a parser is used that may create ANY HTML. Therefore, you need to be careful which contents you load into your I18n instance.
23 | If you just load your .json files from your own server, that should probably be fine. If you have a more complex setup, keep this risk in mind.
24 |
25 | escapingHeadline = Escaping
26 | escapingBody = Since Travelm-Agency understands HTML, the parser gets confused every time it finds a { "<" } symbol with a different meaning.
27 | Therefore, you have to escape the character with the usual techniques: Backslash { "\\<" } for JSON, Quotes { "'<'" } for Properties
28 | and String literals { "{ \"<\" }" } for Fluent.
29 |
30 |
31 | customizingHeadline = Customizing
32 | customizingBody = Travelm-Agency allows you to customize some of its output. In particular, setting --custom_html_module
33 | will allow you to change the imported Html module the generated code is declaring. This will also automatically set
34 | --custom_html_attributes_module to the Html module + .Attributes, since that is the de-facto standard:
35 | elm-html-with-context,
36 | elm-css and, of course, the standard
37 | elm/html are using this structure.
38 | But you can still specify --custom_html_attributes_module explicitely in case other libraries or your own custom
39 | elm/html wrapper is different in this regard.
40 | The interface required by a custom Html implementation is specified in these test files:
41 | CustomHtml
42 | CustomHtmlAttributes
--------------------------------------------------------------------------------
/docs/Changelog_4.0.adoc:
--------------------------------------------------------------------------------
1 | = 4.0 Release
2 |
3 | Assumption:
4 | There is no reason why you want to have multiple languages active at the same time.
5 | If you do want that, you could use multiple I18n instances.
6 |
7 | Planned changes:
8 |
9 | * Require a language on init for all I18n constructors
10 | * Load bundles without specifying the language or path
11 | * Set the current language for dynamic mode, returning (I18n, Cmd msg)
12 | * Get the current language for dynamic mode by storing it inside of I18n
13 |
14 | == Required Language on init
15 |
16 | There really is not much of a reason to use travelm-agency if you do not have some language ready.
17 | If you have some kind of initialization logic and don't want to bother with `Maybe I18n` in your model,
18 | you can just use a default language and have a seperate loading flag.
19 |
20 | == Load bundle without specifying language or path
21 |
22 | In inline mode, we can just set the language and all translations automatically update.
23 | In dynamic mode, we potentially have multiple bundles, some of which are loaded and some of which aren't.
24 |
25 | Currently, we generate this kind of code
26 |
27 | ----
28 | loadMessages : { language : Language, path : String, onLoad : Result Http.Error (I18n -> I18n) -> msg } -> Cmd msg
29 | loadMessages opts =
30 | Http.get
31 | { expect = Http.expectJson opts.onLoad decodeMessages
32 | , url = opts.path ++ "/" ++ languageToFileName_messages opts.language
33 | }
34 | ----
35 |
36 | which requires the user to pass language, base path and a callback.
37 | Ideally, the user could only pass the callback. Why?
38 | The base path should be consistent across bundles - we can just force the user to pass that on initialization.
39 | The language should also be consistent - we don't want to end up with parts of the application in English and parts in French.
40 |
41 | Thus, in the future we could generate this code instead:
42 |
43 | ----
44 | loadMessages : (Result Http.Error (I18n -> I18n) -> msg) -> I18n -> Cmd msg
45 | loadMessages onLoad (I18n { path, language } _) =
46 | Http.get
47 | { expect = Http.expectJson onLoad decodeMessages
48 | , url = path ++ "/" ++ languageToFileName_messages language
49 | }
50 | ----
51 |
52 | == Set current language in dynamic mode
53 |
54 | The previous idea has the benefit that we can automatically get all loaded bundles in a different language if requested.
55 |
56 | ----
57 | setLanguage : Language -> (Result Http.Error (I18n -> I18n) -> msg) -> I18n -> (I18n, Cmd msg)
58 | setLanguage lang onLoad (I18n opts bundles) =
59 | let
60 | i18nNewLang = I18n { opts | language = lang } bundles
61 | in
62 | [ (bundle1, loadBundle1)
63 | , (bundle2, loadBundle2)
64 | , (bundle3, loadBundle3)
65 | ] |> List.filter (\(bundle, _) -> not <| Array.isEmpty bundle)
66 | |> List.map Tuple.second
67 | |> List.map (\load -> load onLoad i18nNewLang)
68 | |> Cmd.batch
69 | |> Tuple.pair i18nNewLang
70 | ----
71 |
72 | == Get current language in dynamic mode
73 |
74 | Since we now store the language in the I18n type, we can implement the `currentLanguage` function just like in the inline case.
75 | The semantics are a bit tricky though - not all bundles might have loaded, so while the language is already switched,
76 | the translations might be in the "old" language.
77 |
78 | The idea here is to expose a seperate function `arrivedLanguage` (name is consistent to elm-animator semantics)
79 | which returns the old language until the new language translations have fully loaded.
80 |
81 | But how do we know that? The trick is storing a language for each bundle! The language has fully loaded if
82 | `currentLanguage` and all bundle languages are equal.
83 |
84 | We could also generate this function in inline mode to facilitate switching.
85 |
--------------------------------------------------------------------------------
/tests/Types/ErrorTest.elm:
--------------------------------------------------------------------------------
1 | module Types.ErrorTest exposing (..)
2 |
3 | import Expect
4 | import Parser
5 | import Test exposing (..)
6 | import Types.Error
7 |
8 |
9 | suite : Test
10 | suite =
11 | describe "Error"
12 | [ test "format recursive issue" <|
13 | \_ ->
14 | Types.Error.cyclicFallback [ "test", "other", "bla" ]
15 | |> Types.Error.formatFail
16 | |> Expect.equal (Err """Detected mutually recursive fallbacks. This is not allowed, because it can lead to indefinite recursion at runtime or unresolved keys.
17 | \tTrace: test --> other --> bla""")
18 | , test "format inconsistent keys" <|
19 | \_ ->
20 | Types.Error.inconsistentKeys { hasKeys = "en", missesKeys = "de", keys = [ "someKey", "anotherKey" ] }
21 | |> Types.Error.formatFail
22 | |> Expect.equal (Err """Found inconsistent keys in translation files.
23 | \tLanguage 'en' includes the keys [ someKey, anotherKey ] but language 'de' does not.
24 | \tEither delete the keys in 'en', add the keys in 'de', or opt into a fallback behaviour by adding '# fallback-language: en' into the 'de' translation file.""")
25 | , test "format recursive term reference" <|
26 | \_ ->
27 | Types.Error.cyclicTermReference [ "termA", "termB" ]
28 | |> Types.Error.formatFail
29 | |> Expect.equal (Err """Detected mutually recursive term references in translation file.
30 | \tTrace: termA --> termB""")
31 | , test "format 'cannot format string as number' with context" <|
32 | \_ ->
33 | Types.Error.failedToFormatStringAsNumber "NaN"
34 | |> Types.Error.addLanguageCtx "en-US"
35 | |> Types.Error.addAdditionalCtx "When doing stuff"
36 | |> Types.Error.formatFail
37 | |> Expect.equal (Err """Cannot format the given string 'NaN' as a number.
38 | \tContext:
39 | \t\t- info: When doing stuff
40 | \t\t- language: en-US""")
41 | , test "format 'cannot parse string as number' with context" <|
42 | \_ ->
43 | Types.Error.failedToParseStringAsNumber "BaNaNa"
44 | |> Types.Error.addContentTypeCtx ".ftl"
45 | |> Types.Error.addAdditionalCtx "Why would you"
46 | |> Types.Error.formatFail
47 | |> Expect.equal (Err """Cannot parse the given string 'BaNaNa' as a number.
48 | \tContext:
49 | \t\t- fileExtension: .ftl
50 | \t\t- info: Why would you""")
51 | , test "format multiple unresolvable term references with different contexts" <|
52 | \_ ->
53 | [ Types.Error.unresolvableTermReference "unknown" |> Types.Error.addLanguageCtx "en"
54 | , Types.Error.unresolvableTermReference "not-found" |> Types.Error.addLanguageCtx "de"
55 | ]
56 | |> Types.Error.combineList
57 | |> Types.Error.addContentTypeCtx ".ftl"
58 | |> Types.Error.formatFail
59 | |> Expect.equal (Err """Failed to resolve reference to unknown term 'unknown' in translation file.
60 | \tContext:
61 | \t\t- fileExtension: .ftl
62 | \t\t- language: en
63 |
64 | Failed to resolve reference to unknown term 'not-found' in translation file.
65 | \tContext:
66 | \t\t- fileExtension: .ftl
67 | \t\t- language: de""")
68 | , test "format runParser error" <|
69 | \_ ->
70 | Types.Error.runParser (Parser.problem "Expected x but was y.") "bla"
71 | |> Types.Error.formatFail
72 | |> Expect.equal (Err """Failed to parse translation file: Problem: Expected x but was y. at row:1 col:1""")
73 | ]
74 |
--------------------------------------------------------------------------------
/demo/translations/html.de.ftl:
--------------------------------------------------------------------------------
1 | headline = Html
2 | preamble = Bisher war der Rückgabetyp für alle generierten Übersetzungsfunktionen String.
3 | Aber manchmal gibt es Formattierungs- oder Hervorhebungsanforderungen an Texte die frustrierend mit Strings zu lösen sind.
4 | Ein gutes Beispiel ist einen Teil eines Satzes zu Färben oder Fett anzuzeigen. Obwohl du Vor- und Nach-HTML String Teile in
5 | verschiedene Übersetzungen aufteilen kannst, wirst du Probleme mit der Reihenfolge in manchen Sprachen haben.
6 | Deshalb versteht Travelm-Agency HTML Syntax und generiert Funktionen mit dem Rückgabetyp List (Html msg) wenn
7 | es HTML Tags in einer Übersetzung findet.
8 |
9 | basicsHeadline = Basics
10 | basicsBody = Damit HTML aus einer Übersetzung generiert wird, musst du nur einen HTML Tag um einen Teil deiner
11 | Übersetzung packen. Selbstschließende Tags sind aktuell nicht erlaubt.
12 | Du kannst HTML Attribute verwenden, die dann zur Laufzeit zu deinem Element per Html.Attributes.attribute hinzugefügt werden.
13 | Verschachtelte HTML Tags sollten auch wie erwartet funktionieren.
14 | Damit die HTML Tags zur Laufzeit mit zusätzlichen Attributen modifiziert und konfiguriert werden können musst du eine Liste
15 | von Attributen für jeden unterschiedlichen HTML Tag in einer Übersetzung übergeben.
16 |
17 | idHeadline = Identifizierung
18 | idBody = Wenn du einen bestimmten HTML Tag stylen möchtest oder mehrere unterschiedliche HTML Tags mit den selben Attributen,
19 | kannst du das spezielle "_id" Attribut in den jeweiligen Tags verwenden.
20 |
21 | securityHeadline = Sicherheit
22 | securityBody = Es gibt gute Gründe wieso Elm nicht HTML aus Strings zur Laufzeit generiert - z.B. Cross-Site Scripting.
23 | Im Inline Mode ist der Code statisch und nicht veränderbar, womit kein Risiko entsteht.
24 | Im Dynamic Mode wird hingegen ein Parser genutzt um letztendlich beliebiges HTML zur Laufzeit zu erstellen.
25 | Daher solltest du vorsichtig sein welche Inhalte du in deine I18n Instanz lädst.
26 | Wenn du deine Übersetzungen von deinem eigenen Server lädst sollte das vermutlich kein Problem sein.
27 | Bei komplexeren Setups sollte dir das Risiko aber bewusst sein.
28 |
29 | escapingHeadline = Escaping
30 | escapingBody = Da Travelm-Agency HTML versteht, ist der Parser verwirrt wenn er ein { "<" } symbol findet was nicht für HTML gedacht war.
31 | Daher muss dieser Char mit den üblichen Techniken escaped werden: Backslash { "\\<" } für JSON, Quotes { "'<'" } für Properties
32 | und String Literale { "{ \"<\" }" } für Fluent.
33 |
34 | customizingHeadline = Anpassungen
35 | customizingBody = Travelm-Agency ermöglicht es, einige seiner Ausgaben anzupassen. Insbesondere durch das Setzen von --custom_html_module
36 | kann das importierte Html-Modul geändert werden, das der generierte Code deklariert. Dies setzt automatisch auch
37 | --custom_html_attributes_module auf das Html-Modul + .Attributes, da dies der De-facto-Standard ist:
38 | elm-html-with-context,
39 | elm-css und natürlich das Standard
40 | elm/html verwenden diese Struktur.
41 | Du kannst --custom_html_attributes_module aber auch explizit angeben, falls andere Bibliotheken oder dein eigener
42 | elm/html-Wrapper sich in dieser Hinsicht unterscheiden.
43 | Die Schnittstelle, die von einer benutzerdefinierten Html-Implementierung benötigt wird, ist in diesen Test-Dateien spezifiziert:
44 | CustomHtml
45 | CustomHtmlAttributes
--------------------------------------------------------------------------------
/demo/main.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --nav-height: 3.5rem;
3 | --file-header-height: 2.5rem;
4 | --explanation-padding-x: 1.2rem;
5 | --explanation-padding-y: 1rem;
6 | }
7 |
8 | body,
9 | html {
10 | background-color: #757f82;
11 | margin: 0;
12 | padding: 0;
13 | }
14 |
15 | * {
16 | box-sizing: content-box;
17 | margin: 0;
18 | padding: 0;
19 | }
20 |
21 | p,
22 | h1,
23 | h2,
24 | h3,
25 | a {
26 | line-height: 1.5;
27 | }
28 |
29 | h2 {
30 | margin-top: 1rem;
31 | }
32 |
33 | a {
34 | color: lightblue;
35 | }
36 |
37 | select {
38 | padding: 0.5rem;
39 | }
40 |
41 | .content {
42 | display: flex;
43 | height: 100vh;
44 | width: 100vw;
45 | max-width: 100%;
46 | }
47 |
48 | .left-sidebar {
49 | height: 100%;
50 | width: 33%;
51 | }
52 |
53 | .nav {
54 | align-items: center;
55 | background-color: #536f76;
56 | display: flex;
57 | font-size: 1.5rem;
58 | height: var(--nav-height);
59 | justify-content: space-between;
60 | }
61 |
62 | .arrow {
63 | display: flex;
64 | }
65 |
66 | .explanation {
67 | background-color: #354042;
68 | color: rgb(211, 211, 211);
69 | display: flex;
70 | flex-direction: column;
71 | height: calc(100% - var(--nav-height) - 2 * var(--explanation-padding-y));
72 | row-gap: 10px;
73 | padding: var(--explanation-padding-y) var(--explanation-padding-x);
74 | overflow: auto;
75 | }
76 |
77 | .playground {
78 | display: flex;
79 | flex-direction: column;
80 | width: 67%;
81 | }
82 |
83 | .language-and-framework-wrapper {
84 | display: flex;
85 | justify-content: space-between;
86 | }
87 |
88 | .language-select {
89 | display: flex;
90 | }
91 |
92 | .framework-select {
93 | border-bottom-left-radius: 0.5rem;
94 | }
95 |
96 | .language-flag {
97 | padding: 10px;
98 | }
99 |
100 | .language-flag.arrived {
101 | box-shadow: inset 0px 0px 0px 2px rgb(25, 244, 80);
102 | }
103 |
104 | .language-flag.current {
105 | box-shadow: inset 0px 0px 0px 2px rgb(217, 233, 48);
106 | }
107 |
108 | .file-header-container {
109 | align-items: flex-end;
110 | display: flex;
111 | height: var(--nav-height);
112 | }
113 |
114 | .file-header-container > select {
115 | margin-left: auto;
116 | border-top-left-radius: 0.5rem;
117 | border-top-right-radius: 0.5rem;
118 | }
119 |
120 | .flex {
121 | display: flex;
122 | }
123 |
124 | .file-header {
125 | background-color: lightgray;
126 | border-top: 2px solid rgb(180, 180, 180);
127 | border-right: 2px solid rgb(180, 180, 180);
128 | border-top-left-radius: 0.5rem;
129 | border-top-right-radius: 0.5rem;
130 | padding: 10px 20px;
131 | }
132 |
133 | .active {
134 | background-color: #2d2d2d;
135 | border-bottom: 0;
136 | color: white;
137 | }
138 |
139 | .editor {
140 | height: calc(50% - var(--nav-height));
141 | overflow: auto;
142 | }
143 |
144 | .error-message {
145 | background-color: rgb(152, 67, 67);
146 | color: white;
147 | padding: 1rem;
148 | }
149 |
150 | highlighted-code > pre[class*="language-"] {
151 | margin: 0;
152 | }
153 |
154 | .highlighted {
155 | background-color: #cac8c8;
156 | color: #2d2d2d;
157 | padding: 2px 6px;
158 | margin: 0px 4px;
159 | border-radius: 10px;
160 | white-space: nowrap;
161 | }
162 |
163 | .accordion-headline {
164 | align-items: center;
165 | background-color: rgb(94, 94, 94);
166 | border-radius: 10px;
167 | cursor: pointer;
168 | display: flex;
169 | justify-content: space-between;
170 | padding: 0 10px;
171 | }
172 |
173 | .accordion-content {
174 | padding-left: 4px;
175 | overflow: hidden;
176 | transition: all 0.5s ease;
177 | }
178 |
179 | @media only screen and (max-width: 600px) {
180 | .content {
181 | width: 200vw;
182 | max-width: 200%;
183 | position: relative;
184 | transition: right 1s ease;
185 | }
186 |
187 | .left-sidebar {
188 | width: 50%;
189 | }
190 |
191 | .playground {
192 | width: 50%;
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/src/Types/Translation.elm:
--------------------------------------------------------------------------------
1 | module Types.Translation exposing (Translation, append, checkTranslationsForConsistency, completeFallback, concat, empty, fromPairs, inferFeatures, map, prefix)
2 |
3 | import Dict exposing (Dict)
4 | import Maybe.Extra
5 | import Set
6 | import Types.Basic exposing (Language)
7 | import Types.Error as Error exposing (Failable)
8 | import Types.Features as Features exposing (Features)
9 | import Types.Segment as Segment exposing (TKey, TValue)
10 | import Util
11 |
12 |
13 | type alias Translation resources =
14 | { pairs : Translations
15 | , resources : resources
16 | , fallback : Maybe Language
17 | }
18 |
19 |
20 | type alias Translations =
21 | Dict TKey TValue
22 |
23 |
24 | fromPairs : List ( TKey, TValue ) -> Translation ()
25 | fromPairs pairs =
26 | { pairs = Dict.fromList pairs, resources = (), fallback = Nothing }
27 |
28 |
29 | empty : Translation ()
30 | empty =
31 | fromPairs []
32 |
33 |
34 | map : (a -> b) -> Translation a -> Translation b
35 | map f { pairs, resources, fallback } =
36 | { pairs = pairs, resources = f resources, fallback = fallback }
37 |
38 |
39 | append : Translation any -> Translation any -> Translation any
40 | append first second =
41 | { pairs = Dict.union first.pairs second.pairs
42 | , fallback = Maybe.Extra.or first.fallback second.fallback
43 | , resources = first.resources
44 | }
45 |
46 |
47 | prefix : String -> Translation any -> Translation any
48 | prefix pre translations =
49 | { translations
50 | | pairs =
51 | Dict.toList translations.pairs
52 | |> List.map (\( k, v ) -> ( Util.keyToName [ pre, k ], v ))
53 | |> Dict.fromList
54 | }
55 |
56 |
57 | concat : List (Translation ()) -> Translation ()
58 | concat =
59 | List.foldl append { pairs = Dict.empty, fallback = Nothing, resources = () }
60 |
61 |
62 | inferFeatures : Translation any -> Features
63 | inferFeatures =
64 | .pairs >> Dict.values >> Features.combineMap Segment.inferFeatures
65 |
66 |
67 | completeFallback : (Language -> Maybe (Translation resources)) -> Language -> Translation resources -> Failable (Translation resources)
68 | completeFallback getTranslationForLang language =
69 | let
70 | go seenLanguages translation =
71 | case translation.fallback of
72 | Just lang ->
73 | case ( getTranslationForLang lang, List.member lang seenLanguages ) of
74 | ( Just fallbackTranslation, False ) ->
75 | let
76 | recursiveResult =
77 | go (lang :: seenLanguages) fallbackTranslation
78 | in
79 | recursiveResult
80 | |> Result.map
81 | (\{ pairs } ->
82 | { translation | pairs = Dict.union translation.pairs pairs }
83 | )
84 |
85 | ( _, True ) ->
86 | Error.cyclicFallback (List.reverse <| lang :: seenLanguages)
87 |
88 | ( Nothing, False ) ->
89 | Ok translation
90 |
91 | Nothing ->
92 | Ok translation
93 | in
94 | go [ language ]
95 |
96 |
97 | checkTranslationsForConsistency : ( Language, Translation any ) -> ( Language, Translation any ) -> Failable ()
98 | checkTranslationsForConsistency ( lang1, t1 ) ( lang2, t2 ) =
99 | let
100 | keys1 =
101 | Dict.keys t1.pairs |> Set.fromList
102 |
103 | keys2 =
104 | Dict.keys t2.pairs |> Set.fromList
105 |
106 | missingKeysInLang2 =
107 | Set.diff keys1 keys2
108 |
109 | extraKeysInLang2 =
110 | Set.diff keys2 keys1
111 | in
112 | if Set.isEmpty missingKeysInLang2 then
113 | if Set.isEmpty extraKeysInLang2 then
114 | Ok ()
115 |
116 | else
117 | Error.inconsistentKeys { keys = Set.toList extraKeysInLang2, missesKeys = lang1, hasKeys = lang2 }
118 |
119 | else
120 | Error.inconsistentKeys { keys = Set.toList missingKeysInLang2, missesKeys = lang2, hasKeys = lang1 }
121 |
--------------------------------------------------------------------------------
/demo/src/code-component.js:
--------------------------------------------------------------------------------
1 | import Prism from "prismjs";
2 | import "prismjs/components/prism-elm";
3 | import "prismjs/components/prism-json";
4 | import "prismjs/components/prism-properties";
5 | import "prismjs/themes/prism-tomorrow.css";
6 |
7 | function getCursorPosition(parent, node, offset, stat) {
8 | if (stat.done) return stat;
9 |
10 | let currentNode = null;
11 | if (parent.childNodes.length == 0) {
12 | stat.pos += parent.textContent.length;
13 | } else {
14 | for (let i = 0; i < parent.childNodes.length && !stat.done; i++) {
15 | currentNode = parent.childNodes[i];
16 | if (currentNode === node) {
17 | stat.pos += offset;
18 | stat.done = true;
19 | return stat;
20 | } else getCursorPosition(currentNode, node, offset, stat);
21 | }
22 | }
23 | return stat;
24 | }
25 |
26 | //find the child node and relative position and set it on range
27 | function setCursorPosition(parent, range, stat) {
28 | if (stat.done) return range;
29 |
30 | if (parent.childNodes.length == 0) {
31 | if (parent.textContent.length >= stat.pos) {
32 | range.setStart(parent, stat.pos);
33 | stat.done = true;
34 | } else {
35 | stat.pos = stat.pos - parent.textContent.length;
36 | }
37 | } else {
38 | for (let i = 0; i < parent.childNodes.length && !stat.done; i++) {
39 | const currentNode = parent.childNodes[i];
40 | setCursorPosition(currentNode, range, stat);
41 | }
42 | }
43 | return range;
44 | }
45 |
46 | function escapeHtml(str) {
47 | return str.replace(//g, ">");
48 | }
49 |
50 | export class CodeComponent extends HTMLElement {
51 | codeEl;
52 | restore;
53 |
54 | static get observedAttributes() {
55 | return ["code", "lang", "pos", "editable"];
56 | }
57 |
58 | constructor() {
59 | super();
60 | this.onEdit = this.onEdit.bind(this);
61 | }
62 |
63 | onEdit() {
64 | const sel = window.getSelection();
65 | const node = sel.focusNode;
66 | const offset = sel.focusOffset;
67 | const { pos } = getCursorPosition(this.codeEl, node, offset, {
68 | pos: 0,
69 | done: false,
70 | });
71 |
72 | this.dispatchEvent(
73 | new CustomEvent("edit", {
74 | detail: { content: this.codeEl.textContent, caretPos: pos },
75 | })
76 | );
77 | }
78 |
79 | connectedCallback() {
80 | const pre = document.createElement("pre");
81 | this.codeEl = document.createElement("code");
82 |
83 | this.codeEl.addEventListener("input", this.onEdit);
84 |
85 | const lang = this.getAttribute("lang");
86 | const code = this.getAttribute("code");
87 | const pos = this.getAttribute("pos");
88 | const editable = this.getAttribute("editable");
89 | lang && this.setLang(lang);
90 | code && this.setCode(code);
91 | pos && this.setCaretPosition(pos);
92 | editable && this.codeEl.setAttribute("contentEditable", !!editable);
93 |
94 | pre.appendChild(this.codeEl);
95 | this.appendChild(pre);
96 |
97 | Prism.highlightElement(this.codeEl);
98 | }
99 |
100 | disconnectedCallback() {
101 | this.codeEl.removeEventListener("input", this.onEdit);
102 | }
103 |
104 | attributeChangedCallback(attrName, _oldVal, newVal) {
105 | if (attrName === "lang") {
106 | this.setLang(newVal);
107 | }
108 | if (attrName === "code") {
109 | this.setCode(newVal);
110 | }
111 | if (attrName === "editable") {
112 | this.codeEl && this.codeEl.setAttribute("contentEditable", !!newVal);
113 | }
114 | if (this.codeEl) {
115 | Prism.highlightElement(this.codeEl);
116 | }
117 |
118 | if (attrName === "pos") {
119 | this.setCaretPosition(newVal);
120 | }
121 | }
122 |
123 | setLang(lang) {
124 | if (this.codeEl) {
125 | this.codeEl.setAttribute("class", `language-${lang}`);
126 | }
127 | }
128 |
129 | setCode(code) {
130 | if (this.codeEl) {
131 | this.codeEl.innerHTML = escapeHtml(code);
132 | }
133 | }
134 |
135 | setCaretPosition(pos) {
136 | if (this.codeEl) {
137 | const sel = window.getSelection();
138 | sel.removeAllRanges();
139 | const range = setCursorPosition(this.codeEl, document.createRange(), {
140 | pos,
141 | done: false,
142 | });
143 | range.collapse(true);
144 | sel.addRange(range);
145 | }
146 | }
147 | }
148 |
149 | export const registerCodeComponent = () => {
150 | window.customElements.define("highlighted-code", CodeComponent);
151 | };
152 |
--------------------------------------------------------------------------------
/demo/translations/intro.en.ftl:
--------------------------------------------------------------------------------
1 | headline = Introduction
2 | preamble = Welcome to the Travelm-Agency tutorial. Here, you get to see all the features of Travelm-Agency, presented in examples
3 | in a playground setting.
4 | explanationHeadline = What is Travelm-Agency?
5 | explanationBody = Travelm-Agency is a compiler that simplifies working with internationalized texts in the Elm programming language.
6 | Instead of writing Elm files containing all of your texts for all languages, and writing functions to access the
7 | correct texts depending on the current language, you can write your texts in one of several data formats.
8 | Travelm-Agency then reads in these files and generates the Elm code you may have otherwise written yourself.
9 | advantagesHeadline = Advantages over handwritten code
10 | advantageReadabilityHeadline = Readability of translations
11 | advantageReadabilityBody = Texts are much more readable in a format like .properties or .json.
12 | Don't get me wrong, texts are also perfectly fine in an Elm file, but when you add in things like interpolation
13 | and more advanced concepts, readability tends to suffer, while Travelm-Agency can tackle these features at a compiler level.
14 | advantageTypeSafetyHeadline = Type safety
15 | advantageTypeSafetyBody = The biggest advantage when using Elm over other web solutions is the compile time safety.
16 | For internationalisation, that safety often goes out the window for ease of development. Functions for each translation key
17 | are annoying to write boilerplate code, you either go towards a Dict String String and/or use some special placeholder syntax
18 | which does not guarantee all placeholders are filled at runtime.
19 | Travelm-Agency does the annoying boilerplate part for you and makes sure that
20 | inline to dynamic mode with just a command line argument and a few small code changes
25 | and have your translations loaded on the fly instead. Since the switch is so simple, you can benchmark bundle sizes and load times
26 | and decide yourself which model suits your application best and possibly switch later.
27 | disadvantagesHeadline = Disadvantages
28 | disadvantageProgrammabilityHeadline = Programmability
29 | disadvantageProgrammabilityBody = When writing translations in Elm, you have the full power of Elm at your disposal.
30 | You can define your own data types, pattern match, define helper functions, all that good stuff.
31 | When using a compiler, you cannot interact on a low level with the code, you are bound to the exposed interface.
32 | I personally think that is completely fine since Travelm-Agency offers enough "programmable" pieces
33 | like interpolation, string matching on interpolated values and automatic html generation with overwritable attributes.
34 | disadvantageToolchainHeadline = Build complexity
35 | disadvantageToolchainBody = Frontend development is full of build tools and bundlers, with code generators left and right
36 | (Elm itself compiles to JS after all). It can be intimidating to add even more tools like this one to the list.
37 | The compiler is generally pretty fast and easy to add to your project, feel free to open an issue if you experience any problems.
38 |
39 | tutorialHowtoHeadline = How this tutorial works
40 | tutorialHowtoBody = This tutorial guides you through the features of Travelm-Agency. On each page will be one or multiple input
41 | windows and one or multiple output windows showing you a particular feature with an accompanying explanation. You may edit
42 | the text in the input windows and observe how the output files change.
43 |
44 | tutorialMobileAdditional = You seem to be on a mobile device. While you cannot view text and examples at once,
45 | you can scroll over to the right to view example input and output files.
46 |
47 | textsFeatureHeadline = Simple texts
48 | textsFeatureBody = It may be weird to call this a feature, but this is the most common translation type that you will need.
49 | Observe how texts get copied into pattern matching functions for inline mode and into a JSON array for dynamic mode.
50 |
--------------------------------------------------------------------------------
/demo/translations/intro.de.ftl:
--------------------------------------------------------------------------------
1 | headline = Einführung
2 | preamble = Willkommen zum Travelm-Agency Tutorial. Die folgenden Seiten sind Dokumentation und Einführung in Travelm-Agency in einem interaktiven Playground.
3 | explanationHeadline = Was ist Travelm-Agency?
4 | explanationBody = Travelm-Agency ist ein Compiler der die Arbeit mit internationalisierten Texten in der Programmiersprache Elm
5 | erleichtert. Anstatt Elm Dateien zu schreiben die alle Texte einer Sprache beinhalten mit Funktionen die abhängig von der aktuellen Sprache
6 | den korrekten Text zurückgeben, kannst du auch Texte in einem von mehreren unterstützten Datenformaten schreiben.
7 | Travelm-Agency liest dann diese Dateien ein und generiert den passenden Elm Code für dich.
8 | advantagesHeadline = Vorteile gegen über handgeschriebenen Code
9 | advantageReadabilityHeadline = Lesbarkeit von Übersetzungen
10 | advantageReadabilityBody = Texte sind in einem dafür ausgelegten Format wie .properties or .json
11 | viel lesbarer. Natürlich sind Texte auch in einer Elm Datei völlig in Ordnung, aber wenn Konzepte wie Interpolation ins Spiel kommen
12 | schadet das oft der Lesbarkeit, während Travelm-Agency diese Probleme auf Compiler Ebene beheben kann.
13 | advantageTypeSafetyHeadline = Typsicherheit
14 | advantageTypeSafetyBody = Der größte Vorteil von Elm gegenüber anderen Web Sprachen ist die große Sicherheit zur Compilezeit.
15 | Im Falle von Internationalisierung geht diese Sicherheit oft verloren für die Einfachheit der Entwicklung.
16 | Für jede Übersetzung eine eigene Funktion schreiben ist nerviger Boilerplate Code, entweder wechselt man zu einem Dict String String
17 | und/oder verwendet eine Platzhalter Syntax die nicht garantiert dass alle notwendigen Parameter auch zur Laufzeit gesetzt sind.
18 | Travelm-Agency übernimmt den nervigen Boilerplate Teil und stellt sicher dass
19 | inline und dynamic mode mit nur einem Kommandozeilen Argument
24 | und nur wenigen Code Anpassungen wechseln, und damit deine Übersetzungen zur Laufzeit nachladen lassen.
25 | Da die Umstellung so einfach ist, kannst du Bundle Größen benchmarken und Ladezeiten vergleichen um zu entscheiden,
26 | welche Lösung für deine Anwendung am besten passt - und möglicherweise später wechseln.
27 | disadvantagesHeadline = Nachteile
28 | disadvantageProgrammabilityHeadline = Programmierbarkeit
29 | disadvantageProgrammabilityBody = Wenn du deine Übersetzungen in Elm schreibst, hast du eine vollständige Programmiersprache
30 | zur Verfügung. Eigene Datentypen, Pattern matching, Hilfsfunktionen, und so weiter. Mit einem Compiler kann man nicht mehr
31 | so einfach mit dem Code interagieren und ist stattdessen an das generierte Interface gebunden.
32 | Meiner Meinung nach ist das vollkommen ok, da Travelm-Agency genug "programmierbare" Teile bietet,
33 | wie Interpolation, Matchen auf interpolierten Werten und automatische Html Generierung mit überschreibbaren Attributen.
34 | disadvantageToolchainHeadline = Build Komplexität
35 | disadvantageToolchainBody = Frontend Entwicklung ist voll von Build Tools, Bundlers, Code Generatoren überall.
36 | Elm selbst kompiliert schließlich zu JS. Es kann einschüchternd sein noch mehr Tools wie dieses hier zu seiner Liste hinzuzufügen.
37 | Der Compiler ist verhältnismäßig schnell und einfach zum Projekt hinzuzufügen, falls Probleme auftreten gerne ein Issue erstellen.
38 | tutorialHowtoHeadline = Wie das Tutorial funktioniert
39 | tutorialHowtoBody = Dieses Tutorial führt dich durch die Features von Travelm-Agency. Auf jeder Seite werden ein oder mehrere
40 | Eingabefenster und ein oder mehrere Ausgabefenster sichtbar sein, die dir ein bestimmtes Feature zeigen, mit einem zugehörigen Erklärungstext.
41 | Du kannst den Text in den Eingabefenstern verändern und beobachten wie sich die Ausgabe verändert.
42 | tutorialMobileAdditional = Du scheinst auf einem Mobilgerät zu sein. Obwohl du nicht gleichzeitig Text und Beispiele sehen kannst,
43 | solltest du nach rechts scrollen können und die Eingabe- und Ausgabefenster zu sehen.
44 | textsFeatureHeadline = Einfache Texte
45 | textsFeatureBody = Es klingt vielleicht merkwürdig dies ein Feature zu nennen aber dies ist der meistgenutzte Übersetzungstyp.
46 | Beobachte wie Texte in Pattern Matching Funktionen für Inline Mode kopiert werden und in ein JSON Array für Dynamic Mode.
47 |
--------------------------------------------------------------------------------
/src/Ports.elm:
--------------------------------------------------------------------------------
1 | port module Ports exposing (FinishRequest, GeneratorMode(..), Request(..), ResponseContent, TranslationRequest, generatorModeFromString, generatorModeToString, requestDecoder, respond, subToRequests)
2 |
3 | import Json.Decode as D
4 | import Json.Decode.Pipeline as D
5 | import State exposing (OptimizedJson)
6 | import Types.Error as Error exposing (Failable)
7 |
8 |
9 | port sendResponse : Response -> Cmd msg
10 |
11 |
12 | type alias Response =
13 | { error : Maybe String, content : Maybe ResponseContent }
14 |
15 |
16 | type alias ResponseContent =
17 | { elmFile : String, optimizedJson : List OptimizedJson }
18 |
19 |
20 | respond : Failable ResponseContent -> Cmd msg
21 | respond res =
22 | sendResponse <|
23 | case Error.formatFail res of
24 | Err err ->
25 | { error = Just err, content = Nothing }
26 |
27 | Ok ok ->
28 | { content = Just ok, error = Nothing }
29 |
30 |
31 | port receiveRequest : (D.Value -> msg) -> Sub msg
32 |
33 |
34 | type Request
35 | = FinishModule FinishRequest
36 | | AddTranslation TranslationRequest
37 |
38 |
39 | type alias TranslationRequest =
40 | { content : String
41 | , extension : String
42 | , identifier : String
43 | , language : String
44 | }
45 |
46 |
47 | type GeneratorMode
48 | = Inline
49 | | Dynamic
50 |
51 |
52 | generatorModeToString : GeneratorMode -> String
53 | generatorModeToString mode =
54 | case mode of
55 | Inline ->
56 | "inline"
57 |
58 | Dynamic ->
59 | "dynamic"
60 |
61 |
62 | generatorModeFromString : String -> Maybe GeneratorMode
63 | generatorModeFromString str =
64 | case String.toLower str of
65 | "inline" ->
66 | Just Inline
67 |
68 | "dynamic" ->
69 | Just Dynamic
70 |
71 | _ ->
72 | Nothing
73 |
74 |
75 | generatorModeDecoder : D.Decoder GeneratorMode
76 | generatorModeDecoder =
77 | D.string
78 | |> D.andThen
79 | (\str ->
80 | case generatorModeFromString str of
81 | Just mode ->
82 | D.succeed mode
83 |
84 | Nothing ->
85 | D.fail <| "Unsupported generator mode: " ++ str
86 | )
87 |
88 |
89 | type alias FinishRequest =
90 | { elmModuleName : String
91 | , generatorMode : GeneratorMode
92 | , addContentHash : Bool
93 | , i18nArgFirst : Bool
94 | , prefixFileIdentifier : Bool
95 | , customHtmlModule : String
96 | , customHtmlAttributesModule : String
97 | }
98 |
99 |
100 | subToRequests : (Result D.Error Request -> msg) -> Sub msg
101 | subToRequests callback =
102 | receiveRequest (D.decodeValue requestDecoder >> callback)
103 |
104 |
105 | requestDecoder : D.Decoder Request
106 | requestDecoder =
107 | D.field "type" D.string
108 | |> D.andThen
109 | (\type_ ->
110 | case type_ of
111 | "translation" ->
112 | translationRequestDecoder |> D.map AddTranslation
113 |
114 | "finish" ->
115 | finishRequestDecoder
116 | |> D.map FinishModule
117 |
118 | _ ->
119 | D.fail <| "Unknown type of request: '" ++ type_ ++ "'"
120 | )
121 |
122 |
123 | translationRequestDecoder : D.Decoder TranslationRequest
124 | translationRequestDecoder =
125 | internalRequestDecoder
126 | |> D.andThen
127 | (\{ fileContent, fileName } ->
128 | case String.split "." fileName of
129 | [ identifier, language, extension ] ->
130 | D.succeed TranslationRequest
131 | |> D.hardcoded fileContent
132 | |> D.hardcoded extension
133 | |> D.hardcoded identifier
134 | |> D.hardcoded language
135 |
136 | [ single ] ->
137 | D.fail <| "Cannot determine extension from file name '" ++ single ++ "'."
138 |
139 | _ ->
140 | D.fail "Please remove dots from identifier to follow the [identifier].[language].[extension] convention."
141 | )
142 |
143 |
144 | finishRequestDecoder : D.Decoder FinishRequest
145 | finishRequestDecoder =
146 | D.succeed FinishRequest
147 | |> D.required "elmModuleName" D.string
148 | |> D.optional "generatorMode" generatorModeDecoder Dynamic
149 | |> D.optional "addContentHash" D.bool False
150 | |> D.optional "i18nArgFirst" D.bool False
151 | |> D.optional "prefixFileIdentifier" D.bool False
152 | |> D.required "customHtmlModule" D.string
153 | |> D.required "customHtmlAttributesModule" D.string
154 |
155 |
156 | type alias InternalRequest =
157 | { fileContent : String
158 | , fileName : String
159 | }
160 |
161 |
162 | internalRequestDecoder : D.Decoder InternalRequest
163 | internalRequestDecoder =
164 | D.succeed InternalRequest
165 | |> D.required "fileContent" D.string
166 | |> D.required "fileName" D.string
167 |
--------------------------------------------------------------------------------
/src/Main.elm:
--------------------------------------------------------------------------------
1 | module Main exposing (main, tryAddTranslation, tryFinishModule)
2 |
3 | import ContentTypes.Fluent
4 | import ContentTypes.Json
5 | import ContentTypes.Properties
6 | import Dict
7 | import Dict.NonEmpty
8 | import Elm.Pretty as Pretty
9 | import Generators.Dynamic
10 | import Generators.Inline
11 | import Generators.Names exposing (defaultNames)
12 | import Intl exposing (Intl)
13 | import Json.Decode as D
14 | import Platform
15 | import Ports exposing (GeneratorMode(..))
16 | import State exposing (State)
17 | import Types.Error as Error exposing (Failable)
18 | import Types.Translation exposing (Translation)
19 | import Util
20 |
21 |
22 | type alias Flags =
23 | { version : String, intl : Intl, devMode : Bool }
24 |
25 |
26 | type alias Model =
27 | { version : String
28 | , state : State ()
29 | , intl : Intl
30 | , devMode : Bool
31 | }
32 |
33 |
34 | init : String -> Intl -> Bool -> Model
35 | init version intl devMode =
36 | { version = version
37 | , state = Dict.empty
38 | , intl = intl
39 | , devMode = devMode
40 | }
41 |
42 |
43 | type Msg
44 | = GotRequest Ports.Request
45 | | UnexpectedRequest D.Error
46 |
47 |
48 | update : Msg -> Model -> ( Model, Cmd Msg )
49 | update msg model =
50 | case msg of
51 | GotRequest (Ports.AddTranslation req) ->
52 | onAddTranslation model req
53 |
54 | GotRequest (Ports.FinishModule req) ->
55 | ( model
56 | , onFinishModule model req
57 | )
58 |
59 | UnexpectedRequest err ->
60 | ( model, Ports.respond <| Error.requestDecodeError err )
61 |
62 |
63 | onAddTranslation : Model -> Ports.TranslationRequest -> ( Model, Cmd msg )
64 | onAddTranslation model req =
65 | case tryAddTranslation req model of
66 | Ok newModel ->
67 | ( newModel
68 | , Cmd.none
69 | )
70 |
71 | Err err ->
72 | ( model, Ports.respond <| Err err )
73 |
74 |
75 | tryAddTranslation : Ports.TranslationRequest -> Model -> Error.Failable Model
76 | tryAddTranslation req model =
77 | parseTranslationContent model.intl req
78 | |> Result.map (\content -> { model | state = State.addTranslations req.identifier req.language content model.state })
79 |
80 |
81 | onFinishModule : Model -> Ports.FinishRequest -> Cmd Msg
82 | onFinishModule model req =
83 | tryFinishModule defaultFileWidth req model |> Ports.respond
84 |
85 |
86 | defaultFileWidth : number
87 | defaultFileWidth =
88 | 120
89 |
90 |
91 | tryFinishModule : Int -> Ports.FinishRequest -> Model -> Failable Ports.ResponseContent
92 | tryFinishModule fileWidth { generatorMode, elmModuleName, addContentHash, i18nArgFirst, prefixFileIdentifier, customHtmlModule, customHtmlAttributesModule } model =
93 | let
94 | context =
95 | { moduleName = Util.moduleName elmModuleName
96 | , version = model.version
97 | , names =
98 | { defaultNames
99 | | htmlModuleName = String.split "." customHtmlModule
100 | , htmlAttributesModuleName = String.split "." customHtmlAttributesModule
101 | }
102 | , intl = model.intl
103 | , i18nArgLast = not i18nArgFirst
104 | }
105 |
106 | prefixTranslations =
107 | if prefixFileIdentifier then
108 | State.prefixTranslationsWithIdentifiers
109 |
110 | else
111 | identity
112 |
113 | generate validatedState =
114 | case generatorMode of
115 | Inline ->
116 | { elmFile = Generators.Inline.toFile context validatedState |> Pretty.pretty fileWidth
117 | , optimizedJson = []
118 | }
119 |
120 | Dynamic ->
121 | let
122 | stateWithResources =
123 | Dict.NonEmpty.map (Generators.Dynamic.optimizeJsonAllLanguages addContentHash) validatedState
124 | in
125 | { elmFile = Generators.Dynamic.toFile context stateWithResources |> Pretty.pretty fileWidth
126 | , optimizedJson = Dict.NonEmpty.toDict stateWithResources |> State.getAllResources
127 | }
128 | in
129 | State.validateState model.devMode model.state |> Result.map (prefixTranslations >> generate)
130 |
131 |
132 | parseTranslationContent : Intl -> Ports.TranslationRequest -> Failable (Translation ())
133 | parseTranslationContent intl { identifier, language, extension, content } =
134 | (case extension of
135 | "json" ->
136 | ContentTypes.Json.parse content
137 |
138 | "properties" ->
139 | ContentTypes.Properties.parse content
140 |
141 | "ftl" ->
142 | ContentTypes.Fluent.runFluentParser content |> Result.andThen (ContentTypes.Fluent.fluentToInternalRep intl language)
143 |
144 | _ ->
145 | Error.unsupportedContentType extension
146 | )
147 | |> Error.addTranslationFileNameCtx identifier
148 | |> Error.addContentTypeCtx extension
149 | |> Error.addLanguageCtx language
150 |
151 |
152 | subscriptions : Model -> Sub Msg
153 | subscriptions _ =
154 | Ports.subToRequests <|
155 | \result ->
156 | case result of
157 | Ok req ->
158 | GotRequest req
159 |
160 | Err err ->
161 | UnexpectedRequest err
162 |
163 |
164 | main : Program Flags Model Msg
165 | main =
166 | Platform.worker
167 | { init = \flags -> ( init flags.version flags.intl flags.devMode, Cmd.none )
168 | , update = update
169 | , subscriptions = subscriptions
170 | }
171 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | // "incremental": true, /* Enable incremental compilation */
5 | "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
6 | "module": "esnext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
7 | // "lib": [], /* Specify library files to be included in the compilation. */
8 | // "allowJs": true, /* Allow javascript files to be compiled. */
9 | // "checkJs": true, /* Report errors in .js files. */
10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
11 | "declaration": true /* Generates corresponding '.d.ts' file. */,
12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
13 | // "sourceMap": true, /* Generates corresponding '.map' file. */
14 | // "outFile": "./", /* Concatenate and emit output to single file. */
15 | "outDir": "lib" /* Redirect output structure to the directory. */,
16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
17 | // "composite": true, /* Enable project compilation */
18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
19 | // "removeComments": true, /* Do not emit comments to output. */
20 | // "noEmit": true, /* Do not emit outputs. */
21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
24 |
25 | /* Strict Type-Checking Options */
26 | "strict": true /* Enable all strict type-checking options. */,
27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
28 | // "strictNullChecks": true, /* Enable strict null checks. */
29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
34 |
35 | /* Additional Checks */
36 | // "noUnusedLocals": true, /* Report errors on unused locals. */
37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
40 |
41 | /* Module Resolution Options */
42 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
46 | // "typeRoots": [], /* List of folders to include type definitions from. */
47 | "types": [
48 | "node"
49 | ] /* Type declaration files to be included in compilation. */,
50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
54 |
55 | /* Source Map Options */
56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
60 |
61 | /* Experimental Options */
62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
64 |
65 | /* Advanced Options */
66 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
67 | "resolveJsonModule": true
68 | },
69 | "include": ["src"]
70 | }
71 |
--------------------------------------------------------------------------------
/demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | // "incremental": true, /* Enable incremental compilation */
5 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
6 | "module": "ES2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
7 | // "lib": [], /* Specify library files to be included in the compilation. */
8 | // "allowJs": true, /* Allow javascript files to be compiled. */
9 | // "checkJs": true, /* Report errors in .js files. */
10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
13 | // "sourceMap": true, /* Generates corresponding '.map' file. */
14 | // "outFile": "./", /* Concatenate and emit output to single file. */
15 | // "outDir": "./", /* Redirect output structure to the directory. */
16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
17 | // "composite": true, /* Enable project compilation */
18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
19 | // "removeComments": true, /* Do not emit comments to output. */
20 | // "noEmit": true, /* Do not emit outputs. */
21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
24 |
25 | /* Strict Type-Checking Options */
26 | "strict": true /* Enable all strict type-checking options. */,
27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
28 | // "strictNullChecks": true, /* Enable strict null checks. */
29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
34 |
35 | /* Additional Checks */
36 | // "noUnusedLocals": true, /* Report errors on unused locals. */
37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
40 |
41 | /* Module Resolution Options */
42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
46 | // "typeRoots": [], /* List of folders to include type definitions from. */
47 | // "types": [], /* Type declaration files to be included in compilation. */
48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
52 |
53 | /* Source Map Options */
54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
58 |
59 | /* Experimental Options */
60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
62 |
63 | /* Advanced Options */
64 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
65 | "resolveJsonModule": true,
66 | "moduleResolution": "nodenext"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/ContentTypes/Shared.elm:
--------------------------------------------------------------------------------
1 | module ContentTypes.Shared exposing (..)
2 |
3 | import List.Extra
4 | import List.NonEmpty
5 | import Parser as P exposing ((|.), (|=))
6 | import String.Extra
7 | import Types.Segment as Segment
8 |
9 |
10 | type alias ParsingState =
11 | { revSegments : List Segment.TSegment
12 | , htmlTagParsingState : HtmlTagState
13 | , nesting : List String
14 | }
15 |
16 |
17 | initialParsingState : ParsingState
18 | initialParsingState =
19 | { revSegments = [], htmlTagParsingState = NoHtml, nesting = [] }
20 |
21 |
22 | type alias HtmlAttrs =
23 | List ( String, List Segment.TSegment )
24 |
25 |
26 | type HtmlTagState
27 | = CollectingAttrs String HtmlAttrs
28 | | CollectingContent String HtmlAttrs ParsingState
29 | | NoHtml
30 |
31 |
32 | buildValueParser : (ParsingState -> P.Parser (P.Step ParsingState Segment.TValue)) -> P.Parser Segment.TValue
33 | buildValueParser =
34 | applyStepInnermost >> P.loop initialParsingState
35 |
36 |
37 | isSpecialAttribute : String -> Bool
38 | isSpecialAttribute =
39 | (==) idAttributeName
40 |
41 |
42 | idAttributeName : String
43 | idAttributeName =
44 | "_id"
45 |
46 |
47 | applyStepInnermost : (ParsingState -> P.Parser (P.Step ParsingState Segment.TValue)) -> ParsingState -> P.Parser (P.Step ParsingState Segment.TValue)
48 | applyStepInnermost step state =
49 | case state.htmlTagParsingState of
50 | CollectingContent tag attrs innerState ->
51 | P.andThen
52 | (\stepResult ->
53 | case stepResult of
54 | P.Done content ->
55 | let
56 | finalizedAttrs =
57 | List.reverse <| List.map (Tuple.mapSecond finalizeRevSegments) attrs
58 |
59 | idAttribute =
60 | List.Extra.find (Tuple.first >> (==) idAttributeName) finalizedAttrs
61 | |> Maybe.map Tuple.second
62 | in
63 | case idAttribute of
64 | Just ( Segment.Text id, [] ) ->
65 | P.succeed <|
66 | P.Loop
67 | { state
68 | | htmlTagParsingState = NoHtml
69 | , revSegments =
70 | Segment.Html
71 | { tag = tag
72 | , id = id
73 | , attrs = List.filter (not << isSpecialAttribute << Tuple.first) finalizedAttrs
74 | , content = content
75 | }
76 | :: state.revSegments
77 | }
78 |
79 | Nothing ->
80 | P.succeed <|
81 | P.Loop
82 | { state
83 | | htmlTagParsingState = NoHtml
84 | , revSegments =
85 | Segment.Html
86 | { tag = tag
87 | , id = String.Extra.camelize tag
88 | , attrs = List.filter (not << isSpecialAttribute << Tuple.first) finalizedAttrs
89 | , content = content
90 | }
91 | :: state.revSegments
92 | }
93 |
94 | _ ->
95 | P.problem <|
96 | """I found an '_id' attribute on an html element containing features other than plain text.
97 | Since the _id attribute is resolved at compile time, this is not allowed.
98 |
99 | Here is the html tag that misses the _id attribute: """
100 | ++ tag
101 |
102 | P.Loop newInnerState ->
103 | P.succeed <| P.Loop { state | htmlTagParsingState = CollectingContent tag attrs newInnerState }
104 | )
105 | (applyStepInnermost step innerState)
106 |
107 | _ ->
108 | step state
109 |
110 |
111 | onEnd : ParsingState -> P.Parser Segment.TValue
112 | onEnd state =
113 | case state.nesting of
114 | firstOpenHtmlTag :: _ ->
115 | P.problem <| "Found unclosed html tag: " ++ firstOpenHtmlTag
116 |
117 | [] ->
118 | P.succeed <| finalizeRevSegments state.revSegments
119 |
120 |
121 | addText : List Segment.TSegment -> String -> List Segment.TSegment
122 | addText revSegments text =
123 | case revSegments of
124 | (Segment.Text previousText) :: otherSegs ->
125 | Segment.Text (previousText ++ text) :: otherSegs
126 |
127 | _ ->
128 | Segment.Text text :: revSegments
129 |
130 |
131 | bracket : String -> String -> P.Parser String
132 | bracket start end =
133 | P.succeed identity
134 | |. P.token start
135 | |= (P.chompUntil end |> P.getChompedString)
136 | |. P.token end
137 |
138 |
139 | chompAllExcept : List Char -> P.Parser String
140 | chompAllExcept chars =
141 | P.getChompedString <|
142 | P.chompWhile (\char -> not <| List.member char chars)
143 |
144 |
145 | finalizeRevSegments : List Segment.TSegment -> Segment.TValue
146 | finalizeRevSegments revSegs =
147 | case List.NonEmpty.fromList revSegs of
148 | Nothing ->
149 | ( Segment.Text "", [] )
150 |
151 | Just nonEmptySegs ->
152 | List.NonEmpty.reverse nonEmptySegs
153 |
--------------------------------------------------------------------------------
/src/Types/UniqueName.elm:
--------------------------------------------------------------------------------
1 | module Types.UniqueName exposing
2 | ( UniqueNameContext
3 | , andThen
4 | , andThen2
5 | , andThen3
6 | , andThen4
7 | , andThen5
8 | , andThen6
9 | , combineAndThen
10 | , map
11 | , mapWithScope
12 | , new
13 | , scoped
14 | , unwrap
15 | )
16 |
17 | {-| This module provides a monad for unique name generation given a context of already taken names.
18 | Elm keywords are automatically added to the set of taken names.
19 | It will postfix already taken names with underscores ("\_").
20 | -}
21 |
22 | import Dict exposing (Dict)
23 | import Set exposing (Set)
24 |
25 |
26 | type UniqueNameContext a
27 | = Context (Set String) a
28 |
29 |
30 | new : a -> UniqueNameContext a
31 | new =
32 | Context <| Set.fromList elmKeywords
33 |
34 |
35 | unwrap : UniqueNameContext a -> a
36 | unwrap (Context _ a) =
37 | a
38 |
39 |
40 | map : (a -> b) -> UniqueNameContext a -> UniqueNameContext b
41 | map f (Context s a) =
42 | Context s (f a)
43 |
44 |
45 | mapWithScope : ((String -> String) -> a -> b) -> UniqueNameContext a -> UniqueNameContext b
46 | mapWithScope f (Context s a) =
47 | Context s (f (\name -> findUnique name s |> Tuple.first) a)
48 |
49 |
50 | andThen : String -> (ScopedLookup -> a -> String -> b) -> UniqueNameContext a -> UniqueNameContext b
51 | andThen nameSuggestion doWithName ((Context s _) as ctx) =
52 | let
53 | ( uniqueName, newUsedNames ) =
54 | findUnique nameSuggestion s
55 | in
56 | Context newUsedNames (threadCtx ctx doWithName uniqueName)
57 |
58 |
59 | threadCtx : UniqueNameContext a -> (ScopedLookup -> a -> b) -> b
60 | threadCtx (Context s a) f =
61 | f (\name -> findUnique name s |> Tuple.first) a
62 |
63 |
64 | andThen2 : String -> String -> (ScopedLookup -> a -> String -> String -> b) -> UniqueNameContext a -> UniqueNameContext b
65 | andThen2 n1 n2 doWithName ctx =
66 | andThen n1 doWithName ctx |> andThen n2 (always (<|))
67 |
68 |
69 | andThen3 : String -> String -> String -> (ScopedLookup -> a -> String -> String -> String -> b) -> UniqueNameContext a -> UniqueNameContext b
70 | andThen3 n1 n2 n3 doWithName ctx =
71 | andThen2 n1 n2 doWithName ctx |> andThen n3 (always (<|))
72 |
73 |
74 | andThen4 : String -> String -> String -> String -> (ScopedLookup -> a -> String -> String -> String -> String -> b) -> UniqueNameContext a -> UniqueNameContext b
75 | andThen4 n1 n2 n3 n4 doWithName ctx =
76 | andThen3 n1 n2 n3 doWithName ctx |> andThen n4 (always (<|))
77 |
78 |
79 | andThen5 :
80 | String
81 | -> String
82 | -> String
83 | -> String
84 | -> String
85 | -> (ScopedLookup -> a -> String -> String -> String -> String -> String -> b)
86 | -> UniqueNameContext a
87 | -> UniqueNameContext b
88 | andThen5 n1 n2 n3 n4 n5 doWithName ctx =
89 | andThen4 n1 n2 n3 n4 doWithName ctx |> andThen n5 (always (<|))
90 |
91 |
92 | andThen6 :
93 | String
94 | -> String
95 | -> String
96 | -> String
97 | -> String
98 | -> String
99 | -> (ScopedLookup -> a -> String -> String -> String -> String -> String -> String -> b)
100 | -> UniqueNameContext a
101 | -> UniqueNameContext b
102 | andThen6 n1 n2 n3 n4 n5 n6 doWithName ctx =
103 | andThen5 n1 n2 n3 n4 n5 doWithName ctx |> andThen n6 (always (<|))
104 |
105 |
106 | type alias ScopedLookup =
107 | String -> String
108 |
109 |
110 | {-| Run unique name generation inside of a scope. This means that the names already in the set are relevant for the generation,
111 | but the generated names inside of the scope do not leak out. So if I open two scopes and want a unique name for "test" in each scope,
112 | I will get "test" in both scopes. Without the scope, the latter one would be "test\_" for uniqueness reasons.
113 | -}
114 | scoped : (UniqueNameContext a -> UniqueNameContext b) -> UniqueNameContext a -> UniqueNameContext b
115 | scoped doWithContext ((Context s _) as ctx) =
116 | doWithContext ctx |> unwrap |> Context s
117 |
118 |
119 | {-| Get a set of unique names for a set of suggestions. The new names are then given as a lookup function.
120 |
121 | combineAndThen (\_ -> Set.fromList [ "hi", "type" ]) (\lookupName a ->
122 | let
123 | newNameForHi = lookupName "hi"
124 | newNameForType = lookupName "type"
125 | in
126 | -- do something with the new names
127 | )
128 |
129 | -}
130 | combineAndThen : (a -> Set String) -> (ScopedLookup -> a -> (String -> String) -> b) -> UniqueNameContext a -> UniqueNameContext b
131 | combineAndThen nameSuggestions doWithNames ((Context s a) as ctx) =
132 | let
133 | ( lookupDict, newUsedNames ) =
134 | findUniqueSet (nameSuggestions a) s
135 |
136 | lookup name =
137 | Dict.get name lookupDict
138 | |> Maybe.withDefault ("Failed to lookup name '" ++ name ++ "'. This should not happen! Open an issue at https://github.com/andreasewering/travelm-agency/issues please, so this will not happen again.")
139 | in
140 | Context newUsedNames (threadCtx ctx doWithNames lookup)
141 |
142 |
143 | findUniqueSet : Set String -> Set String -> ( Dict String String, Set String )
144 | findUniqueSet suggestions usedNamesStart =
145 | Set.foldl
146 | (\suggestion ( dict, usedNames ) ->
147 | let
148 | ( uniqueName, newUsedNames ) =
149 | findUnique suggestion usedNames
150 | in
151 | ( Dict.insert suggestion uniqueName dict, newUsedNames )
152 | )
153 | ( Dict.empty, usedNamesStart )
154 | suggestions
155 |
156 |
157 | findUnique : String -> Set String -> ( String, Set String )
158 | findUnique suggestion usedNames =
159 | if Set.member suggestion usedNames then
160 | findUnique (suggestion ++ "_") usedNames
161 |
162 | else
163 | ( suggestion, Set.insert suggestion usedNames )
164 |
165 |
166 | elmKeywords : List String
167 | elmKeywords =
168 | [ "type"
169 | , "alias"
170 | , "port"
171 | , "if"
172 | , "then"
173 | , "else"
174 | , "case"
175 | , "of"
176 | , "let"
177 | , "in"
178 | , "infix"
179 | , "module"
180 | , "import"
181 | , "exposing"
182 | , "as"
183 | , "where"
184 | ]
185 |
--------------------------------------------------------------------------------