metanorma-mirror 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +25 -0
  3. data/README.adoc +41 -0
  4. data/lib/metanorma/mirror/class_table.rb +50 -0
  5. data/lib/metanorma/mirror/default_registry.rb +23 -0
  6. data/lib/metanorma/mirror/handler_registry.rb +60 -0
  7. data/lib/metanorma/mirror/handler_result.rb +37 -0
  8. data/lib/metanorma/mirror/handlers/admonition.rb +18 -0
  9. data/lib/metanorma/mirror/handlers/example.rb +21 -0
  10. data/lib/metanorma/mirror/handlers/figure.rb +53 -0
  11. data/lib/metanorma/mirror/handlers/formula.rb +35 -0
  12. data/lib/metanorma/mirror/handlers/imagemap.rb +43 -0
  13. data/lib/metanorma/mirror/handlers/inline/catalog.rb +36 -0
  14. data/lib/metanorma/mirror/handlers/inline/rich_html_renderer.rb +132 -0
  15. data/lib/metanorma/mirror/handlers/inline/text_extractor.rb +114 -0
  16. data/lib/metanorma/mirror/handlers/inline.rb +155 -0
  17. data/lib/metanorma/mirror/handlers/list.rb +110 -0
  18. data/lib/metanorma/mirror/handlers/note.rb +26 -0
  19. data/lib/metanorma/mirror/handlers/paragraph.rb +19 -0
  20. data/lib/metanorma/mirror/handlers/quote.rb +16 -0
  21. data/lib/metanorma/mirror/handlers/review.rb +19 -0
  22. data/lib/metanorma/mirror/handlers/section.rb +149 -0
  23. data/lib/metanorma/mirror/handlers/sourcecode.rb +27 -0
  24. data/lib/metanorma/mirror/handlers/structural.rb +68 -0
  25. data/lib/metanorma/mirror/handlers/svgmap.rb +52 -0
  26. data/lib/metanorma/mirror/handlers/table.rb +81 -0
  27. data/lib/metanorma/mirror/handlers/term.rb +100 -0
  28. data/lib/metanorma/mirror/handlers.rb +62 -0
  29. data/lib/metanorma/mirror/id_strategy/positional.rb +151 -0
  30. data/lib/metanorma/mirror/id_strategy/preserve.rb +12 -0
  31. data/lib/metanorma/mirror/id_strategy.rb +34 -0
  32. data/lib/metanorma/mirror/math_util.rb +53 -0
  33. data/lib/metanorma/mirror/metadata.rb +37 -0
  34. data/lib/metanorma/mirror/model/container.rb +50 -0
  35. data/lib/metanorma/mirror/model/factory.rb +34 -0
  36. data/lib/metanorma/mirror/model/guide.rb +29 -0
  37. data/lib/metanorma/mirror/model/leaf.rb +17 -0
  38. data/lib/metanorma/mirror/model/mark.rb +39 -0
  39. data/lib/metanorma/mirror/model/node.rb +42 -0
  40. data/lib/metanorma/mirror/model/soft_break.rb +32 -0
  41. data/lib/metanorma/mirror/model/text.rb +27 -0
  42. data/lib/metanorma/mirror/model.rb +16 -0
  43. data/lib/metanorma/mirror/output/builder.rb +39 -0
  44. data/lib/metanorma/mirror/output/formats/base_format.rb +90 -0
  45. data/lib/metanorma/mirror/output/formats/inline_format.rb +142 -0
  46. data/lib/metanorma/mirror/output/formats.rb +53 -0
  47. data/lib/metanorma/mirror/output/pipeline.rb +109 -0
  48. data/lib/metanorma/mirror/output/pipeline_context.rb +22 -0
  49. data/lib/metanorma/mirror/output.rb +12 -0
  50. data/lib/metanorma/mirror/rewriter.rb +123 -0
  51. data/lib/metanorma/mirror/safe_attr.rb +16 -0
  52. data/lib/metanorma/mirror/serialization/json_serializer.rb +27 -0
  53. data/lib/metanorma/mirror/serialization/yaml_serializer.rb +20 -0
  54. data/lib/metanorma/mirror/serialization.rb +10 -0
  55. data/lib/metanorma/mirror/transformer.rb +110 -0
  56. data/lib/metanorma/mirror/version.rb +7 -0
  57. data/lib/metanorma/mirror.rb +98 -0
  58. metadata +197 -0
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Model
6
+ class Container < Node
7
+ def initialize(type: nil, attrs: {}, content: [], **)
8
+ super(type: type, attrs: attrs, **)
9
+ self.content = Array(content)
10
+ end
11
+
12
+ def container?
13
+ true
14
+ end
15
+
16
+ def text_content
17
+ content.map do |item|
18
+ item.is_a?(String) ? item : item.text_content
19
+ end.join
20
+ end
21
+
22
+ def accept_rewriter(rewriter)
23
+ rewriter.rewrite_container(self)
24
+ end
25
+ end
26
+
27
+ # content is declared after the sibling node classes exist: the
28
+ # union member list references Container itself, and lutaml-model
29
+ # resolves union members eagerly at declaration time. Deserialized
30
+ # children dispatch through Factory (the child's shape — content
31
+ # array vs leaf vs text — picks the class), which is why the union
32
+ # never receives raw hashes on the way in.
33
+ class Container
34
+ attribute :content, [Text, SoftBreak, Leaf, Container, :string],
35
+ collection: true, default: -> { [] }
36
+
37
+ key_value do
38
+ map "content", to: :content, render_empty: false,
39
+ with: { from: :content_from_mapping }
40
+ end
41
+
42
+ def content_from_mapping(model, value)
43
+ model.content = Array(value).map do |child|
44
+ child.is_a?(Hash) ? Factory.from_hash(child) : child
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Model
6
+ # Semantic dispatcher: the wire's "type" and "content" shape pick
7
+ # the model class; deserialization itself is the framework's
8
+ # from_hash on the chosen class.
9
+ class Factory
10
+ INVALID_INPUT = "Factory.from_hash expects a Hash, got %<class>s"
11
+ MISSING_TYPE = "Factory.from_hash requires a 'type' key, got %<hash>s"
12
+
13
+ def self.from_hash(hash)
14
+ unless hash.is_a?(Hash)
15
+ raise ArgumentError, format(INVALID_INPUT, class: hash.class)
16
+ end
17
+
18
+ case hash["type"]
19
+ when "text" then Text.from_hash(hash)
20
+ when "soft_break" then SoftBreak.from_hash(hash)
21
+ when nil
22
+ raise ArgumentError, format(MISSING_TYPE, hash: hash.inspect)
23
+ else
24
+ if hash["content"].is_a?(Array)
25
+ Container.from_hash(hash)
26
+ else
27
+ Leaf.from_hash(hash)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Model
6
+ class Guide < Lutaml::Model::Serializable
7
+ attribute :content, [Container, :string]
8
+ attribute :meta, :hash, default: -> { {} }
9
+ attribute :title, :string
10
+
11
+ key_value do
12
+ map "content", to: :content
13
+ map "meta", to: :meta, render_empty: false
14
+ map "title", to: :title, render_nil: false
15
+ end
16
+
17
+ # the parsed source document rides along for output formats; it
18
+ # is deliberately outside the serialization contract
19
+ attr_accessor :document
20
+
21
+ def initialize(content: nil, meta: {}, title: nil, document: nil,
22
+ **)
23
+ super(content: content, meta: meta, title: title, **)
24
+ self.document = document
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Model
6
+ class Leaf < Node
7
+ def leaf?
8
+ true
9
+ end
10
+
11
+ def accept_rewriter(rewriter)
12
+ rewriter.rewrite_leaf(self)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Model
6
+ class Mark < Lutaml::Model::Serializable
7
+ attribute :type, :string
8
+ attribute :attrs, :hash, default: -> { {} }
9
+
10
+ key_value do
11
+ map "type", to: :type
12
+ map "attrs", to: :attrs, render_empty: false
13
+ end
14
+
15
+ def initialize(type: nil, attrs: {}, **)
16
+ # wire keys are strings; handlers build attrs with symbol keys
17
+ super(type: type, attrs: (attrs || {}).transform_keys(&:to_s), **)
18
+ end
19
+
20
+ def [](key)
21
+ attrs[key.to_s]
22
+ end
23
+
24
+ def []=(key, value)
25
+ attrs[key.to_s] = value
26
+ end
27
+
28
+ def set_attr(key, value)
29
+ attrs[key.to_s] = value
30
+ self
31
+ end
32
+
33
+ def fetch(key, default = nil, &)
34
+ attrs.fetch(key.to_s, default, &)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Model
6
+ class Node < Lutaml::Model::Serializable
7
+ attribute :type, :string
8
+ attribute :attrs, :hash, default: -> { {} }
9
+
10
+ key_value do
11
+ map "type", to: :type
12
+ map "attrs", to: :attrs, render_empty: false
13
+ end
14
+
15
+ # **options carries framework construction kwargs
16
+ # (e.g. lutaml_register) through to the base serializer
17
+ def initialize(type: nil, attrs: {}, **)
18
+ # wire keys are strings; handlers and the transformer build
19
+ # attrs with symbol keys
20
+ super(type: type, attrs: (attrs || {}).transform_keys(&:to_s), **)
21
+ end
22
+
23
+ def leaf?
24
+ false
25
+ end
26
+
27
+ def container?
28
+ false
29
+ end
30
+
31
+ def text_content
32
+ ""
33
+ end
34
+
35
+ def accept_rewriter(_rewriter)
36
+ raise NotImplementedError,
37
+ "#{self.class}#accept_rewriter not implemented"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Model
6
+ class SoftBreak < Lutaml::Model::Serializable
7
+ attribute :type, :string, default: -> { "soft_break" }
8
+
9
+ key_value do
10
+ map "type", to: :type, render_default: true
11
+ end
12
+
13
+ # rewriter contract: a soft break carries no attrs or content
14
+ def attrs
15
+ {}
16
+ end
17
+
18
+ def content
19
+ []
20
+ end
21
+
22
+ def text_content
23
+ ""
24
+ end
25
+
26
+ def accept_rewriter(rewriter)
27
+ rewriter.rewrite_soft_break(self)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Model
6
+ class Text < Lutaml::Model::Serializable
7
+ attribute :type, :string, default: -> { "text" }
8
+ attribute :text, :string, default: -> { "" }
9
+ attribute :marks, Mark, collection: true, default: -> { [] }
10
+
11
+ key_value do
12
+ map "type", to: :type, render_default: true
13
+ map "text", to: :text, render_default: true, render_empty: true
14
+ map "marks", to: :marks, render_empty: false
15
+ end
16
+
17
+ def text_content
18
+ text
19
+ end
20
+
21
+ def accept_rewriter(rewriter)
22
+ rewriter.rewrite_text(self)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Model
6
+ autoload :Node, "#{__dir__}/model/node"
7
+ autoload :Container, "#{__dir__}/model/container"
8
+ autoload :Leaf, "#{__dir__}/model/leaf"
9
+ autoload :Text, "#{__dir__}/model/text"
10
+ autoload :SoftBreak, "#{__dir__}/model/soft_break"
11
+ autoload :Mark, "#{__dir__}/model/mark"
12
+ autoload :Guide, "#{__dir__}/model/guide"
13
+ autoload :Factory, "#{__dir__}/model/factory"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Metanorma
6
+ module Mirror
7
+ module Output
8
+ class Builder
9
+ attr_reader :xml_path, :output_path, :format, :options
10
+
11
+ def initialize(xml_path:, output_path:, format: :inline, **options)
12
+ @xml_path = xml_path
13
+ @output_path = output_path
14
+ @format = format
15
+ @options = options
16
+ end
17
+
18
+ def build
19
+ pipeline = Pipeline.new(
20
+ xml_path: @xml_path,
21
+ flavor: @options[:flavor],
22
+ title: @options[:title] || File.basename(@xml_path, ".*"),
23
+ id_strategy: @options[:id_strategy],
24
+ )
25
+
26
+ guide = pipeline.process
27
+
28
+ format_class = Formats.lookup(@format)
29
+ raise ArgumentError, "Unknown format: #{@format}" unless format_class
30
+
31
+ formatter = format_class.new(dist_dir: @options[:dist_dir])
32
+ formatter.write(@output_path, guide, title: @options[:title])
33
+
34
+ @output_path
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Metanorma
6
+ module Mirror
7
+ module Output
8
+ module Formats
9
+ class BaseFormat
10
+ attr_reader :dist_dir
11
+
12
+ def initialize(dist_dir: nil)
13
+ @dist_dir = dist_dir || self.class.default_dist_dir
14
+ end
15
+
16
+ def write(output_path, guide, title: "Metanorma")
17
+ raise NotImplementedError, "#{self.class}#write not implemented"
18
+ end
19
+
20
+ class << self
21
+ attr_accessor :configured_dist_dir
22
+
23
+ def default_dist_dir
24
+ @configured_dist_dir || File.expand_path(
25
+ "../../../../../frontend/dist", __dir__
26
+ )
27
+ end
28
+ end
29
+
30
+ protected
31
+
32
+ def safe_json(data)
33
+ JSON.generate(data).gsub("</script", '<\\/script')
34
+ end
35
+
36
+ def html_boilerplate(title:, body_content:, head_extra: "",
37
+ script_data: nil, app_mount_id: nil)
38
+ Nokogiri::HTML5::Builder.new do |doc|
39
+ doc.html(lang: "en") do
40
+ build_head(doc, title:, head_extra:, script_data:)
41
+ build_body(doc, body_content:, app_mount_id:)
42
+ end
43
+ end.doc.to_html
44
+ end
45
+
46
+ def iife_bundle_path
47
+ File.join(@dist_dir, "app.iife.js")
48
+ end
49
+
50
+ def iife_css_path
51
+ File.join(@dist_dir, "app.css")
52
+ end
53
+
54
+ def iife_bundle_exists?
55
+ File.exist?(iife_bundle_path)
56
+ end
57
+
58
+ private
59
+
60
+ def build_head(doc, title:, head_extra:, script_data:)
61
+ doc.head do
62
+ doc.meta(charset: "UTF-8")
63
+ doc.meta(name: "viewport",
64
+ content: "width=device-width, initial-scale=1.0")
65
+ doc.title { doc.text title.to_s }
66
+ if script_data
67
+ doc.script { doc.text script_data }
68
+ end
69
+ next unless head_extra && !head_extra.empty?
70
+
71
+ doc << Nokogiri::HTML5::DocumentFragment.parse(head_extra)
72
+ end
73
+ end
74
+
75
+ def build_body(doc, body_content:, app_mount_id:)
76
+ doc.body do
77
+ if app_mount_id
78
+ doc.div(id: app_mount_id) do
79
+ doc << Nokogiri::HTML5::DocumentFragment.parse(body_content.to_s)
80
+ end
81
+ else
82
+ doc << Nokogiri::HTML5::DocumentFragment.parse(body_content.to_s)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "nokogiri"
5
+
6
+ module Metanorma
7
+ module Mirror
8
+ module Output
9
+ module Formats
10
+ class InlineFormat < BaseFormat
11
+ # Classic content styles inlined for the no-JS SSR body: reset +
12
+ # typography + block/inline component styles from the classic
13
+ # asset pipeline. Page chrome (layout, print, transitions, dark,
14
+ # cover, header, footer, toc, search, progress, shortcuts,
15
+ # glossary panel, flavor modules) is owned by the SPA and
16
+ # excluded.
17
+ CONTENT_CSS_MODULES = %w[
18
+ base/_reset
19
+ base/_typography
20
+ components/section
21
+ components/note
22
+ components/example
23
+ components/sourcecode
24
+ components/formula
25
+ components/admonition
26
+ components/table
27
+ components/footnote
28
+ components/figure
29
+ components/term
30
+ components/bibliography
31
+ components/inline
32
+ components/index
33
+ ].freeze
34
+
35
+ class << self
36
+ attr_accessor :missing_bundle_warned
37
+ end
38
+
39
+ def write(output_path, guide, title: "Metanorma")
40
+ FileUtils.mkdir_p(File.dirname(output_path))
41
+
42
+ warn_missing_bundle unless iife_bundle_exists?
43
+
44
+ data_script = "window.METANORMA_DATA = #{safe_json(guide.to_hash)};"
45
+ ssr_body = render_ssr_body(guide)
46
+ head_parts = [build_style(classic_content_css)]
47
+ css_inline = read_css_inline
48
+ if css_inline && !css_inline.empty?
49
+ head_parts << build_style(css_inline)
50
+ end
51
+ head_parts << build_script_src("app.iife.js") if iife_bundle_exists?
52
+ head_extra = head_parts.join("\n")
53
+
54
+ html = html_boilerplate(
55
+ title: title,
56
+ body_content: ssr_body,
57
+ head_extra: head_extra,
58
+ script_data: data_script,
59
+ app_mount_id: iife_bundle_exists? ? "metanorma-app" : nil,
60
+ )
61
+
62
+ File.write(output_path, html)
63
+
64
+ if iife_bundle_exists?
65
+ copy_if_exists(iife_bundle_path,
66
+ File.join(File.dirname(output_path),
67
+ "app.iife.js"))
68
+ end
69
+
70
+ output_path
71
+ end
72
+
73
+ private
74
+
75
+ # SSR body is the classic renderer's document body: identical to
76
+ # the static HTML output, embedded as the no-JS / first-paint
77
+ # placeholder the SPA replaces on mount.
78
+ def render_ssr_body(guide)
79
+ document = guide.document
80
+ unless document
81
+ raise ArgumentError,
82
+ "InlineFormat requires a Guide carrying its source document"
83
+ end
84
+
85
+ renderer = Metanorma::Html::Generator.renderer_for(document).new
86
+ renderer.generate_body(document)
87
+ end
88
+
89
+ def classic_content_css
90
+ css_dir = Metanorma::Html::AssetPipeline::CSS_DIR
91
+ CONTENT_CSS_MODULES.filter_map do |mod|
92
+ path = File.join(css_dir, "#{mod}.css")
93
+ File.read(path) if File.exist?(path)
94
+ end.join("\n")
95
+ end
96
+
97
+ def warn_missing_bundle
98
+ return if self.class.missing_bundle_warned
99
+
100
+ warn "metanorma-document: #{iife_bundle_path} not found — " \
101
+ "writing static HTML without the interactive SPA. " \
102
+ "Run `rake build_frontend` to build it."
103
+ self.class.missing_bundle_warned = true
104
+ end
105
+
106
+ def build_style(css)
107
+ Nokogiri::HTML5::Builder.new do |doc|
108
+ doc.style { doc.text css }
109
+ end.doc.root.to_html
110
+ end
111
+
112
+ def build_script_src(src)
113
+ Nokogiri::HTML5::Builder.new do |doc|
114
+ doc.script(src: src)
115
+ end.doc.root.to_html
116
+ end
117
+
118
+ def read_css_inline
119
+ path = iife_css_path
120
+ File.exist?(path) ? File.read(path) : ""
121
+ end
122
+
123
+ def copy_if_exists(src, dst)
124
+ return unless File.exist?(src)
125
+
126
+ unless File.exist?(dst) && FileUtils.identical?(
127
+ src, dst
128
+ )
129
+ FileUtils.cp(src,
130
+ dst)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ Metanorma::Mirror::Output::Formats.register(
140
+ :inline,
141
+ Metanorma::Mirror::Output::Formats::InlineFormat,
142
+ )
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Mirror
5
+ module Output
6
+ module Formats
7
+ autoload :BaseFormat, "#{__dir__}/formats/base_format"
8
+ autoload :InlineFormat, "#{__dir__}/formats/inline_format"
9
+
10
+ # All format modules that should be auto-registered. Adding a new
11
+ # format = adding one entry here. No edits to lookup logic.
12
+ REGISTERED = %i[InlineFormat].freeze
13
+
14
+ class << self
15
+ # Returns the format class registered under `name`, or nil.
16
+ # Triggers autoload of all known format modules on first call.
17
+ def lookup(name)
18
+ ensure_loaded
19
+ format_map[name]
20
+ end
21
+
22
+ def registered?(name)
23
+ ensure_loaded
24
+ format_map.key?(name)
25
+ end
26
+
27
+ def register(name, format_class)
28
+ format_map[name] = format_class
29
+ self
30
+ end
31
+
32
+ def unregister(name)
33
+ format_map.delete(name)
34
+ self
35
+ end
36
+
37
+ private
38
+
39
+ def format_map
40
+ @format_map ||= {}
41
+ end
42
+
43
+ def ensure_loaded
44
+ return if @loaded
45
+
46
+ REGISTERED.each { |mod_name| const_get(mod_name) }
47
+ @loaded = true
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end