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
120 changes: 90 additions & 30 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,70 +4,123 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

`mml` is a Ruby gem that provides MathML 3 and MathML 4 XML parsing and serialization. It maps MathML elements into Ruby model classes using the `lutaml-model` framework. Part of the [Plurimath](https://github.com/plurimath/mml) ecosystem.
`mml` is a Ruby gem that provides MathML 2, MathML 3, and MathML 4 XML parsing and serialization. It maps MathML elements into Ruby model classes using the `lutaml-model` framework. Part of the [Plurimath](https://github.com/plurimath/mml) ecosystem.

## Commands

```bash
rake # Run specs + rubocop (default task)
bundle exec rspec # Run tests
bundle exec rspec spec/mml_spec.rb:42 # Run single test by line
bundle exec rspec --only-failures # Run only previously failing tests
bundle exec rubocop # Lint
bundle exec rubocop -a # Auto-fix lint issues
bin/console # IRB with gem loaded
rake # Run specs + rubocop (default task)
bundle exec rspec # Run all tests
bundle exec rspec spec/mml/v3/msub_spec.rb:14 # Run single test by line
bundle exec rspec --only-failures # Previously failing tests only
bundle exec rubocop # Lint
bundle exec rubocop -a # Auto-fix lint issues
bin/console # IRB with gem loaded
```

## Versioned Architecture

**MathML 3 vs MathML 4:** The gem maintains separate class hierarchies for MathML 3 (`Mml::V3::`) and MathML 4 (`Mml::V4::`). Users must reference the versioned namespace explicitly — no backward-compat aliases.
The gem maintains **three** separate class hierarchies: `Mml::V2::` (MathML 2), `Mml::V3::` (MathML 3), and `Mml::V4::` (MathML 4). Users must reference the versioned namespace explicitly — no backward-compat aliases.

```ruby
Mml.parse(input) # Default: MathML 3
Mml.parse(input, version: 3) # Explicit MathML 3
Mml.parse(input, version: 4) # MathML 4 with intent/arg attributes
Mml.parse(input) # Default: MathML 3
Mml.parse(input, version: 2) # MathML 2
Mml.parse(input, version: 3) # Explicit MathML 3
Mml.parse(input, version: 4) # MathML 4 with intent/arg attributes
```

**Directory structure:**
- `lib/mml/v3/` — MathML 3 element classes (original)
- `lib/mml/v4/` — MathML 4 element classes (intent/arg added, deprecated attrs removed)
- `lib/mml/base/` — version-agnostic element modules, mixed into each version's class via `include Base::XYZ`. The single source of truth for the XML mapping DSL.
- `lib/mml/v2/`, `lib/mml/v3/`, `lib/mml/v4/` — per-version class declarations (each ~3 lines: declare class, mix in base module, register).

**Key difference:** MathML 4 adds `intent`, `arg`, `displaystyle`, and `scriptlevel` attributes as universal presentation attributes for accessibility markup.
**Key difference (V3 → V4):** MathML 4 adds `intent`, `arg`, `displaystyle`, and `scriptlevel` as universal presentation attributes for accessibility markup; removes some deprecated attributes.

**No hidden delegation:** The `Mml` module does not alias or delegate constants. Use `Mml::V3::Math`, `Mml::V4::Mi`, etc. directly.

## Entry Points

- `Mml.parse(input, version: N)` — parse XML, returns `Mml::V3::Math` or `Mml::V4::Math` object graph
- `Mml.parse(input, version: N)` — parse XML, returns `Mml::V{2,3,4}::Math` object graph
- `Mml::V3.parse(input)` / `Mml::V4.parse(input)` — version-specific parsing
- `Mml::V4::Math.from_xml(input)` — directly parse with v4 classes
- Call `to_xml` on any element to serialize back

## Pattern
## Element Mapping Pattern

Each MathML element is a `Mml::V3::` or `Mml::V4::` class inheriting from `Lutaml::Model::Serializable` with an `xml do...end` DSL block. Two element types:
- **Leaf elements** (e.g., `Mi`, `Mn`, `Mo`): use `map_content to: :value` for text content
- **Container elements** (e.g., `Math`, `Mrow`, `Mfrac`): use `mixed_content` to accept arbitrary child elements
Each MathML element is a `Lutaml::Model::Serializable` subclass with an `xml do...end` DSL block. The XML mapping lives in `lib/mml/base/<tag>.rb` as a module included into each version's class (e.g., `Mml::V3::Msub` includes `Mml::Base::Msub`).

**CommonAttributes:** A `no_root` Lutaml model imported into container elements via `import_model`. It dynamically creates `#{tag}_value` attributes for each tag in `Configuration::SUPPORTED_TAGS`. Classes that receive it are listed in `Configuration::COMMON_ATTRIBUTES_CLASSES`.
### Content model — `mixed_content` vs `ordered` vs `map_content`

**Autoloading:** Each version (`lib/mml/v3.rb`, `lib/mml/v4.rb`) autoloads its element classes. `CommonAttributes` is required after all classes exist, then `update_attributes` mixes it into the configured classes.
This is the most important decision when adding or editing an element. Pick based on the **schema** (see `schemas/mathml3/`, `schemas/mathml4/`, `reference-docs/mathml-source/`):

**Namespace:** Both versions use the same URI (`http://www.w3.org/1998/Math/MathML`) — MathML 4 chose backward compatibility over a new namespace.
| Schema content model | Use | Examples |
|---------------------------------------------------|--------------------------------------|--------------------------------|
| True mixed `(#PCDATA \| mglyph \| malignmark)*` | `mixed_content` + `map_content` | `Mi`, `Mn`, `Mo`, `Ms`, `Mtext`|
| Element-only (`ImpliedMrow`, `<xs:sequence>`, etc.) | `ordered` | `Msub`, `Mfrac`, `Mrow`, `Math`, `Munder`, `Mfenced`, `Msgroup`, … |

**Why this matters:** lutaml-model's `each_mixed_content` iteration exposes all children to consumers (Plurimath's translator does positional indexing like `children[0]`, `children[1]` for `msub`/`mfrac`/etc.).
- Under `mixed_content`, whitespace between elements is yielded as a String child, shifting positional indices.
- Under `ordered`, whitespace-only text nodes are skipped — only elements and non-whitespace text are yielded.

If you mistakenly use `mixed_content` on an element-only schema element, downstream consumers see phantom whitespace children and silently misinterpret the tree (e.g., render `<msub><mi>t</mi><mn>90</mn></msub>` with whitespace between children as base=whitespace, sub=`<mi>t</mi>`, dropping `<mn>90</mn>`).

**Verification workflow for any element edit:**
```bash
grep -A15 'name="<tag>"' schemas/mathml3/mathml3-presentation.xsd schemas/mathml3/mathml3-common.xsd
grep -A3 '<tag>\s*=\s*element' schemas/mathml4/mathml4-core.rnc schemas/mathml4/mathml4-presentation.rnc
```

If the schema says `ImpliedMrow`, `MathExpression, MathExpression`, `MstackExpression*`, `TableRowExpression*`, etc. (any element-only sequence), use `ordered`. If it says `(#PCDATA | mglyph | malignmark)*`, use `mixed_content` + `map_content` + `map_element` for the inline children.

### Token elements are the only true mixed content

Per MathML schema, token elements (`mi`, `mn`, `mo`, `ms`, `mtext`) accept `(#PCDATA | mglyph | malignmark)*` — text interleaved with inline elements. They use:

```ruby
attribute :value, :string, collection: true # collection required for mixed_content
attribute :mglyph_value, :mglyph, collection: true
attribute :malignmark_value, :malignmark, collection: true

xml do
element "<tag>"
mixed_content
map_content to: :value
map_element "mglyph", to: :mglyph_value
map_element "malignmark", to: :malignmark_value
end
```

`value` is a `String` **collection** (Array) because text can be split by intervening inline elements (e.g., `<mi>x<malignmark/>y</mi>` → `["x", "y"]`).

### CommonAttributes

A `no_root` Lutaml model imported into container elements via `import_model`. It dynamically creates `#{tag}_value` attributes for each tag in `Configuration::SUPPORTED_TAGS`. Classes that receive it are listed in `Configuration::COMMON_ATTRIBUTES_CLASSES`.

### Per-file registration

Each `lib/mml/v{2,3,4}/<tag>.rb` ends with `Configuration.register_model(Klass, id: :tag)` so the type is registered as soon as the file is loaded (eager via `require_relative` at the bottom of each version file).

**Namespace:** All versions use the same URI (`http://www.w3.org/1998/Math/MathML`) — MathML 4 chose backward compatibility over a new namespace.

## Adapter

`Mml.default_adapter` returns `:oga` under Opal, otherwise delegates to `Lutaml::Model::Config.xml_adapter_type` (defaults to `:nokogiri`). Specs pin `:nokogiri` in `spec_helper.rb`. Users can override globally via `Lutaml::Model::Config`.

## Spec Structure

- `spec/mml_spec.rb` — tests `Mml::V3` and `Mml::V4` with separate fixture directories
- `spec/fixtures/with_namespace/` — v3 fixtures (MathML 3)
- `spec/fixtures/with_namespace_prefix/` — v3 fixtures with namespace prefix
- `spec/fixtures/v4/` — v4 fixtures (MathML 4 with intent attributes)
- `spec/mml/v2/`, `spec/mml/v3/`, `spec/mml/v4/` — per-version element specs (round-trip + attribute preservation)
- `spec/mml/v3_spec.rb`, `spec/mml/v4_spec.rb` — whole-testsuite round-trip tests against `spec/fixtures/mml3-testsuite/` and `spec/fixtures/mmlcore-testsuite/`
- `spec/mml/ordered_content_spec.rb` — regression specs locking in the mixed_content vs ordered distinction (whitespace handling + token element inline children)
- `spec/mml/adapter_configuration_spec.rb` — adapter delegation
- `spec/context_support_spec.rb`, `spec/lutaml_default_register_spec.rb` — registry/context behavior
- `spec/fixtures/mml2-testsuite/`, `mml3-testsuite/`, `mmlcore-testsuite/` — W3C test suites (submodules)
- `spec/fixtures/v2/`, `v4/` — version-specific fixtures

Specs use the `:nokogiri` Lutaml adapter (configured in `spec_helper.rb`). Runtime uses `:ox` adapter by default.
Specs use `canon`'s `be_xml_equivalent_to` matcher with the `:spec_friendly` profile (whitespace-tolerant). When adding a regression spec for an ordering bug, **explicitly test the inter-element-whitespace scenario** — the suite's whitespace tolerance masks positional bugs.

## Key Dependencies

- `lutaml-model` (~ 0.8.0) — data mapper framework; all element classes inherit from `Lutaml::Model::Serializable`
- `moxml` — XML parsing library (uses `:ox` adapter by default)
- `lutaml-model` (~> 0.8.0) — data mapper framework; all element classes inherit from `Lutaml::Model::Serializable`
- `moxml` — XML parsing backbone (adapter selected via `Lutaml::Model::Config`)
- `canon` — XML comparison for specs

## Conventions
Expand All @@ -77,3 +130,10 @@ Specs use the `:nokogiri` Lutaml adapter (configured in `spec_helper.rb`). Runti
- CI workflows are auto-generated by Cimas — do not edit manually
- `Gemfile.lock` is gitignored; dependencies come from the gemspec
- Type signatures exist in `sig/mml.rbs`

## Reference Materials

- `schemas/mathml2/`, `schemas/mathml3/`, `schemas/mathml4/` — official W3C schemas (XSD for 2/3, RelaxNG for 4). **Authoritative** source for content models and attribute lists.
- `reference-docs/mathml-source/` — W3C spec source XML (presentation-markup.xml, validation-grammar.xml, etc.)

When deciding whether an element should have `mixed_content`, `ordered`, or `map_content`, **always** verify against the schema first.
2 changes: 1 addition & 1 deletion lib/mml/base/maction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "maction"
mixed_content
ordered

map_attribute "mathcolor", to: :mathcolor
map_attribute "mathbackground", to: :mathbackground
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/math.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "math"
mixed_content
ordered

map_attribute :display, to: :display
map_attribute "mode", to: :mode
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/menclose.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "menclose"
mixed_content
ordered

map_attribute "mathcolor", to: :mathcolor
map_attribute "mathbackground", to: :mathbackground
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/merror.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "merror"
mixed_content
ordered

map_attribute "mathcolor", to: :mathcolor
map_attribute "mathbackground", to: :mathbackground
Expand Down
5 changes: 1 addition & 4 deletions lib/mml/base/mfenced.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,13 @@ def self.included(klass)
attribute :mathbackground, :string
attribute :separators, :string
attribute :mathcolor, :string
attribute :content, :string, collection: true
attribute :close, :string
attribute :open, :string

xml do
namespace Mml::Namespace
element "mfenced"
mixed_content

map_content to: :content
ordered

map_attribute "mathbackground", to: :mathbackground
map_attribute "separators", to: :separators, render_empty: true
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/mfrac.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "mfrac"
mixed_content
ordered

map_attribute "mathcolor", to: :mathcolor
map_attribute "mathbackground", to: :mathbackground
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/mfraction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "mfraction"
mixed_content
ordered

map_attribute "mathcolor", to: :mathcolor
map_attribute "mathbackground", to: :mathbackground
Expand Down
2 changes: 2 additions & 0 deletions lib/mml/base/mi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def self.included(klass)
attribute :mathsize, :string
attribute :mathvariant, :string
attribute :mglyph_value, :mglyph, collection: true
attribute :malignmark_value, :malignmark, collection: true
attribute :lang, :string

xml do
Expand All @@ -27,6 +28,7 @@ def self.included(klass)
map_attribute "mathvariant", to: :mathvariant
map_attribute "xml:lang", to: :lang
map_element "mglyph", to: :mglyph_value
map_element "malignmark", to: :malignmark_value
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/mlabeledtr.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "mlabeledtr"
mixed_content
ordered

map_attribute "mathbackground", to: :mathbackground
map_attribute "columnalign", to: :columnalign
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/mlongdiv.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "mlongdiv"
mixed_content
ordered

map_attribute "mathbackground", to: :mathbackground
map_attribute "longdivstyle", to: :longdivstyle
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/mmultiscripts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "mmultiscripts"
mixed_content
ordered

map_attribute "mathcolor", to: :mathcolor
map_attribute "mathbackground", to: :mathbackground
Expand Down
2 changes: 2 additions & 0 deletions lib/mml/base/mn.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def self.included(klass)
attribute :mathvariant, :string
attribute :mathsize, :string
attribute :mglyph_value, :mglyph, collection: true
attribute :malignmark_value, :malignmark, collection: true

xml do
namespace Mml::Namespace
Expand All @@ -25,6 +26,7 @@ def self.included(klass)
map_attribute "mathvariant", to: :mathvariant
map_attribute "mathsize", to: :mathsize
map_element "mglyph", to: :mglyph_value
map_element "malignmark", to: :malignmark_value
end
end
end
Expand Down
7 changes: 6 additions & 1 deletion lib/mml/base/mo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module Mo
# Use fully qualified names (e.g., Mml::Namespace).
def self.included(klass)
klass.class_eval do
attribute :value, :string
attribute :value, :string, collection: true
attribute :mathcolor, :string
attribute :mathbackground, :string
attribute :mathvariant, :string
Expand All @@ -34,11 +34,14 @@ def self.included(klass)
attribute :indentshiftfirst, :string
attribute :indentalignlast, :string
attribute :indentshiftlast, :string
attribute :mglyph_value, :mglyph, collection: true
attribute :malignmark_value, :malignmark, collection: true

# rubocop:disable Metrics/BlockLength
xml do
namespace Mml::Namespace
element "mo"
mixed_content

map_content to: :value
map_attribute "form", to: :form
Expand Down Expand Up @@ -67,6 +70,8 @@ def self.included(klass)
map_attribute "indentalignfirst", to: :indentalignfirst
map_attribute "indentshiftfirst", to: :indentshiftfirst
map_attribute "linebreakmultchar", to: :linebreakmultchar
map_element "mglyph", to: :mglyph_value
map_element "malignmark", to: :malignmark_value
end
# rubocop:enable Metrics/BlockLength
end
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/mover.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "mover"
mixed_content
ordered

map_attribute "mathbackground", to: :mathbackground
map_attribute "mathcolor", to: :mathcolor
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/mpadded.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "mpadded"
mixed_content
ordered

map_attribute "mathbackground", to: :mathbackground
map_attribute "mathcolor", to: :mathcolor
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/mphantom.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "mphantom"
mixed_content
ordered

map_attribute "mathcolor", to: :mathcolor
map_attribute "mathbackground", to: :mathbackground
Expand Down
2 changes: 1 addition & 1 deletion lib/mml/base/mroot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def self.included(klass)
xml do
namespace Mml::Namespace
element "mroot"
mixed_content
ordered

map_attribute "mathcolor", to: :mathcolor
map_attribute "mathbackground", to: :mathbackground
Expand Down
4 changes: 1 addition & 3 deletions lib/mml/base/mrow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,11 @@ def self.included(klass)
klass.class_eval do
attribute :mathbackground, :string
attribute :mathcolor, :string
attribute :content, :string, collection: true
xml do
namespace Mml::Namespace
element "mrow"
mixed_content
ordered

map_content to: :content
map_attribute "mathcolor", to: :mathcolor
map_attribute "mathbackground", to: :mathbackground
end
Expand Down
4 changes: 4 additions & 0 deletions lib/mml/base/ms.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ def self.included(klass)
attribute :lquote, :string
attribute :rquote, :string
attribute :value, :string, collection: true
attribute :mglyph_value, :mglyph, collection: true
attribute :malignmark_value, :malignmark, collection: true
xml do
namespace Mml::Namespace
element "ms"
Expand All @@ -26,6 +28,8 @@ def self.included(klass)
map_attribute "mathvariant", to: :mathvariant
map_attribute "lquote", to: :lquote, render_empty: true
map_attribute "rquote", to: :rquote, render_empty: true
map_element "mglyph", to: :mglyph_value
map_element "malignmark", to: :malignmark_value
end
end
end
Expand Down
Loading
Loading