├── GLEAM_VERSION ├── .gitignore ├── static ├── precompiled │ └── my_package_ffi.mjs ├── share-preview.png ├── css │ ├── layout.css │ ├── fonts.css │ ├── theme.css │ ├── code │ │ ├── color-schemes │ │ │ └── atom-one.css │ │ └── syntax-highlight.css │ ├── root.css │ └── pages │ │ └── playground.css ├── common.css ├── compiler.js ├── worker.js └── index.js ├── test └── playground_test.gleam ├── bin └── download-compiler ├── README.md ├── .github └── workflows │ ├── test.yml │ └── deploy.yml ├── gleam.toml ├── manifest.toml └── src ├── playground ├── html.gleam └── widgets.gleam └── playground.gleam /GLEAM_VERSION: -------------------------------------------------------------------------------- 1 | v1.13.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | public/ 6 | -------------------------------------------------------------------------------- /static/precompiled/my_package_ffi.mjs: -------------------------------------------------------------------------------- 1 | export function now() { 2 | return new Date(); 3 | } 4 | -------------------------------------------------------------------------------- /static/share-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleam-lang/playground/main/static/share-preview.png -------------------------------------------------------------------------------- /test/playground_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | // gleeunit test functions end in `_test` 9 | pub fn hello_world_test() { 10 | 1 11 | |> should.equal(1) 12 | } 13 | -------------------------------------------------------------------------------- /static/css/layout.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --gap: 0.75rem; 3 | --gap-double: calc(2 * var(--gap)); 4 | --gap-triple: calc(3 * var(--gap)); 5 | --gap-quad: calc(4 * var(--gap)); 6 | --gap-half: calc(0.5 * var(--gap)); 7 | --gap-quarter: calc(0.25 * var(--gap)); 8 | 9 | --navbar-height: calc(var(--gap-double) + 20px); 10 | --border-radius: .25rem; 11 | } 12 | -------------------------------------------------------------------------------- /bin/download-compiler: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | project_root="$(dirname $0)/.." 6 | version=$(cat "${project_root}/GLEAM_VERSION") 7 | 8 | rm -fr "${project_root}/wasm-compiler" 9 | mkdir "${project_root}/wasm-compiler" 10 | curl -L "https://github.com/gleam-lang/gleam/releases/download/$version/gleam-$version-browser.tar.gz" | tar xz -C "${project_root}/wasm-compiler" 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Gleam Playground 2 | 3 | An interactive playground for the Gleam language. 4 | 5 | ```sh 6 | # Set the gleam version in the GLEAM_VERSION file 7 | #./GLEAM_VERSION 8 | v1.11.0 9 | 10 | # Download a wasm version of the Gleam compiler 11 | ./bin/download-compiler 12 | 13 | # Build the site 14 | gleam run 15 | 16 | # It's now all the in `public/` directory 17 | ``` 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "28" 18 | # Ensure you update the ./GLEAM_VERSION to match this 19 | gleam-version: "1.13.0" 20 | rebar3-version: "3" 21 | - run: ./bin/download-compiler 22 | - run: gleam deps download 23 | - run: gleam test 24 | - run: gleam run 25 | - run: gleam format --check src test 26 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "playground" 2 | version = "1.0.0" 3 | target = "javascript" 4 | licences = ["Apache-2.0"] 5 | 6 | # Fill out these fields if you intend to generate HTML documentation or publish 7 | # your project to the Hex package manager. 8 | # 9 | # description = "" 10 | # repository = { type = "github", user = "username", repo = "project" } 11 | # links = [{ title = "Website", href = "https://gleam.run" }] 12 | # 13 | # For a full reference of all the available options, you can have a look at 14 | # https://gleam.run/writing-gleam/gleam-toml/. 15 | 16 | [dependencies] 17 | gleam_stdlib = ">= 0.34.0 and < 2.0.0" 18 | simplifile = ">= 2.2.0 and < 3.0.0" 19 | snag = "~> 1.0" 20 | htmb = "~> 2.0" 21 | filepath = ">= 1.0.0 and < 2.0.0" 22 | 23 | [dev-dependencies] 24 | gleeunit = ">= 1.0.0 and < 2.0.0" 25 | -------------------------------------------------------------------------------- /static/css/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Lexend"; 3 | font-display: swap; 4 | font-weight: 400; 5 | src: url("https://gleam.run/fonts/Lexend.woff2") format("woff2"); 6 | } 7 | 8 | @font-face { 9 | font-family: "Lexend"; 10 | font-display: swap; 11 | font-weight: 700; 12 | src: url("https://gleam.run/fonts/Lexend-700.woff2") format("woff2"); 13 | } 14 | 15 | @font-face { 16 | font-family: "Outfit"; 17 | font-display: swap; 18 | src: url("https://gleam.run/fonts/Outfit.woff") format("woff"); 19 | } 20 | 21 | :root { 22 | --font-family-normal: "Outfit", sans-serif; 23 | --font-family-title: "Lexend", sans-serif; 24 | 25 | --font-size-normal: calc(var(--gap) * 1.5); 26 | --font-size-small: calc(var(--gap) * 1.2); 27 | --font-size-extra-small: calc(var(--gap)); 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | on: 3 | push: 4 | branches: ["main"] 5 | 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | - uses: erlef/setup-beam@v1 28 | with: 29 | otp-version: "28" 30 | # Ensure you update the ./GLEAM_VERSION to match this 31 | gleam-version: "1.13.0" 32 | rebar3-version: "3" 33 | 34 | - name: Download WASM version of Gleam compiler 35 | run: ./bin/download-compiler 36 | - name: Build site 37 | run: gleam run 38 | 39 | - name: Setup Pages 40 | uses: actions/configure-pages@v4 41 | - name: Upload artifact 42 | uses: actions/upload-pages-artifact@v3 43 | with: 44 | path: 'public' 45 | - name: Deploy to GitHub Pages 46 | id: deployment 47 | uses: actions/deploy-pages@v4 48 | -------------------------------------------------------------------------------- /static/common.css: -------------------------------------------------------------------------------- 1 | /* This file contains design tokens used in core Gleam projects */ 2 | :root { 3 | /* Branding */ 4 | --faff-pink: #ffaff3; 5 | --white: #fefefc; 6 | --unnamed-blue: #a6f0fc; 7 | --aged-plastic-yellow: #fffbe8; 8 | --unexpected-aubergine: #584355; 9 | --underwater-blue: #292d3e; 10 | --charcoal: #2f2f2f; 11 | --black: #1e1e1e; 12 | --blacker: #151515; 13 | 14 | /* Other greys */ 15 | --off-white: #f5f5f5; 16 | 17 | /* Other colors */ 18 | --menthol: #c8ffa7; 19 | --caramel: #ffd596; 20 | --deep-saffron: #ff9d35; 21 | --tomato: #ff6262; 22 | 23 | /* Semantic colors */ 24 | --brand-success: var(--menthol); 25 | --brand-warning: var(--caramel); 26 | --brand-error: var(--tomato); 27 | 28 | /* Light theme */ 29 | --light-theme-background: var(--white); 30 | --light-theme-background-dim: var(--off-white); 31 | --light-theme-text: var(--black); 32 | --light-theme-text-secondary: var(--charcoal); 33 | --light-theme-code: var(--black); 34 | 35 | /* Dark theme */ 36 | --dark-theme-background: var(--underwater-blue); 37 | --dark-theme-background-dim: var(--black); 38 | --dark-theme-text: var(--white); 39 | --dark-theme-text-secondary: var(--aged-plastic-yellow); 40 | --dark-theme-code: var(--deep-saffron); 41 | } 42 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 6 | { name = "gleam_stdlib", version = "0.67.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6368313DB35963DC02F677A513BB0D95D58A34ED0A9436C8116820BF94BE3511" }, 7 | { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 8 | { name = "htmb", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "htmb", source = "hex", outer_checksum = "023218E5A4DE7A1BA5E2BB449F063382F8F747A11F13423433D60AEA14CC2655" }, 9 | { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, 10 | { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" }, 11 | ] 12 | 13 | [requirements] 14 | filepath = { version = ">= 1.0.0 and < 2.0.0" } 15 | gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 16 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 17 | htmb = { version = "~> 2.0" } 18 | simplifile = { version = ">= 2.2.0 and < 3.0.0" } 19 | snag = { version = "~> 1.0" } 20 | -------------------------------------------------------------------------------- /static/css/theme.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Derives app colors for both dark & light themes from common.css variables 4 | 5 | */ 6 | 7 | :root { 8 | --hot-pink: #d900b8; 9 | --light-pink: #fedcfb; 10 | --gray-light: #dfdfdf; 11 | 12 | --drop-shadow: 0 0 var(--gap-quarter) var(--color-background), 13 | var(--gap) var(--gap) 0 var(--color-drop-shadow), 14 | inset 0 0 0 1px var(--color-accent-muted); 15 | 16 | --color-navbar-background: var(--faff-pink); 17 | --color-navbar-text: var(--light-theme-text); 18 | --color-navbar-link: var(--light-theme-text); 19 | 20 | --color-accent: var(--faff-pink); 21 | --color-accent-light: var(--light-pink); 22 | --color-accent-hot: var(--hot-pink); 23 | --color-accent-dark: var(--unexpected-aubergine); 24 | } 25 | 26 | html.theme-light { 27 | --color-background: var(--light-theme-background); 28 | --color-background-dim: var(--light-theme-background-dim); 29 | --color-text: var(--light-theme-text); 30 | --color-text-secondary: var(--light-theme-text-secondary); 31 | --color-link: var(--light-theme-text); 32 | --color-link-decoration: var(--faff-pink); 33 | --color-code: var(--light-theme-code); 34 | --color-divider: var(--faff-pink); 35 | --color-drop-shadow: var(--gray-light); 36 | --color-accent-muted: var(--color-accent-light); 37 | --color-text-accent: var(--color-accent-dark); 38 | color-scheme: light; 39 | } 40 | 41 | html.theme-dark { 42 | --color-background: var(--dark-theme-background); 43 | --color-background-dim: var(--dark-theme-background-dim); 44 | --color-text: var(--dark-theme-text); 45 | --color-text-secondary: var(--dark-theme-text-secondary); 46 | --color-link: var(--dark-theme-text); 47 | --color-link-decoration: var(--faff-pink); 48 | --color-code: var(--dark-theme-code); 49 | --color-divider: var(--unexpected-aubergine); 50 | --color-drop-shadow: var(--color-background-dim); 51 | --color-accent-muted: var(--color-accent-dark); 52 | --color-text-accent: var(--color-accent-light); 53 | color-scheme: dark; 54 | } 55 | 56 | -------------------------------------------------------------------------------- /static/compiler.js: -------------------------------------------------------------------------------- 1 | let compiler; 2 | 3 | export default async function initGleamCompiler() { 4 | const wasm = await import("./compiler/gleam_wasm.js"); 5 | await wasm.default(); 6 | wasm.initialise_panic_hook(); 7 | if (!compiler) { 8 | compiler = new Compiler(wasm); 9 | } 10 | return compiler; 11 | } 12 | 13 | class Compiler { 14 | #wasm; 15 | #nextId = 0; 16 | #projects = new Map(); 17 | 18 | constructor(wasm) { 19 | this.#wasm = wasm; 20 | } 21 | 22 | get wasm() { 23 | return this.#wasm; 24 | } 25 | 26 | newProject() { 27 | const id = this.#nextId++; 28 | const project = new Project(id); 29 | this.#projects.set(id, new WeakRef(project)); 30 | return project; 31 | } 32 | 33 | garbageCollectProjects() { 34 | const gone = []; 35 | for (const [id, project] of this.#projects) { 36 | if (!project.deref()) gone.push(id); 37 | } 38 | for (const id of gone) { 39 | this.#projects.delete(id); 40 | this.#wasm.delete_project(id); 41 | } 42 | } 43 | } 44 | 45 | class Project { 46 | #id; 47 | 48 | constructor(id) { 49 | this.#id = id; 50 | } 51 | 52 | get projectId() { 53 | return this.#id; 54 | } 55 | 56 | writeModule(moduleName, code) { 57 | compiler.wasm.write_module(this.#id, moduleName, code); 58 | } 59 | 60 | compilePackage(target) { 61 | compiler.garbageCollectProjects(); 62 | compiler.wasm.reset_warnings(this.#id); 63 | compiler.wasm.compile_package(this.#id, target); 64 | } 65 | 66 | readCompiledJavaScript(moduleName) { 67 | return compiler.wasm.read_compiled_javascript(this.#id, moduleName); 68 | } 69 | 70 | readCompiledErlang(moduleName) { 71 | return compiler.wasm.read_compiled_erlang(this.#id, moduleName); 72 | } 73 | 74 | resetFilesystem() { 75 | compiler.wasm.reset_filesystem(this.#id); 76 | } 77 | 78 | delete() { 79 | compiler.wasm.delete_project(this.#id); 80 | } 81 | 82 | takeWarnings() { 83 | const warnings = []; 84 | while (true) { 85 | const warning = compiler.wasm.pop_warning(this.#id); 86 | if (!warning) return warnings; 87 | warnings.push(warning.trimStart()); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/playground/html.gleam: -------------------------------------------------------------------------------- 1 | //// Generic HTML rendering utils 2 | 3 | import gleam/list 4 | import gleam/string_tree 5 | import htmb.{type Html, h, text} 6 | 7 | pub type HtmlAttribute = 8 | #(String, String) 9 | 10 | pub type ScriptOptions { 11 | ScriptOptions(module: Bool, defer: Bool) 12 | } 13 | 14 | /// Formats js script options into usage html attributes 15 | fn html_script_common_attributes( 16 | attributes: ScriptOptions, 17 | ) -> List(HtmlAttribute) { 18 | let type_attr = #("type", case attributes.module { 19 | True -> "module" 20 | _ -> "text/javascript" 21 | }) 22 | let defer_attr = #("defer", "") 23 | 24 | case attributes.defer { 25 | True -> [defer_attr, type_attr] 26 | _ -> [type_attr] 27 | } 28 | } 29 | 30 | /// Renders an HTML script tag 31 | pub fn html_script( 32 | src source: String, 33 | options attributes: ScriptOptions, 34 | attributes additional_attributes: List(HtmlAttribute), 35 | ) -> Html { 36 | let attrs = { 37 | let src_attr = #("src", source) 38 | let base_attrs = [src_attr, ..html_script_common_attributes(attributes)] 39 | list.flatten([base_attrs, additional_attributes]) 40 | } 41 | h("script", attrs, []) 42 | } 43 | 44 | /// Renders an inline HTML script tag 45 | pub fn html_dangerous_inline_script( 46 | script content: String, 47 | options attributes: ScriptOptions, 48 | attributes additional_attributes: List(HtmlAttribute), 49 | ) -> Html { 50 | let attrs = { 51 | list.flatten([ 52 | html_script_common_attributes(attributes), 53 | additional_attributes, 54 | ]) 55 | } 56 | h("script", attrs, [ 57 | htmb.dangerous_unescaped_fragment(string_tree.from_string(content)), 58 | ]) 59 | } 60 | 61 | /// Renders an HTML meta tag 62 | pub fn html_meta(data attributes: List(HtmlAttribute)) -> Html { 63 | h("meta", attributes, []) 64 | } 65 | 66 | /// Renders an HTML meta property tag 67 | pub fn html_meta_prop(property: String, content: String) -> Html { 68 | html_meta([#("property", property), #("content", content)]) 69 | } 70 | 71 | /// Renders an HTML link tag 72 | pub fn html_link(rel: String, href: String) -> Html { 73 | h("link", [#("rel", rel), #("href", href)], []) 74 | } 75 | 76 | /// Renders a stylesheet link tag 77 | pub fn html_stylesheet(src: String) -> Html { 78 | html_link("stylesheet", src) 79 | } 80 | 81 | /// Renders an HTML title tag 82 | pub fn html_title(title: String) -> Html { 83 | h("title", [], [text(title)]) 84 | } 85 | -------------------------------------------------------------------------------- /static/css/code/color-schemes/atom-one.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Atom One Dark & Light by Daniel Gamage 4 | Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax 5 | 6 | Tweaked for compat with light / dark themes 7 | 8 | */ 9 | 10 | :root { 11 | /* 12 | Atom One Dark 13 | 14 | base: #282c34 15 | mono-1: #abb2bf 16 | mono-2: #818896 17 | mono-3: #5c6370 18 | hue-1: #56b6c2 19 | hue-2: #61aeee 20 | hue-3: #c678dd 21 | hue-4: #98c379 22 | hue-5: #e06c75 23 | hue-5-2: #be5046 24 | hue-6: #d19a66 25 | hue-6-2: #e6c07b 26 | */ 27 | 28 | --code-background-dark: #282c34; /* base */ 29 | --code-token-base-dark: #abb2bf; /* mono-1 */ 30 | --code-token-punctuation-dark: #818896; /* mono-2 */ 31 | --code-token-operator-dark: #c678dd; /* hue-3 */ 32 | --code-token-keyword-dark: #c678dd; /* hue-3 */ 33 | --code-token-string-dark: #98c379; /* hue-4 */ 34 | --code-token-comment-dark: #5c6370; /* mono-3 */ 35 | --code-token-attribute-dark: #818896; /* mono-2 */ 36 | --code-token-function-dark: #61aeee; /* hue-2 */ 37 | --code-token-function-name-dark: #61aeee; /* hue-2 */ 38 | --code-token-function-param-dark: #abb2bf; /* mono-1 */ 39 | --code-token-boolean-dark: #d19a66; /* hue-6 */ 40 | --code-token-number-dark: #d19a66; /* hue-6 */ 41 | --code-token-selector-dark: #e6c07b; /* hue-6-2 */ 42 | --code-token-type-dark: #56b6c2; /* hue-1 */ 43 | 44 | /* 45 | Atom One Light 46 | 47 | base: #fafafa 48 | mono-1: #383a42 49 | mono-2: #686b77 50 | mono-3: #a0a1a7 51 | hue-1: #0184bb 52 | hue-2: #4078f2 53 | hue-3: #a626a4 54 | hue-4: #50a14f 55 | hue-5: #e45649 56 | hue-5-2: #c91243 57 | hue-6: #986801 58 | hue-6-2: #c18401 59 | */ 60 | 61 | --code-background-light: #fafafa; /* base */ 62 | --code-token-base-light: #383a42; /* mono-1 */ 63 | --code-token-punctuation-light: #686b77; /* mono-2 */ 64 | --code-token-operator-light: #a626a4; /* hue-3 */ 65 | --code-token-keyword-light: #a626a4; /* hue-3 */ 66 | --code-token-string-light: #50a14f; /* hue-4 */ 67 | --code-token-comment-light: #a0a1a7; /* mono-3 */ 68 | --code-token-attribute-light: #686b77; /* mono-2 */ 69 | --code-token-function-light: #4078f2; /* hue-2 */ 70 | --code-token-function-name-light: #4078f2; /* hue-2 */ 71 | --code-token-function-param-light: #383a42; /* mono-1 */ 72 | --code-token-boolean-light: #986801; /* hue-6 */ 73 | --code-token-number-light: #986801; /* hue-6 */ 74 | --code-token-selector-light: #c18401; /* hue-6-2 */ 75 | --code-token-type-light: #0184bb; /* hue-1 */ 76 | } -------------------------------------------------------------------------------- /static/worker.js: -------------------------------------------------------------------------------- 1 | import initGleamCompiler from "./compiler.js"; 2 | import stdlib from "./stdlib.js"; 3 | 4 | const compiler = await initGleamCompiler(); 5 | const project = compiler.newProject(); 6 | 7 | for (const [name, code] of Object.entries(stdlib)) { 8 | project.writeModule(name, code); 9 | } 10 | 11 | // Monkey patch console.log to keep a copy of the output 12 | let logged = ""; 13 | const log = console.log; 14 | console.log = (...args) => { 15 | log(...args); 16 | logged += args.map((e) => `${e}`).join(" ") + "\n"; 17 | }; 18 | 19 | async function loadProgram(js) { 20 | // URL to worker.js ('base/worker.js') 21 | const url = new URL(import.meta.url); 22 | // Remove 'worker.js', keep just 'base/' 23 | url.pathname = url.pathname.substring(0, url.pathname.lastIndexOf("/") + 1); 24 | url.hash = ""; 25 | url.search = ""; 26 | const href = url.toString(); 27 | let editedJs = js; 28 | // If echo has been used then add the import to the dict module that the 29 | // compiler would have added if the stdlib had have been present. 30 | if (editedJs.includes("return value instanceof $stdlib$dict.default;")) { 31 | editedJs = 'import * as $stdlib$dict from "./dict.mjs";\n' + js; 32 | } 33 | // Rewrite the stdlib imports to work in this context 34 | editedJs = editedJs.replaceAll( 35 | /from\s+"\.\/(.+)"/g, 36 | `from "${href}precompiled/$1"`, 37 | ); 38 | // Evaluate the code 39 | const encoded = btoa(unescape(encodeURIComponent(editedJs))); 40 | const module = await import("data:text/javascript;base64," + encoded); 41 | return module.main; 42 | } 43 | 44 | async function compileEval(code) { 45 | logged = ""; 46 | const result = { 47 | log: null, 48 | js: null, 49 | erlang: null, 50 | error: null, 51 | warnings: [], 52 | }; 53 | 54 | try { 55 | project.writeModule("main", code); 56 | project.compilePackage("javascript"); 57 | result.js = project.readCompiledJavaScript("main"); 58 | project.compilePackage("erlang"); 59 | result.erlang = project.readCompiledErlang("main"); 60 | const main = await loadProgram(result.js); 61 | if (main) main(); 62 | } catch (error) { 63 | console.error(error); 64 | result.error = error.toString(); 65 | } 66 | for (const warning of project.takeWarnings()) { 67 | result.warnings.push(warning); 68 | } 69 | result.log = logged; 70 | 71 | return result; 72 | } 73 | 74 | self.onmessage = async (event) => { 75 | const result = compileEval(event.data); 76 | postMessage(await result); 77 | }; 78 | 79 | // Send an initial message to the main thread to indicate that the worker is 80 | // ready to receive messages. 81 | postMessage({}); 82 | -------------------------------------------------------------------------------- /static/css/root.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Common page styles 3 | * 4 | * used by all pages to ensure consistent styling of common elements 5 | */ 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | padding: 0; 14 | height: 100vh; 15 | display: flex; 16 | flex-direction: column; 17 | background-color: var(--color-background); 18 | font-family: var(--font-family-normal); 19 | letter-spacing: 0.01em; 20 | color: var(--color-text); 21 | } 22 | 23 | .codeflask__textarea, 24 | pre, 25 | code { 26 | font-weight: normal; 27 | letter-spacing: initial; 28 | } 29 | 30 | p code { 31 | padding: 1px 2px; 32 | color: var(--color-code); 33 | background-color: var(--color-background-dim); 34 | } 35 | 36 | h1, 37 | h2, 38 | h3, 39 | h4, 40 | h5, 41 | h6 { 42 | font-family: var(--font-family-title); 43 | font-weight: normal; 44 | } 45 | 46 | a { 47 | color: var(--color-link); 48 | text-decoration-color: var(--color-link-decoration); 49 | } 50 | 51 | /* 52 | * Nav bar & Nav links 53 | */ 54 | 55 | .navbar { 56 | display: flex; 57 | justify-content: space-between; 58 | align-items: center; 59 | height: var(--navbar-height); 60 | min-height: var(--navbar-height); 61 | padding: var(--gap); 62 | background: var(--color-navbar-background); 63 | color: var(--color-navbar-text); 64 | box-shadow: 0 0 5px 5px rgba(0, 0, 0, 0.1); 65 | } 66 | 67 | .navbar .logo { 68 | display: flex; 69 | align-items: center; 70 | } 71 | 72 | .navbar .logo img { 73 | display: inline-block; 74 | height: 2em; 75 | transform: rotate(-10deg); 76 | margin-right: 0.5em; 77 | } 78 | 79 | .navbar .version-number { 80 | padding-left: 8px; 81 | padding-top: 3px; 82 | font-size: var(--font-size-extra-small); 83 | opacity: 0.8; 84 | } 85 | 86 | .navbar a:visited, 87 | .navbar a { 88 | text-decoration: none; 89 | color: var(--color-navbar-link); 90 | } 91 | 92 | .navbar .nav-right { 93 | display: flex; 94 | align-items: center; 95 | gap: var(--gap-double); 96 | } 97 | 98 | /* 99 | * Theme toggle button 100 | */ 101 | 102 | html.theme-dark .theme-button.-dark { 103 | display: none; 104 | } 105 | 106 | html.theme-light .theme-button.-light { 107 | display: none; 108 | } 109 | 110 | .theme-button { 111 | appearance: none; 112 | margin: 0; 113 | border: 0; 114 | padding: 0; 115 | background: none; 116 | color: inherit; 117 | display: flex; 118 | gap: 0.25em; 119 | font-size: inherit; 120 | color: inherit; 121 | cursor: pointer; 122 | } 123 | 124 | .theme-button svg { 125 | display: inline-block; 126 | fill: currentColor; 127 | height: 1em; 128 | width: 1em; 129 | } 130 | 131 | /* 132 | * utility classes 133 | */ 134 | 135 | /* 136 | * dims the background of any element it its applied to 137 | */ 138 | 139 | .dim-bg { 140 | position: relative; 141 | } 142 | 143 | .dim-bg * { 144 | z-index: 1; 145 | position: relative; 146 | } 147 | 148 | .dim-bg::before { 149 | content: ""; 150 | position: absolute; 151 | inset: 0; 152 | background: inherit; 153 | filter: brightness(0.4) saturate(1.3); 154 | z-index: 0; 155 | opacity: 0.3; 156 | } 157 | 158 | .theme-light .dim-bg::before { 159 | filter: brightness(0.8) saturate(1.3); 160 | } 161 | 162 | /* 163 | * highlights an element (usually a link) on hover 164 | */ 165 | 166 | .link { 167 | color: var(--color-text-accent); 168 | text-decoration: underline; 169 | text-decoration-color: var(--color-accent-muted); 170 | } 171 | 172 | .link.padded, 173 | .navbar .link { 174 | padding: calc(var(--gap-quarter) * 0.5) var(--gap-quarter) 0; 175 | } 176 | -------------------------------------------------------------------------------- /static/css/pages/playground.css: -------------------------------------------------------------------------------- 1 | .output > *, 2 | #editor .codeflask__flatten { 3 | padding: var(--gap); 4 | } 5 | 6 | #playground-container { 7 | display: flex; 8 | height: calc(100dvh - var(--navbar-height)); 9 | } 10 | 11 | #playground { 12 | display: flex; 13 | flex-direction: column; 14 | border: var(--color-divider); 15 | flex-grow: 1; 16 | min-height: 100%; 17 | width: 100%; 18 | } 19 | 20 | #playground-content { 21 | display: flex; 22 | flex-direction: column; 23 | border: var(--color-divider); 24 | background: var(--code-background); 25 | flex-grow: 1; 26 | height: 100%; 27 | } 28 | 29 | #tabs { 30 | display: flex; 31 | gap: var(--gap); 32 | border-bottom: 1px solid var(--color-accent-muted); 33 | align-items: end; 34 | height: fit-content; 35 | } 36 | 37 | .tab { 38 | padding: var(--gap); 39 | padding-bottom: calc(var(--gap) - 2px); 40 | cursor: pointer; 41 | height: fit-content; 42 | border-bottom: 2px solid transparent; 43 | } 44 | 45 | .tab:has(input[type="radio"]:checked) { 46 | border-bottom: 2px solid var(--color-accent-muted); 47 | } 48 | 49 | .tab > p { 50 | margin: 0; 51 | } 52 | 53 | #output-container, 54 | #editor { 55 | border-top: 1px solid var(--color-accent-muted); 56 | } 57 | 58 | #editor { 59 | position: relative; 60 | overflow: clip; 61 | flex-grow: 1; 62 | } 63 | 64 | #output-container { 65 | height: 30dvh; 66 | background: var(--color-background-dim); 67 | } 68 | 69 | .output { 70 | /* Only display if radio is checked */ 71 | display: none; 72 | max-height: calc(100% - 4 * var(--gap)); 73 | overflow: auto; 74 | } 75 | 76 | .output > * { 77 | margin: 0; 78 | white-space: pre-wrap; 79 | } 80 | 81 | .output > pre { 82 | background: none !important; 83 | } 84 | 85 | #output-container:has(#output-radio:checked) > #output { 86 | display: block; 87 | } 88 | 89 | #output-container:has(#compiled-javascript-radio:checked) 90 | > #compiled-javascript { 91 | display: block; 92 | } 93 | 94 | #output-container:has(#compiled-erlang-radio:checked) > #compiled-erlang { 95 | display: block; 96 | } 97 | 98 | #share-button { 99 | padding: var(--gap-half) var(--gap); 100 | border: none; 101 | color: var(--color-text); 102 | background-color: var(--color-background); 103 | border-radius: 0.5rem; 104 | font-family: var(--font-family-normal); 105 | cursor: pointer; 106 | align-self: center; 107 | margin-left: auto; 108 | margin-right: var(--gap); 109 | } 110 | 111 | /* Larger than mobile */ 112 | @media (min-width: 768px) { 113 | #playground-content { 114 | border-left: 1px solid var(--color-accent-muted); 115 | flex-direction: row; 116 | } 117 | 118 | #editor { 119 | border: none; 120 | } 121 | 122 | #output-container { 123 | height: unset; 124 | width: 50%; 125 | overflow: auto; 126 | border: none; 127 | border-left: 1px solid var(--color-accent-muted); 128 | } 129 | } 130 | 131 | /* Larger than medium screen and has enough to height to not worry about losing vertical space */ 132 | @media (min-width: 1200px) and (min-height: 700px) { 133 | #playground-container { 134 | /* Use calc here to add additional padding dynamically to allow for the drop shadow */ 135 | padding-top: calc(var(--gap) * 2); 136 | padding-right: calc(var(--gap) * 3); 137 | padding-bottom: calc(var(--gap) * 3); 138 | padding-left: calc(var(--gap) * 2); 139 | } 140 | 141 | #playground { 142 | border-radius: var(--border-radius); 143 | padding: 2px 1px; 144 | box-shadow: var(--drop-shadow); 145 | } 146 | 147 | #playground-content { 148 | border-left: unset; 149 | } 150 | 151 | #output-container { 152 | width: 40%; 153 | } 154 | } 155 | 156 | .error, 157 | .warning { 158 | border-style: solid; 159 | height: 100%; 160 | } 161 | 162 | .error { 163 | border-color: var(--brand-error); 164 | } 165 | 166 | .warning { 167 | border-color: var(--brand-warning); 168 | } 169 | 170 | .prev-next { 171 | display: flex; 172 | justify-content: center; 173 | align-items: center; 174 | padding: 0 var(--gap); 175 | gap: 0.5em; 176 | } 177 | 178 | .prev-next span { 179 | opacity: 0.5; 180 | } 181 | 182 | .mb-0 { 183 | margin-bottom: 0; 184 | } 185 | -------------------------------------------------------------------------------- /static/css/code/syntax-highlight.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Swiches between code colors based on theme 4 | and binds color scheme values to highlightJS & CodeFlask 5 | defaults to light theme. 6 | 7 | */ 8 | 9 | :root { 10 | --code-background: var(--code-background-light); 11 | --code-token-base: var(--code-token-base-light); 12 | --code-token-punctuation: var(--code-token-punctuation-light); 13 | --code-token-operator: var(--code-token-operator-light); 14 | --code-token-keyword: var(--code-token-keyword-light); 15 | --code-token-boolean: var(--code-token-boolean-light); 16 | --code-token-number: var(--code-token-number-light); 17 | --code-token-type: var(--code-token-type-light); 18 | --code-token-function-name: var(--code-token-function-name-light); 19 | --code-token-function-param: var(--code-token-function-param-light); 20 | --code-token-attribute: var(--code-token-attribute-light); 21 | --code-token-string: var(--code-token-string-light); 22 | --code-token-function: var(--code-token-function-light); 23 | --code-token-comment: var(--code-token-comment-light); 24 | } 25 | 26 | html.theme-light { 27 | --code-background: var(--code-background-light); 28 | --code-token-base: var(--code-token-base-light); 29 | --code-token-punctuation: var(--code-token-punctuation-light); 30 | --code-token-operator: var(--code-token-operator-light); 31 | --code-token-keyword: var(--code-token-keyword-light); 32 | --code-token-boolean: var(--code-token-boolean-light); 33 | --code-token-number: var(--code-token-number-light); 34 | --code-token-type: var(--code-token-type-light); 35 | --code-token-function-name: var(--code-token-function-name-light); 36 | --code-token-function-param: var(--code-token-function-param-light); 37 | --code-token-attribute: var(--code-token-attribute-light); 38 | --code-token-string: var(--code-token-string-light); 39 | --code-token-function: var(--code-token-function-light); 40 | --code-token-comment: var(--code-token-comment-light); 41 | } 42 | 43 | html.theme-dark { 44 | --code-background: var(--code-background-dark); 45 | --code-token-base: var(--code-token-base-dark); 46 | --code-token-punctuation: var(--code-token-punctuation-dark); 47 | --code-token-operator: var(--code-token-operator-dark); 48 | --code-token-keyword: var(--code-token-keyword-dark); 49 | --code-token-boolean: var(--code-token-boolean-dark); 50 | --code-token-number: var(--code-token-number-dark); 51 | --code-token-type: var(--code-token-type-dark); 52 | --code-token-function-name: var(--code-token-function-name-dark); 53 | --code-token-function-param: var(--code-token-function-param-dark); 54 | --code-token-attribute: var(--code-token-attribute-dark); 55 | --code-token-string: var(--code-token-string-dark); 56 | --code-token-function: var(--code-token-function-dark); 57 | --code-token-comment: var(--code-token-comment-dark); 58 | } 59 | 60 | /* 61 | 62 | highlightJS mappings 63 | 64 | */ 65 | 66 | pre.hljs { 67 | background: var(--code-background); 68 | } 69 | .hljs { 70 | color: var(--color-token-base); 71 | } 72 | .hljs-punctuation { 73 | /* and operators */ 74 | color: var(--code-token-punctuation); 75 | } 76 | .hljs-variable, 77 | .hljs-name { 78 | color: var(--code-token-base); 79 | } 80 | .hljs-function-param { 81 | font-weight: bold; 82 | font-style: italic; 83 | color: var(--color-token-base); 84 | } 85 | .hljs-operator { 86 | color: var(--code-token-operator); 87 | } 88 | .hljs-keyword { 89 | color: var(--code-token-keyword); 90 | } 91 | .hljs-boolean { 92 | color: var(--code-token-boolean); 93 | } 94 | .hljs-number { 95 | color: var(--code-token-number); 96 | } 97 | .hljs-type { 98 | color: var(--code-token-type); 99 | } 100 | .hljs-function.function-name { 101 | color: var(--code-token-function-name); 102 | } 103 | .hljs-function.function-call { 104 | font-style: italic; 105 | } 106 | .hljs-function.function-params { 107 | color: var(--code-token-function); 108 | } 109 | .hljs-attribute { 110 | color: var(--code-token-attribute); 111 | font-style: italic; 112 | } 113 | .hljs-string { 114 | color: var(--code-token-string); 115 | } 116 | .hljs-comment { 117 | color: var(--code-token-comment); 118 | font-style: italic; 119 | } 120 | /* 121 | .hljs-symbol, 122 | .hljs-bullet, 123 | .hljs-link, 124 | .hljs-meta, 125 | .hljs-selector-id, 126 | .hljs-title { 127 | color: var(--code-token-function); 128 | } 129 | .hljs-built_in, 130 | .hljs-title.class_, 131 | .hljs-class .hljs-title { 132 | color: var(--code-token-function); 133 | } */ 134 | .hljs-emphasis { 135 | font-style: italic; 136 | } 137 | .hljs-strong { 138 | font-weight: bold; 139 | } 140 | .hljs-link { 141 | text-decoration: underline; 142 | } 143 | 144 | /* 145 | 146 | CodeFlask mappings 147 | 148 | */ 149 | 150 | .codeflask .codeflask__textarea { 151 | color: var(--code-background); /* Prevents rendering artifacts in dark mode */ 152 | caret-color: var( 153 | --code-token-base 154 | ); /* Makes the text input cursor visible in dark mode */ 155 | } 156 | 157 | .codeflask { 158 | background: var(--code-background); 159 | color: var(--code-token-base); 160 | } 161 | .codeflask .token.punctuation { 162 | color: var(--code-token-punctuation); 163 | } 164 | .codeflask .token.keyword { 165 | color: var(--code-token-keyword); 166 | } 167 | .codeflask .token.operator { 168 | color: var(--code-token-operator); 169 | } 170 | .codeflask .token.string { 171 | color: var(--code-token-string); 172 | } 173 | .codeflask .token.comment { 174 | color: var(--code-token-comment); 175 | } 176 | .codeflask .token.function { 177 | color: var(--code-token-function); 178 | } 179 | .codeflask .token.boolean { 180 | color: var(--code-token-boolean); 181 | } 182 | .codeflask .token.number { 183 | color: var(--code-token-number); 184 | } 185 | .codeflask .token.selector { 186 | color: var(--code-token-selector); 187 | } 188 | .codeflask .token.property { 189 | color: var(--code-token-property); 190 | } 191 | .codeflask .token.tag { 192 | color: var(--code-token-tag); 193 | } 194 | .codeflask .token.attr-value { 195 | color: var(--code-token-attr-value); 196 | } 197 | -------------------------------------------------------------------------------- /static/index.js: -------------------------------------------------------------------------------- 1 | import CodeFlask from "https://cdn.jsdelivr.net/npm/codeflask@1.4.1/+esm"; 2 | import hljs from "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/es/highlight.min.js"; 3 | import js from "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/es/languages/javascript.min.js"; 4 | import erlang from "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/es/languages/erlang.min.js"; 5 | import lz from "https://cdn.jsdelivr.net/npm/lz-string@1.5.0/+esm"; 6 | 7 | globalThis.CodeFlask = CodeFlask; 8 | globalThis.hljs = hljs; 9 | 10 | hljs.registerLanguage("javascript", js); 11 | hljs.registerLanguage("erlang", erlang); 12 | 13 | const outputEl = document.querySelector("#output"); 14 | const compiledJavascriptEl = document.querySelector("#compiled-javascript"); 15 | const compiledErlangEl = document.querySelector("#compiled-erlang"); 16 | const initialCode = document.querySelector("#code").innerHTML; 17 | 18 | const prismGrammar = { 19 | comment: { 20 | pattern: /\/\/.*/, 21 | greedy: true, 22 | }, 23 | function: /([a-z_][a-z0-9_]+)(?=\()/, 24 | keyword: 25 | /\b(use|case|if|@external|@deprecated|fn|import|let|assert|try|pub|type|opaque|const|panic|todo|as|echo)\b/, 26 | symbol: { 27 | pattern: /([A-Z][A-Za-z0-9_]+)/, 28 | greedy: true, 29 | }, 30 | operator: { 31 | pattern: 32 | /(<<|>>|<-|->|\|>|<>|\.\.|<=\.?|>=\.?|==\.?|!=\.?|<\.?|>\.?|&&|\|\||\+\.?|-\.?|\/\.?|\*\.?|%\.?|=)/, 33 | greedy: true, 34 | }, 35 | string: { 36 | pattern: /"((?:[^"\\]|\\.)*)"/, 37 | greedy: true, 38 | }, 39 | module: { 40 | pattern: /([a-z][a-z0-9_]*)\./, 41 | inside: { 42 | punctuation: /\./, 43 | }, 44 | alias: "keyword", 45 | }, 46 | punctuation: /[.\\:,{}()]/, 47 | number: 48 | /\b(?:0b[0-1]+|0o[0-7]+|[[:digit:]][[:digit:]_]*(\\.[[:digit:]]*)?|0x[[:xdigit:]]+)\b/, 49 | }; 50 | 51 | function clearElement(target) { 52 | while (target.firstChild) { 53 | target.removeChild(target.firstChild); 54 | } 55 | } 56 | 57 | function appendCode(target, content, className) { 58 | if (!content) return; 59 | const element = document.createElement("pre"); 60 | const code = document.createElement("code"); 61 | code.textContent = content; 62 | element.appendChild(code); 63 | element.className = className; 64 | target.appendChild(element); 65 | } 66 | 67 | function highlightOutput(target, childClassName) { 68 | // Disable annoying warnings from hljs 69 | const warn = console.warn; 70 | console.warn = () => {}; 71 | target.querySelectorAll(`.${childClassName}`).forEach((element) => { 72 | hljs.highlightElement(element); 73 | }); 74 | console.warn = warn; 75 | } 76 | 77 | const editor = new CodeFlask("#editor-target", { 78 | language: "gleam", 79 | defaultTheme: false, 80 | }); 81 | editor.addLanguage("gleam", prismGrammar); 82 | editor.updateCode(initialCode); 83 | 84 | function debounce(fn, delay) { 85 | let timer = null; 86 | return (...args) => { 87 | clearTimeout(timer); 88 | timer = setTimeout(() => fn(...args), delay); 89 | }; 90 | } 91 | 92 | // Whether the worker is currently working or not, used to avoid sending 93 | // multiple messages to the worker at once. 94 | // This will be true when the worker is compiling and executing the code, but 95 | // this first time it is as the worker is initialising. 96 | let workerWorking = true; 97 | let queuedWork = undefined; 98 | const worker = new Worker("worker.js", { type: "module" }); 99 | 100 | function sendToWorker(code) { 101 | if (workerWorking) { 102 | queuedWork = code; 103 | return; 104 | } 105 | workerWorking = true; 106 | worker.postMessage(code); 107 | } 108 | 109 | worker.onmessage = (event) => { 110 | // Handle the result of the compilation and execution 111 | const result = event.data; 112 | clearElement(outputEl); 113 | clearElement(compiledJavascriptEl); 114 | clearElement(compiledErlangEl); 115 | if (result.log) { 116 | appendCode(outputEl, result.log, "log"); 117 | } 118 | if (result.error) { 119 | appendCode(outputEl, result.error, "error"); 120 | } 121 | if (result.js) { 122 | appendCode(compiledJavascriptEl, result.js, "javascript"); 123 | } 124 | if (result.erlang) { 125 | appendCode(compiledErlangEl, result.erlang, "erlang"); 126 | } 127 | for (const warning of result.warnings || []) { 128 | appendCode(outputEl, warning, "warning"); 129 | } 130 | 131 | highlightOutput(compiledJavascriptEl, "javascript"); 132 | highlightOutput(compiledErlangEl, "erlang"); 133 | 134 | // Deal with any queued work 135 | workerWorking = false; 136 | if (queuedWork) sendToWorker(queuedWork); 137 | queuedWork = undefined; 138 | }; 139 | 140 | editor.onUpdate(debounce((code) => sendToWorker(code), 200)); 141 | 142 | /** 143 | * Hashed object format: 144 | * { 145 | * version: 1, 146 | * content: "code" 147 | * } 148 | */ 149 | function makeV1Hash(code) { 150 | return lz.compressToBase64( 151 | JSON.stringify({ 152 | version: 1, 153 | content: code, 154 | }), 155 | ); 156 | } 157 | 158 | function parseV1Hash(obj) { 159 | if (obj.version !== 1) { 160 | throw new Error("Unsupported version"); 161 | } 162 | return obj.content; 163 | } 164 | 165 | function parseHash(hash) { 166 | let obj; 167 | try { 168 | obj = JSON.parse(lz.decompressFromBase64(hash)); 169 | } catch (e) { 170 | return null; 171 | } 172 | if (!obj) { 173 | return null; 174 | } 175 | switch (obj.version) { 176 | case 1: 177 | return parseV1Hash(obj); 178 | } 179 | return null; 180 | } 181 | 182 | if (window.location.hash) { 183 | const hash = window.location.hash.slice(1); 184 | const code = parseHash(hash); 185 | if (code) { 186 | editor.updateCode(code); 187 | } 188 | } 189 | 190 | const shareButton = document.querySelector("#share-button"); 191 | 192 | function share() { 193 | const code = editor.getCode(); 194 | const compressed = makeV1Hash(code); 195 | const url = `${window.location.origin}${window.location.pathname}#${compressed}`; 196 | navigator.clipboard.writeText(url); 197 | const before = shareButton.textContent; 198 | shareButton.textContent = "Link copied!"; 199 | setTimeout(() => { 200 | shareButton.textContent = before; 201 | }, 1000); 202 | } 203 | shareButton.addEventListener("click", share); 204 | -------------------------------------------------------------------------------- /src/playground/widgets.gleam: -------------------------------------------------------------------------------- 1 | import htmb.{type Html, h, text} 2 | 3 | pub fn icon_moon() -> Html { 4 | h("svg", [#("id", "icon-moon"), #("viewBox", "0 0 24 24")], [ 5 | h( 6 | "path", 7 | [ 8 | #( 9 | "d", 10 | "M21.996 12.882c0.022-0.233-0.038-0.476-0.188-0.681-0.325-0.446-0.951-0.544-1.397-0.219-0.95 0.693-2.060 1.086-3.188 1.162-1.368 0.092-2.765-0.283-3.95-1.158-1.333-0.985-2.139-2.415-2.367-3.935s0.124-3.124 1.109-4.456c0.142-0.191 0.216-0.435 0.191-0.691-0.053-0.55-0.542-0.952-1.092-0.898-2.258 0.22-4.314 1.18-5.895 2.651-1.736 1.615-2.902 3.847-3.137 6.386-0.254 2.749 0.631 5.343 2.266 7.311s4.022 3.313 6.772 3.567 5.343-0.631 7.311-2.266 3.313-4.022 3.567-6.772zM19.567 14.674c-0.49 1.363-1.335 2.543-2.416 3.441-1.576 1.309-3.648 2.016-5.848 1.813s-4.108-1.278-5.417-2.854-2.016-3.648-1.813-5.848c0.187-2.032 1.117-3.814 2.507-5.106 0.782-0.728 1.71-1.3 2.731-1.672-0.456 1.264-0.577 2.606-0.384 3.899 0.303 2.023 1.38 3.934 3.156 5.247 1.578 1.167 3.448 1.668 5.272 1.545 0.752-0.050 1.496-0.207 2.21-0.465z", 11 | ), 12 | ], 13 | [], 14 | ), 15 | ]) 16 | } 17 | 18 | pub fn icon_sun() -> Html { 19 | h("svg", [#("id", "icon-sun"), #("viewBox", "0 0 24 24")], [ 20 | h( 21 | "path", 22 | [ 23 | #( 24 | "d", 25 | "M18 12c0-1.657-0.673-3.158-1.757-4.243s-2.586-1.757-4.243-1.757-3.158 0.673-4.243 1.757-1.757 2.586-1.757 4.243 0.673 3.158 1.757 4.243 2.586 1.757 4.243 1.757 3.158-0.673 4.243-1.757 1.757-2.586 1.757-4.243zM16 12c0 1.105-0.447 2.103-1.172 2.828s-1.723 1.172-2.828 1.172-2.103-0.447-2.828-1.172-1.172-1.723-1.172-2.828 0.447-2.103 1.172-2.828 1.723-1.172 2.828-1.172 2.103 0.447 2.828 1.172 1.172 1.723 1.172 2.828zM11 1v2c0 0.552 0.448 1 1 1s1-0.448 1-1v-2c0-0.552-0.448-1-1-1s-1 0.448-1 1zM11 21v2c0 0.552 0.448 1 1 1s1-0.448 1-1v-2c0-0.552-0.448-1-1-1s-1 0.448-1 1zM3.513 4.927l1.42 1.42c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414l-1.42-1.42c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414zM17.653 19.067l1.42 1.42c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414l-1.42-1.42c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414zM1 13h2c0.552 0 1-0.448 1-1s-0.448-1-1-1h-2c-0.552 0-1 0.448-1 1s0.448 1 1 1zM21 13h2c0.552 0 1-0.448 1-1s-0.448-1-1-1h-2c-0.552 0-1 0.448-1 1s0.448 1 1 1zM4.927 20.487l1.42-1.42c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-1.42 1.42c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0zM19.067 6.347l1.42-1.42c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-1.42 1.42c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z", 26 | ), 27 | ], 28 | [], 29 | ), 30 | ]) 31 | } 32 | 33 | pub fn icon_toggle_left() -> Html { 34 | h("svg", [#("id", "icon-toggle-left"), #("viewBox", "0 0 24 24")], [ 35 | h( 36 | "path", 37 | [ 38 | #( 39 | "d", 40 | "M8 4c-2.209 0-4.21 0.897-5.657 2.343s-2.343 3.448-2.343 5.657 0.897 4.21 2.343 5.657 3.448 2.343 5.657 2.343h8c2.209 0 4.21-0.897 5.657-2.343s2.343-3.448 2.343-5.657-0.897-4.21-2.343-5.657-3.448-2.343-5.657-2.343zM8 6h8c1.657 0 3.156 0.67 4.243 1.757s1.757 2.586 1.757 4.243-0.67 3.156-1.757 4.243-2.586 1.757-4.243 1.757h-8c-1.657 0-3.156-0.67-4.243-1.757s-1.757-2.586-1.757-4.243 0.67-3.156 1.757-4.243 2.586-1.757 4.243-1.757zM12 12c0-1.104-0.449-2.106-1.172-2.828s-1.724-1.172-2.828-1.172-2.106 0.449-2.828 1.172-1.172 1.724-1.172 2.828 0.449 2.106 1.172 2.828 1.724 1.172 2.828 1.172 2.106-0.449 2.828-1.172 1.172-1.724 1.172-2.828zM10 12c0 0.553-0.223 1.051-0.586 1.414s-0.861 0.586-1.414 0.586-1.051-0.223-1.414-0.586-0.586-0.861-0.586-1.414 0.223-1.051 0.586-1.414 0.861-0.586 1.414-0.586 1.051 0.223 1.414 0.586 0.586 0.861 0.586 1.414z", 41 | ), 42 | ], 43 | [], 44 | ), 45 | ]) 46 | } 47 | 48 | pub fn icon_toggle_right() -> Html { 49 | h("svg", [#("id", "icon-toggle-right"), #("viewBox", "0 0 24 24")], [ 50 | h( 51 | "path", 52 | [ 53 | #( 54 | "d", 55 | "M8 4c-2.209 0-4.21 0.897-5.657 2.343s-2.343 3.448-2.343 5.657 0.897 4.21 2.343 5.657 3.448 2.343 5.657 2.343h8c2.209 0 4.21-0.897 5.657-2.343s2.343-3.448 2.343-5.657-0.897-4.21-2.343-5.657-3.448-2.343-5.657-2.343zM8 6h8c1.657 0 3.156 0.67 4.243 1.757s1.757 2.586 1.757 4.243-0.67 3.156-1.757 4.243-2.586 1.757-4.243 1.757h-8c-1.657 0-3.156-0.67-4.243-1.757s-1.757-2.586-1.757-4.243 0.67-3.156 1.757-4.243 2.586-1.757 4.243-1.757zM20 12c0-1.104-0.449-2.106-1.172-2.828s-1.724-1.172-2.828-1.172-2.106 0.449-2.828 1.172-1.172 1.724-1.172 2.828 0.449 2.106 1.172 2.828 1.724 1.172 2.828 1.172 2.106-0.449 2.828-1.172 1.172-1.724 1.172-2.828zM18 12c0 0.553-0.223 1.051-0.586 1.414s-0.861 0.586-1.414 0.586-1.051-0.223-1.414-0.586-0.586-0.861-0.586-1.414 0.223-1.051 0.586-1.414 0.861-0.586 1.414-0.586 1.051 0.223 1.414 0.586 0.586 0.861 0.586 1.414z", 56 | ), 57 | ], 58 | [], 59 | ), 60 | ]) 61 | } 62 | 63 | pub fn theme_picker() -> Html { 64 | h("div", [#("class", "theme-picker")], [ 65 | h( 66 | "button", 67 | [ 68 | #("type", "button"), 69 | #("alt", "Switch to light mode"), 70 | #("title", "Switch to light mode"), 71 | #("class", "theme-button -light"), 72 | #("data-light-theme-toggle", ""), 73 | ], 74 | [icon_moon(), icon_toggle_left()], 75 | ), 76 | h( 77 | "button", 78 | [ 79 | #("type", "button"), 80 | #("alt", "Switch to dark mode"), 81 | #("title", "Switch to dark mode"), 82 | #("class", "theme-button -dark"), 83 | #("data-dark-theme-toggle", ""), 84 | ], 85 | [icon_sun(), icon_toggle_right()], 86 | ), 87 | ]) 88 | } 89 | 90 | // This script is inlined in the response to avoid FOUC when applying the theme 91 | pub const theme_picker_js = " 92 | const mediaPrefersDarkTheme = window.matchMedia('(prefers-color-scheme: dark)'); 93 | const themeStorageKey = 'theme'; 94 | 95 | function getPreferredTheme() { 96 | return mediaPrefersDarkTheme.matches ? 'dark' : 'light'; 97 | } 98 | 99 | function getAppliedTheme() { 100 | return document.documentElement.classList.contains('theme-dark') 101 | ? 'dark' 102 | : 'light'; 103 | } 104 | 105 | function getStoredTheme() { 106 | return localStorage.getItem(themeStorageKey); 107 | } 108 | 109 | function storeTheme(selectedTheme) { 110 | localStorage.setItem(themeStorageKey, selectedTheme); 111 | } 112 | 113 | function syncStoredTheme(theme) { 114 | if (theme === getPreferredTheme()) { 115 | // Selected theme is the same as the device's preferred theme, so we can forget this setting. 116 | localStorage.removeItem(themeStorageKey); 117 | } else { 118 | // Remember the selected theme to apply it on the next visit 119 | storeTheme(theme); 120 | } 121 | } 122 | 123 | function applyTheme(theme, initial = false) { 124 | // abort if theme is already applied 125 | if (theme === getAppliedTheme()) return; 126 | // apply theme css class 127 | document.documentElement.classList.toggle('theme-dark', theme === 'dark'); 128 | document.documentElement.classList.toggle('theme-light', theme !== 'dark'); 129 | } 130 | 131 | function setTheme(theme) { 132 | syncStoredTheme(theme); 133 | applyTheme(theme); 134 | } 135 | 136 | function toggleTheme() { 137 | setTheme(getAppliedTheme() === 'dark' ? 'light' : 'dark'); 138 | } 139 | 140 | function initThemeEvents() { 141 | // Watch the device's preferred theme and update theme if user did not select a theme 142 | mediaPrefersDarkTheme.addEventListener('change', () => { 143 | // abort if the user already selected a theme 144 | if (!!getStoredTheme()) return; 145 | // update applied theme accordingly 146 | applyTheme(getPreferredTheme()); 147 | }); 148 | // Add handlers for theme selection button 149 | document 150 | .querySelector('.theme-picker') 151 | ?.addEventListener('click', toggleTheme); 152 | } 153 | 154 | function initTheme() { 155 | // apply stored or preferred theme 156 | applyTheme(getStoredTheme() ?? getPreferredTheme()); 157 | initThemeEvents(); 158 | } 159 | 160 | initTheme(); 161 | " 162 | 163 | /// Renders an HTML anhor tag 164 | pub fn anchor( 165 | to href: String, 166 | attrs attributes: List(#(String, String)), 167 | with content: List(Html), 168 | ) -> Html { 169 | h("a", [#("href", href), ..attributes], content) 170 | } 171 | 172 | pub type Link { 173 | Link(label: String, to: String) 174 | } 175 | 176 | /// Renders a styled text link 177 | pub fn text_link( 178 | for link: Link, 179 | attributes attributes: List(#(String, String)), 180 | ) -> Html { 181 | let link_attributes = [#("class", "link"), ..attributes] 182 | 183 | anchor(link.to, link_attributes, [text(link.label)]) 184 | } 185 | 186 | /// Renders the playground's navbar as html 187 | pub fn navbar(gleam_version: String) -> Html { 188 | h("nav", [#("class", "navbar")], [ 189 | anchor("", [#("class", "logo")], [ 190 | h( 191 | "img", 192 | [ 193 | #("src", "https://gleam.run/images/lucy/lucy.svg"), 194 | #("alt", "Lucy the star, Gleam's mascot"), 195 | ], 196 | [], 197 | ), 198 | text("Gleam Playground"), 199 | h("p", [#("class", "version-number")], [text(gleam_version)]), 200 | ]), 201 | h("div", [#("class", "nav-right")], [ 202 | anchor("https://gleam.run", [#("class", "link")], [text("gleam.run")]), 203 | theme_picker(), 204 | ]), 205 | ]) 206 | } 207 | 208 | /// Renders a tab for the output display 209 | pub fn output_tab( 210 | label: String, 211 | id: String, 212 | value: String, 213 | checked: Bool, 214 | ) -> Html { 215 | let attrs = [ 216 | #("type", "radio"), 217 | #("id", id), 218 | #("name", "output-display"), 219 | #("value", value), 220 | #("hidden", "true"), 221 | ] 222 | let attrs = case checked { 223 | True -> [#("checked", ""), ..attrs] 224 | False -> attrs 225 | } 226 | h("label", [#("class", "tab")], [ 227 | h("p", [], [text(label)]), 228 | h("input", attrs, []), 229 | ]) 230 | } 231 | 232 | /// Renders a container for containing some output 233 | pub fn output_container(id: String, class: String) -> Html { 234 | h("aside", [#("id", id), #("class", class)], []) 235 | } 236 | -------------------------------------------------------------------------------- /src/playground.gleam: -------------------------------------------------------------------------------- 1 | import filepath 2 | import gleam/io 3 | import gleam/list 4 | import gleam/result 5 | import gleam/string 6 | import gleam/string_tree 7 | import htmb.{type Html, h} 8 | import playground/html.{ 9 | ScriptOptions, html_dangerous_inline_script, html_link, html_meta, 10 | html_meta_prop, html_script, html_stylesheet, html_title, 11 | } 12 | import playground/widgets.{output_container, output_tab} 13 | import simplifile 14 | import snag 15 | 16 | // Meta bits 17 | 18 | const meta_title = "The Gleam Playground" 19 | 20 | const meta_description = "Write, run, and share Gleam code in your browser: a playground for the Gleam programming language." 21 | 22 | const meta_image = "https://playground.gleam.run/share-preview.png" 23 | 24 | const meta_url = "https://play.gleam.run" 25 | 26 | // Paths 27 | 28 | const static = "static" 29 | 30 | const public = "public" 31 | 32 | const public_precompiled = "public/precompiled" 33 | 34 | const prelude = "build/dev/javascript/prelude.mjs" 35 | 36 | const stdlib_compiled = "build/dev/javascript/gleam_stdlib/gleam" 37 | 38 | const stdlib_sources = "build/packages/gleam_stdlib/src/gleam" 39 | 40 | const stdlib_external = "build/packages/gleam_stdlib/src" 41 | 42 | const compiler_wasm = "./wasm-compiler" 43 | 44 | const gleam_version = "GLEAM_VERSION" 45 | 46 | const hello_joe = "import gleam/io 47 | 48 | pub fn main() { 49 | io.println(\"Hello, Joe!\") 50 | } 51 | " 52 | 53 | // page paths 54 | 55 | pub fn main() { 56 | let result = { 57 | use _ <- result.try(reset_output()) 58 | use _ <- result.try(make_prelude_available()) 59 | use _ <- result.try(make_stdlib_available()) 60 | use _ <- result.try(copy_wasm_compiler()) 61 | use version <- result.try(read_gleam_version()) 62 | 63 | let page_html = 64 | home_page(version) 65 | |> htmb.render_page 66 | |> string_tree.to_string 67 | 68 | use _ <- result.try(ensure_directory(public)) 69 | let path = filepath.join(public, "index.html") 70 | 71 | use _ <- result.try(write_text(path, page_html)) 72 | 73 | Ok(Nil) 74 | } 75 | 76 | case result { 77 | Ok(_) -> { 78 | io.println("Site compiled to ./public 🎉") 79 | } 80 | Error(snag) -> { 81 | panic as snag.pretty_print(snag) 82 | } 83 | } 84 | } 85 | 86 | fn ensure_directory(path: String) -> snag.Result(Nil) { 87 | simplifile.create_directory_all(path) 88 | |> file_error("Failed to create directory " <> path) 89 | } 90 | 91 | fn write_text(path: String, text: String) -> snag.Result(Nil) { 92 | simplifile.write(path, text) 93 | |> file_error("Failed to write " <> path) 94 | } 95 | 96 | fn copy_wasm_compiler() -> snag.Result(Nil) { 97 | use compiler_wasm_exists <- result.try( 98 | simplifile.is_directory(compiler_wasm) 99 | |> file_error("Failed to check compiler-wasm directory"), 100 | ) 101 | use <- require(compiler_wasm_exists, "compiler-wasm folder must exist") 102 | 103 | use compiler_was_downloaded <- result.try( 104 | simplifile.get_files(compiler_wasm) 105 | |> file_error("Failed to check compiler-wasm directory for files"), 106 | ) 107 | 108 | use <- require( 109 | compiler_was_downloaded != [], 110 | "compiler-wasm must have been compiled", 111 | ) 112 | 113 | simplifile.copy_directory(compiler_wasm, public <> "/compiler") 114 | |> file_error("Failed to copy compiler-wasm") 115 | } 116 | 117 | fn make_prelude_available() -> snag.Result(Nil) { 118 | use _ <- result.try( 119 | simplifile.create_directory_all(public_precompiled) 120 | |> file_error("Failed to make " <> public_precompiled), 121 | ) 122 | 123 | simplifile.copy_file(prelude, public_precompiled <> "/gleam.mjs") 124 | |> file_error("Failed to copy prelude.mjs") 125 | } 126 | 127 | fn make_stdlib_available() -> snag.Result(Nil) { 128 | use files <- result.try( 129 | simplifile.get_files(stdlib_sources) 130 | |> file_error("Failed to read stdlib directory"), 131 | ) 132 | 133 | let modules = 134 | files 135 | |> list.filter(fn(file) { string.ends_with(file, ".gleam") }) 136 | |> list.map(string.replace(_, ".gleam", "")) 137 | |> list.map(string.replace(_, stdlib_sources <> "/", "")) 138 | 139 | use _ <- result.try( 140 | generate_stdlib_bundle(modules) 141 | |> snag.context("Failed to generate stdlib.js bundle"), 142 | ) 143 | 144 | use _ <- result.try( 145 | copy_compiled_stdlib(modules) 146 | |> snag.context("Failed to copy precompiled stdlib modules"), 147 | ) 148 | 149 | use _ <- result.try( 150 | copy_stdlib_externals() 151 | |> snag.context("Failed to copy stdlib external files"), 152 | ) 153 | 154 | Ok(Nil) 155 | } 156 | 157 | fn copy_stdlib_externals() -> snag.Result(Nil) { 158 | use files <- result.try( 159 | simplifile.read_directory(stdlib_external) 160 | |> file_error("Failed to read stdlib external directory"), 161 | ) 162 | let files = list.filter(files, string.ends_with(_, ".mjs")) 163 | 164 | list.try_each(files, fn(file) { 165 | let from = stdlib_external <> "/" <> file 166 | let to = public_precompiled <> "/" <> file 167 | simplifile.copy_file(from, to) 168 | |> file_error("Failed to copy stdlib external file " <> from) 169 | }) 170 | } 171 | 172 | fn copy_compiled_stdlib(modules: List(String)) -> snag.Result(Nil) { 173 | use stdlib_dir_exists <- result.try( 174 | simplifile.is_directory(stdlib_compiled) 175 | |> file_error("Failed to check stdlib directory"), 176 | ) 177 | use <- require( 178 | stdlib_dir_exists, 179 | "Project must have been compiled for JavaScript", 180 | ) 181 | 182 | let dest = public_precompiled <> "/gleam" 183 | 184 | use _ <- result.try( 185 | list.try_each(modules, fn(name) { 186 | let from = stdlib_compiled <> "/" <> name <> ".mjs" 187 | let to = dest <> "/" <> name <> ".mjs" 188 | let parent = filepath.directory_name(to) 189 | use _ <- result.try( 190 | simplifile.create_directory_all(parent) 191 | |> file_error("Failed to make " <> parent), 192 | ) 193 | simplifile.copy_file(from, to) 194 | |> file_error("Failed to copy stdlib module " <> from) 195 | }), 196 | ) 197 | 198 | Ok(Nil) 199 | } 200 | 201 | fn generate_stdlib_bundle(modules: List(String)) -> snag.Result(Nil) { 202 | use entries <- result.try( 203 | list.try_map(modules, fn(name) { 204 | let path = stdlib_sources <> "/" <> name <> ".gleam" 205 | use code <- result.try( 206 | simplifile.read(path) 207 | |> file_error("Failed to read stdlib module " <> path), 208 | ) 209 | let name = string.replace(name, ".gleam", "") 210 | let code = 211 | code 212 | |> string.replace("\\", "\\\\") 213 | |> string.replace("`", "\\`") 214 | |> string.split("\n") 215 | |> list.filter(fn(line) { !string.starts_with(string.trim(line), "//") }) 216 | |> list.filter(fn(line) { line != "" }) 217 | |> string.join("\n") 218 | 219 | Ok(" \"gleam/" <> name <> "\": `" <> code <> "`") 220 | }), 221 | ) 222 | 223 | entries 224 | |> string.join(",\n") 225 | |> string.append("export default {\n", _) 226 | |> string.append("\n}\n") 227 | |> simplifile.write(public <> "/stdlib.js", _) 228 | |> file_error("Failed to write stdlib.js") 229 | } 230 | 231 | fn reset_output() -> snag.Result(Nil) { 232 | use _ <- result.try( 233 | simplifile.create_directory_all(public) 234 | |> file_error("Failed to delete public directory"), 235 | ) 236 | 237 | use files <- result.try( 238 | simplifile.read_directory(public) 239 | |> file_error("Failed to read public directory"), 240 | ) 241 | 242 | use _ <- result.try( 243 | files 244 | |> list.map(string.append(public <> "/", _)) 245 | |> simplifile.delete_all 246 | |> file_error("Failed to delete public directory"), 247 | ) 248 | 249 | simplifile.copy_directory(static, public) 250 | |> file_error("Failed to copy static directory") 251 | } 252 | 253 | fn require( 254 | that condition: Bool, 255 | because reason: String, 256 | then next: fn() -> snag.Result(t), 257 | ) -> snag.Result(t) { 258 | case condition { 259 | True -> next() 260 | False -> Error(snag.new(reason)) 261 | } 262 | } 263 | 264 | fn read_gleam_version() -> snag.Result(String) { 265 | gleam_version 266 | |> simplifile.read() 267 | |> file_error("Failed to read Gleam version at path " <> gleam_version) 268 | } 269 | 270 | fn file_error( 271 | result: Result(t, simplifile.FileError), 272 | context: String, 273 | ) -> snag.Result(t) { 274 | case result { 275 | Ok(value) -> Ok(value) 276 | Error(error) -> 277 | snag.error("File error: " <> string.inspect(error)) 278 | |> snag.context(context) 279 | } 280 | } 281 | 282 | // Shared stylesheets paths 283 | 284 | const css__gleam_common = "common.css" 285 | 286 | /// Loads fonts and defines font sizes 287 | const css_fonts = "css/fonts.css" 288 | 289 | /// Derives app colors for both dark & light themes from common.css variables 290 | const css_theme = "css/theme.css" 291 | 292 | /// Defines layout unit variables 293 | const css_layout = "css/layout.css" 294 | 295 | /// Sensitive defaults for any page 296 | const css_defaults_page = [css_fonts, css_theme, css__gleam_common, css_layout] 297 | 298 | // Page stylesheet paths 299 | 300 | /// Common stylesheet for all playground pages 301 | const css_root = "css/root.css" 302 | 303 | // Path to the css speciic to to lesson & main pages 304 | const css_playground_page = "css/pages/playground.css" 305 | 306 | // Defines code syntax highlighting for highlightJS & CodeFlash 307 | // based on dark / light mode and the currenly loaded color scheme 308 | const css_syntax_highlight = "css/code/syntax-highlight.css" 309 | 310 | // Color schemes 311 | // TODO: add more color schemes 312 | 313 | /// Atom One Dark & Atom One Light colors 314 | const css_scheme_atom_one = "css/code/color-schemes/atom-one.css" 315 | 316 | /// Sensitive defaults for any page needing to display Gleam code 317 | /// To be used alonside defaults_page 318 | const css_defaults_code = [css_syntax_highlight, css_scheme_atom_one] 319 | 320 | /// Renders the script that that contains the code 321 | /// needed for the light/dark theme picker to work 322 | pub fn theme_picker_script() -> Html { 323 | html_dangerous_inline_script( 324 | widgets.theme_picker_js, 325 | ScriptOptions(module: True, defer: False), 326 | [], 327 | ) 328 | } 329 | 330 | // Page Renders 331 | 332 | fn home_page(gleam_version: String) -> Html { 333 | let head_content = [ 334 | // Meta property tags 335 | html_meta_prop("og:type", "website"), 336 | html_meta_prop("og:title", meta_title), 337 | html_meta_prop("og:description", meta_description), 338 | html_meta_prop("og:url", meta_url), 339 | html_meta_prop("og:image", meta_image), 340 | html_meta_prop("twitter:card", "summary_large_image"), 341 | html_meta_prop("twitter:url", meta_url), 342 | html_meta_prop("twitter:title", meta_title), 343 | html_meta_prop("twitter:description", meta_description), 344 | html_meta_prop("twitter:image", meta_image), 345 | // Page meta 346 | html_meta([#("charset", "utf-8")]), 347 | html_meta([ 348 | #("name", "viewport"), 349 | #("content", "width=device-width, initial-scale=1"), 350 | ]), 351 | html_title(meta_title), 352 | html_meta([#("name", "description"), #("content", meta_description)]), 353 | // Links 354 | html_link("shortcut icon", "https://gleam.run/images/lucy/lucy.svg"), 355 | // Scripts 356 | html_script( 357 | "https://plausible.io/js/script.js", 358 | ScriptOptions(defer: True, module: False), 359 | [#("data-domain", "playground.gleam.run")], 360 | ), 361 | // Stylesheets 362 | ..{ 363 | list.flatten([ 364 | css_defaults_page, 365 | css_defaults_code, 366 | [css_root, css_playground_page], 367 | ]) 368 | |> list.map(html_stylesheet) 369 | } 370 | ] 371 | 372 | let body_scripts = [ 373 | theme_picker_script(), 374 | h("script", [#("type", "gleam"), #("id", "code")], [ 375 | htmb.dangerous_unescaped_fragment(string_tree.from_string(hello_joe)), 376 | ]), 377 | html_script("index.js", ScriptOptions(module: True, defer: False), []), 378 | ] 379 | 380 | let body_content = [ 381 | widgets.navbar(gleam_version), 382 | h("article", [#("id", "playground-container")], [ 383 | h("section", [#("id", "playground")], [ 384 | h("div", [#("id", "playground-content")], [ 385 | h("section", [#("id", "editor")], [ 386 | h("div", [#("id", "editor-target")], []), 387 | ]), 388 | h("div", [#("id", "output-container")], [ 389 | h("div", [#("id", "tabs")], [ 390 | output_tab("Output", "output-radio", "output", True), 391 | output_tab( 392 | "Compiled Erlang", 393 | "compiled-erlang-radio", 394 | "erlang", 395 | False, 396 | ), 397 | output_tab( 398 | "Compiled JavaScript", 399 | "compiled-javascript-radio", 400 | "javascript", 401 | False, 402 | ), 403 | h("button", [#("id", "share-button")], [htmb.text("Share code")]), 404 | ]), 405 | output_container("output", "output"), 406 | output_container("compiled-erlang", "output language-erlang"), 407 | output_container( 408 | "compiled-javascript", 409 | "output language-javascript", 410 | ), 411 | ]), 412 | ]), 413 | ]), 414 | ]), 415 | ..body_scripts 416 | ] 417 | 418 | h("html", [#("class", "theme-light"), #("lang", "en-GB")], [ 419 | h("head", [], head_content), 420 | h("body", [], body_content), 421 | ]) 422 | } 423 | --------------------------------------------------------------------------------