@dogsbay/format-dita 0.2.0-beta.124 → 0.2.0-beta.125

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/README.md CHANGED
@@ -38,6 +38,11 @@ emit `{% include %}` so one canonical copy of reused text survives. Use
38
38
  `dogsbay import-dita --no-conref-includes` to keep conrefs on the ledger
39
39
  instead.
40
40
 
41
+ **Lines.** Every conversion note carries `line`, the source line of the
42
+ element that raised it (or of the block being converted), so a host can point
43
+ at it. `{ sourceLines: true }` also puts each block's line on `meta.line`, which
44
+ `@dogsbay/render-html` writes as `data-line` when asked (`dataLines: true`).
45
+
41
46
  **Specializations in raw source:** a file as authored has no `@class`, so
42
47
  an element is known only by its name, and a specialization the converter
43
48
  does not know by name (a `<troubleshooting>` topic, a hazard statement's
package/dist/blocks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { inlineToDogsbayMd } from "@dogsbay/format-dogsbay-md/browser";
2
- import { convertInline, elementName, idAnchor, publishImage, trimInline, textOf, inlineSafeSnippet, conrefLedgerDetail, } from "./inline.js";
2
+ import { convertInline, elementName, isKnownInline, idAnchor, publishImage, trimInline, textOf, inlineSafeSnippet, conrefLedgerDetail, } from "./inline.js";
3
3
  import { publishHref, publishLabel, publishLanguage, publishString } from "./types.js";
4
4
  import { anchorId } from "./anchors.js";
5
5
  import { DB_QUALIFIERS, outputTokens, youtubeId } from "./qualifiers.js";
@@ -52,6 +52,13 @@ const withProvenance = (node, el) => {
52
52
  }
53
53
  return node;
54
54
  };
