From 7ba461e6e9ae76abe1edd6878cea23e365a1846f Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 13 Oct 2026 16:21:18 -0600 Subject: [PATCH] Style packages can bundle fonts via assets.fonts (MOB-476) priv/mob_style.exs takes an optional assets: %{fonts: [path]}; activated styles' fonts join app + plugin fonts in collect_all_fonts/0 (same dedup and collision checks). A declared file that doesn't exist fails the build naming the style and path. mix mob.styles lists each style's fonts. New: MobDev.Style.fonts!/1. --- CHANGELOG.md | 19 +++++ .../2026-10-13-style-packages-bundle-fonts.md | 54 ++++++++++++ lib/mix/tasks/mob.styles.ex | 15 +++- lib/mob_dev/native_build.ex | 14 ++-- lib/mob_dev/style.ex | 84 +++++++++++++++++-- test/mob_dev/native_build_test.exs | 78 +++++++++++++++++ test/mob_dev/style_test.exs | 78 +++++++++++++++++ 7 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 decisions/2026-10-13-style-packages-bundle-fonts.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 913b8040..8d3bd13d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## [Unreleased] + +### Added + +- **Style packages can bundle fonts (MOB-476).** `priv/mob_style.exs` takes + an optional `assets: %{fonts: ["priv/fonts/X.ttf", ...]}`, paths relative + to the style package root. An activated style's fonts ship like plugin + fonts: iOS `.app` root + `UIAppFonts`, Android `res/font/`, with the same + dedup and collision checks, so the style's theme can name them in its + `fonts:` map through `Mob.Theme.font/2`. A declared font file that doesn't + exist fails the build, naming the style and the path. Only `:fonts` is + supported in a style's `assets`; any other key is a validation error. + `style_spec_version` stays `1`. `mix mob.styles` lists each style's fonts + and flags missing ones. New: `MobDev.Style.fonts!/1`. If your app copied a + style's font files into its own `priv/fonts/`, delete the copies once the + style lists them: the same font from both places is a bundle-name + collision and fails the build. See + `decisions/2026-10-13-style-packages-bundle-fonts.md`. + ## [0.7.24] - 2026-10-13 ### Fixed diff --git a/decisions/2026-10-13-style-packages-bundle-fonts.md b/decisions/2026-10-13-style-packages-bundle-fonts.md new file mode 100644 index 00000000..7b056af3 --- /dev/null +++ b/decisions/2026-10-13-style-packages-bundle-fonts.md @@ -0,0 +1,54 @@ +# Style packages bundle fonts through the plugin font path + +- Date: 2026-10-13 +- Status: accepted +- Issue: MOB-476 + +## Context + +A style package (`priv/mob_style.exs`, `MobDev.Style`) could only declare +tokens: a theme module. A typographic style (mob_bezalel: EB Garamond, +Instrument Sans, Frank Ruhl Libre) names its faces in the theme's `fonts:` +map through `Mob.Theme.font/2`, and those names resolve only if the files are +in the app: the iOS `.app` root plus `UIAppFonts`, and Android `res/font/`. +Before this, the files had to come from the host's `priv/fonts/` or from a +plugin manifest's `assets.fonts`, so a style could not be self-contained. + +## Decision + +`priv/mob_style.exs` takes an optional `assets: %{fonts: [path]}`, with paths +relative to the style package root. It is the same key and shape as the plugin +manifest's, so authors write one thing. + +- **Fonts only.** Any other key in a style's `assets`, `:images` included, is a + validation error. Images belong to plugins, which get the + `plugin:///` resolver. A style has no image resolver to use them + with, so accepting the key would validate and then ship nothing. +- **One font pipeline.** `MobDev.Style.fonts!/1` resolves the activated styles' + fonts to absolute paths, and `NativeBuild.collect_all_fonts/0` appends them to + the app and plugin fonts. Dedup and the iOS basename / Android + resource-name collision checks are the existing ones, so a style font that + collides with an app or plugin font fails the build the same way. +- **A missing file fails the build** with the style name, the declared path and + the resolved path, the same rule `runtime_entries!/0` applies to an invalid + manifest. Without that check the build would crash in `File.cp!` with + nothing pointing at the style. An invalid manifest raises here too, rather + than contributing no fonts. +- `style_spec_version` stays `1`: the key is optional and additive, and an + older mob_dev ignores it (styles have no unknown-key check). + +## Consequences + +- A style that uses `assets` needs a mob_dev with this change to ship its + fonts. On an older mob_dev the manifest still validates, the fonts are not + bundled, and the theme's font names fall back to the system face. +- Style fonts are not signed. Styles have no signature scheme, and fonts are + data, not code. +- An app that copied a style's fonts into its own `priv/fonts/` hits a + bundle-name collision once the style lists the same files, since app fonts + and style fonts are different paths. That is the existing collision rule; + the fix is to delete the app's copies. +- The guarantee covers styles that `MobDev.Style.activated/0` resolved and + loaded. An activated name that isn't a dep, or whose manifest fails to + evaluate, is still skipped there (pre-existing behaviour), so it ships no + fonts and raises nothing here. diff --git a/lib/mix/tasks/mob.styles.ex b/lib/mix/tasks/mob.styles.ex index 7160c127..82c33c12 100644 --- a/lib/mix/tasks/mob.styles.ex +++ b/lib/mix/tasks/mob.styles.ex @@ -3,7 +3,8 @@ defmodule Mix.Tasks.Mob.Styles do @moduledoc """ Lists the activated style packages (MOB_STYLES.md, tokens-only tier) with - their themes and which one `config :mob, :default_style` selects. + their themes, the fonts each bundles (`assets: %{fonts: [...]}`), and which + one `config :mob, :default_style` selects. mix mob.styles """ @@ -38,6 +39,8 @@ defmodule Mix.Tasks.Mob.Styles do " #{String.pad_trailing(to_string(m.name), 18)}#{String.pad_trailing(inspect(m.theme), 28)}#{marker}" ) + print_fonts(dir, m) + {:error, errs} -> Mix.shell().error(" #{Path.basename(dir)}: INVALID — #{Enum.join(errs, "; ")}") end @@ -52,4 +55,14 @@ defmodule Mix.Tasks.Mob.Styles do Mix.shell().info("") end end + + defp print_fonts(dir, manifest) do + for rel <- get_in(manifest, [:assets, :fonts]) || [] do + if File.regular?(Path.join(dir, rel)) do + Mix.shell().info(" font #{rel}") + else + Mix.shell().error(" font #{rel} ✗ missing (build will fail)") + end + end + end end diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 6bf9b9ed..a4ac92cf 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -5523,8 +5523,12 @@ defmodule MobDev.NativeBuild do @android_res_font "android/app/src/main/res/font" - # App-level (`priv/fonts/*.ttf|otf`) + plugin (`assets.fonts`) custom fonts. - defp collect_all_fonts do + # App-level (`priv/fonts/*.ttf|otf`) + plugin (`assets.fonts`) + activated + # style (`assets.fonts` in `priv/mob_style.exs`) custom fonts. Public for + # testing; raises when an activated style declares a missing font. + @doc false + @spec collect_all_fonts() :: [Path.t()] + def collect_all_fonts do app_fonts = Path.wildcard("priv/fonts/*.{ttf,otf,TTF,OTF}") plugin_fonts = @@ -5532,10 +5536,10 @@ defmodule MobDev.NativeBuild do f <- fonts, do: f - Enum.uniq(app_fonts ++ plugin_fonts) + Enum.uniq(app_fonts ++ plugin_fonts ++ MobDev.Style.fonts!()) end - # Copies the app's + plugins' fonts into the iOS `.app` bundle root and lists + # Copies the app's, plugins' and styles' fonts into the iOS `.app` bundle root and lists # them in `Info.plist` UIAppFonts so iOS registers them at launch (the SwiftUI # `Font.custom(name, …)` path in MobRootView then resolves them by name). No-op # when there are no fonts. @@ -5566,7 +5570,7 @@ defmodule MobDev.NativeBuild do :ok end - # Copies the app's + plugins' fonts into the Android `res/font/` dir under a + # Copies the app's, plugins' and styles' fonts into the Android `res/font/` dir under a # normalised resource name (lowercase + underscores; the renderer normalises # the `font:` prop the same way to look them up via `getIdentifier`). Unlike # `assets/`, `res/font/` entries are stored uncompressed, which Android's font diff --git a/lib/mob_dev/style.ex b/lib/mob_dev/style.ex index 2ea55321..6ea40169 100644 --- a/lib/mob_dev/style.ex +++ b/lib/mob_dev/style.ex @@ -6,6 +6,10 @@ defmodule MobDev.Style do `config :mob, :default_style, :name` in `mob.exs`, and the runtime manifest carries the resolved set so core can apply the default style's theme at boot. + A style may also bundle font files (`assets: %{fonts: [...]}`, paths + relative to the style package root) for its theme's `fonts:` map; the build + ships them exactly like plugin fonts (`fonts!/1`). + Tokens-only is the doc's smallest tier ("repalette-only styles"); the per-component native-override tier (cascade, `_style` dispatch — the mob_m3 case) is NOT implemented yet. @@ -38,7 +42,9 @@ defmodule MobDev.Style do @doc """ Validates the four required fields (MOB_STYLES.md "Minimum viable manifest"): `:name` atom, `:mob_version` requirement string, - `:style_spec_version` integer, `:theme` module atom. + `:style_spec_version` integer, `:theme` module atom; plus the optional + `:assets`, a map whose only supported key is `:fonts` (a list of path + strings relative to the style package root). """ @spec validate(map()) :: {:ok, map()} | {:error, [String.t()]} def validate(manifest) when is_map(manifest) do @@ -58,6 +64,7 @@ defmodule MobDev.Style do &(is_atom(&1) and not is_nil(&1)), ":theme is required and must be a theme module atom" ) + |> check_assets(manifest) case errors do [] -> {:ok, manifest} @@ -113,14 +120,8 @@ defmodule MobDev.Style do def runtime_entries! do entries = for {dir, manifest} <- activated() do - case validate(manifest) do - {:ok, m} -> - %{name: m.name, theme: m.theme} - - {:error, errs} -> - raise ArgumentError, - "invalid style manifest in #{dir}:\n " <> Enum.join(errs, "\n ") - end + m = validate!(dir, manifest) + %{name: m.name, theme: m.theme} end default = default_style() @@ -134,12 +135,77 @@ defmodule MobDev.Style do %{styles: entries, default_style: default} end + @doc """ + Absolute paths of the fonts the given styles (default: the activated ones) + bundle via `assets: %{fonts: [...]}`, resolved against each style's + package dir. Raises when a manifest is invalid or a declared font file + doesn't exist, so the build fails instead of shipping a theme whose fonts + fall back to the system face. A style `activated/0` couldn't resolve or + load contributes nothing here. + """ + @spec fonts!([{Path.t(), map()}]) :: [Path.t()] + def fonts!(styles \\ activated()) do + for {dir, manifest} <- styles, + m = validate!(dir, manifest), + rel <- get_in(m, [:assets, :fonts]) || [] do + path = Path.join(dir, rel) + + if !File.regular?(path) do + raise ArgumentError, + "style #{inspect(m.name)} declares font #{inspect(rel)} in assets.fonts, " <> + "but #{path} does not exist" + end + + path + end + end + # ── helpers ──────────────────────────────────────────────────────────────── defp check(errors, manifest, key, pred, msg) do if pred.(Map.get(manifest, key)), do: errors, else: [msg | errors] end + defp validate!(dir, manifest) do + case validate(manifest) do + {:ok, m} -> + m + + {:error, errs} -> + raise ArgumentError, "invalid style manifest in #{dir}:\n " <> Enum.join(errs, "\n ") + end + end + + # Styles bundle fonts only; images and anything else belong to plugins. + defp check_assets(errors, %{assets: %{} = assets}) do + errors + |> check_asset_keys(Map.keys(assets) -- [:fonts]) + |> check_font_paths(Map.get(assets, :fonts)) + end + + defp check_assets(errors, %{assets: other}), + do: ["assets must be a map with a :fonts path list, got: #{inspect(other)}" | errors] + + defp check_assets(errors, _), do: errors + + defp check_asset_keys(errors, []), do: errors + + defp check_asset_keys(errors, unknown), + do: [ + "assets supports only :fonts in a style manifest, got unknown key(s): #{inspect(unknown)}" + | errors + ] + + defp check_font_paths(errors, nil), do: errors + + defp check_font_paths(errors, paths) when is_list(paths) do + if Enum.all?(paths, &is_binary/1), do: errors, else: bad_font_paths(errors) + end + + defp check_font_paths(errors, _), do: bad_font_paths(errors) + + defp bad_font_paths(errors), do: ["assets.fonts must be a list of path strings" | errors] + defp check_version(errors, %{mob_version: req}) when is_binary(req) do case Version.parse_requirement(req) do {:ok, _} -> errors diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 7355f587..a1fd69b1 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2681,4 +2681,82 @@ defmodule MobDev.NativeBuildTest do assert File.ls!(tmp_dir) == ["mob_plugin_bootstrap.swift"] end end + + describe "collect_all_fonts/0 in a host project with an activated style" do + setup do + tmp = Path.join(System.tmp_dir!(), "mob_style_fonts_#{System.unique_integer([:positive])}") + on_exit(fn -> File.rm_rf!(tmp) end) + suffix = System.unique_integer([:positive]) + host = Path.join(tmp, "host") + style = Path.join(tmp, "elsewhere/sfonts") + + for {dir, rel} <- [{host, "priv/fonts/App.ttf"}, {style, "priv/fonts/Style-Medium.ttf"}] do + File.mkdir_p!(Path.join(dir, "priv/fonts")) + File.write!(Path.join(dir, rel), "font") + end + + File.write!(Path.join(style, "mix.exs"), """ + defmodule SFonts#{suffix}.MixProject do + use Mix.Project + def project, do: [app: :sfonts, version: "0.1.0"] + end + """) + + File.write!(Path.join(host, "mix.exs"), """ + defmodule SFontsHost#{suffix}.MixProject do + use Mix.Project + def project, do: [app: :host, version: "0.1.0", deps: [{:sfonts, path: "../elsewhere/sfonts"}]] + end + """) + + File.write!(Path.join(host, "mob.exs"), """ + import Config + config :mob, styles: [:sfonts], default_style: :sfonts + """) + + write_style_manifest = fn fonts -> + File.write!( + Path.join(style, "priv/mob_style.exs"), + inspect(%{ + name: :sfonts, + mob_version: "~> 0.9", + style_spec_version: 1, + theme: SFonts.Theme, + assets: %{fonts: fonts} + }) + ) + end + + # on_clean_slate makes the fixture the top-level project (see the + # classify_project_nif path-dep test above). + collect = fn -> + Mix.ProjectStack.on_clean_slate(fn -> + Mix.Project.in_project(:"sfonts_host#{suffix}", host, fn _ -> + NativeBuild.collect_all_fonts() + end) + end) + end + + {:ok, write_style_manifest: write_style_manifest, collect: collect} + end + + test "the style's fonts join the app's", %{write_style_manifest: write, collect: collect} do + write.(["priv/fonts/Style-Medium.ttf"]) + + assert [app, style_font] = collect.() + assert app == "priv/fonts/App.ttf" + # deps_paths resolves symlinks (macOS /var → /private/var). + assert String.ends_with?(style_font, "elsewhere/sfonts/priv/fonts/Style-Medium.ttf") + assert Path.type(style_font) == :absolute + end + + test "a style font that doesn't exist fails the build", %{ + write_style_manifest: write, + collect: collect + } do + write.(["priv/fonts/Gone.ttf"]) + + assert_raise ArgumentError, ~r/style :sfonts declares font "priv\/fonts\/Gone.ttf"/, collect + end + end end diff --git a/test/mob_dev/style_test.exs b/test/mob_dev/style_test.exs index a0fec48f..9f9fb2a0 100644 --- a/test/mob_dev/style_test.exs +++ b/test/mob_dev/style_test.exs @@ -39,6 +39,78 @@ defmodule MobDev.StyleTest do end end + describe "validate/1 — optional assets.fonts" do + test "accepts a fonts path list" do + m = Map.put(@valid, :assets, %{fonts: ["priv/fonts/X.ttf"]}) + assert {:ok, ^m} = Style.validate(m) + end + + test "rejects a non-map assets" do + assert {:error, [msg]} = Style.validate(Map.put(@valid, :assets, ["priv/fonts/X.ttf"])) + assert msg =~ "assets must be a map" + end + + test "rejects keys other than :fonts (styles don't bundle images)" do + m = Map.put(@valid, :assets, %{fonts: ["priv/fonts/X.ttf"], images: ["priv/a.png"]}) + assert {:error, [msg]} = Style.validate(m) + assert msg =~ ":images" + end + + test "rejects fonts that aren't a list of strings" do + for fonts <- ["priv/fonts/X.ttf", [:x], ["priv/fonts/X.ttf", nil]] do + assert {:error, [msg]} = Style.validate(Map.put(@valid, :assets, %{fonts: fonts})) + assert msg =~ "assets.fonts" + end + end + end + + describe "fonts!/1" do + test "resolves declared fonts against each style dir, in order" do + a = tmp_style!("") + b = tmp_style!("") + touch!(a, "priv/fonts/A.ttf") + touch!(a, "priv/fonts/B.otf") + touch!(b, "fonts/C.ttf") + + styles = [ + {a, Map.put(@valid, :assets, %{fonts: ["priv/fonts/A.ttf", "priv/fonts/B.otf"]})}, + {b, Map.merge(@valid, %{name: :other, assets: %{fonts: ["fonts/C.ttf"]}})}, + {tmp_style!(""), @valid} + ] + + assert Style.fonts!(styles) == [ + Path.join(a, "priv/fonts/A.ttf"), + Path.join(a, "priv/fonts/B.otf"), + Path.join(b, "fonts/C.ttf") + ] + end + + test "a declared font that doesn't exist fails, naming the style and path" do + dir = tmp_style!("") + m = Map.put(@valid, :assets, %{fonts: ["priv/fonts/Missing.ttf"]}) + + err = assert_raise ArgumentError, fn -> Style.fonts!([{dir, m}]) end + assert err.message =~ ":mob_theme_citrus" + assert err.message =~ Path.join(dir, "priv/fonts/Missing.ttf") + end + + test "a declared path that is a directory, not a file, fails" do + dir = tmp_style!("") + File.mkdir_p!(Path.join(dir, "priv/fonts")) + m = Map.put(@valid, :assets, %{fonts: ["priv/fonts"]}) + + assert_raise ArgumentError, fn -> Style.fonts!([{dir, m}]) end + end + + test "an invalid manifest fails instead of contributing no fonts" do + m = Map.put(@valid, :assets, %{fonts: "priv/fonts/X.ttf"}) + + assert_raise ArgumentError, ~r/invalid style manifest/, fn -> + Style.fonts!([{tmp_style!(""), m}]) + end + end + end + describe "load/1" do test "loads a manifest file from a style dir" do dir = tmp_style!(inspect(@valid)) @@ -130,6 +202,12 @@ defmodule MobDev.StyleTest do dir end + defp touch!(dir, rel) do + path = Path.join(dir, rel) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, "font") + end + # Style.activated_names/default_style read mob.exs from cwd (absent in a tmp # dir → Application-env fallback, which the tests control). defp in_tmp_project(fun) do