@pieceful/ravel-html 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,41 @@
1
+ # `@pieceful/ravel-html`
2
+
3
+ Lossless, script-free HTML source adapter for [Ravel](https://github.com/jostylr/ravel). Semantic `section` and
4
+ `figure` elements declare visible pieces; `pre > code` elements provide exact
5
+ source fragments; ordinary links can declare conspicuous graph directives.
6
+
7
+ ```js
8
+ import { htmlToMap } from "@pieceful/ravel-html";
9
+
10
+ const { map, diagnostics, surface } = htmlToMap(source, {
11
+ uri: "program.html",
12
+ document: "program",
13
+ });
14
+ ```
15
+
16
+ The adapter parses source HTML with scripting disabled. It never constructs a
17
+ browser DOM, evaluates scripts, follows links, mutates the document, executes
18
+ code, or writes outputs.
19
+
20
+ Canonical section form:
21
+
22
+ ```html
23
+ <meta name="ravel-document" content="program" />
24
+ <section id="lp-main" data-ravel-piece="main | trim()">
25
+ <h2>Main program <code>main</code></h2>
26
+ <pre><code class="language-javascript">console.log(_"helper");</code></pre>
27
+ </section>
28
+ ```
29
+
30
+ Canonical block form:
31
+
32
+ ```html
33
+ <figure id="lp-helper" data-ravel-piece="helper">
34
+ <figcaption>Helper <code>helper</code></figcaption>
35
+ <pre><code class="language-javascript">export const helper = true;</code></pre>
36
+ </figure>
37
+ ```
38
+
39
+ Visible `<a>` and `<data>` elements with `data-ravel-effect` declare `read`,
40
+ `derive`, and `write` graph operations. `data-lp-*` remains a compatibility
41
+ alias for the earlier design spelling.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@pieceful/ravel-html",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Lossless, script-free semantic HTML source 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/html"
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
+ "html",
23
+ "literate-programming"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "exports": {
29
+ ".": "./src/index.js"
30
+ },
31
+ "types": "./src/index.d.ts",
32
+ "files": [
33
+ "src",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "dependencies": {
38
+ "@pieceful/ravel-core": "0.2.0",
39
+ "character-entities": "^2.0.2",
40
+ "parse5": "^8.0.0"
41
+ }
42
+ }
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 HtmlAdapterOptions {
13
+ uri?: string;
14
+ document?: string;
15
+ run?: boolean | string[];
16
+ provider?: string;
17
+ }
18
+
19
+ export interface HtmlAdapterResult {
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
+ entities: unknown[];
33
+ };
34
+ }
35
+
36
+ export function htmlToMap(
37
+ text: string,
38
+ options?: HtmlAdapterOptions
39
+ ): HtmlAdapterResult;
package/src/index.js ADDED
@@ -0,0 +1,868 @@
1
+ import { characterEntities } from "character-entities";
2
+ import { parse } from "parse5";
3
+ import { parseDefinitionPipeline } from "@pieceful/ravel-core";
4
+
5
+ const componentPattern = /^[a-z][a-z0-9-]*$/;
6
+ const pieceTags = new Set(["section", "figure"]);
7
+
8
+ const diagnostic = (code, message, source, severity = "error") => ({
9
+ code,
10
+ severity,
11
+ message,
12
+ source
13
+ });
14
+
15
+ const lineStarts = (text) => {
16
+ const starts = [0];
17
+ for (let index = 0; index < text.length; index += 1) {
18
+ if (text[index] === "\n") starts.push(index + 1);
19
+ }
20
+ return starts;
21
+ };
22
+
23
+ const positionAt = (starts, offset) => {
24
+ let low = 0;
25
+ let high = starts.length;
26
+ while (low + 1 < high) {
27
+ const middle = Math.floor((low + high) / 2);
28
+ if (starts[middle] <= offset) low = middle;
29
+ else high = middle;
30
+ }
31
+ return { line: low, column: offset - starts[low], offset };
32
+ };
33
+
34
+ const rangeAt = (uri, starts, start, end) => ({
35
+ uri,
36
+ range: {
37
+ start: positionAt(starts, start),
38
+ end: positionAt(starts, end)
39
+ }
40
+ });
41
+
42
+ const locationRange = (location, uri, starts) => rangeAt(
43
+ uri,
44
+ starts,
45
+ location?.startOffset ?? 0,
46
+ location?.endOffset ?? location?.startOffset ?? 0
47
+ );
48
+
49
+ const defaultDocumentId = (uri) => {
50
+ const base = uri.split(/[\\/]/).at(-1)
51
+ ?.replace(/\.html?$/i, "") ?? "";
52
+ const id = base.toLowerCase()
53
+ .normalize("NFKD")
54
+ .replace(/[\u0300-\u036f]/g, "")
55
+ .replace(/[^a-z0-9]+/g, "-")
56
+ .replace(/^-|-$/g, "");
57
+ return componentPattern.test(id) ? id : null;
58
+ };
59
+
60
+ const semanticComponent = (value) => {
61
+ const normalized = String(value ?? "").trim().toLowerCase()
62
+ .normalize("NFKD")
63
+ .replace(/[\u0300-\u036f]/g, "")
64
+ .replace(/[^a-z0-9]+/g, "-")
65
+ .replace(/^-|-$/g, "");
66
+ return componentPattern.test(normalized) ? normalized : null;
67
+ };
68
+
69
+ const firstUnescapedPipe = (value) => {
70
+ let escaped = false;
71
+ for (let index = 0; index < value.length; index += 1) {
72
+ if (escaped) escaped = false;
73
+ else if (value[index] === "\\") escaped = true;
74
+ else if (value[index] === "|") return index;
75
+ }
76
+ return -1;
77
+ };
78
+
79
+ const splitNamePipeline = (value) => {
80
+ const pipeIndex = firstUnescapedPipe(value);
81
+ return {
82
+ name: (pipeIndex === -1 ? value : value.slice(0, pipeIndex))
83
+ .trim()
84
+ .replace(/\\\|/g, "|"),
85
+ pipe: pipeIndex === -1 ? null : value.slice(pipeIndex + 1).trim(),
86
+ pipeIndex
87
+ };
88
+ };
89
+
90
+ const attributesOf = (node) => Object.fromEntries(
91
+ (node.attrs ?? []).map((attribute) => [attribute.name, attribute.value])
92
+ );
93
+
94
+ const attributeValue = (node, name) =>
95
+ node?.attrs?.find((attribute) => attribute.name === name)?.value;
96
+
97
+ const ravelAttribute = (node, suffix) =>
98
+ attributeValue(node, "data-ravel-" + suffix) ??
99
+ attributeValue(node, "data-lp-" + suffix);
100
+
101
+ const attributeSource = (node, name, text, uri, starts) => {
102
+ const location = node.sourceCodeLocation?.attrs?.[name];
103
+ if (!location) return locationRange(node.sourceCodeLocation, uri, starts);
104
+ const raw = text.slice(location.startOffset, location.endOffset);
105
+ const equals = raw.indexOf("=");
106
+ if (equals === -1) return locationRange(location, uri, starts);
107
+ let start = equals + 1;
108
+ while (/\s/.test(raw[start] ?? "")) start += 1;
109
+ let end = raw.length;
110
+ while (end > start && /\s/.test(raw[end - 1])) end -= 1;
111
+ if ((raw[start] === "\"" || raw[start] === "'") &&
112
+ raw[end - 1] === raw[start]) {
113
+ start += 1;
114
+ end -= 1;
115
+ }
116
+ return rangeAt(
117
+ uri,
118
+ starts,
119
+ location.startOffset + start,
120
+ location.startOffset + end
121
+ );
122
+ };
123
+
124
+ const ravelAttributeSource = (node, suffix, text, uri, starts) => {
125
+ const canonical = "data-ravel-" + suffix;
126
+ return attributeValue(node, canonical) !== undefined
127
+ ? attributeSource(node, canonical, text, uri, starts)
128
+ : attributeSource(node, "data-lp-" + suffix, text, uri, starts);
129
+ };
130
+
131
+ const childNodesOf = (node) =>
132
+ node.tagName === "template" ? [] : node.childNodes ?? [];
133
+
134
+ const walk = (node, visit) => {
135
+ visit(node);
136
+ for (const child of childNodesOf(node)) walk(child, visit);
137
+ };
138
+
139
+ const descendants = (node, predicate, stop = () => false) => {
140
+ const result = [];
141
+ const visit = (current) => {
142
+ for (const child of childNodesOf(current)) {
143
+ if (stop(child)) continue;
144
+ if (predicate(child)) result.push(child);
145
+ visit(child);
146
+ }
147
+ };
148
+ visit(node);
149
+ return result;
150
+ };
151
+
152
+ const normalizedText = (node, excludedTags = new Set()) => {
153
+ let value = "";
154
+ const collect = (current) => {
155
+ if (current.nodeName === "#text") {
156
+ value += current.value;
157
+ return;
158
+ }
159
+ if (excludedTags.has(current.tagName)) return;
160
+ for (const child of childNodesOf(current)) collect(child);
161
+ };
162
+ collect(node);
163
+ return value.replace(/\s+/g, " ").trim();
164
+ };
165
+
166
+ const mergeSegment = (segments, next) => {
167
+ const prior = segments.at(-1);
168
+ if (prior &&
169
+ prior.precision === next.precision &&
170
+ prior.source.range.end.offset === next.source.range.start.offset) {
171
+ prior.body += next.body;
172
+ prior.source.range.end = next.source.range.end;
173
+ return;
174
+ }
175
+ segments.push(next);
176
+ };
177
+
178
+ const namedEntityAt = (raw, rawIndex, decoded, decodedIndex) => {
179
+ const match = /^&([A-Za-z0-9]+)/.exec(raw.slice(rawIndex));
180
+ if (!match) return null;
181
+ for (let length = match[1].length; length > 0; length -= 1) {
182
+ const name = match[1].slice(0, length);
183
+ const value = characterEntities[name];
184
+ if (value === undefined || !decoded.startsWith(value, decodedIndex)) {
185
+ continue;
186
+ }
187
+ const semicolon = raw[rawIndex + 1 + length] === ";" ? 1 : 0;
188
+ return {
189
+ rawLength: 1 + length + semicolon,
190
+ value
191
+ };
192
+ }
193
+ return null;
194
+ };
195
+
196
+ const entityAt = (raw, rawIndex, decoded, decodedIndex) => {
197
+ const numeric = /^&#(?:[xX][0-9A-Fa-f]+|[0-9]+);?/
198
+ .exec(raw.slice(rawIndex));
199
+ if (numeric) {
200
+ const codePoint = decoded.codePointAt(decodedIndex);
201
+ const value = codePoint === undefined ? "" : String.fromCodePoint(codePoint);
202
+ return value
203
+ ? { rawLength: numeric[0].length, value }
204
+ : null;
205
+ }
206
+ return namedEntityAt(raw, rawIndex, decoded, decodedIndex);
207
+ };
208
+
209
+ const fallbackBoundary = (raw, rawIndex, decoded, decodedIndex) => {
210
+ const limit = 64;
211
+ for (let distance = 2; distance <= limit; distance += 1) {
212
+ for (let rawDistance = 1; rawDistance < distance; rawDistance += 1) {
213
+ const decodedDistance = distance - rawDistance;
214
+ const rawAnchor = raw.slice(
215
+ rawIndex + rawDistance,
216
+ rawIndex + rawDistance + 4
217
+ );
218
+ if (rawAnchor.length &&
219
+ decoded.slice(
220
+ decodedIndex + decodedDistance,
221
+ decodedIndex + decodedDistance + rawAnchor.length
222
+ ) === rawAnchor) {
223
+ return {
224
+ rawEnd: rawIndex + rawDistance,
225
+ decodedEnd: decodedIndex + decodedDistance
226
+ };
227
+ }
228
+ }
229
+ }
230
+ return { rawEnd: raw.length, decodedEnd: decoded.length };
231
+ };
232
+
233
+ const decodedTextSegments = (node, text, uri, starts) => {
234
+ const location = node.sourceCodeLocation;
235
+ if (!location) return [];
236
+ const raw = text.slice(location.startOffset, location.endOffset);
237
+ const decoded = node.value;
238
+ const segments = [];
239
+ let rawIndex = 0;
240
+ let decodedIndex = 0;
241
+ const append = (rawEnd, decodedEnd, precision) => {
242
+ mergeSegment(segments, {
243
+ body: decoded.slice(decodedIndex, decodedEnd),
244
+ source: rangeAt(
245
+ uri,
246
+ starts,
247
+ location.startOffset + rawIndex,
248
+ location.startOffset + rawEnd
249
+ ),
250
+ precision
251
+ });
252
+ rawIndex = rawEnd;
253
+ decodedIndex = decodedEnd;
254
+ };
255
+
256
+ while (rawIndex < raw.length || decodedIndex < decoded.length) {
257
+ if (raw[rawIndex] === "&") {
258
+ const entity = entityAt(raw, rawIndex, decoded, decodedIndex);
259
+ if (entity) {
260
+ append(
261
+ rawIndex + entity.rawLength,
262
+ decodedIndex + entity.value.length,
263
+ "coarse"
264
+ );
265
+ continue;
266
+ }
267
+ }
268
+ if (raw[rawIndex] === decoded[decodedIndex]) {
269
+ let end = rawIndex + 1;
270
+ while (end < raw.length &&
271
+ raw[end] !== "&" &&
272
+ raw[end] !== "\r" &&
273
+ raw[end] === decoded[decodedIndex + end - rawIndex]) {
274
+ end += 1;
275
+ }
276
+ append(end, decodedIndex + end - rawIndex, "exact");
277
+ continue;
278
+ }
279
+ if (raw.startsWith("\r\n", rawIndex) &&
280
+ decoded[decodedIndex] === "\n") {
281
+ append(rawIndex + 2, decodedIndex + 1, "coarse");
282
+ continue;
283
+ }
284
+ if (raw[rawIndex] === "\r" && decoded[decodedIndex] === "\n") {
285
+ append(rawIndex + 1, decodedIndex + 1, "coarse");
286
+ continue;
287
+ }
288
+ const boundary = fallbackBoundary(
289
+ raw,
290
+ rawIndex,
291
+ decoded,
292
+ decodedIndex
293
+ );
294
+ append(boundary.rawEnd, boundary.decodedEnd, "coarse");
295
+ }
296
+ return segments;
297
+ };
298
+
299
+ const segmentsForCode = (node, text, uri, starts) => {
300
+ const textNodes = descendants(node, (child) => child.nodeName === "#text");
301
+ const segments = textNodes.flatMap((child) =>
302
+ decodedTextSegments(child, text, uri, starts)
303
+ );
304
+ if (segments.length) return segments;
305
+ const start = node.sourceCodeLocation?.startTag?.endOffset ??
306
+ node.sourceCodeLocation?.startOffset ?? 0;
307
+ const end = node.sourceCodeLocation?.endTag?.startOffset ?? start;
308
+ return [{
309
+ body: "",
310
+ source: rangeAt(uri, starts, start, end),
311
+ precision: "exact"
312
+ }];
313
+ };
314
+
315
+ const languageFrom = (code, candidate) => {
316
+ const explicit = ravelAttribute(code, "language") ??
317
+ ravelAttribute(candidate.node, "language");
318
+ if (explicit) return explicit;
319
+ const classes = (attributeValue(code, "class") ?? "").split(/\s+/);
320
+ return classes.find((entry) => entry.startsWith("language-"))
321
+ ?.slice("language-".length) || null;
322
+ };
323
+
324
+ const runSelected = (options, names) =>
325
+ options.run === true ||
326
+ (Array.isArray(options.run) &&
327
+ names.some((name) => options.run.includes(name)));
328
+
329
+ const truthyAttribute = (value) =>
330
+ value === "" || /^(?:1|yes|true|on)$/i.test(value ?? "");
331
+
332
+ const virtualRangeSource = (segments, start, end) => {
333
+ const overlapping = segments.filter((segment) =>
334
+ start < segment.generated.end && end > segment.generated.start
335
+ );
336
+ if (!overlapping.length) return null;
337
+ return {
338
+ uri: overlapping[0].source.uri,
339
+ range: {
340
+ start: overlapping[0].source.range.start,
341
+ end: overlapping.at(-1).source.range.end
342
+ }
343
+ };
344
+ };
345
+
346
+ const underscoreReferencesFrom = (body, segments) => {
347
+ const references = [];
348
+ const pattern = /(?:\\[1-9][0-9]*)?_(["'`])([\s\S]*?)\1/g;
349
+ for (const match of body.matchAll(pattern)) {
350
+ const prefix = match[0].indexOf("_");
351
+ const start = match.index + prefix;
352
+ const source = virtualRangeSource(
353
+ segments,
354
+ start,
355
+ match.index + match[0].length
356
+ );
357
+ if (source) references.push({ targetText: match[2], source });
358
+ }
359
+ return references;
360
+ };
361
+
362
+ const directiveFrom = (
363
+ node,
364
+ document,
365
+ text,
366
+ uri,
367
+ starts,
368
+ diagnostics
369
+ ) => {
370
+ const effect = ravelAttribute(node, "effect");
371
+ if (!effect) return null;
372
+ const source = locationRange(node.sourceCodeLocation, uri, starts);
373
+ const target = ravelAttribute(node, "target") ??
374
+ attributeValue(node, "href") ??
375
+ attributeValue(node, "value");
376
+ const from = ravelAttribute(node, "from");
377
+ if (effect === "write") {
378
+ if (!target || !from) {
379
+ diagnostics.push(diagnostic(
380
+ "LPH101",
381
+ "HTML write links require a target and data-ravel-from.",
382
+ source
383
+ ));
384
+ return null;
385
+ }
386
+ return {
387
+ kind: "out",
388
+ name: target,
389
+ from: from.includes("::") ? from : document + "::" + from,
390
+ source
391
+ };
392
+ }
393
+ if (effect === "read") {
394
+ if (!target) {
395
+ diagnostics.push(diagnostic(
396
+ "LPH101",
397
+ "HTML read links require an href or data-ravel-target.",
398
+ source
399
+ ));
400
+ return null;
401
+ }
402
+ const alias = ravelAttribute(node, "as");
403
+ return {
404
+ kind: "in",
405
+ target,
406
+ source,
407
+ metadata: {
408
+ adapter: "html",
409
+ ...(alias ? { legacy: { alias } } : {})
410
+ }
411
+ };
412
+ }
413
+ if (effect === "derive") {
414
+ const using = ravelAttribute(node, "using") ??
415
+ ravelAttribute(node, "pipe");
416
+ if (!target || !from || !using) {
417
+ diagnostics.push(diagnostic(
418
+ "LPH101",
419
+ "HTML derive elements require target, data-ravel-from, and data-ravel-using.",
420
+ source
421
+ ));
422
+ return null;
423
+ }
424
+ const usingSource = ravelAttributeSource(
425
+ node,
426
+ ravelAttribute(node, "using") !== undefined ? "using" : "pipe",
427
+ text,
428
+ uri,
429
+ starts
430
+ );
431
+ const parsed = parseDefinitionPipeline(using, usingSource);
432
+ diagnostics.push(...parsed.diagnostics);
433
+ return {
434
+ kind: "create",
435
+ document,
436
+ name: target,
437
+ compose: [
438
+ {
439
+ kind: "append",
440
+ reference: from,
441
+ source: ravelAttributeSource(node, "from", text, uri, starts)
442
+ },
443
+ {
444
+ kind: "pipe",
445
+ steps: parsed.pipeline.map((step) => ({
446
+ type: "transform",
447
+ name: step.name,
448
+ arguments: step.arguments ?? [],
449
+ source: usingSource
450
+ })),
451
+ source: usingSource
452
+ }
453
+ ],
454
+ source
455
+ };
456
+ }
457
+ diagnostics.push(diagnostic(
458
+ "LPH101",
459
+ "Unknown HTML Ravel effect: " + effect + ".",
460
+ source
461
+ ));
462
+ return null;
463
+ };
464
+
465
+ export const htmlToMap = (text, options = {}) => {
466
+ const uri = options.uri ?? "document.html";
467
+ const starts = lineStarts(text);
468
+ const diagnostics = [];
469
+ const parseErrors = [];
470
+ const documentNode = parse(text, {
471
+ scriptingEnabled: false,
472
+ sourceCodeLocationInfo: true,
473
+ onParseError: (error) => parseErrors.push(error)
474
+ });
475
+ for (const error of parseErrors) {
476
+ if (error.code === "missing-doctype") continue;
477
+ diagnostics.push(diagnostic(
478
+ "LPH110",
479
+ "HTML parse error: " + error.code + ".",
480
+ rangeAt(
481
+ uri,
482
+ starts,
483
+ error.startOffset ?? 0,
484
+ error.endOffset ?? error.startOffset ?? 0
485
+ ),
486
+ "warning"
487
+ ));
488
+ }
489
+
490
+ const nodes = [];
491
+ walk(documentNode, (node) => nodes.push(node));
492
+ const configuredDocument = nodes
493
+ .filter((node) => node.tagName === "meta")
494
+ .find((node) => ["ravel-document", "lp-document"].includes(
495
+ attributeValue(node, "name")
496
+ ));
497
+ const documentId = options.document ??
498
+ attributeValue(configuredDocument, "content") ??
499
+ defaultDocumentId(uri);
500
+ if (!componentPattern.test(documentId ?? "")) {
501
+ throw new Error(
502
+ "HTML document identity must be a lowercase identifier: " +
503
+ String(documentId)
504
+ );
505
+ }
506
+
507
+ const ids = new Map();
508
+ for (const node of nodes) {
509
+ const id = attributeValue(node, "id");
510
+ if (!id || !node.sourceCodeLocation) continue;
511
+ if (ids.has(id)) {
512
+ diagnostics.push(diagnostic(
513
+ "LPH103",
514
+ "HTML element IDs must be unique: " + id + ".",
515
+ attributeSource(node, "id", text, uri, starts)
516
+ ));
517
+ } else {
518
+ ids.set(id, node);
519
+ }
520
+ }
521
+
522
+ const candidateNodes = nodes.filter((node) =>
523
+ pieceTags.has(node.tagName) &&
524
+ ravelAttribute(node, "piece") !== undefined &&
525
+ node.sourceCodeLocation
526
+ );
527
+ const candidates = [];
528
+ const candidateByNode = new Map();
529
+ for (const node of candidateNodes) {
530
+ const declaration = locationRange(node.sourceCodeLocation, uri, starts);
531
+ const authored = ravelAttribute(node, "piece");
532
+ const split = splitNamePipeline(authored);
533
+ const explicitPipeline = ravelAttribute(node, "pipe");
534
+ if (split.pipe !== null && explicitPipeline !== undefined &&
535
+ split.pipe !== explicitPipeline) {
536
+ diagnostics.push(diagnostic(
537
+ "LPH113",
538
+ "HTML piece name and data-ravel-pipe declare conflicting pipelines.",
539
+ declaration
540
+ ));
541
+ }
542
+ const canonical = semanticComponent(split.name);
543
+ if (!canonical) {
544
+ diagnostics.push(diagnostic(
545
+ "LPH101",
546
+ "HTML data-ravel-piece does not produce a usable Ravel ID.",
547
+ declaration
548
+ ));
549
+ continue;
550
+ }
551
+ const titleTag = node.tagName === "figure"
552
+ ? "figcaption"
553
+ : /^(?:h[1-6])$/;
554
+ const titleNode = descendants(
555
+ node,
556
+ (child) => typeof titleTag === "string"
557
+ ? child.tagName === titleTag
558
+ : titleTag.test(child.tagName ?? ""),
559
+ (child) => candidateNodes.includes(child)
560
+ )[0];
561
+ const displayName = titleNode
562
+ ? normalizedText(titleNode, new Set(["code"]))
563
+ : "";
564
+ if (!displayName) {
565
+ diagnostics.push(diagnostic(
566
+ "LPH102",
567
+ "HTML pieces should have a visible heading or figcaption.",
568
+ declaration,
569
+ "warning"
570
+ ));
571
+ }
572
+ const pipelineText = explicitPipeline ?? split.pipe;
573
+ const pipelineSource = explicitPipeline !== undefined
574
+ ? ravelAttributeSource(node, "pipe", text, uri, starts)
575
+ : (() => {
576
+ const pieceSource = ravelAttributeSource(
577
+ node,
578
+ "piece",
579
+ text,
580
+ uri,
581
+ starts
582
+ );
583
+ const afterPipe = authored.slice(split.pipeIndex + 1);
584
+ const whitespace = afterPipe.search(/\S|$/);
585
+ const start = pieceSource.range.start.offset +
586
+ split.pipeIndex + 1 + whitespace;
587
+ return rangeAt(
588
+ uri,
589
+ starts,
590
+ start,
591
+ start + (split.pipe?.length ?? 0)
592
+ );
593
+ })();
594
+ const parsed = pipelineText
595
+ ? parseDefinitionPipeline(pipelineText, pipelineSource)
596
+ : { pipeline: [], diagnostics: [] };
597
+ diagnostics.push(...parsed.diagnostics);
598
+ const label = attributeValue(node, "id") ?? null;
599
+ const requestedRun = runSelected(
600
+ options,
601
+ [split.name, canonical, label].filter(Boolean)
602
+ ) || truthyAttribute(ravelAttribute(node, "run"));
603
+ const candidate = {
604
+ node,
605
+ authoredName: split.name,
606
+ canonical,
607
+ displayName: displayName || split.name,
608
+ label,
609
+ pipeline: parsed.pipeline,
610
+ pipelineSource: pipelineText ? pipelineSource : null,
611
+ declaration,
612
+ fragments: [],
613
+ codeBlocks: [],
614
+ run: requestedRun,
615
+ provider: ravelAttribute(node, "provider") ?? options.provider ?? null,
616
+ order: node.sourceCodeLocation.startOffset
617
+ };
618
+ candidates.push(candidate);
619
+ candidateByNode.set(node, candidate);
620
+ }
621
+
622
+ const nearestCandidate = (node) => {
623
+ let current = node.parentNode;
624
+ while (current) {
625
+ if (candidateByNode.has(current)) return candidateByNode.get(current);
626
+ current = current.parentNode;
627
+ }
628
+ return null;
629
+ };
630
+ for (const code of nodes.filter((node) =>
631
+ node.tagName === "code" && node.parentNode?.tagName === "pre"
632
+ )) {
633
+ const owner = nearestCandidate(code);
634
+ if (!owner) continue;
635
+ const language = languageFrom(code, owner);
636
+ const segments = segmentsForCode(code, text, uri, starts);
637
+ const body = segments.map((segment) => segment.body).join("");
638
+ const generated = [];
639
+ let cursor = 0;
640
+ for (const segment of segments) {
641
+ generated.push({
642
+ ...segment,
643
+ generated: {
644
+ start: cursor,
645
+ end: cursor + segment.body.length
646
+ }
647
+ });
648
+ cursor += segment.body.length;
649
+ }
650
+ const broadSource = rangeAt(
651
+ uri,
652
+ starts,
653
+ code.sourceCodeLocation?.startTag?.endOffset ??
654
+ code.sourceCodeLocation.startOffset,
655
+ code.sourceCodeLocation?.endTag?.startOffset ??
656
+ code.sourceCodeLocation.endOffset
657
+ );
658
+ owner.fragments.push(...generated.map((segment) => ({
659
+ body: segment.body,
660
+ source: segment.source,
661
+ precision: segment.precision
662
+ })));
663
+ owner.codeBlocks.push({
664
+ body,
665
+ language,
666
+ source: broadSource,
667
+ declaration: locationRange(code.sourceCodeLocation, uri, starts),
668
+ segments: generated
669
+ });
670
+ }
671
+
672
+ candidates.sort((left, right) => left.order - right.order);
673
+ const aliases = {};
674
+ const chunks = [];
675
+ const definitions = [];
676
+ const references = [];
677
+ const semanticOwners = new Map();
678
+ const labelToPiece = new Map();
679
+ for (const candidate of candidates) {
680
+ const id = documentId + "::" + candidate.canonical;
681
+ if (semanticOwners.has(id)) {
682
+ diagnostics.push(diagnostic(
683
+ "LPH103",
684
+ "HTML semantic piece IDs must be unique: " + id + ".",
685
+ candidate.declaration
686
+ ));
687
+ continue;
688
+ }
689
+ semanticOwners.set(id, candidate);
690
+ if (candidate.label && !labelToPiece.has(candidate.label)) {
691
+ labelToPiece.set(candidate.label, id);
692
+ }
693
+ for (const alias of [
694
+ candidate.authoredName,
695
+ candidate.canonical,
696
+ candidate.label
697
+ ].filter(Boolean)) {
698
+ aliases[alias] = candidate.canonical;
699
+ }
700
+ const languages = candidate.codeBlocks
701
+ .map((block) => block.language)
702
+ .filter(Boolean);
703
+ const language = languages.length &&
704
+ languages.every((entry) => entry === languages[0])
705
+ ? languages[0]
706
+ : null;
707
+ if (new Set(languages).size > 1) {
708
+ diagnostics.push(diagnostic(
709
+ "LPH113",
710
+ "HTML piece fragments have conflicting source languages.",
711
+ candidate.declaration
712
+ ));
713
+ }
714
+ const body = candidate.fragments.map((fragment) => fragment.body).join("");
715
+ const chunkSegments = [];
716
+ let bodyOffset = 0;
717
+ for (const fragment of candidate.fragments) {
718
+ chunkSegments.push({
719
+ ...fragment,
720
+ generated: {
721
+ start: bodyOffset,
722
+ end: bodyOffset + fragment.body.length
723
+ }
724
+ });
725
+ bodyOffset += fragment.body.length;
726
+ }
727
+ for (const reference of underscoreReferencesFrom(body, chunkSegments)) {
728
+ references.push({ ownerPieceId: id, ...reference });
729
+ }
730
+ const chunk = {
731
+ id,
732
+ identity: {
733
+ document: documentId,
734
+ chunk: candidate.canonical,
735
+ minor: null,
736
+ type: null
737
+ },
738
+ name: candidate.displayName,
739
+ body,
740
+ definitionPipeline: candidate.pipeline,
741
+ metadata: {
742
+ ...(language ? { language } : {}),
743
+ tags: [],
744
+ data: {
745
+ ravel: {
746
+ adapter: "html",
747
+ displayName: candidate.displayName,
748
+ ...(candidate.label
749
+ ? { renderedAnchor: candidate.label }
750
+ : {}),
751
+ referenceSyntax: {
752
+ noweb: false,
753
+ underscore: true,
754
+ aliases
755
+ },
756
+ ...(candidate.run ? { run: true } : {}),
757
+ ...(candidate.run && candidate.provider
758
+ ? { provider: candidate.provider }
759
+ : {})
760
+ },
761
+ html: {
762
+ element: candidate.node.tagName,
763
+ label: candidate.label,
764
+ attributes: attributesOf(candidate.node),
765
+ fragments: candidate.codeBlocks.map((block) => ({
766
+ source: block.source,
767
+ declaration: block.declaration,
768
+ language: block.language,
769
+ entities: block.segments
770
+ .filter((segment) => segment.precision === "coarse")
771
+ .map((segment) => ({
772
+ generated: segment.generated,
773
+ source: segment.source,
774
+ value: segment.body
775
+ }))
776
+ }))
777
+ }
778
+ }
779
+ },
780
+ source: candidate.fragments[0]?.source ?? candidate.declaration,
781
+ fragments: candidate.fragments
782
+ };
783
+ chunks.push(chunk);
784
+ definitions.push({
785
+ pieceId: id,
786
+ declaration: candidate.declaration,
787
+ fragments: candidate.codeBlocks.map((block) => block.source),
788
+ displayName: candidate.displayName,
789
+ ...(candidate.label
790
+ ? {
791
+ sourceAnchor: candidate.label,
792
+ renderedAnchor: candidate.label
793
+ }
794
+ : {}),
795
+ ...(candidate.pipelineSource
796
+ ? { pipeline: candidate.pipelineSource }
797
+ : {})
798
+ });
799
+ }
800
+
801
+ const directives = nodes
802
+ .filter((node) =>
803
+ (node.tagName === "a" || node.tagName === "data") &&
804
+ ravelAttribute(node, "effect") !== undefined
805
+ )
806
+ .map((node) => directiveFrom(
807
+ node,
808
+ documentId,
809
+ text,
810
+ uri,
811
+ starts,
812
+ diagnostics
813
+ ))
814
+ .filter(Boolean);
815
+ const navigation = nodes
816
+ .filter((node) =>
817
+ node.tagName === "a" &&
818
+ attributeValue(node, "href")?.startsWith("#") &&
819
+ ravelAttribute(node, "effect") === undefined
820
+ )
821
+ .map((node) => {
822
+ const targetLabel = attributeValue(node, "href").slice(1);
823
+ const targetPieceId = labelToPiece.get(targetLabel);
824
+ return targetPieceId
825
+ ? {
826
+ targetPieceId,
827
+ targetLabel,
828
+ syntax: "anchor",
829
+ source: locationRange(node.sourceCodeLocation, uri, starts)
830
+ }
831
+ : null;
832
+ })
833
+ .filter(Boolean);
834
+ const entities = chunks.flatMap((chunk) =>
835
+ chunk.metadata.data.html.fragments.flatMap((fragment) =>
836
+ fragment.entities.map((entity) => ({
837
+ pieceId: chunk.id,
838
+ ...entity
839
+ }))
840
+ )
841
+ );
842
+
843
+ return {
844
+ map: {
845
+ version: 1,
846
+ document: {
847
+ id: documentId,
848
+ uri,
849
+ format: "html+ravel-v1"
850
+ },
851
+ chunks,
852
+ directives,
853
+ metadata: {
854
+ adapter: "html",
855
+ crossReferences: navigation,
856
+ sourceBoundary: "authored-html"
857
+ }
858
+ },
859
+ diagnostics,
860
+ surface: {
861
+ definitions,
862
+ references,
863
+ directives: [...directives],
864
+ navigation,
865
+ entities
866
+ }
867
+ };
868
+ };