Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
54 changes: 54 additions & 0 deletions decisions/2026-10-13-style-packages-bundle-fonts.md
Original file line number Diff line number Diff line change
@@ -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://<plugin>/<file>` 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.
15 changes: 14 additions & 1 deletion lib/mix/tasks/mob.styles.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -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
Expand All @@ -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
14 changes: 9 additions & 5 deletions lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5523,19 +5523,23 @@ 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 =
for %{fonts: fonts} <- MobDev.Plugin.Merge.assets(MobDev.Plugin.activated()),
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.
Expand Down Expand Up @@ -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
Expand Down
84 changes: 75 additions & 9 deletions lib/mob_dev/style.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
78 changes: 78 additions & 0 deletions test/mob_dev/native_build_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading