relaton 3.0.0.pre.alpha.17 → 3.0.0.pre.alpha.18
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 +4 -4
- data/data/equivalence/adoption-chains.yaml +38 -0
- data/lib/relaton/3gpp/CLAUDE.md +187 -0
- data/lib/relaton/3gpp/README.adoc +149 -0
- data/lib/relaton/adobe/CLAUDE.md +104 -0
- data/lib/relaton/bib/CLAUDE.md +130 -0
- data/lib/relaton/bib/README.adoc +330 -0
- data/lib/relaton/bib/model/series.rb +1 -1
- data/lib/relaton/bipm/CLAUDE.md +173 -0
- data/lib/relaton/bipm/README.adoc +352 -0
- data/lib/relaton/bipm/data_fetcher.rb +3 -2
- data/lib/relaton/bsi/CLAUDE.md +35 -0
- data/lib/relaton/bsi/README.adoc +112 -0
- data/lib/relaton/calconnect/CLAUDE.md +177 -0
- data/lib/relaton/calconnect/README.adoc +140 -0
- data/lib/relaton/ccsds/CLAUDE.md +74 -0
- data/lib/relaton/ccsds/README.adoc +154 -0
- data/lib/relaton/cen/CLAUDE.md +219 -0
- data/lib/relaton/cen/README.adoc +155 -0
- data/lib/relaton/cie/CLAUDE.md +90 -0
- data/lib/relaton/cie/README.adoc +121 -0
- data/lib/relaton/core/CLAUDE.md +140 -0
- data/lib/relaton/core/README.adoc +64 -0
- data/lib/relaton/core/data_fetcher.rb +50 -3
- data/lib/relaton/db/CLAUDE.md +373 -0
- data/lib/relaton/db.rb +19 -1
- data/lib/relaton/doi/CLAUDE.md +105 -0
- data/lib/relaton/doi/README.adoc +89 -0
- data/lib/relaton/easc/CLAUDE.md +85 -0
- data/lib/relaton/ecma/CLAUDE.md +217 -0
- data/lib/relaton/ecma/README.adoc +178 -0
- data/lib/relaton/equivalence.rb +89 -0
- data/lib/relaton/etsi/CLAUDE.md +219 -0
- data/lib/relaton/etsi/README.adoc +137 -0
- data/lib/relaton/evs/CLAUDE.md +120 -0
- data/lib/relaton/evs/README.adoc +27 -0
- data/lib/relaton/gb/CLAUDE.md +47 -0
- data/lib/relaton/gb/README.adoc +169 -0
- data/lib/relaton/gost/CLAUDE.md +100 -0
- data/lib/relaton/iala/CLAUDE.md +97 -0
- data/lib/relaton/iana/CLAUDE.md +105 -0
- data/lib/relaton/iana/README.adoc +107 -0
- data/lib/relaton/iec/CLAUDE.md +13 -0
- data/lib/relaton/iec/README.adoc +280 -0
- data/lib/relaton/iec/data_fetcher.rb +24 -8
- data/lib/relaton/iec/project_fetcher.rb +48 -0
- data/lib/relaton/iec/project_parser.rb +111 -0
- data/lib/relaton/iec.rb +5 -0
- data/lib/relaton/ieee/CLAUDE.md +176 -0
- data/lib/relaton/ieee/README.adoc +131 -0
- data/lib/relaton/ietf/CLAUDE.md +357 -0
- data/lib/relaton/ietf/README.adoc +167 -0
- data/lib/relaton/iho/CLAUDE.md +70 -0
- data/lib/relaton/iho/README.adoc +119 -0
- data/lib/relaton/index/CLAUDE.md +207 -0
- data/lib/relaton/index/README.adoc +221 -0
- data/lib/relaton/index/dataset_lock.rb +122 -0
- data/lib/relaton/index/family.rb +63 -0
- data/lib/relaton/index/type.rb +32 -0
- data/lib/relaton/index.rb +3 -0
- data/lib/relaton/isbn/CLAUDE.md +47 -0
- data/lib/relaton/isbn/README.adoc +91 -0
- data/lib/relaton/iso/CLAUDE.md +38 -0
- data/lib/relaton/iso/README.adoc +370 -0
- data/lib/relaton/iso/data_fetcher.rb +16 -1
- data/lib/relaton/itu/CLAUDE.md +685 -0
- data/lib/relaton/itu/README.adoc +188 -0
- data/lib/relaton/jcgm/CLAUDE.md +107 -0
- data/lib/relaton/jis/CLAUDE.md +64 -0
- data/lib/relaton/jis/README.adoc +167 -0
- data/lib/relaton/logger/CLAUDE.md +37 -0
- data/lib/relaton/logger/README.adoc +120 -0
- data/lib/relaton/nist/CLAUDE.md +180 -0
- data/lib/relaton/nist/README.adoc +389 -0
- data/lib/relaton/nist/bibliography.rb +17 -1
- data/lib/relaton/nist/data_fetcher.rb +60 -4
- data/lib/relaton/nist/hit_collection.rb +121 -59
- data/lib/relaton/nist/scraper.rb +21 -7
- data/lib/relaton/oasis/CLAUDE.md +251 -0
- data/lib/relaton/oasis/README.adoc +132 -0
- data/lib/relaton/ogc/CLAUDE.md +242 -0
- data/lib/relaton/ogc/README.adoc +134 -0
- data/lib/relaton/oiml/CLAUDE.md +47 -0
- data/lib/relaton/oiml/README.adoc +70 -0
- data/lib/relaton/omg/CLAUDE.md +137 -0
- data/lib/relaton/omg/README.adoc +101 -0
- data/lib/relaton/plateau/CLAUDE.md +126 -0
- data/lib/relaton/plateau/README.adoc +139 -0
- data/lib/relaton/sdo/CLAUDE.md +81 -0
- data/lib/relaton/un/CLAUDE.md +91 -0
- data/lib/relaton/un/README.adoc +125 -0
- data/lib/relaton/version.rb +1 -1
- data/lib/relaton/w3c/CLAUDE.md +369 -0
- data/lib/relaton/w3c/README.adoc +140 -0
- data/lib/relaton/w3c/data_fetcher.rb +1 -1
- data/lib/relaton/xsf/CLAUDE.md +189 -0
- data/lib/relaton/xsf/README.adoc +127 -0
- data/lib/relaton.rb +1 -0
- metadata +83 -2
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
relaton-w3c is a Ruby gem for retrieving and representing W3C Standards bibliographic data using the Relaton model. It is part of the larger Relaton ecosystem of gems. Uses a LutaML-based model architecture under the `Relaton::W3c` namespace.
|
|
8
|
+
|
|
9
|
+
## Common Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Install dependencies
|
|
13
|
+
bundle install
|
|
14
|
+
|
|
15
|
+
# Run all tests
|
|
16
|
+
bundle exec rake spec
|
|
17
|
+
|
|
18
|
+
# Run a specific test file
|
|
19
|
+
bundle exec rspec spec/relaton/w3c/item_spec.rb
|
|
20
|
+
|
|
21
|
+
# Run a specific test by line number
|
|
22
|
+
bundle exec rspec spec/relaton/w3c/item_spec.rb:7
|
|
23
|
+
|
|
24
|
+
# Lint
|
|
25
|
+
bundle exec rubocop
|
|
26
|
+
|
|
27
|
+
# Interactive console
|
|
28
|
+
bin/console
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Architecture
|
|
32
|
+
|
|
33
|
+
### Class Hierarchy
|
|
34
|
+
|
|
35
|
+
All classes live under `lib/relaton/w3c/` in the `Relaton::W3c` namespace:
|
|
36
|
+
|
|
37
|
+
**Model classes:**
|
|
38
|
+
- **`Item`** (`item.rb`) — extends `Bib::Item`, adds W3C `ext` attribute. Base class for both Bibitem and Bibdata.
|
|
39
|
+
- **`ItemData`** (`item_data.rb`) — LutaML data model for `Item`
|
|
40
|
+
- **`Bibitem`** (`bibitem.rb`) — extends `Item`, includes `Bib::BibitemShared` (XML serialization without `<bibdata>` wrapper)
|
|
41
|
+
- **`Bibdata`** (`bibdata.rb`) — extends `Item`, includes `Bib::BibdataShared` (XML serialization with `<bibdata>` wrapper)
|
|
42
|
+
- **`Ext`** (`ext.rb`) — extends `Bib::Ext`, adds W3C-specific `doctype` attribute
|
|
43
|
+
- **`Doctype`** (`doctype.rb`) — extends `Bib::Doctype`, restricts content to `groupNote` or `technicalReport`
|
|
44
|
+
|
|
45
|
+
**Public API:**
|
|
46
|
+
- **`Bibliography`** (`bibliography.rb`) — search and retrieve W3C standards from the Relaton index. Parses the reference with `Pubid::W3c` and narrows the index by number before matching; see **Index (`index-v2`, pubid-keyed)**.
|
|
47
|
+
- **`Processor`** (`processor.rb`) — extends `Relaton::Core::Processor`, registers the W3C flavor (prefix `W3C`, dataset `w3c-api`)
|
|
48
|
+
|
|
49
|
+
**Data fetching:**
|
|
50
|
+
- **`DataFetcher`** (`data_fetcher.rb`) — extends `Core::DataFetcher`, fetches all W3C specs via the W3C API. Fetches the specification index with `embed: true` so each spec is realized from the page's embedded payload instead of a per-spec HTTP request, and paginates by page number (only the `fetch` path repopulates `_embedded`, unlike realizing the `next` link). Runs `fetch_spec` across a small thread pool. A SIGINT (Ctrl-C) is handled gracefully — the producer stops queuing and workers stop after their in-flight spec, then the index of everything fetched so far is saved (the prior INT handler is restored afterwards, so the trap doesn't leak into the host process). The crawl refuses to save a truncated dataset — `crawler.rb` wipes `data/` before each run, so saving one commits mass deletions. Three guards enforce that, all raising `CrawlIncompleteError`: an index page that fails after retries or pagination ending before the API's advertised last page (`enqueue_specs`); the governor concluding the crawl is banned (`guard_rate_limited`); and more than `max_throttled_losses` resources dropped to rate limiting (`guard_throttle_budget` — the per-resource counterpart, since pagination can complete cleanly while the documents behind it were quietly hollowed out). A **deliberate** `@interrupted` (Ctrl-C) is exempt: it already means "give me what you have". The worker pool is still drained in an `ensure` so an abort doesn't deadlock. See **Crawler tuning** for the env-var knobs.
|
|
51
|
+
- **`DataParser`** (`data_parser.rb`) — converts W3C API spec objects into `Relaton::W3c::Item` instances
|
|
52
|
+
- **`Governor`** (`governor.rb`) — the W3C binding of **`Relaton::Core::Governor`**, which is where process-wide, thread-safe rate-limit back-pressure now lives (see Rate limiting & retries). It was promoted out of this flavor when the ITU-R crawler became the second consumer. What stays here is only what W3C knows: `THROTTLE_ERRORS` (429 **and** 403, the lutaml-hal classes core must not depend on) and `ENV_PREFIX = "RELATON_W3C"`. The ladder, the per-round escalation, the jitter, the latched give-up and the `Retry-After` parsing are core's, and `spec/w3c/relaton/w3c/governor_spec.rb` still exercises all of it through this subclass unchanged — which is the promotion's acceptance test.
|
|
53
|
+
- **`SafeRealize`** (`safe_realize.rb`) — mixin that, on a terminal error, skips the resource (returns `nil`) so one bad link doesn't abort the crawl, and that routes rate limiting through the `Governor` instead (see Rate limiting & retries). It does not cache successes — that lives upstream.
|
|
54
|
+
- **`Docidentifier`** (`docidentifier.rb`) — `Bib::Docidentifier` subclass that parses its `content` into a `Pubid::W3c::Identifier` and exposes it as `#pubid`. See **Index (`index-v2`, pubid-keyed)**.
|
|
55
|
+
- **`PubId`** (`pubid.rb`) — the **legacy `index-v1` row shape**, no longer on any lookup or crawl path here. Retained public only so `relaton-data-w3c`'s crawler can keep emitting `index-v1` for released relaton v2 consumers (the `Relaton::Bipm::Id` precedent). Do not add call sites.
|
|
56
|
+
|
|
57
|
+
**Utilities:**
|
|
58
|
+
- **`Util`** (`util.rb`) — extends `Relaton::Bib::Util`, sets `PROGNAME` for logging
|
|
59
|
+
|
|
60
|
+
The entry module is defined in `lib/relaton/w3c.rb` and exposes `grammar_hash`.
|
|
61
|
+
|
|
62
|
+
### Crawler tuning
|
|
63
|
+
|
|
64
|
+
`DataFetcher` is tunable via environment variables (read by class methods, so they apply to the whole crawl):
|
|
65
|
+
|
|
66
|
+
- **`RELATON_W3C_FETCH_CONCURRENCY`** (default `4`) — number of `fetch_spec` worker threads. Kept conservative so the version-history requests don't burst fast enough to trip the W3C API rate limiter (429s); raise it for a faster run, lower it for debugging or if 429 skips appear.
|
|
67
|
+
- **`RELATON_W3C_FETCH_VERSIONS`** (default enabled) — set to `false`/`0`/`no`/`off` for a faster, shallower crawl that emits only the top-level specifications and skips each spec's version-history fan-out (version_history, predecessor/successor versions — the bulk of the API requests). Leave it set (the default) for a complete dataset.
|
|
68
|
+
- **`RELATON_W3C_THROTTLE_BASE`** / **`_MAX`** / **`_GIVEUP`** (defaults `60` / `900` / `5` — see `Governor`) — first cooldown, cooldown ceiling, and how many consecutive throttle rounds without a success end the crawl. Non-positive values are ignored. These three are read by the governor on construction **and on each `SafeRealize.reset!`** (i.e. at the start of every `fetch`), so like the others they can be set any time before the crawl rather than before the flavor is autoloaded.
|
|
69
|
+
- **`RELATON_W3C_MAX_THROTTLED`** (default `25`) — how many resources may be lost to rate limiting before `fetch` refuses to save the index. `0` is accepted and means "tolerate none".
|
|
70
|
+
|
|
71
|
+
`embed: true` (always on) inlines each specification into its index page, so the per-spec realize is served from memory rather than an HTTP request — the largest single reduction in request count.
|
|
72
|
+
|
|
73
|
+
### Rate limiting & retries
|
|
74
|
+
|
|
75
|
+
Per-request retries are layered upstream; **pool-wide back-pressure is this
|
|
76
|
+
gem's job** (`Governor`), because upstream has no notion of the crawl as a whole.
|
|
77
|
+
|
|
78
|
+
- **w3c_api** (>= 0.3.3, pinned in the gemspec) builds its HAL client with
|
|
79
|
+
`faraday-retry` for HTTP 403 — the W3C rate-limit signal — plus
|
|
80
|
+
connection/timeout errors, and sends an identifying `User-Agent`. Both landed
|
|
81
|
+
in 0.3.3 (relaton/w3c_api#22, #23); before it, requests went out as
|
|
82
|
+
`Faraday v2.x` (a Cloudflare bot-heuristic trigger, and the most likely reason
|
|
83
|
+
a crawl flipped into the banned mode at all) and the 403 retry never fired.
|
|
84
|
+
- **lutaml-hal** (>= 0.2.5, declared directly in the gemspec because this flavor
|
|
85
|
+
rescues its error classes by name) retries 429 and 5xx with exponential
|
|
86
|
+
backoff. `DataFetcher` **shortens** that policy via
|
|
87
|
+
`W3cApi::Hal.instance.configure_rate_limiting(UPSTREAM_RATE_LIMITING)`: its
|
|
88
|
+
defaults burn ~31 s (1+2+4+8+16) before a 429 ever reaches us, which only
|
|
89
|
+
delays the back-pressure that actually works. `max_retries` stays at 5 — one
|
|
90
|
+
count covers both 429 and 5xx, and a 5xx is still a permanent skip here.
|
|
91
|
+
From 0.3.3 that call rebuilds w3c_api's register itself; **don't** follow it
|
|
92
|
+
with `reset_register`, which would leave the register absent from lutaml-hal's
|
|
93
|
+
`GlobalRegister`, where `Link#realize` raises.
|
|
94
|
+
`max_retry_after` is set too, to `max_delay` (4.0). Left unset, lutaml-hal's
|
|
95
|
+
`DEFAULT_MAX_RETRY_AFTER` (300.0) applies and a large `Retry-After` on a 429
|
|
96
|
+
parks one thread inside the HTTP layer for up to 5 x 300 s with the governor
|
|
97
|
+
hearing nothing — the very thing the compressed chain exists to prevent. The
|
|
98
|
+
server's hint is not discarded: `Governor.retry_after` reads it off the same
|
|
99
|
+
error and uses it as the **floor** of the pool-wide cooldown, under the
|
|
100
|
+
governor's own 900 s cap, so it is honoured once at pool scale. Do **not**
|
|
101
|
+
lower `w3c_api`'s `DEFAULT_RETRY_OPTIONS[:max_interval]` or lutaml-hal's
|
|
102
|
+
`DEFAULT_MAX_RETRY_AFTER` instead — the first turns a polite server hint into
|
|
103
|
+
an immediate give-up, and either imposes a crawl-shaped number on every
|
|
104
|
+
consumer of those gems.
|
|
105
|
+
|
|
106
|
+
**`Governor` (`governor.rb`) is the important piece.** `api.w3.org` is behind
|
|
107
|
+
Cloudflare, whose rate limiting is **bimodal**: a run either never trips it or
|
|
108
|
+
trips it and stays banned for hours. Second-scale backoff cannot climb out of
|
|
109
|
+
that, and per-worker backoff is worse than useless — the retries are what keep
|
|
110
|
+
the limiter engaged. So all workers share one cooldown:
|
|
111
|
+
|
|
112
|
+
- Any thread seeing a 429 opens/extends it; every other thread observes it in
|
|
113
|
+
`#wait`. Cooldowns escalate **per round** (60 s → 120 → … → 900 s cap), where a
|
|
114
|
+
"round" is one open cooldown — four workers tripping the same limiter must not
|
|
115
|
+
fast-forward the ladder four steps.
|
|
116
|
+
- A `Retry-After` header raises the floor but never lowers our own ladder, and is
|
|
117
|
+
itself capped (a hostile value must not park a worker for an hour). Both RFC
|
|
118
|
+
9110 forms are honoured: delta-seconds, and an HTTP-date parsed properly
|
|
119
|
+
(digit-scanning one would pick up the day-of-month).
|
|
120
|
+
- A success calls `#succeeded!`, which clears the cooldown and **decays**
|
|
121
|
+
(halves) the penalty rather than resetting it, so a flapping limiter still
|
|
122
|
+
climbs. **Only a realize that actually went to the network counts** — with a
|
|
123
|
+
`parent_resource`, lutaml-hal serves the object out of the page's `_embedded`
|
|
124
|
+
payload and issues no request (`link.rb`: *"Priority 1: check embedded content
|
|
125
|
+
first"*). That is the common path, one per specification, and counting those
|
|
126
|
+
would reset the ladder between every pair of version-history 429s, so the
|
|
127
|
+
governor could never reach its threshold.
|
|
128
|
+
- After `GIVE_UP_AFTER` consecutive rounds with no success (~15 min) the crawl is
|
|
129
|
+
declared banned: `#exhausted?` goes true, `#wait` stops blocking so shutdown is
|
|
130
|
+
immediate, and `fetch` aborts. The abort message reads `#give_up_rounds`, the
|
|
131
|
+
round count **latched** at that moment — `#throttle_rounds` is live and
|
|
132
|
+
`#succeeded!` resets it, so a straggler success made a give-up at round 5 print
|
|
133
|
+
as *"gave up after 2 consecutive backoffs"*.
|
|
134
|
+
- The mutex is never held across a sleep, and wake-ups are jittered so workers
|
|
135
|
+
don't resume in lockstep.
|
|
136
|
+
|
|
137
|
+
**Giving up stops asking, not just waiting.** `#wait` returning 0.0 once latched
|
|
138
|
+
makes shutdown prompt, but it does not stop a request. `#stopping?` is consulted
|
|
139
|
+
only by the producer loop and at `queue.pop`, so an in-flight spec used to run
|
|
140
|
+
its whole fan-out — `fetch_versions` plus `DataParser`'s seven realize sites —
|
|
141
|
+
against a host that had just banned it. The 2026-08-31 crawl (CI run
|
|
142
|
+
33435903404) spent its last **11 minutes** doing exactly that, which extends the
|
|
143
|
+
Cloudflare ban into the next run; 95 of its 105 `Throttled out` warnings say
|
|
144
|
+
`after 1 attempt(s)`, which in `record_throttle` can only mean `gave_up`.
|
|
145
|
+
|
|
146
|
+
The guard is one line at the top of `SafeRealize#realize`
|
|
147
|
+
(`return nil if SafeRealize.governor.exhausted?`) because that is the **single
|
|
148
|
+
choke point** every crawl request passes through — `fetch_specifications_page`
|
|
149
|
+
is the only other one, and it already returns nil on give-up. One check
|
|
150
|
+
therefore covers every fan-out site, including any added later, and every site
|
|
151
|
+
already tolerates `nil` (the `SafeRealize` contract; `data_parser_spec.rb`
|
|
152
|
+
proves it per site). Per-site checks in `data_parser.rb` would also need a stop
|
|
153
|
+
flag plumbed into `DataParser`, which holds no reference to the fetcher.
|
|
154
|
+
|
|
155
|
+
It checks `governor.exhausted?`, **not** `#stopping?`, and
|
|
156
|
+
`DataFetcher#abandoned?` names that distinction. The two stop reasons want
|
|
157
|
+
opposite things of a spec already in flight: a **Ctrl-C** saves the index, so
|
|
158
|
+
the spec must finish or a half-parsed document is written; a **latched give-up**
|
|
159
|
+
discards everything (`guard_rate_limited` raises), so there is nothing to gain
|
|
160
|
+
from parsing, writing or crawling one more document.
|
|
161
|
+
|
|
162
|
+
`SafeRealize` sits on top of it and makes the distinction that matters: **a
|
|
163
|
+
throttle is not a broken resource.** `Governor::THROTTLE_ERRORS` is the single
|
|
164
|
+
list of what counts — `TooManyRequestsError` **and `ForbiddenError`**, because
|
|
165
|
+
api.w3.org signals rate limiting with 403 as well as 429 (w3c_api's whole retry
|
|
166
|
+
layer is built around that). Blacklisting a 403 would be the same bug as
|
|
167
|
+
blacklisting a 429. It retries a rate-limited realize
|
|
168
|
+
`THROTTLE_ATTEMPTS` times behind the shared cooldown and records a give-up in
|
|
169
|
+
`SafeRealize.throttled` — *never* in `skipped`. An href that a later attempt does
|
|
170
|
+
realize is removed from `throttled` again, so a briefly-throttled-then-recovered
|
|
171
|
+
resource doesn't count against the budget for a document that is in fact present.
|
|
172
|
+
`fetch_specifications_page` is the exception to `THROTTLE_ATTEMPTS`: an index page
|
|
173
|
+
retries until the *governor* gives up, since a 3-attempt cap would pre-empt the
|
|
174
|
+
escalation ladder and report a pagination fault instead of the real reason.
|
|
175
|
+
Everything else is unchanged:
|
|
176
|
+
`NotFoundError` and other terminal `Lutaml::Hal::Error`s (403, 5xx) still go into
|
|
177
|
+
`skipped` (a `Concurrent::Map`) so a broken link isn't re-fetched for every
|
|
178
|
+
reference, and network errors are remembered nowhere so a later reference can try
|
|
179
|
+
again. 5xx deliberately stays a permanent skip — the handful of persistent
|
|
180
|
+
per-resource 500s a healthy crawl sees are broken records, and routing them
|
|
181
|
+
through the governor would open a pool-wide cooldown for each.
|
|
182
|
+
|
|
183
|
+
Successful objects are cached by **w3c_api** (lutaml-hal caches realized objects
|
|
184
|
+
keyed by URL, thread-safely as of lutaml-hal 0.2.1), so `SafeRealize` doesn't
|
|
185
|
+
cache them.
|
|
186
|
+
|
|
187
|
+
> **Why this exists.** The 2026-08-19 `relaton-data-w3c` crawl ran **4 h 56 m**
|
|
188
|
+
> (healthy runs take ~1 h 30 m), permanently blacklisted **1,412** rate-limited
|
|
189
|
+
> resources, and only then aborted on a failed index page. Every 429 warning in
|
|
190
|
+
> that log is ~31 s apart — one exhausted lutaml-hal chain — i.e. *every* request
|
|
191
|
+
> for four hours was rate-limited. Had the last index page happened to succeed,
|
|
192
|
+
> a dataset missing 1,412 documents would have been committed.
|
|
193
|
+
|
|
194
|
+
### Index (`index-v2`, pubid-keyed)
|
|
195
|
+
|
|
196
|
+
`INDEXFILE` is the pubid-backed **`index-v2`**: rows are `Pubid::W3c::Identifier`
|
|
197
|
+
leaves serialized as `_type: pubid:w3c:*` with a `number` (the document slug) and
|
|
198
|
+
an optional verbatim `date`.
|
|
199
|
+
|
|
200
|
+
```yaml
|
|
201
|
+
- :id:
|
|
202
|
+
_type: pubid:w3c:recommendation
|
|
203
|
+
number: xml-names
|
|
204
|
+
date: '20091208'
|
|
205
|
+
:file: data/rec-xml-names-20091208.yaml
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Why.** `Relaton::Index::Type#candidates_by_number` sorts and binary-searches
|
|
209
|
+
every row on `id.root.number.to_s`. The old `index-v1` stored plain hashes and
|
|
210
|
+
was built without `pubid_class:`, so `FileIO#sorted` stayed false and every
|
|
211
|
+
lookup scanned all 17,287 rows. Keyed on the pubid slug the corpus splits into
|
|
212
|
+
**1,972 buckets**, the largest holding 101 rows.
|
|
213
|
+
|
|
214
|
+
**The pubid contract.** `Pubid::W3c::Identifier` keeps the slug in `number`.
|
|
215
|
+
It was called `code` until pubid #339 (merged 2026-08-28, `181fd4b9`), which
|
|
216
|
+
renamed it with **no alias** — read `number`, never `code`. The root `Gemfile`
|
|
217
|
+
pins pubid to `main`, so a checkout whose lock predates that merge still has the
|
|
218
|
+
old attribute: run `bundle update pubid`, not `bundle install`, because an
|
|
219
|
+
already-locked git source does not refloat.
|
|
220
|
+
|
|
221
|
+
**Where the consumer reads it: the Pages machine index (relaton#189 pilot).**
|
|
222
|
+
`Bibliography#index` is `find_or_create(:W3C, pages_url: PAGES_URL,
|
|
223
|
+
pubid_class: ...)`, not the `index-v2.zip` under `SOURCE`. A parsed query reads
|
|
224
|
+
`index/manifest.json` and the one `index/shard-NNNNN.json` of its slug from
|
|
225
|
+
`https://relaton.github.io/relaton-data-w3c/`, in memory for 24 h — three
|
|
226
|
+
requests for a lookup, including the document, in place of a 180 KB zip. A Pages
|
|
227
|
+
failure raises `Relaton::RequestError`, with no fallback to the zip. Only the
|
|
228
|
+
case-insensitive `#loose_match?` fallback reads every row (the Pages copy of the
|
|
229
|
+
zip), and only after an exact miss. Documents still come from `SOURCE`.
|
|
230
|
+
`get`/`search` accept a parsed `Pubid::W3c::Identifier` as well as a String, so
|
|
231
|
+
`Relaton::Db` can hand over the pubid it parsed (#189 change 2).
|
|
232
|
+
|
|
233
|
+
**The Pages copy lags the data repo.** The site is rebuilt by the `Deploy`
|
|
234
|
+
workflow, not by the crawler's push, so a document the crawler committed after
|
|
235
|
+
the last deploy is not in any shard yet. On 2026-09-23 the Pages index had
|
|
236
|
+
17,396 rows against 17,444 in `v2/index-v2.zip`: the 48 missing rows
|
|
237
|
+
(`webxrlayers-1`, `rdf12-xml`, …) were committed at 20:01, after the 18:54
|
|
238
|
+
deploy. Until the next deploy those references answer "not found".
|
|
239
|
+
|
|
240
|
+
**Producer and consumer must stay in step.** Both pass
|
|
241
|
+
`pubid_class: ::Pubid::W3c::Identifier` — `DataFetcher#index` and
|
|
242
|
+
`Bibliography#index` (which deserializes the shard rows with it). (`Processor#remove_index_file` passes `url: true` and
|
|
243
|
+
`file:` only: the delete never reads the index, see
|
|
244
|
+
`lib/relaton/index/CLAUDE.md`.) On the producer side
|
|
245
|
+
`#index_primary` stores the pubid **object**: `Relaton::Index::FileIO#save` only
|
|
246
|
+
serializes to the `_type:` shape when the value is an instance of the configured
|
|
247
|
+
`pubid_class`, so handing it a hash writes a v1-shaped file under a v2 name,
|
|
248
|
+
silently, with no narrowing. On the consumer side, omitting `pubid_class:` leaves
|
|
249
|
+
the rows as raw hashes and `FileIO#sorted` false, so `Type#search` stops
|
|
250
|
+
narrowing — also silently.
|
|
251
|
+
|
|
252
|
+
**How a reference is matched.** `Bibliography#best_match` passes the parsed
|
|
253
|
+
pubid to `Index::Type#search`, which is what enables the bsearch; a block alone
|
|
254
|
+
scans everything. It then selects with pubid's asymmetric subset match
|
|
255
|
+
(`Pubid::SubsetMatch`) — the reference on the left, a component it omits matches
|
|
256
|
+
any value:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
rows = index.search(pubid) { |r| pubid === r[:id] }
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`date` is W3C's only optional component, so an undated `REC-xml-names` still
|
|
263
|
+
finds the dated row and a dated reference reaches only its own. The maturity
|
|
264
|
+
level is **not** a wildcard: it is the identifier's class, and `===` requires
|
|
265
|
+
the same class, so `WD-`, `REC-` and a bare slug never match each other — the
|
|
266
|
+
contract the bespoke `PubId#==` had for its `stage`/`type`. Measured over the
|
|
267
|
+
whole fixture index (17,303 rows, 1,214,578 comparisons): the subset match and
|
|
268
|
+
the old `matches?(…, ignore: [:date])` agree on every pair. Newest edition wins, with the file path breaking ties because
|
|
269
|
+
undated rows all score 0 and the index sort is not stable.
|
|
270
|
+
|
|
271
|
+
Two things the narrowing cannot do, both handled around it:
|
|
272
|
+
|
|
273
|
+
- **Case.** The bsearch key is case-sensitive; `PubId#==` compared its slug with
|
|
274
|
+
`casecmp?`. When the narrowed range is empty, `#loose_match?` repeats the
|
|
275
|
+
comparison case-insensitively over a full scan (the BIPM `search_index`
|
|
276
|
+
precedent).
|
|
277
|
+
- **Queries that are not identifiers.** `#parse_ref`/`#normalize_ref` absorb a
|
|
278
|
+
URL (`https://www.w3.org/TR/xml-names/`) and a leading `TR-`/`TR/`, which the
|
|
279
|
+
bespoke regex accepted. A URL is not an identifier and `TR` is a path segment
|
|
280
|
+
of one rather than a maturity level, so neither belongs in a pubid grammar —
|
|
281
|
+
they stay here. The publisher prefix is added when absent. A reference pubid
|
|
282
|
+
still rejects is a **miss, not an error**: `parse_ref` returns nil outside the
|
|
283
|
+
transport rescue, so it never becomes a `Relaton::RequestError`.
|
|
284
|
+
|
|
285
|
+
**An unparseable id is an ERROR, never a warning.** `Docidentifier#parse` logs at
|
|
286
|
+
`Util.error` and leaves `#pubid` nil — it must not raise, or an already-published
|
|
287
|
+
record would stop deserializing. `DataFetcher#index_primary` then records the
|
|
288
|
+
failure in `@errors` **keyed by the id, not the output path** (the id is the
|
|
289
|
+
defect; one broken id reaching several files must still report once), and the
|
|
290
|
+
inherited `Core::DataFetcher#report_errors` logs it and raises a tracked GitHub
|
|
291
|
+
issue. The row is skipped rather than indexed unparsed: `Relaton::Index` rejects
|
|
292
|
+
the *whole* index if one row fails to deserialize, so one bad record must cost
|
|
293
|
+
one document, never every lookup. The data file is still written — unindexed,
|
|
294
|
+
not lost.
|
|
295
|
+
|
|
296
|
+
`Core::DataFetcher#report_errors` treats a **String** `@errors` value as the
|
|
297
|
+
message itself; a boolean still means "this field failed for every record" and
|
|
298
|
+
renders as `Failed to fetch <key>`. That is what lets this flavor report a
|
|
299
|
+
specific per-document failure without overriding `report_errors`.
|
|
300
|
+
|
|
301
|
+
### Key Dependencies
|
|
302
|
+
|
|
303
|
+
- **relaton-bib** (~> 2.2.0) — provides base `Bib::Item`, `Bib::Ext`, `Bib::Doctype` and serialization mixins (LutaML model layer)
|
|
304
|
+
- **relaton-core** — provides base `Core::Processor` and `Core::DataFetcher`
|
|
305
|
+
- **relaton-index** — index-based search for bibliographic references; also unpacks the index zip at runtime
|
|
306
|
+
- **pubid** — `Pubid::W3c::Identifier` backs `Docidentifier` and the `index-v2` rows
|
|
307
|
+
- **w3c_api** (~> 0.3.3) — W3C API (HAL/REST) client used by `DataFetcher` to retrieve specifications; owns the `User-Agent`, the 403 retry, and the object cache. The 0.3.3 floor is load-bearing, not cosmetic — see **Rate limiting & retries**.
|
|
308
|
+
- **lutaml-hal** (~> 0.2, >= 0.2.5) — HAL layer beneath w3c_api. Declared directly because this flavor rescues `Lutaml::Hal::*` error classes by name; `ForbiddenError` only exists from 0.2.5, and w3c_api's own `~> 0.2.1` would resolve happily to an older one and NameError at rescue time. 0.2.5 also made `Client#get`'s last-response bookkeeping thread-local (lutaml/lutaml-hal#21), which matters directly to the worker pool.
|
|
309
|
+
|
|
310
|
+
The W3C data is fetched entirely through `w3c_api`; the older RDF/SPARQL/scraping stack (linkeddata, rdf, sparql, shex, mechanize, …) has been removed.
|
|
311
|
+
|
|
312
|
+
### Schema Validation
|
|
313
|
+
|
|
314
|
+
XML output is validated against RelaxNG grammars in `grammars/`:
|
|
315
|
+
- `relaton-w3c-compile.rng` — top-level compiled grammar (includes all others)
|
|
316
|
+
- `relaton-w3c.rng` — W3C-specific overrides (DocumentType restrictions)
|
|
317
|
+
- `basicdoc.rng`, `biblio.rng`, `biblio-standoc.rng` — shared base schemas
|
|
318
|
+
|
|
319
|
+
Tests use [Jing](https://github.com/jing-trang/jing-trang) for RelaxNG validation.
|
|
320
|
+
|
|
321
|
+
### Test Structure
|
|
322
|
+
|
|
323
|
+
Tests use RSpec with:
|
|
324
|
+
- **Round-trip tests** — YAML/XML → object → YAML/XML, verifying lossless serialization
|
|
325
|
+
- **Schema validation** — XML output validated against `grammars/relaton-w3c-compile.rng`
|
|
326
|
+
- **VCR** — recorded HTTP cassettes in `spec/vcr_cassettes/` (7-day re-record interval)
|
|
327
|
+
- **WebMock** — disables external HTTP in tests
|
|
328
|
+
|
|
329
|
+
Test fixtures live in `spec/fixtures/` (YAML and XML files).
|
|
330
|
+
|
|
331
|
+
## Style
|
|
332
|
+
|
|
333
|
+
- Follows [Ribose OSS Ruby style guide](https://github.com/riboseinc/oss-guides) via RuboCop
|
|
334
|
+
- Target Ruby version: 3.1
|
|
335
|
+
- RuboCop config inherits from remote Ribose guide; Rails cops disabled
|
|
336
|
+
|
|
337
|
+
## CI
|
|
338
|
+
|
|
339
|
+
GitHub Actions workflows (auto-generated by Cimas) delegate to shared workflows in `relaton/support`:
|
|
340
|
+
- `rake.yml` — runs tests on push to main and PRs
|
|
341
|
+
- `release.yml` — gem versioning and publishing to RubyGems
|
|
342
|
+
|
|
343
|
+
## Testing
|
|
344
|
+
|
|
345
|
+
- **Index fixture:** `spec/fixtures/index-v2.zip` — the published index verbatim, seeded into the `Relaton::Index` pool by `spec/support/webmock.rb`. Refresh it from the live published `index-v2.zip`; there is no `rake spec:update_index` task in this repo.
|
|
346
|
+
- It is built **with `pubid_class:`**. Without it the rows stay raw hashes and `Type#search` silently stops narrowing, so the suite would pass while exercising something the runtime never does.
|
|
347
|
+
- It is re-seeded in **`before(:each)`**, not only `before(:suite)`: `Index::Pool#type` replaces the pooled entry whenever `actual?` says no, and `DataFetcher#index` asks for the same `:W3C` type with `file:` but no `url:`. `before(:suite)` alone leaves a later example searching a producer index. IANA hit this first.
|
|
348
|
+
- The zip name is read from `Relaton::W3c::INDEXFILE`, but **lazily** — `spec_helper` loads `support/` before the flavor, so touching the constant at file-body time NameErrors.
|
|
349
|
+
- **The HAL object cache is process-global, and the suite clears it per example.**
|
|
350
|
+
`W3cApi::Hal` is a Singleton holding one `Lutaml::Hal::ModelRegister` with an
|
|
351
|
+
in-memory URL cache, registered in the `Lutaml::Hal::GlobalRegister` Singleton.
|
|
352
|
+
`W3cApi::Client.new` carries no state, so a fresh client per example still
|
|
353
|
+
reads that one cache. Left warm it chains the examples together:
|
|
354
|
+
`DataParser#parse` realizes `@spec.links.specification` for the `editionOf`
|
|
355
|
+
relation, so the `vcr: "webrtc-20241008"` examples fetch
|
|
356
|
+
`https://api.w3.org/specifications/webrtc` and the later `vcr: "webrtc"`
|
|
357
|
+
examples then issue no request at all. A cassette recorded in that state holds
|
|
358
|
+
only what the cache was missing, and replays only in that one example order —
|
|
359
|
+
which is how `spec/w3c/vcr_cassettes/webrtc.yml` came to lack the very request
|
|
360
|
+
its own examples make. `spec/w3c/support/hal_cache.rb` clears the cache in a
|
|
361
|
+
`before` hook so each example issues its own requests. **A cassette
|
|
362
|
+
re-recorded without that reset is not reusable.**
|
|
363
|
+
- **`json` must stay below 3 (`relaton.gemspec`).** json 3.0.0 made the
|
|
364
|
+
`JSON.parse` options keyword-only; Faraday's JSON response middleware — the
|
|
365
|
+
one `Lutaml::Hal::Client#create_connection` installs — still passes them
|
|
366
|
+
positionally, up to and including faraday 2.14.3. With json 3 every
|
|
367
|
+
api.w3.org response raises `ArgumentError: wrong number of arguments (given 2,
|
|
368
|
+
expected 1)`, surfaced as `Lutaml::Hal::ParsingError`, and this flavor fetches
|
|
369
|
+
nothing. `spec/w3c/relaton/w3c/faraday_json_spec.rb` guards the constraint.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
= Relaton::W3c
|
|
2
|
+
|
|
3
|
+
RelatonW3c is a Ruby gem that implements the https://github.com/metanorma/metanorma-model-iso#iso-bibliographic-item[IsoBibliographicItem model].
|
|
4
|
+
|
|
5
|
+
You can use it to retrieve metadata of W3C Standards from https://w3.org, and access such metadata through the `Relaton::W3c::Item` object.
|
|
6
|
+
|
|
7
|
+
== Usage
|
|
8
|
+
|
|
9
|
+
=== Search for a standard using keywords
|
|
10
|
+
|
|
11
|
+
[source,ruby]
|
|
12
|
+
----
|
|
13
|
+
require 'relaton/w3c'
|
|
14
|
+
=> true
|
|
15
|
+
|
|
16
|
+
item = Relaton::W3c::Bibliography.get "W3C REC-json-ld11-20200716"
|
|
17
|
+
[relaton-w3c] INFO: (W3C REC-json-ld11-20200716) Fetching from Relaton repository ...
|
|
18
|
+
[relaton-w3c] INFO: (W3C REC-json-ld11-20200716) Found: `W3C REC-json-ld11-20200716`
|
|
19
|
+
=> #<Relaton::W3c::ItemData:0x0000000129b05b38
|
|
20
|
+
...
|
|
21
|
+
----
|
|
22
|
+
|
|
23
|
+
=== XML serialization
|
|
24
|
+
|
|
25
|
+
[source,ruby]
|
|
26
|
+
----
|
|
27
|
+
item.to_xml
|
|
28
|
+
=> "<bibitem id="W3CRECjsonld1120200716" type="standard" schema-version="v1.4.1">
|
|
29
|
+
<fetched>2026-03-04</fetched>
|
|
30
|
+
<formattedref>W3C REC-json-ld11-20200716</formattedref>
|
|
31
|
+
<title language="en" script="Latn">JSON-LD 1.1</title>
|
|
32
|
+
<uri type="src">https://www.w3.org/TR/2020/REC-json-ld11-20200716/</uri>
|
|
33
|
+
<docidentifier type="W3C" primary="true">W3C REC-json-ld11-20200716</docidentifier>
|
|
34
|
+
..
|
|
35
|
+
</bibitem>"
|
|
36
|
+
----
|
|
37
|
+
|
|
38
|
+
With argument `bibdata: true` it outputs XML wrapped by `bibdata` element and adds flavor `ext` element.
|
|
39
|
+
|
|
40
|
+
[source,ruby]
|
|
41
|
+
----
|
|
42
|
+
item.to_xml bibdata: true
|
|
43
|
+
=> "<bibdata type="standard" schema-version="v1.4.1">
|
|
44
|
+
<fetched>2026-03-04</fetched>
|
|
45
|
+
<formattedref>W3C REC-json-ld11-20200716</formattedref>
|
|
46
|
+
<title language="en" script="Latn">JSON-LD 1.1</title>
|
|
47
|
+
<uri type="src">https://www.w3.org/TR/2020/REC-json-ld11-20200716/</uri>
|
|
48
|
+
<docidentifier type="W3C" primary="true">W3C REC-json-ld11-20200716</docidentifier>
|
|
49
|
+
...
|
|
50
|
+
<ext schema-version="v1.0.0">
|
|
51
|
+
<doctype>technicalReport</doctype>
|
|
52
|
+
<flavor>w3c</flavor>
|
|
53
|
+
</ext>
|
|
54
|
+
</bibdata>"
|
|
55
|
+
----
|
|
56
|
+
|
|
57
|
+
=== Typed source links
|
|
58
|
+
|
|
59
|
+
Each W3C document has `src` type source link.
|
|
60
|
+
|
|
61
|
+
[source,ruby]
|
|
62
|
+
----
|
|
63
|
+
item.source.first.type
|
|
64
|
+
=> "src"
|
|
65
|
+
|
|
66
|
+
item.source.first.content
|
|
67
|
+
=> "https://www.w3.org/TR/2020/REC-json-ld11-20200716/"
|
|
68
|
+
----
|
|
69
|
+
|
|
70
|
+
=== Create bibliographic item from XML
|
|
71
|
+
[source,ruby]
|
|
72
|
+
----
|
|
73
|
+
Relaton::W3c::Bibdata.from_xml File.read('spec/fixtures/cr_json_ld11.xml')
|
|
74
|
+
=> #<Relaton::Bib::ItemData:0x0000000100f50018
|
|
75
|
+
...
|
|
76
|
+
----
|
|
77
|
+
|
|
78
|
+
=== Create bibliographic item from YAML
|
|
79
|
+
[source,ruby]
|
|
80
|
+
----
|
|
81
|
+
Relaton::W3c::Item.from_yaml File.read('spec/fixtures/item.yaml')
|
|
82
|
+
=> #<Relaton::W3c::ItemData:0x000000012b917450
|
|
83
|
+
...
|
|
84
|
+
----
|
|
85
|
+
|
|
86
|
+
=== Fetch data
|
|
87
|
+
|
|
88
|
+
The method `Relaton::W3c::DataFetcher.fetch(output: "data", format: "yaml")` converts all the documents from the W3C API and saves them to the `./data` folder in YAML format.
|
|
89
|
+
Arguments:
|
|
90
|
+
|
|
91
|
+
- `output` - folder to save documents (default './data').
|
|
92
|
+
- `format` - the format in which the documents are saved. Possible formats are: `yaml`, `xml`, `bibxml` (default `yaml`).
|
|
93
|
+
|
|
94
|
+
The available dataset is:
|
|
95
|
+
- `w3c-api` - The dataset is fetched from the W3C API.
|
|
96
|
+
|
|
97
|
+
[source,ruby]
|
|
98
|
+
----
|
|
99
|
+
require 'relaton/w3c/data_fetcher'
|
|
100
|
+
|
|
101
|
+
Relaton::W3c::DataFetcher.fetch
|
|
102
|
+
----
|
|
103
|
+
|
|
104
|
+
The crawl is tunable via environment variables:
|
|
105
|
+
|
|
106
|
+
- `RELATON_W3C_FETCH_CONCURRENCY` (default `4`) - number of parallel worker threads. The default is kept conservative so the version-history requests don't burst fast enough to trip the W3C API rate limiter; raise it for a faster run, lower it if you still see rate-limit (429) skips.
|
|
107
|
+
- `RELATON_W3C_FETCH_VERSIONS` (default enabled) - set to `false` for a faster, shallower crawl that fetches only the top-level specifications and skips each spec's version history (the bulk of the API requests). Leave it unset for a complete dataset.
|
|
108
|
+
|
|
109
|
+
The fetcher requests the specifications index with embedded specification data, so each specification is read from the page already in memory instead of issuing a separate HTTP request.
|
|
110
|
+
|
|
111
|
+
A full crawl is long-running, so it handles `Ctrl-C` gracefully: it stops fetching, lets in-flight work finish, and saves the index of everything collected so far rather than losing the run.
|
|
112
|
+
|
|
113
|
+
If a specifications-index page can't be fetched (e.g. a rate-limit that outlasts the retries) the crawl aborts with `CrawlIncompleteError` rather than treating the failure as the end of the list. This is deliberate: a truncated crawl is never saved, so a transient API hiccup can't silently drop most of the dataset.
|
|
114
|
+
|
|
115
|
+
[source,sh]
|
|
116
|
+
----
|
|
117
|
+
# Fast, shallow refresh: top-level specs only, 8 workers
|
|
118
|
+
RELATON_W3C_FETCH_VERSIONS=false RELATON_W3C_FETCH_CONCURRENCY=8 \
|
|
119
|
+
ruby -r relaton/w3c/data_fetcher -e 'Relaton::W3c::DataFetcher.fetch'
|
|
120
|
+
----
|
|
121
|
+
|
|
122
|
+
=== Logging
|
|
123
|
+
|
|
124
|
+
RelatonW3c uses the relaton-logger gem for logging. By default, it logs to STDOUT. To change the log levels and add other loggers, read the https://github.com/relaton/relaton-logger#usage[relaton-logger] documentation.
|
|
125
|
+
|
|
126
|
+
== Development
|
|
127
|
+
|
|
128
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
129
|
+
|
|
130
|
+
To update the index test fixture (used by tests), download the latest published index from the https://github.com/relaton/relaton-data-w3c[relaton-data-w3c] repository into `spec/w3c/fixtures/`. There is no `rake spec:update_index` task.
|
|
131
|
+
|
|
132
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
133
|
+
|
|
134
|
+
== Contributing
|
|
135
|
+
|
|
136
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/relaton/relaton_w3c.
|
|
137
|
+
|
|
138
|
+
== License
|
|
139
|
+
|
|
140
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|