55
+ /** A list item or step: its source line (ParseOptions.sourceLines), as convertBlock gives a block. */
56
+ const atLine = (node, el, ctx) => {
57
+ const line = ctx.lineOf?.(el);
58
+ if (line !== undefined && node.meta?.line === undefined)
59
+ node.meta = { ...node.meta, line };
60
+ return node;
61
+ };
55
62
  const noteFilterAttrs = (el, ctx) => {
56
63
  for (const attr of ["audience", "platform", "product", "props", "otherprops", "rev"]) {
57
64
  if (el.attribs?.[attr]) {
@@ -77,8 +84,29 @@ const isBlockChild = (el, ctx) => {
77
84
  const name = blockName(el, ctx);
78
85
  if (ctx.publish && name === "image")
79
86
  return el.attribs?.placement === "break";
80
- return BLOCK_ELEMENTS.has(name);
87
+ return BLOCK_ELEMENTS.has(name) || (isUnknownElement(el, ctx) && holdsBlocks(el, ctx));
81
88
  };
89
+ /**
90
+ * An element the converter knows by neither a block nor an inline name (a
91
+ * custom specialization). Both resolutions count: with @class, a
92
+ * specialization of <fn> or <ph> resolves to an inline name, not a block one.
93
+ */
94
+ const isUnknownElement = (el, ctx) => !isKnownBlock(elementName(el, ctx, isKnownBlock)) && !isKnownInline(elementName(el, ctx, isKnownInline));
95
+ /**
96
+ * Whether an unknown element holds block content (a <p>, a list), directly or
97
+ * through other unknown wrappers. Such an element is a block wrapper: read as
98
+ * inline, its paragraphs and lists were flattened into one run of text, with
99
+ * an "unrecognized inline element" note for each of them. An image does not
100
+ * count: it sits in a sentence as often as not (an icon in a UI label).
101
+ */
102
+ const holdsBlocks = (el, ctx) => el.children.some((c) => {
103
+ if (!isElement(c))
104
+ return false;
105
+ const name = blockName(c, ctx);
106
+ if (name === "image")
107
+ return false;
108
+ return BLOCK_ELEMENTS.has(name) || TRANSPARENT_CONTAINERS.has(name) || (isUnknownElement(c, ctx) && holdsBlocks(c, ctx));
109
+ });
82
110
  const paragraphFrom = (nodes, ctx) => {
83
111
  const inline = trimInline(convertInline(nodes, ctx));
84
112
  if (inline.length === 0)
@@ -221,7 +249,7 @@ const convertListItems = (el, ctx, itemTag) => el.children
221
249
  .map((li) => {
222
250
  const viaConref = itemConref(li, ctx, "list-item") ?? definingItemNode(li, ctx, "list-item");
223
251
  if (viaConref)
224
- return withProvenance(viaConref, li);
252
+ return atLine(withProvenance(viaConref, li), li, ctx);
225
253
  const { inline: content, children: blocks } = mixedContent(li, { ...ctx, constrained: true });
226
254
  const { inline, children } = withItemAnchor(content, blocks, [li.attribs?.id], ctx);
227
255
  const item = { type: "list-item" };
@@ -229,7 +257,7 @@ const convertListItems = (el, ctx, itemTag) => el.children
229
257
  item.inline = inline;
230
258
  if (children.length > 0)
231
259
  item.children = children;
232
- return withProvenance(item, li);
260
+ return atLine(withProvenance(item, li), li, ctx);
233
261
  });
234
262
  /** Components an `@outputclass` `db-*` token selects, per element. */
235
263
  const DB_COMPONENTS = {
@@ -966,7 +994,7 @@ const convertSteps = (el, ctx, type) => {
966
994
  .map((stepEl) => {
967
995
  const viaConref = itemConref(stepEl, ctx, "step") ?? definingItemNode(stepEl, ctx, "step");
968
996
  if (viaConref)
969
- return withProvenance(viaConref, stepEl);
997
+ return atLine(withProvenance(viaConref, stepEl), stepEl, ctx);
970
998
  const { inline: content, children: blocks } = convertStepContent(stepEl, { ...ctx, constrained: true });
971
999
  // Publishing: DITA-OT labels optional and required steps
972
1000
  const importance = stepEl.attribs?.importance;
@@ -982,7 +1010,7 @@ const convertSteps = (el, ctx, type) => {
982
1010
  step.inline = inline;
983
1011
  if (children.length > 0)
984
1012
  step.children = children;
985
- return withProvenance(step, stepEl);
1013
+ return atLine(withProvenance(step, stepEl), stepEl, ctx);
986
1014
  });
987
1015
  return { type, children };
988
1016
  };
@@ -1141,6 +1169,27 @@ const ANCHORED_ELSEWHERE = new Set(["title", "related-links", "prolog", "data",
1141
1169
  * DITA-OT's pages. (Headings carry their own id.)
1142
1170
  */
1143
1171
  export function convertBlock(el, ctx) {
1172
+ // A note raised while converting this element takes its line
1173
+ const outer = ctx.position?.el;
1174
+ if (ctx.position)
1175
+ ctx.position.el = el;
1176
+ try {
1177
+ const out = convertBlockThemed(el, ctx);
1178
+ // Source lines: each node this element became starts at its line
1179
+ // (nested blocks came through here first and keep their own)
1180
+ const line = ctx.lineOf?.(el);
1181
+ if (line !== undefined)
1182
+ for (const node of out)
1183
+ if (node.meta?.line === undefined)
1184
+ node.meta = { ...node.meta, line };
1185
+ return out;
1186
+ }
1187
+ finally {
1188
+ if (ctx.position)
1189
+ ctx.position.el = outer;
1190
+ }
1191
+ }
1192
+ function convertBlockThemed(el, ctx) {
1144
1193
  const out = convertBlockAnchored(el, ctx);
1145
1194
  // A scoped theme wraps the element's whole output, its anchor included
1146
1195
  const theme = /(^|\s)db-theme-/i.test(el.attribs?.outputclass ?? "") ? themeOf(el, ctx) : undefined;
@@ -1,4 +1,4 @@
1
- import type { AnyNode } from "domhandler";
1
+ import type { AnyNode, Element } from "domhandler";
2
2
  /** An element's DITA 1.3 default @class (`inMap`: inside a map document), or undefined for a name the DTDs do not declare. */
3
3
  export declare const classDefault: (name: string, inMap?: boolean) => string | undefined;
4
4
  /**
@@ -12,4 +12,4 @@ export declare const classDefault: (name: string, inMap?: boolean) => string | u
12
12
  * specialization: MathML, SVG), which are not DITA elements and are left
13
13
  * untouched. A DITA element inside one (`<mathmlref>`, `<svgref>`) is classed.
14
14
  */
15
- export declare function applyClassDefaults(nodes: AnyNode[]): string[];
15
+ export declare function applyClassDefaults(nodes: AnyNode[], firstOf?: Map<string, Element>): string[];
@@ -14,7 +14,7 @@ export const classDefault = (name, inMap = false) => (inMap ? own(DITA_MAP_CLASS
14
14
  * specialization: MathML, SVG), which are not DITA elements and are left
15
15
  * untouched. A DITA element inside one (`<mathmlref>`, `<svgref>`) is classed.
16
16
  */
17
- export function applyClassDefaults(nodes) {
17
+ export function applyClassDefaults(nodes, firstOf) {
18
18
  const root = nodes.find(isTag);
19
19
  const inMap = !!root && (["map", "bookmap", "subjectScheme"].includes(root.name) || ` ${root.attribs?.class ?? ""} `.includes(" map/map "));
20
20
  const unknown = new Set();
@@ -25,8 +25,10 @@ export function applyClassDefaults(nodes) {
25
25
  const value = classDefault(el.name, inMap);
26
26
  if (value)
27
27
  el.attribs = { ...el.attribs, class: value };
28
- else if (el.name !== "dita" && !inForeign)
28
+ else if (el.name !== "dita" && !inForeign && !unknown.has(el.name)) {
29
29
  unknown.add(el.name);
30
+ firstOf?.set(el.name, el);
31
+ }
30
32
  }
31
33
  const foreign = inForeign || ` ${el.attribs?.class ?? ""} `.includes(" topic/foreign ");
32
34
  for (const child of el.children)
package/dist/inline.d.ts CHANGED
@@ -11,6 +11,16 @@ export interface InlineContext {
11
11
  publish?: PublishContext;
12
12
  /** Every element carries @class (DTD defaults filled in): resolve unknown names by it, as publishing does. */
13
13
  classes?: boolean;
14
+ /**
15
+ * The element being converted, shared by every context derived from the
16
+ * topic's (a note takes its line from it). Set by convertBlock and, for each
17
+ * element, convertInline.
18
+ */
19
+ position?: {
20
+ el?: Element;
21
+ };
22
+ /** ParseOptions.sourceLines: an element's 1-based source line. */
23
+ lineOf?: (el: Element) => number | undefined;
14
24
  /** Publishing: trademarks already marked in this topic (DITA-OT marks only the first). */
15
25
  trademarks?: Set<string>;
16
26
  /** Publishing: the number label of each titled table and figure ("Table 1. "). */
package/dist/inline.js CHANGED
@@ -216,6 +216,19 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
216
216
  }
217
217
  if (!isElement(node))
218
218
  continue;
219
+ const outer = ctx.position?.el;
220
+ if (ctx.position)
221
+ ctx.position.el = node;
222
+ try {
223
+ convertInlineElement(node);
224
+ }
225
+ finally {
226
+ if (ctx.position)
227
+ ctx.position.el = outer;
228
+ }
229
+ }
230
+ return mergeAdjacentText(out);
231
+ function convertInlineElement(node) {
219
232
  const name = elementName(node, ctx, isKnownInline);
220
233
  const children = node.children;
221
234
  // Publishing: an inline element with an @id gets an anchor, as DITA-OT's HTML keeps the id
@@ -238,7 +251,7 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
238
251
  const defining = ctx.conref.definingSnippet(node.attribs.id);
239
252
  if (defining && inlineSafeSnippet(defining)) {
240
253
  pushText(`{% include "${ctx.conref.includePath(defining)}" %}`);
241
- continue;
254
+ return;
242
255
  }
243
256
  }
244
257
  // Inline conrefs (<ph conref="…"/>) are the standard DITA reused-text
@@ -254,7 +267,7 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
254
267
  // Local children are the stale preview copy in DITA conref
255
268
  // semantics — the include replaces the whole element
256
269
  pushText(`{% include "${ctx.conref.includePath(snippet)}" %}`);
257
- continue;
270
+ return;
258
271
  }
259
272
  ctx.note(node.attribs.conref ? "@conref" : "@conkeyref", `<${name}> pulls content from "${ref}" — ${conrefLedgerDetail(node.attribs, snippet, ctx.conref !== undefined)}`);
260
273
  }
@@ -275,7 +288,7 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
275
288
  noteOnce(ctx, `db-badge ${t}`, `"${t}" is not a tone (${DB_QUALIFIERS.tones.join(", ")}) — default badge`);
276
289
  }
277
290
  out.push({ type: "badge", text, ...(variant ? { variant } : {}) });
278
- continue;
291
+ return;
279
292
  }
280
293
  // A content-less keyref falls through to the key's text ({{ key }}): the badge look is what is lost
281
294
  if (node.attribs?.keyref)
@@ -309,7 +322,7 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
309
322
  : [];
310
323
  // An unresolved key with no content renders nothing, as in DITA-OT's output
311
324
  if (content.length === 0)
312
- continue;
325
+ return;
313
326
  const href = node.attribs?.href;
314
327
  if (href) {
315
328
  // An external link keeps its href as written, even to a .dita file
@@ -319,14 +332,14 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
319
332
  else {
320
333
  out.push(...content);
321
334
  }
322
- continue;
335
+ return;
323
336
  }
324
337
  if (!ctx.publish && node.attribs?.keyref && name !== "xref" && name !== "link" && name !== "image") {
325
338
  const hasFallback = children.some((c) => isElement(c) || (isText(c) && c.data.trim().length > 0));
326
339
  if (!hasFallback) {
327
340
  noteOnce(ctx, "@keyref", "content-less keyref emitted as {{ key }} minja variable — resolves only for TEXT keys (keys.yaml entries with text:); href-only keys need manual attention");
328
341
  pushText(`{{ ${node.attribs.keyref} }}`);
329
- continue;
342
+ return;
330
343
  }
331
344
  // Fallback content present: note the dropped indirection, then fall
332
345
  // through so the element converts normally (term stays italic, …)
@@ -334,12 +347,12 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
334
347
  }
335
348
  if (TRANSPARENT.has(name) && !isCodeLike(name, ctx)) {
336
349
  out.push(...convertInline(children, ctx, formatting));
337
- continue;
350
+ return;
338
351
  }
339
352
  // Publishing: content DITA-OT's HTML does not show (foreign content such as MathML without
340
353
  // a plugin, and required-cleanup without args.draft)
341
354
  if (ctx.publish && (name === "foreign" || name === "unknown" || name === "required-cleanup"))
342
- continue;
355
+ return;
343
356
  if ((name === "fn" || name === "synnote") && ctx.publish && ctx.footnotes) {
344
357
  // Publishing: a numbered footnote, listed at the end of the page as DITA-OT does. Only
345
358
  // footnotes without @callout are numbered; a callout is the label when it is a valid,
@@ -351,14 +364,14 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
351
364
  // Converted by parse.ts, which can convert block content (a list in a footnote)
352
365
  ctx.footnotes.push({ label, el: node, ...(callout ? { callout: true } : {}) });
353
366
  out.push({ type: "footnote-ref", label });
354
- continue;
367
+ return;
355
368
  }
356
369
  if (name === "q" && ctx.publish) {
357
370
  // Publishing: a quotation in quotation marks, as a browser renders DITA-OT's <q>
358
371
  pushText("\u201C");
359
372
  out.push(...convertInline(children, ctx, formatting));
360
373
  pushText("\u201D");
361
- continue;
374
+ return;
362
375
  }
363
376
  if (isCodeLike(name, ctx) && ctx.publish && children.some((c) => isElement(c) && ["xref", "link"].includes(c.name.toLowerCase()))) {
364
377
  // Publishing: a code phrase containing a link, as DITA-OT renders it: one code
@@ -374,7 +387,7 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
374
387
  out.push({ type: "html-inline", html: `<code>${collapseWhitespace(html).trim()}</code>` });
375
388
  if (raw.endsWith(" "))
376
389
  pushText(" ");
377
- continue;
390
+ return;
378
391
  }
379
392
  if (isCodeLike(name, ctx)) {
380
393
  if (name !== "codeph" && name !== "tt") {
@@ -394,7 +407,7 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
394
407
  });
395
408
  if (ctx.publish && raw.endsWith(" ") && raw.trim())
396
409
  pushText(" ");
397
- continue;
410
+ return;
398
411
  }
399
412
  if (name === "menucascade") {
400
413
  // "File > Save Project", as DITA-OT's HTML renders it: each step keeps
@@ -412,7 +425,7 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
412
425
  out.push(...converted);
413
426
  any = true;
414
427
  }
415
- continue;
428
+ return;
416
429
  }
417
430
  if (name === "shortcut") {
418
431
  // A keystroke (`<shortcut>Ctrl+S</shortcut>`) is a keycap. Inside a
@@ -424,21 +437,21 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
424
437
  out.push({ type: "kbd", keys });
425
438
  else
426
439
  out.push(...convertInline(children, ctx, formatting));
427
- continue;
440
+ return;
428
441
  }
429
442
  if (BOLD_LIKE.has(name)) {
430
443
  if (name !== "b" && name !== "strong") {
431
444
  noteOnce(ctx, `<${name}>`, `rendered as bold — semantic identity not preserved`);
432
445
  }
433
446
  out.push(...convertInline(children, ctx, { ...formatting, bold: true }));
434
- continue;
447
+ return;
435
448
  }
436
449
  if (ITALIC_LIKE.has(name)) {
437
450
  if (name !== "i" && name !== "em") {
438
451
  noteOnce(ctx, `<${name}>`, `rendered as italic — semantic identity not preserved`);
439
452
  }
440
453
  out.push(...convertInline(children, ctx, { ...formatting, italic: true }));
441
- continue;
454
+ return;
442
455
  }
443
456
  if (name === "tm" && ctx.publish) {
444
457
  // Publishing: the trademark symbol DITA-OT's HTML adds, on a trademark's first use in the topic
@@ -448,18 +461,18 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
448
461
  if (symbol && !ctx.trademarks?.has(trademark))
449
462
  pushText(symbol);
450
463
  ctx.trademarks?.add(trademark);
451
- continue;
464
+ return;
452
465
  }
453
466
  // Highlighting Markdown has no syntax for: inline HTML around the converted content
454
467
  if (name === "u" || name === "sub" || name === "sup" || name === "overline") {
455
468
  const open = name === "overline" ? '<span style="text-decoration: overline">' : `<${name}>`;
456
469
  const close = name === "overline" ? "</span>" : `</${name}>`;
457
470
  out.push({ type: "html-inline", html: open }, ...convertInline(children, ctx, formatting), { type: "html-inline", html: close });
458
- continue;
471
+ return;
459
472
  }
460
473
  if (name === "line-through") {
461
474
  out.push(...convertInline(children, ctx, { ...formatting, strikethrough: true }));
462
- continue;
475
+ return;
463
476
  }
464
477
  if (name === "xref" || name === "link") {
465
478
  const href = node.attribs?.href;
@@ -476,7 +489,7 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
476
489
  out.push(...linkChildren);
477
490
  else if (text)
478
491
  pushText(text);
479
- continue;
492
+ return;
480
493
  }
481
494
  if (keyref && !ctx.publish) {
482
495
  // Key-based links keep their indirection via the key: scheme
@@ -486,7 +499,7 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
486
499
  href: `key:${keyref}`,
487
500
  children: linkChildren.length > 0 ? linkChildren : [{ type: "text", text: keyref }],
488
501
  });
489
- continue;
502
+ return;
490
503
  }
491
504
  if (href) {
492
505
  // An external link keeps its href as written, even to a .dita file
@@ -498,11 +511,11 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
498
511
  ? linkChildren
499
512
  : [{ type: "text", text: fallbackText || (ctx.publish ? ditaOtHrefText(href) : href) }],
500
513
  });
501
- continue;
514
+ return;
502
515
  }
503
516
  noteOnce(ctx, `<${name}>`, "xref without href/keyref — emitted as plain text");
504
517
  pushText(fallbackText);
505
- continue;
518
+ return;
506
519
  }
