Lang Module Guide

Author: Zach Bogart, Brayden Youngberg Version: 3.0.0

Overview

The Lang module provides a set of helper functions to manage translations, template insertions, and string formatting in JavaScript notebooks and apps. It is especially useful for multi-language support and dynamic text replacement.


Installation & Defining Languages

Define available languages with keys, labels, and locales:

import { lang as Lang } from "/helpers/lang.js";

languages = [
  { key: "en", label: "English", locale: "en-US" },
  { key: "fr", label: "Français", locale: "fr-FR" },
]

Setting Up Translation JSON

Structure your translations as a nested object or external JSON, with language keys for each field:

nbText = new Object({
  hello: {
    en: "hello",
    fr: "Bonjour",
  },
  error: {
    en: "Whoops",
  },
  insert: {
    small: {
      en: "This is an insertion example: :::field:::",
      fr: "Ceci est un exemple d'insertion: :::field:::",
    },
    big: {
      en: "This is an insertion example: :::field::: :::name:::",
      fr: "Ceci est un exemple d'insertion: :::field::: :::name:::",
    },
  },
})

Language State

Don’t build a toggle per notebook. The navbar switcher (components/langSwitcher.js) owns language state site-wide, and notebooks get language / _lang through the shared notebook runtime (see Notebook Content Layout below). For the standalone examples on this page we simply pin it:

language = ({ key: "en" })

Retrieving Translations

Get a translation for the current language:

Lang.getText(nbText.hello, { key: language.key })

Shorthand Helper

To avoid repeating the language key, use lg:

_lang = Lang.lg(language.key)
_lang(nbText.hello)

Template Insertion

Replace placeholders in template strings with dynamic values.

Example: Single Insertion

{
  const template = Lang.getText(nbText.insert.small, { key: language.key });
  const items = [{ name: "field", value: "hello world!" }];
  return Lang.reduceReplaceTemplateItems(template, items);
}

Example: Multiple Insertions

{
  const template = Lang.getText(nbText.insert.big, { key: language.key });
  const greeting = Lang.getText(nbText.hello, { key: language.key });
  const items = [
    { name: "field", value: greeting },
    { name: "name", value: "Alice" },
  ];
  return Lang.reduceReplaceTemplateItems(template, items);
}

Custom Placeholder Delimiters

{
  const customTemplate = "This is a test: >field<";
  const result = Lang.reduceReplaceTemplateItems(
    customTemplate,
    [{ name: "field", value: "hello world!" }],
    { start: ">", end: "<" }
  );
  return result;
}

String Formatting Helpers

Lang.toSentenceCase("welcome to my home") // "Welcome to my home"

API Reference

FunctionDescription
lg(defaultKey)Returns a function to fetch text for a default language key.
getText(textObj, { key })Gets text for the specified language key.
getRegexForNamedInsertion(item, opts)Returns regex to match placeholders (default: :::item:::).
reduceReplaceTemplateItems(...)Replaces all placeholders in a template with provided values.
toSentenceCase(str)Converts a string to sentence case.

Notebook Content Layout (CMS)

Each notebook is defined by data/<notebook>/notebook.json. The manifest is the single source for its localized page title, hero image, contributors, and text directory. Which blocks appear, and in what order, is decided by the {{< prose >}} markers in the .qmd — the manifest keeps no block list. Every notebook must place overview and methods; summary is optional.

Localized notebook text lives in data/<notebook>/text/:

  • <id>.en.md, <id>.fr.md — one file per narrative block. The filename is the block id; the front matter is exactly one title: field, followed by the markdown body:

    ---
    title: "Overview"
    ---
    
    Understanding the economic impacts...

    Notebooks place a block with the prose shortcode, using the block id:

    {{< prose overview >}}
    
    {{< prose costs level=2 >}}
    
    {{< prose overview-closing heading=false >}}
    
    {{< prose overview-note details=true >}}

    Developers control heading level and placement in the .qmd; authors control the displayed text in the block file. The shortcode (scripts/build/proseShortcode.lua) emits a section heading titled from the block’s title: (level=N sets the heading level, default 1) followed by the block body; heading=false injects just the body, for free-floating blocks — for example an overview-closing block placed after a figure. details=true renders the block collapsed inside a native <details> note, with its title: as the summary — a note is an ordinary block in every respect, usually placed on the line after the section it belongs to. Its id is free: the existing ones use <parent>-note only so they sort beside their parent, and nothing in the build depends on that. Flags that conflict are rejected rather than silently ignored, as is an unknown option. A shortcode pointing at a missing or empty block fails the render, and CI flags block files that nothing references (a typo’d id would otherwise fail silently). Pandoc renders both languages as static, language-tagged HTML at build time. The navbar switcher changes which version CSS displays; prose is never fetched or parsed in the browser.

    The body is full markdown, so authors can add subheadings (##, ###), lists, tables and links without a developer. # is rejected by CI: the shortcode owns the section heading. A subheading that must appear somewhere else on the page — below a figure, say — is its own block placed with level=, because placement lives in the .qmd.

  • en.json, fr.json — widget labels, tooltips, headings used inside charts, and templated sentences. Their key trees must match, and every value must be a non-empty string. The page title belongs in the manifest, not these files.

The shared /components/_notebookRuntime.qmd include reads the manifest and loads the localized widget strings used by OJS cells. Entry notebooks should not maintain their own translation-loading cells.

scripts/build/checkTranslations.ts enforces locale parity, localized titles, non-empty values, and prose references.

The narrative blocks are editable in the browser at /admin/ (Sveltia CMS, configured in admin/config.yml — one folder collection per notebook; authors can edit but not create or delete blocks). Every block is one entry with the same two fields — Heading and Content — collapsible notes included, since they are just blocks. Page titles, descriptions, keywords, hero images and contributor lists are editable under Notebook Settings. Regular collaborators can be selected from the shared Contributor Directory; a notebook may also include a custom, one-off contributor. Structural manifest fields and widget JSON strings remain hidden from the CMS because they are coupled to notebook code. Edits are committed to the repo and published by the normal build.

Adding a new section

Three steps, in one commit. No manifest or CMS-config change is needed — the prose collections are folder collections, so a new file pair shows up in the CMS by itself.

  1. Copy an existing block pair in data/<notebook>/text/ to the new id, e.g. sensitivity.en.md and sensitivity.fr.md. The filename is the block id; authors never see it, so pick something short and stable.

  2. Edit the two files — the title: and the body, in each language. Keep the front matter to the single title: field. The body is full markdown; use ## and below, never #. For a collapsible note, make it its own block and place it with details=true — any id works.

  3. Add one marker to the .qmd, at the point in the page where the section belongs — usually just before the chart it introduces:

    {{< prose sensitivity >}}

    Default renders an H1 section; level=2 makes it a subsection, and heading=false injects only the body (for a fragment continuing an earlier section).

Then run deno run --allow-read scripts/build/checkTranslations.ts. All three steps must land together: a marker with no block file fails the render, and a block file no .qmd references fails CI. Once merged, the new section appears in the CMS sidebar under that notebook, titled from title:, and authors own the text in both languages from then on.

To extend a section that already exists, no code change is needed at all — authors add ## subheadings and content to its body in the CMS.

Language state itself is owned by a vanilla navbar switcher (components/langSwitcher.js) that works before the OJS runtime boots. The shared notebook runtime includes /components/_lang.qmd, then exposes nbText, nbTitle, and tmpl for notebook code. Entry notebooks include the runtime once and must not redefine those cells.


Links: Observable Notebook Example

Happy translating!