Skip to content

Commit b7c1940

Browse files
committed
resolves asciidoctor#1507 introduce an in-memory cache (Node.js)
1 parent eb33486 commit b7c1940

File tree

13 files changed

+476
-53
lines changed

13 files changed

+476
-53
lines changed

packages/core/lib/asciidoctor/js/asciidoctor_ext/node.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
require 'asciidoctor/js/asciidoctor_ext/node/open_uri'
33
require 'asciidoctor/js/asciidoctor_ext/node/stylesheet'
44
require 'asciidoctor/js/asciidoctor_ext/node/template'
5+
require 'asciidoctor/js/asciidoctor_ext/node/helpers'
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
module Asciidoctor
2+
# Internal: Except where noted, a module that contains internal helper functions.
3+
module Helpers
4+
module_function
5+
6+
# preserve the original require_library method
7+
alias original_require_library require_library
8+
9+
# Public: Require the specified library using Kernel#require.
10+
#
11+
# Attempts to load the library specified in the first argument using the
12+
# Kernel#require. Rescues the LoadError if the library is not available and
13+
# passes a message to Kernel#raise if on_failure is :abort or Kernel#warn if
14+
# on_failure is :warn to communicate to the user that processing is being
15+
# aborted or functionality is disabled, respectively. If a gem_name is
16+
# specified, the message communicates that a required gem is not available.
17+
#
18+
# name - the String name of the library to require.
19+
# gem_name - a Boolean that indicates whether this library is provided by a RubyGem,
20+
# or the String name of the RubyGem if it differs from the library name
21+
# (default: true)
22+
# on_failure - a Symbol that indicates how to handle a load failure (:abort, :warn, :ignore) (default: :abort)
23+
#
24+
# Returns The [Boolean] return value of Kernel#require if the library can be loaded.
25+
# Otherwise, if on_failure is :abort, Kernel#raise is called with an appropriate message.
26+
# Otherwise, if on_failure is :warn, Kernel#warn is called with an appropriate message and nil returned.
27+
# Otherwise, nil is returned.
28+
def require_library name, gem_name = true, on_failure = :abort
29+
if name == 'open-uri/cached'
30+
`return Opal.Asciidoctor.Cache.enable()`
31+
end
32+
original_require_library name, gem_name, on_failure
33+
end
34+
end
35+
end

packages/core/lib/asciidoctor/js/opal_ext/uri.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
module URI
22
def self.parse str
3-
str.extend URI
3+
# REMIND: Cannot create property '$$meta' on string in strict mode!
4+
#str.extend URI
5+
str
46
end
57

68
def path

