@dogsbay/format-dita 0.2.0-beta.114 → 0.2.0-beta.116

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/dist/blocks.js CHANGED
@@ -1,4 +1,7 @@
1
- import { convertInline, trimInline, textOf, inlineSafeSnippet, conrefLedgerDetail, } from "./inline.js";
1
+ import { inlineToDogsbayMd } from "@dogsbay/format-dogsbay-md";
2
+ import { convertInline, elementName, idAnchor, publishImage, trimInline, textOf, inlineSafeSnippet, conrefLedgerDetail, } from "./inline.js";
3
+ import { publishHref, publishLabel, publishLanguage, publishString } from "./types.js";
4
+ import { anchorId } from "./anchors.js";
2
5
  const isElement = (node) => node.type === "tag";
3
6
  const isNonEmptyText = (node) => node.type === "text" && node.data.trim().length > 0;
4
7
  /** DITA note @type → callout type in the dogsbay-md vocabulary. */
@@ -14,6 +17,10 @@ const NOTE_TYPE_MAP = {
14
17
  restriction: "important",
15
18
  trouble: "warning",
16
19
  };
20
+ /** Publishing: more DITA note types, as the closest callout dogsbay-md renders. */
21
+ const PUBLISH_NOTE_TYPE_MAP = { notice: "important", fastpath: "tip", other: "note" };
22
+ /** Callout types dogsbay-md renders, kept as authored when publishing. */
23
+ const PUBLISH_CALLOUT_TYPES = new Set(["note", "tip", "important", "warning", "caution", "danger"]);
17
24
  /** Containers whose children are spliced through without a wrapper node. */
18
25
  const TRANSPARENT_CONTAINERS = new Set([
19
26
  "body", "conbody", "refbody", "taskbody", "glossdef", "abstract", "bodydiv", "sectiondiv", "div",
@@ -51,6 +58,26 @@ const noteFilterAttrs = (el, ctx) => {
51
58
  }
52
59
  }
53
60
  };
61
+ /**
62
+ * A caption is inline Markdown: the title's formatting (code, emphasis,
63
+ * and in publishing DITA-OT's generated markup such as `<plugin>`) is kept.
64
+ */
65
+ const captionText = (titleEl, ctx, owner) => {
66
+ // Publishing with numbering: DITA-OT's "Table 1. " / "Figure 2. " label first
67
+ const label = ctx.numberLabels?.get(owner);
68
+ const inline = trimInline(convertInline(titleEl.children, ctx));
69
+ return inlineToDogsbayMd(label ? [{ type: "text", text: label }, ...inline] : inline);
70
+ };
71
+ /**
72
+ * Whether a child element converts as a block. Publishing: an image is inline
73
+ * unless its placement is "break" (DITA's default placement is inline).
74
+ */
75
+ const isBlockChild = (el, ctx) => {
76
+ const name = blockName(el, ctx);
77
+ if (ctx.publish && name === "image")
78
+ return el.attribs?.placement === "break";
79
+ return BLOCK_ELEMENTS.has(name);
80
+ };
54
81
  const paragraphFrom = (nodes, ctx) => {
55
82
  const inline = trimInline(convertInline(nodes, ctx));
56
83
  if (inline.length === 0)
@@ -82,13 +109,15 @@ const mixedContent = (el, ctx) => {
82
109
  }
83
110
  };
