/
JSDependencies.jl
339 lines (289 loc) · 10.2 KB
/
JSDependencies.jl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
"""
Provides an API to programmatically construct a [RequireJS](https://requirejs.org/) script.
"""
module JSDependencies
using JSON
"""
struct RemoteLibrary
Declares a remote JS dependency that should be declared in the RequireJS configuration shim.
# Fields
* `name`: a unique name for the dependency, used to refer to it in other dependencies and
snippets
* `url`: full remote URL from where the dependency can be loaded from
* `deps`: a list of the library's dependencies (becomes the `deps` configuration in the
RequireJS shim)
* `exports`: sets the `exports` config in the resulting RequireJS shim
# Constructors
```julia
RemoteLibrary(name::AbstractString, url::AbstractString; deps=String[], exports=nothing)
```
"""
struct RemoteLibrary
name :: String
url :: String
# The following become part of the shim
deps :: Vector{String}
exports :: Union{Nothing, String}
function RemoteLibrary(name::AbstractString, url::AbstractString; deps=String[], exports=nothing)
new(name, url, deps, exports)
end
end
"""
struct Snippet
Declares a JS code snipped that should be loaded with RequireJS. This gets wrapped in
`require([deps...], function(args...) {script...})` in the output.
# Fields
* `deps`: names of the [`RemoteLibrary`](@ref) dependencies of the snippet
* `args`: the arguments of the callback function, corresponding to the library objects
of the dependencies, in the order of `deps`
* `js`: the JS code of the function that gets used as the function body of the callback
# Constructors
```julia
Snippet(deps::AbstractVector, args::AbstractVector, js::AbstractString)
```
"""
struct Snippet
deps :: Vector{String}
args :: Vector{String}
js :: String
function Snippet(deps::AbstractVector, args::AbstractVector, js::AbstractString)
new(deps, args, js)
end
end
"""
struct RequireJS
Declares a single RequireJS configuration/app file.
# Fields
* `libraries`: a dictionary of [`RemoteLibrary`](@ref) declarations (keys are the library
names)
* `snippets`: a list of JS snippets ([`Snippet`](@ref))
# Constructors
```julia
RequireJS(libraries::AbstractVector{RemoteLibrary}, snippets::AbstractVector{Snippet} = Snippet[])
```
# API
* The `push!` function can be used to add additional libraries and snippets.
*
"""
struct RequireJS
libraries :: Dict{String, RemoteLibrary}
snippets :: Vector{Snippet}
function RequireJS(libraries::AbstractVector, snippets::AbstractVector = Snippet[])
all(x -> isa(x, RemoteLibrary), libraries) || throw(ArgumentError("Bad element types for `libraries`: $(typeof.(libraries))"))
all(x -> isa(x, Snippet), snippets) || throw(ArgumentError("Bad element types for `snippets`: $(typeof.(snippets))"))
r = new(Dict(), [])
for library in libraries
push!(r, library)
end
for snippet in snippets
push!(r, snippet)
end
return r
end
end
function Base.push!(r::RequireJS, lib::RemoteLibrary)
if lib.name in keys(r.libraries)
error("Library already added.")
end
r.libraries[lib.name] = lib
end
Base.push!(r::RequireJS, s::Snippet) = push!(r.snippets, s)
"""
verify(r::RequireJS; verbose=false) -> Bool
Checks that none of the dependencies are missing (returns `false` if some are). If `verbose`
is set to `true`, it will also log an error with the missing dependency.
"""
function verify(r::RequireJS; verbose=false)
isvalid = true
for (name, lib) in r.libraries
for dep in lib.deps
if !(dep in keys(r.libraries))
verbose && @error("$(dep) of $(name) missing from libraries")
isvalid = false
end
end
end
for s in r.snippets
for dep in s.deps
if !(dep in keys(r.libraries))
verbose && @error("$(dep) missing from libraries")
isvalid = false
end
end
end
return isvalid
end
"""
writejs(io::IO, r::RequireJS)
writejs(filename::AbstractString, r::RequireJS)
Writes out the [`RequireJS`](@ref) object as a valid JS that can be loaded with a `<script>`
tag, either into a stream or a file. It will contain all the configuration and snippets.
"""
function writejs end
function writejs(filename::AbstractString, r::RequireJS)
open(filename, "w") do io
writejs(io, r)
end
end
function writejs(io::IO, r::RequireJS)
write(io, """
// Generated by Documenter.jl
requirejs.config({
paths: {
""")
for (name, lib) in r.libraries
url = endswith(lib.url, ".js") ? replace(lib.url, r"\.js$" => "") : lib.url
write(io, """
'$(jsescape(lib.name))': '$(jsescape(url))',
""")
end
write(io, " }")
shim = shimdict(r)
isempty(shim) ? write(io, '\n') : write(io, ",\n shim: ", json_jsescape(shim, 2))
write(io, "});\n")
for s in r.snippets
args = join(s.args, ", ") # Note: not string literals => no escaping
deps = join(("\'$(jsescape(d))\'" for d in s.deps), ", ")
write(io, """
$("/"^80)
require([$(deps)], function($(args)) {
$(s.js)
})
""")
end
end
function shimdict(r::RequireJS)
shim = Dict{String,Any}()
for (name, lib) in r.libraries
@assert name == lib.name
libshim = shimdict(lib)
if libshim !== nothing
shim[name] = libshim
end
end
return shim
end
function shimdict(lib::RemoteLibrary)
isempty(lib.deps) && (lib.exports === nothing) && return nothing
shim = Dict{Symbol,Any}()
if !isempty(lib.deps)
shim[:deps] = lib.deps
end
if lib.exports !== nothing
shim[:exports] = lib.exports
end
return shim
end
"""
parse_snippet(filename::AbstractString) -> Snippet
parse_snippet(io::IO) -> Snippet
Parses a JS snippet file into a [`Snippet`](@ref) object.
# Format
The first few lines are parsed to get the dependencies and argument variable names of the
snippet. They need to match `^//\\s*([a-z]+):` (i.e. start with `//`, optional whitespace, a
lowercase identifier, and a colon). Once the parser hits a line that does not match that
pattern, it will assume that it and all the following lines are the actual script.
Only lowercase letters are allowed in the identifiers. Currently only `libraries` and
`arguments` are actually parsed and lines with other syntactically valid identifiers are
ignored. For `libraries` and `arguments`, the value (after the colon) must be a comma
separated list.
A valid snippet file would look like the following. Note that the list of arguments can be
shorter than the list of dependencies.
```js
// libraries: jquery, highlight, highlight-julia, highlight-julia-repl
// arguments: \$, hljs
// Initialize the highlight.js highlighter
\$(document).ready(function() {
hljs.initHighlighting();
})
```
"""
function parse_snippet end
parse_snippet(filename::AbstractString; kwargs...) = open(filename, "r") do io
parse_snippet(io; kwargs...)
end
function parse_snippet(io::IO)
libraries = String[]
arguments = String[]
lineno = 1
while true
pos = position(io)
line = readline(io)
m = match(r"^//\s*([a-z]+):(.*)$", line)
if m === nothing
seek(io, pos) # undo the last readline() call
break
end
if m[1] == "libraries"
libraries = strip.(split(m[2], ","))
if any(s -> match(r"^[a-z-_]+$", s) === nothing, libraries)
error("Unable to parse a library declaration '$(line)' on line $(lineno)")
end
elseif m[1] == "arguments"
arguments = strip.(split(m[2], ","))
end
lineno += 1
end
snippet = String(read(io))
Snippet(libraries, arguments, snippet)
end
"""
Replaces some of the characters in the string with escape sequences so that the strings
would be valid JS string literals, as per the
[ECMAScript® 2017 standard](https://www.ecma-international.org/ecma-262/8.0/index.html#sec-literals-string-literals).
Note that it always escapes both potential `"` and `'` closing quotes.
"""
function jsescape(s)
b = IOBuffer()
# From the ECMAScript® 2017 standard:
#
# > All code points may appear literally in a string literal except for the closing
# > quote code points, U+005C (REVERSE SOLIDUS), U+000D (CARRIAGE RETURN), U+2028 (LINE
# > SEPARATOR), U+2029 (PARAGRAPH SEPARATOR), and U+000A (LINE FEED).
#
# https://www.ecma-international.org/ecma-262/8.0/index.html#sec-literals-string-literals
#
# Note: in ECMAScript® 2019 (10th edition), U+2028 and U+2029 do not actually need to be
# escaped anymore:
#
# > Updated syntax includes /--/ allowing U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH
# > SEPARATOR) in string literals to align with JSON.
#
# https://www.ecma-international.org/ecma-262/10.0/index.html#sec-intro
#
# But we'll keep these escapes around for now, as not all JS parsers may be compatible
# with the latest standard yet.
for c in s
if c === '\u000a' # LINE FEED, i.e. \n
write(b, "\\n")
elseif c === '\u000d' # CARRIAGE RETURN, i.e. \r
write(b, "\\r")
elseif c === '\u005c' # REVERSE SOLIDUS, i.e. \
write(b, "\\\\")
elseif c === '\u0022' # QUOTATION MARK, i.e. "
write(b, "\\\"")
elseif c === '\u0027' # APOSTROPHE, i.e. '
write(b, "\\'")
elseif c === '\u2028' # LINE SEPARATOR
write(b, "\\u2028")
elseif c === '\u2029' # PARAGRAPH SEPARATOR
write(b, "\\u2029")
else
write(b, c)
end
end
String(take!(b))
end
"""
json_jsescape(args...)
Call `JSON.json(args...)` to generate a `String` of JSON, but then also escape two Unicode
characters to get valid JS (since [JSON is not a JS subset](http://timelessrepo.com/json-isnt-a-javascript-subset)).
!!! note
Technically, starting with ECMAScript® 2019 (10th edition), this is no longer necessary.
The JS standard was changed in a way that all valid JSON is also valid JS.
"""
function json_jsescape(args...)
escapes = ('\u2028' => "\\u2028", '\u2029' => "\\u2029")
reduce(replace, escapes, init=JSON.json(args...))
end
end