507
520
  if (name === "image") {
508
521
  const imgHref = node.attribs?.href;
@@ -513,13 +526,13 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
513
526
  else {
514
527
  noteOnce(ctx, "<image>", "image without href dropped");
515
528
  }
516
- continue;
529
+ return;
517
530
  }
518
531
  if (ctx.publish) {
519
532
  // Publishing: size, alignment and the <alt> element, as DITA-OT's HTML renders them
520
533
  const { alt, attrs } = publishImage(node, ctx.publish);
521
534
  out.push({ type: "image", src: imgHref, ...(alt ? { alt } : {}), ...(attrs ? { attrs } : {}) });
522
- continue;
535
+ return;
523
536
  }
524
537
  // The <alt> element (DITA 1.1+) wins over the deprecated @alt, as in publishing.
525
538
  const altEl = (children).find((c) => isElement(c) && c.name.toLowerCase() === "alt");
@@ -529,17 +542,16 @@ export const convertInline = (nodes, ctx, formatting = {}) => {
529
542
  src: imgHref,
530
543
  ...(alt ? { alt } : {}),
531
544
  });
532
- continue;
545
+ return;
533
546
  }
534
547
  if (name === "indexterm" || name === "draft-comment" || name === "data") {
535
548
  noteOnce(ctx, `<${name}>`, "metadata element dropped from output");
536
- continue;
549
+ return;
537
550
  }
