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
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ All extensions follow the Asciidoctor extension API. The two main extension type

### Preprocessor Inheritance Hierarchy

- `LutamlPreprocessor` — handles `[lutaml]`, `[lutaml_express]`, `[lutaml_express_liquid]` blocks. Parses EXPRESS files via the `lutaml`/`expressir` gems, builds Liquid contexts, and renders templates.
- `BasePreprocessor` — abstract base for EXPRESS and XSD preprocessors. Uses Template Method pattern: subclasses implement `lutaml_liquid?`, `load_lutaml_file`, `index_type_name` and may override `update_repo`, `template`, `reorder_schemas`.
- `LutamlPreprocessor` < `BasePreprocessor` — handles `[lutaml]`, `[lutaml_express]`, `[lutaml_express_liquid]` blocks. Adds EXPRESS-specific `update_repo` (cache unwrap, remark decoration), Liquid environment with custom tags/filters, schema reordering.
- `LutamlXsdPreprocessor` < `BasePreprocessor` — handles `[lutaml_xsd]` blocks. Parses XSD files via `lutaml-model`, double-newline template joins for Asciidoctor paragraph breaks.
- `LutamlUmlDatamodelDescriptionPreprocessor` and `LutamlEaXmiPreprocessor` — both include `LutamlEaXmiBase`, which handles XMI parsing via `lutaml` gem and renders using bundled Liquid templates.
- `LutamlXmiUmlPreprocessor` — another XMI-based preprocessor with its own macro regex.
- `BaseStructuredTextPreprocessor` — base for `[yaml2text]`, `[json2text]`, `[data2text]` blocks. Its subclasses (`Yaml2TextPreprocessor`, `Json2TextPreprocessor`, `Data2TextPreprocessor`) differ only in how they load content (YAML vs JSON vs auto-detect). The `Content` module provides the actual parsing logic.
- `BaseStructuredTextPreprocessor` — base for `[yaml2text]`, `[json2text]`, `[data2text]` blocks. Its subclasses (`Yaml2TextPreprocessor`, `Json2TextPreprocessor`, `Data2TextPreprocessor`) differ only in how they load content (YAML vs JSON vs auto-detect).

### Key Shared Modules

Expand Down Expand Up @@ -71,8 +73,10 @@ Tests use `metanorma-standoc` as the backend. The spec helper registers all exte

## Key Dependencies

- `lutaml` — core LutaML parser/model library (EXPRESS, UML, XMI formats)
- `lutaml` — core LutaML parser/model library (EXPRESS, UML, XMI, XSD formats)
- `lutaml-model` — LutaML serialization framework (provides XSD parsing, Liquid drops)
- `expressir` — EXPRESS schema parser
- `ogc-gml` — OGC GML dictionary parser
- `liquid` — template rendering engine
- `asciidoctor` — document processing framework
- `canon` — semantic XML comparison for test assertions
8 changes: 7 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ rescue StandardError
nil
end

gem "rake", "~> 13"
gem "canon"
gem "html2doc", github: "metanorma/html2doc", branch: "main"
gem "lutaml"
gem "metanorma", github: "metanorma/metanorma", branch: "main"
gem "metanorma-standoc", github: "metanorma/metanorma-standoc", branch: "main"
gem "rake"
gem "rspec"
gem "rspec-html-matchers"
gem "rubocop"
Expand All @@ -23,3 +28,4 @@ gem "simplecov"
gem "timecop"
gem "vcr"
gem "webmock"

3 changes: 3 additions & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ within a Metanorma document:
* Enterprise Architect exported UML files in XMI format (`*.xmi`)
* LutaML GML Dictionary files (`*.xml`)
* JSON or YAML files (`*.json|*.yml|*.yaml`)
* XML Schema files (`*.xsd`)

== Installation

