n3.js-messages 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/index.d.ts +115 -0
- package/lib/MessageParser.js +76 -0
- package/lib/MessageParserCore.js +162 -0
- package/lib/MessageScanner.js +395 -0
- package/lib/MessageStreamParser.js +44 -0
- package/lib/MessageStreamWriter.js +35 -0
- package/lib/MessageWriter.js +178 -0
- package/lib/formats.js +55 -0
- package/lib/index.js +47 -0
- package/lib/package.json +1 -0
- package/package.json +107 -0
- package/src/MessageParser.js +71 -0
- package/src/MessageParserCore.js +197 -0
- package/src/MessageScanner.js +390 -0
- package/src/MessageStreamParser.js +33 -0
- package/src/MessageStreamWriter.js +22 -0
- package/src/MessageWriter.js +194 -0
- package/src/formats.js +39 -0
- package/src/index.js +4 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// **MessageScanner** finds RDF Message delimiters in a text stream.
|
|
2
|
+
//
|
|
3
|
+
// N3.js has no extension point for new directives, and its lexer rejects
|
|
4
|
+
// `MESSAGE` and `@message`. The scanners in this file therefore sit in front of
|
|
5
|
+
// the N3.js lexer: they track just enough lexical context (IRIs, strings,
|
|
6
|
+
// long strings, comments, words, braces) to recognize *real* delimiters, and
|
|
7
|
+
// rewrite them into text that N3.js can lex and that is easy to recognize
|
|
8
|
+
// again in N3.js' public parser callbacks:
|
|
9
|
+
//
|
|
10
|
+
// - Turtle and TriG: a delimiter becomes `@version "<sentinel>" .`.
|
|
11
|
+
// N3.js only accepts version directives at statement boundaries, so the
|
|
12
|
+
// N3.js parser itself rejects delimiters in other grammar positions, and
|
|
13
|
+
// reports the delimiter through its public `onVersion` callback, exactly in
|
|
14
|
+
// order with the emitted quads. The dot matters: the N3.js lexer reads an
|
|
15
|
+
// `@` after a literal as a language tag, so a literal that ends a SPARQL-style
|
|
16
|
+
// `VERSION "label"` directive would swallow a following `@prefix`.
|
|
17
|
+
// For the same reason, SPARQL-style `VERSION "label"` directives in the input
|
|
18
|
+
// are rewritten to `@version "label" .`.
|
|
19
|
+
// - N-Triples and N-Quads: N3.js has no version directive in line mode.
|
|
20
|
+
// The grammar is line-based, so a delimiter or version line becomes the
|
|
21
|
+
// comment `#<sentinel>M<line>` / `#<sentinel>V<label>`, which is reported through
|
|
22
|
+
// the public `onComment` callback, again in order with the quads.
|
|
23
|
+
//
|
|
24
|
+
// Delimiters that are not recognized (inside strings, IRIs, comments, prefixed
|
|
25
|
+
// names or graph blocks) are passed through unchanged. The scanners never
|
|
26
|
+
// throw: whatever they do not rewrite is rejected by N3.js as a syntax error.
|
|
27
|
+
|
|
28
|
+
const TOP = 0, IRI = 1, LT = 2, COMMENT = 3, STRING = 4, QUOTE1 = 5, QUOTE2 = 6, LONG = 7, CANDIDATE = 8;
|
|
29
|
+
|
|
30
|
+
const MESSAGE = 'MESSAGE', AT_MESSAGE = '@message', VERSION = 'VERSION';
|
|
31
|
+
|
|
32
|
+
function isWhitespace(code) {
|
|
33
|
+
return code === 32 || code === 9 || code === 10 || code === 13;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Compares a character to a position of a keyword; `VERSION` is case-insensitive
|
|
37
|
+
function matches(keyword, index, code) {
|
|
38
|
+
return keyword === VERSION ? String.fromCharCode(code).toUpperCase() === keyword[index] :
|
|
39
|
+
code === keyword.charCodeAt(index);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Characters that may follow `MESSAGE`: whitespace, a comment, or an IRI
|
|
43
|
+
function endsMessageKeyword(code) {
|
|
44
|
+
return isWhitespace(code) || code === 35 || code === 60;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Characters that may follow `@message`: whitespace, a comment, or the dot
|
|
48
|
+
function endsAtMessageKeyword(code) {
|
|
49
|
+
return isWhitespace(code) || code === 35 || code === 46;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ## Turtle and TriG
|
|
53
|
+
export class TurtleScanner {
|
|
54
|
+
constructor(sentinel) {
|
|
55
|
+
this._sentinel = sentinel;
|
|
56
|
+
this._state = TOP;
|
|
57
|
+
// Whether the next character can start a new token
|
|
58
|
+
this._tokenStart = true;
|
|
59
|
+
// Whether we are inside a bare word (prefixed name, number, keyword)
|
|
60
|
+
this._inWord = false;
|
|
61
|
+
this._wordEscape = false;
|
|
62
|
+
this._quote = 0;
|
|
63
|
+
this._escaped = false;
|
|
64
|
+
this._quoteRun = 0;
|
|
65
|
+
this._depth = 0;
|
|
66
|
+
this._candidate = '';
|
|
67
|
+
this._target = '';
|
|
68
|
+
this._candidateLine = 1;
|
|
69
|
+
this._beforeCandidate = 32;
|
|
70
|
+
this._versionPending = false;
|
|
71
|
+
this._line = 1;
|
|
72
|
+
this._afterCR = false;
|
|
73
|
+
this._started = false;
|
|
74
|
+
this._lastOut = 32;
|
|
75
|
+
// The line of every rewritten delimiter that was not yet consumed
|
|
76
|
+
this.delimiterLines = [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ### `push` scans a chunk of text and returns the text to hand to N3.js
|
|
80
|
+
push(text) {
|
|
81
|
+
let out = '';
|
|
82
|
+
let segmentStart = 0;
|
|
83
|
+
for (let i = 0; i < text.length; i++) {
|
|
84
|
+
const code = text.charCodeAt(i);
|
|
85
|
+
// Keep track of the line number (CRLF counts once, also across chunks)
|
|
86
|
+
if (code === 10) {
|
|
87
|
+
if (!this._afterCR)
|
|
88
|
+
this._line++;
|
|
89
|
+
this._afterCR = false;
|
|
90
|
+
}
|
|
91
|
+
else if (code === 13) {
|
|
92
|
+
this._line++;
|
|
93
|
+
this._afterCR = true;
|
|
94
|
+
}
|
|
95
|
+
else
|
|
96
|
+
this._afterCR = false;
|
|
97
|
+
|
|
98
|
+
// A state change may require the same character to be handled again
|
|
99
|
+
let again;
|
|
100
|
+
do {
|
|
101
|
+
again = false;
|
|
102
|
+
switch (this._state) {
|
|
103
|
+
case TOP:
|
|
104
|
+
if (this._scanTop(code)) {
|
|
105
|
+
// A possible delimiter starts here: hold it back until it is resolved
|
|
106
|
+
out += text.substring(segmentStart, i);
|
|
107
|
+
segmentStart = i + 1;
|
|
108
|
+
this._candidate = text[i];
|
|
109
|
+
this._candidateLine = this._line;
|
|
110
|
+
this._beforeCandidate = i > 0 ? text.charCodeAt(i - 1) : this._lastOut;
|
|
111
|
+
this._target = code === 77 ? MESSAGE : code === 64 ? AT_MESSAGE : VERSION;
|
|
112
|
+
this._state = CANDIDATE;
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
case CANDIDATE: {
|
|
116
|
+
const index = this._candidate.length, target = this._target;
|
|
117
|
+
if (index < target.length && matches(target, index, code)) {
|
|
118
|
+
this._candidate += text[i];
|
|
119
|
+
segmentStart = i + 1;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const complete = index === target.length &&
|
|
123
|
+
(target === AT_MESSAGE ? endsAtMessageKeyword(code) : endsMessageKeyword(code));
|
|
124
|
+
out += this._resolveCandidate(complete);
|
|
125
|
+
segmentStart = i;
|
|
126
|
+
again = true;
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case IRI:
|
|
131
|
+
if (code === 62)
|
|
132
|
+
this._endToken(true);
|
|
133
|
+
break;
|
|
134
|
+
case LT:
|
|
135
|
+
// `<<` starts a reified triple, everything else an IRI
|
|
136
|
+
if (code === 60)
|
|
137
|
+
this._endToken(true);
|
|
138
|
+
else {
|
|
139
|
+
this._state = IRI;
|
|
140
|
+
again = true;
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
case COMMENT:
|
|
144
|
+
if (code === 10 || code === 13)
|
|
145
|
+
this._endToken(true);
|
|
146
|
+
break;
|
|
147
|
+
case STRING:
|
|
148
|
+
if (this._escaped)
|
|
149
|
+
this._escaped = false;
|
|
150
|
+
else if (code === 92)
|
|
151
|
+
this._escaped = true;
|
|
152
|
+
else if (code === this._quote) {
|
|
153
|
+
this._endToken(false);
|
|
154
|
+
if (this._versionPending) {
|
|
155
|
+
// Terminate a SPARQL-style version directive, which we turned into an `@version` directive
|
|
156
|
+
this._versionPending = false;
|
|
157
|
+
out += `${text.substring(segmentStart, i + 1)} .`;
|
|
158
|
+
segmentStart = i + 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case QUOTE1:
|
|
163
|
+
// A second quote either starts a long string or ends an empty string
|
|
164
|
+
if (code === this._quote)
|
|
165
|
+
this._state = QUOTE2;
|
|
166
|
+
else {
|
|
167
|
+
this._state = STRING;
|
|
168
|
+
again = true;
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
case QUOTE2:
|
|
172
|
+
if (code === this._quote) {
|
|
173
|
+
this._state = LONG;
|
|
174
|
+
this._escaped = false;
|
|
175
|
+
this._quoteRun = 0;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
this._endToken(false);
|
|
179
|
+
again = true;
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
default: // LONG
|
|
183
|
+
if (this._escaped) {
|
|
184
|
+
this._escaped = false;
|
|
185
|
+
this._quoteRun = 0;
|
|
186
|
+
}
|
|
187
|
+
else if (code === 92) {
|
|
188
|
+
this._escaped = true;
|
|
189
|
+
this._quoteRun = 0;
|
|
190
|
+
}
|
|
191
|
+
else if (code === this._quote) {
|
|
192
|
+
if (++this._quoteRun === 3)
|
|
193
|
+
this._endToken(false);
|
|
194
|
+
}
|
|
195
|
+
else
|
|
196
|
+
this._quoteRun = 0;
|
|
197
|
+
}
|
|
198
|
+
} while (again);
|
|
199
|
+
}
|
|
200
|
+
out += text.substring(segmentStart);
|
|
201
|
+
return this._remember(out);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ### `end` resolves any held-back text at the end of the input
|
|
205
|
+
end() {
|
|
206
|
+
let out = '';
|
|
207
|
+
if (this._state === CANDIDATE)
|
|
208
|
+
out = this._resolveCandidate(this._candidate.length === this._target.length);
|
|
209
|
+
return this._remember(out);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ### `_scanTop` handles a character between tokens or inside a word.
|
|
213
|
+
// It returns true if the character may start a message delimiter.
|
|
214
|
+
_scanTop(code) {
|
|
215
|
+
// An escape inside a word makes the next character part of the word
|
|
216
|
+
if (this._wordEscape) {
|
|
217
|
+
this._wordEscape = false;
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
switch (code) {
|
|
221
|
+
case 32: case 9: case 10: case 13:
|
|
222
|
+
this._structural();
|
|
223
|
+
break;
|
|
224
|
+
case 0xFEFF:
|
|
225
|
+
// A byte order mark at the very start is not part of any token
|
|
226
|
+
if (this._started)
|
|
227
|
+
this._word();
|
|
228
|
+
break;
|
|
229
|
+
case 35: // #
|
|
230
|
+
this._state = COMMENT;
|
|
231
|
+
this._inWord = false;
|
|
232
|
+
break;
|
|
233
|
+
case 60: // <
|
|
234
|
+
this._state = LT;
|
|
235
|
+
this._inWord = false;
|
|
236
|
+
break;
|
|
237
|
+
case 34: case 39: // " '
|
|
238
|
+
this._state = QUOTE1;
|
|
239
|
+
this._quote = code;
|
|
240
|
+
this._inWord = false;
|
|
241
|
+
break;
|
|
242
|
+
case 123: // {
|
|
243
|
+
this._depth++;
|
|
244
|
+
this._structural();
|
|
245
|
+
break;
|
|
246
|
+
case 125: // }
|
|
247
|
+
this._depth = Math.max(0, this._depth - 1);
|
|
248
|
+
this._structural();
|
|
249
|
+
break;
|
|
250
|
+
case 40: case 41: case 91: case 93: case 44: case 59: // ( ) [ ] , ;
|
|
251
|
+
this._structural();
|
|
252
|
+
break;
|
|
253
|
+
case 46: // . ends a statement, unless it is part of a word
|
|
254
|
+
if (!this._inWord)
|
|
255
|
+
this._structural();
|
|
256
|
+
break;
|
|
257
|
+
case 92: // \
|
|
258
|
+
this._word();
|
|
259
|
+
this._wordEscape = true;
|
|
260
|
+
break;
|
|
261
|
+
case 77: case 64: case 86: case 118: // M @ V v
|
|
262
|
+
if (this._tokenStart) {
|
|
263
|
+
this._started = true;
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
this._word();
|
|
267
|
+
break;
|
|
268
|
+
default:
|
|
269
|
+
this._word();
|
|
270
|
+
}
|
|
271
|
+
this._started = true;
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_structural() {
|
|
276
|
+
this._inWord = false;
|
|
277
|
+
this._tokenStart = true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
_word() {
|
|
281
|
+
this._inWord = true;
|
|
282
|
+
this._tokenStart = false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ### `_endToken` returns to the top level after an IRI, string or comment
|
|
286
|
+
_endToken(tokenStart) {
|
|
287
|
+
this._state = TOP;
|
|
288
|
+
this._inWord = false;
|
|
289
|
+
this._tokenStart = tokenStart;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ### `_resolveCandidate` decides whether a held-back candidate is a delimiter
|
|
293
|
+
// and returns the text to emit for it
|
|
294
|
+
_resolveCandidate(complete) {
|
|
295
|
+
const candidate = this._candidate;
|
|
296
|
+
this._state = TOP;
|
|
297
|
+
this._candidate = '';
|
|
298
|
+
// A delimiter in a graph block is not rewritten, so that N3.js rejects it
|
|
299
|
+
if (!complete || (this._depth > 0 && this._target !== VERSION)) {
|
|
300
|
+
this._word();
|
|
301
|
+
return candidate;
|
|
302
|
+
}
|
|
303
|
+
this._structural();
|
|
304
|
+
const previous = this._beforeCandidate;
|
|
305
|
+
const space = isWhitespace(previous) || previous === 0xFEFF ? '' : ' ';
|
|
306
|
+
if (this._target === VERSION) {
|
|
307
|
+
this._versionPending = true;
|
|
308
|
+
return `${space}@version`;
|
|
309
|
+
}
|
|
310
|
+
this.delimiterLines.push(this._candidateLine);
|
|
311
|
+
return `${space}@version "${this._sentinel}"${candidate === MESSAGE ? ' .' : ''}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_remember(out) {
|
|
315
|
+
if (out.length > 0)
|
|
316
|
+
this._lastOut = out.charCodeAt(out.length - 1);
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ## N-Triples and N-Quads
|
|
322
|
+
// Lines of the form `MESSAGE` or `VERSION "label"` are the only directives.
|
|
323
|
+
// They cannot be confused with strings or IRIs, as those never span lines.
|
|
324
|
+
export class LineScanner {
|
|
325
|
+
constructor(sentinel) {
|
|
326
|
+
this._sentinel = sentinel;
|
|
327
|
+
this._buffer = '';
|
|
328
|
+
this._line = 1;
|
|
329
|
+
this._messageLine = new RegExp(`^[ \\t]*${MESSAGE}[ \\t]*(?:#.*)?$`);
|
|
330
|
+
this._versionLine = /^[ \t]*VERSION[ \t]*(?:"([^"\\]*)"|'([^'\\]*)')[ \t]*(?:#.*)?$/i;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ### `push` scans a chunk of text and returns the complete lines, rewritten
|
|
334
|
+
push(text) {
|
|
335
|
+
const data = this._buffer + text;
|
|
336
|
+
// A final carriage return waits for a possible line feed, so that CRLF counts as one line break
|
|
337
|
+
const limit = data.endsWith('\r') ? data.length - 1 : data.length;
|
|
338
|
+
const lastBreak = Math.max(data.lastIndexOf('\n', limit - 1), data.lastIndexOf('\r', limit - 1));
|
|
339
|
+
if (lastBreak < 0) {
|
|
340
|
+
this._buffer = data;
|
|
341
|
+
return '';
|
|
342
|
+
}
|
|
343
|
+
this._buffer = data.substring(lastBreak + 1);
|
|
344
|
+
return this._rewrite(data.substring(0, lastBreak + 1));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ### `end` returns the last line, which has no line break
|
|
348
|
+
end() {
|
|
349
|
+
const rest = this._buffer;
|
|
350
|
+
this._buffer = '';
|
|
351
|
+
return rest.length > 0 ? this._rewrite(rest) : '';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
_rewrite(block) {
|
|
355
|
+
let out = '';
|
|
356
|
+
let copyFrom = 0, lineStart = 0;
|
|
357
|
+
const breaks = /\r\n|\n|\r/g;
|
|
358
|
+
let match;
|
|
359
|
+
do {
|
|
360
|
+
match = breaks.exec(block);
|
|
361
|
+
const lineEnd = match ? match.index : block.length;
|
|
362
|
+
const replacement = this._rewriteLine(block, lineStart, lineEnd);
|
|
363
|
+
if (replacement !== null) {
|
|
364
|
+
out += block.substring(copyFrom, lineStart) + replacement;
|
|
365
|
+
copyFrom = lineEnd;
|
|
366
|
+
}
|
|
367
|
+
lineStart = match ? breaks.lastIndex : block.length + 1;
|
|
368
|
+
if (match)
|
|
369
|
+
this._line++;
|
|
370
|
+
} while (lineStart <= block.length);
|
|
371
|
+
return out + block.substring(copyFrom);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Only lines that start with `MESSAGE` or `VERSION` need a closer look
|
|
375
|
+
_rewriteLine(block, start, end) {
|
|
376
|
+
let first = start;
|
|
377
|
+
while (first < end && (block[first] === ' ' || block[first] === '\t'))
|
|
378
|
+
first++;
|
|
379
|
+
const code = block.charCodeAt(first);
|
|
380
|
+
if (first === end || (code !== 77 && code !== 86 && code !== 118))
|
|
381
|
+
return null;
|
|
382
|
+
const line = block.substring(first, end);
|
|
383
|
+
if (this._messageLine.test(line))
|
|
384
|
+
return `#${this._sentinel}M${this._line}`;
|
|
385
|
+
const version = this._versionLine.exec(line);
|
|
386
|
+
if (version)
|
|
387
|
+
return `#${this._sentinel}V${version[1] === undefined ? version[2] : version[1]}`;
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// **MessageStreamParser** is a Node.js stream that turns text into RDF Messages.
|
|
2
|
+
//
|
|
3
|
+
// It consumes strings or buffers, and produces one array of quads per message,
|
|
4
|
+
// as soon as that message is complete.
|
|
5
|
+
import { Transform } from 'stream';
|
|
6
|
+
import { StringDecoder } from 'string_decoder';
|
|
7
|
+
import MessageParserCore from './MessageParserCore.js';
|
|
8
|
+
|
|
9
|
+
export default class MessageStreamParser extends Transform {
|
|
10
|
+
constructor(options) {
|
|
11
|
+
super({ decodeStrings: false, readableObjectMode: true });
|
|
12
|
+
this._decoder = new StringDecoder('utf8');
|
|
13
|
+
this._error = null;
|
|
14
|
+
this._core = new MessageParserCore(options, {
|
|
15
|
+
message: quads => this.push(quads),
|
|
16
|
+
end: () => {},
|
|
17
|
+
error: error => { this._error = this._error || error; },
|
|
18
|
+
prefix: (prefix, term) => this.emit('prefix', prefix, term),
|
|
19
|
+
version: label => this.emit('version', label),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_transform(chunk, encoding, done) {
|
|
24
|
+
this._core.write(typeof chunk === 'string' ? chunk : this._decoder.write(chunk));
|
|
25
|
+
done(this._error);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_flush(done) {
|
|
29
|
+
this._core.write(this._decoder.end());
|
|
30
|
+
this._core.end();
|
|
31
|
+
done(this._error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// **MessageStreamWriter** is a Node.js stream that serializes RDF Messages.
|
|
2
|
+
//
|
|
3
|
+
// It consumes messages (arrays of quads) and produces text.
|
|
4
|
+
import { Transform } from 'stream';
|
|
5
|
+
import MessageWriter from './MessageWriter.js';
|
|
6
|
+
|
|
7
|
+
export default class MessageStreamWriter extends Transform {
|
|
8
|
+
constructor(options) {
|
|
9
|
+
super({ writableObjectMode: true, encoding: 'utf8' });
|
|
10
|
+
// Writing into this stream never blocks, and output is pushed synchronously
|
|
11
|
+
const output = { write: (chunk, encoding, done) => (this.push(chunk), done && done()) };
|
|
12
|
+
this._writer = new MessageWriter(output, Object.assign({}, options, { end: false }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
_transform(message, encoding, done) {
|
|
16
|
+
this._writer.addMessage(message, done);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_flush(done) {
|
|
20
|
+
this._writer.end(done);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// **MessageWriter** writes RDF Message Logs (Turtle, TriG, N-Triples and N-Quads).
|
|
2
|
+
//
|
|
3
|
+
// Every message is written by a fresh N3.js writer, so no serialization state
|
|
4
|
+
// (current graph, subject, predicate) leaks from one message into the next.
|
|
5
|
+
// Every message is followed by a delimiter (`MESSAGE` in line formats, `@message .` in Turtle and TriG), so that consumers receive
|
|
6
|
+
// a message as soon as it is written, and so that trailing empty messages survive
|
|
7
|
+
// a round trip (a delimiter before the end of the input does not create a message).
|
|
8
|
+
import { Writer } from 'n3';
|
|
9
|
+
import { resolveFormat, isMessagesVersion, baseVersion, isSupportedVersion } from './formats.js';
|
|
10
|
+
|
|
11
|
+
export default class MessageWriter {
|
|
12
|
+
// `outputStream` is optional: without it, `end` returns the output as a string
|
|
13
|
+
constructor(outputStream, options) {
|
|
14
|
+
if (outputStream && typeof outputStream.write !== 'function')
|
|
15
|
+
options = outputStream, outputStream = null;
|
|
16
|
+
options = options || {};
|
|
17
|
+
this._format = resolveFormat(options.format);
|
|
18
|
+
this._options = options;
|
|
19
|
+
this._version = options.version === undefined ? '1.2-messages' : options.version;
|
|
20
|
+
if (!isMessagesVersion(this._version) || !isSupportedVersion(baseVersion(this._version)))
|
|
21
|
+
throw new TypeError(`Unsupported RDF Messages version: "${this._version}"`);
|
|
22
|
+
|
|
23
|
+
this._output = '';
|
|
24
|
+
this._stream = outputStream;
|
|
25
|
+
this._endStream = !outputStream || options.end !== false;
|
|
26
|
+
this._pending = 0;
|
|
27
|
+
this._waiting = [];
|
|
28
|
+
this._error = null;
|
|
29
|
+
this._muted = false;
|
|
30
|
+
this._started = false;
|
|
31
|
+
this._ended = false;
|
|
32
|
+
this._declared = Object.assign({}, options.prefixes);
|
|
33
|
+
this._newPrefixes = {};
|
|
34
|
+
// The N3.js writers write to this sink, which they never end
|
|
35
|
+
this._sink = { write: (chunk, encoding, done) => this._write(chunk, done) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ### `addMessage` writes the quads of one message, followed by a delimiter.
|
|
39
|
+
// The callback (or returned promise) completes once the output is flushed.
|
|
40
|
+
addMessage(quads, done) {
|
|
41
|
+
return this._complete(done, () => {
|
|
42
|
+
this._start();
|
|
43
|
+
this._writeMessage(quads || []);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ### `addPrefix` declares a prefix, which is written before the next message
|
|
48
|
+
// and stays in effect for all messages after it
|
|
49
|
+
addPrefix(prefix, iri, done) {
|
|
50
|
+
return this._complete(done, () => {
|
|
51
|
+
if (!this._format.lineMode && this._declared[prefix] !== (iri.value || iri))
|
|
52
|
+
this._newPrefixes[prefix] = iri.value || iri;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ### `end` ends the log and, unless disabled, the output stream.
|
|
57
|
+
// The callback receives the output as a string if no output stream was given.
|
|
58
|
+
end(done) {
|
|
59
|
+
// Ending twice is harmless
|
|
60
|
+
if (this._ended)
|
|
61
|
+
return typeof done === 'function' ? done(null) : Promise.resolve();
|
|
62
|
+
this._start();
|
|
63
|
+
if (typeof done === 'function')
|
|
64
|
+
return this._finish(done);
|
|
65
|
+
return new Promise((resolve, reject) => this._finish((error, result) => error ? reject(error) : resolve(result)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ## Private methods
|
|
69
|
+
|
|
70
|
+
// Runs an action and reports completion with a callback or a promise
|
|
71
|
+
_complete(done, action) {
|
|
72
|
+
if (this._ended)
|
|
73
|
+
this._error = this._error || new Error('Cannot write after end');
|
|
74
|
+
else
|
|
75
|
+
try {
|
|
76
|
+
action();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
this._error = this._error || error;
|
|
80
|
+
}
|
|
81
|
+
if (typeof done === 'function')
|
|
82
|
+
return this._whenFlushed(done);
|
|
83
|
+
return new Promise((resolve, reject) => this._whenFlushed(error => error ? reject(error) : resolve()));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Ends the output once all queued writes were accepted
|
|
87
|
+
_finish(callback) {
|
|
88
|
+
this._ended = true;
|
|
89
|
+
this._whenFlushed(error => this._endOutput(error, callback));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_endOutput(error, callback) {
|
|
93
|
+
if (error || !this._endStream)
|
|
94
|
+
return callback(error);
|
|
95
|
+
if (!this._stream)
|
|
96
|
+
return callback(null, this._output);
|
|
97
|
+
try {
|
|
98
|
+
this._stream.end(endError => callback(endError));
|
|
99
|
+
}
|
|
100
|
+
catch (endError) {
|
|
101
|
+
callback(endError);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Calls back once all queued writes were accepted by the output
|
|
106
|
+
_whenFlushed(done) {
|
|
107
|
+
if (this._pending === 0)
|
|
108
|
+
this._settle(done);
|
|
109
|
+
else
|
|
110
|
+
this._waiting.push(done);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_settle(done) {
|
|
114
|
+
const error = this._error;
|
|
115
|
+
this._error = null;
|
|
116
|
+
done(error || null);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_write(chunk, done) {
|
|
120
|
+
if (this._muted || !this._stream) {
|
|
121
|
+
if (!this._muted)
|
|
122
|
+
this._output += chunk;
|
|
123
|
+
return done && done();
|
|
124
|
+
}
|
|
125
|
+
this._pending++;
|
|
126
|
+
try {
|
|
127
|
+
this._stream.write(chunk, 'utf8', error => this._written(error, done));
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
this._written(error, done);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Registers that the output accepted a write, and wakes up those waiting for the output
|
|
135
|
+
_written(error, done) {
|
|
136
|
+
this._pending--;
|
|
137
|
+
this._error = this._error || error || null;
|
|
138
|
+
done && done(error);
|
|
139
|
+
if (this._pending === 0)
|
|
140
|
+
this._waiting.splice(0).forEach(callback => this._settle(callback));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Announces the RDF Messages version, which makes parsers expect delimiters
|
|
144
|
+
_start() {
|
|
145
|
+
if (!this._started) {
|
|
146
|
+
this._started = true;
|
|
147
|
+
this._write(this._format.lineMode ? `VERSION "${this._version}"\n` : `@version "${this._version}" .\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_writeMessage(quads) {
|
|
152
|
+
const first = !this._messageWritten;
|
|
153
|
+
const options = { format: this._format.name, end: false };
|
|
154
|
+
if (this._options.baseIRI) {
|
|
155
|
+
options.baseIRI = this._options.baseIRI;
|
|
156
|
+
// The base is declared by us, once, as it stays in effect (and older N3.js writers lack `writeBase`)
|
|
157
|
+
if (first && this._options.writeBase && !this._format.lineMode)
|
|
158
|
+
this._write(`@base <${this._options.baseIRI}>.\n`);
|
|
159
|
+
}
|
|
160
|
+
// Prefixes and base are declared once, as they stay in effect
|
|
161
|
+
options.prefixes = this._declared;
|
|
162
|
+
this._muted = !first;
|
|
163
|
+
const writer = new Writer(this._sink, options);
|
|
164
|
+
this._muted = false;
|
|
165
|
+
this._messageWritten = true;
|
|
166
|
+
for (const prefix of Object.keys(this._newPrefixes)) {
|
|
167
|
+
writer.addPrefix(prefix, this._newPrefixes[prefix]);
|
|
168
|
+
this._declared[prefix] = this._newPrefixes[prefix];
|
|
169
|
+
}
|
|
170
|
+
this._newPrefixes = {};
|
|
171
|
+
const record = error => error && (this._error = this._error || error);
|
|
172
|
+
for (const quad of quads)
|
|
173
|
+
writer.addQuad(quad.subject, quad.predicate, quad.object, quad.graph, record);
|
|
174
|
+
writer.end(record);
|
|
175
|
+
this._write(this._format.lineMode ? 'MESSAGE\n' : '@message .\n');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ### `writeMessages` synchronously serializes an array of messages to a string
|
|
180
|
+
export function writeMessages(messages, options) {
|
|
181
|
+
const writer = new MessageWriter(options);
|
|
182
|
+
const errors = [];
|
|
183
|
+
let output;
|
|
184
|
+
function collect(error) {
|
|
185
|
+
if (error)
|
|
186
|
+
errors.push(error);
|
|
187
|
+
}
|
|
188
|
+
for (const message of messages)
|
|
189
|
+
writer.addMessage(message, collect);
|
|
190
|
+
writer.end((error, result) => (collect(error), output = result));
|
|
191
|
+
if (errors.length > 0)
|
|
192
|
+
throw errors[0];
|
|
193
|
+
return output;
|
|
194
|
+
}
|
package/src/formats.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// **formats** describes the RDF syntaxes that support RDF Messages.
|
|
2
|
+
|
|
3
|
+
const FORMATS = {
|
|
4
|
+
turtle: { name: 'Turtle', lineMode: false },
|
|
5
|
+
trig: { name: 'TriG', lineMode: false },
|
|
6
|
+
ntriples: { name: 'N-Triples', lineMode: true },
|
|
7
|
+
nquads: { name: 'N-Quads', lineMode: true },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// The version labels of RDF 1.2 syntaxes, as accepted by N3.js
|
|
11
|
+
const BASE_VERSIONS = ['1.2', '1.2-basic', '1.1'];
|
|
12
|
+
|
|
13
|
+
// ### `resolveFormat` maps a format name or media type to a supported syntax
|
|
14
|
+
export function resolveFormat(format = 'TriG') {
|
|
15
|
+
const key = String(format).toLowerCase().split(';')[0].trim()
|
|
16
|
+
.replace(/^(?:text|application)\//, '')
|
|
17
|
+
.replace(/^x-/, '')
|
|
18
|
+
.replace(/[-_ ]/g, '');
|
|
19
|
+
const resolved = FORMATS[key];
|
|
20
|
+
if (!resolved)
|
|
21
|
+
throw new TypeError(`Unsupported RDF Messages format: "${format}". ` +
|
|
22
|
+
'Supported formats are Turtle, TriG, N-Triples and N-Quads.');
|
|
23
|
+
return resolved;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ### `isMessagesVersion` checks whether a version label announces RDF Messages
|
|
27
|
+
export function isMessagesVersion(version) {
|
|
28
|
+
return typeof version === 'string' && version.endsWith('-messages');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ### `baseVersion` strips the `-messages` suffix of a version label
|
|
32
|
+
export function baseVersion(version) {
|
|
33
|
+
return isMessagesVersion(version) ? version.slice(0, -'-messages'.length) : version;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ### `isSupportedVersion` checks whether a (base) version label is supported
|
|
37
|
+
export function isSupportedVersion(version, supported = BASE_VERSIONS) {
|
|
38
|
+
return supported.includes(version);
|
|
39
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { default as MessageParser, parseMessages } from './MessageParser.js';
|
|
2
|
+
export { default as MessageStreamParser } from './MessageStreamParser.js';
|
|
3
|
+
export { default as MessageWriter, writeMessages } from './MessageWriter.js';
|
|
4
|
+
export { default as MessageStreamWriter } from './MessageStreamWriter.js';
|