538
551
  // Unknown inline element: keep the text, note the loss
539
552
  noteOnce(ctx, `<${name}>`, "unrecognized inline element — text kept, markup dropped");
540
553
  out.push(...convertInline(children, ctx, formatting));
541
554
  }
542
- return mergeAdjacentText(out);
543
555
  };
544
556
  /** Merge neighbouring text spans with identical formatting (recursive calls
545
557
  * push whole runs, so pushText's local merging alone is not enough). */
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Offsets → 1-based line numbers, counting lines as checkWellFormed does:
3
+ * `\n`, `\r\n` and a lone `\r` each end one.
4
+ */
5
+ export declare function lineLocator(text: string): (offset: number) => number;
package/dist/lines.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Offsets → 1-based line numbers, counting lines as checkWellFormed does:
3
+ * `\n`, `\r\n` and a lone `\r` each end one.
4
+ */
5
+ export function lineLocator(text) {
6
+ const starts = [0];
7
+ for (let i = 0; i < text.length; i++) {
8
+ const c = text.charCodeAt(i);
9
+ if (c === 10 || (c === 13 && text.charCodeAt(i + 1) !== 10))
10
+ starts.push(i + 1);
11
+ }
12
+ return (offset) => {
13
+ let lo = 0;
14
+ let hi = starts.length - 1;
15
+ while (lo < hi) {
16
+ const mid = (lo + hi + 1) >> 1;
17
+ if (starts[mid] <= offset)
18
+ lo = mid;
19
+ else
20
+ hi = mid - 1;
21
+ }
22
+ return lo + 1;
23
+ };
24
+ }
package/dist/parse.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { checkWellFormed } from "./well-formed.js";
2
2
  import { applyClassDefaults } from "./class-defaults-apply.js";
