Skip to content

Commit a22a36c

Browse files
committed
feat(site): AsciiDoc content collections for blog + docs (TODOs 51-52)
## Content ports - src/content/blog/: 4 posts copied from legacy _legacy-content/posts/ - src/content/docs/: 6 docs copied from legacy _legacy-content/docs/ ## AsciiDoc loader - src/content/loaders/asciidoc.ts - Pure function renderAsciiDocDir() reads .adoc files, parses header (title/author/date), renders body to HTML via @asciidoctor/core - No global state, side-effect-free; safe to call multiple times ## Blog detail pages (TODO 51) - src/pages/blog/[slug].astro: getStaticPaths from rendered entries - Per-post layout with back link, author, date, GitHub edit link - Astro renders body HTML via set:html ## Docs viewer (TODO 52) - src/pages/docs/[slug].astro: same pattern as blog - Sidebar nav across all docs (active link highlighting) - Edit-on-GitHub link per page - Responsive: sidebar drops below on mobile ## Updated docs index - Two-column runtime tiles (Ruby + TypeScript) - Full docs list with summaries - Source links to monorepo + maps ## Build - 18 pages now (was 8): +4 blog posts, +6 docs, +1 blog index update - All AsciiDoc converted to HTML at build time (zero client JS for content)
1 parent 9ae325a commit a22a36c

17 files changed

Lines changed: 2689 additions & 51 deletions

package-lock.json

