@dogsbay/format-dita 0.2.0-beta.121 → 0.2.0-beta.123

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,7 +1,8 @@
1
- import { inlineToDogsbayMd } from "@dogsbay/format-dogsbay-md";
1
+ import { inlineToDogsbayMd } from "@dogsbay/format-dogsbay-md/browser";
2
2
  import { convertInline, elementName, 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
+ import { DB_QUALIFIERS, outputTokens, youtubeId } from "./qualifiers.js";
5
6
  const isElement = (node) => node.type === "tag";
6
7
  const isNonEmptyText = (node) => node.type === "text" && node.data.trim().length > 0;
7
8
  /** DITA note @type → callout type in the dogsbay-md vocabulary. */
@@ -224,6 +225,415 @@ const convertListItems = (el, ctx, itemTag) => el.children
224
225
  item.children = children;
225
226
  return withProvenance(item, li);
226
227
  });
228
+ /** Components an `@outputclass` `db-*` token selects, per element. */
229
+ const DB_COMPONENTS = {
230
+ dl: ["db-tabs", "db-accordion", "db-cards"],
231
+ section: ["db-details"],
232
+ // A section cannot nest, and cannot hold one: these can, so a details or
233
+ // sheet goes inside a section (or another div) through them
234
+ div: ["db-details", "db-grid"],
235
+ sectiondiv: ["db-details", "db-grid"],
236
+ bodydiv: ["db-details", "db-grid"],
237
+ ul: ["db-files"],
238
+ ol: ["db-steps"],
239
+ // Alone in a <p>: a block component (xrefComponent)
240
+ xref: ["db-link-card", "db-button", "db-video"],
241
+ };
242
+ /** Elements without a <title>: a `db-details` one takes it from a leading `<p outputclass="db-title">`. */
243
+ const UNTITLED_DETAILS = new Set(["div", "sectiondiv", "bodydiv"]);
244
+ /**
245
+ * A `db-details` element as a collapsible block. The modifiers: `sheet` opens
246
+ * it as a side sheet (`drawer`, the first name, is accepted with a note);
247
+ * other tokens are classes. The element's id stays on the block.
248
+ */
249
+ const toDetails = (el, ctx, classes, title, skip, titleId) => {
250
+ const props = { title: title || "Details" };
251
+ // The title paragraph's id (a cross-reference to it) lands on the block,
252
+ // or, when the block has its own, as an anchor at the start of its body
253
+ const ownId = el.attribs?.id ?? titleId;
254
+ if (ownId)
255
+ props.id = anchorId(ctx, ownId);
256
+ const q = qualify("db-details", classes, ctx);
257
+ Object.assign(props, q.props);
258
+ const tokens = (q.classes ?? "").split(" ").filter(Boolean);
259
+ if (tokens.includes("drawer"))
260
+ ctx.note("@outputclass", `"drawer" on db-details is now "sheet" — converted as a sheet`);
261
+ if (tokens.includes("sheet") || tokens.includes("drawer"))
262
+ props.sheet = true;
263
+ const rest = tokens.filter((c) => c !== "sheet" && c !== "drawer");
264
+ if (rest.length)
265
+ props.class = rest.join(" ");
266
+ const inner = { ...ctx, headingLevel: Math.min(6, ctx.headingLevel + 1) };
267
+ let children = convertChildren(el, inner, skip);
268
+ if (titleId && el.attribs?.id) {
269
+ const anchors = anchorNodes([titleId], ctx);
270
+ if (anchors.length)
271
+ children = prependToFirstParagraph(children, anchors) ?? [{ type: "paragraph", inline: anchors }, ...children];
272
+ }
273
+ if (children.length === 0)
274
+ ctx.note("@outputclass", `db-details "${props.title}" has no content`);
275
+ return withProvenance({ type: "details", props, children }, el);
276
+ };
277
+ /**
278
+ * `<div outputclass="db-grid cols-3">`: each child `<div>` is a cell (its
279
+ * `span-N` token spans columns); any other child block is a cell of its own.
280
+ * DITA-OT shows the cells one after another.
281
+ */
282
+ const toGrid = (el, ctx, classes) => {
283
+ const q = qualify("db-grid", classes, ctx);
284
+ const props = { ...q.props, ...(q.classes ? { class: q.classes } : {}) };
285
+ if (el.attribs?.id)
286
+ props.id = anchorId(ctx, el.attribs.id);
287
+ const cells = [];
288
+ for (const child of el.children) {
289
+ if (!isElement(child)) {
290
+ if (isNonEmptyText(child))
291
+ cells.push({ type: "grid-item", children: [{ type: "paragraph", inline: convertInline([child], ctx) }] });
292
+ continue;
293
+ }
294
+ if (blockName(child, ctx) === "div") {
295
+ const cellProps = {};
296
+ const rest = [];
297
+ for (const t of outputTokens(child.attribs?.outputclass)) {
298
+ const span = /^span-(\d+)$/.exec(t);
299
+ if (span && DB_QUALIFIERS.gridColumns.includes(Number(span[1])))
300
+ cellProps.span = Number(span[1]);
301
+ else if (/^[a-z0-9][a-z0-9-]*$/.test(t) && !t.startsWith("db-"))
302
+ rest.push(t);
303
+ else
304
+ ctx.note("@outputclass", `"${t}" on a db-grid cell ignored: a cell takes span-2 to span-6`);
305
+ }
306
+ if (rest.length)
307
+ cellProps.class = rest.join(" ");
308
+ if (child.attribs?.id)
309
+ cellProps.id = anchorId(ctx, child.attribs.id);
310
+ cells.push(withProvenance({ type: "grid-item", ...(Object.keys(cellProps).length ? { props: cellProps } : {}), children: convertChildren(child, ctx) }, child));
311
+ }
312
+ else {
313
+ const blocks = convertBlock(child, ctx);
314
+ if (blocks.length)
315
+ cells.push({ type: "grid-item", children: blocks });
316
+ }
317
+ }
318
+ if (!cells.length)
319
+ ctx.note("@outputclass", `db-grid has no cells`);
320
+ return withProvenance({ type: "grid", props, children: cells }, el);
321
+ };
322
+ /**
323
+ * A `<p>` holding only an `<xref outputclass="db-link-card|db-button|db-video">`
324
+ * (and whitespace): the block component. The href resolves exactly as the
325
+ * xref's would (rename, fragment, key, publish paths), because the link is
326
+ * converted first and read back. Undefined when the paragraph is anything
327
+ * else; an xref component beside other text stays a link, with a note.
328
+ */
329
+ const xrefComponent = (p, ctx) => {
330
+ const kids = p.children.filter((c) => isElement(c) || isNonEmptyText(c));
331
+ const xrefs = p.children.filter((c) => isElement(c) && c.name.toLowerCase() === "xref" && /(^|\s)db-/.test(c.attribs?.outputclass ?? ""));
332
+ if (!xrefs.length)
333
+ return undefined;
334
+ if (kids.length !== 1) {
335
+ ctx.note("@outputclass", `an <xref> component must be alone in its <p> — kept as a link`);
336
+ return undefined;
337
+ }
338
+ const x = xrefs[0];
339
+ const component = dbComponent(x, ctx);
340
+ if (!component)
341
+ return undefined;
342
+ if (ctx.inTableCell) {
343
+ // A cell holds inline content only: the component would lose its link
344
+ ctx.note("@outputclass", `${component.name} inside a table cell — kept as a link`);
345
+ return undefined;
346
+ }
347
+ const link = convertInline([x], ctx).find((n) => n.type === "link");
348
+ if (!link?.href) {
349
+ ctx.note("@outputclass", `${component.name} without a resolvable href — kept as text`);
350
+ return undefined;
351
+ }
352
+ const title = inlineLabel(link.children);
353
+ const q = qualify(component.name, component.classes, ctx);
354
+ const props = { ...q.props, ...(q.classes ? { class: q.classes } : {}) };
355
+ if (component.name === "db-link-card") {
356
+ const desc = x.children.find((c) => isElement(c) && c.name.toLowerCase() === "desc");
357
+ const description = desc ? inlineLabel(convertInline(desc.children, ctx)) : "";
358
+ return withProvenance({ type: "link-card", props: { ...props, title: title || link.href, href: link.href, ...(description ? { description } : {}) } }, x);
359
+ }
360
+ if (component.name === "db-button") {
361
+ return withProvenance({ type: "link-button", props: { ...props, href: link.href }, children: [{ type: "paragraph", inline: link.children }] }, x);
362
+ }
363
+ const id = youtubeId(link.href);
364
+ if (!id) {
365
+ ctx.note("@outputclass", `db-video "${link.href}" is not a YouTube URL — kept as a link`);
366
+ return undefined;
367
+ }
368
+ return withProvenance({ type: "youtube", props: { ...props, id, ...(title && title !== link.href ? { title } : {}) } }, x);
369
+ };
370
+ /**
371
+ * The component an element's `@outputclass` selects: its first `db-*` token,
372
+ * and the remaining tokens as classes. `db-` makes the token ours: other
373
+ * values (DITAVAL flagging, vendor CSS) pass through untouched. A class must
374
+ * be `[a-z0-9-]`, so a Tailwind arbitrary value (`bg-[url(…)]`) never reaches
375
+ * a site build that compiles the classes it finds; others are noted and
376
+ * dropped. An unknown or misplaced `db-*` is noted and the element converts
377
+ * as plain DITA.
378
+ */
379
+ const dbComponent = (el, ctx) => {
380
+ // `db-theme-*` is a scope, not a component (themeOf): never picked or kept as a class
381
+ const tokens = outputTokens(el.attribs?.outputclass);
382
+ const at = tokens.findIndex((t) => t.startsWith("db-"));
383
+ if (at < 0)
384
+ return undefined;
385
+ const name = tokens[at].toLowerCase();
386
+ const element = el.name.toLowerCase();
387
+ if (!(DB_COMPONENTS[element] ?? []).includes(name)) {
388
+ ctx.note("@outputclass", `"${tokens[at]}" is not a component on <${element}> — converted as plain DITA`);
389
+ return undefined;
390
+ }
391
+ // One component per element: a second `db-*` token is noted, not a class
392
+ const extra = tokens.filter((t, i) => i !== at && t.startsWith("db-"));
393
+ if (extra.length)
394
+ ctx.note("@outputclass", `${extra.map((t) => `"${t}"`).join(", ")} ignored: <${element}> is already ${name}`);
395
+ const rest = tokens.filter((t, i) => i !== at && !t.startsWith("db-"));
396
+ const safe = rest.filter((t) => /^[a-z0-9][a-z0-9-]*$/.test(t));
397
+ const dropped = rest.filter((t) => !safe.includes(t));
398
+ if (dropped.length)
399
+ ctx.note("@outputclass", `class ${dropped.map((t) => `"${t}"`).join(", ")} on ${name} dropped: classes are lowercase letters, digits and hyphens`);
400
+ return { name, ...(safe.length ? { classes: safe.join(" ") } : {}) };
401
+ };
402
+ export { DB_QUALIFIERS };
403
+ /**
404
+ * A component's qualifier tokens as props: `pills`/`underline` on db-tabs
405
+ * (`variant`), `tone-*` on db-cards (`tone`) and db-details (its colour,
406
+ * `variant`). Props, not classes: a sanitized single page strips classes, and
407
+ * every renderer and exporter can implement or declare a prop. Anything else
408
+ * stays a free class; a `tone-` token outside the tones is noted.
409
+ */
410
+ const qualify = (component, classes, ctx) => {
411
+ const props = {};
412
+ const rest = [];
413
+ for (const t of (classes ?? "").split(" ").filter(Boolean)) {
414
+ if (component === "db-tabs" && DB_QUALIFIERS.tabVariants.includes(t)) {
415
+ props.variant = t;
416
+ continue;
417
+ }
418
+ if (component === "db-button" && DB_QUALIFIERS.buttonVariants.includes(t)) {
419
+ if (t !== "primary")
420
+ props.variant = t;
421
+ continue;
422
+ }
423
+ const cols = /^cols-(\d+)$/.exec(t);
424
+ if (component === "db-grid" && cols) {
425
+ if (DB_QUALIFIERS.gridColumns.includes(Number(cols[1])))
426
+ props.cols = Number(cols[1]);
427
+ else
428
+ ctx.note("@outputclass", `"${t}": db-grid takes cols-1 to cols-6 — ignored`);
429
+ continue;
430
+ }
431
+ const tone = /^tone-(.+)$/.exec(t);
432
+ if (tone && (component === "db-cards" || component === "db-details")) {
433
+ if (DB_QUALIFIERS.tones.includes(tone[1]))
434
+ props[component === "db-details" ? "variant" : "tone"] = tone[1];
435
+ else
436
+ ctx.note("@outputclass", `"${t}" is not a tone (${DB_QUALIFIERS.tones.join(", ")}) — ignored`);
437
+ continue;
438
+ }
439
+ if (tone) {
440
+ ctx.note("@outputclass", `"${t}": ${component} takes no tone (db-cards and db-details do) — ignored`);
441
+ continue;
442
+ }
443
+ rest.push(t);
444
+ }
445
+ return { props, ...(rest.length ? { classes: rest.join(" ") } : {}) };
446
+ };
447
+ /** Elements a scoped theme (`db-theme-<name>`) applies to: containers of blocks. */
448
+ // section and its task/reference specialisations, plus the generic containers
449
+ const THEMED = new Set(["section", "sectiondiv", "div", "bodydiv", "example", "context", "prereq", "result", "postreq", "refsyn"]);
450
+ /**
451
+ * The scoped theme an element asks for (`outputclass="db-theme-dark"`),
452
+ * independent of its component token. Noted when it is unknown, on an element
453
+ * that is not a block container, or inside a table cell.
454
+ */
455
+ const themeOf = (el, ctx) => {
456
+ const tokens = (el.attribs?.outputclass ?? "").split(/\s+/).filter((t) => /^db-theme-/i.test(t));
457
+ const token = tokens[0];
458
+ if (!token)
459
+ return undefined;
460
+ if (tokens.length > 1)
461
+ ctx.note("@outputclass", `${tokens.slice(1).map((t) => `"${t}"`).join(", ")} ignored: one theme per element`);
462
+ const name = token.slice("db-theme-".length).toLowerCase();
463
+ // By @class in publish mode, so a specialised section is still a section
464
+ const element = blockName(el, ctx).toLowerCase();
465
+ if (!THEMED.has(element)) {
466
+ ctx.note("@outputclass", `"${token}" applies to section, sectiondiv, div, bodydiv and example — not <${element}>`);
467
+ return undefined;
468
+ }
469
+ if (!DB_QUALIFIERS.themes.includes(name)) {
470
+ ctx.note("@outputclass", `"${token}" is not a theme (${DB_QUALIFIERS.themes.join(", ")}) — content kept, unthemed`);
471
+ return undefined;
472
+ }
473
+ if (ctx.inTableCell) {
474
+ ctx.note("@outputclass", `"${token}" inside a table cell — content kept, unthemed`);
475
+ return undefined;
476
+ }
477
+ return name;
478
+ };
479
+ /** An `id` anchor the converter wrote (`<a id="x"></a>`), as opposed to other inline HTML. */
480
+ const isIdAnchor = (n) => n.type === "html-inline" && /^<a id="[^"]*"><\/a>$/.test(n.html);
481
+ const decodeEntities = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (m, e) => {
482
+ const named = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
483
+ if (named[e.toLowerCase()])
484
+ return named[e.toLowerCase()];
485
+ const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
486
+ return Number.isFinite(code) ? String.fromCodePoint(code) : m;
487
+ });
488
+ /**
489
+ * An inline run as plain text, for a label: markup is flattened, keys keep
490
+ * their `+`, an image gives its alt text, a line break a space, and inline
491
+ * HTML (`<sub>`, a code phrase holding a link) its text. Id anchors give
492
+ * nothing; the caller moves them.
493
+ */
494
+ const inlineLabel = (inline) => (inline ?? [])
495
+ .map((n) => {
496
+ if (isIdAnchor(n))
497
+ return "";
498
+ if (n.type === "kbd")
499
+ return n.keys.join("+");
500
+ if (n.type === "image")
501
+ return n.alt ?? "";
502
+ if (n.type === "break")
503
+ return " ";
504
+ if (n.type === "html-inline")
505
+ return decodeEntities(n.html.replace(/<[^>]*>/g, ""));
506
+ const node = n;
507
+ if (typeof node.text === "string")
508
+ return node.text;
509
+ if (Array.isArray(node.children))
510
+ return inlineLabel(node.children);
511
+ return "";
512
+ })
513
+ .join("")
514
+ .replace(/\s+/g, " ")
515
+ .trim();
516
+ /**
517
+ * The entries of a component `<dl>` (`db-tabs`, `db-accordion`, `db-cards`),
518
+ * in the shape dogsbay-md's directives parse from a definition list. Each
519
+ * entry is converted on its own (as convertDefList converts it: conrefs,
520
+ * anchors, publish filtering), since the merged list loses where an entry
521
+ * whose definitions were filtered out ends. Id anchors in a term (an entry
522
+ * `@id`) move to the start of the entry's content; those of an entry that is
523
+ * dropped go to the next one, or the end of the last, so a cross-reference
524
+ * to them still lands. A second term in one entry is noted. An entry with no
525
+ * content is dropped unless `keepEmpty` (a card can be a bare link).
526
+ * `anchorsOut` collects the anchors instead (cards: a card's body sits inside
527
+ * its link, and an `<a id>` inside an `<a href>` splits the link).
528
+ */
529
+ const dlComponentEntries = (el, ctx, component, keepEmpty = false, anchorsOut) => {
530
+ const out = [];
531
+ let pending = [];
532
+ const entries = el.children.filter((c) => isElement(c) && c.name.toLowerCase() === "dlentry");
533
+ for (const entry of entries) {
534
+ const items = convertDefList({ ...el, children: [entry] }, ctx).children ?? [];
535
+ const terms = items.filter((n) => n.type === "dt");
536
+ for (const t of terms)
537
+ (anchorsOut ?? pending).push(...(t.inline ?? []).filter(isIdAnchor));
538
+ for (const extra of terms.slice(1)) {
539
+ ctx.note("@outputclass", `${component} entry with several terms: "${inlineLabel(extra.inline)}" is not its label`);
540
+ }
541
+ const dt = terms[0];
542
+ const content = [];
543
+ for (const dd of items.filter((n) => n.type === "dd")) {
544
+ if (dd.inline?.length)
545
+ content.push({ type: "paragraph", inline: dd.inline });
546
+ content.push(...(dd.children ?? []));
547
+ }
548
+ if (!dt)
549
+ continue;
550
+ // A term whose definitions were all filtered out is not an entry
551
+ if (content.length === 0 && !keepEmpty)
552
+ continue;
553
+ const anchored = pending.length ? [{ type: "paragraph", inline: pending }, ...content] : content;
554
+ pending = [];
555
+ out.push({ term: (dt.inline ?? []).filter((n) => !isIdAnchor(n)), label: inlineLabel(dt.inline), content: anchored });
556
+ }
557
+ const last = out[out.length - 1];
558
+ if (pending.length && last)
559
+ last.content.push({ type: "paragraph", inline: pending });
560
+ return out;
561
+ };
562
+ /**
563
+ * Labels for tabs and accordion items, which a component matches by label
564
+ * (tabs) or value (accordion): an empty one is numbered, a repeated one
565
+ * suffixed, each with a note.
566
+ */
567
+ const uniqueLabels = (entries, ctx, component, unit) => {
568
+ const used = new Set();
569
+ return entries.map((entry, i) => {
570
+ let label = entry.label;
571
+ if (!label) {
572
+ label = `${unit} ${i + 1}`;
573
+ ctx.note("@outputclass", `${component} term with no text — labelled "${label}"`);
574
+ }
575
+ if (used.has(label)) {
576
+ let n = 2;
577
+ while (used.has(`${label} (${n})`))
578
+ n++;
579
+ ctx.note("@outputclass", `${component} label "${label}" repeated — shown as "${label} (${n})"`);
580
+ label = `${label} (${n})`;
581
+ }
582
+ used.add(label);
583
+ return label;
584
+ });
585
+ };
586
+ /** `<dl outputclass="db-tabs">`: each entry is a tab, its term the label. */
587
+ const dlToTabs = (el, ctx) => {
588
+ const entries = dlComponentEntries(el, ctx, "db-tabs");
589
+ const labels = uniqueLabels(entries, ctx, "db-tabs", "Tab");
590
+ const tabs = entries.map((e, i) => ({ type: "tab", props: { label: labels[i] }, children: e.content }));
591
+ return tabs.length ? tabs : undefined;
592
+ };
593
+ /**
594
+ * A value for an accordion item: its label as a slug, by the rule dogsbay-md's
595
+ * `:::accordion` uses (`uniqueAccordionValue` in its parse.ts), so a published
596
+ * page and a migrated one give an item the same value. Empty → `item-N`.
597
+ */
598
+ const itemValue = (label) => label.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-");
599
+ /** `<dl outputclass="db-accordion">`: each entry is a collapsible item, its term the label. */
600
+ const dlToAccordion = (el, ctx) => {
601
+ const entries = dlComponentEntries(el, ctx, "db-accordion");
602
+ const labels = uniqueLabels(entries, ctx, "db-accordion", "Item");
603
+ const values = new Set();
604
+ let fallback = 1;
605
+ const items = entries.map((e, i) => {
606
+ const slug = itemValue(labels[i]) || `item-${fallback++}`;
607
+ let value = slug;
608
+ for (let n = 2; values.has(value); n++)
609
+ value = `${slug}-${n}`;
610
+ values.add(value);
611
+ return { type: "accordion-item", props: { value, label: labels[i] }, children: e.content };
612
+ });
613
+ return items.length ? items : undefined;
614
+ };
615
+ /**
616
+ * `<dl outputclass="db-cards">`: each entry is a card, its term the title.
617
+ * A term that is exactly one link (`<dt><xref href="install.dita">Install</xref></dt>`)
618
+ * makes the card a link to that target; any other term is a title only. A
619
+ * card may have no definition (a bare link card), but not no title.
620
+ */
621
+ const dlToCards = (el, ctx, anchors) => {
622
+ const cards = [];
623
+ for (const e of dlComponentEntries(el, ctx, "db-cards", true, anchors)) {
624
+ const parts = e.term.filter((n) => !(n.type === "text" && !n.text.trim()));
625
+ const link = parts.length === 1 && parts[0].type === "link" ? parts[0] : undefined;
626
+ if (!e.label) {
627
+ ctx.note("@outputclass", `db-cards term with no text — entry dropped`);
628
+ continue;
629
+ }
630
+ const props = { title: e.label };
631
+ if (link?.href)
632
+ props.href = link.href;
633
+ cards.push({ type: "card", props, ...(e.content.length ? { children: e.content } : {}) });
634
+ }
635
+ return cards.length ? cards : undefined;
636
+ };
227
637
  const convertDefList = (el, ctx, tags = { entry: "dlentry", term: "dt", def: "dd" }) => {
228
638
  const children = [];
229
639
  for (const entry of el.children.filter(isElement)) {
@@ -354,7 +764,7 @@ const convertTable = (el, ctx) => {
354
764
  // Cell content always lives in children (leading inline wrapped as a
355
765
  // paragraph): the serializer's HTML-table path reads only children,
356
766
  // and its pipe path reads children paragraphs too
357
- const { inline, children } = mixedContent(cellEl, { ...ctx, constrained: true });
767
+ const { inline, children } = mixedContent(cellEl, { ...ctx, constrained: true, inTableCell: true });
358
768
  const cellChildren = [];
359
769
  if (inline)
360
770
  cellChildren.push({ type: "paragraph", inline });
@@ -709,6 +1119,17 @@ const ANCHORED_ELSEWHERE = new Set(["title", "related-links", "prolog", "data",
709
1119
  * DITA-OT's pages. (Headings carry their own id.)
710
1120
  */
711
1121
  export function convertBlock(el, ctx) {
1122
+ const out = convertBlockAnchored(el, ctx);
1123
+ // A scoped theme wraps the element's whole output, its anchor included
1124
+ const theme = /(^|\s)db-theme-/i.test(el.attribs?.outputclass ?? "") ? themeOf(el, ctx) : undefined;
1125
+ if (!theme || !out.length)
1126
+ return out;
1127
+ // The element's own id anchor stays outside the band: inside, it would be
1128
+ // the band's first child and take the first-child margin trim meant for content
1129
+ const lead = out[0].type === "html" && /^<a id="[^"]*"><\/a>$/.test(out[0].html ?? "") ? [out[0]] : [];
1130
+ return [...lead, { type: "theme", props: { name: theme }, children: out.slice(lead.length) }];
1131
+ }
1132
+ function convertBlockAnchored(el, ctx) {
712
1133
  const out = convertBlockContent(el, ctx);
713
1134
  const id = el.attribs?.id;
714
1135
  if (!ctx.publish || !id || ANCHORED_ELSEWHERE.has(blockName(el, ctx)))
@@ -717,8 +1138,8 @@ export function convertBlock(el, ctx) {
717
1138
  // An empty paragraph or division still keeps its id (DITA-OT writes the empty element)
718
1139
  if (out.length === 0)
719
1140
  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))
1141
+ // A titled section's heading (or a db-details block) carries the id itself
1142
+ if (out.some((n) => n.props?.id === anchor))
722
1143
  return out;
723
1144
  // Before a paragraph, the anchor is the start of its text (a separate block would be a paragraph of its own)
724
1145
  if (out[0].type === "paragraph" && out[0].inline) {
@@ -816,6 +1237,33 @@ function convertBlockContent(el, ctx) {
816
1237
  ];
817
1238
  }
818
1239
  }
1240
+ // `<div|sectiondiv|bodydiv outputclass="db-details">`, titled by a leading
1241
+ // `<p outputclass="db-title">` (a visible first line in DITA-OT's output)
1242
+ if (UNTITLED_DETAILS.has(name) && /(^|\s)db-/.test(el.attribs?.outputclass ?? "")) {
1243
+ const component = dbComponent(el, ctx);
1244
+ if (component?.name === "db-details" && ctx.inTableCell) {
1245
+ // A table cell cannot hold a collapsible block (the HTML-table form
1246
+ // dropped it, title and all): keep its content, title included
1247
+ ctx.note("@outputclass", `db-details inside a table cell — kept as its content`);
1248
+ }
1249
+ else if (component?.name === "db-details") {
1250
+ // The first element, past whitespace, comments and processing
1251
+ // instructions (an editor's <?oxy_comment_start?>), may be the title
1252
+ const first = el.children.find((c) => isElement(c) || (c.type === "text" && (c.data ?? "").trim() !== ""));
1253
+ const titleP = first && isElement(first) && blockName(first, ctx) === "p" && /(^|\s)db-title(\s|$)/.test(first.attribs?.outputclass ?? "") ? first : undefined;
1254
+ // A conref'd title is pulled like a section title's
1255
+ const title = titleP ? inlineLabel(constrainedConrefInline(titleP, ctx) ?? convertInline(titleP.children, ctx)) : "";
1256
+ if (!title)
1257
+ ctx.note("@outputclass", `db-details on <${name}> has no leading <p outputclass="db-title"> with text — titled "Details"`);
1258
+ return [toDetails(el, ctx, component.classes, title, new Set(titleP ? [titleP] : []), titleP?.attribs?.id)];
1259
+ }
1260
+ else if (component?.name === "db-grid" && ctx.inTableCell) {
1261
+ ctx.note("@outputclass", `db-grid inside a table cell — kept as its content`);
1262
+ }
1263
+ else if (component?.name === "db-grid") {
1264
+ return [toGrid(el, ctx, component.classes)];
1265
+ }
1266
+ }
819
1267
  if (TRANSPARENT_CONTAINERS.has(name)) {
820
1268
  // Publishing: a topic body starts with the topic's "Prerequisites" links, when it has any
821
1269
  const topicId = el.parent?.attribs?.id;
@@ -830,6 +1278,13 @@ function convertBlockContent(el, ctx) {
830
1278
  switch (name) {
831
1279
  case "p":
832
1280
  case "shortdesc": {
1281
+ // A link card, button or video: an xref component alone in its paragraph
1282
+ const xc = name === "p" ? xrefComponent(el, ctx) : undefined;
1283
+ if (xc) {
1284
+ // Publishing: the xref's own id (a link target) as an anchor before the component
1285
+ const anchors = anchorNodes([el.children.find((c) => isElement(c) && c.name.toLowerCase() === "xref")?.attribs?.id], ctx);
1286
+ return anchors.length ? [{ type: "paragraph", inline: anchors }, xc] : [xc];
1287
+ }
833
1288
  // DITA allows blocks (ul, note, pre, …) inside <p>: leading inline
834
1289
  // becomes the paragraph, block children follow as siblings
835
1290
  const { inline, children } = mixedContent(el, ctx);
@@ -864,11 +1319,13 @@ function convertBlockContent(el, ctx) {
864
1319
  case "screen":
865
1320
  case "msgblock": {
866
1321
  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;
1322
+ // DITA-OT's language classes (language-xml, syntax-bash) name the
1323
+ // language in both modes. Publishing reads only those; migration also
1324
+ // takes a bare first token (outputclass="python"), the older convention
1325
+ const prefixed = /(?:^|\s)(?:language|syntax)-(\S+)/.exec(outputclass)?.[1];
1326
+ const bare = outputclass.trim().split(/\s+/)[0] ?? "";
1327
+ const language = prefixed
1328
+ ?? (!ctx.publish && /^[A-Za-z0-9][\w+#.-]*$/.test(bare) ? bare : "");
872
1329
  const content = textOf(el.children).replace(/^\n/, "").replace(/\s+$/, "");
873
1330
  // Content in BOTH html and props.code: the serializer's block path
874
1331
  // reads html, its HTML-table path (code inside cells) reads props.code
@@ -879,10 +1336,29 @@ function convertBlockContent(el, ctx) {
879
1336
  };
880
1337
  return [withProvenance(node, el)];
881
1338
  }
882
- case "ul":
883
- return [withProvenance({ type: "unordered-list", children: convertListItems(el, ctx, "li") }, el)];
884
- case "ol":
885
- return [withProvenance({ type: "ordered-list", children: convertListItems(el, ctx, "li") }, el)];
1339
+ case "ul": {
1340
+ const list = withProvenance({ type: "unordered-list", children: convertListItems(el, ctx, "li") }, el);
1341
+ // A file tree: folders are items with a nested list, or end in "/"
1342
+ if (dbComponent(el, ctx)?.name === "db-files") {
1343
+ if (!ctx.inTableCell)
1344
+ return [{ type: "files", children: [list] }];
1345
+ ctx.note("@outputclass", `db-files inside a table cell — kept as a list`);
1346
+ }
1347
+ return [list];
1348
+ }
1349
+ case "ol": {
1350
+ const items = convertListItems(el, ctx, "li");
1351
+ // Steps outside a task: each item a numbered, connected step
1352
+ if (dbComponent(el, ctx)?.name === "db-steps") {
1353
+ if (!ctx.inTableCell) {
1354
+ // An item stays itself (its id, its provenance) as a step
1355
+ const steps = items.map((it) => ({ ...it, type: "step" }));
1356
+ return [withProvenance({ type: "steps", children: steps }, el)];
1357
+ }
1358
+ ctx.note("@outputclass", `db-steps inside a table cell — kept as a numbered list`);
1359
+ }
1360
+ return [withProvenance({ type: "ordered-list", children: items }, el)];
1361
+ }
886
1362
  case "sl": {
887
1363
  // Simple list → unordered list of <sli> items (same conref hooks
888
1364
  // as <li>: referencing items include or ledger, defining items
@@ -898,8 +1374,27 @@ function convertBlockContent(el, ctx) {
898
1374
  });
899
1375
  return [withProvenance({ type: "unordered-list", children: items }, el)];
900
1376
  }
901
- case "dl":
1377
+ case "dl": {
1378
+ const component = dbComponent(el, ctx);
1379
+ if (component) {
1380
+ // Card entries' id anchors go before the grid, not inside a card's link
1381
+ const anchors = [];
1382
+ const [type, build] = {
1383
+ "db-tabs": ["tabs", dlToTabs],
1384
+ "db-accordion": ["accordion", dlToAccordion],
1385
+ "db-cards": ["cards", (e, c) => dlToCards(e, c, anchors)],
1386
+ }[component.name];
1387
+ const children = build(el, ctx);
1388
+ if (children) {
1389
+ const q = qualify(component.name, component.classes, ctx);
1390
+ const props = { ...q.props, ...(q.classes ? { class: q.classes } : {}) };
1391
+ const group = withProvenance({ type, ...(Object.keys(props).length ? { props } : {}), children }, el);
1392
+ return anchors.length ? [{ type: "paragraph", inline: anchors }, group] : [group];
1393
+ }
1394
+ ctx.note("@outputclass", `${component.name} on a <dl> with no usable entry — kept as a definition list`);
1395
+ }
902
1396
  return [withProvenance(convertDefList(el, ctx), el)];
1397
+ }
903
1398
  case "parml":
904
1399
  // Parameter list — structurally a definition list
905
1400
  return [withProvenance(convertDefList(el, ctx, { entry: "plentry", term: "pt", def: "pd" }), el)];
@@ -996,6 +1491,18 @@ function convertBlockContent(el, ctx) {
996
1491
  const titleEl = el.children
997
1492
  .filter(isElement)
998
1493
  .find((c) => blockName(c, ctx) === "title");
1494
+ // `<section outputclass="db-details">`: a collapsible block titled by
1495
+ // the section title (flattened to text)
1496
+ const component = name === "section" ? dbComponent(el, ctx) : undefined;
1497
+ if (component?.name === "db-details") {
1498
+ if (titleEl) {
1499
+ const title = inlineLabel(constrainedConrefInline(titleEl, ctx) ?? convertInline(titleEl.children, ctx));
1500
+ if (!title)
1501
+ ctx.note("@outputclass", `db-details section title has no text — titled "Details"`);
1502
+ return [toDetails(el, ctx, component.classes, title, new Set(["title"]))];
1503
+ }
1504
+ ctx.note("@outputclass", `db-details on a <section> with no <title> — converted as a plain section`);
1505
+ }
999
1506
  if (titleEl) {
1000
1507
  // <title conref=…/> — heading text pulled from elsewhere
1001
1508
  const titleConref = constrainedConrefInline(titleEl, ctx);
@@ -1098,7 +1605,9 @@ function convertBlockContent(el, ctx) {
1098
1605
  }
1099
1606
  }
1100
1607
  }
1101
- export function convertChildren(el, ctx, skip = new Set()) {
1608
+ export function convertChildren(el, ctx,
1609
+ /** Element names, or particular child nodes, to leave out */
1610
+ skip = new Set()) {
1102
1611
  const out = [];
1103
1612
  let inlineRun = [];
1104
1613
  const flushInline = () => {
@@ -1110,7 +1619,7 @@ export function convertChildren(el, ctx, skip = new Set()) {
1110
1619
  for (const child of el.children) {
1111
1620
  if (isElement(child)) {
1112
1621
  const name = blockName(child, ctx);
1113
- if (skip.has(name))
1622
+ if (skip.has(name) || skip.has(child))
1114
1623
  continue;
1115
1624
  if (isBlockChild(child, ctx) || TRANSPARENT_CONTAINERS.has(name) || name === "title" ||
1116
1625
  name === "prolog" || name === "related-links" || name === "draft-comment") {
@@ -1128,4 +1637,3 @@ export function convertChildren(el, ctx, skip = new Set()) {
1128
1637
  flushInline();
1129
1638
  return out;
1130
1639
  }
1131
- //# sourceMappingURL=blocks.js.map
package/dist/conref.d.ts CHANGED
@@ -25,4 +25,3 @@ export interface ConrefPlan extends ConrefPlanApi {
25
25
  * snippet naming) follows sorted keys, not discovery order.
26
26
  */
27
27
  export declare function planConrefs(files: ConrefCorpusFile[]): ConrefPlan;
28
- //# sourceMappingURL=conref.d.ts.map
package/dist/conref.js CHANGED
@@ -19,7 +19,7 @@
19
19
  * definition-list entries).
20
20
  */
21
21
  import { parseDocument } from "htmlparser2";
22
- import { treeToDogsbayMd } from "@dogsbay/format-dogsbay-md";
22
+ import { treeToDogsbayMd } from "@dogsbay/format-dogsbay-md/browser";
23
23
  import { convertBlock, convertItemContent, isBlockTarget, } from "./blocks.js";
24
24
  import { convertInline, trimInline } from "./inline.js";
25
25
  import { DITA_TOPIC_TYPES, } from "./types.js";
@@ -300,4 +300,3 @@ export function planConrefs(files) {
300
300
  },
301
301
  };
302
302
  }
303
- //# sourceMappingURL=conref.js.map
package/dist/index.d.ts CHANGED
@@ -16,4 +16,5 @@ export { DITA_TOPIC_TYPES, renameDitaHref, relativeIncludePath, type DitaConvers
16
16
  export { ditaTopicToTree } from "./parse.js";
17
17
  export { ditaTopicToDogsbayMd, type DitaMarkdownResult } from "./markdown.js";
18
18
  export { planConrefs, type ConrefCorpusFile, type ConrefPlan, type ConrefSnippet, } from "./conref.js";
19
- //# sourceMappingURL=index.d.ts.map
19
+ export { DB_QUALIFIERS } from "./blocks.js";
20
+ export { checkWellFormed, type WellFormedProblem } from "./well-formed.js";