├── 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /demo/public/flag_de.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Flag of Germany 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /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 = 3 | fakeHtml = { "" }inside of span{ "" } 4 | customElementExample = This content is inside a custom element 5 | customElementWithAttrs = Custom element with attributes 6 | customElementNested = Nested content with {variable} -------------------------------------------------------------------------------- /demo/public/html/example.en.properties: -------------------------------------------------------------------------------- 1 | partlyBoldText = Needs some highlighting 2 | listWithDifferentStyles = 3 | fakeHtml = ''inside of span'' 4 | customElementExample = This content is inside a custom element 5 | customElementWithAttrs = Custom element with attributes 6 | customElementNested = Nested content with {variable} -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Handle line endings automatically for files detected as text 2 | # and leave all files detected as binary untouched. 3 | * text=auto 4 | 5 | # 6 | # The above will handle all files NOT found below 7 | # 8 | # These files are text and should be normalized (Convert crlf => lf) 9 | *.css text 10 | *.html text 11 | *.elm text 12 | *.js text 13 | *.ts text 14 | *.json text 15 | *.properties text 16 | *.sh text 17 | *.ftl text 18 | *.adoc text 19 | *.md text 20 | *.yml text -------------------------------------------------------------------------------- /demo/public/html/example.en.json: -------------------------------------------------------------------------------- 1 | { 2 | "partlyBoldText": "Needs some highlighting", 3 | "listWithDifferentStyles": "", 4 | "fakeHtml": "\\inside of span\\", 5 | "customElementExample": "This content is inside a custom element", 6 | "customElementWithAttrs": "Custom element with attributes", 7 | "customElementNested": "Nested content with {variable}" 8 | } 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "elm" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "npm" 13 | directory: "/" # Location of package manifests 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /gen_test_cases/SingleTextCase.elm: -------------------------------------------------------------------------------- 1 | module SingleTextCase 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.singleton "en" 19 | { pairs = Dict.fromList [ ( "singleText", ( Text "the text", [] ) ) ] 20 | , fallback = Nothing 21 | , resources = () 22 | } 23 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "1.0.0", 4 | "description": "demo application for travelm-agency", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "vite", 9 | "prebuild": "node ../index.js translations --json_path=public/i18n --prefix_file_identifier --hash", 10 | "build": "cross-env NODE_ENV=production vite build", 11 | "preview": "vite preview" 12 | }, 13 | "author": "Andreas Molitor", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "cross-env": "^7.0.3", 17 | "intl-proxy": "^1.0.1", 18 | "vite": "^6.3.5", 19 | "vite-plugin-elm": "^3.0.1" 20 | }, 21 | "dependencies": { 22 | "prismjs": "1.30" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /demo/src/InputType.elm: -------------------------------------------------------------------------------- 1 | module InputType exposing (..) 2 | 3 | 4 | type InputType 5 | = Json 6 | | Properties 7 | | Fluent 8 | 9 | 10 | toString : InputType -> String 11 | toString inputType = 12 | case inputType of 13 | Json -> 14 | "json" 15 | 16 | Properties -> 17 | "properties" 18 | 19 | Fluent -> 20 | "ftl" 21 | 22 | 23 | fromString : String -> Maybe InputType 24 | fromString str = 25 | case String.toLower str of 26 | "json" -> 27 | Just Json 28 | 29 | "properties" -> 30 | Just Properties 31 | 32 | "ftl" -> 33 | Just Fluent 34 | 35 | _ -> 36 | Nothing 37 | -------------------------------------------------------------------------------- /gen_test_cases/SingleInterpolationCase.elm: -------------------------------------------------------------------------------- 1 | module SingleInterpolationCase 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.singleton 19 | "en" 20 | { pairs = Dict.fromList [ ( "text", ( Text "hello ", [ Interpolation "planet", Text "!" ] ) ) ] 21 | , fallback = Nothing 22 | , resources = () 23 | } 24 | -------------------------------------------------------------------------------- /src/Types/InterpolationKind.elm: -------------------------------------------------------------------------------- 1 | module Types.InterpolationKind exposing (..) 2 | 3 | import Elm.CodeGen as CG 4 | 5 | 6 | type InterpolationKind 7 | = Simple 8 | | Typed { ann : CG.TypeAnnotation, toString : CG.Expression -> CG.Expression } 9 | 10 | 11 | toTypeAnn : InterpolationKind -> CG.TypeAnnotation 12 | toTypeAnn kind = 13 | case kind of 14 | Simple -> 15 | CG.stringAnn 16 | 17 | Typed { ann } -> 18 | ann 19 | 20 | 21 | interpolatedValueToString : InterpolationKind -> CG.Expression -> CG.Expression 22 | interpolatedValueToString kind = 23 | case kind of 24 | Simple -> 25 | identity 26 | 27 | Typed { toString } -> 28 | toString 29 | -------------------------------------------------------------------------------- /gen_test_cases/DateFormatCase.elm: -------------------------------------------------------------------------------- 1 | module DateFormatCase 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 = Dict.fromList [ ( "text", ( Text "Today: ", [ FormatDate "date" [ ( "weekday", StringArg "long" ) ] ] ) ) ] 22 | , fallback = Nothing 23 | , resources = () 24 | } 25 | -------------------------------------------------------------------------------- /gen_test_cases/Util/CustomHtml.elm: -------------------------------------------------------------------------------- 1 | module Util.CustomHtml exposing (Attribute, Html, node, text, unpackHtml) 2 | 3 | import Html 4 | import Util.CustomHtmlAttributes as CustomHtmlAttributes exposing (unpackAttribute) 5 | 6 | 7 | type Html msg 8 | = CustomHtml (Html.Html msg) 9 | 10 | 11 | type alias Attribute msg = 12 | CustomHtmlAttributes.Attribute msg 13 | 14 | 15 | text : String -> Html msg 16 | text str = 17 | CustomHtml (Html.text str) 18 | 19 | 20 | node : String -> List (Attribute msg) -> List (Html msg) -> Html msg 21 | node name attrs children = 22 | Html.node name (List.map unpackAttribute attrs) (List.map unpackHtml children) 23 | |> CustomHtml 24 | 25 | 26 | unpackHtml : Html msg -> Html.Html msg 27 | unpackHtml (CustomHtml html) = 28 | html 29 | -------------------------------------------------------------------------------- /gen_test_cases/NumberFormatCase.elm: -------------------------------------------------------------------------------- 1 | module NumberFormatCase 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 = Dict.fromList [ ( "text", ( Text "Price: ", [ FormatNumber "price" [ ( "style", StringArg "currency" ) ] ] ) ) ] 22 | , fallback = Nothing 23 | , resources = () 24 | } 25 | -------------------------------------------------------------------------------- /gen_test_cases/NamespacingCase.elm: -------------------------------------------------------------------------------- 1 | module NamespacingCase 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 | [ ( "let", ( Text "elm keyword", [] ) ) 22 | , ( "init", ( Text "reserved name", [] ) ) 23 | ] 24 | , fallback = Nothing 25 | , resources = () 26 | } 27 | -------------------------------------------------------------------------------- /demo/src/File.elm: -------------------------------------------------------------------------------- 1 | module File exposing (..) 2 | 3 | 4 | type alias OutputFile = 5 | { name : String 6 | , language : Maybe String 7 | , extension : String 8 | , content : String 9 | } 10 | 11 | 12 | type alias InputFile = 13 | { name : String 14 | , language : String 15 | , extension : String 16 | , content : String 17 | } 18 | 19 | 20 | outputFileToPath : OutputFile -> String 21 | outputFileToPath file = 22 | List.filterMap identity 23 | [ Just file.name 24 | , file.language 25 | , Just file.extension 26 | ] 27 | |> String.join "." 28 | 29 | 30 | inputFileToPath : InputFile -> String 31 | inputFileToPath file = 32 | [ file.name 33 | , file.language 34 | , file.extension 35 | ] 36 | |> String.join "." 37 | -------------------------------------------------------------------------------- /gen_test_cases/SimpleHtmlCase.elm: -------------------------------------------------------------------------------- 1 | module SimpleHtmlCase 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", ( Html { tag = "a", id = "link", attrs = [ ( "href", ( Text "/", [] ) ) ], content = ( Text "Click me", [] ) }, [] ) ) 22 | ] 23 | , fallback = Nothing 24 | , resources = () 25 | } 26 | -------------------------------------------------------------------------------- /demo/translations/terms.en.ftl: -------------------------------------------------------------------------------- 1 | headline = Fluent: Terms 2 | preamble = Text content tends to have quite a lot of repetitions. These can lead to copy & paste mistakes 3 | and code that needs to be adjusted via a full text search. Terms present a solution to this problem. 4 | In essence, terms are just your normal key value pairs, but with a twist. They can be included in other 5 | translations and will be inlined at compile time. 6 | 7 | syntaxHeadline = Syntax 8 | syntaxBody = Terms are key value pairs whose key begins with a dash "-". In this example, -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 | Travelm-Agency Demo 8 | 9 | 10 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /gen_test_cases/ComplexI18nFirstCase.elm: -------------------------------------------------------------------------------- 1 | module ComplexI18nFirstCase 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 | [ ( "interpolationCase" 22 | , ( InterpolationCase "var" ( Text "default", [] ) <| 23 | Dict.fromList [ ( "one", ( Text "One", [] ) ) ] 24 | , [] 25 | ) 26 | ) 27 | , ( "html" 28 | , ( Html 29 | { tag = "a" 30 | , id = "link" 31 | , attrs = [ ( "href", ( Text "/", [] ) ) ] 32 | , content = ( Interpolation "test", [] ) 33 | } 34 | , [] 35 | ) 36 | ) 37 | , ( "numberFormat", ( FormatNumber "num" [], [] ) ) 38 | ] 39 | , fallback = Nothing 40 | , resources = () 41 | } 42 | -------------------------------------------------------------------------------- /demo/src/Pages/Html.elm: -------------------------------------------------------------------------------- 1 | module Pages.Html 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.Inline ) 17 | |> Page.loadInputFiles { directory = "html", files = ( { name = "example", language = "en" }, [] ) } 18 | |> Page.withTranslations Translations.loadHtml 19 | 20 | 21 | viewExplanation : Model -> List (Html Msg) 22 | viewExplanation { i18n } = 23 | [ Html.p [] <| Translations.htmlPreamble [ class "highlighted" ] i18n 24 | , Html.h2 [] [ Html.text <| Translations.htmlBasicsHeadline i18n ] 25 | , Html.p [] <| Translations.htmlBasicsBody [ class "highlighted" ] i18n 26 | , Html.h2 [] [ Html.text <| Translations.htmlIdHeadline i18n ] 27 | , Html.p [] [ Html.text <| Translations.htmlIdBody i18n ] 28 | , Html.h2 [] [ Html.text <| Translations.htmlSecurityHeadline i18n ] 29 | , Html.p [] <| Translations.htmlSecurityBody [] i18n 30 | , Html.h2 [] [ Html.text <| Translations.htmlEscapingHeadline i18n ] 31 | , Html.p [] <| Translations.htmlEscapingBody [ class "highlighted" ] i18n 32 | , Html.h2 [] [ Html.text <| Translations.htmlCustomizingHeadline i18n ] 33 | , Html.p [] <| Translations.htmlCustomizingBody { a = [], code = [ class "highlighted" ] } i18n 34 | ] 35 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": ["src", "gen_test_cases"], 4 | "elm-version": "0.19.1", 5 | "dependencies": { 6 | "direct": { 7 | "NoRedInk/elm-json-decode-pipeline": "1.0.1", 8 | "anmolitor/intl-proxy": "1.0.0", 9 | "elm/browser": "1.0.2", 10 | "elm/core": "1.0.5", 11 | "elm/html": "1.0.0", 12 | "elm/http": "2.0.0", 13 | "elm/json": "1.1.3", 14 | "elm/parser": "1.1.0", 15 | "elm/time": "1.0.0", 16 | "elm-community/list-extra": "8.7.0", 17 | "elm-community/maybe-extra": "5.3.0", 18 | "elm-community/result-extra": "2.4.0", 19 | "elm-community/string-extra": "4.0.1", 20 | "robinheghan/fnv1a": "1.0.0", 21 | "rtfeldman/elm-iso8601-date-strings": "1.1.4", 22 | "stil4m/elm-syntax": "7.2.9", 23 | "the-sett/elm-syntax-dsl": "5.3.0", 24 | "turboMaCk/non-empty-list-alias": "1.3.1" 25 | }, 26 | "indirect": { 27 | "Chadtech/elm-bool-extra": "2.4.2", 28 | "elm/bytes": "1.0.8", 29 | "elm/file": "1.0.5", 30 | "elm/regex": "1.0.0", 31 | "elm/url": "1.0.0", 32 | "elm/virtual-dom": "1.0.2", 33 | "elm-community/basics-extra": "4.1.0", 34 | "miniBill/elm-unicode": "1.0.2", 35 | "rtfeldman/elm-hex": "1.0.0", 36 | "stil4m/structured-writer": "1.0.3", 37 | "the-sett/elm-pretty-printer": "2.2.3" 38 | } 39 | }, 40 | "test-dependencies": { 41 | "direct": { 42 | "elm-explorations/test": "2.1.1" 43 | }, 44 | "indirect": { 45 | "elm/random": "1.0.0" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /demo/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": ["src", "../src"], 4 | "elm-version": "0.19.1", 5 | "dependencies": { 6 | "direct": { 7 | "NoRedInk/elm-json-decode-pipeline": "1.0.1", 8 | "anmolitor/intl-proxy": "1.0.0", 9 | "avh4/elm-color": "1.0.0", 10 | "elm/browser": "1.0.2", 11 | "elm/core": "1.0.5", 12 | "elm/html": "1.0.0", 13 | "elm/http": "2.0.0", 14 | "elm/json": "1.1.3", 15 | "elm/parser": "1.1.0", 16 | "elm/time": "1.0.0", 17 | "elm/url": "1.0.0", 18 | "elm-community/list-extra": "8.6.0", 19 | "elm-community/maybe-extra": "5.3.0", 20 | "elm-community/result-extra": "2.4.0", 21 | "elm-community/string-extra": "4.0.1", 22 | "icidasset/elm-material-icons": "10.0.0", 23 | "robinheghan/fnv1a": "1.0.0", 24 | "rtfeldman/elm-iso8601-date-strings": "1.1.4", 25 | "stil4m/elm-syntax": "7.2.9", 26 | "the-sett/elm-syntax-dsl": "6.0.2", 27 | "turboMaCk/non-empty-list-alias": "1.3.0" 28 | }, 29 | "indirect": { 30 | "Chadtech/elm-bool-extra": "2.4.2", 31 | "elm/bytes": "1.0.8", 32 | "elm/file": "1.0.5", 33 | "elm/regex": "1.0.0", 34 | "elm/svg": "1.0.1", 35 | "elm/virtual-dom": "1.0.3", 36 | "elm-community/basics-extra": "4.1.0", 37 | "miniBill/elm-unicode": "1.0.2", 38 | "rtfeldman/elm-hex": "1.0.0", 39 | "stil4m/structured-writer": "1.0.3", 40 | "the-sett/elm-pretty-printer": "3.0.0" 41 | } 42 | }, 43 | "test-dependencies": { 44 | "direct": {}, 45 | "indirect": {} 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /demo/src/Accordion.elm: -------------------------------------------------------------------------------- 1 | module Accordion exposing (..) 2 | 3 | import Dict 4 | import Html exposing (Html) 5 | import Html.Attributes exposing (class, style) 6 | import Html.Events 7 | import Json.Decode as Decode exposing (Decoder) 8 | import Material.Icons 9 | import Material.Icons.Types exposing (Coloring(..)) 10 | import Model exposing (Model) 11 | import Msg exposing (Msg(..)) 12 | 13 | 14 | onClick : (Int -> msg) -> Html.Attribute msg 15 | onClick listener = 16 | Html.Events.on "click" 17 | (Decode.map listener maxHeight) 18 | 19 | 20 | maxHeight : Decoder Int 21 | maxHeight = 22 | Decode.at 23 | [ "currentTarget" 24 | , "parentElement" 25 | , "children" 26 | , "1" 27 | , "scrollHeight" 28 | ] 29 | Decode.int 30 | 31 | 32 | view : 33 | { headline : String 34 | , content : List (Html Msg) 35 | , id : String 36 | } 37 | -> Model 38 | -> Html Msg 39 | view { headline, content, id } { openAccordionElements } = 40 | let 41 | height = 42 | Dict.get id openAccordionElements |> Maybe.withDefault 0 43 | 44 | isOpen = 45 | height /= 0 46 | 47 | icon = 48 | (if isOpen then 49 | Material.Icons.expand_less 50 | 51 | else 52 | Material.Icons.expand_more 53 | ) 54 | 50 55 | Inherit 56 | in 57 | Html.div [] 58 | [ Html.h3 [ onClick <| ToggleAccordionElement id, class "accordion-headline" ] [ Html.text headline, icon ] 59 | , Html.p [ class "accordion-content", style "max-height" <| String.fromInt height ++ "px" ] content 60 | ] 61 | -------------------------------------------------------------------------------- /demo/translations/bundles.en.ftl: -------------------------------------------------------------------------------- 1 | headline = Translation bundles 2 | preamble = Until now, we only considered a single set of translation files for multiple languages. 3 | In large applications, we might benefit from not loading all translations for a language at once, but instead 4 | loading just the translations we need for the current page or subtree of pages. This 5 | feature is only doing something relevant in 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 | Single Page Apps for GitHub Pages 6 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /demo/translations/date_format.de.ftl: -------------------------------------------------------------------------------- 1 | headline = Fluent: Ein Datum formatieren 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 = Ein Datum kann in noch viel mehr Wegen angezeigt werden als eine Zahl. 7 | Reihenfolge von Jahr/Monat/Tag unterscheidet sich je nach Sprache und dann ist auch noch die Frage ob man 8 | die Kurz- oder Langschreibweise für Wochentage/Monate nutzt... von Hand macht das keinen Spaß. 9 | 10 | intlHeadline = Elm und die Intl APIs 11 | intlBody = Zum Glück hilft uns hier wieder die Intl API des Browsers. 12 | Auch hier gibt es durch Elms Restriktionen keinen Weg ohne intl-proxy zu installieren 13 | und in deine Applikation als Flag zu übergeben. Der generierte Code wird dich dazu zwingen den Proxy zu übergeben 14 | wenn du die initiale 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 ", 12 | "Generate Elm code for your translation files", 13 | (builder) => 14 | builder 15 | .option("elm_path", { 16 | description: 17 | "Where to put the generated Elm file. There needs to be an elm.json file in some parent folder for this script to work.", 18 | type: "string", 19 | default: "src/Translations.elm", 20 | }) 21 | .option("json_path", { 22 | description: 23 | "The directory to generate the optimized translation files into. This will not be used if --inline is specified.", 24 | type: "string", 25 | default: "dist/i18n", 26 | }) 27 | .option("inline", { 28 | description: 29 | "Generate an Elm module that contains all of the translations inline (no resource loading necessary at runtime).", 30 | type: "boolean", 31 | default: false, 32 | }) 33 | .option("hash", { 34 | description: 35 | "Add content hashes to generated json files. This helps with caching. This will not be used if --inline is specified.", 36 | type: "boolean", 37 | default: false, 38 | }) 39 | .option("i18n_arg_first", { 40 | description: 41 | "Pass the i18n instance as the last parameter to the generated function (opposed to it being the first).", 42 | type: "boolean", 43 | default: false, 44 | }) 45 | .option("prefix_file_identifier", { 46 | description: 47 | "Prefix the identifier of the files containing the translation keys to the generated translation functions.", 48 | type: "boolean", 49 | default: false, 50 | }) 51 | .option("devMode", { 52 | description: "Disable completeness check for i18n keys", 53 | type: "boolean", 54 | default: false, 55 | }) 56 | .option("custom_html_module", { 57 | description: 58 | "Which module the generated code should use instead of elm/html", 59 | type: "string", 60 | }) 61 | .option("custom_html_attributes_module", { 62 | description: 63 | "Which module the generated code should use instead of elm/html Html.Attributes. Defaults to the value of custom_html_module + '.Attributes'", 64 | type: "string", 65 | }) 66 | .positional("translation_directory", { 67 | description: 68 | "The directory containing translation files (.json/.properties).", 69 | type: "string", 70 | demandOption: true, 71 | }) 72 | ) 73 | .parse(); 74 | run({ 75 | translationDir: args.translation_directory, 76 | elmPath: args.elm_path, 77 | jsonPath: args.json_path, 78 | elmJson: args.elm_json, 79 | generatorMode: args.inline ? "inline" : "dynamic", 80 | addContentHash: args.hash, 81 | devMode: args.devMode, 82 | i18nArgFirst: args.i18n_arg_first, 83 | prefixFileIdentifier: args.prefix_file_identifier, 84 | customHtmlModule: args.custom_html_module, 85 | customHtmlAttributesModule: args.custom_html_attributes_module, 86 | }); 87 | })(); 88 | 89 | process.on("unhandledRejection", (err) => { 90 | throw err; 91 | }); 92 | -------------------------------------------------------------------------------- /demo/translations/html.en.ftl: -------------------------------------------------------------------------------- 1 | headline = Html 2 | preamble = Previously, the return type for all the generated translation key functions was 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 | 21 | advantagePerformanceHeadline = Performance 22 | advantagePerformanceBody = Elm's bundle size is generally pretty small, but can get bloated if you have lots of texts in 23 | different languages on your site. Loading all the inactive languages is a waste most of the time. 24 | With Travelm-Agency you can switch from 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 | 20 | advantagePerformanceHeadline = Performanz 21 | advantagePerformanceBody = Elms Bundle Größe ist insgesamt relativ klein aber kann schnell wachsen wenn es viele Texte in vielen 22 | verschiedenen Sprachen auf einer Seite gibt. Die ganzen inaktiven Übersetzungen zu laden ist einen Großteil der Zeit unnötig. 23 | Mit Travelm-Agency kannst du zwischen 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 | --------------------------------------------------------------------------------