From ca6707d3c4c27a61462b6c87fc34bc19b5d78aaa Mon Sep 17 00:00:00 2001 From: Guillaume Grossetie Date: Wed, 29 Dec 2021 17:30:49 +0100 Subject: [PATCH] resolves #1507 introduce an in-memory cache (Node.js) --- .../asciidoctor/js/asciidoctor_ext/node.rb | 1 + .../js/asciidoctor_ext/node/abstract_node.rb | 13 +- .../js/asciidoctor_ext/node/helpers.rb | 35 ++++ .../core/lib/asciidoctor/js/opal_ext/uri.rb | 4 +- packages/core/lib/open-uri/cached.rb | 156 ++++++++++++++++++ packages/core/package.json | 2 +- .../core/spec/fixtures/images/cc-heart.svg | 5 + packages/core/spec/node/asciidoctor.spec.js | 108 +++++++++++- .../share/asciidoctor-include-https-spec.cjs | 70 ++++---- packages/core/spec/share/bin/mock-server.cjs | 7 + packages/core/spec/share/mock-server.cjs | 48 ++++++ .../core/src/template-asciidoctor-node.js | 38 +++++ packages/core/tasks/module/builder.cjs | 3 + packages/core/tasks/module/compiler.cjs | 51 +++--- 14 files changed, 487 insertions(+), 54 deletions(-) create mode 100644 packages/core/lib/asciidoctor/js/asciidoctor_ext/node/helpers.rb create mode 100644 packages/core/lib/open-uri/cached.rb create mode 100644 packages/core/spec/fixtures/images/cc-heart.svg diff --git a/packages/core/lib/asciidoctor/js/asciidoctor_ext/node.rb b/packages/core/lib/asciidoctor/js/asciidoctor_ext/node.rb index 2d5efcbf0..b023fab45 100644 --- a/packages/core/lib/asciidoctor/js/asciidoctor_ext/node.rb +++ b/packages/core/lib/asciidoctor/js/asciidoctor_ext/node.rb @@ -2,3 +2,4 @@ require 'asciidoctor/js/asciidoctor_ext/node/open_uri' require 'asciidoctor/js/asciidoctor_ext/node/stylesheet' require 'asciidoctor/js/asciidoctor_ext/node/template' +require 'asciidoctor/js/asciidoctor_ext/node/helpers' diff --git a/packages/core/lib/asciidoctor/js/asciidoctor_ext/node/abstract_node.rb b/packages/core/lib/asciidoctor/js/asciidoctor_ext/node/abstract_node.rb index 723349807..e2b4382e2 100644 --- a/packages/core/lib/asciidoctor/js/asciidoctor_ext/node/abstract_node.rb +++ b/packages/core/lib/asciidoctor/js/asciidoctor_ext/node/abstract_node.rb @@ -1,6 +1,15 @@ module Asciidoctor class AbstractNode + + @cache = {} + + def self.cache + @cache + end + def generate_data_uri_from_uri image_uri, cache_uri = false + return data if cache_uri && (data = ::Asciidoctor::AbstractNode.cache[uri]) + %x{ var contentType = '' var b64encoded = '' @@ -28,8 +37,10 @@ def generate_data_uri_from_uri image_uri, cache_uri = false self.$logger().$warn('could not retrieve image data from URI: ' + #{image_uri}) return #{image_uri} } - return 'data:' + contentType + ';base64,' + b64encoded + const result = 'data:' + contentType + ';base64,' + b64encoded } + ::Asciidoctor::AbstractNode.cache[image_uri] = result if cache_uri + return result end end end diff --git a/packages/core/lib/asciidoctor/js/asciidoctor_ext/node/helpers.rb b/packages/core/lib/asciidoctor/js/asciidoctor_ext/node/helpers.rb new file mode 100644 index 000000000..03dc4bb84 --- /dev/null +++ b/packages/core/lib/asciidoctor/js/asciidoctor_ext/node/helpers.rb @@ -0,0 +1,35 @@ +module Asciidoctor +# Internal: Except where noted, a module that contains internal helper functions. +module Helpers + module_function + + # preserve the original require_library method + alias original_require_library require_library + + # Public: Require the specified library using Kernel#require. + # + # Attempts to load the library specified in the first argument using the + # Kernel#require. Rescues the LoadError if the library is not available and + # passes a message to Kernel#raise if on_failure is :abort or Kernel#warn if + # on_failure is :warn to communicate to the user that processing is being + # aborted or functionality is disabled, respectively. If a gem_name is + # specified, the message communicates that a required gem is not available. + # + # name - the String name of the library to require. + # gem_name - a Boolean that indicates whether this library is provided by a RubyGem, + # or the String name of the RubyGem if it differs from the library name + # (default: true) + # on_failure - a Symbol that indicates how to handle a load failure (:abort, :warn, :ignore) (default: :abort) + # + # Returns The [Boolean] return value of Kernel#require if the library can be loaded. + # Otherwise, if on_failure is :abort, Kernel#raise is called with an appropriate message. + # Otherwise, if on_failure is :warn, Kernel#warn is called with an appropriate message and nil returned. + # Otherwise, nil is returned. + def require_library name, gem_name = true, on_failure = :abort + if name == 'open-uri/cached' + `return Opal.Asciidoctor.Cache.enable()` + end + original_require_library name, gem_name, on_failure + end +end +end diff --git a/packages/core/lib/asciidoctor/js/opal_ext/uri.rb b/packages/core/lib/asciidoctor/js/opal_ext/uri.rb index 9840b5743..f186f1ec2 100644 --- a/packages/core/lib/asciidoctor/js/opal_ext/uri.rb +++ b/packages/core/lib/asciidoctor/js/opal_ext/uri.rb @@ -1,6 +1,8 @@ module URI def self.parse str - str.extend URI + # REMIND: Cannot create property '$$meta' on string in strict mode! + #str.extend URI + str end def path diff --git a/packages/core/lib/open-uri/cached.rb b/packages/core/lib/open-uri/cached.rb new file mode 100644 index 000000000..ac80a73c4 --- /dev/null +++ b/packages/core/lib/open-uri/cached.rb @@ -0,0 +1,156 @@ +# copied from https://github.com/tigris/open-uri-cached/blob/master/lib/open-uri/cached.rb +module OpenURI + class << self + # preserve the original open_uri method + alias original_open_uri open_uri + def cache_open_uri(uri, *rest, &block) + response = Cache.get(uri.to_s) || + Cache.set(uri.to_s, original_open_uri(uri, *rest)) + + if block_given? + begin + yield response + ensure + response.close + end + else + response + end + end + # replace the existing open_uri method + alias open_uri cache_open_uri + end + + class Cache + class << self + + %x{ + // largely inspired by https://github.com/isaacs/node-lru-cache/blob/master/index.js + let cache = new Map() + let max = 16000000 // bytes + let length = 0 + let lruList = [] + + class Entry { + constructor (key, value, length) { + this.key = key + this.value = value + this.length = length + } + } + + const trim = () => { + while (length > max) { + pop() + } + } + + const reset = () => { + cache = new Map() + length = 0 + lruList = [] + } + + const pop = () => { + const leastRecentEntry = lruList.pop() + if (leastRecentEntry) { + length -= leastRecentEntry.length + cache.delete(leastRecentEntry.key) + } + } + + const del = (entry) => { + if (entry) { + length -= entry.length + cache.delete(entry.key) + const entryIndex = lruList.indexOf(entry) + if (entryIndex > -1) { + lruList.splice(entryIndex, 1) + } + } + } + } + + ## + # Retrieve file content and meta data from cache + # @param [String] key + # @return [StringIO] + def get(key) + %x{ + const cacheKey = crypto.createHash('sha256').update(key).digest('hex') + if (cache.has(cacheKey)) { + const entry = cache.get(cacheKey) + const io = Opal.$$$('::', 'StringIO').$new() + io['$<<'](entry.value) + io.$rewind() + return io + } + } + + nil + end + + # Cache file content + # @param [String] key + # URL of content to be cached + # @param [StringIO] value + # value to be cached, typically StringIO returned from `original_open_uri` + # @return [StringIO] + # Returns value + def set(key, value) + %x{ + const cacheKey = crypto.createHash('sha256').update(key).digest('hex') + const contents = value.string + const len = contents.length + if (cache.has(cacheKey)) { + if (len > max) { + // oversized object, dispose the current entry. + del(cache.get(cacheKey)) + return value + } + // update current entry + const entry = cache.get(cacheKey) + // remove existing entry in the LRU list (unless the entry is already the head). + const listIndex = lruList.indexOf(entry) + if (listIndex > 0) { + lruList.splice(listIndex, 1) + lruList.unshift(entry) + } + entry.value = value + length += len - entry.length + entry.length = len + trim() + return value + } + + const entry = new Entry(cacheKey, value, len) + // oversized objects fall out of cache automatically. + if (entry.length > max) { + return value + } + + length += entry.length + lruList.unshift(entry) + cache.set(cacheKey, entry) + trim() + return value + } + end + + def max=(maxLength) + %x{ + if (typeof maxLength !== 'number' || maxLength < 0) { + throw new TypeError('max must be a non-negative number') + } + + max = maxLength || Infinity + trim() + } + end + + def reset + `reset()` + end + end + end +end diff --git a/packages/core/package.json b/packages/core/package.json index 361de7569..2889f2172 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,7 +20,7 @@ "scripts": { "test:graalvm": "node tasks/graalvm.cjs", "test:node": "mocha spec/*/*.spec.cjs && npm run test:node:esm", - "test:node:esm": "mocha --experimental-json-modules spec/node/asciidoctor.spec.js", + "test:node:esm": "mocha spec/node/asciidoctor.spec.js", "test:browser": "node spec/browser/run.cjs", "test:types": "rm -f types/tests.js && eslint types --ext .ts && tsc --build types/tsconfig.json && node --input-type=commonjs types/tests.js", "test": "node tasks/test/unsupported-features.cjs && npm run test:node && npm run test:browser && npm run test:types", diff --git a/packages/core/spec/fixtures/images/cc-heart.svg b/packages/core/spec/fixtures/images/cc-heart.svg new file mode 100644 index 000000000..2db6ec14f --- /dev/null +++ b/packages/core/spec/fixtures/images/cc-heart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/core/spec/node/asciidoctor.spec.js b/packages/core/spec/node/asciidoctor.spec.js index 2066521ad..497a0da35 100644 --- a/packages/core/spec/node/asciidoctor.spec.js +++ b/packages/core/spec/node/asciidoctor.spec.js @@ -9,14 +9,12 @@ import dirtyChai from 'dirty-chai' import dot from 'dot' import nunjucks from 'nunjucks' import Opal from 'asciidoctor-opal-runtime' // for testing purpose only - import semVer from '../share/semver.cjs' import MockServer from '../share/mock-server.cjs' import shareSpec from '../share/asciidoctor-spec.cjs' import includeHttpsSpec from '../share/asciidoctor-include-https-spec.cjs' import { fileExists, isWin, removeFile, resolveFixture, truncateFile } from './helpers.js' import Asciidoctor from '../../build/asciidoctor-node.js' -import packageJson from '../../package.json' import fooBarPostProcessor from '../share/extensions/foo-bar-postprocessor.cjs' import loveTreeProcessor from '../share/extensions/love-tree-processor.cjs' @@ -30,6 +28,10 @@ import chartBlockMacro from '../share/extensions/chart-block.cjs' import smileyInlineMacro from '../share/extensions/smiley-inline-macro.cjs' import loremBlockMacro from '../share/extensions/lorem-block-macro.cjs' +import { createRequire } from "module" +const require = createRequire(import.meta.url) +const packageJson = require('../../package.json') + const expect = chai.expect chai.use(dirtyChai) @@ -2186,6 +2188,108 @@ content content`, options) expect(html).to.contain('0. Chapter A') }) + + describe('Cache', () => { + it('should cache remote SVG when allow-uri-read, cache-uri, and inline option are set', async () => { + try { + const input = ` + +image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline] + +image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline] + +image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline] +` + await mockServer.resetRequests() + asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': '', 'cache-uri': '' } }) + const requestsReceived = await mockServer.getRequests() + expect(requestsReceived.length).to.equal(1) + } finally { + asciidoctor.Cache.disable() + } + }) + + it('should not cache remote SVG when cache-uri is absent (undefined)', async () => { + const input = ` + +image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline] + +image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline] + +image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline] +` + await mockServer.resetRequests() + asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': '' } }) + const requestsReceived = await mockServer.getRequests() + expect(requestsReceived.length).to.equal(3) + }) + + it('should cache remote include when cache-uri is set', async () => { + try { + const input = ` + +include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=1..2] + +include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=3..4] +` + await mockServer.resetRequests() + asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': true, 'cache-uri': '' } }) + const requestsReceived = await mockServer.getRequests() + expect(requestsReceived.length).to.equal(1) + } finally { + asciidoctor.Cache.disable() + } + }) + + it('should not cache file if the size exceed the max cache', async () => { + try { + asciidoctor.Cache.setMax(1) + const input = ` + +include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=1..2] + +include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=3..4] +` + await mockServer.resetRequests() + asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': true, 'cache-uri': '' } }) + const requestsReceived = await mockServer.getRequests() + expect(requestsReceived.length).to.equal(2) + } finally { + asciidoctor.Cache.disable() + asciidoctor.Cache.reset() + asciidoctor.Cache.setMax(Opal.Asciidoctor.Cache.DEFAULT_MAX) + } + }) + + it('should not exceed max cache size', async () => { + try { + // cc-zero.svg exact size/length + asciidoctor.Cache.setMax(1519) + const input = ` + +image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline] + +image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline] + +// will remove cc-zero.svg from the cache! +image::${testOptions.remoteBaseUri}/cc-heart.svg[opts=inline] + +image::${testOptions.remoteBaseUri}/cc-heart.svg[opts=inline] + +image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline] +` + await mockServer.resetRequests() + asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': true, 'cache-uri': '' } }) + const requestsReceived = await mockServer.getRequests() + expect(requestsReceived.length).to.equal(3) + expect(requestsReceived.map((request) => request.pathname)).to.have.members(['/cc-zero.svg', '/cc-heart.svg', '/cc-heart.svg']) + } finally { + asciidoctor.Cache.disable() + asciidoctor.Cache.reset() + asciidoctor.Cache.setMax(Opal.Asciidoctor.Cache.DEFAULT_MAX) + } + }) + }) }) describe('Docinfo files', () => { diff --git a/packages/core/spec/share/asciidoctor-include-https-spec.cjs b/packages/core/spec/share/asciidoctor-include-https-spec.cjs index cda4696db..145a4c1ba 100644 --- a/packages/core/spec/share/asciidoctor-include-https-spec.cjs +++ b/packages/core/spec/share/asciidoctor-include-https-spec.cjs @@ -1,8 +1,8 @@ /* global it, describe, define */ const includeHttpsSpec = function (testOptions, asciidoctor, expect) { - if (testOptions.remoteBaseUri) { - describe('Include https URI', function () { - it('should include file with an absolute http URI (base_dir is an absolute http URI)', function () { + describe('Include https URI', function () { + it('should include file with an absolute http URI (base_dir is an absolute http URI)', function () { + if (testOptions.remoteBaseUri) { const opts = { safe: 'safe', base_dir: testOptions.remoteBaseUri, @@ -10,9 +10,11 @@ const includeHttpsSpec = function (testOptions, asciidoctor, expect) { } const html = asciidoctor.convert(`include::${testOptions.remoteBaseUri}/foo.adoc[]`, opts) expect(html).to.include('Foo') - }) + } + }) - it('should partially include file with an absolute http URI (using tag)', function () { + it('should partially include file with an absolute http URI (using tag)', function () { + if (testOptions.remoteBaseUri) { const opts = { safe: 'safe', attributes: { 'allow-uri-read': true } @@ -21,9 +23,11 @@ const includeHttpsSpec = function (testOptions, asciidoctor, expect) { expect(html).to.include('tag-a') html = asciidoctor.convert(`include::${testOptions.remoteBaseUri}/include-tag.adoc[tag=b]`, opts) expect(html).to.include('tag-b') - }) + } + }) - it('should partially include file with an absolute http URI (using lines)', function () { + it('should partially include file with an absolute http URI (using lines)', function () { + if (testOptions.remoteBaseUri) { const opts = { safe: 'safe', attributes: { 'allow-uri-read': true } @@ -34,19 +38,23 @@ const includeHttpsSpec = function (testOptions, asciidoctor, expect) { html = asciidoctor.convert(`include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=3..4]`, opts) expect(html).to.include('Third line') expect(html).to.include('Fourth line') - }) + } + }) - it('should include file with an absolute http URI (base_dir is not defined)', function () { + it('should include file with an absolute http URI (base_dir is not defined)', function () { + if (testOptions.remoteBaseUri) { const opts = { safe: 'safe', attributes: { 'allow-uri-read': true } } const html = asciidoctor.convert(`include::${testOptions.remoteBaseUri}/dir/bar.adoc[]`, opts) expect(html).to.include('Bar') - }) + } + }) - if (testOptions.platform === 'Node.js') { - // When running on Node.js, the following exception is thrown: - // "SecurityError: Jail is not an absolute path: http://localhost:8080" - } else { - it('should include file with a relative http URI (base_dir is an absolute http URI)', function () { + if (testOptions.platform === 'Node.js') { + // When running on Node.js, the following exception is thrown: + // "SecurityError: Jail is not an absolute path: http://localhost:8080" + } else { + it('should include file with a relative http URI (base_dir is an absolute http URI)', function () { + if (testOptions.remoteBaseUri) { const opts = { safe: 'safe', base_dir: testOptions.remoteBaseUri, @@ -54,15 +62,17 @@ const includeHttpsSpec = function (testOptions, asciidoctor, expect) { } const html = asciidoctor.convert('include::foo.adoc[]', opts) expect(html).to.include('Foo') - }) - } + } + }) + } - if (testOptions.platform !== 'Browser') { - // CommonJS and RequireJS tests suites are executed on a Browser (Chrome Headless). - // Other tests suites are executed on Node.js (using the xmlhttprequest Node module to emulate the browser XMLHttpRequest) - // Unlike the browser XMLHttpRequest, the xmlhttprequest node module does not expand path and therefore returns a 404! - } else { - it('should include file with a relative expandable path (base_dir is an absolute http URI)', function () { + if (testOptions.platform !== 'Browser') { + // CommonJS and RequireJS tests suites are executed on a Browser (Chrome Headless). + // Other tests suites are executed on Node.js (using the xmlhttprequest Node module to emulate the browser XMLHttpRequest) + // Unlike the browser XMLHttpRequest, the xmlhttprequest node module does not expand path and therefore returns a 404! + } else { + it('should include file with a relative expandable path (base_dir is an absolute http URI)', function () { + if (testOptions.remoteBaseUri) { const opts = { safe: 'safe', base_dir: `${testOptions.remoteBaseUri}/dir/subdir`, @@ -70,16 +80,18 @@ const includeHttpsSpec = function (testOptions, asciidoctor, expect) { } const html = asciidoctor.convert('include::../1.0.0/release.adoc[]', opts) expect(html).to.include('Emojis') - }) + } + }) - it('should include file with an absolute expandable https URI (base_dir is not defined)', function () { + it('should include file with an absolute expandable https URI (base_dir is not defined)', function () { + if (testOptions.remoteBaseUri) { const opts = { safe: 'safe', attributes: { 'allow-uri-read': true } } const html = asciidoctor.convert(`include::${testOptions.remoteBaseUri}/dir/subdir/../1.0.0/release.adoc[]`, opts) expect(html).to.include('Emojis') - }) - } - }) - } + } + }) + } + }) } if (typeof module !== 'undefined' && module.exports) { diff --git a/packages/core/spec/share/bin/mock-server.cjs b/packages/core/spec/share/bin/mock-server.cjs index 0a2ae3f4f..65cba8818 100644 --- a/packages/core/spec/share/bin/mock-server.cjs +++ b/packages/core/spec/share/bin/mock-server.cjs @@ -5,12 +5,19 @@ const ServerMock = require('mock-http-server') let server +// ignore "possible EventEmitter memory leak detected" warning message +process.setMaxListeners(0) process.on('message', (msg) => { if (msg.event === 'exit') { process.send({ event: 'exiting' }) process.exit(0) } else if (msg.event === 'configure') { server.on(msg.data) + } else if (msg.event === 'resetRequests') { + server.resetRequests() + process.send({ event: 'requestsCleared' }) + } else if (msg.event === 'getRequests') { + process.send({ event: 'requestsReceived', requestsReceived: server.requests() }) } }) diff --git a/packages/core/spec/share/mock-server.cjs b/packages/core/spec/share/mock-server.cjs index 9d4a650e7..dbaccba17 100644 --- a/packages/core/spec/share/mock-server.cjs +++ b/packages/core/spec/share/mock-server.cjs @@ -8,6 +8,8 @@ class MockServer { constructor (listener) { // we need to use "fork" to spawn a new Node.js process otherwise we will create a deadlock. this.childProcess = childProcess.fork(ospath.join(__dirname, 'bin', 'mock-server.cjs')) + // ignore "possible EventEmitter memory leak detected" warning message + this.childProcess.setMaxListeners(0) this.childProcess.on('message', (msg) => { if (msg.event === 'started') { // auto-configure @@ -98,6 +100,30 @@ tag-b } } }) + this.childProcess.send({ + event: 'configure', + data: { + method: 'GET', + path: '/cc-zero.svg', + reply: { + status: 200, + headers: { 'content-type': 'image/svg+xml' }, + body: fs.readFileSync(ospath.join(__dirname, '..', 'fixtures', 'images', 'cc-zero.svg'), 'utf-8') + } + } + }) + this.childProcess.send({ + event: 'configure', + data: { + method: 'GET', + path: '/cc-heart.svg', + reply: { + status: 200, + headers: { 'content-type': 'image/svg+xml' }, + body: fs.readFileSync(ospath.join(__dirname, '..', 'fixtures', 'images', 'cc-heart.svg'), 'utf-8') + } + } + }) } }) if (listener) { @@ -134,6 +160,28 @@ tag-b }) } } + + async resetRequests () { + return new Promise((resolve, reject) => { + this.childProcess.on('message', (msg) => { + if (msg.event === 'requestsCleared') { + resolve() + } + }) + this.childProcess.send({ event: 'resetRequests' }) + }) + } + + async getRequests () { + return new Promise((resolve, reject) => { + this.childProcess.on('message', (msg) => { + if (msg.event === 'requestsReceived') { + resolve(msg.requestsReceived) + } + }) + this.childProcess.send({ event: 'getRequests' }) + }) + } } module.exports = MockServer diff --git a/packages/core/src/template-asciidoctor-node.js b/packages/core/src/template-asciidoctor-node.js index d7e2feb85..dd09821c0 100644 --- a/packages/core/src/template-asciidoctor-node.js +++ b/packages/core/src/template-asciidoctor-node.js @@ -3,6 +3,7 @@ import { createRequire } from 'module' import { fileURLToPath } from 'url' import path from 'path' import fs from 'fs' +import crypto from 'crypto' import Opal from 'asciidoctor-opal-runtime' import unxhr from 'unxhr' @@ -13,6 +14,8 @@ const __asciidoctorDistDir__ = path.dirname(fileURLToPath(import.meta.url)) const __require__ = createRequire(import.meta.url) export default function (moduleConfig) { +//{{openUriCachedCode}} + //{{asciidoctorCode}} //{{asciidoctorAPI}} @@ -28,5 +31,40 @@ export default function (moduleConfig) { Asciidoctor.prototype.getVersion = function () { return ASCIIDOCTOR_JS_VERSION } + + // Alias + Opal.Asciidoctor.Cache = { + disable: function () { + // QUESTION: should we also reset cache? + const openUriSingleton = Opal.OpenURI.$singleton_class() + if (Opal.OpenURI['$respond_to?']('original_open_uri')) { + openUriSingleton.$send('remove_method', 'open_uri') + openUriSingleton.$send('alias_method', 'open_uri', 'original_open_uri') + } + }, + reset: function() { + // QUESTION: should we also reset the max value? + if (typeof Opal.OpenURI.Cache['$reset'] === 'function') { + Opal.OpenURI.Cache['$reset']() + } + }, + enable: function () { + const result = Opal.require('open-uri/cached') + const openUriSingleton = Opal.OpenURI.$singleton_class() + openUriSingleton.$send('remove_method', 'open_uri') + openUriSingleton.$send('alias_method', 'open_uri', 'cache_open_uri') + return result + }, + setMax: function (maxLength) { + if (!Opal.OpenURI['$respond_to?']('cache_open_uri')) { + this.enable() + } + if (typeof Opal.OpenURI.Cache['$max='] === 'function') { + Opal.OpenURI.Cache['$max='](maxLength) + } + } + } + Opal.Asciidoctor.Cache.DEFAULT_MAX = 16000000 + return Opal.Asciidoctor } diff --git a/packages/core/tasks/module/builder.cjs b/packages/core/tasks/module/builder.cjs index 6fc3af3b2..f3d7c8782 100644 --- a/packages/core/tasks/module/builder.cjs +++ b/packages/core/tasks/module/builder.cjs @@ -60,6 +60,7 @@ const generateCommonJSSpec = async () => { const bundle = await rollup({ input: 'spec/node/asciidoctor.spec.js', external: [ + 'crypto', 'child_process', 'module', 'url', @@ -142,6 +143,7 @@ const generateFlavors = async (asciidoctorCoreTarget, environments) => { templateFile = 'src/template-asciidoctor-browser.js' } templateModel['//{{asciidoctorCode}}'] = asciidoctorData + templateModel['//{{openUriCachedCode}}'] = fs.readFileSync('build/open-uri-cached.js', 'utf8') const content = parseTemplateFile(templateFile, templateModel) // remove the default export on Opal in the bundle because Asciidoctor is already the default export! // otherwise, the following exception is thrown: "Uncaught SyntaxError: Duplicate export of 'default'" @@ -161,6 +163,7 @@ const generateFlavors = async (asciidoctorCoreTarget, environments) => { const bundle = await rollup({ input: target, external: [ + 'crypto', 'module', 'url', 'path', diff --git a/packages/core/tasks/module/compiler.cjs b/packages/core/tasks/module/compiler.cjs index d4dd8a54f..8c546b7c3 100644 --- a/packages/core/tasks/module/compiler.cjs +++ b/packages/core/tasks/module/compiler.cjs @@ -5,16 +5,39 @@ const path = require('path') const bfs = require('bestikk-fs') const log = require('bestikk-log') +const compileOpenUriCache = (skipped) => { + if (fs.existsSync('lib/open-uri/cached.rb')) { + const module = 'open-uri/cached.rb' + log.debug(module) + const target = 'build/open-uri-cached.js' + if (fs.existsSync(target)) { + skipped.push(target) + return + } + // Build a new instance each time, otherwise the context is shared. + const opalBuilder = require('opal-compiler').Builder.create() + opalBuilder.appendPaths('lib') + opalBuilder.setCompilerOptions({ requirable: true }) + try { + const data = opalBuilder.build(module).toString() + fs.writeFileSync(target, data, 'utf8') + } catch (e) { + console.error(`Unable to compile: ${module}`, e) + throw e + } + } +} + const compileExt = (name, environment, skipped) => { if (fs.existsSync(`lib/asciidoctor/js/${name}_ext/${environment}.rb`)) { const module = `asciidoctor/js/${name}_ext/${environment}` log.debug(module) - // Build a new instance each time, otherwise the context is shared. const target = `build/${name}-ext-${environment}.js` if (fs.existsSync(target)) { skipped.push(target) return } + // Build a new instance each time, otherwise the context is shared. const opalBuilder = require('opal-compiler').Builder.create() opalBuilder.appendPaths('lib') opalBuilder.setCompilerOptions({ dynamic_require_severity: 'ignore', requirable: true }) @@ -39,6 +62,7 @@ const compileRuntimeEnvironments = (environments) => { compileExt('opal', environment, skipped) compileExt('asciidoctor', environment, skipped) } + compileOpenUriCache(skipped) if (skipped.length > 0) { log.info(`${skipped.join(', ')} files already exist, skipping "compile" task.\nTIP: Use "npm run clean:patch" to compile again from Ruby sources.`) } @@ -61,6 +85,12 @@ const compileAsciidoctorCore = (asciidoctorCoreDependency) => { fs.writeFileSync(converterFilePath, converterSource.replace(/^(\s+autoload :TemplateConverter,.*)$/m, '$1 unless RUBY_ENGINE == \'opal\''), 'utf-8') } // end::asciidoctor#4205 + // FIXME: remove once this change has been merged in Asciidoctor Ruby (core) + const readerFilePath = path.join(__dirname, '..', '..', 'build', 'asciidoctor', 'lib', 'asciidoctor', 'reader.rb') + if (fs.existsSync(readerFilePath)) { + const readerSource = fs.readFileSync(readerFilePath, 'utf8') + fs.writeFileSync(readerFilePath, readerSource.replace(/^(\s+Helpers\.require_library 'open-uri\/cached', 'open-uri-cached') unless defined\? ::OpenURI::Cache$/m, '$1'), 'utf-8') + } const opalBuilder = require('opal-compiler').Builder.create() opalBuilder.appendPaths('build/asciidoctor/lib') opalBuilder.appendPaths('node_modules/opal-compiler/src/stdlib') @@ -69,7 +99,6 @@ const compileAsciidoctorCore = (asciidoctorCoreDependency) => { fs.writeFileSync(target, opalBuilder.build('asciidoctor').toString(), 'utf8') replaceUnsupportedFeatures(asciidoctorCoreDependency) - applyPatches(asciidoctorCoreDependency) } const replaceUnsupportedFeatures = (asciidoctorCoreDependency) => { @@ -81,26 +110,8 @@ const replaceUnsupportedFeatures = (asciidoctorCoreDependency) => { fs.writeFileSync(path, data, 'utf8') } -const applyPatches = (asciidoctorCoreDependency) => { - log.task('apply patches') - const path = asciidoctorCoreDependency.target - let data = fs.readFileSync(path, 'utf8') - log.debug('preserve stack on Error (workaround: https://github.com/opal/opal/issues/1962)') - data = data.replace(/([\s]+)(wrapped_ex\.\$set_backtrace\(ex\.\$backtrace\(\)\);)/g, '$1$2$1wrapped_ex.stack = ex.stack;') - fs.writeFileSync(path, data, 'utf8') -} - -const patchOpalCompiler = () => { - // revert https://github.com/opal/opal/commit/d46792d2160e4f524c3add711f6424dd99187d1c - // see: https://github.com/opal/opal/issues/2099 - const sourceFile = path.join(__dirname, '..', '..', 'node_modules', 'opal-compiler', 'src', 'opal-builder.js') - const source = fs.readFileSync(sourceFile, 'utf8') - fs.writeFileSync(sourceFile, source.replace(/(Opal\.modules\["opal\/parser\/patch"].*?)(\s+\(function\([^)]+\) {\n\s+var self = \$klass\(\$base, \$super, 'Lexer'\);.*},\s\$Lexer_source_buffer\$eq\$1\.\$\$arity = 1\), nil\) && 'source_buffer='\n\s+}\)\(\$\$\(\$nesting, 'Parser'\), null, \$nesting\);)/gs, '$1')) -} - module.exports.compile = (asciidoctorCoreDependency, environments) => { bfs.mkdirsSync('build') - patchOpalCompiler() compileRuntimeEnvironments(environments) compileAsciidoctorCore(asciidoctorCoreDependency) log.task('copy resources')