Lines changed: 12 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"format:check": "prettier --check ."
1919
},
2020
"dependencies": {
21+
"@asciidoctor/core": "^4.0.6",
2122
"@astrojs/vue": "^7.0.0",
2223
"@tailwindcss/vite": "^4.0.0",
2324
"astro": "^7.0.0",
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
= WebAssembly and advanced regular expressions with Opal
2+
webdev778
3+
v1.0, 2021-06-26
4+
:doctype: book
5+
:docinfo:
6+
7+
NOTE: This is also published as a
8+
https://opalrb.com/blog/2021/06/26/webassembly-and-advanced-regexp-with-opal/[guest post]
9+
at the Opal website. Thank you and a great hug to the Opal team!
10+
11+
== Background
12+
13+
At https://github.com/riboseinc[Ribose] we develop
14+
https://github.com/interscript/interscript[Interscript], an open source Ruby
15+
implementation of interoperable transliteration schemes from
16+
https://www.loc.gov/catdir/cpso/roman.html[ALA-LC],
17+
https://www.usgs.gov/core-science-systems/ngp/board-on-geographic-names[BGN],
18+
https://www.gov.uk/government/groups/the-permanent-committee-on-geographical-names[PCGN],
19+
https://www.icao.int[ICAO], https://www.iso.org[ISO], https://www.un.org[UN] (by
20+
UNGEGN) and many, many other script conversion system authorities. The goal of
21+
this project is to achieve interoperable transliteration schemes allowing
22+
quality comparisons.
23+
24+
We needed to port the Interscript runtime to JavaScript using
25+
https://opalrb.com[Opal] (the Ruby to JavaScript compiler), so it can be also
26+
used in web browsers and https://nodejs.org[Node.js] environments.
27+
28+
The problem is that Opal translates Ruby regular expressions (upon which we rely
29+
quite heavily) to JavaScript almost verbatim. This made our ported codebase
30+
incompatible on principle, so we searched for a better solution.
31+
32+
Unfortunately, Regexp is basically something like a programming language that
33+
has more than a dozen of incompatible implementations -- even across the web
34+
browsers. For instance, we need lookbehind assertions, but even if there is a
35+
https://tc39.es/proposal-regexp-lookbehind/[new standard in ECMAScript] which
36+
adds lookbehind assertions, Safari doesn't implement that.
37+
38+
Given all this context let's dive into how we ported the original Ruby Regexp
39+
engine to the browser!
40+
41+
== Onigmo
42+
43+
We started by trying to compile https://github.com/k-takata/Onigmo[Onigmo] with
44+
WebAssembly.
45+
46+
Onigmo is a Regexp engine used by Ruby. It is a fork of
47+
https://github.com/kkos/oniguruma[Oniguruma], which
48+
is also in use by PHP and a few more programming languages. Fortunately, it was
49+
possible to compile it to a static WebAssembly module which can be interfaced
50+
with the JavaScript land.
51+
52+
We tried compiling Onigmo using a simple handcrafted `libc` with no memory
53+
management so as to reduce the size, but this plan backfired, and rightfully so!
54+
55+
Now we use https://github.com/WebAssembly/wasi-libc[wasi-libc]. WASI stands for
56+
WebAssembly System Interface, and is designed to provide "`a wide array of
57+
POSIX-compatible C APIs`".
58+
59+
The library is made to be able to work with both `wasi-libc` and the handcrafted
60+
`libc`, but use of `wasi-libc` is highly encouraged. As we are concerned about the
61+
output size of the resulting WASM binaries, we chose not to use
62+
https://emscripten.org[Emscripten], just
63+
the upstream LLVM/Clang and its WASM target.
64+
65+
== Opal-WebAssembly
66+
67+
After getting Onigmo working, we noted, that the WebAssembly interface doesn't
68+
map 100% between C and JS. We can't pass strings verbatim and we need to do
69+
memory management (think: pointers). Is there a better solution for that than
70+
writing an Opal library to interface WebAssembly libraries?
71+
72+
The solution we came up with is
73+
https://github.com/interscript/opal-webassembly[opal-webassembly].
74+
75+
This library is divided in two parts:
76+
77+
* a simple WebAssembly interface
78+
* a Ruby-FFI compatible binding that works on everything memory-related and
79+
brings C functions to seamlessly work with the Ruby (Opal, that is)
80+
workflow.
81+
82+
This library can be used in more advanced use cases beyond Interscript. Its
83+
interface is rather compatible with Ruby-FFI allowing C API bindings across all
84+
Ruby implementations. There are some minor incompatibilities though.
85+
86+
Ruby-FFI assumes a shared memory model. WebAssembly has different memory spaces
87+
for a calling process and each library (think about something like a segmented
88+
memory). This makes some assumptions false.
89+
90+
For instance, for the following code, we don't know which memory space to use:
91+
92+
[source,ruby]
93+
----
94+
FFI::MemoryPointer.new(:uint8, 1200)
95+
----
96+
97+
This requires us to use a special syntax, like:
98+
99+
[source,ruby]
100+
----
101+
LibraryName.context do
102+
FFI::MemoryPointer.new(:uint8, 1200)
103+
end
104+
----
105+
106+
This context call makes it clear that we want this memory to be allocated in the
107+
`LibraryName` space.
108+
109+
Another thing is that a call like the following:
110+
111+
[source,ruby]
112+
----
113+
FFI::MemoryPointer.from_string("Test string")
114+
----
115+
116+
Would not allocate the memory, but share the memory between the calling process
117+
and the library. In
118+
https://github.com/interscript/opal-webassembly[opal-webassembly] we must
119+
allocate the memory, as sharing is not an option in the WASM model.
120+
121+
Now, another issue comes into play. In regular Ruby a call similar to this
122+
should allocate the memory and clear it later, once the object is destroyed. In
123+
our case, we can't really access JavaScript's GC. This means we always need to
124+
free the memory ourselves.
125+
126+
Due to some Opal inadequacies, we are unable interface floating-point fields in
127+
structs. This doesn't happen in Onigmo, but if needed in the future, a
128+
pack/unpack implementation for those will be needed.
129+
130+
The Chromium browser doesn't allow us to load WebAssembly modules larger than
131+
4KB synchronously. This means that we had to implement some methods for awaiting
132+
the load. This also means, that in the browser we can't use the code in a
133+
following way:
134+
135+
[source,html]
136+
----
137+
<script src='file.js'></script>
138+
<script>
139+
Opal.Library.$new();
140+
</script>
141+
----
142+
143+
This approach works in Node.js and possibly in other browsers, but Chromium
144+
requires us to use promises:
145+
146+
[source,html]
147+
----
148+
<script src='file.js'></script>
149+
<script>
150+
Opal.WebAssembly.$wait_for("library-wasm").then(function() {
151+
Opal.Library.$new();
152+
});
153+
</script>
154+
----
155+
156+
There are certain assumptions of how a library should be loaded on Opal side --
157+
the FFI library creation depends on the WebAssembly module being already loaded,
158+
so we need to either move those definitions to a `wait_for` block or move require
159+
directives, like so:
160+
161+
[source,ruby]
162+
----
163+
WebAssembly.wait_for "onigmo/onigmo-wasm" do
164+
require 'interscript'
165+
require 'my_application_logic'
166+
end
167+
----
168+
169+
170+
== Opal-Onigmo
171+
172+
After having a nice library
173+
(https://github.com/interscript/opal-webassembly[opal-webassembly]) to bind with
174+
WebAssembly modules, writing an individual binding was very easy and the
175+
resulting code looks (in my opinion) very cool.
176+
177+
Our initial plan assumed upstreaming the code later on, but on further
178+
consideration it might not be the correct choice for Opal. This library should
179+
stay as a separate gem for a couple of reasons.
180+
181+
The resulting work is https://github.com/interscript/opal-onigmo[opal-onigmo],
182+
available on GitHub.
183+
184+
First, due to memory issues, we aren't able to make it work as a drop-in
185+
replacement. We need to manually call an `#ffi_free` method.
186+
187+
For example:
188+
189+
[source,ruby]
190+
----
191+
re = Onigmo::Regexp.new("ab+")
192+
# use the regular expression
193+
re.ffi_free # free it afterwards and not use it anymore
194+
----
195+
196+
At early stages our implementation of Opal-Onigmo we didn't consider the memory
197+
a problem. When hit with a real world scenario, we found out, that it's a severe
198+
issue and needs to be dealt with. As far as we know, the library doesn't leak
199+
any memory if the regular expression memory is managed correctly.
200+
201+
The second is that after all, we don't really have a way of caching the compiled
202+
Regexps. Furthermore, Onigmo compiled with WASM may not be as performant as the
203+
highly optimized JS regexp engine. In this case it's much better to leave it as
204+
a drop-in replacement for those who need more correctness.
205+
206+
Opal-Onigmo doesn't implement all the methods for Ruby Regexp, it was mostly
207+
meant for completion of the Interscript project, but can be extended beyond. It
208+
implements a few methods it needs to implement for String (this is just an
209+
option - you need to load onigmo/core_ext manually), but most of the existing
210+
ones work without a problem. We implemented a `Regexp.exec` (JavaScript) method,
211+
and the rest of Opal happened to mostly interface with it. At the current time
212+
we know that `String#split` won't "just" work, but
213+
`String#{index,rindex,partition,rpartition}` should.
214+
215+
Opal-Onigmo depends on the strings being coded as UTF-16. There are two reasons
216+
to that:
217+
218+
. Opal includes methods for getting the binary form of strings in various
219+
encodings, but only methods for UTF-16 are valid for characters beyond the
220+
Basic Multilingual Plane (Unicode 0x0000 to 0xffff) which are used in 2 maps.
221+
222+
. JavaScript uses UTF-16 strings internally.
223+
224+
225+
226+
== Interscript
227+
228+
Finally by using `opal-onigmo`, the Opal-generated code passes _all_ the tests
229+
(not counting transliterating Thai scripts which ultimately depends on an
230+
external process, which relies on machine learning).
231+
232+
To optimize it, we use `opal-onigmo` _only_ when the regexp is a more complex
233+
regexp, otherwise we fall back to an (ultimately faster) JavaScript regexp
234+
engine:
235+
236+
[source,ruby]
237+
----
238+
def mkregexp(regexpstring)
239+
@cache ||= {}
240+
if s = @cache[regexpstring]
241+
if s.class == Onigmo::Regexp
242+
# Opal-Onigmo stores a variable "lastIndex" mimicking the JS
243+
# global regexp. If we want to reuse it, we need to reset it.
244+
s.reset
245+
else
246+
s
247+
end
248+
else
249+
# JS regexp is more performant than Onigmo. Let's use the JS
250+
# regexp wherever possible, but use Onigmo where we must.
251+
# Let's allow those characters to happen for the regexp to be
252+
# considered compatible: ()|.*+?{} ** BUT NOT (? **.
253+
if /[\\$^\[\]]|\(\?/.match?(regexpstring)
254+
# Ruby caches its regexps internally. We can't GC. We could
255+
# think about freeing them, but we really can't, because they
256+
# may be in use.
257+
@cache[regexpstring] = Onigmo::Regexp.new(regexpstring)
258+
else
259+
@cache[regexpstring] = Regexp.new(regexpstring)
260+
end
261+
end
262+
end
263+
----
264+
265+
It also never frees the Regexps (see a previous note about `#ffi_free`), because
266+
we never know if a Regexp won't be in use later on (and the Regexps are actually
267+
cached in a Hash for performance reasons). The issue about dangling Regexps can
268+
be worked out in the future, but the JS API will need to change again.
269+
270+
We would need to do something like:
271+
272+
[source,ruby]
273+
----
274+
Opal.Interscript.$with_a_map("map-name", function() {
275+
// do some work with a map
276+
});
277+
----
278+
279+
This call would at the beginning allocate all the Regexps needed, and at the
280+
end, free them all. The good news is that we would be able to somehow integrate
281+
loading transliteration maps from the network (along with dependencies) with
282+
such a construct.
283+
284+
== The future
285+
286+
Post writing this article we noted that JavaScript actually does implement a
287+
construct that would work like a destructor, allowing us to free the allocated
288+
memory dynamically. Unfortunately, that is the latest ECMAScript addition, which
289+
means there are still environments that don't support it (Safari) and there is
290+
one that needs an explicit flag (Node 13+).
291+
292+
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry
293+
294+
We could use it to implement some parts of ObjectSpace of Ruby and then use it
295+
in `opal-webassembly` to free memory on demand.
296+
297+
298+
== Postscript
299+
300+
This article was written long before it was published. Since then, Interscript
301+
has been rewritten in a different architecture and does not relies on Opal.
302+
303+
While we no longer use Regexps directly, we have created a higher-level (Ruby)
304+
DSL to describe the transliteration process that we compile directly to a
305+
highly-optimized pure Ruby/JavaScript code (and it can be extended to other
306+
languages as well).
307+
308+
Ribose still uses Opal in other projects, for example to build the
309+
https://github.com/plurimath/latexmath[`latexmath` gem], a library that compiles
310+
LaTeX math expressions into MathML, as a JavaScript library. We also contribute
311+
fixes back to the upstream Opal project.
312+
313+
For the Opal project, this effort serves as an interesting experiment to
314+
establish further guidelines should we decide to increase Regexp compatibility
315+
in the future and can serve as a useful tool for anyone wanting to port his Ruby
316+
codebase with a heavy regexp use to JavaScript. It should also facilitate
317+
porting libraries that utilize Ruby-FFI.
318+
319+
The libraries we created are available under a 2-clause BSD license in the
320+
following repositories:
321+
322+
* https://github.com/interscript/Onigmo - Onigmo port to WebAssembly
323+
* https://github.com/interscript/opal-onigmo - the Onigmo interface to Opal
324+
* https://github.com/interscript/opal-webassembly - the FFI-like interface to Opal, using WebAssembly
325+
* https://github.com/interscript/interscript/tree/v1 - the obsolete v1 branch of Interscript that used Opal and Opal-Onigmo
326+
327+
Enjoy Opaling!

0 commit comments

Comments
 (0)