@dogsbay/format-dita 0.2.0-beta.101

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 ADDED
@@ -0,0 +1,734 @@
1
+ import { convertInline, trimInline, textOf, inlineSafeSnippet, conrefLedgerDetail, } from "./inline.js";
2
+ const isElement = (node) => node.type === "tag";
3
+ const isNonEmptyText = (node) => node.type === "text" && node.data.trim().length > 0;
4
+ /** DITA note @type → callout type in the dogsbay-md vocabulary. */
5
+ const NOTE_TYPE_MAP = {
6
+ note: "note",
7
+ tip: "tip",
8
+ important: "important",
9
+ warning: "warning",
10
+ caution: "warning",
11
+ danger: "danger",
12
+ attention: "warning",
13
+ remember: "note",
14
+ restriction: "important",
15
+ trouble: "warning",
16
+ };
17
+ /** Containers whose children are spliced through without a wrapper node. */
18
+ const TRANSPARENT_CONTAINERS = new Set([
19
+ "body", "conbody", "refbody", "taskbody", "glossdef", "abstract", "bodydiv", "sectiondiv", "div",
20
+ ]);
21
+ /** Children that carry no content — a conref shell with only these is empty. */
22
+ const METADATA_ONLY = new Set(["indexterm", "data", "draft-comment", "sort-as", "topicmeta"]);
23
+ const hasOwnContent = (el) => el.children.some((c) => (isElement(c) && !METADATA_ONLY.has(c.name.toLowerCase())) || isNonEmptyText(c));
24
+ /** '--' is illegal inside HTML comments (and '-->' would close early). */
25
+ const sanitizeCommentRef = (ref) => String(ref).replace(/-(?=-)/g, "- ");
26
+ const anchorHtml = (id) => `<a id="${id.replace(/"/g, "&quot;")}"></a>`;
27
+ /**
28
+ * The include directive for a snippet, wrapped in a scoped leveloffset
29
+ * when the snippet carries headings and the site's heading depth differs
30
+ * from the snippet's baked-in base level.
31
+ */
32
+ export const includeDirective = (snippet, site, headingLevel) => {
33
+ const include = `{% include "${site.includePath(snippet)}" %}`;
34
+ if (snippet.headingBase !== undefined && headingLevel !== snippet.headingBase) {
35
+ const delta = headingLevel - snippet.headingBase;
36
+ return `{% leveloffset ${delta >= 0 ? "+" : ""}${delta} %}${include}{% endleveloffset %}`;
37
+ }
38
+ return include;
39
+ };
40
+ const withProvenance = (node, el) => {
41
+ const id = el.attribs?.id;
42
+ if (id) {
43
+ node.meta = { ...(node.meta ?? {}), dita: { id } };
44
+ }
45
+ return node;
46
+ };
47
+ const noteFilterAttrs = (el, ctx) => {
48
+ for (const attr of ["audience", "platform", "product", "props", "otherprops", "rev"]) {
49
+ if (el.attribs?.[attr]) {
50
+ ctx.note(`@${attr}`, `<${el.name}> ${attr}="${el.attribs[attr]}" — map to a profile condition (ifdef) manually`);
51
+ }
52
+ }
53
+ };
54
+ const paragraphFrom = (nodes, ctx) => {
55
+ const inline = trimInline(convertInline(nodes, ctx));
56
+ if (inline.length === 0)
57
+ return null;
58
+ return { type: "paragraph", inline };
59
+ };
60
+ /**
61
+ * <li>/<entry>/<p>-style mixed content: a leading inline run, block
62
+ * children, and any inline runs BETWEEN or AFTER blocks (text and inline
63
+ * elements alike) wrapped as paragraphs — nothing is dropped.
64
+ */
65
+ const mixedContent = (el, ctx) => {
66
+ const blockNodes = [];
67
+ let leadingInline;
68
+ let sawBlock = false;
69
+ let run = [];
70
+ const flushRun = () => {
71
+ if (run.length === 0)
72
+ return;
73
+ const inline = trimInline(convertInline(run, ctx));
74
+ run = [];
75
+ if (inline.length === 0)
76
+ return;
77
+ if (!sawBlock) {
78
+ leadingInline = inline;
79
+ }
80
+ else {
81
+ blockNodes.push({ type: "paragraph", inline });
82
+ }
83
+ };
84
+ for (const child of el.children) {
85
+ if (isElement(child) && BLOCK_ELEMENTS.has(child.name.toLowerCase())) {
86
+ flushRun();
87
+ sawBlock = true;
88
+ blockNodes.push(...convertBlock(child, ctx));
89
+ continue;
90
+ }
91
+ if (isElement(child) || isNonEmptyText(child)) {
92
+ run.push(child);
93
+ }
94
+ }
95
+ flushRun();
96
+ return { inline: leadingInline, children: blockNodes };
97
+ };
98
+ /**
99
+ * Fallback-content check for item-level conref shells. Unlike blocks,
100
+ * items routinely carry DTD-required empty children (`<step conref>
101
+ * <cmd/></step>`), which are placeholders, not fallback content.
102
+ */
103
+ const itemHasFallback = (el) => el.children.some((c) => (isElement(c) &&
104
+ !METADATA_ONLY.has(c.name.toLowerCase()) &&
105
+ (c.name.toLowerCase() !== "cmd" || hasOwnContent(c))) ||
106
+ isNonEmptyText(c));
107
+ /**
108
+ * conref on a list item / step / substep / choice — these elements never
109
+ * pass through convertBlock, so without this hook a `<step conref=…/>`
110
+ * converted to an EMPTY step with no ledger note (silent loss). Item
111
+ * bodies serialize indented, so only single-line snippets can splice;
112
+ * everything else lands on the ledger, with a source-visible placeholder
113
+ * when the item has no fallback content.
114
+ */
115
+ const itemConref = (el, ctx, type) => {
116
+ if (!el.attribs?.conref && !el.attribs?.conkeyref)
117
+ return null;
118
+ const name = el.name.toLowerCase();
119
+ const snippet = el.attribs.conref && !el.attribs.conrefend && !el.attribs.conaction
120
+ ? ctx.conref?.resolve(el.attribs.conref)
121
+ : undefined;
122
+ if (snippet && inlineSafeSnippet(snippet)) {
123
+ return {
124
+ type,
125
+ inline: [{ type: "text", text: `{% include "${ctx.conref.includePath(snippet)}" %}` }],
126
+ };
127
+ }
128
+ const ref = el.attribs.conref ?? el.attribs.conkeyref;
129
+ ctx.note(el.attribs.conref ? "@conref" : "@conkeyref", `<${name}> pulls content from "${ref}" — ${conrefLedgerDetail(el.attribs, snippet, ctx.conref !== undefined)}`);
130
+ if (itemHasFallback(el))
131
+ return null;
132
+ return {
133
+ type,
134
+ children: [
135
+ {
136
+ type: "html",
137
+ html: `<!-- TODO(dita-${el.attribs.conref ? "conref" : "conkeyref"}): include ${sanitizeCommentRef(ref)} -->`,
138
+ },
139
+ ],
140
+ };
141
+ };
142
+ /**
143
+ * conref on a line-constrained PART (<cmd>, <dt>, <dd>, table cells,
144
+ * titles) — content that must land on a single output line. Returns the
145
+ * include as an inline run when the snippet can splice; ledgers and
146
+ * returns null otherwise; undefined when the part has no conref at all.
147
+ */
148
+ const constrainedConrefInline = (el, ctx) => {
149
+ if (!el.attribs?.conref && !el.attribs?.conkeyref)
150
+ return undefined;
151
+ const snippet = el.attribs.conref && !el.attribs.conrefend && !el.attribs.conaction
152
+ ? ctx.conref?.resolve(el.attribs.conref)
153
+ : undefined;
154
+ if (snippet && inlineSafeSnippet(snippet)) {
155
+ return [{ type: "text", text: `{% include "${ctx.conref.includePath(snippet)}" %}` }];
156
+ }
157
+ const ref = el.attribs.conref ?? el.attribs.conkeyref;
158
+ ctx.note(el.attribs.conref ? "@conref" : "@conkeyref", `<${el.name.toLowerCase()}> pulls content from "${ref}" — ${conrefLedgerDetail(el.attribs, snippet, ctx.conref !== undefined)}`);
159
+ return null;
160
+ };
161
+ /**
162
+ * Defining site for an extracted ITEM (<li>/<step>/<sli>): items never
163
+ * pass through convertBlock, so without this hook the warehouse item
164
+ * would keep its full text next to the snippet — two divergent copies.
165
+ * Single-line snippets replace the item; multi-line ones are ledgered
166
+ * (content kept) so the duplication is at least never silent.
167
+ */
168
+ const definingItemNode = (el, ctx, type) => {
169
+ if (!ctx.conref || !el.attribs?.id || el.attribs?.conref || el.attribs?.conkeyref)
170
+ return null;
171
+ const snippet = ctx.conref.definingSnippet(el.attribs.id);
172
+ if (!snippet)
173
+ return null;
174
+ if (inlineSafeSnippet(snippet)) {
175
+ return {
176
+ type,
177
+ inline: [{ type: "text", text: `{% include "${ctx.conref.includePath(snippet)}" %}` }],
178
+ };
179
+ }
180
+ ctx.note("@conref", `<${el.name.toLowerCase()} id="${el.attribs.id}"> is reused elsewhere via "${snippet.file}" but its content is multi-line — replace it with the include manually to keep one copy`);
181
+ return null;
182
+ };
183
+ const convertListItems = (el, ctx, itemTag) => el.children
184
+ .filter((c) => isElement(c) && c.name.toLowerCase() === itemTag)
185
+ .map((li) => {
186
+ const viaConref = itemConref(li, ctx, "list-item") ?? definingItemNode(li, ctx, "list-item");
187
+ if (viaConref)
188
+ return withProvenance(viaConref, li);
189
+ const { inline, children } = mixedContent(li, { ...ctx, constrained: true });
190
+ const item = { type: "list-item" };
191
+ if (inline)
192
+ item.inline = inline;
193
+ if (children.length > 0)
194
+ item.children = children;
195
+ return withProvenance(item, li);
196
+ });
197
+ const convertDefList = (el, ctx, tags = { entry: "dlentry", term: "dt", def: "dd" }) => {
198
+ const children = [];
199
+ for (const entry of el.children.filter(isElement)) {
200
+ if (entry.name.toLowerCase() !== tags.entry)
201
+ continue;
202
+ // Entry-level conref (<dlentry conref>): a term/definition pair can't
203
+ // splice as an include — ledger it so the loss is never silent
204
+ if (entry.attribs?.conref || entry.attribs?.conkeyref) {
205
+ const ref = entry.attribs.conref ?? entry.attribs.conkeyref;
206
+ ctx.note(entry.attribs.conref ? "@conref" : "@conkeyref", `<${tags.entry}> pulls content from "${ref}" — ${conrefLedgerDetail(entry.attribs, undefined, ctx.conref !== undefined)}`);
207
+ }
208
+ for (const part of entry.children.filter(isElement)) {
209
+ const name = part.name.toLowerCase();
210
+ if (name === tags.term) {
211
+ // Term-level conref (<dt conref>/<pt conref>): single-line
212
+ // include or ledger — never a silent empty term
213
+ const viaConref = constrainedConrefInline(part, ctx);
214
+ children.push({
215
+ type: "dt",
216
+ inline: viaConref ?? trimInline(convertInline(part.children, ctx)),
217
+ });
218
+ }
219
+ else if (name === tags.def) {
220
+ const viaConref = constrainedConrefInline(part, ctx);
221
+ if (viaConref) {
222
+ children.push({ type: "dd", inline: viaConref });
223
+ continue;
224
+ }
225
+ const { inline, children: blocks } = mixedContent(part, { ...ctx, constrained: true });
226
+ const dd = { type: "dd" };
227
+ if (inline)
228
+ dd.inline = inline;
229
+ if (blocks.length > 0)
230
+ dd.children = blocks;
231
+ children.push(dd);
232
+ }
233
+ }
234
+ }
235
+ return { type: "deflist", children };
236
+ };
237
+ /** CALS <table> and <simpletable> → table/tr/td tree. */
238
+ const convertTable = (el, ctx) => {
239
+ const rows = [];
240
+ const headRows = [];
241
+ const convertRow = (rowEl, cellTags) => {
242
+ // Row-level conref: a whole row can't splice as an include — ledger it
243
+ if (rowEl.attribs?.conref || rowEl.attribs?.conkeyref) {
244
+ const ref = rowEl.attribs.conref ?? rowEl.attribs.conkeyref;
245
+ ctx.note(rowEl.attribs.conref ? "@conref" : "@conkeyref", `<${rowEl.name.toLowerCase()}> pulls content from "${ref}" — ${conrefLedgerDetail(rowEl.attribs, undefined, ctx.conref !== undefined)}`);
246
+ }
247
+ 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
+ }
254
+ }
255
+ // Cell-level conref: single-line snippets splice as an inline
256
+ // include; anything else is ledgered
257
+ const cellConref = constrainedConrefInline(cellEl, ctx);
258
+ if (cellConref) {
259
+ return {
260
+ type: "td",
261
+ children: [{ type: "paragraph", inline: cellConref }],
262
+ };
263
+ }
264
+ // Cell content always lives in children (leading inline wrapped as a
265
+ // paragraph): the serializer's HTML-table path reads only children,
266
+ // and its pipe path reads children paragraphs too
267
+ const { inline, children } = mixedContent(cellEl, { ...ctx, constrained: true });
268
+ const cellChildren = [];
269
+ if (inline)
270
+ cellChildren.push({ type: "paragraph", inline });
271
+ cellChildren.push(...children);
272
+ const cell = { type: "td" };
273
+ if (cellChildren.length > 0)
274
+ cell.children = cellChildren;
275
+ return cell;
276
+ });
277
+ return { type: "tr", children: cells };
278
+ };
279
+ const CALS_CELLS = new Set(["entry"]);
280
+ const SIMPLE_CELLS = new Set(["stentry"]);
281
+ const CHOICE_HEAD_CELLS = new Set(["choptionhd", "chdeschd"]);
282
+ const CHOICE_ROW_CELLS = new Set(["choption", "chdesc"]);
283
+ const walk = (node) => {
284
+ for (const child of node.children.filter(isElement)) {
285
+ const name = child.name.toLowerCase();
286
+ // Row-set reuse (conref on tgroup/thead/tbody) can't splice into a
287
+ // markdown table — ledger it so the pulled rows are never silently
288
+ // an empty table body
289
+ if ((name === "tgroup" || name === "thead" || name === "tbody") &&
290
+ (child.attribs?.conref || child.attribs?.conkeyref)) {
291
+ const ref = child.attribs.conref ?? child.attribs.conkeyref;
292
+ ctx.note(child.attribs.conref ? "@conref" : "@conkeyref", `<${name}> pulls content from "${ref}" — ${conrefLedgerDetail(child.attribs, undefined, ctx.conref !== undefined)}`);
293
+ }
294
+ if (name === "tgroup")
295
+ walk(child);
296
+ else if (name === "thead") {
297
+ for (const row of child.children.filter(isElement)) {
298
+ if (row.name.toLowerCase() === "row")
299
+ headRows.push(convertRow(row, CALS_CELLS));
300
+ }
301
+ }
302
+ else if (name === "tbody") {
303
+ for (const row of child.children.filter(isElement)) {
304
+ if (row.name.toLowerCase() === "row")
305
+ rows.push(convertRow(row, CALS_CELLS));
306
+ }
307
+ }
308
+ else if (name === "sthead") {
309
+ headRows.push(convertRow(child, SIMPLE_CELLS));
310
+ }
311
+ else if (name === "strow") {
312
+ rows.push(convertRow(child, SIMPLE_CELLS));
313
+ }
314
+ else if (name === "chhead") {
315
+ headRows.push(convertRow(child, CHOICE_HEAD_CELLS));
316
+ }
317
+ else if (name === "chrow") {
318
+ rows.push(convertRow(child, CHOICE_ROW_CELLS));
319
+ }
320
+ else if (name === "colspec") {
321
+ // Column widths are presentation-only; dropped silently
322
+ }
323
+ else if (name === "title") {
324
+ // handled below via caption
325
+ }
326
+ }
327
+ };
328
+ walk(el);
329
+ const table = {
330
+ type: "table",
331
+ children: [
332
+ ...(headRows.length > 0 ? [{ type: "thead", children: headRows }] : []),
333
+ { type: "tbody", children: rows },
334
+ ],
335
+ };
336
+ const titleEl = el.children
337
+ .filter(isElement)
338
+ .find((c) => c.name.toLowerCase() === "title");
339
+ if (titleEl) {
340
+ // Caption is a plain-string prop — a conref'd title can't carry an
341
+ // include directive, so ledger it instead of emitting empty text
342
+ if (titleEl.attribs?.conref || titleEl.attribs?.conkeyref) {
343
+ const ref = titleEl.attribs.conref ?? titleEl.attribs.conkeyref;
344
+ ctx.note(titleEl.attribs.conref ? "@conref" : "@conkeyref", `<title> (table caption) pulls content from "${ref}" — ${conrefLedgerDetail(titleEl.attribs, undefined, ctx.conref !== undefined)}`);
345
+ }
346
+ table.props = { caption: textOf(titleEl.children).trim() };
347
+ }
348
+ return table;
349
+ };
350
+ /**
351
+ * One <step>/<substep>'s content: <cmd> as the leading inline run, the
352
+ * rest as block children. Shared by convertSteps (constrained context)
353
+ * and conref snippet extraction (standalone, unconstrained).
354
+ */
355
+ export const convertStepContent = (stepEl, ctx) => {
356
+ let stepInline;
357
+ const blocks = [];
358
+ for (const part of stepEl.children.filter(isElement)) {
359
+ const name = part.name.toLowerCase();
360
+ if (name === "cmd") {
361
+ // <cmd conref=…/> is the standard reused-command pattern: include
362
+ // when spliceable, ledger otherwise — never a silent empty step
363
+ const viaConref = constrainedConrefInline(part, ctx);
364
+ if (viaConref) {
365
+ stepInline = viaConref;
366
+ continue;
367
+ }
368
+ stepInline = trimInline(convertInline(part.children, ctx));
369
+ }
370
+ else if (name === "info" || name === "stepresult" || name === "stepxmp" || name === "steptroubleshooting") {
371
+ const { inline, children: inner } = mixedContent(part, ctx);
372
+ if (inline)
373
+ blocks.push({ type: "paragraph", inline });
374
+ blocks.push(...inner);
375
+ }
376
+ else if (name === "substeps") {
377
+ blocks.push(convertSteps(part, ctx, "substeps"));
378
+ }
379
+ else if (name === "choices" || name === "choicetable") {
380
+ ctx.note(`<${name}>`, "step choices converted as a plain list/table");
381
+ blocks.push(...convertBlock(part, ctx));
382
+ }
383
+ else {
384
+ blocks.push(...convertBlock(part, ctx));
385
+ }
386
+ }
387
+ return { inline: stepInline, children: blocks };
388
+ };
389
+ /** Task <steps>/<substeps> → steps/substeps nodes with step children. */
390
+ const convertSteps = (el, ctx, type) => {
391
+ const stepTag = type === "steps" ? "step" : "substep";
392
+ const children = el.children
393
+ .filter((c) => isElement(c) && c.name.toLowerCase() === stepTag)
394
+ .map((stepEl) => {
395
+ const viaConref = itemConref(stepEl, ctx, "step") ?? definingItemNode(stepEl, ctx, "step");
396
+ if (viaConref)
397
+ return withProvenance(viaConref, stepEl);
398
+ const { inline, children: blocks } = convertStepContent(stepEl, { ...ctx, constrained: true });
399
+ const step = { type: "step" };
400
+ if (inline)
401
+ step.inline = inline;
402
+ if (blocks.length > 0)
403
+ step.children = blocks;
404
+ return withProvenance(step, stepEl);
405
+ });
406
+ return { type, children };
407
+ };
408
+ /**
409
+ * Standalone content of an item-level element (<li>, <step>, …) for
410
+ * conref snippet extraction: leading inline as a paragraph, block
411
+ * children after.
412
+ */
413
+ export const convertItemContent = (el, ctx) => {
414
+ const name = el.name.toLowerCase();
415
+ const { inline, children } = name === "step" || name === "substep" ? convertStepContent(el, ctx) : mixedContent(el, ctx);
416
+ const out = [];
417
+ if (inline && inline.length > 0)
418
+ out.push({ type: "paragraph", inline });
419
+ out.push(...children);
420
+ return out;
421
+ };
422
+ /** Task sections that render as a heading + content. */
423
+ const TASK_SECTION_HEADINGS = {
424
+ prereq: "Before you begin",
425
+ result: "Result",
426
+ postreq: "What to do next",
427
+ tasktroubleshooting: "Troubleshooting",
428
+ };
429
+ /** Block-level DITA elements (used to split mixed content). */
430
+ const BLOCK_ELEMENTS = new Set([
431
+ "p", "shortdesc", "note", "codeblock", "pre", "screen", "msgblock", "ul", "ol", "sl", "dl", "parml", "table", "simpletable",
432
+ "fig", "image", "section", "example", "steps", "steps-unordered", "substeps", "lq", "object",
433
+ "prereq", "context", "result", "postreq", "tasktroubleshooting", "choices", "choicetable",
434
+ ]);
435
+ /** Whether an element name converts as a standalone block (conref-extractable). */
436
+ export const isBlockTarget = (name) => BLOCK_ELEMENTS.has(name);
437
+ export function convertBlock(el, ctx) {
438
+ const name = el.name.toLowerCase();
439
+ noteFilterAttrs(el, ctx);
440
+ // Defining site: this element was extracted into a reuse snippet — emit
441
+ // the include here too, so exactly ONE copy of the text survives
442
+ // migration (the DITA warehouse copy and every referent share the
443
+ // snippet). The element's @id must survive as a real anchor unless the
444
+ // snippet itself renders one (section/example heading {#id}).
445
+ if (ctx.conref && el.attribs?.id && !el.attribs?.conref && !el.attribs?.conkeyref) {
446
+ const defining = ctx.conref.definingSnippet(el.attribs.id);
447
+ if (defining && (!ctx.constrained || inlineSafeSnippet(defining))) {
448
+ const anchor = defining.carriesAnchor ? "" : anchorHtml(el.attribs.id);
449
+ return [
450
+ withProvenance({ type: "html", html: `${anchor}${includeDirective(defining, ctx.conref, ctx.headingLevel)}` }, el),
451
+ ];
452
+ }
453
+ if (defining) {
454
+ ctx.note("@conref", `<${name} id="${el.attribs.id}"> is reused elsewhere via "${defining.file}" but sits in a list/step/table cell — replace its content with the include manually`);
455
+ }
456
+ }
457
+ if (el.attribs?.conref || el.attribs?.conkeyref) {
458
+ const ref = el.attribs.conref ?? el.attribs.conkeyref;
459
+ const snippet = el.attribs.conref && !el.attribs.conrefend && !el.attribs.conaction
460
+ ? ctx.conref?.resolve(el.attribs.conref)
461
+ : undefined;
462
+ if (snippet && (!ctx.constrained || inlineSafeSnippet(snippet))) {
463
+ // Local children are the stale preview copy in DITA conref
464
+ // semantics — the include replaces the whole element. The
465
+ // referencing element's own @id (if any) still needs an anchor:
466
+ // links may target the referencing shell, not the snippet source.
467
+ const anchor = el.attribs?.id ? anchorHtml(el.attribs.id) : "";
468
+ return [
469
+ withProvenance({ type: "html", html: `${anchor}${includeDirective(snippet, ctx.conref, ctx.headingLevel)}` }, el),
470
+ ];
471
+ }
472
+ ctx.note(el.attribs.conref ? "@conref" : "@conkeyref", `<${name}> pulls content from "${ref}" — ${conrefLedgerDetail(el.attribs, snippet, ctx.conref !== undefined)}`);
473
+ // Content-less conref shells would render as empty blocks (an empty
474
+ // callout, an empty paragraph). Emit a visible source-level placeholder
475
+ // instead: invisible when rendered, obvious to the migrating writer.
476
+ // Metadata-only children (indexterm, data, …) convert to nothing, so
477
+ // they don't count as content; the @id (if any) must survive as both
478
+ // provenance and a real anchor, since links may target the shell.
479
+ if (!hasOwnContent(el)) {
480
+ const anchor = el.attribs?.id ? anchorHtml(el.attribs.id) : "";
481
+ return [
482
+ withProvenance({
483
+ type: "html",
484
+ html: `${anchor}<!-- TODO(dita-${el.attribs.conref ? "conref" : "conkeyref"}): include ${sanitizeCommentRef(ref)} -->`,
485
+ }, el),
486
+ ];
487
+ }
488
+ }
489
+ if (TRANSPARENT_CONTAINERS.has(name)) {
490
+ return convertChildren(el, ctx);
491
+ }
492
+ switch (name) {
493
+ case "p":
494
+ case "shortdesc": {
495
+ // DITA allows blocks (ul, note, pre, …) inside <p>: leading inline
496
+ // becomes the paragraph, block children follow as siblings
497
+ const { inline, children } = mixedContent(el, ctx);
498
+ const out = [];
499
+ if (inline)
500
+ out.push({ type: "paragraph", inline });
501
+ out.push(...children);
502
+ if (out.length > 0 && name === "p")
503
+ withProvenance(out[0], el);
504
+ return out;
505
+ }
506
+ case "note": {
507
+ const type = NOTE_TYPE_MAP[el.attribs?.type ?? "note"] ?? "note";
508
+ if (el.attribs?.type && !NOTE_TYPE_MAP[el.attribs.type]) {
509
+ ctx.note("@type", `<note type="${el.attribs.type}"> mapped to plain note callout`);
510
+ }
511
+ // Callouts serialize in the GitHub `> [!TYPE]` per-line-prefixed
512
+ // form — a constrained context for includes
513
+ const { inline, children } = mixedContent(el, { ...ctx, constrained: true });
514
+ const body = [];
515
+ if (inline)
516
+ body.push({ type: "paragraph", inline });
517
+ body.push(...children);
518
+ return [withProvenance({ type: "callout", props: { type }, children: body }, el)];
519
+ }
520
+ case "codeblock":
521
+ case "pre":
522
+ case "screen":
523
+ case "msgblock": {
524
+ const language = el.attribs?.outputclass ?? "";
525
+ const content = textOf(el.children).replace(/^\n/, "").replace(/\s+$/, "");
526
+ // Content in BOTH html and props.code: the serializer's block path
527
+ // reads html, its HTML-table path (code inside cells) reads props.code
528
+ const node = {
529
+ type: "code",
530
+ html: content,
531
+ props: { code: content, ...(language ? { language } : {}) },
532
+ };
533
+ return [withProvenance(node, el)];
534
+ }
535
+ case "ul":
536
+ return [withProvenance({ type: "unordered-list", children: convertListItems(el, ctx, "li") }, el)];
537
+ case "ol":
538
+ return [withProvenance({ type: "ordered-list", children: convertListItems(el, ctx, "li") }, el)];
539
+ case "sl": {
540
+ // Simple list → unordered list of <sli> items (same conref hooks
541
+ // as <li>: referencing items include or ledger, defining items
542
+ // hand their text to the snippet)
543
+ const items = el.children
544
+ .filter((c) => isElement(c) && c.name.toLowerCase() === "sli")
545
+ .map((sli) => {
546
+ const viaConref = itemConref(sli, ctx, "list-item") ?? definingItemNode(sli, ctx, "list-item");
547
+ if (viaConref)
548
+ return withProvenance(viaConref, sli);
549
+ const inline = trimInline(convertInline(sli.children, ctx));
550
+ return { type: "list-item", ...(inline.length > 0 ? { inline } : {}) };
551
+ });
552
+ return [withProvenance({ type: "unordered-list", children: items }, el)];
553
+ }
554
+ case "dl":
555
+ return [withProvenance(convertDefList(el, ctx), el)];
556
+ case "parml":
557
+ // Parameter list — structurally a definition list
558
+ return [withProvenance(convertDefList(el, ctx, { entry: "plentry", term: "pt", def: "pd" }), el)];
559
+ case "table":
560
+ case "simpletable":
561
+ return [withProvenance(convertTable(el, ctx), el)];
562
+ case "fig": {
563
+ const titleEl = el.children
564
+ .filter(isElement)
565
+ .find((c) => c.name.toLowerCase() === "title");
566
+ const imageEl = el.children
567
+ .filter(isElement)
568
+ .find((c) => c.name.toLowerCase() === "image");
569
+ if (imageEl) {
570
+ const figure = {
571
+ type: "figure",
572
+ children: [
573
+ {
574
+ type: "image",
575
+ props: {
576
+ src: imageEl.attribs?.href ?? "",
577
+ alt: imageEl.attribs?.alt ??
578
+ textOf(imageEl.children).trim() ??
579
+ "",
580
+ },
581
+ },
582
+ ],
583
+ ...(titleEl
584
+ ? { props: { caption: textOf(titleEl.children).trim() } }
585
+ : {}),
586
+ };
587
+ return [withProvenance(figure, el)];
588
+ }
589
+ // fig without image: treat as transparent container (keep the title as bold para)
590
+ const parts = [];
591
+ if (titleEl) {
592
+ parts.push({
593
+ type: "paragraph",
594
+ inline: [{ type: "text", text: textOf(titleEl.children).trim(), bold: true }],
595
+ });
596
+ }
597
+ parts.push(...convertChildren(el, ctx, new Set(["title"])));
598
+ return parts;
599
+ }
600
+ case "image": {
601
+ const src = el.attribs?.href;
602
+ if (!src) {
603
+ // Same contract as the inline path: no silent src:"" images
604
+ if (el.attribs?.keyref) {
605
+ ctx.note("@keyref", `<image keyref="${el.attribs.keyref}"/> — key-based images cannot be resolved at conversion; insert the image manually`);
606
+ }
607
+ else {
608
+ ctx.note("<image>", "image without href dropped");
609
+ }
610
+ return [];
611
+ }
612
+ const node = {
613
+ type: "image",
614
+ props: {
615
+ src,
616
+ alt: el.attribs?.alt ?? textOf(el.children).trim(),
617
+ },
618
+ };
619
+ return [withProvenance(node, el)];
620
+ }
621
+ case "section":
622
+ case "example": {
623
+ const out = [];
624
+ const titleEl = el.children
625
+ .filter(isElement)
626
+ .find((c) => c.name.toLowerCase() === "title");
627
+ if (titleEl) {
628
+ // <title conref=…/> — heading text pulled from elsewhere
629
+ const titleConref = constrainedConrefInline(titleEl, ctx);
630
+ out.push(withProvenance({
631
+ type: "heading",
632
+ // @id also as props.id so the serializer emits a {#id} anchor —
633
+ // cross-topic xref fragments (renamed to #elementid) must land
634
+ props: {
635
+ level: ctx.headingLevel,
636
+ ...(el.attribs?.id ? { id: el.attribs.id } : {}),
637
+ },
638
+ inline: titleConref ?? trimInline(convertInline(titleEl.children, ctx)),
639
+ }, el));
640
+ }
641
+ else if (name === "example") {
642
+ out.push({
643
+ type: "heading",
644
+ props: { level: ctx.headingLevel },
645
+ inline: [{ type: "text", text: "Example" }],
646
+ });
647
+ }
648
+ const inner = { ...ctx, headingLevel: Math.min(6, ctx.headingLevel + 1) };
649
+ out.push(...convertChildren(el, inner, new Set(["title"])));
650
+ return out;
651
+ }
652
+ case "steps":
653
+ case "steps-unordered":
654
+ return [withProvenance(convertSteps(el, ctx, "steps"), el)];
655
+ case "substeps":
656
+ return [withProvenance(convertSteps(el, ctx, "substeps"), el)];
657
+ case "context":
658
+ return convertChildren(el, ctx);
659
+ case "prereq":
660
+ case "result":
661
+ case "postreq":
662
+ case "tasktroubleshooting": {
663
+ const heading = {
664
+ type: "heading",
665
+ props: { level: ctx.headingLevel },
666
+ inline: [{ type: "text", text: TASK_SECTION_HEADINGS[name] }],
667
+ };
668
+ return [heading, ...convertChildren(el, ctx)];
669
+ }
670
+ case "choices":
671
+ return [{ type: "unordered-list", children: convertListItems(el, ctx, "choice") }];
672
+ case "choicetable":
673
+ return [withProvenance(convertTable(el, ctx), el)];
674
+ case "lq": {
675
+ // Blockquotes serialize `> `-prefixed — constrained for includes
676
+ const { inline, children } = mixedContent(el, { ...ctx, constrained: true });
677
+ const body = [];
678
+ if (inline)
679
+ body.push({ type: "paragraph", inline });
680
+ body.push(...children);
681
+ return [{ type: "blockquote", children: body }];
682
+ }
683
+ case "title":
684
+ // Titles are handled by their parents (topic/section/fig/table)
685
+ return [];
686
+ case "prolog":
687
+ case "related-links":
688
+ ctx.note(`<${name}>`, "metadata/related-links dropped — links belong in the topic map (reltable design pending)");
689
+ return [];
690
+ case "draft-comment":
691
+ case "data":
692
+ case "indexterm":
693
+ return [];
694
+ default: {
695
+ // Unknown block: convert children if any survive, note the loss
696
+ const children = convertChildren(el, ctx);
697
+ ctx.note(`<${name}>`, children.length > 0
698
+ ? "unrecognized block element — children kept, wrapper dropped"
699
+ : "unrecognized block element dropped");
700
+ return children;
701
+ }
702
+ }
703
+ }
704
+ export function convertChildren(el, ctx, skip = new Set()) {
705
+ const out = [];
706
+ let inlineRun = [];
707
+ const flushInline = () => {
708
+ const para = paragraphFrom(inlineRun, ctx);
709
+ if (para)
710
+ out.push(para);
711
+ inlineRun = [];
712
+ };
713
+ for (const child of el.children) {
714
+ if (isElement(child)) {
715
+ const name = child.name.toLowerCase();
716
+ if (skip.has(name))
717
+ continue;
718
+ if (BLOCK_ELEMENTS.has(name) || TRANSPARENT_CONTAINERS.has(name) || name === "title" ||
719
+ name === "prolog" || name === "related-links" || name === "draft-comment") {
720
+ flushInline();
721
+ out.push(...convertBlock(child, ctx));
722
+ continue;
723
+ }
724
+ // Inline element loose in a body: accumulate into a paragraph
725
+ inlineRun.push(child);
726
+ }
727
+ else if (isNonEmptyText(child)) {
728
+ inlineRun.push(child);
729
+ }
730
+ }
731
+ flushInline();
732
+ return out;
733
+ }
734
+ //# sourceMappingURL=blocks.js.map