Expand All @@ -34,6 +35,8 @@ link:docs/usages/lutaml-gml.adoc[Usage with LutaML GML Dictionary by lutaml_gml_

link:docs/usages/json_yaml.adoc[Usage with JSON or YAML files by data2text, yaml2text or json2text]

link:docs/usages/lutaml-xsd.adoc[Usage with XML Schema files by lutaml_xsd]

== Documentation

Please refer to https://www.metanorma.org.
Expand Down
94 changes: 94 additions & 0 deletions docs/usages/lutaml-xsd.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
== Usage with LutaML XSD

=== Overview

The `lutaml_xsd` macro parses *XML Schema (XSD)* files through `lutaml-model`
and exposes the parsed schema object to *Liquid* templates.

=== Syntax

[source,adoc]
-----
[lutaml_xsd,<path_to_xsd>,<context_name>[, option1=value1, option2=value2, ...]]
----
<your Liquid template here>
----
-----

* `<path_to_xsd>`: Path to the XSD file to be processed.
* `<context_name>`: The name of the context variable to use in the template.
* `option1=value1, ...`: Optional parameters (<<options,supported options>>).

[[options]]
=== Options

* `location`: Base URL or path for resolving `<xs:import>` and `<xs:include>`
statements in the XSD. When omitted, the directory of `<path_to_xsd>` is used.

=== Liquid Template Context

The context variable (e.g., `unitsml`) exposes the parsed
`Lutaml::Xml::Schema::Xsd::Schema` object through its Liquid drop.

Commonly used schema collections:

* `element`: List of elements defined in the XSD.
* `complex_type`: List of complex types defined in the XSD.
* `simple_type`, `attribute`, `attribute_group`, `group`, `import`, and
`include`: Other schema components exposed by `lutaml-model`.

Commonly used helpers:

* `elements_sorted_by_name`, `complex_types_sorted_by_name`,
`attribute_groups_sorted_by_name`: Sorted schema collections.
* `used_by`, `child_elements`, `attribute_elements`, and `referenced_type`:
Component helpers exposed by parsed XSD objects.

=== Example: Listing Elements and Complex Types

[source,adoc]
-----
= Elements
[lutaml_xsd,path/to/unitsml.xsd,unitsml]
----
{% for element in unitsml.elements_sorted_by_name %}
Name: *{{ element.name }}*
Type: *{{ element.type }}*
Used by: {{ element.used_by | map: "name" | join: ", " }}
{% endfor %}
----

= ComplexTypes
[lutaml_xsd,path/to/unitsml.xsd,unitsml]
----
{% for complex_type in unitsml.complex_types_sorted_by_name %}
Name: *{{ complex_type.name }}*
Children: {{ complex_type.child_elements | map: "name" | join: ", " }}
Attributes: {{ complex_type.attribute_elements | map: "name" | join: ", " }}
{% endfor %}
----
-----

=== Example: Using with Remote XSD and Options

[source,adoc]
-----
[lutaml_xsd,path/to/omml.xsd,omml, location=https://raw.githubusercontent.com/t-yuki/ooxml-xsd/refs/heads/master]
----
{% for element in omml.element %}
Name: *{{ element.name }}*
Type: *{{ element.type }}*
{% endfor %}
----
-----

=== Use Cases

* Generate documentation for XML schemas.
* Extract and list schema elements and types or other details.
* Customize output using Liquid templates.

=== Notes

* The macro supports local files at `<path_to_xsd>`.
* You can use all standard *Liquid* template features for formatting and logic.
2 changes: 2 additions & 0 deletions lib/metanorma-plugin-lutaml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
require "metanorma/plugin/lutaml/json2_text_preprocessor"
require "metanorma/plugin/lutaml/yaml2_text_preprocessor"
require "metanorma/plugin/lutaml/data2_text_preprocessor"
require "metanorma/plugin/lutaml/base_preprocessor"
require "metanorma/plugin/lutaml/lutaml_preprocessor"
require "metanorma/plugin/lutaml/lutaml_xsd_preprocessor"
require "metanorma/plugin/lutaml/lutaml_uml_datamodel_description_preprocessor"
require "metanorma/plugin/lutaml/lutaml_ea_xmi_preprocessor"
require "metanorma/plugin/lutaml/lutaml_xmi_uml_preprocessor"
Expand Down
221 changes: 221 additions & 0 deletions lib/metanorma/plugin/lutaml/base_preprocessor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# frozen_string_literal: true

require "liquid"
require "asciidoctor"
require "asciidoctor/reader"
require "metanorma/plugin/lutaml/utils"
require "metanorma/plugin/lutaml/asciidoctor/preprocessor"

module Metanorma
module Plugin
module Lutaml
# Base preprocessor for LutaML format-specific preprocessors.
#
# Subclasses must implement:
# - #lutaml_liquid?(line) — match the macro header line
# - #load_lutaml_file(document, file_path, options)
# parse format-specific input
#
# Subclasses may override:
# - #index_type_name — human-readable format name for error messages
# - #update_repo(options, repo) — transform parsed repo before rendering
# - #template(lines) — parse Liquid template lines
# - #reorder_schemas(repo_liquid, options) — reorder/filter schemas
class BasePreprocessor < ::Asciidoctor::Extensions::Preprocessor
include Utils

def process(document, reader)
input_lines = Asciidoctor::PreprocessorNoIfdefsReader
.new(document, reader.lines).readlines.to_enum

express_indexes = Utils.parse_document_express_indexes(
document, input_lines
)

result_content = process_input_lines(
document: document,
input_lines: input_lines,
express_indexes: express_indexes,
)

Asciidoctor::PreprocessorNoIfdefsReader.new(document, result_content)
end

protected

def load_lutaml_file(_document, _file_path, _options)
raise NotImplementedError,
"#{self.class}#load_lutaml_file must be implemented"
end

def lutaml_liquid?(_line)
raise NotImplementedError,
"#{self.class}#lutaml_liquid? must be implemented"
end

def index_type_name
raise NotImplementedError,
"#{self.class}#index_type_name must be implemented"
end

def update_repo(_options, repo)
repo
end

def template(lines)
::Liquid::Template.parse(lines.join("\n"))
end

def reorder_schemas(repo_liquid, _options)
repo_liquid
end

def index_missing_message(path)
"Unable to load #{index_type_name} file for `#{path}`, " \
"please specify the full path."
end

private

def process_input_lines(document:, input_lines:, express_indexes:)
result = []
loop do
result.push(
*process_text_blocks(document, input_lines, express_indexes),
)
end
result
end

def process_text_blocks(document, input_lines, express_indexes) # rubocop:disable Metrics/AbcSize
line = input_lines.next
block_header_match = lutaml_liquid?(line)

return [line] unless block_header_match

index_names = block_header_match[:index_names].split(";").map(&:strip)
context_name = block_header_match[:context_name].strip
options = (block_header_match[:options] &&
parse_options(block_header_match[:options].to_s.strip)) || {}

end_mark = input_lines.next

render_liquid_template(
document: document,
lines: extract_block_lines(input_lines, end_mark),
index_names: index_names,
context_name: context_name,
options: options,
indexes: express_indexes,
)
end

def extract_block_lines(input_lines, end_mark)
block = []
while (block_line = input_lines.next) != end_mark
block.push(block_line)
end
block
end

# rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/ParameterLists
def gather_context_liquid_items(index_names:, document:,
indexes:, options: {})
index_names.map do |path|
if indexes[path] && indexes[path][:model]
repo = indexes[path][:model]
repo = update_repo(options, repo)
indexes[path][:liquid_drop] ||= repo.to_liquid
else
full_path = Utils.relative_file_path(document, path)
unless File.file?(full_path)
raise StandardError, index_missing_message(path)
end

repo = load_lutaml_file(document, path, options)
repo = update_repo(options, repo)
indexes[path] = { liquid_drop: repo.to_liquid }
end

indexes[path]
end
end
# rubocop:enable Metrics/AbcSize,Metrics/MethodLength,Metrics/ParameterLists

def render_liquid_template(document:, lines:, context_name:, # rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/ParameterLists
index_names:, options:, indexes:)
options = process_options(document, options)

all_items = gather_context_liquid_items(
index_names: index_names, document: document, indexes: indexes,
options: options.merge("document" => document)
)

include_paths = [Utils.relative_file_path(document, "")]
options["include_path"]&.split(",")&.each do |path|
include_paths.push(Utils.relative_file_path(document, path))
end

file_system = ::Metanorma::Plugin::Lutaml::Liquid::LocalFileSystem
.new(include_paths, ["%s.liquid", "_%s.liquid", "_%s.adoc"])

parsed_template = template(lines)
parsed_template.registers[:file_system] = file_system

all_items.map do |item|
parsed_template.assigns[context_name] = item[:liquid_drop]
parsed_template.assigns["ordered_schemas"] = reorder_schemas(
item[:liquid_drop], options
)
parsed_template.assigns["schemas_order"] =
options["selected_schemas"]
parsed_template.render
end.flatten
rescue StandardError => e
::Metanorma::Util.log(
"[#{self.class.name}] Failed to parse LutaML block: #{e.message}",
:error,
)
raise e
end

def process_options(document, options)
if (config_yaml_path = options.delete("config_yaml"))
config = read_config_yaml_file(document, config_yaml_path)
if config["selected_schemas"]
options["selected_schemas"] =
config["selected_schemas"]
end
end
options
end

def read_config_yaml_file(document, file_path) # rubocop:disable Metrics/MethodLength
return {} unless file_path

relative_file_path = Utils.relative_file_path(document, file_path)
config_yaml = YAML.safe_load(
File.read(relative_file_path, encoding: "UTF-8"),
)

return {} unless config_yaml["schemas"]

unless config_yaml["schemas"].is_a?(Hash)
raise StandardError,
"[lutaml_express_liquid] attribute `config_yaml` must " \
"point to a YAML file with the `schemas` key as a hash."
end

{ "selected_schemas" => config_yaml["schemas"].keys }
end

def parse_options(options_string)
options_string
.to_s
.scan(/,\s*([^=]+?)=(\s*[^,]+)/)
.to_h { |elem| elem.map(&:strip) }
end
end
end
end
end
Loading
Loading