packages/core/lib/open-uri/cached.rb

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# copied from https://github.com/tigris/open-uri-cached/blob/master/lib/open-uri/cached.rb
2+
module OpenURI
3+
class << self
4+
# preserve the original open_uri method
5+
alias original_open_uri open_uri
6+
def cache_open_uri(uri, *rest, &block)
7+
response = Cache.get(uri.to_s) ||
8+
Cache.set(uri.to_s, original_open_uri(uri, *rest))
9+
10+
if block_given?
11+
begin
12+
yield response
13+
ensure
14+
response.close
15+
end
16+
else
17+
response
18+
end
19+
end
20+
# replace the existing open_uri method
21+
alias open_uri cache_open_uri
22+
end
23+
24+
class Cache
25+
class << self
26+
27+
%x{
28+
// largely inspired by https://github.com/isaacs/node-lru-cache/blob/master/index.js
29+
let cache = new Map()
30+
let max = 16000000 // bytes
31+
let length = 0
32+
let lruList = []
33+
34+
class Entry {
35+
constructor (key, value, length) {
36+
this.key = key
37+
this.value = value
38+
this.length = length
39+
}
40+
}
41+
42+
const trim = () => {
43+
while (length > max) {
44+
pop()
45+
}
46+
}
47+
48+
const reset = () => {
49+
cache = new Map()
50+
length = 0
51+
lruList = []
52+
}
53+
54+
const pop = () => {
55+
const leastRecentEntry = lruList.pop()
56+
if (leastRecentEntry) {
57+
length -= leastRecentEntry.length
58+
cache.delete(leastRecentEntry.key)
59+
}
60+
}
61+
62+
const del = (entry) => {
63+
if (entry) {
64+
length -= entry.length
65+
cache.delete(entry.key)
66+
const entryIndex = lruList.indexOf(entry)
67+
if (entryIndex > -1) {
68+
lruList.splice(entryIndex, 1)
69+
}
70+
}
71+
}
72+
}
73+
74+
##
75+
# Retrieve file content and meta data from cache
76+
# @param [String] key
77+
# @return [StringIO]
78+
def get(key)
79+
%x{
80+
const cacheKey = crypto.createHash('sha256').update(key).digest('hex')
81+
if (cache.has(cacheKey)) {
82+
const entry = cache.get(cacheKey)
83+
const io = Opal.$$$('::', 'StringIO').$new()
84+
io['$<<'](entry.value)
85+
io.$rewind()
86+
return io
87+
}
88+
}
89+
90+
nil
91+
end
92+
93+
# Cache file content
94+
# @param [String] key
95+
# URL of content to be cached
96+
# @param [StringIO] value
97+
# value to be cached, typically StringIO returned from `original_open_uri`
98+
# @return [StringIO]
99+
# Returns value
100+
def set(key, value)
101+
%x{
102+
const cacheKey = crypto.createHash('sha256').update(key).digest('hex')
103+
const contents = value.string
104+
const len = contents.length
105+
if (cache.has(cacheKey)) {
106+
if (len > max) {
107+
// oversized object, dispose the current entry.
108+
del(cache.get(cacheKey))
109+
return value
110+
}
111+
// update current entry
112+
const entry = cache.get(cacheKey)
113+
// remove existing entry in the LRU list (unless the entry is already the head).
114+
const listIndex = lruList.indexOf(entry)
115+
if (listIndex > 0) {
116+
lruList.splice(listIndex, 1)
117+
lruList.unshift(entry)
118+
}
119+
entry.value = value
120+
length += len - entry.length
121+
entry.length = len
122+
trim()
123+
return value
124+
}
125+
126+
const entry = new Entry(cacheKey, value, len)
127+
// oversized objects fall out of cache automatically.
128+
if (entry.length > max) {
129+
return value
130+
}
131+
132+
length += entry.length
133+
lruList.unshift(entry)
134+
cache.set(cacheKey, entry)
135+
trim()
136+
return value
137+
}
138+
end
139+
140+
def max=(maxLength)
141+
%x{
142+
if (typeof maxLength !== 'number' || maxLength < 0) {
143+
throw new TypeError('max must be a non-negative number')
144+
}
145+
146+
max = maxLength || Infinity
147+
trim()
148+
}
149+
end
150+
151+
def reset
152+
`reset()`
153+
end
154+
end
155+
end
156+
end

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"scripts": {
2121
"test:graalvm": "node tasks/graalvm.cjs",
2222
"test:node": "mocha spec/*/*.spec.cjs && npm run test:node:esm",
23-
"test:node:esm": "mocha --experimental-json-modules spec/node/asciidoctor.spec.js",
23+
"test:node:esm": "mocha spec/node/asciidoctor.spec.js",
2424
"test:browser": "node spec/browser/run.cjs",
2525
"test:types": "rm -f types/tests.js && eslint types --ext .ts && tsc --build types/tsconfig.json && node --input-type=commonjs types/tests.js",
2626
"test": "node tasks/test/unsupported-features.cjs && npm run test:node && npm run test:browser && npm run test:types",
Lines changed: 5 additions & 0 deletions
Loading

packages/core/spec/node/asciidoctor.spec.js

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@ import dirtyChai from 'dirty-chai'
99
import dot from 'dot'
1010
import nunjucks from 'nunjucks'
1111
import Opal from 'asciidoctor-opal-runtime' // for testing purpose only
12-
1312
import semVer from '../share/semver.cjs'
1413
import MockServer from '../share/mock-server.cjs'
1514
import shareSpec from '../share/asciidoctor-spec.cjs'
1615
import includeHttpsSpec from '../share/asciidoctor-include-https-spec.cjs'
1716
import { fileExists, isWin, removeFile, resolveFixture, truncateFile } from './helpers.js'
1817
import Asciidoctor from '../../build/asciidoctor-node.js'
19-
import packageJson from '../../package.json'
2018

2119
import fooBarPostProcessor from '../share/extensions/foo-bar-postprocessor.cjs'
2220
import loveTreeProcessor from '../share/extensions/love-tree-processor.cjs'
@@ -30,6 +28,10 @@ import chartBlockMacro from '../share/extensions/chart-block.cjs'
3028
import smileyInlineMacro from '../share/extensions/smiley-inline-macro.cjs'
3129
import loremBlockMacro from '../share/extensions/lorem-block-macro.cjs'
3230

31+
import { createRequire } from 'module'
32+
const require = createRequire(import.meta.url)
33+
const packageJson = require('../../package.json')
34+
3335
const expect = chai.expect
3436
chai.use(dirtyChai)
3537

@@ -2186,6 +2188,109 @@ content
21862188
content`, options)
21872189
expect(html).to.contain('0. Chapter A')
21882190
})
2191+
2192+
describe('Cache', () => {
2193+
it('should cache remote SVG when allow-uri-read, cache-uri, and inline option are set', async () => {
2194+
try {
2195+
const input = `
2196+
2197+
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
2198+
2199+
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
2200+
2201+
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
2202+
`
2203+
await mockServer.resetRequests()
2204+
asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': '', 'cache-uri': '' } })
2205+
const requestsReceived = await mockServer.getRequests()
2206+
expect(requestsReceived.length).to.equal(1)
2207+
} finally {
2208+
asciidoctor.Cache.disable()
2209+
}
2210+
})
2211+
2212+
it('should not cache remote SVG when cache-uri is absent (undefined)', async () => {
2213+
const input = `
2214+
2215+
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
2216+
2217+
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
2218+
2219+
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
2220+
`
2221+
await mockServer.resetRequests()
2222+
asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': '' } })
2223+
const requestsReceived = await mockServer.getRequests()
2224+
expect(requestsReceived.length).to.equal(3)
2225+
})
2226+
2227+
it('should cache remote include when cache-uri is set', async () => {
2228+
try {
2229+
const input = `
2230+
2231+
include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=1..2]
2232+
2233+
include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=3..4]
2234+
`
2235+
await mockServer.resetRequests()
2236+
asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': true, 'cache-uri': '' } })
2237+
const requestsReceived = await mockServer.getRequests()
2238+
expect(requestsReceived.length).to.equal(1)
2239+
} finally {
2240+
asciidoctor.Cache.disable()
2241+
}
2242+
})
2243+
2244+
it('should not cache file if the size exceed the max cache', async () => {
2245+
try {
2246+
asciidoctor.Cache.setMax(1)
2247+
const input = `
2248+
2249+
include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=1..2]
2250+
2251+
include::${testOptions.remoteBaseUri}/include-lines.adoc[lines=3..4]
2252+
`
2253+
await mockServer.resetRequests()
2254+
asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': true, 'cache-uri': '' } })
2255+
const requestsReceived = await mockServer.getRequests()
2256+
expect(requestsReceived.length).to.equal(2)
2257+
} finally {
2258+
asciidoctor.Cache.disable()
2259+
asciidoctor.Cache.reset()
2260+
asciidoctor.Cache.setMax(Opal.Asciidoctor.Cache.DEFAULT_MAX)
2261+
}
2262+
})
2263+
2264+
it('should not exceed max cache size', async () => {
2265+
try {
2266+
// cc-zero.svg exact size/length
2267+
const contentLength = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'images', 'cc-zero.svg'), 'utf-8').length
2268+
asciidoctor.Cache.setMax(contentLength)
2269+
const input = `
2270+
2271+
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
2272+
2273+
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
2274+
2275+
// will remove cc-zero.svg from the cache!
2276+
image::${testOptions.remoteBaseUri}/cc-heart.svg[opts=inline]
2277+
2278+
image::${testOptions.remoteBaseUri}/cc-heart.svg[opts=inline]
2279+
2280+
image::${testOptions.remoteBaseUri}/cc-zero.svg[opts=inline]
2281+
`
2282+
await mockServer.resetRequests()
2283+
asciidoctor.convert(input, { safe: 'safe', attributes: { 'allow-uri-read': true, 'cache-uri': '' } })
2284+
const requestsReceived = await mockServer.getRequests()
2285+
expect(requestsReceived.length).to.equal(3)
2286+
expect(requestsReceived.map((request) => request.pathname)).to.have.members(['/cc-zero.svg', '/cc-heart.svg', '/cc-heart.svg'])
2287+
} finally {
2288+
asciidoctor.Cache.disable()
2289+
asciidoctor.Cache.reset()
2290+
asciidoctor.Cache.setMax(Opal.Asciidoctor.Cache.DEFAULT_MAX)
2291+
}
2292+
})
2293+
})
21892294
})
21902295

21912296
describe('Docinfo files', () => {

0 commit comments

Comments
 (0)