@pieceful/ravel-asciidoc 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Taylor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # `@pieceful/ravel-asciidoc`
2
+
3
+ Lossless AsciiDoc source adapter for [Ravel](https://github.com/jostylr/ravel). It recognizes section-owned pieces,
4
+ attributed source blocks and containers, native cross references, exact code
5
+ bodies, definition pipelines, live metadata, and visible `ravel::` directive
6
+ macros without invoking Asciidoctor or executing code.
7
+
8
+ ```js
9
+ import { asciidocToMap } from "@pieceful/ravel-asciidoc";
10
+
11
+ const { map, diagnostics, surface } = asciidocToMap(source, {
12
+ uri: "program.adoc",
13
+ document: "program",
14
+ });
15
+ ```
16
+
17
+ Section form:
18
+
19
+ ```asciidoc
20
+ [#lp-main]
21
+ == Main program | trim()
22
+
23
+ [source,javascript]
24
+ ----
25
+ console.log(_"helper");
26
+ ----
27
+ ```
28
+
29
+ Self-contained block form:
30
+
31
+ ```asciidoc
32
+ .Main program
33
+ [source#lp-main,javascript,role=lp-piece,lp-id=main,lp-pipe="trim()"]
34
+ ----
35
+ console.log(_"helper");
36
+ ----
37
+ ```
38
+
39
+ Graph directives remain conspicuous block macros:
40
+
41
+ ```asciidoc
42
+ ravel::write[target=dist/main.js,from=main]
43
+ ravel::read[target=shared.adoc,as=shared]
44
+ ```
45
+
46
+ The adapter is native ESM and portable across Node, Bun, and browsers.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@pieceful/ravel-asciidoc",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Lossless AsciiDoc section, block, cross-reference, and directive adapter for Ravel.",
6
+ "license": "MIT",
7
+ "author": "James Taylor",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jostylr/ravel.git",
11
+ "directory": "packages/asciidoc"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/jostylr/ravel/issues"
15
+ },
16
+ "homepage": "https://ravel.jostylr.com/",
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "keywords": [
21
+ "ravel",
22
+ "asciidoc",
23
+ "asciidoctor",
24
+ "literate-programming"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "exports": {
30
+ ".": "./src/index.js"
31
+ },
32
+ "types": "./src/index.d.ts",
33
+ "files": [
34
+ "src",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "dependencies": {
39
+ "@pieceful/ravel-core": "0.2.0"
40
+ }
41
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ export interface SourcePosition {
2
+ line: number;
3
+ column: number;
4
+ offset: number;
5
+ }
6
+
7
+ export interface SourceLocation {
8
+ uri: string;
9
+ range: { start: SourcePosition; end: SourcePosition };
10
+ }
11
+
12
+ export interface AsciiDocAdapterOptions {
13
+ uri?: string;
14
+ document?: string;
15
+ run?: boolean | string[];
16
+ provider?: string;
17
+ }
18
+
19
+ export interface AsciiDocAdapterResult {
20
+ map: Record<string, unknown>;
21
+ diagnostics: Array<{
22
+ code: string;
23
+ severity: "error" | "warning" | "info";
24
+ message: string;
25
+ source: SourceLocation;
26
+ }>;
27
+ surface: {
28
+ definitions: unknown[];
29
+ references: unknown[];
30
+ directives: unknown[];
31
+ navigation: unknown[];
32
+ includes: unknown[];
33
+ };
34
+ }
35
+
36
+ export function asciidocToMap(
37
+ text: string,
38
+ options?: AsciiDocAdapterOptions
39
+ ): AsciiDocAdapterResult;
package/src/index.js ADDED
@@ -0,0 +1,1033 @@
1
+ import { parseDefinitionPipeline } from "@pieceful/ravel-core";
2
+
3
+ const componentPattern = /^[a-z][a-z0-9-]*$/;
4
+ const delimiters = new Set(["----", "....", "===="]);
5
+
6
+ const diagnostic = (code, message, source, severity = "error") => ({
7
+ code,
8
+ severity,
9
+ message,
10
+ source
11
+ });
12
+
13
+ const lineStarts = (text) => {
14
+ const starts = [0];
15
+ for (let index = 0; index < text.length; index += 1) {
16
+ if (text[index] === "\n") starts.push(index + 1);
17
+ }
18
+ return starts;
19
+ };
20
+
21
+ const positionAt = (starts, offset) => {
22
+ let low = 0;
23
+ let high = starts.length;
24
+ while (low + 1 < high) {
25
+ const middle = Math.floor((low + high) / 2);
26
+ if (starts[middle] <= offset) low = middle;
27
+ else high = middle;
28
+ }
29
+ return { line: low, column: offset - starts[low], offset };
30
+ };
31
+
32
+ const rangeAt = (uri, starts, start, end) => ({
33
+ uri,
34
+ range: {
35
+ start: positionAt(starts, start),
36
+ end: positionAt(starts, end)
37
+ }
38
+ });
39
+
40
+ const advanceRange = (source, text, start, end) => {
41
+ const starts = lineStarts(text);
42
+ const base = source.range.start;
43
+ const position = (offset) => {
44
+ const local = positionAt(starts, offset);
45
+ return {
46
+ line: base.line + local.line,
47
+ column: local.line === 0 ? base.column + local.column : local.column,
48
+ offset: base.offset + offset
49
+ };
50
+ };
51
+ return {
52
+ uri: source.uri,
53
+ range: { start: position(start), end: position(end) }
54
+ };
55
+ };
56
+
57
+ const linesOf = (text) => {
58
+ const lines = [];
59
+ let start = 0;
60
+ while (start < text.length) {
61
+ let end = text.indexOf("\n", start);
62
+ if (end === -1) end = text.length;
63
+ else end += 1;
64
+ const contentEnd = end > start && text[end - 1] === "\n"
65
+ ? end - (end > start + 1 && text[end - 2] === "\r" ? 2 : 1)
66
+ : end;
67
+ lines.push({
68
+ start,
69
+ end,
70
+ contentEnd,
71
+ value: text.slice(start, contentEnd)
72
+ });
73
+ start = end;
74
+ }
75
+ return lines;
76
+ };
77
+
78
+ const defaultDocumentId = (uri) => {
79
+ const base = uri.split(/[\\/]/).at(-1)
80
+ ?.replace(/\.(?:adoc|asciidoc)$/i, "") ?? "";
81
+ const id = base.toLowerCase()
82
+ .normalize("NFKD")
83
+ .replace(/[\u0300-\u036f]/g, "")
84
+ .replace(/[^a-z0-9]+/g, "-")
85
+ .replace(/^-|-$/g, "");
86
+ return componentPattern.test(id) ? id : null;
87
+ };
88
+
89
+ const semanticComponent = (value) => {
90
+ const normalized = String(value ?? "").trim().toLowerCase()
91
+ .normalize("NFKD")
92
+ .replace(/[\u0300-\u036f]/g, "")
93
+ .replace(/[^a-z0-9]+/g, "-")
94
+ .replace(/^-|-$/g, "");
95
+ return componentPattern.test(normalized) ? normalized : null;
96
+ };
97
+
98
+ const firstUnescapedPipe = (value) => {
99
+ let escaped = false;
100
+ for (let index = 0; index < value.length; index += 1) {
101
+ if (escaped) escaped = false;
102
+ else if (value[index] === "\\") escaped = true;
103
+ else if (value[index] === "|") return index;
104
+ }
105
+ return -1;
106
+ };
107
+
108
+ const splitNamePipeline = (value) => {
109
+ const pipeIndex = firstUnescapedPipe(value);
110
+ return {
111
+ name: (pipeIndex === -1 ? value : value.slice(0, pipeIndex))
112
+ .trim()
113
+ .replace(/\\\|/g, "|"),
114
+ pipe: pipeIndex === -1 ? null : value.slice(pipeIndex + 1).trim(),
115
+ pipeIndex
116
+ };
117
+ };
118
+
119
+ const unquote = (value) => {
120
+ const trimmed = value.trim();
121
+ if (trimmed.length >= 2 &&
122
+ ((trimmed[0] === "\"" && trimmed.at(-1) === "\"") ||
123
+ (trimmed[0] === "'" && trimmed.at(-1) === "'"))) {
124
+ return trimmed.slice(1, -1)
125
+ .replace(/\\(["'\\])/g, "$1");
126
+ }
127
+ return trimmed;
128
+ };
129
+
130
+ const splitAttributes = (value) => {
131
+ const entries = [];
132
+ let start = 0;
133
+ let quote = "";
134
+ let escaped = false;
135
+ for (let index = 0; index <= value.length; index += 1) {
136
+ const character = value[index];
137
+ if (index === value.length || (character === "," && !quote)) {
138
+ entries.push({
139
+ raw: value.slice(start, index),
140
+ start,
141
+ end: index
142
+ });
143
+ start = index + 1;
144
+ continue;
145
+ }
146
+ if (quote) {
147
+ if (escaped) escaped = false;
148
+ else if (character === "\\") escaped = true;
149
+ else if (character === quote) quote = "";
150
+ } else if (character === "\"" || character === "'") {
151
+ quote = character;
152
+ }
153
+ }
154
+ return entries;
155
+ };
156
+
157
+ const equalsOutsideQuotes = (value) => {
158
+ let quote = "";
159
+ let escaped = false;
160
+ for (let index = 0; index < value.length; index += 1) {
161
+ const character = value[index];
162
+ if (quote) {
163
+ if (escaped) escaped = false;
164
+ else if (character === "\\") escaped = true;
165
+ else if (character === quote) quote = "";
166
+ } else if (character === "\"" || character === "'") {
167
+ quote = character;
168
+ } else if (character === "=") {
169
+ return index;
170
+ }
171
+ }
172
+ return -1;
173
+ };
174
+
175
+ const shorthandFrom = (value) => {
176
+ const match = /^([^#.]*)(.*)$/.exec(value.trim());
177
+ const style = match?.[1] || null;
178
+ const suffix = match?.[2] ?? "";
179
+ const id = /#([A-Za-z][A-Za-z0-9_.:-]*)/.exec(suffix)?.[1] ?? null;
180
+ const roles = [...suffix.matchAll(/\.([A-Za-z][A-Za-z0-9_-]*)/g)]
181
+ .map((entry) => entry[1]);
182
+ return { style, id, roles };
183
+ };
184
+
185
+ const emptyMetadata = () => ({
186
+ title: null,
187
+ titleSource: null,
188
+ id: null,
189
+ idSource: null,
190
+ style: null,
191
+ positional: [],
192
+ roles: [],
193
+ attributes: {},
194
+ attributeSources: {},
195
+ lines: []
196
+ });
197
+
198
+ const parseAttributeLine = (line, uri, starts, metadata) => {
199
+ const content = line.value.slice(1, -1);
200
+ const entries = splitAttributes(content);
201
+ for (const [index, entry] of entries.entries()) {
202
+ const leading = entry.raw.search(/\S|$/);
203
+ const trailing = entry.raw.length - entry.raw.trimEnd().length;
204
+ const raw = entry.raw.slice(leading, entry.raw.length - trailing);
205
+ if (!raw) continue;
206
+ const equals = equalsOutsideQuotes(raw);
207
+ if (equals !== -1) {
208
+ const key = raw.slice(0, equals).trim().toLowerCase();
209
+ const rawValue = raw.slice(equals + 1).trim();
210
+ const value = unquote(rawValue);
211
+ const rawValueStart = raw.indexOf(rawValue, equals + 1);
212
+ const quoteOffset = rawValue.length >= 2 &&
213
+ ((rawValue[0] === "\"" && rawValue.at(-1) === "\"") ||
214
+ (rawValue[0] === "'" && rawValue.at(-1) === "'"))
215
+ ? 1
216
+ : 0;
217
+ const start = line.start + 1 + entry.start + leading +
218
+ rawValueStart + quoteOffset;
219
+ metadata.attributes[key] = value;
220
+ metadata.attributeSources[key] = rangeAt(
221
+ uri,
222
+ starts,
223
+ start,
224
+ start + value.length
225
+ );
226
+ continue;
227
+ }
228
+ metadata.positional.push(raw);
229
+ if (index === 0) {
230
+ const shorthand = shorthandFrom(raw);
231
+ metadata.style = shorthand.style;
232
+ if (shorthand.id) {
233
+ metadata.id = shorthand.id;
234
+ metadata.idSource = rangeAt(uri, starts, line.start, line.end);
235
+ }
236
+ metadata.roles.push(...shorthand.roles);
237
+ }
238
+ }
239
+ const namedId = metadata.attributes.id;
240
+ if (namedId) {
241
+ metadata.id = namedId;
242
+ metadata.idSource = metadata.attributeSources.id;
243
+ }
244
+ const namedRoles = metadata.attributes.role;
245
+ if (namedRoles) {
246
+ metadata.roles.push(...namedRoles.split(/\s+/).filter(Boolean));
247
+ }
248
+ };
249
+
250
+ const metadataBefore = (lines, index, uri, starts) => {
251
+ const selected = [];
252
+ let cursor = index - 1;
253
+ while (cursor >= 0) {
254
+ const value = lines[cursor].value;
255
+ if (/^\[[^\]]*\]$/.test(value) ||
256
+ /^\[\[[^\]]+\]\]$/.test(value) ||
257
+ /^\.[^. \t].*$/.test(value)) {
258
+ selected.unshift(lines[cursor]);
259
+ cursor -= 1;
260
+ continue;
261
+ }
262
+ break;
263
+ }
264
+ const metadata = emptyMetadata();
265
+ metadata.lines = selected;
266
+ for (const line of selected) {
267
+ const anchor = /^\[\[([^,\]]+)(?:,[^\]]*)?\]\]$/.exec(line.value);
268
+ if (anchor) {
269
+ metadata.id = anchor[1];
270
+ const start = line.start + line.value.indexOf(anchor[1]);
271
+ metadata.idSource = rangeAt(
272
+ uri,
273
+ starts,
274
+ start,
275
+ start + anchor[1].length
276
+ );
277
+ continue;
278
+ }
279
+ if (/^\[[^\]]*\]$/.test(line.value)) {
280
+ parseAttributeLine(line, uri, starts, metadata);
281
+ continue;
282
+ }
283
+ if (line.value.startsWith(".")) {
284
+ metadata.title = line.value.slice(1);
285
+ metadata.titleSource = rangeAt(
286
+ uri,
287
+ starts,
288
+ line.start + 1,
289
+ line.contentEnd
290
+ );
291
+ }
292
+ }
293
+ metadata.roles = [...new Set(metadata.roles)];
294
+ metadata.declarationStart = selected[0]?.start ?? lines[index]?.start ?? 0;
295
+ return metadata;
296
+ };
297
+
298
+ const hasPieceRole = (metadata) =>
299
+ metadata.roles.includes("lp-piece") ||
300
+ metadata.roles.includes("ravel-piece");
301
+
302
+ const isPieceMetadata = (metadata) =>
303
+ hasPieceRole(metadata) ||
304
+ typeof metadata.attributes["lp-id"] === "string" ||
305
+ typeof metadata.attributes["ravel-id"] === "string" ||
306
+ metadata.id?.startsWith("lp-");
307
+
308
+ const blockLanguage = (metadata) =>
309
+ metadata.attributes.language ||
310
+ metadata.attributes.lang ||
311
+ (metadata.style === "source" ? metadata.positional[1] : null) ||
312
+ null;
313
+
314
+ const scanDelimitedBlocks = (text, lines, uri, starts, diagnostics) => {
315
+ const blocks = [];
316
+ const stack = [];
317
+ for (let index = 0; index < lines.length; index += 1) {
318
+ const marker = lines[index].value;
319
+ const top = stack.at(-1);
320
+ if (top && (top.marker === "----" || top.marker === "....")) {
321
+ if (marker !== top.marker) continue;
322
+ top.closeIndex = index;
323
+ top.close = rangeAt(uri, starts, lines[index].start, lines[index].end);
324
+ top.body = text.slice(lines[top.openIndex].end, lines[index].start);
325
+ top.bodySource = rangeAt(
326
+ uri,
327
+ starts,
328
+ lines[top.openIndex].end,
329
+ lines[index].start
330
+ );
331
+ stack.pop();
332
+ continue;
333
+ }
334
+ if (!delimiters.has(marker)) continue;
335
+ if (top?.marker === marker) {
336
+ top.closeIndex = index;
337
+ top.close = rangeAt(uri, starts, lines[index].start, lines[index].end);
338
+ top.body = text.slice(lines[top.openIndex].end, lines[index].start);
339
+ top.bodySource = rangeAt(
340
+ uri,
341
+ starts,
342
+ lines[top.openIndex].end,
343
+ lines[index].start
344
+ );
345
+ stack.pop();
346
+ continue;
347
+ }
348
+ const metadata = metadataBefore(lines, index, uri, starts);
349
+ const block = {
350
+ marker,
351
+ openIndex: index,
352
+ closeIndex: null,
353
+ parent: top ?? null,
354
+ metadata,
355
+ declaration: rangeAt(
356
+ uri,
357
+ starts,
358
+ metadata.declarationStart,
359
+ lines[index].end
360
+ ),
361
+ open: rangeAt(uri, starts, lines[index].start, lines[index].end),
362
+ close: null,
363
+ body: "",
364
+ bodySource: rangeAt(
365
+ uri,
366
+ starts,
367
+ lines[index].end,
368
+ lines[index].end
369
+ )
370
+ };
371
+ blocks.push(block);
372
+ stack.push(block);
373
+ }
374
+ for (const block of stack) {
375
+ block.body = text.slice(lines[block.openIndex].end);
376
+ block.bodySource = rangeAt(
377
+ uri,
378
+ starts,
379
+ lines[block.openIndex].end,
380
+ text.length
381
+ );
382
+ diagnostics.push(diagnostic(
383
+ "LPA111",
384
+ "AsciiDoc delimited block is missing its closing " + block.marker + ".",
385
+ block.open
386
+ ));
387
+ }
388
+ return blocks;
389
+ };
390
+
391
+ const pipelineKey = (pipeline) =>
392
+ JSON.stringify(pipeline.map(({ name, arguments: args }) => [name, args ?? []]));
393
+
394
+ const runSelected = (options, names) =>
395
+ options.run === true ||
396
+ (Array.isArray(options.run) &&
397
+ names.some((name) => options.run.includes(name)));
398
+
399
+ const truthyAttribute = (value) =>
400
+ value === "" || /^(?:1|yes|true|on)$/i.test(value ?? "");
401
+
402
+ const candidateFrom = ({
403
+ form,
404
+ authoredName,
405
+ displayName,
406
+ label,
407
+ metadata,
408
+ declaration,
409
+ pipelineText,
410
+ pipelineSource,
411
+ order
412
+ }, options, diagnostics) => {
413
+ const explicit = metadata.attributes["ravel-id"] ??
414
+ metadata.attributes["lp-id"] ??
415
+ (label?.startsWith("lp-") ? label.slice(3) : null);
416
+ const canonical = semanticComponent(explicit ?? authoredName);
417
+ if (!canonical) {
418
+ diagnostics.push(diagnostic(
419
+ "LPA101",
420
+ "AsciiDoc piece name, ID, or lp-id does not produce a usable Ravel ID.",
421
+ declaration
422
+ ));
423
+ return null;
424
+ }
425
+ const parsed = pipelineText
426
+ ? parseDefinitionPipeline(pipelineText, pipelineSource ?? declaration)
427
+ : { pipeline: [], diagnostics: [] };
428
+ diagnostics.push(...parsed.diagnostics);
429
+ const requestedRun = runSelected(
430
+ options,
431
+ [authoredName, canonical, label].filter(Boolean)
432
+ ) || truthyAttribute(metadata.attributes["ravel-run"]);
433
+ return {
434
+ form,
435
+ authoredName,
436
+ canonical,
437
+ displayName: displayName || authoredName,
438
+ label,
439
+ metadata,
440
+ declaration,
441
+ pipeline: parsed.pipeline,
442
+ pipelineSource,
443
+ language: null,
444
+ fragments: [],
445
+ run: requestedRun,
446
+ provider: metadata.attributes["ravel-provider"] ||
447
+ options.provider ||
448
+ null,
449
+ order
450
+ };
451
+ };
452
+
453
+ const headingCandidates = (
454
+ lines,
455
+ uri,
456
+ starts,
457
+ excludedLines,
458
+ options,
459
+ diagnostics
460
+ ) => {
461
+ const result = [];
462
+ for (let index = 0; index < lines.length; index += 1) {
463
+ if (excludedLines.has(index)) continue;
464
+ const match = /^(={2,6})[ \t]+(.+?)[ \t]*$/.exec(lines[index].value);
465
+ if (!match) continue;
466
+ const metadata = metadataBefore(lines, index, uri, starts);
467
+ if (!isPieceMetadata(metadata)) continue;
468
+ const split = splitNamePipeline(match[2]);
469
+ const titleStart = lines[index].value.indexOf(match[2]);
470
+ const pipeWhitespace = split.pipe !== null
471
+ ? match[2].slice(split.pipeIndex + 1).search(/\S|$/)
472
+ : 0;
473
+ const pipeStart = titleStart + split.pipeIndex + 1 + pipeWhitespace;
474
+ const attributePipe = metadata.attributes["ravel-pipe"] ??
475
+ metadata.attributes["lp-pipe"] ??
476
+ null;
477
+ if (split.pipe !== null && attributePipe !== null &&
478
+ split.pipe !== attributePipe) {
479
+ diagnostics.push(diagnostic(
480
+ "LPA113",
481
+ "AsciiDoc section title and block attribute declare conflicting pipelines.",
482
+ rangeAt(uri, starts, lines[index].start, lines[index].end)
483
+ ));
484
+ }
485
+ const pipelineText = attributePipe ?? split.pipe;
486
+ const pipelineSource = attributePipe !== null
487
+ ? metadata.attributeSources["ravel-pipe"] ??
488
+ metadata.attributeSources["lp-pipe"]
489
+ : split.pipe !== null
490
+ ? rangeAt(
491
+ uri,
492
+ starts,
493
+ lines[index].start + pipeStart,
494
+ lines[index].start + pipeStart + split.pipe.length
495
+ )
496
+ : null;
497
+ const declaration = rangeAt(
498
+ uri,
499
+ starts,
500
+ metadata.declarationStart,
501
+ lines[index].end
502
+ );
503
+ const candidate = candidateFrom({
504
+ form: "section",
505
+ authoredName: split.name,
506
+ displayName: split.name,
507
+ label: metadata.id,
508
+ metadata,
509
+ declaration,
510
+ pipelineText,
511
+ pipelineSource,
512
+ order: lines[index].start
513
+ }, options, diagnostics);
514
+ if (candidate) {
515
+ candidate.headingIndex = index;
516
+ result.push(candidate);
517
+ }
518
+ }
519
+ return result;
520
+ };
521
+
522
+ const blockCandidate = (block, options, diagnostics) => {
523
+ const metadata = block.metadata;
524
+ const authoredName = metadata.attributes["ravel-id"] ??
525
+ metadata.attributes["lp-id"] ??
526
+ (metadata.id?.startsWith("lp-") ? metadata.id.slice(3) : metadata.title);
527
+ const pipelineText = metadata.attributes["ravel-pipe"] ??
528
+ metadata.attributes["lp-pipe"] ??
529
+ null;
530
+ return candidateFrom({
531
+ form: block.marker === "====" ? "container" : "block",
532
+ authoredName,
533
+ displayName: metadata.title ?? authoredName,
534
+ label: metadata.id,
535
+ metadata,
536
+ declaration: block.declaration,
537
+ pipelineText,
538
+ pipelineSource: metadata.attributeSources["ravel-pipe"] ??
539
+ metadata.attributeSources["lp-pipe"] ??
540
+ null,
541
+ order: block.declaration.range.start.offset
542
+ }, options, diagnostics);
543
+ };
544
+
545
+ const underscoreReferencesFrom = (body, source) => {
546
+ const references = [];
547
+ const pattern = /(?:\\[1-9][0-9]*)?_(["'`])([\s\S]*?)\1/g;
548
+ for (const match of body.matchAll(pattern)) {
549
+ const prefix = match[0].indexOf("_");
550
+ references.push({
551
+ targetText: match[2],
552
+ source: advanceRange(
553
+ source,
554
+ body,
555
+ match.index + prefix,
556
+ match.index + match[0].length
557
+ )
558
+ });
559
+ }
560
+ return references;
561
+ };
562
+
563
+ const navigationFrom = (
564
+ text,
565
+ excluded,
566
+ labelToPiece,
567
+ uri,
568
+ starts
569
+ ) => {
570
+ const navigation = [];
571
+ const patterns = [
572
+ {
573
+ pattern: /<<([^>,\s]+)(?:,[^>]*)?>>/g,
574
+ target: (match) => match[1],
575
+ syntax: "angle-xref"
576
+ },
577
+ {
578
+ pattern: /xref:([^\s\[]+)\[[^\]]*\]/g,
579
+ target: (match) => match[1].replace(/^#/, ""),
580
+ syntax: "xref-macro"
581
+ }
582
+ ];
583
+ const included = (offset) =>
584
+ !excluded.some((range) => offset >= range.start && offset < range.end);
585
+ for (const entry of patterns) {
586
+ for (const match of text.matchAll(entry.pattern)) {
587
+ if (!included(match.index)) continue;
588
+ const targetLabel = entry.target(match);
589
+ const targetPieceId = labelToPiece.get(targetLabel);
590
+ if (!targetPieceId) continue;
591
+ navigation.push({
592
+ targetPieceId,
593
+ targetLabel,
594
+ syntax: entry.syntax,
595
+ source: rangeAt(
596
+ uri,
597
+ starts,
598
+ match.index,
599
+ match.index + match[0].length
600
+ )
601
+ });
602
+ }
603
+ }
604
+ return navigation;
605
+ };
606
+
607
+ const directiveAttributes = (content, line, uri, starts) => {
608
+ const metadata = emptyMetadata();
609
+ const fakeLine = {
610
+ ...line,
611
+ value: "[" + content + "]"
612
+ };
613
+ parseAttributeLine(fakeLine, uri, starts, metadata);
614
+ return metadata;
615
+ };
616
+
617
+ const directivesFrom = (
618
+ lines,
619
+ document,
620
+ uri,
621
+ starts,
622
+ excludedLines,
623
+ diagnostics
624
+ ) => {
625
+ const directives = [];
626
+ for (const [index, line] of lines.entries()) {
627
+ if (excludedLines.has(index)) continue;
628
+ const match = /^[ \t]*ravel::(write|derive|read)\[([\s\S]*)\][ \t]*$/
629
+ .exec(line.value);
630
+ if (!match) continue;
631
+ const contentStart = line.value.indexOf("[") + 1;
632
+ const attrs = directiveAttributes(
633
+ match[2],
634
+ {
635
+ ...line,
636
+ start: line.start + contentStart - 1,
637
+ value: "[" + match[2] + "]"
638
+ },
639
+ uri,
640
+ starts
641
+ );
642
+ const source = rangeAt(uri, starts, line.start, line.end);
643
+ const valueSource = (name) => attrs.attributeSources[name] ?? source;
644
+ if (match[1] === "write") {
645
+ const target = attrs.attributes.target;
646
+ const from = attrs.attributes.from;
647
+ if (!target || !from) {
648
+ diagnostics.push(diagnostic(
649
+ "LPA101",
650
+ "ravel::write requires target and from attributes.",
651
+ source
652
+ ));
653
+ continue;
654
+ }
655
+ directives.push({
656
+ kind: "out",
657
+ name: target,
658
+ from: from.includes("::") ? from : document + "::" + from,
659
+ source
660
+ });
661
+ continue;
662
+ }
663
+ if (match[1] === "read") {
664
+ const target = attrs.attributes.target;
665
+ if (!target) {
666
+ diagnostics.push(diagnostic(
667
+ "LPA101",
668
+ "ravel::read requires a target attribute.",
669
+ source
670
+ ));
671
+ continue;
672
+ }
673
+ directives.push({
674
+ kind: "in",
675
+ target,
676
+ source,
677
+ metadata: {
678
+ adapter: "asciidoc",
679
+ ...(attrs.attributes.as
680
+ ? { legacy: { alias: attrs.attributes.as } }
681
+ : {})
682
+ }
683
+ });
684
+ continue;
685
+ }
686
+ const target = attrs.attributes.target;
687
+ const from = attrs.attributes.from;
688
+ const using = attrs.attributes.using;
689
+ if (!target || !from || !using) {
690
+ diagnostics.push(diagnostic(
691
+ "LPA101",
692
+ "ravel::derive requires target, from, and using attributes.",
693
+ source
694
+ ));
695
+ continue;
696
+ }
697
+ const parsed = parseDefinitionPipeline(using, valueSource("using"));
698
+ diagnostics.push(...parsed.diagnostics);
699
+ directives.push({
700
+ kind: "create",
701
+ document,
702
+ name: target,
703
+ compose: [
704
+ {
705
+ kind: "append",
706
+ reference: from,
707
+ source: valueSource("from")
708
+ },
709
+ {
710
+ kind: "pipe",
711
+ steps: parsed.pipeline.map((step) => ({
712
+ type: "transform",
713
+ name: step.name,
714
+ arguments: step.arguments ?? [],
715
+ source: valueSource("using")
716
+ })),
717
+ source: valueSource("using")
718
+ }
719
+ ],
720
+ source
721
+ });
722
+ }
723
+ return directives;
724
+ };
725
+
726
+ const includesFrom = (lines, uri, starts, excludedLines) => {
727
+ const includes = [];
728
+ for (const [index, line] of lines.entries()) {
729
+ if (excludedLines.has(index)) continue;
730
+ const match = /^[ \t]*include::([^\[]+)\[([^\]]*)\][ \t]*$/
731
+ .exec(line.value);
732
+ if (!match) continue;
733
+ includes.push({
734
+ target: match[1],
735
+ attributes: match[2],
736
+ source: rangeAt(uri, starts, line.start, line.end)
737
+ });
738
+ }
739
+ return includes;
740
+ };
741
+
742
+ const configuredDocumentFrom = (lines) => {
743
+ for (const line of lines.slice(0, 100)) {
744
+ const match = /^:(?:ravel|lp)-document:[ \t]*(\S+)[ \t]*$/
745
+ .exec(line.value);
746
+ if (match) return match[1];
747
+ }
748
+ return null;
749
+ };
750
+
751
+ export const asciidocToMap = (text, options = {}) => {
752
+ const uri = options.uri ?? "document.adoc";
753
+ const starts = lineStarts(text);
754
+ const lines = linesOf(text);
755
+ const documentId = options.document ??
756
+ configuredDocumentFrom(lines) ??
757
+ defaultDocumentId(uri);
758
+ if (!componentPattern.test(documentId ?? "")) {
759
+ throw new Error(
760
+ "AsciiDoc document identity must be a lowercase identifier: " +
761
+ String(documentId)
762
+ );
763
+ }
764
+
765
+ const diagnostics = [];
766
+ const blocks = scanDelimitedBlocks(text, lines, uri, starts, diagnostics);
767
+ const excludedLines = new Set();
768
+ for (const block of blocks) {
769
+ if (block.marker !== "----" && block.marker !== "....") continue;
770
+ const end = block.closeIndex ?? lines.length;
771
+ for (let index = block.openIndex + 1; index < end; index += 1) {
772
+ excludedLines.add(index);
773
+ }
774
+ }
775
+ const headings = headingCandidates(
776
+ lines,
777
+ uri,
778
+ starts,
779
+ excludedLines,
780
+ options,
781
+ diagnostics
782
+ );
783
+ const candidates = [...headings];
784
+ const pieceContainers = new Map();
785
+
786
+ for (const block of blocks) {
787
+ if (block.marker !== "====" || !isPieceMetadata(block.metadata)) continue;
788
+ const candidate = blockCandidate(block, options, diagnostics);
789
+ if (!candidate) continue;
790
+ candidates.push(candidate);
791
+ pieceContainers.set(block, candidate);
792
+ }
793
+
794
+ const sourceBlocks = blocks.filter((block) =>
795
+ block.marker === "----" || block.marker === "...."
796
+ );
797
+ for (const block of sourceBlocks) {
798
+ let owner = null;
799
+ if (isPieceMetadata(block.metadata)) {
800
+ owner = blockCandidate(block, options, diagnostics);
801
+ if (owner) candidates.push(owner);
802
+ }
803
+ if (!owner) {
804
+ let parent = block.parent;
805
+ while (parent && !pieceContainers.has(parent)) parent = parent.parent;
806
+ owner = parent ? pieceContainers.get(parent) : null;
807
+ }
808
+ if (!owner) {
809
+ owner = headings
810
+ .filter((candidate) => candidate.headingIndex < block.openIndex)
811
+ .at(-1) ?? null;
812
+ }
813
+ if (!owner) continue;
814
+ owner.fragments.push({
815
+ body: block.body,
816
+ source: block.bodySource,
817
+ language: blockLanguage(block.metadata),
818
+ declaration: block.declaration,
819
+ metadata: block.metadata
820
+ });
821
+ }
822
+
823
+ candidates.sort((left, right) => left.order - right.order);
824
+ const aliases = {};
825
+ const chunks = [];
826
+ const chunksById = new Map();
827
+ const labelToPiece = new Map();
828
+ const labelOwners = new Map();
829
+ const surface = {
830
+ definitions: [],
831
+ references: [],
832
+ directives: [],
833
+ navigation: [],
834
+ includes: includesFrom(lines, uri, starts, excludedLines)
835
+ };
836
+
837
+ for (const candidate of candidates) {
838
+ const id = documentId + "::" + candidate.canonical;
839
+ for (const alias of [
840
+ candidate.authoredName,
841
+ candidate.label,
842
+ candidate.canonical
843
+ ].filter(Boolean)) {
844
+ aliases[alias] = candidate.canonical;
845
+ }
846
+ if (candidate.label) {
847
+ const prior = labelOwners.get(candidate.label);
848
+ if (prior && prior !== id) {
849
+ diagnostics.push(diagnostic(
850
+ "LPA103",
851
+ "AsciiDoc rendered labels must be unique: " + candidate.label + ".",
852
+ candidate.declaration
853
+ ));
854
+ } else {
855
+ labelOwners.set(candidate.label, id);
856
+ labelToPiece.set(candidate.label, id);
857
+ }
858
+ }
859
+ let chunk = chunksById.get(id);
860
+ if (!chunk) {
861
+ const languages = candidate.fragments
862
+ .map((fragment) => fragment.language)
863
+ .filter(Boolean);
864
+ const language = languages.length &&
865
+ languages.every((entry) => entry === languages[0])
866
+ ? languages[0]
867
+ : null;
868
+ if (new Set(languages).size > 1) {
869
+ diagnostics.push(diagnostic(
870
+ "LPA113",
871
+ "AsciiDoc piece fragments have conflicting source languages.",
872
+ candidate.declaration
873
+ ));
874
+ }
875
+ chunk = {
876
+ id,
877
+ identity: {
878
+ document: documentId,
879
+ chunk: candidate.canonical,
880
+ minor: null,
881
+ type: null
882
+ },
883
+ name: candidate.displayName,
884
+ body: "",
885
+ definitionPipeline: candidate.pipeline,
886
+ metadata: {
887
+ ...(language ? { language } : {}),
888
+ tags: [],
889
+ data: {
890
+ ravel: {
891
+ adapter: "asciidoc",
892
+ displayName: candidate.displayName,
893
+ ...(candidate.label
894
+ ? { renderedAnchor: candidate.label }
895
+ : {}),
896
+ referenceSyntax: {
897
+ noweb: false,
898
+ underscore: true,
899
+ aliases
900
+ },
901
+ ...(candidate.run ? { run: true } : {}),
902
+ ...(candidate.run && candidate.provider
903
+ ? { provider: candidate.provider }
904
+ : {})
905
+ },
906
+ asciidoc: {
907
+ form: candidate.form,
908
+ label: candidate.label,
909
+ roles: candidate.metadata.roles,
910
+ attributes: candidate.metadata.attributes,
911
+ fragments: []
912
+ }
913
+ }
914
+ },
915
+ source: candidate.fragments[0]?.source ?? candidate.declaration,
916
+ fragments: [],
917
+ _pipelineKey: pipelineKey(candidate.pipeline)
918
+ };
919
+ chunks.push(chunk);
920
+ chunksById.set(id, chunk);
921
+ } else {
922
+ const key = pipelineKey(candidate.pipeline);
923
+ if (candidate.pipeline.length && chunk.definitionPipeline.length &&
924
+ key !== chunk._pipelineKey) {
925
+ diagnostics.push(diagnostic(
926
+ "LPA113",
927
+ "Repeated AsciiDoc pieces have conflicting pipelines.",
928
+ candidate.declaration
929
+ ));
930
+ } else if (candidate.pipeline.length &&
931
+ !chunk.definitionPipeline.length) {
932
+ chunk.definitionPipeline = candidate.pipeline;
933
+ chunk._pipelineKey = key;
934
+ }
935
+ }
936
+
937
+ const fragmentRanges = [];
938
+ for (const fragment of candidate.fragments) {
939
+ if (fragment.language && chunk.metadata.language &&
940
+ fragment.language !== chunk.metadata.language) {
941
+ diagnostics.push(diagnostic(
942
+ "LPA113",
943
+ "Repeated AsciiDoc piece fragments have conflicting source languages.",
944
+ fragment.declaration
945
+ ));
946
+ } else if (fragment.language && !chunk.metadata.language) {
947
+ chunk.metadata.language = fragment.language;
948
+ }
949
+ chunk.body += fragment.body;
950
+ chunk.fragments.push({
951
+ body: fragment.body,
952
+ source: fragment.source
953
+ });
954
+ chunk.metadata.data.asciidoc.fragments.push({
955
+ source: fragment.source,
956
+ declaration: fragment.declaration,
957
+ language: fragment.language,
958
+ attributes: fragment.metadata.attributes
959
+ });
960
+ fragmentRanges.push(fragment.source);
961
+ for (const reference of underscoreReferencesFrom(
962
+ fragment.body,
963
+ fragment.source
964
+ )) {
965
+ surface.references.push({
966
+ ownerPieceId: id,
967
+ ...reference
968
+ });
969
+ }
970
+ }
971
+ if (candidate.run) {
972
+ chunk.metadata.data.ravel.run = true;
973
+ if (candidate.provider) {
974
+ chunk.metadata.data.ravel.provider = candidate.provider;
975
+ }
976
+ }
977
+ surface.definitions.push({
978
+ pieceId: id,
979
+ declaration: candidate.declaration,
980
+ fragments: fragmentRanges,
981
+ displayName: candidate.displayName,
982
+ ...(candidate.label
983
+ ? {
984
+ sourceAnchor: candidate.label,
985
+ renderedAnchor: candidate.label
986
+ }
987
+ : {}),
988
+ ...(candidate.pipelineSource
989
+ ? { pipeline: candidate.pipelineSource }
990
+ : {})
991
+ });
992
+ }
993
+
994
+ const directives = directivesFrom(
995
+ lines,
996
+ documentId,
997
+ uri,
998
+ starts,
999
+ excludedLines,
1000
+ diagnostics
1001
+ );
1002
+ surface.directives = [...directives];
1003
+ surface.navigation = navigationFrom(
1004
+ text,
1005
+ sourceBlocks.map((block) => ({
1006
+ start: block.bodySource.range.start.offset,
1007
+ end: block.bodySource.range.end.offset
1008
+ })),
1009
+ labelToPiece,
1010
+ uri,
1011
+ starts
1012
+ );
1013
+
1014
+ return {
1015
+ map: {
1016
+ version: 1,
1017
+ document: {
1018
+ id: documentId,
1019
+ uri,
1020
+ format: "asciidoc+ravel-v1"
1021
+ },
1022
+ chunks: chunks.map(({ _pipelineKey, ...chunk }) => chunk),
1023
+ directives,
1024
+ metadata: {
1025
+ adapter: "asciidoc",
1026
+ crossReferences: surface.navigation,
1027
+ includes: surface.includes
1028
+ }
1029
+ },
1030
+ diagnostics,
1031
+ surface
1032
+ };
1033
+ };