84
111
  for (const child of el.children) {
85
- if (isElement(child) && BLOCK_ELEMENTS.has(child.name.toLowerCase())) {
112
+ if (isElement(child) && (isBlockChild(child, ctx) || TRANSPARENT_CONTAINERS.has(blockName(child, ctx)))) {
86
113
  flushRun();
87
114
  sawBlock = true;
88
115
  blockNodes.push(...convertBlock(child, ctx));
89
116
  continue;
90
117
  }
91
- if (isElement(child) || isNonEmptyText(child)) {
118
+ // Whitespace between two inline elements is a word space; at the edges
119
+ // of a run it is trimmed
120
+ if (isElement(child) || isNonEmptyText(child) || (child.type === "text" && run.length > 0)) {
92
121
  run.push(child);
93
122
  }
94
123
  }
@@ -186,7 +215,8 @@ const convertListItems = (el, ctx, itemTag) => el.children
186
215
  const viaConref = itemConref(li, ctx, "list-item") ?? definingItemNode(li, ctx, "list-item");
187
216
  if (viaConref)
188
217
  return withProvenance(viaConref, li);
189
- const { inline, children } = mixedContent(li, { ...ctx, constrained: true });
218
+ const { inline: content, children: blocks } = mixedContent(li, { ...ctx, constrained: true });
219
+ const { inline, children } = withItemAnchor(content, blocks, [li.attribs?.id], ctx);
190
220
  const item = { type: "list-item" };
191
221
  if (inline)
192
222
  item.inline = inline;
@@ -205,6 +235,7 @@ const convertDefList = (el, ctx, tags = { entry: "dlentry", term: "dt", def: "dd
205
235
  const ref = entry.attribs.conref ?? entry.attribs.conkeyref;
206
236
  ctx.note(entry.attribs.conref ? "@conref" : "@conkeyref", `<${tags.entry}> pulls content from "${ref}" — ${conrefLedgerDetail(entry.attribs, undefined, ctx.conref !== undefined)}`);
207
237
  }
238
+ let entryAnchored = false;
208
239
  for (const part of entry.children.filter(isElement)) {
209
240
  const name = part.name.toLowerCase();
210
241
  if (name === tags.term) {
@@ -213,8 +244,10 @@ const convertDefList = (el, ctx, tags = { entry: "dlentry", term: "dt", def: "dd
213
244
  const viaConref = constrainedConrefInline(part, ctx);
214
245
  children.push({
215
246
  type: "dt",
216
- inline: viaConref ?? trimInline(convertInline(part.children, ctx)),
247
+ // The entry's id anchors its first term only (several terms would repeat it)
248
+ inline: withInlineAnchor(viaConref ?? trimInline(convertInline(part.children, ctx)), [entryAnchored ? undefined : entry.attribs?.id, part.attribs?.id], ctx),
217
249
  });
250
+ entryAnchored = true;
218
251
  }
219
252
  else if (name === tags.def) {
220
253
  const viaConref = constrainedConrefInline(part, ctx);
@@ -223,6 +256,9 @@ const convertDefList = (el, ctx, tags = { entry: "dlentry", term: "dt", def: "dd
223
256
  continue;
224
257
  }
225
258
  const { inline, children: blocks } = mixedContent(part, { ...ctx, constrained: true });
259
+ // Publishing: an empty definition (all its content filtered out) renders nothing
260
+ if (ctx.publish && !inline && blocks.length === 0)
261
+ continue;
226
262
  const dd = { type: "dd" };
227
263
  if (inline)
228
264
  dd.inline = inline;
@@ -234,31 +270,85 @@ const convertDefList = (el, ctx, tags = { entry: "dlentry", term: "dt", def: "dd
234
270
  }
235
271
  return { type: "deflist", children };
236
272
  };
273
+ /**
274
+ * A CALS entry's column and row spans: `morerows`, and the columns from
275
+ * `namest` to `nameend` by their `colspec` positions. DITA-OT's
276
+ * preprocessing also writes the column span as `dita-ot:morecols`.
277
+ */
278
+ const cellSpans = (cellEl, rowEl) => {
279
+ const spans = {};
280
+ const morerows = Number(cellEl.attribs?.morerows);
281
+ if (morerows > 0)
282
+ spans.rowspan = morerows + 1;
283
+ const morecols = Number(cellEl.attribs?.["dita-ot:morecols"]);
284
+ if (morecols > 0) {
285
+ spans.colspan = morecols + 1;
286
+ }
287
+ else if (cellEl.attribs?.namest && cellEl.attribs?.nameend) {
288
+ const tgroup = rowEl.parent?.parent;
289
+ const columns = new Map();
290
+ (tgroup?.children ?? [])
291
+ .filter((c) => isElement(c) && c.name.toLowerCase() === "colspec")
292
+ .reduce((previous, spec) => {
293
+ // A colspec without colnum is the column after the previous one (CALS)
294
+ const colnum = Number(spec.attribs?.colnum) || previous + 1;
295
+ if (spec.attribs?.colname)
296
+ columns.set(spec.attribs.colname, colnum);
297
+ return colnum;
298
+ }, 0);
299
+ const start = columns.get(cellEl.attribs.namest);
300
+ const end = columns.get(cellEl.attribs.nameend);
301
+ if (start && end && end > start)
302
+ spans.colspan = end - start + 1;
303
+ }
304
+ return spans.colspan || spans.rowspan ? spans : undefined;
305
+ };
306
+ const TABLE_PARTS = new Set([
307
+ "tgroup", "thead", "tbody", "row", "entry", "sthead", "strow", "stentry", "chhead", "chrow", "colspec", "title",
308
+ ]);
309
+ const hasClassToken = (el, token) => ` ${el.attribs?.class ?? ""} `.includes(` ${token} `);
310
+ const elementChildren = (el) => el.children.filter(isElement);
311
+ const PROPERTY_KINDS = ["proptype", "propvalue", "propdesc"];
312
+ /** A properties table cell's column: `proptype`, or with suffix "hd" a header cell (`proptypehd`). */
313
+ const propertyKind = (cell, suffix = "") => PROPERTY_KINDS.find((k) => hasClassToken(cell, `reference/${k}${suffix}`));
314
+ /** A properties table's columns in use, its properties that have cells, and its header row. */
315
+ const propertiesColumns = (el) => {
316
+ const header = elementChildren(el).find((c) => hasClassToken(c, "reference/prophead"));
317
+ const propertyRows = elementChildren(el).filter((c) => hasClassToken(c, "reference/property") && elementChildren(c).some((cell) => !!propertyKind(cell)));
318
+ const used = PROPERTY_KINDS.filter((k) => propertyRows.some((r) => elementChildren(r).some((c) => propertyKind(c) === k)) ||
319
+ (header ? elementChildren(header).some((c) => propertyKind(c, "hd") === k) : false));
320
+ return { used, propertyRows, ...(header ? { header } : {}) };
321
+ };
237
322
  /** CALS <table> and <simpletable> → table/tr/td tree. */
238
323
  const convertTable = (el, ctx) => {
239
324
  const rows = [];
240
325
  const headRows = [];
241
- const convertRow = (rowEl, cellTags) => {
326
+ // Header cells: every cell of a header row; publishing also marks the key
327
+ // column of a simple table (@keycol, 1-based) or the first column of a
328
+ // CALS table with rowheader="firstcol", as DITA-OT's HTML does
329
+ const keycol = ctx.publish ? Number(el.attribs?.keycol ?? (el.attribs?.rowheader === "firstcol" ? 1 : 0)) || 0 : 0;
330
+ const convertRow = (rowEl, cellTags, header = false) => {
242
331
  // Row-level conref: a whole row can't splice as an include — ledger it
243
332
  if (rowEl.attribs?.conref || rowEl.attribs?.conkeyref) {
244
333
  const ref = rowEl.attribs.conref ?? rowEl.attribs.conkeyref;
245
334
  ctx.note(rowEl.attribs.conref ? "@conref" : "@conkeyref", `<${rowEl.name.toLowerCase()}> pulls content from "${ref}" — ${conrefLedgerDetail(rowEl.attribs, undefined, ctx.conref !== undefined)}`);
246
335
  }
247
336
  const cells = rowEl.children
248
- .filter((c) => isElement(c) && cellTags.has(c.name.toLowerCase()))
249
- .map((cellEl) => {
250
- for (const attr of ["namest", "nameend", "morerows"]) {
251
- if (cellEl.attribs?.[attr]) {
252
- ctx.note(`@${attr}`, "table cell spans are not converted — cell emitted without span");
253
- }
337
+ .filter((c) => isElement(c) && cellTags.has(elementName(c, ctx, (n) => cellTags.has(n))))
338
+ .map((cellEl, index) => {
339
+ const cellType = header || index + 1 === keycol ? "th" : "td";
340
+ const spans = cellSpans(cellEl, rowEl);
341
+ if (cellEl.attribs?.namest && cellEl.attribs?.nameend && !spans?.colspan) {
342
+ ctx.note("@namest", `column span ${cellEl.attribs.namest}–${cellEl.attribs.nameend} has no matching colspec — cell emitted without its column span`);
254
343
  }
255
344
  // Cell-level conref: single-line snippets splice as an inline
256
345
  // include; anything else is ledgered
257
346
  const cellConref = constrainedConrefInline(cellEl, ctx);
258
347
  if (cellConref) {
259
348
  return {
260
- type: "td",
349
+ type: cellType,
261
350
  children: [{ type: "paragraph", inline: cellConref }],
351
+ ...(spans ? { props: spans } : {}),
262
352
  };
263
353
  }
264
354
  // Cell content always lives in children (leading inline wrapped as a
@@ -269,9 +359,11 @@ const convertTable = (el, ctx) => {
269
359
  if (inline)
270
360
  cellChildren.push({ type: "paragraph", inline });
271
361
  cellChildren.push(...children);
272
- const cell = { type: "td" };
362
+ const cell = { type: cellType };
273
363
  if (cellChildren.length > 0)
274
364
  cell.children = cellChildren;
365
+ if (spans)
366
+ cell.props = spans;
275
367
  return cell;
276
368
  });
277
369
  return { type: "tr", children: cells };
@@ -282,7 +374,8 @@ const convertTable = (el, ctx) => {
282
374
  const CHOICE_ROW_CELLS = new Set(["choption", "chdesc"]);
283
375
  const walk = (node) => {
284
376
  for (const child of node.children.filter(isElement)) {
285
- const name = child.name.toLowerCase();
377
+ // Specialized table parts (a properties table's prophead and property rows) by @class
378
+ const name = elementName(child, ctx, (n) => TABLE_PARTS.has(n));
286
379
  // Row-set reuse (conref on tgroup/thead/tbody) can't splice into a
287
380
  // markdown table — ledger it so the pulled rows are never silently
288
381
  // an empty table body
@@ -295,24 +388,24 @@ const convertTable = (el, ctx) => {
295
388
  walk(child);
296
389
  else if (name === "thead") {
297
390
  for (const row of child.children.filter(isElement)) {
298
- if (row.name.toLowerCase() === "row")
299
- headRows.push(convertRow(row, CALS_CELLS));
391
+ if (elementName(row, ctx, (n) => n === "row") === "row")
392
+ headRows.push(convertRow(row, CALS_CELLS, true));
300
393
  }
301
394
  }
302
395
  else if (name === "tbody") {
303
396
  for (const row of child.children.filter(isElement)) {
304
- if (row.name.toLowerCase() === "row")
397
+ if (elementName(row, ctx, (n) => n === "row") === "row")
305
398
  rows.push(convertRow(row, CALS_CELLS));
306
399
  }
307
400
  }
308
401
  else if (name === "sthead") {
309
- headRows.push(convertRow(child, SIMPLE_CELLS));
402
+ headRows.push(convertRow(child, SIMPLE_CELLS, true));
310
403
  }
311
404
  else if (name === "strow") {
312
405
  rows.push(convertRow(child, SIMPLE_CELLS));
313
406
  }
314
407
  else if (name === "chhead") {
315
- headRows.push(convertRow(child, CHOICE_HEAD_CELLS));
408
+ headRows.push(convertRow(child, CHOICE_HEAD_CELLS, true));
316
409
  }
317
410
  else if (name === "chrow") {
318
411
  rows.push(convertRow(child, CHOICE_ROW_CELLS));
@@ -326,16 +419,63 @@ const convertTable = (el, ctx) => {
326
419
  }
327
420
  };
328
421
  walk(el);
422
+ // Publishing: a properties table's columns are the type, value and description columns
423
+ // its header or any property uses (DITA-OT's hasType, hasValue, hasDesc); each row
424
+ // aligns its cells to them, a property with no cells is dropped, and a header cell
425
+ // missing for a column in use is DITA-OT's generated label ("Type", "Value", "Description")
426
+ if (ctx.publish && hasClassToken(el, "reference/properties")) {
427
+ const { used, propertyRows, header } = propertiesColumns(el);
428
+ rows.length = 0;
429
+ for (const row of propertyRows) {
430
+ const converted = convertRow(row, SIMPLE_CELLS);
431
+ const cellEls = elementChildren(row).filter((c) => !!propertyKind(c));
432
+ rows.push({
433
+ type: "tr",
434
+ children: used.map((k) => {
435
+ const i = cellEls.findIndex((c) => propertyKind(c) === k);
436
+ return i >= 0 ? converted.children[i] : { type: "td" };
437
+ }),
438
+ });
439
+ }
440
+ const lang = publishLanguage(el, ctx.publish);
441
+ const label = {
442
+ proptype: publishString(ctx.publish, lang, "Type"),
443
+ propvalue: publishString(ctx.publish, lang, "Value"),
444
+ propdesc: publishString(ctx.publish, lang, "Description"),
445
+ };
446
+ const authored = header ? convertRow(header, SIMPLE_CELLS, true) : undefined;
447
+ const headerCells = header ? elementChildren(header).filter((c) => !!propertyKind(c, "hd")) : [];
448
+ headRows.length = 0;
449
+ headRows.push({
450
+ type: "tr",
451
+ children: used.map((k) => {
452
+ const i = headerCells.findIndex((c) => propertyKind(c, "hd") === k);
453
+ return i >= 0 && authored ? authored.children[i] : { type: "th", children: [{ type: "paragraph", inline: [{ type: "text", text: label[k] }] }] };
454
+ }),
455
+ });
456
+ }
457
+ // Publishing: DITA-OT generates the "Option | Description" header of a
458
+ // choice table that has none
459
+ if (ctx.publish && el.name.toLowerCase() === "choicetable" && headRows.length === 0) {
460
+ const th = (text) => ({
461
+ type: "th",
462
+ children: [{ type: "paragraph", inline: [{ type: "text", text }] }],
463
+ });
464
+ const lang = publishLanguage(el, ctx.publish);
465
+ headRows.push({ type: "tr", children: [th(publishString(ctx.publish, lang, "Option")), th(publishString(ctx.publish, lang, "Description"))] });
466
+ }
329
467
  const table = {
330
468
  type: "table",
331
469
  children: [
332
470
  ...(headRows.length > 0 ? [{ type: "thead", children: headRows }] : []),
333
471
  { type: "tbody", children: rows },
334
472
  ],
473
+ // Publishing: a table without a header row has none (DITA-OT does not promote a row)
474
+ ...(ctx.publish && headRows.length === 0 ? { meta: { headerless: true } } : {}),
335
475
  };
336
476
  const titleEl = el.children
337
477
  .filter(isElement)
338
- .find((c) => c.name.toLowerCase() === "title");
478
+ .find((c) => blockName(c, ctx) === "title");
339
479
  if (titleEl) {
340
480
  // Caption is a plain-string prop — a conref'd title can't carry an
341
481
  // include directive, so ledger it instead of emitting empty text
@@ -343,7 +483,7 @@ const convertTable = (el, ctx) => {
343
483
  const ref = titleEl.attribs.conref ?? titleEl.attribs.conkeyref;
344
484
  ctx.note(titleEl.attribs.conref ? "@conref" : "@conkeyref", `<title> (table caption) pulls content from "${ref}" — ${conrefLedgerDetail(titleEl.attribs, undefined, ctx.conref !== undefined)}`);
345
485
  }
346
- table.props = { caption: textOf(titleEl.children).trim() };
486
+ table.props = { caption: captionText(titleEl, ctx, el) };
347
487
  }
348
488
  return table;
349
489
  };
@@ -395,16 +535,36 @@ const convertSteps = (el, ctx, type) => {
395
535
  const viaConref = itemConref(stepEl, ctx, "step") ?? definingItemNode(stepEl, ctx, "step");
396
536
  if (viaConref)
397
537
  return withProvenance(viaConref, stepEl);
398
- const { inline, children: blocks } = convertStepContent(stepEl, { ...ctx, constrained: true });
538
+ const { inline: content, children: blocks } = convertStepContent(stepEl, { ...ctx, constrained: true });
539
+ // Publishing: DITA-OT labels optional and required steps
540
+ const importance = stepEl.attribs?.importance;
541
+ const stepLabel = ctx.publish && (importance === "optional" || importance === "required")
542
+ ? publishLabel(ctx.publish, publishLanguage(stepEl, ctx.publish), importance === "optional" ? "Optional" : "Required")
543
+ : undefined;
544
+ const labelled = stepLabel
545
+ ? [{ type: "text", text: stepLabel, bold: true }, { type: "text", text: " " }, ...(content ?? [])]
546
+ : content;
547
+ const { inline, children } = withItemAnchor(labelled, blocks, [stepEl.attribs?.id], ctx);
399
548
  const step = { type: "step" };
400
549
  if (inline)
401
550
  step.inline = inline;
402
- if (blocks.length > 0)
403
- step.children = blocks;
551
+ if (children.length > 0)
552
+ step.children = children;
404
553
  return withProvenance(step, stepEl);
405
554
  });
406
555
  return { type, children };
407
556
  };
557
+ /**
558
+ * Publishing: a task with one step renders it as a paragraph and its
559
+ * content, not a one-item list, as DITA-OT's HTML does.
560
+ */
561
+ const publishSteps = (el, ctx, type) => {
562
+ const steps = convertSteps(el, ctx, type);
563
+ const only = steps.children?.length === 1 ? steps.children[0] : undefined;
564
+ if (!ctx.publish || type !== "steps" || !only || only.type !== "step")
565
+ return [withProvenance(steps, el)];
566
+ return [...(only.inline ? [{ type: "paragraph", inline: only.inline }] : []), ...(only.children ?? [])];
567
+ };
408
568
  /**
409
569
  * Standalone content of an item-level element (<li>, <step>, …) for
410
570
  * conref snippet extraction: leading inline as a paragraph, block
@@ -426,16 +586,186 @@ const TASK_SECTION_HEADINGS = {
426
586
  postreq: "What to do next",
427
587
  tasktroubleshooting: "Troubleshooting",
428
588
  };
589
+ /** Publishing: where a topic's related links go (see publishDitaTemp). */
590
+ export const PUBLISH_RELATED_LINKS_MARKER = "<!-- dogsbay:related-links -->";
591
+ /** Publishing: where a topic's required links go, at the start of its body (see publishDitaTemp). */
592
+ export const PUBLISH_PREREQUISITES_MARKER = /<!-- dogsbay:prerequisites:([^ ]*) -->/g;
593
+ const prerequisitesMarker = (topicId) => `<!-- dogsbay:prerequisites:${topicId} -->`;
594
+ /** DITA-OT's string ids for the task labels. */
595
+ const TASK_LABEL_IDS = {
596
+ prereq: "task_prereq", context: "task_context", steps: "task_procedure",
597
+ "steps-unordered": "task_procedure_unordered", result: "task_results", postreq: "task_postreq",
598
+ };
599
+ const publishTaskLabel = (name, ctx, el) => ctx.publish?.taskLabels && TASK_LABEL_IDS[name]
600
+ ? [{ type: "heading", props: { level: ctx.headingLevel }, inline: [{ type: "text", text: publishString(ctx.publish, publishLanguage(el ?? null, ctx.publish), TASK_LABEL_IDS[name]) }] }]
601
+ : [];
429
602
  /** Block-level DITA elements (used to split mixed content). */
430
603
  const BLOCK_ELEMENTS = new Set([
431
604
  "p", "shortdesc", "note", "codeblock", "pre", "screen", "msgblock", "ul", "ol", "sl", "dl", "parml", "table", "simpletable",
432
605
  "fig", "image", "section", "example", "steps", "steps-unordered", "substeps", "lq", "object",
433
- "prereq", "context", "result", "postreq", "tasktroubleshooting", "choices", "choicetable",
606
+ "prereq", "context", "result", "postreq", "tasktroubleshooting", "choices", "choicetable", "syntaxdiagram",
434
607
  ]);
608
+ const SYNTAX_GROUPS = new Set(["groupseq", "groupchoice", "groupcomp"]);
609
+ /**
610
+ * Publishing: a syntax diagram's inline form, as DITA-OT's HTML writes it
611
+ * (syntax-braces.xsl): keywords bold code, operators code, variables
612
+ * italic, `[ ]` around optional parts, `{ a | b }` for choices.
613
+ */
614
+ const syntaxInline = (el, ctx) => {
615
+ const out = [];
616
+ const text = (t) => void out.push({ type: "text", text: t });
617
+ for (const child of el.children) {
618
+ if (child.type === "text") {
619
+ text(child.data.replace(/\s+/g, " "));
620
+ continue;
621
+ }
622
+ if (!isElement(child))
623
+ continue;
624
+ const name = blockName(child, ctx) === "syntaxdiagram" ? "syntaxdiagram" : elementName(child, ctx, (n) => SYNTAX_GROUPS.has(n) || ["kwd", "var", "oper", "delim", "sep", "fragref", "repsep", "synnote", "synnoteref", "title"].includes(n));
625
+ const inChoice = elementName(el, ctx, (n) => SYNTAX_GROUPS.has(n)) === "groupchoice";
626
+ const hasPreceding = (child.prev ? el.children.slice(0, el.children.indexOf(child)).some(isElement) : false);
627
+ const optional = child.attribs?.importance === "optional";
628
+ const choiceBar = inChoice && hasPreceding ? " | " : "";
629
+ const value = collapseText(child);
630
+ if (SYNTAX_GROUPS.has(name)) {
631
+ text(`${choiceBar ? " |" : ""}${optional ? " [" : ""}${name === "groupchoice" ? " {" : ""} `);
632
+ out.push(...syntaxInline(child, ctx));
633
+ text(` ${name === "groupchoice" ? "} " : ""}${optional ? "] " : ""}`);
634
+ }
635
+ else if (name === "kwd") {
636
+ text(choiceBar);
637
+ out.push({ type: "code", text: `${optional ? "[" : ""}${value}${optional ? "]" : ""}`, bold: true });
638
+ text(" ");
639
+ }
640
+ else if (name === "var") {
641
+ // DITA-OT writes the choice bar and brackets inside the variable's italics
642
+ text(" ");
643
+ out.push({ type: "text", text: `${choiceBar ? "| " : ""}${optional ? "[" : ""}${value}${optional ? "]" : ""}`, italic: true });
644
+ text(" ");
645
+ }
646
+ else if (name === "oper") {
647
+ text(choiceBar);
648
+ out.push({ type: "code", text: value });
649
+ }
650
+ else if (name === "fragref") {
651
+ // DITA-OT links a fragment reference to the fragment by its title text
652
+ out.push({ type: "link", href: `#${fragmentSlug(value)}`, children: [{ type: "code", text: `<${value}>` }] });
653
+ }
654
+ else if (name === "synnote" && ctx.publish && ctx.footnotes) {
655
+ // A syntax diagram note is a footnote (DITA-OT lists them at the end of the page)
656
+ const label = String(ctx.footnotes.filter((f) => !f.callout).length + 1);
657
+ ctx.footnotes.push({ label, el: child });
658
+ out.push({ type: "footnote-ref", label });
659
+ }
660
+ else if (name === "delim" || name === "sep") {
661
+ text(value);
662
+ }
663
+ }
664
+ return out;
665
+ };
666
+ /** The anchor of a syntax diagram fragment, from its title text (what a fragref names). */
667
+ const fragmentSlug = (title) => `fragment-${title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`;
668
+ const collapseText = (el) => textOf(el.children).replace(/\s+/g, " ").trim();
669
+ const convertSyntaxDiagram = (el, ctx) => {
670
+ const out = [];
671
+ const groupName = (c) => elementName(c, ctx, (n) => SYNTAX_GROUPS.has(n) || n === "fragment" || n === "title");
672
+ for (const child of el.children.filter(isElement)) {
673
+ const name = groupName(child);
674
+ if (name === "title") {
675
+ out.push({ type: "heading", props: { level: Math.min(6, ctx.headingLevel + 1) }, inline: trimInline(convertInline(child.children, ctx)) });
676
+ }
677
+ else if (name === "fragment") {
678
+ for (const part of child.children.filter(isElement)) {
679
+ if (groupName(part) === "title") {
680
+ out.push({ type: "heading", props: { level: Math.min(6, ctx.headingLevel + 2), id: fragmentSlug(collapseText(part)) }, inline: trimInline(convertInline(part.children, ctx)) });
681
+ }
682
+ else if (SYNTAX_GROUPS.has(groupName(part))) {
683
+ const inline = trimInline(syntaxInline({ ...child, children: [part] }, ctx));
684
+ if (inline.length)
685
+ out.push({ type: "blockquote", children: [{ type: "paragraph", inline }] });
686
+ }
687
+ }
688
+ }
689
+ else if (SYNTAX_GROUPS.has(name)) {
690
+ const inline = trimInline(syntaxInline({ ...el, children: [child] }, ctx));
691
+ if (inline.length)
692
+ out.push({ type: "paragraph", inline });
693
+ }
694
+ }
695
+ return out;
696
+ };
697
+ /** Element names convertBlock handles, for publishing's `@class` fallback. */
698
+ const isKnownBlock = (name) => BLOCK_ELEMENTS.has(name) || TRANSPARENT_CONTAINERS.has(name) || name === "foreign" || name === "unknown" || name === "required-cleanup" ||
699
+ ["title", "prolog", "related-links", "draft-comment", "data", "indexterm", "substeps", "sli", "li", "dlentry", "dt", "dd", "syntaxdiagram"].includes(name);
700
+ /** The name to convert a block as (see elementName). */
701
+ const blockName = (el, ctx) => elementName(el, ctx, isKnownBlock);
435
702
  /** Whether an element name converts as a standalone block (conref-extractable). */
436
703
  export const isBlockTarget = (name) => BLOCK_ELEMENTS.has(name);
704
+ /** Elements whose @id already becomes an anchor (a heading id) or never is a link target. */
705
+ const ANCHORED_ELSEWHERE = new Set(["title", "related-links", "prolog", "data", "indexterm", "draft-comment", "required-cleanup", "foreign", "unknown"]);
706
+ /**
707
+ * Publishing: an element with an @id gets an anchor before its blocks, so
708
+ * cross-references to paragraphs, notes, tables and figures land, as they do on
709
+ * DITA-OT's pages. (Headings carry their own id.)
710
+ */
437
711
  export function convertBlock(el, ctx) {
438
- const name = el.name.toLowerCase();
712
+ const out = convertBlockContent(el, ctx);
713
+ const id = el.attribs?.id;
714
+ if (!ctx.publish || !id || ANCHORED_ELSEWHERE.has(blockName(el, ctx)))
715
+ return out;
716
+ const anchor = anchorId(ctx, id);
717
+ // An empty paragraph or division still keeps its id (DITA-OT writes the empty element)
718
+ if (out.length === 0)
719
+ return KEEPS_EMPTY.has(blockName(el, ctx)) ? [{ type: "html", html: idAnchor(anchor) }] : out;
720
+ // A titled section's heading carries the id
721
+ if (out.some((n) => n.type === "heading" && n.props?.id === anchor))
722
+ return out;
723
+ // Before a paragraph, the anchor is the start of its text (a separate block would be a paragraph of its own)
724
+ if (out[0].type === "paragraph" && out[0].inline) {
725
+ return [{ ...out[0], inline: [{ type: "html-inline", html: idAnchor(anchor) }, ...out[0].inline] }, ...out.slice(1)];
726
+ }
727
+ return [{ type: "html", html: idAnchor(anchor) }, ...out];
728
+ }
729
+ /** Elements DITA-OT's HTML writes, with their id, even when they have no content. */
730
+ const KEEPS_EMPTY = new Set(["p", "div", "bodydiv", "sectiondiv", "section", "example"]);
731
+ /** Publishing: the anchor elements for ids (none outside publishing). */
732
+ const anchorNodes = (ids, ctx) => ctx.publish ? ids.filter((id) => !!id).map((id) => ({ type: "html-inline", html: idAnchor(anchorId(ctx, id)) })) : [];
733
+ /** Inline anchors for an item's @id (terms), first in its text. */
734
+ const withInlineAnchor = (inline, ids, ctx) => {
735
+ const anchors = anchorNodes(ids, ctx);
736
+ return anchors.length ? [...anchors, ...(inline ?? [])] : inline;
737
+ };
738
+ /** Anchors at the start of the first paragraph in blocks (inside a note or quotation too), or undefined when none starts them. */
739
+ const prependToFirstParagraph = (blocks, anchors) => {
740
+ const [first, ...rest] = blocks;
741
+ if (!first)
742
+ return undefined;
743
+ if (first.type === "paragraph" && first.inline)
744
+ return [{ ...first, inline: [...anchors, ...first.inline] }, ...rest];
745
+ if ((first.type === "callout" || first.type === "blockquote") && first.children) {
746
+ const inner = prependToFirstParagraph(first.children, anchors);
747
+ return inner && [{ ...first, children: inner }, ...rest];
748
+ }
749
+ return undefined;
750
+ };
751
+ /**
752
+ * Anchors for an item's @id (list items, steps, definitions): first in its text, or, when
753
+ * the item has only blocks, in its first paragraph (an anchor alone would add an empty
754
+ * paragraph to the item).
755
+ */
756
+ const withItemAnchor = (inline, children, ids, ctx) => {
757
+ const anchors = anchorNodes(ids, ctx);
758
+ if (anchors.length === 0)
759
+ return { inline, children };
760
+ if (!inline?.length) {
761
+ const anchored = prependToFirstParagraph(children, anchors);
762
+ if (anchored)
763
+ return { inline, children: anchored };
764
+ }
765
+ return { inline: [...anchors, ...(inline ?? [])], children };
766
+ };
767
+ function convertBlockContent(el, ctx) {
768
+ const name = blockName(el, ctx);
439
769
  noteFilterAttrs(el, ctx);
440
770
  // Defining site: this element was extracted into a reuse snippet — emit
441
771
  // the include here too, so exactly ONE copy of the text survives
@@ -487,8 +817,16 @@ export function convertBlock(el, ctx) {
487
817
  }
488
818
  }
489
819
  if (TRANSPARENT_CONTAINERS.has(name)) {
820
+ // Publishing: a topic body starts with the topic's "Prerequisites" links, when it has any
821
+ const topicId = el.parent?.attribs?.id;
822
+ if (ctx.publish && name.endsWith("body") && topicId && !name.includes("div")) {
823
+ return [{ type: "html", html: prerequisitesMarker(topicId) }, ...convertChildren(el, ctx)];
824
+ }
490
825
  return convertChildren(el, ctx);
491
826
  }
827
+ // Publishing: DITA-OT's HTML shows none of these (MathML needs a plugin; cleanup needs args.draft)
828
+ if (ctx.publish && (name === "foreign" || name === "unknown" || name === "required-cleanup"))
829
+ return [];
492
830
  switch (name) {
493
831
  case "p":
494
832
  case "shortdesc": {
@@ -504,7 +842,11 @@ export function convertBlock(el, ctx) {
504
842
  return out;
505
843
  }
506
844
  case "note": {
507
- const type = NOTE_TYPE_MAP[el.attribs?.type ?? "note"] ?? "note";
845
+ const authored = el.attribs?.type ?? "note";
846
+ // Publishing keeps every type dogsbay-md renders (caution, danger)
847
+ const type = ctx.publish && PUBLISH_CALLOUT_TYPES.has(authored)
848
+ ? authored
849
+ : NOTE_TYPE_MAP[authored] ?? (ctx.publish ? PUBLISH_NOTE_TYPE_MAP[authored] : undefined) ?? "note";
508
850
  if (el.attribs?.type && !NOTE_TYPE_MAP[el.attribs.type]) {
509
851
  ctx.note("@type", `<note type="${el.attribs.type}"> mapped to plain note callout`);
510
852
  }
@@ -521,7 +863,12 @@ export function convertBlock(el, ctx) {
521
863
  case "pre":
522
864
  case "screen":
523
865
  case "msgblock": {
524
- const language = el.attribs?.outputclass ?? "";
866
+ const outputclass = el.attribs?.outputclass ?? "";
867
+ // Publishing: DITA-OT's language classes (language-xml, syntax-bash)
868
+ // name the language; other output classes are not languages
869
+ const language = ctx.publish
870
+ ? (/(?:^|\s)(?:language|syntax)-(\S+)/.exec(outputclass)?.[1] ?? "")
871
+ : outputclass;
525
872
  const content = textOf(el.children).replace(/^\n/, "").replace(/\s+$/, "");
526
873
  // Content in BOTH html and props.code: the serializer's block path
527
874
  // reads html, its HTML-table path (code inside cells) reads props.code
@@ -558,14 +905,35 @@ export function convertBlock(el, ctx) {
558
905
  return [withProvenance(convertDefList(el, ctx, { entry: "plentry", term: "pt", def: "pd" }), el)];
559
906
  case "table":
560
907
  case "simpletable":
908
+ // Publishing: DITA-OT drops a properties table none of whose properties has a cell
909
+ if (ctx.publish && hasClassToken(el, "reference/properties") && propertiesColumns(el).propertyRows.length === 0)
910
+ return [];
561
911
  return [withProvenance(convertTable(el, ctx), el)];
912
+ case "syntaxdiagram":
913
+ if (ctx.publish)
914
+ return convertSyntaxDiagram(el, ctx);
915
+ // Migration: as any unrecognized block
916
+ {
917
+ const children = convertChildren(el, ctx);
918
+ ctx.note(`<${name}>`, children.length > 0
919
+ ? "unrecognized block element — children kept, wrapper dropped"
920
+ : "unrecognized block element dropped");
921
+ return children;
922
+ }
562
923
  case "fig": {
563
924
  const titleEl = el.children
564
925
  .filter(isElement)
565
- .find((c) => c.name.toLowerCase() === "title");
926
+ .find((c) => blockName(c, ctx) === "title");
566
927
  const imageEl = el.children
567
928
  .filter(isElement)
568
929
  .find((c) => c.name.toLowerCase() === "image");
930
+ // Publishing: every figure converts its content (an image with its size, and anything else in it)
931
+ if (ctx.publish) {
932
+ const body = convertChildren(el, ctx, new Set(["title"]));
933
+ return titleEl
934
+ ? [withProvenance({ type: "figure", props: { caption: captionText(titleEl, ctx, el) }, children: body }, el)]
935
+ : body;
936
+ }
569
937
  if (imageEl) {
570
938
  const figure = {
571
939
  type: "figure",
@@ -581,7 +949,7 @@ export function convertBlock(el, ctx) {
581
949
  },
582
950
  ],
583
951
  ...(titleEl
584
- ? { props: { caption: textOf(titleEl.children).trim() } }
952
+ ? { props: { caption: captionText(titleEl, ctx, el) } }
585
953
  : {}),
586
954
  };
587
955
  return [withProvenance(figure, el)];
@@ -609,6 +977,10 @@ export function convertBlock(el, ctx) {
609
977
  }
610
978
  return [];
611
979
  }
980
+ if (ctx.publish) {
981
+ const { alt, attrs } = publishImage(el, ctx.publish);
982
+ return [withProvenance({ type: "image", props: { src, alt: alt ?? "", ...(attrs ?? {}) } }, el)];
983
+ }
612
984
  const node = {
613
985
  type: "image",
614
986
  props: {
@@ -623,7 +995,7 @@ export function convertBlock(el, ctx) {
623
995
  const out = [];
624
996
  const titleEl = el.children
625
997
  .filter(isElement)
626
- .find((c) => c.name.toLowerCase() === "title");
998
+ .find((c) => blockName(c, ctx) === "title");
627
999
  if (titleEl) {
628
1000
  // <title conref=…/> — heading text pulled from elsewhere
629
1001
  const titleConref = constrainedConrefInline(titleEl, ctx);
@@ -633,16 +1005,17 @@ export function convertBlock(el, ctx) {
633
1005
  // cross-topic xref fragments (renamed to #elementid) must land
634
1006
  props: {
635
1007
  level: ctx.headingLevel,
636
- ...(el.attribs?.id ? { id: el.attribs.id } : {}),
1008
+ ...(el.attribs?.id ? { id: anchorId(ctx, el.attribs.id) } : {}),
637
1009
  },
638
1010
  inline: titleConref ?? trimInline(convertInline(titleEl.children, ctx)),
639
1011
  }, el));
640
1012
  }
641
- else if (name === "example") {
1013
+ else if (name === "example" && (!ctx.publish || (ctx.publish.taskLabels && el.parent && isElement(el.parent) && el.parent.name.toLowerCase() === "taskbody"))) {
1014
+ // Publishing: DITA-OT labels only an untitled task example, and only with task labels on
642
1015
  out.push({
643
1016
  type: "heading",
644
1017
  props: { level: ctx.headingLevel },
645
- inline: [{ type: "text", text: "Example" }],
1018
+ inline: [{ type: "text", text: ctx.publish ? publishString(ctx.publish, publishLanguage(el, ctx.publish), "task_example") : "Example" }],
646
1019
  });
647
1020
  }
648
1021
  const inner = { ...ctx, headingLevel: Math.min(6, ctx.headingLevel + 1) };
@@ -651,15 +1024,24 @@ export function convertBlock(el, ctx) {
651
1024
  }
652
1025
  case "steps":
653
1026
  case "steps-unordered":
654
- return [withProvenance(convertSteps(el, ctx, "steps"), el)];
1027
+ // Publishing: unordered steps are a bulleted list, as DITA-OT renders them
1028
+ if (ctx.publish && name === "steps-unordered") {
1029
+ const steps = convertSteps(el, ctx, "steps");
1030
+ const items = (steps.children ?? []).map((step) => ({ ...step, type: "list-item" }));
1031
+ return [...publishTaskLabel(name, ctx, el), withProvenance({ type: "unordered-list", children: items }, el)];
1032
+ }
1033
+ return [...publishTaskLabel(name, ctx, el), ...publishSteps(el, ctx, "steps")];
655
1034
  case "substeps":
656
1035
  return [withProvenance(convertSteps(el, ctx, "substeps"), el)];
657
1036
  case "context":
658
- return convertChildren(el, ctx);
1037
+ return [...publishTaskLabel(name, ctx, el), ...convertChildren(el, ctx)];
659
1038
  case "prereq":
660
1039
  case "result":
661
1040
  case "postreq":
662
1041
  case "tasktroubleshooting": {
1042
+ // Publishing: DITA-OT's HTML titles these sections only with task labels on
1043
+ if (ctx.publish)
1044
+ return [...publishTaskLabel(name, ctx, el), ...convertChildren(el, ctx)];
663
1045
  const heading = {
664
1046
  type: "heading",
665
1047
  props: { level: ctx.headingLevel },
@@ -678,13 +1060,28 @@ export function convertBlock(el, ctx) {
678
1060
  if (inline)
679
1061
  body.push({ type: "paragraph", inline });
680
1062
  body.push(...children);
1063
+ // Publishing: DITA-OT cites the quote's source (@reftitle, linked to @href)
1064
+ const cite = el.attribs?.reftitle ?? el.attribs?.href;
1065
+ if (ctx.publish && cite) {
1066
+ const text = { type: "text", text: cite, italic: true };
1067
+ // A link to a DITA topic goes to its page, as an xref does
1068
+ const href = el.attribs?.href;
1069
+ const external = !!href && (el.attribs?.scope === "external" || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href));
1070
+ const target = href && !external && /\.(dita|xml)(#|$)/i.test(href) ? publishHref(ctx.publish, href) : href;
1071
+ body.push({ type: "paragraph", inline: [target ? { type: "link", href: target, children: [text] } : text] });
1072
+ }
681
1073
  return [{ type: "blockquote", children: body }];
682
1074
  }
683
1075
  case "title":
684
1076
  // Titles are handled by their parents (topic/section/fig/table)
685
1077
  return [];
686
- case "prolog":
687
1078
  case "related-links":
1079
+ // Publishing: a placeholder where DITA-OT renders the topic's related
1080
+ // links (after its body, before nested topics); publishDitaTemp fills it
1081
+ if (ctx.publish)
1082
+ return [{ type: "html", html: PUBLISH_RELATED_LINKS_MARKER }];
1083
+ // falls through
1084
+ case "prolog":
688
1085
  ctx.note(`<${name}>`, "metadata/related-links dropped — links belong in the topic map (reltable design pending)");
689
1086
  return [];
690
1087
  case "draft-comment":
@@ -712,10 +1109,10 @@ export function convertChildren(el, ctx, skip = new Set()) {
712
1109
  };
713
1110
  for (const child of el.children) {
714
1111
  if (isElement(child)) {
715
- const name = child.name.toLowerCase();
1112
+ const name = blockName(child, ctx);
716
1113
  if (skip.has(name))
717
1114
  continue;
718
- if (BLOCK_ELEMENTS.has(name) || TRANSPARENT_CONTAINERS.has(name) || name === "title" ||
1115
+ if (isBlockChild(child, ctx) || TRANSPARENT_CONTAINERS.has(name) || name === "title" ||
719
1116
  name === "prolog" || name === "related-links" || name === "draft-comment") {
720
1117
  flushInline();
721
1118
  out.push(...convertBlock(child, ctx));
@@ -724,7 +1121,7 @@ export function convertChildren(el, ctx, skip = new Set()) {
724
1121
  // Inline element loose in a body: accumulate into a paragraph
725
1122
  inlineRun.push(child);
726
1123
  }
727
- else if (isNonEmptyText(child)) {
1124
+ else if (isNonEmptyText(child) || (child.type === "text" && inlineRun.length > 0)) {
728
1125
  inlineRun.push(child);
729
1126
  }
730
1127
  }