metanorma-plugin-asciichem 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 582411f25fabc8527cec867947b12b663e2e3944724910676c56c562e485de3e
4
+ data.tar.gz: d49a888d06069c133646bdc2f7e7ecb628662804a508c35858454d27437af862
5
+ SHA512:
6
+ metadata.gz: d51b92da35d3cf24e9d1da8acca09f8d95abf1f98e6ed9aa3b9e8ada5912c9218f8341b1f7fa4627a16e072bf6d270b2b597d8457ced0d6466082c7298d0650a
7
+ data.tar.gz: 73213d86ba4cbc8d7c4adbbc91920e55f2a3c67aa3da2989a442c8af26f7c31a3caf7a6a45d0ae56df3f1bf28f6f5738413ed399c2cea56369511f1b8e2cfc52
@@ -0,0 +1,22 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ ruby: ["3.3", "3.4"]
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ - uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: ${{ matrix.ruby }}
20
+ bundler-cache: true
21
+ - run: bundle exec rspec
22
+ - run: bundle exec rubocop
@@ -0,0 +1,49 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ version:
7
+ description: "Version to release (must match lib/metanorma/plugin/asciichem/version.rb)"
8
+ required: true
9
+ type: string
10
+
11
+ jobs:
12
+ release:
13
+ runs-on: ubuntu-latest
14
+ # Trusted publishing (OIDC): register the publisher on RubyGems.org
15
+ # as repository metanorma/metanorma-plugin-asciichem + workflow
16
+ # release.yml, with no environment — so this job must not claim
17
+ # one.
18
+ permissions:
19
+ contents: read
20
+ id-token: write
21
+ steps:
22
+ - uses: actions/checkout@v7
23
+ with:
24
+ ref: main
25
+ persist-credentials: false
26
+ - name: Verify version matches input
27
+ run: |
28
+ actual=$(ruby -e 'require "./lib/metanorma/plugin/asciichem/version"; print Metanorma::Plugin::Asciichem::VERSION')
29
+ if [ "$actual" != "${{ inputs.version }}" ]; then
30
+ echo "Version mismatch: input=${{ inputs.version }}, version.rb=$actual"
31
+ exit 1
32
+ fi
33
+ echo "Releasing $actual"
34
+ - uses: ruby/setup-ruby@v1
35
+ with:
36
+ ruby-version: "3.4"
37
+ bundler-cache: true
38
+ # CI never pushes to git (read-only contents). `rake release`
39
+ # attempts `git push origin main` after publishing unless the
40
+ # version tag already exists locally — bundler then skips its
41
+ # git stage entirely (see asciichem-ruby PR 86 for the incident).
42
+ - name: Pre-create the release tag (skips rake's git stage)
43
+ run: git tag "v${{ inputs.version }}"
44
+ # Builds and pushes using the GitHub OIDC identity — no API keys.
45
+ - uses: rubygems/release-gem@v1
46
+ - name: Summary
47
+ run: |
48
+ echo "Released metanorma-plugin-asciichem ${{ inputs.version }} to RubyGems"
49
+ echo "https://rubygems.org/gems/metanorma-plugin-asciichem"
data/.gitignore ADDED
@@ -0,0 +1,15 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /tmp/
10
+ /ruby/
11
+ /.vscode/
12
+ /.idea/
13
+ *.gem
14
+ Gemfile.lock
15
+ /.html.err.html
data/.rubocop.yml ADDED
@@ -0,0 +1,26 @@
1
+ require:
2
+ - rubocop
3
+
4
+ AllCops:
5
+ NewCops: enable
6
+ TargetRubyVersion: 3.3
7
+ Exclude:
8
+ - "pkg/**/*"
9
+ - "vendor/**/*"
10
+ - "coverage/**/*"
11
+
12
+ # The extension rewrites nodes inside Asciidoctor's visitor contract;
13
+ # methods mirror that contract and read top-to-bottom.
14
+ Metrics/MethodLength:
15
+ Max: 30
16
+
17
+ Metrics/BlockLength:
18
+ Exclude:
19
+ - "spec/**/*"
20
+ - "*.gemspec"
21
+
22
+ # Gem-name entry file (require "metanorma-plugin-asciichem"), the
23
+ # plugin-family convention (metanorma-plugin-lutaml does the same).
24
+ Naming/FileName:
25
+ Exclude:
26
+ - "lib/metanorma-plugin-asciichem.rb"
data/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Changed
8
+
9
+ - Requires asciichem >= 0.29.2 (relaton-bib `< 3`), enabling
10
+ co-resolution with current metanorma gems; metanorma-standoc is
11
+ now a development dependency.
12
+ - End-to-end standoc compile wired into the suite
13
+ (`standoc_spec.rb`): `[chem]` → `<formula><stem type="MathML">`,
14
+ dataset bibitems verbatim in `<references normative="false">`,
15
+ InChIKey anchors.
16
+ - Citation anchors are derived from the emitted bibitem XML (works
17
+ under both relaton-bib major lines) instead of vendor object APIs.
18
+
19
+ ## [0.1.0] - 2026-09-16
20
+
21
+ ### Added
22
+
23
+ - `[chem]` block: AsciiChem source parses to the semantic model and
24
+ renders as a MathML stem (`<formula><stem type="MathML">` in
25
+ Metanorma semantic XML). Block ids and titles carry over; invalid
26
+ sources log an error and keep the block as sourcecode.
27
+ - `chem:[]` inline macro (target and attribute forms) for inline
28
+ chemistry.
29
+ - Substance citations: `@cite`-annotated molecules in `[chem]` blocks
30
+ resolve through `AsciiChem::Citation` to dataset-type Relaton
31
+ bibitems, one per (source, substance), deduplicated, anchored by
32
+ InChIKey, appended as a `[bibliography]` section.
33
+ - `:asciichem-cache-dir:` document attribute for offline,
34
+ reproducible citation resolution.
35
+
36
+ ## [0.1.0-renamed] - 2026-09-17
37
+ ### Renamed
38
+
39
+ - The gem moves to the metanorma org as
40
+ `metanorma-plugin-asciichem` (from asciichem/metanorma-asciichem),
41
+ following the plugin-family convention (lutaml, glossarist,
42
+ plantuml). Namespace is now `Metanorma::Plugin::Asciichem` with
43
+ lib at `metanorma/plugin/asciichem`; the require-by-name entry is
44
+ `metanorma-plugin-asciichem`. Per the plugin contract the gem no
45
+ longer self-registers with Asciidoctor — metanorma-standoc
46
+ requires and registers it (as it does for lutaml/glossarist).
47
+ Not released under the previous name, so the rename is invisible
48
+ to users.
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :development do
8
+ gem 'metanorma-standoc', '~> 3.5'
9
+ gem 'rake', '~> 13.2'
10
+ gem 'rspec', '~> 3.13'
11
+ gem 'rubocop', '~> 1.66'
12
+ end
data/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, Ribose Inc.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # metanorma-plugin-asciichem
2
+
3
+ [AsciiChem](https://www.asciichem.org) chemistry for
4
+ [Metanorma](https://www.metanorma.org) documents: `[chem]` blocks and
5
+ `chem:[]` inline macros render chemistry as MathML, and
6
+ `@cite`-annotated molecules resolve into dataset-type Relaton
7
+ bibitems — one per (source, substance), anchored by InChIKey —
8
+ collected automatically into the document bibliography.
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ gem install metanorma-plugin-asciichem
14
+ ```
15
+
16
+ The plugin follows the lutaml/glossarist contract: it exposes the
17
+ extension classes and does not register them itself — the host
18
+ does. [metanorma-standoc](https://github.com/metanorma/metanorma-standoc)
19
+ requires and registers this gem (see its converter), so with the
20
+ gem installed, `[chem]` and `chem:[]` are active in every Metanorma
21
+ compile. Outside Metanorma, register manually:
22
+
23
+ ```ruby
24
+ require "metanorma-plugin-asciichem"
25
+
26
+ Asciidoctor::Extensions.register do
27
+ treeprocessor Metanorma::Plugin::Asciichem::Extension::ChemTreeprocessor
28
+ inline_macro Metanorma::Plugin::Asciichem::Extension::ChemInlineMacro, :chem
29
+ end
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ### Chemistry blocks
35
+
36
+ ```adoc
37
+ [chem]
38
+ ----
39
+ 2H_2 + O_2 -> 2H_2O
40
+ ----
41
+ ```
42
+
43
+ The block's AsciiChem source parses into the semantic model and
44
+ renders as MathML. In the semantic XML this becomes
45
+ `<formula><stem type="MathML">…</stem></formula>` — the same shape
46
+ Metanorma uses for math, so every flavour renders it. Block ids and
47
+ titles carry over (`[chem#reaction,title="Hydrolysis"]`).
48
+
49
+ Invalid AsciiChem logs an error and keeps the original block as
50
+ sourcecode — the build stays reproducible, the problem stays visible.
51
+
52
+ ### Inline chemistry
53
+
54
+ ```adoc
55
+ Water is chem:H_2O[] in prose, or chem:[2H_2 + O_2 -> 2H_2O] for
56
+ sources with spaces.
57
+ ```
58
+
59
+ Inline macros render; they do not contribute citations (inline
60
+ substitution runs after the document pass that collects them).
61
+
62
+ ### Substance citations
63
+
64
+ Annotate a molecule with `@cite` naming the source to cite from; the
65
+ molecule's other identifiers say who to resolve it as:
66
+
67
+ ```adoc
68
+ [chem]
69
+ ----
70
+ CC(=O)OC1=CC=CC=C1C(=O)O @cas("50-78-2") @cite("pubchem")
71
+ ----
72
+
73
+ The record is <<BSYNRYMUTXBXSQ-UHFFFAOYSA-N>>.
74
+ ```
75
+
76
+ The build resolves the molecule (CAS RN → PubChem) and appends a
77
+ `[bibliography]` section containing one `<bibitem type="dataset">`
78
+ per (source, substance):
79
+
80
+ - **Same substance cited twice** → one deduplicated entry per source.
81
+ - **Two sources for one substance** → two entries (PubChem and CAS
82
+ Common Chemistry are different documents), both anchored by the
83
+ same InChIKey — the cross-document join key.
84
+ - Every identifier the source returned rides along as a `keyword`, so
85
+ citations stay machine-checkable long after page numbers change.
86
+
87
+ Resolution goes through `AsciiChem::Citation.for_molecule` — the same
88
+ code path as the `asciichem cite` CLI — so the document pipeline and
89
+ the command line can never drift.
90
+
91
+ **Offline/reproducible builds.** Results come from the AsciiChem
92
+ resolver cache (user cache directory, TTL'd). Point the document at a
93
+ seeded cache to build without network:
94
+
95
+ ```adoc
96
+ :asciichem-cache-dir: ./.asciichem-cache
97
+ ```
98
+
99
+ Seed it with `asciichem resolve --name aspirin`. Resolution failures
100
+ warn and emit no bibitem; they never break the build.
101
+
102
+ **Sources and licensing.** PubChem resolves by default. CAS Common
103
+ Chemistry (CC BY-NC 4.0) is opt-in:
104
+
105
+ ```ruby
106
+ AsciiChem::Resolver.register(:common_chemistry,
107
+ AsciiChem::Resolver::CommonChemistry)
108
+ ```
109
+
110
+ A worked example document ships in `docs/example.adoc`.
111
+
112
+ ## Compatibility
113
+
114
+ Runtime dependencies are `asciichem` (>= 0.29.2), `asciidoctor`, and
115
+ `nokogiri` — the extension operates at the Asciidoctor AST level and
116
+ needs no Metanorma gem to run.
117
+
118
+ The full metanorma-standoc compile is exercised by the suite:
119
+ `spec/metanorma/plugin/asciichem/standoc_spec.rb` compiles a document
120
+ through metanorma-standoc (dev dependency) and asserts the semantic
121
+ XML — `[chem]` → `<formula><stem type="MathML">`, bibitems verbatim
122
+ inside `<references normative="false">` via the
123
+ `formats="metanorma"` passthrough, InChIKey anchors. That became
124
+ possible with asciichem 0.29.2, which widened its relaton-bib
125
+ constraint to `< 3` so asciichem and current metanorma gems
126
+ co-resolve in one bundle. Citation anchors read the emitted wire XML,
127
+ so both relaton-bib major lines (1.x and 2.x keyword nestings) work.
128
+
129
+ ## Development
130
+
131
+ ```sh
132
+ bundle install
133
+ bundle exec rspec # 16 examples, network-free
134
+ bundle exec rubocop
135
+ ```
136
+
137
+ Spec fixtures are real PubChem / CAS Common Chemistry payloads; the
138
+ fetchers are Structs shaped like the asciidoctor HTTP surface — no
139
+ network, no test doubles.
140
+
141
+ ## License
142
+
143
+ BSD-2-Clause.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/docs/example.adoc ADDED
@@ -0,0 +1,42 @@
1
+ = Aspirin monograph (worked example)
2
+
3
+ This document exercises metanorma-asciichem end to end: chemistry
4
+ rendered from `[chem]` blocks, an inline formula, and a
5
+ `@cite`-annotated molecule whose PubChem record becomes a
6
+ dataset-type bibitem — anchored by InChIKey, so the `<<BSYNRYMUTXBXSQ-UHFFFAOYSA-N>>`
7
+ cross-reference in the body lands on the emitted entry.
8
+
9
+ Seed the resolver cache first for an offline, reproducible build:
10
+
11
+ asciichem resolve --name aspirin
12
+
13
+ then set `:asciichem-cache-dir:` to your cache directory (or omit the
14
+ attribute to resolve live).
15
+
16
+ = Aspirin monograph
17
+ Author
18
+ :asciichem-cache-dir: ~/.cache/asciichem
19
+
20
+ == Synthesis
21
+
22
+ Aspirin is synthesised from salicylic acid. The overall hydrolysis
23
+ equilibrium:
24
+
25
+ [chem]
26
+ ----
27
+ 2H_2 + O_2 -> 2H_2O
28
+ ----
29
+
30
+ Water, inline: chem:H_2O[], appears on both sides of the mechanism.
31
+
32
+ == Structure and identity
33
+
34
+ [chem]
35
+ ----
36
+ CC(=O)OC1=CC=CC=C1C(=O)O @cas("50-78-2") @cite("pubchem")
37
+ ----
38
+
39
+ The substance record is <<BSYNRYMUTXBXSQ-UHFFFAOYSA-N>>. Citing a
40
+ second source for the same substance (after registering the opt-in
41
+ adapter) yields a second, distinct entry — one bibitem per
42
+ (substance, source).
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module Metanorma
6
+ module Plugin
7
+ module Asciichem
8
+ # Bibliography emission (TODO.impl 49; TODO.v2 08 section 3):
9
+ # `@cite`-annotated molecules resolve to dataset-type Relaton
10
+ # bibitems - one per (source, substance), deduplicated, anchored by
11
+ # InChIKey - collected into a [bibliography] section appended to
12
+ # the document. Resolution goes through AsciiChem::Citation (the
13
+ # same code path as the CLI), so the document pipeline and the
14
+ # `asciichem cite` command can never drift.
15
+ module Citations
16
+ include Asciidoctor::Logging
17
+
18
+ BIBLIOGRAPHY_TITLE = 'Bibliography'
19
+ INCHIKEY_PREFIX = 'inchikey='
20
+ ANCHOR_SANITIZE = /[^A-Za-z0-9-]/
21
+
22
+ module_function
23
+
24
+ # molecules: parsed AsciiChem model nodes (Model::Molecule) as
25
+ # harvested from [chem] blocks. cache:/fetch: pass through to
26
+ # the resolver (same knobs as `asciichem resolve --refresh` and
27
+ # the asciichem CLI specs). Appends nothing when none of them
28
+ # carries @cite or none resolves (warns instead - builds stay
29
+ # green and reproducible via the resolver cache).
30
+ def append_bibliography(document, molecules, cache: nil, fetch: nil)
31
+ entries = resolve(molecules, cache: cache, fetch: fetch)
32
+ return if entries.empty?
33
+
34
+ document << bibliography_section(document, entries)
35
+ end
36
+
37
+ def resolve(molecules, cache:, fetch:)
38
+ entries = {}
39
+ molecules.each do |molecule|
40
+ # First-wins: a later identical (source, substance) keeps the
41
+ # first-mention entry and order.
42
+ entries.merge!(entries_for(molecule, cache: cache, fetch: fetch)) do |_key, first, _last|
43
+ first
44
+ end
45
+ end
46
+ entries.values
47
+ end
48
+
49
+ def entries_for(molecule, cache:, fetch:)
50
+ AsciiChem::Citation.for_molecule(molecule, cache: cache, fetch: fetch)
51
+ .map { |source, bibitem| entry(source, bibitem) }
52
+ .reduce({}, :merge)
53
+ rescue AsciiChem::Error => e
54
+ logger.warn(message_with_context(
55
+ "cannot build substance citation: #{e.message} " \
56
+ '(no bibitem emitted)',
57
+ source_location: nil
58
+ ))
59
+ {}
60
+ end
61
+
62
+ # [source, bibitem] -> { [source, anchor] => [anchor, xml] }:
63
+ # the compound key dedupes same (source, substance) while the
64
+ # insertion-ordered hash preserves first-mention order.
65
+ def entry(source, bibitem)
66
+ xml = bibitem.to_xml
67
+ anchor = anchor_for(xml) || fallback_anchor(source, xml)
68
+ { [source, anchor] => [anchor, with_anchor(xml, anchor)] }
69
+ end
70
+
71
+ # The InChIKey is the cross-source join key: every bibitem for
72
+ # the same substance carries the same keyword, so two spellings
73
+ # of one substance (CAS RN vs name) collapse onto one anchor.
74
+ # Anchors read the emitted wire XML, not vendor object APIs:
75
+ # relaton-bib 1 writes <keyword>inchikey=...</keyword> and
76
+ # relaton-bib 2 nests it (<keyword><vocab>...</vocab></keyword>)
77
+ # — one pattern serves both.
78
+ def anchor_for(xml)
79
+ xml[/#{INCHIKEY_PREFIX}([A-Za-z0-9-]+)/, 1]
80
+ end
81
+
82
+ def fallback_anchor(source, xml)
83
+ docid = xml[%r{<docidentifier[^>]*>([^<]+)</docidentifier>}, 1]
84
+ slug = docid ? docid.gsub(ANCHOR_SANITIZE, '') : 'substance'
85
+ "#{source}-#{slug}"
86
+ end
87
+
88
+ # Relaton emits <bibitem id="..."> with an id derived from the
89
+ # docidentifier; the anchor (InChIKey) replaces it so document
90
+ # cross-references <<INCHIKEY>> land on the entry.
91
+ def with_anchor(xml, anchor)
92
+ root = Nokogiri::XML(xml).root
93
+ root['id'] = anchor
94
+ root.to_xml
95
+ end
96
+
97
+ def bibliography_section(document, entries)
98
+ section = Asciidoctor::Section.new(document, 1, false)
99
+ section.style = 'bibliography'
100
+ section.title = BIBLIOGRAPHY_TITLE
101
+ entries.each { |anchor, xml| section << pass_block(section, anchor, xml) }
102
+ section
103
+ end
104
+
105
+ # A [pass] block's content reaches the semantic XML unwrapped
106
+ # (passthrough formats="metanorma"), placing the bibitem
107
+ # verbatim inside <references>.
108
+ def pass_block(section, _anchor, xml)
109
+ Asciidoctor::Block.new(section, :pass, content_model: :raw,
110
+ source: xml,
111
+ attributes: { 'style' => 'pass' })
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Plugin
5
+ module Asciichem
6
+ # Asciidoctor extension surface (TODO.impl 49): one treeprocessor
7
+ # for block-level chemistry + citation collection, one inline macro
8
+ # for inline chemistry.
9
+ #
10
+ # Block form (citations are collected here - inline macros resolve
11
+ # at substitution time, after treeprocessors have run):
12
+ #
13
+ # [chem]
14
+ # ----
15
+ # 2H_2 + O_2 -> 2H_2O
16
+ # ----
17
+ #
18
+ # Inline form (render-only):
19
+ #
20
+ # chem:H_2O[] or chem:[2H_2 + O_2 -> 2H_2O]
21
+ #
22
+ module Extension
23
+ # Rewrites every style-"chem" block into a stem block carrying
24
+ # the formula's MathML, then appends the substance bibliography
25
+ # built from the harvested molecules.
26
+ #
27
+ # Document attributes:
28
+ # asciichem-cache-dir - resolve citations from this resolver
29
+ # cache directory (reproducible, offline
30
+ # builds; seed it with `asciichem
31
+ # resolve`).
32
+ class ChemTreeprocessor < Asciidoctor::Extensions::Treeprocessor
33
+ include Asciidoctor::Logging
34
+
35
+ def process(document)
36
+ molecules = []
37
+ document.find_by(style: 'chem').each do |node|
38
+ next unless node.is_a?(Asciidoctor::Block)
39
+
40
+ molecules.concat(rewrite(node))
41
+ end
42
+ Citations.append_bibliography(document, molecules,
43
+ cache: cache_for(document))
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ def cache_for(document)
50
+ dir = document.attr('asciichem-cache-dir')
51
+ dir && AsciiChem::Resolver::Cache.new(dir: dir)
52
+ end
53
+
54
+ # Parses the block source once; replaces the node with a stem
55
+ # block of its MathML. Returns the formula's molecule nodes
56
+ # (only molecules can carry @cite annotations), or [] when the
57
+ # block does not parse.
58
+ def rewrite(node)
59
+ formula = parse_or_log(node, '[chem] block', '(block kept as sourcecode)')
60
+ return [] unless formula
61
+
62
+ replace_with_stem(node, formula)
63
+ formula.nodes.grep(AsciiChem::Model::Molecule)
64
+ end
65
+
66
+ # Parses, or logs the failure and returns nil (the caller
67
+ # keeps the offending block visible instead of crashing the
68
+ # build).
69
+ def parse_or_log(node, where, remedy)
70
+ Renderer.parse(node.lines.join("\n"))
71
+ rescue AsciiChem::ParseError => e
72
+ logger.error(message_with_context(
73
+ "invalid AsciiChem in #{where}: #{e.message} #{remedy}",
74
+ source_location: node.source_location
75
+ ))
76
+ nil
77
+ end
78
+
79
+ def replace_with_stem(node, formula)
80
+ stem = Asciidoctor::Block.new(
81
+ node.parent, :stem,
82
+ content_model: :verbatim, source: Renderer.mathml(formula),
83
+ # Standoc reads node.lines (raw); :default subs keep plain
84
+ # Asciidoctor/HTML5 previews working (escaped text).
85
+ subs: :default
86
+ )
87
+ # Style "asciimath" matches core stem blocks; standoc still
88
+ # types the stem MathML from the <math> content itself.
89
+ stem.style = 'asciimath'
90
+ stem.id = node.id if node.id
91
+ stem.title = node.title if node.title
92
+ siblings = node.parent.blocks
93
+ siblings[siblings.index(node)] = stem
94
+ stem
95
+ end
96
+ end
97
+
98
+ # Inline chemistry: chem:H_2O[] (target form, no spaces) or
99
+ # chem:[source] (attribute form, any single-line source).
100
+ # Render-only - inline macros resolve after treeprocessors run,
101
+ # so they cannot contribute bibliography entries.
102
+ class ChemInlineMacro < Asciidoctor::Extensions::InlineMacroProcessor
103
+ include Asciidoctor::Logging
104
+
105
+ use_dsl
106
+ name_positional_attributes 'text'
107
+
108
+ def process(parent, target, attrs)
109
+ source = target.empty? ? attrs['text'] : target
110
+ return empty_inline(parent) if source.nil? || source.empty?
111
+
112
+ formula = Renderer.parse(source)
113
+ # Context :quoted with type :asciimath is the shape core
114
+ # stem:[] macros produce; converters dispatch inline_quoted
115
+ # and type the stem MathML from the <math> content. (A
116
+ # :asciimath-context node dispatches inline_asciimath,
117
+ # which metanorma converters do not define.)
118
+ create_inline(parent, :quoted, Renderer.mathml(formula), type: :asciimath)
119
+ rescue AsciiChem::ParseError => e
120
+ logger.error(message_with_context(
121
+ "invalid AsciiChem in chem: macro: #{e.message} " \
122
+ '(rendered verbatim)',
123
+ source_location: parent.source_location
124
+ ))
125
+ create_inline(parent, :quoted, source, type: :monospaced)
126
+ end
127
+
128
+ private
129
+
130
+ def empty_inline(parent)
131
+ logger.error(message_with_context(
132
+ 'empty chem: macro - provide source ' \
133
+ '(chem:H_2O[] or chem:[...])',
134
+ source_location: parent.source_location
135
+ ))
136
+ create_inline(parent, :quoted, '')
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Plugin
5
+ module Asciichem
6
+ # The parse/render seam between AsciiDoc and AsciiChem. Parsing is
7
+ # separated from rendering so the treeprocessor can parse once and
8
+ # both render MathML and harvest citation molecules from the same
9
+ # semantic model.
10
+ module Renderer
11
+ module_function
12
+
13
+ # Parses AsciiChem source into the semantic model. Raises
14
+ # AsciiChem::ParseError on invalid input.
15
+ def parse(source)
16
+ AsciiChem.parse(source)
17
+ end
18
+
19
+ # Renders the parsed model to a MathML <math> document. Standoc's
20
+ # stem handling detects the <math> root and types the stem
21
+ # accordingly (type="MathML"), so no converter-side support is
22
+ # needed. The formatter's XML declaration is stripped: embedded
23
+ # MathML is a fragment inside the document, not a document.
24
+ def mathml(formula)
25
+ formula.to_mathml.sub(/\A<\?xml[^>]*>\s*/, '')
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Plugin
5
+ module Asciichem
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'asciidoctor' unless defined?(Asciidoctor)
4
+ require 'asciichem'
5
+
6
+ module Metanorma
7
+ module Plugin
8
+ # AsciiChem integration for Metanorma (TODO.impl 49; TODO.v2 08
9
+ # section 3): chemistry in AsciiDoc documents parses to MathML for
10
+ # rendering, and `@cite`-annotated molecules resolve to dataset-type
11
+ # Relaton bibitems collected into the document bibliography - one
12
+ # bibitem per (source, substance), anchored by InChIKey.
13
+ #
14
+ # Following the plugin contract (lutaml, glossarist), this gem
15
+ # exposes the extension classes and does NOT register them with
16
+ # Asciidoctor itself; the host converter registers what it needs:
17
+ #
18
+ # Asciidoctor::Extensions.register do
19
+ # treeprocessor Metanorma::Plugin::Asciichem::Extension::ChemTreeprocessor
20
+ # inline_macro Metanorma::Plugin::Asciichem::Extension::ChemInlineMacro, :chem
21
+ # end
22
+ module Asciichem
23
+ autoload :Citations, 'metanorma/plugin/asciichem/citations'
24
+ autoload :Extension, 'metanorma/plugin/asciichem/extension'
25
+ autoload :Renderer, 'metanorma/plugin/asciichem/renderer'
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'metanorma/plugin/asciichem/version'
4
+ require_relative 'metanorma/plugin/asciichem'
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/metanorma/plugin/asciichem/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'metanorma-plugin-asciichem'
7
+ spec.version = Metanorma::Plugin::Asciichem::VERSION
8
+ spec.authors = ['Ribose Inc.']
9
+ spec.email = ['open.source@ribose.com']
10
+
11
+ spec.summary = 'AsciiChem chemistry blocks and substance citations for Metanorma documents.'
12
+ spec.description = 'Adds [chem] blocks and chem:[] inline macros to Metanorma ' \
13
+ 'AsciiDoc: chemistry written in AsciiChem parses to MathML ' \
14
+ 'for rendering, and @cite-annotated molecules resolve to ' \
15
+ 'dataset-type Relaton bibitems - one per (source, substance), ' \
16
+ 'anchored by InChIKey - automatically collected into the ' \
17
+ "document's bibliography."
18
+
19
+ spec.homepage = 'https://www.asciichem.org'
20
+ spec.license = 'BSD-2-Clause'
21
+ spec.required_ruby_version = Gem::Requirement.new('>= 3.3.0')
22
+
23
+ spec.metadata['homepage_uri'] = spec.homepage
24
+ spec.metadata['source_code_uri'] = 'https://github.com/metanorma/metanorma-plugin-asciichem'
25
+ spec.metadata['changelog_uri'] = 'https://github.com/metanorma/metanorma-plugin-asciichem/blob/main/CHANGELOG.md'
26
+ spec.metadata['docs_uri'] = 'https://www.asciichem.org'
27
+ spec.metadata['rubygems_mfa_required'] = 'true'
28
+
29
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
30
+ `git ls-files -z`.split("\x0").reject do |f|
31
+ f.match(%r{^(test|spec|features)/})
32
+ end
33
+ end
34
+ spec.require_paths = ['lib']
35
+
36
+ spec.add_dependency 'asciichem', '>= 0.29.2'
37
+ spec.add_dependency 'asciidoctor', '~> 2.0'
38
+ spec.add_dependency 'nokogiri', '~> 1.16'
39
+
40
+ # metanorma-standoc lives in the Gemfile dev group: it is the
41
+ # integration-test backend (spec/metanorma/plugin/asciichem/
42
+ # standoc_spec.rb), not a runtime requirement — following the
43
+ # plugin contract, metanorma-standoc requires and registers this
44
+ # gem, like it does for metanorma-plugin-lutaml.
45
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: metanorma-plugin-asciichem
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ribose Inc.
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: asciichem
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.29.2
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.29.2
26
+ - !ruby/object:Gem::Dependency
27
+ name: asciidoctor
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: nokogiri
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.16'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.16'
54
+ description: 'Adds [chem] blocks and chem:[] inline macros to Metanorma AsciiDoc:
55
+ chemistry written in AsciiChem parses to MathML for rendering, and @cite-annotated
56
+ molecules resolve to dataset-type Relaton bibitems - one per (source, substance),
57
+ anchored by InChIKey - automatically collected into the document''s bibliography.'
58
+ email:
59
+ - open.source@ribose.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - ".github/workflows/ci.yml"
65
+ - ".github/workflows/release.yml"
66
+ - ".gitignore"
67
+ - ".rubocop.yml"
68
+ - CHANGELOG.md
69
+ - Gemfile
70
+ - LICENSE
71
+ - README.md
72
+ - Rakefile
73
+ - docs/example.adoc
74
+ - lib/metanorma-plugin-asciichem.rb
75
+ - lib/metanorma/plugin/asciichem.rb
76
+ - lib/metanorma/plugin/asciichem/citations.rb
77
+ - lib/metanorma/plugin/asciichem/extension.rb
78
+ - lib/metanorma/plugin/asciichem/renderer.rb
79
+ - lib/metanorma/plugin/asciichem/version.rb
80
+ - metanorma-plugin-asciichem.gemspec
81
+ homepage: https://www.asciichem.org
82
+ licenses:
83
+ - BSD-2-Clause
84
+ metadata:
85
+ homepage_uri: https://www.asciichem.org
86
+ source_code_uri: https://github.com/metanorma/metanorma-plugin-asciichem
87
+ changelog_uri: https://github.com/metanorma/metanorma-plugin-asciichem/blob/main/CHANGELOG.md
88
+ docs_uri: https://www.asciichem.org
89
+ rubygems_mfa_required: 'true'
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: 3.3.0
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 3.6.9
105
+ specification_version: 4
106
+ summary: AsciiChem chemistry blocks and substance citations for Metanorma documents.
107
+ test_files: []