3
+ import { lineLocator } from "./lines.js";
3
4
  import { parseDocument } from "htmlparser2";
4
5
  import { convertChildren, convertItemContent } from "./blocks.js";
5
6
  import { textOf, convertInline, trimInline, collapseWhitespace } from "./inline.js";
@@ -89,6 +90,8 @@ const convertSecondaryTopic = (el, ctx, level, bindConrefSite) => {
89
90
  for (const child of el.children) {
90
91
  if (!isElement(child))
91
92
  continue;
93
+ if (ctx.position)
94
+ ctx.position.el = child;
92
95
  const name = child.name.toLowerCase();
93
96
  if (name === "title" || name === "glossterm") {
94
97
  out.push({
@@ -98,7 +101,9 @@ const convertSecondaryTopic = (el, ctx, level, bindConrefSite) => {
98
101
  ...(el.attribs?.id ? { id: el.attribs.id } : {}),
99
102
  },
100
103
  inline: trimInline(convertInline(child.children, inner)),
101
- ...(el.attribs?.id ? { meta: { dita: { id: el.attribs.id } } } : {}),
104
+ ...(el.attribs?.id || ctx.lineOf?.(child) !== undefined
105
+ ? { meta: { ...(el.attribs?.id ? { dita: { id: el.attribs.id } } : {}), ...(ctx.lineOf?.(child) !== undefined ? { line: ctx.lineOf(child) } : {}) } }
106
+ : {}),
102
107
  });
103
108
  headingEmitted = true;
104
109
  continue;
@@ -220,17 +225,23 @@ const extractProlog = (el, meta, ctx) => {
220
225
  export function ditaTopicToTree(xml, options = {}) {
221
226
  const topicPath = options.topicPath ?? "";
222
227
  const notes = [];
223
- const note = (construct, detail) => notes.push({ topicPath, construct, detail });
228
+ // Every note carries the line of the element converting when it was raised
229
+ // (a shared holder: contexts are copied, the holder is not)
230
+ const lineOf = lineLocator(xml);
231
+ const position = {};
232
+ const lineAt = (el) => el?.startIndex != null ? lineOf(el.startIndex) : undefined;
233
+ const note = (construct, detail, line = lineAt(position.el)) => notes.push({ topicPath, construct, detail, ...(line ? { line } : {}) });
224
234
  // The parser repairs malformed XML silently: say so, with a line
225
235
  const malformed = checkWellFormed(xml);
226
236
  if (malformed)
227
- note("<xml>", `not well-formed XML, line ${malformed.line}: ${malformed.message} — the converted content may be incomplete`);
228
- const doc = parseDocument(xml, { xmlMode: true });
237
+ note("<xml>", `not well-formed XML, line ${malformed.line}: ${malformed.message} — the converted content may be incomplete`, malformed.line);
238
+ const doc = parseDocument(xml, { xmlMode: true, withStartIndices: true });
229
239
  // Raw source: fill in the @class the DTDs default, so specializations convert as their base
230
240
  if (options.classDefaults) {
231
- const undeclared = applyClassDefaults(doc.children);
241
+ const firstOf = new Map();
242
+ const undeclared = applyClassDefaults(doc.children, firstOf);
232
243
  if (undeclared.length) {
233
- note("@class", `no DITA 1.3 class default for ${undeclared.map((n) => `<${n}>`).join(", ")} — converted by name only`);
244
+ note("@class", `no DITA 1.3 class default for ${undeclared.map((n) => `<${n}>`).join(", ")} — converted by name only`, lineAt(firstOf.get(undeclared[0])));
234
245
  }
235
246
  }
236
247
  const byClass = !!options.publish || !!options.classDefaults;
@@ -283,7 +294,9 @@ export function ditaTopicToTree(xml, options = {}) {
283
294
  note,
284
295
  seenConstructs: new Set(),
285
296
  headingLevel: 2,
297
+ position,
286
298
  ...(options.classDefaults ? { classes: true } : {}),
299
+ ...(options.sourceLines ? { lineOf: lineAt } : {}),
287
300
  ...(bindConrefSite ? { conref: bindConrefSite(root.attribs?.id) } : {}),
288
301
  ...(options.publish
289
302
  ? { publish: options.publish, trademarks: new Set(), footnotes: [], topicId: root.attribs?.id, anchorIds: renamedElementIds(doc.children) }
@@ -297,6 +310,7 @@ export function ditaTopicToTree(xml, options = {}) {
297
310
  for (const child of root.children) {
298
311
  if (!isElement(child))
299
312
  continue;
313
+ position.el = child;
300
314
  const name = child.name.toLowerCase();
301
315
  if (name === "title" || name === "glossterm") {
302
316
  // The page title is plain text: a badge in it keeps only its words
@@ -351,9 +365,12 @@ export function ditaTopicToTree(xml, options = {}) {
351
365
  }
352
366
  // Sibling topics under a composite wrapper convert the same way
353
367
  for (const sibling of roots.slice(1)) {
368
+ position.el = sibling;
354
369
  // Publishing: DITA-OT titles sibling topics in one file like the first (h1)
355
370
  tree.push(...convertSecondaryTopic(sibling, ctx, options.publish ? 1 : 2, bindConrefSite));
356
371
  }
372
+ // Notes about the whole file (duplicate ids) are about no one element
373
+ position.el = undefined;
357
374
  // DITA element ids are unique per TOPIC, not per file — after inlining
358
375
  // multiple topics, colliding ids mean fragment links can mis-target.
359
376
  // Known loss, never silent loss: report every duplicate anchor.
package/dist/types.d.ts CHANGED
@@ -10,6 +10,12 @@ export interface DitaConversionNote {
10
10
  /** Construct that triggered the note (element/attribute name). */
11
11
  construct: string;
12
12
  detail: string;
13
+ /**
14
+ * The 1-based source line the note is about: the element that raised it, or
15
+ * failing that the block being converted (absent when there is none, as for
16
+ * "no topic root").
17
+ */
18
+ line?: number;
13
19
  }
14
20
  /** Topic-level metadata extracted alongside the block tree. */
15
21
  export interface DitaTopicMeta {
@@ -162,6 +168,12 @@ export interface DitaParseOptions {
162
168
  * noted once. Off by default (it changes the output for such elements).
163
169
  */
164
170
  classDefaults?: boolean;
171
+ /**
172
+ * Put each block node's 1-based source line on `meta.line` (the line of the
173
+ * element it came from), so a host can map the rendered page back to the
174
+ * source; render-html writes it as `data-line` when asked. Off by default.
175
+ */
176
+ sourceLines?: boolean;
165
177
  }
166
178
  /** DITA topic root elements this importer understands. */
167
179
  export declare const DITA_TOPIC_TYPES: readonly ["concept", "task", "reference", "topic", "glossentry"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/format-dita",
3
- "version": "0.2.0-beta.124",
3
+ "version": "0.2.0-beta.125",
4
4
  "description": "DITA topic XML → TreeNode importer with conversion-note ledger; composes with format-dogsbay-md for .dita → .md migration. Browser-safe: no Node built-ins",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,8 +28,8 @@
28
28
  "dependencies": {
29
29
  "domhandler": "^5.0.3",
30
30
  "htmlparser2": "^10.1.0",
31
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.124",
32
- "@dogsbay/types": "0.2.0-beta.124"
31
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.125",
32
+ "@dogsbay/types": "0.2.0-beta.125"
33
33
  },
34
34
  "license": "MIT",
35
35
  "repository": {