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

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.
Files changed (46) hide show
  1. package/README.md +19 -1
  2. package/dist/anchors.d.ts +0 -1
  3. package/dist/anchors.js +0 -1
  4. package/dist/blocks.d.ts +2 -1
  5. package/dist/blocks.js +289 -37
  6. package/dist/class-defaults-apply.d.ts +15 -0
  7. package/dist/class-defaults-apply.js +40 -0
  8. package/dist/class-defaults.d.ts +8 -0
  9. package/dist/class-defaults.js +625 -0
  10. package/dist/conref.d.ts +0 -1
  11. package/dist/conref.js +1 -2
  12. package/dist/index.d.ts +4 -1
  13. package/dist/index.js +5 -1
  14. package/dist/inline.d.ts +2 -9
  15. package/dist/inline.js +50 -8
  16. package/dist/markdown.d.ts +0 -1
  17. package/dist/markdown.js +1 -2
  18. package/dist/parse.d.ts +0 -1
  19. package/dist/parse.js +24 -4
  20. package/dist/publish-support.d.ts +0 -1
  21. package/dist/publish-support.js +0 -1
  22. package/dist/qualifiers.d.ts +25 -0
  23. package/dist/qualifiers.js +40 -0
  24. package/dist/types.d.ts +10 -1
  25. package/dist/types.js +0 -1
  26. package/dist/well-formed.d.ts +14 -0
  27. package/dist/well-formed.js +110 -0
  28. package/package.json +5 -4
  29. package/dist/anchors.d.ts.map +0 -1
  30. package/dist/anchors.js.map +0 -1
  31. package/dist/blocks.d.ts.map +0 -1
  32. package/dist/blocks.js.map +0 -1
  33. package/dist/conref.d.ts.map +0 -1
  34. package/dist/conref.js.map +0 -1
  35. package/dist/index.d.ts.map +0 -1
  36. package/dist/index.js.map +0 -1
  37. package/dist/inline.d.ts.map +0 -1
  38. package/dist/inline.js.map +0 -1
  39. package/dist/markdown.d.ts.map +0 -1
  40. package/dist/markdown.js.map +0 -1
  41. package/dist/parse.d.ts.map +0 -1
  42. package/dist/parse.js.map +0 -1
  43. package/dist/publish-support.d.ts.map +0 -1
  44. package/dist/publish-support.js.map +0 -1
  45. package/dist/types.d.ts.map +0 -1
  46. package/dist/types.js.map +0 -1
package/README.md CHANGED
@@ -3,6 +3,12 @@
3
3
  Convert DITA topic XML to Dogsbay's TreeNode model — and, composed with
4
4
  `@dogsbay/format-dogsbay-md`, straight to markdown.
5
5
 
6
+ **Browser-safe:** the package imports no Node built-ins, so it bundles for a
7
+ browser or a Worker (a test bundles it with esbuild for `platform: "browser"`).
8
+ Malformed XML is reported, not repaired silently: the parser would close an
9
+ unclosed element for you, so a `<xml>` note gives the first problem and its
10
+ line (`checkWellFormed` is exported too).
11
+
6
12
  ```ts
7
13
  import { ditaTopicToDogsbayMd } from "@dogsbay/format-dita";
8
14
 
@@ -21,7 +27,7 @@ dogsbay import-dita docs/ -o content --report topics.md
21
27
  ```
22
28
 
23
29
  Tasks become native `:::steps`, notes become callouts, CALS and
24
- simpletables become tables, `<xref href="x.dita">` becomes `[…](x.md)`,
30
+ simpletables (and a reference topic's `<properties>`) become tables, `<xref href="x.dita">` becomes `[…](x.md)`,
25
31
  and `keyref` links become `key:` links. Nested or sibling topics in
26
32
  multi-topic files are emitted inline as anchored sections.
27
33
 
@@ -32,6 +38,18 @@ emit `{% include %}` so one canonical copy of reused text survives. Use
32
38
  `dogsbay import-dita --no-conref-includes` to keep conrefs on the ledger
33
39
  instead.
34
40
 
41
+ **Specializations in raw source:** a file as authored has no `@class`, so
42
+ an element is known only by its name, and a specialization the converter
43
+ does not know by name (a `<troubleshooting>` topic, a hazard statement's
44
+ items) loses its markup. `{ classDefaults: true }` fills in each missing
45
+ `@class` from the DITA 1.3 DTD defaults first (612 elements, the table
46
+ `DITA_CLASS_DEFAULTS`, generated by `scripts/generate-class-defaults.mjs`
47
+ from DITA-OT's `.mod` files), as parsing against the DTDs would; the
48
+ element then converts as what it specializes. An existing `@class` is kept;
49
+ a name the DTDs do not declare (custom or DITA 2.0) stays unclassed, with
50
+ one note per topic. It is off by default for now because it changes the
51
+ output for such elements.
52
+
35
53
  Everything that cannot map cleanly lands in the conversion ledger instead
36
54
  of disappearing: filter attributes, table spans, semantic-inline downgrades,
37
55
  `conkeyref`, conref ranges/pushes, whole-topic conrefs, unresolved targets,
package/dist/anchors.d.ts CHANGED
@@ -17,4 +17,3 @@ export declare const anchorId: (ctx: {
17
17
  topicId?: string;
18
18
  anchorIds?: Map<string, string>;
19
19
  }, id: string) => string;
20
- //# sourceMappingURL=anchors.d.ts.map
package/dist/anchors.js CHANGED
@@ -32,4 +32,3 @@ export const renamedElementIds = (nodes) => {
32
32
  };
33
33
  /** The anchor for an element id in a topic: the id, or `topicid__elementid` when an earlier topic has it. */
34
34
  export const anchorId = (ctx, id) => (ctx.topicId !== undefined && ctx.anchorIds?.get(`${ctx.topicId}/${id}`)) || id;
35
- //# sourceMappingURL=anchors.js.map
package/dist/blocks.d.ts CHANGED
@@ -11,6 +11,7 @@ import type { Element, AnyNode } from "domhandler";
11
11
  import type { TreeNode } from "@dogsbay/types";
12
12
  import { type InlineContext } from "./inline.js";
13
13
  import { type ConrefSiteContext, type ConrefSnippetRef } from "./types.js";
14
+ import { DB_QUALIFIERS } from "./qualifiers.js";
14
15
  export interface BlockContext extends InlineContext {
15
16
  /** Current heading depth for <section>/<example> titles. */
16
17
  headingLevel: number;
@@ -31,6 +32,7 @@ export interface BlockContext extends InlineContext {
31
32
  * from the snippet's baked-in base level.
32
33
  */
33
34
  export declare const includeDirective: (snippet: ConrefSnippetRef, site: ConrefSiteContext, headingLevel: number) => string;
35
+ export { DB_QUALIFIERS };
34
36
  /**
35
37
  * One <step>/<substep>'s content: <cmd> as the leading inline run, the
36
38
  * rest as block children. Shared by convertSteps (constrained context)
@@ -61,4 +63,3 @@ export declare function convertBlock(el: Element, ctx: BlockContext): TreeNode[]
61
63
  export declare function convertChildren(el: Element, ctx: BlockContext,
62
64
  /** Element names, or particular child nodes, to leave out */
63
65
  skip?: Set<string | AnyNode>): TreeNode[];
64
- //# sourceMappingURL=blocks.d.ts.map
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. */
@@ -209,8 +210,14 @@ const definingItemNode = (el, ctx, type) => {
209
210
  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`);
210
211
  return null;
211
212
  };
213
+ /**
214
+ * Whether an element is a `tag` by name or, with @class, by what it
215
+ * specializes: a hazard statement's <typeofhazard> is an <li>. Matching the
216
+ * name alone dropped such items, and their text, without a note.
217
+ */
218
+ const isPart = (el, ctx, tag) => isElement(el) && elementName(el, ctx, (n) => n === tag) === tag;
212
219
  const convertListItems = (el, ctx, itemTag) => el.children
213
- .filter((c) => isElement(c) && c.name.toLowerCase() === itemTag)
220
+ .filter((c) => isPart(c, ctx, itemTag))
214
221
  .map((li) => {
215
222
  const viaConref = itemConref(li, ctx, "list-item") ?? definingItemNode(li, ctx, "list-item");
216
223
  if (viaConref)
@@ -230,9 +237,13 @@ const DB_COMPONENTS = {
230
237
  section: ["db-details"],
231
238
  // A section cannot nest, and cannot hold one: these can, so a details or
232
239
  // sheet goes inside a section (or another div) through them
233
- div: ["db-details"],
234
- sectiondiv: ["db-details"],
235
- bodydiv: ["db-details"],
240
+ div: ["db-details", "db-grid"],
241
+ sectiondiv: ["db-details", "db-grid"],
242
+ bodydiv: ["db-details", "db-grid"],
243
+ ul: ["db-files"],
244
+ ol: ["db-steps"],
245
+ // Alone in a <p>: a block component (xrefComponent)
246
+ xref: ["db-link-card", "db-button", "db-video"],
236
247
  };
237
248
  /** Elements without a <title>: a `db-details` one takes it from a leading `<p outputclass="db-title">`. */
238
249
  const UNTITLED_DETAILS = new Set(["div", "sectiondiv", "bodydiv"]);
@@ -248,7 +259,9 @@ const toDetails = (el, ctx, classes, title, skip, titleId) => {
248
259
  const ownId = el.attribs?.id ?? titleId;
249
260
  if (ownId)
250
261
  props.id = anchorId(ctx, ownId);
251
- const tokens = (classes ?? "").split(" ").filter(Boolean);
262
+ const q = qualify("db-details", classes, ctx);
263
+ Object.assign(props, q.props);
264
+ const tokens = (q.classes ?? "").split(" ").filter(Boolean);
252
265
  if (tokens.includes("drawer"))
253
266
  ctx.note("@outputclass", `"drawer" on db-details is now "sheet" — converted as a sheet`);
254
267
  if (tokens.includes("sheet") || tokens.includes("drawer"))
@@ -267,6 +280,99 @@ const toDetails = (el, ctx, classes, title, skip, titleId) => {
267
280
  ctx.note("@outputclass", `db-details "${props.title}" has no content`);
268
281
  return withProvenance({ type: "details", props, children }, el);
269
282
  };
283
+ /**
284
+ * `<div outputclass="db-grid cols-3">`: each child `<div>` is a cell (its
285
+ * `span-N` token spans columns); any other child block is a cell of its own.
286
+ * DITA-OT shows the cells one after another.
287
+ */
288
+ const toGrid = (el, ctx, classes) => {
289
+ const q = qualify("db-grid", classes, ctx);
290
+ const props = { ...q.props, ...(q.classes ? { class: q.classes } : {}) };
291
+ if (el.attribs?.id)
292
+ props.id = anchorId(ctx, el.attribs.id);
293
+ const cells = [];
294
+ for (const child of el.children) {
295
+ if (!isElement(child)) {
296
+ if (isNonEmptyText(child))
297
+ cells.push({ type: "grid-item", children: [{ type: "paragraph", inline: convertInline([child], ctx) }] });
298
+ continue;
299
+ }
300
+ if (blockName(child, ctx) === "div") {
301
+ const cellProps = {};
302
+ const rest = [];
303
+ for (const t of outputTokens(child.attribs?.outputclass)) {
304
+ const span = /^span-(\d+)$/.exec(t);
305
+ if (span && DB_QUALIFIERS.gridColumns.includes(Number(span[1])))
306
+ cellProps.span = Number(span[1]);
307
+ else if (/^[a-z0-9][a-z0-9-]*$/.test(t) && !t.startsWith("db-"))
308
+ rest.push(t);
309
+ else
310
+ ctx.note("@outputclass", `"${t}" on a db-grid cell ignored: a cell takes span-2 to span-6`);
311
+ }
312
+ if (rest.length)
313
+ cellProps.class = rest.join(" ");
314
+ if (child.attribs?.id)
315
+ cellProps.id = anchorId(ctx, child.attribs.id);
316
+ cells.push(withProvenance({ type: "grid-item", ...(Object.keys(cellProps).length ? { props: cellProps } : {}), children: convertChildren(child, ctx) }, child));
317
+ }
318
+ else {
319
+ const blocks = convertBlock(child, ctx);
320
+ if (blocks.length)
321
+ cells.push({ type: "grid-item", children: blocks });
322
+ }
323
+ }
324
+ if (!cells.length)
325
+ ctx.note("@outputclass", `db-grid has no cells`);
326
+ return withProvenance({ type: "grid", props, children: cells }, el);
327
+ };
328
+ /**
329
+ * A `<p>` holding only an `<xref outputclass="db-link-card|db-button|db-video">`
330
+ * (and whitespace): the block component. The href resolves exactly as the
331
+ * xref's would (rename, fragment, key, publish paths), because the link is
332
+ * converted first and read back. Undefined when the paragraph is anything
333
+ * else; an xref component beside other text stays a link, with a note.
334
+ */
335
+ const xrefComponent = (p, ctx) => {
336
+ const kids = p.children.filter((c) => isElement(c) || isNonEmptyText(c));
337
+ const xrefs = p.children.filter((c) => isElement(c) && c.name.toLowerCase() === "xref" && /(^|\s)db-/.test(c.attribs?.outputclass ?? ""));
338
+ if (!xrefs.length)
339
+ return undefined;
340
+ if (kids.length !== 1) {
341
+ ctx.note("@outputclass", `an <xref> component must be alone in its <p> — kept as a link`);
342
+ return undefined;
343
+ }
344
+ const x = xrefs[0];
345
+ const component = dbComponent(x, ctx);
346
+ if (!component)
347
+ return undefined;
348
+ if (ctx.inTableCell) {
349
+ // A cell holds inline content only: the component would lose its link
350
+ ctx.note("@outputclass", `${component.name} inside a table cell — kept as a link`);
351
+ return undefined;
352
+ }
353
+ const link = convertInline([x], ctx).find((n) => n.type === "link");
354
+ if (!link?.href) {
355
+ ctx.note("@outputclass", `${component.name} without a resolvable href — kept as text`);
356
+ return undefined;
357
+ }
358
+ const title = inlineLabel(link.children);
359
+ const q = qualify(component.name, component.classes, ctx);
360
+ const props = { ...q.props, ...(q.classes ? { class: q.classes } : {}) };
361
+ if (component.name === "db-link-card") {
362
+ const desc = x.children.find((c) => isElement(c) && c.name.toLowerCase() === "desc");
363
+ const description = desc ? inlineLabel(convertInline(desc.children, ctx)) : "";
364
+ return withProvenance({ type: "link-card", props: { ...props, title: title || link.href, href: link.href, ...(description ? { description } : {}) } }, x);
365
+ }
366
+ if (component.name === "db-button") {
367
+ return withProvenance({ type: "link-button", props: { ...props, href: link.href }, children: [{ type: "paragraph", inline: link.children }] }, x);
368
+ }
369
+ const id = youtubeId(link.href);
370
+ if (!id) {
371
+ ctx.note("@outputclass", `db-video "${link.href}" is not a YouTube URL — kept as a link`);
372
+ return undefined;
373
+ }
374
+ return withProvenance({ type: "youtube", props: { ...props, id, ...(title && title !== link.href ? { title } : {}) } }, x);
375
+ };
270
376
  /**
271
377
  * The component an element's `@outputclass` selects: its first `db-*` token,
272
378
  * and the remaining tokens as classes. `db-` makes the token ours: other
@@ -277,7 +383,8 @@ const toDetails = (el, ctx, classes, title, skip, titleId) => {
277
383
  * as plain DITA.
278
384
  */
279
385
  const dbComponent = (el, ctx) => {
280
- const tokens = (el.attribs?.outputclass ?? "").split(/\s+/).filter(Boolean);
386
+ // `db-theme-*` is a scope, not a component (themeOf): never picked or kept as a class
387
+ const tokens = outputTokens(el.attribs?.outputclass);
281
388
  const at = tokens.findIndex((t) => t.startsWith("db-"));
282
389
  if (at < 0)
283
390
  return undefined;
@@ -298,6 +405,83 @@ const dbComponent = (el, ctx) => {
298
405
  ctx.note("@outputclass", `class ${dropped.map((t) => `"${t}"`).join(", ")} on ${name} dropped: classes are lowercase letters, digits and hyphens`);
299
406
  return { name, ...(safe.length ? { classes: safe.join(" ") } : {}) };
300
407
  };
408
+ export { DB_QUALIFIERS };
409
+ /**
410
+ * A component's qualifier tokens as props: `pills`/`underline` on db-tabs
411
+ * (`variant`), `tone-*` on db-cards (`tone`) and db-details (its colour,
412
+ * `variant`). Props, not classes: a sanitized single page strips classes, and
413
+ * every renderer and exporter can implement or declare a prop. Anything else
414
+ * stays a free class; a `tone-` token outside the tones is noted.
415
+ */
416
+ const qualify = (component, classes, ctx) => {
417
+ const props = {};
418
+ const rest = [];
419
+ for (const t of (classes ?? "").split(" ").filter(Boolean)) {
420
+ if (component === "db-tabs" && DB_QUALIFIERS.tabVariants.includes(t)) {
421
+ props.variant = t;
422
+ continue;
423
+ }
424
+ if (component === "db-button" && DB_QUALIFIERS.buttonVariants.includes(t)) {
425
+ if (t !== "primary")
426
+ props.variant = t;
427
+ continue;
428
+ }
429
+ const cols = /^cols-(\d+)$/.exec(t);
430
+ if (component === "db-grid" && cols) {
431
+ if (DB_QUALIFIERS.gridColumns.includes(Number(cols[1])))
432
+ props.cols = Number(cols[1]);
433
+ else
434
+ ctx.note("@outputclass", `"${t}": db-grid takes cols-1 to cols-6 — ignored`);
435
+ continue;
436
+ }
437
+ const tone = /^tone-(.+)$/.exec(t);
438
+ if (tone && (component === "db-cards" || component === "db-details")) {
439
+ if (DB_QUALIFIERS.tones.includes(tone[1]))
440
+ props[component === "db-details" ? "variant" : "tone"] = tone[1];
441
+ else
442
+ ctx.note("@outputclass", `"${t}" is not a tone (${DB_QUALIFIERS.tones.join(", ")}) — ignored`);
443
+ continue;
444
+ }
445
+ if (tone) {
446
+ ctx.note("@outputclass", `"${t}": ${component} takes no tone (db-cards and db-details do) — ignored`);
447
+ continue;
448
+ }
449
+ rest.push(t);
450
+ }
451
+ return { props, ...(rest.length ? { classes: rest.join(" ") } : {}) };
452
+ };
453
+ /** Elements a scoped theme (`db-theme-<name>`) applies to: containers of blocks. */
454
+ // section and its task/reference specialisations, plus the generic containers
455
+ const THEMED = new Set(["section", "sectiondiv", "div", "bodydiv", "example", "context", "prereq", "result", "postreq", "refsyn"]);
456
+ /**
457
+ * The scoped theme an element asks for (`outputclass="db-theme-dark"`),
458
+ * independent of its component token. Noted when it is unknown, on an element
459
+ * that is not a block container, or inside a table cell.
460
+ */
461
+ const themeOf = (el, ctx) => {
462
+ const tokens = (el.attribs?.outputclass ?? "").split(/\s+/).filter((t) => /^db-theme-/i.test(t));
463
+ const token = tokens[0];
464
+ if (!token)
465
+ return undefined;
466
+ if (tokens.length > 1)
467
+ ctx.note("@outputclass", `${tokens.slice(1).map((t) => `"${t}"`).join(", ")} ignored: one theme per element`);
468
+ const name = token.slice("db-theme-".length).toLowerCase();
469
+ // By @class in publish mode, so a specialised section is still a section
470
+ const element = blockName(el, ctx).toLowerCase();
471
+ if (!THEMED.has(element)) {
472
+ ctx.note("@outputclass", `"${token}" applies to section, sectiondiv, div, bodydiv and example — not <${element}>`);
473
+ return undefined;
474
+ }
475
+ if (!DB_QUALIFIERS.themes.includes(name)) {
476
+ ctx.note("@outputclass", `"${token}" is not a theme (${DB_QUALIFIERS.themes.join(", ")}) — content kept, unthemed`);
477
+ return undefined;
478
+ }
479
+ if (ctx.inTableCell) {
480
+ ctx.note("@outputclass", `"${token}" inside a table cell — content kept, unthemed`);
481
+ return undefined;
482
+ }
483
+ return name;
484
+ };
301
485
  /** An `id` anchor the converter wrote (`<a id="x"></a>`), as opposed to other inline HTML. */
302
486
  const isIdAnchor = (n) => n.type === "html-inline" && /^<a id="[^"]*"><\/a>$/.test(n.html);
303
487
  const decodeEntities = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (m, e) => {
@@ -351,7 +535,7 @@ const inlineLabel = (inline) => (inline ?? [])
351
535
  const dlComponentEntries = (el, ctx, component, keepEmpty = false, anchorsOut) => {
352
536
  const out = [];
353
537
  let pending = [];
354
- const entries = el.children.filter((c) => isElement(c) && c.name.toLowerCase() === "dlentry");
538
+ const entries = el.children.filter((c) => isPart(c, ctx, "dlentry"));
355
539
  for (const entry of entries) {
356
540
  const items = convertDefList({ ...el, children: [entry] }, ctx).children ?? [];
357
541
  const terms = items.filter((n) => n.type === "dt");
@@ -459,7 +643,7 @@ const dlToCards = (el, ctx, anchors) => {
459
643
  const convertDefList = (el, ctx, tags = { entry: "dlentry", term: "dt", def: "dd" }) => {
460
644
  const children = [];
461
645
  for (const entry of el.children.filter(isElement)) {
462
- if (entry.name.toLowerCase() !== tags.entry)
646
+ if (!isPart(entry, ctx, tags.entry))
463
647
  continue;
464
648
  // Entry-level conref (<dlentry conref>): a term/definition pair can't
465
649
  // splice as an include — ledger it so the loss is never silent
@@ -469,8 +653,7 @@ const convertDefList = (el, ctx, tags = { entry: "dlentry", term: "dt", def: "dd
469
653
  }
470
654
  let entryAnchored = false;
471
655
  for (const part of entry.children.filter(isElement)) {
472
- const name = part.name.toLowerCase();
473
- if (name === tags.term) {
656
+ if (isPart(part, ctx, tags.term)) {
474
657
  // Term-level conref (<dt conref>/<pt conref>): single-line
475
658
  // include or ledger — never a silent empty term
476
659
  const viaConref = constrainedConrefInline(part, ctx);
@@ -481,7 +664,7 @@ const convertDefList = (el, ctx, tags = { entry: "dlentry", term: "dt", def: "dd
481
664
  });
482
665
  entryAnchored = true;
483
666
  }
484
- else if (name === tags.def) {
667
+ else if (isPart(part, ctx, tags.def)) {
485
668
  const viaConref = constrainedConrefInline(part, ctx);
486
669
  if (viaConref) {
487
670
  children.push({ type: "dd", inline: viaConref });
@@ -541,12 +724,23 @@ const TABLE_PARTS = new Set([
541
724
  const hasClassToken = (el, token) => ` ${el.attribs?.class ?? ""} `.includes(` ${token} `);
542
725
  const elementChildren = (el) => el.children.filter(isElement);
543
726
  const PROPERTY_KINDS = ["proptype", "propvalue", "propdesc"];
727
+ /**
728
+ * Whether an element is the reference domain's `local` (`properties`, `property`, …):
729
+ * by @class when DITA-OT has filled it in, else by name, as authored source has none.
730
+ */
731
+ const isReference = (el, local) => hasClassToken(el, `reference/${local}`) || el.name.toLowerCase() === local;
544
732
  /** A properties table cell's column: `proptype`, or with suffix "hd" a header cell (`proptypehd`). */
545
- const propertyKind = (cell, suffix = "") => PROPERTY_KINDS.find((k) => hasClassToken(cell, `reference/${k}${suffix}`));
733
+ const propertyKind = (cell, suffix = "") => PROPERTY_KINDS.find((k) => isReference(cell, `${k}${suffix}`));
734
+ /**
735
+ * A properties table's cells. Exactly the cells `propertyKind` recognizes (a
736
+ * published cell's @class names its kind before the stentry it specializes),
737
+ * so a row's converted cells and its cell elements line up index for index.
738
+ */
739
+ const PROPERTY_CELLS = new Set([...PROPERTY_KINDS, ...PROPERTY_KINDS.map((k) => `${k}hd`)]);
546
740
  /** A properties table's columns in use, its properties that have cells, and its header row. */
547
741
  const propertiesColumns = (el) => {
548
- const header = elementChildren(el).find((c) => hasClassToken(c, "reference/prophead"));
549
- const propertyRows = elementChildren(el).filter((c) => hasClassToken(c, "reference/property") && elementChildren(c).some((cell) => !!propertyKind(cell)));
742
+ const header = elementChildren(el).find((c) => isReference(c, "prophead"));
743
+ const propertyRows = elementChildren(el).filter((c) => isReference(c, "property") && elementChildren(c).some((cell) => !!propertyKind(cell)));
550
744
  const used = PROPERTY_KINDS.filter((k) => propertyRows.some((r) => elementChildren(r).some((c) => propertyKind(c) === k)) ||
551
745
  (header ? elementChildren(header).some((c) => propertyKind(c, "hd") === k) : false));
552
746
  return { used, propertyRows, ...(header ? { header } : {}) };
@@ -651,15 +845,24 @@ const convertTable = (el, ctx) => {
651
845
  }
652
846
  };
653
847
  walk(el);
654
- // Publishing: a properties table's columns are the type, value and description columns
848
+ // A properties table's columns are the type, value and description columns
655
849
  // its header or any property uses (DITA-OT's hasType, hasValue, hasDesc); each row
656
850
  // aligns its cells to them, a property with no cells is dropped, and a header cell
657
- // missing for a column in use is DITA-OT's generated label ("Type", "Value", "Description")
658
- if (ctx.publish && hasClassToken(el, "reference/properties")) {
851
+ // missing for a column in use is DITA-OT's generated label ("Type", "Value",
852
+ // "Description"; localized when publishing). Authored source has no @class, so
853
+ // the parts are matched by name too (the walk above sees no sthead/strow then).
854
+ if (isReference(el, "properties")) {
659
855
  const { used, propertyRows, header } = propertiesColumns(el);
856
+ // A property with no cells has no row; one that reuses a row by conref is noted, not lost
857
+ for (const row of elementChildren(el)) {
858
+ if (isReference(row, "property") && !propertyRows.includes(row) && (row.attribs?.conref || row.attribs?.conkeyref)) {
859
+ const ref = row.attribs.conref ?? row.attribs.conkeyref;
860
+ ctx.note(row.attribs.conref ? "@conref" : "@conkeyref", `<property> pulls content from "${ref}" — ${conrefLedgerDetail(row.attribs, undefined, ctx.conref !== undefined)}`);
861
+ }
862
+ }
660
863
  rows.length = 0;
661
864
  for (const row of propertyRows) {
662
- const converted = convertRow(row, SIMPLE_CELLS);
865
+ const converted = convertRow(row, PROPERTY_CELLS);
663
866
  const cellEls = elementChildren(row).filter((c) => !!propertyKind(c));
664
867
  rows.push({
665
868
  type: "tr",
@@ -669,13 +872,10 @@ const convertTable = (el, ctx) => {
669
872
  }),
670
873
  });
671
874
  }
672
- const lang = publishLanguage(el, ctx.publish);
673
- const label = {
674
- proptype: publishString(ctx.publish, lang, "Type"),
675
- propvalue: publishString(ctx.publish, lang, "Value"),
676
- propdesc: publishString(ctx.publish, lang, "Description"),
677
- };
678
- const authored = header ? convertRow(header, SIMPLE_CELLS, true) : undefined;
875
+ const lang = ctx.publish ? publishLanguage(el, ctx.publish) : undefined;
876
+ const word = (w) => (ctx.publish ? publishString(ctx.publish, lang, w) : w);
877
+ const label = { proptype: word("Type"), propvalue: word("Value"), propdesc: word("Description") };
878
+ const authored = header ? convertRow(header, PROPERTY_CELLS, true) : undefined;
679
879
  const headerCells = header ? elementChildren(header).filter((c) => !!propertyKind(c, "hd")) : [];
680
880
  headRows.length = 0;
681
881
  headRows.push({
@@ -762,7 +962,7 @@ export const convertStepContent = (stepEl, ctx) => {
762
962
  const convertSteps = (el, ctx, type) => {
763
963
  const stepTag = type === "steps" ? "step" : "substep";
764
964
  const children = el.children
765
- .filter((c) => isElement(c) && c.name.toLowerCase() === stepTag)
965
+ .filter((c) => isPart(c, ctx, stepTag))
766
966
  .map((stepEl) => {
767
967
  const viaConref = itemConref(stepEl, ctx, "step") ?? definingItemNode(stepEl, ctx, "step");
768
968
  if (viaConref)
@@ -834,7 +1034,7 @@ const publishTaskLabel = (name, ctx, el) => ctx.publish?.taskLabels && TASK_LABE
834
1034
  /** Block-level DITA elements (used to split mixed content). */
835
1035
  const BLOCK_ELEMENTS = new Set([
836
1036
  "p", "shortdesc", "note", "codeblock", "pre", "screen", "msgblock", "ul", "ol", "sl", "dl", "parml", "table", "simpletable",
837
- "fig", "image", "section", "example", "steps", "steps-unordered", "substeps", "lq", "object",
1037
+ "properties", "fig", "image", "section", "example", "steps", "steps-unordered", "substeps", "lq", "object",
838
1038
  "prereq", "context", "result", "postreq", "tasktroubleshooting", "choices", "choicetable", "syntaxdiagram",
839
1039
  ]);
840
1040
  const SYNTAX_GROUPS = new Set(["groupseq", "groupchoice", "groupcomp"]);
@@ -941,6 +1141,17 @@ const ANCHORED_ELSEWHERE = new Set(["title", "related-links", "prolog", "data",
941
1141
  * DITA-OT's pages. (Headings carry their own id.)
942
1142
  */
943
1143
  export function convertBlock(el, ctx) {
1144
+ const out = convertBlockAnchored(el, ctx);
1145
+ // A scoped theme wraps the element's whole output, its anchor included
1146
+ const theme = /(^|\s)db-theme-/i.test(el.attribs?.outputclass ?? "") ? themeOf(el, ctx) : undefined;
1147
+ if (!theme || !out.length)
1148
+ return out;
1149
+ // The element's own id anchor stays outside the band: inside, it would be
1150
+ // the band's first child and take the first-child margin trim meant for content
1151
+ const lead = out[0].type === "html" && /^<a id="[^"]*"><\/a>$/.test(out[0].html ?? "") ? [out[0]] : [];
1152
+ return [...lead, { type: "theme", props: { name: theme }, children: out.slice(lead.length) }];
1153
+ }
1154
+ function convertBlockAnchored(el, ctx) {
944
1155
  const out = convertBlockContent(el, ctx);
945
1156
  const id = el.attribs?.id;
946
1157
  if (!ctx.publish || !id || ANCHORED_ELSEWHERE.has(blockName(el, ctx)))
@@ -1068,6 +1279,12 @@ function convertBlockContent(el, ctx) {
1068
1279
  ctx.note("@outputclass", `db-details on <${name}> has no leading <p outputclass="db-title"> with text — titled "Details"`);
1069
1280
  return [toDetails(el, ctx, component.classes, title, new Set(titleP ? [titleP] : []), titleP?.attribs?.id)];
1070
1281
  }
1282
+ else if (component?.name === "db-grid" && ctx.inTableCell) {
1283
+ ctx.note("@outputclass", `db-grid inside a table cell — kept as its content`);
1284
+ }
1285
+ else if (component?.name === "db-grid") {
1286
+ return [toGrid(el, ctx, component.classes)];
1287
+ }
1071
1288
  }
1072
1289
  if (TRANSPARENT_CONTAINERS.has(name)) {
1073
1290
  // Publishing: a topic body starts with the topic's "Prerequisites" links, when it has any
@@ -1083,6 +1300,13 @@ function convertBlockContent(el, ctx) {
1083
1300
  switch (name) {
1084
1301
  case "p":
1085
1302
  case "shortdesc": {
1303
+ // A link card, button or video: an xref component alone in its paragraph
1304
+ const xc = name === "p" ? xrefComponent(el, ctx) : undefined;
1305
+ if (xc) {
1306
+ // Publishing: the xref's own id (a link target) as an anchor before the component
1307
+ const anchors = anchorNodes([el.children.find((c) => isElement(c) && c.name.toLowerCase() === "xref")?.attribs?.id], ctx);
1308
+ return anchors.length ? [{ type: "paragraph", inline: anchors }, xc] : [xc];
1309
+ }
1086
1310
  // DITA allows blocks (ul, note, pre, …) inside <p>: leading inline
1087
1311
  // becomes the paragraph, block children follow as siblings
1088
1312
  const { inline, children } = mixedContent(el, ctx);
@@ -1134,16 +1358,35 @@ function convertBlockContent(el, ctx) {
1134
1358
  };
1135
1359
  return [withProvenance(node, el)];
1136
1360
  }
1137
- case "ul":
1138
- return [withProvenance({ type: "unordered-list", children: convertListItems(el, ctx, "li") }, el)];
1139
- case "ol":
1140
- return [withProvenance({ type: "ordered-list", children: convertListItems(el, ctx, "li") }, el)];
1361
+ case "ul": {
1362
+ const list = withProvenance({ type: "unordered-list", children: convertListItems(el, ctx, "li") }, el);
1363
+ // A file tree: folders are items with a nested list, or end in "/"
1364
+ if (dbComponent(el, ctx)?.name === "db-files") {
1365
+ if (!ctx.inTableCell)
1366
+ return [{ type: "files", children: [list] }];
1367
+ ctx.note("@outputclass", `db-files inside a table cell — kept as a list`);
1368
+ }
1369
+ return [list];
1370
+ }
1371
+ case "ol": {
1372
+ const items = convertListItems(el, ctx, "li");
1373
+ // Steps outside a task: each item a numbered, connected step
1374
+ if (dbComponent(el, ctx)?.name === "db-steps") {
1375
+ if (!ctx.inTableCell) {
1376
+ // An item stays itself (its id, its provenance) as a step
1377
+ const steps = items.map((it) => ({ ...it, type: "step" }));
1378
+ return [withProvenance({ type: "steps", children: steps }, el)];
1379
+ }
1380
+ ctx.note("@outputclass", `db-steps inside a table cell — kept as a numbered list`);
1381
+ }
1382
+ return [withProvenance({ type: "ordered-list", children: items }, el)];
1383
+ }
1141
1384
  case "sl": {
1142
1385
  // Simple list → unordered list of <sli> items (same conref hooks
1143
1386
  // as <li>: referencing items include or ledger, defining items
1144
1387
  // hand their text to the snippet)
1145
1388
  const items = el.children
1146
- .filter((c) => isElement(c) && c.name.toLowerCase() === "sli")
1389
+ .filter((c) => isPart(c, ctx, "sli"))
1147
1390
  .map((sli) => {
1148
1391
  const viaConref = itemConref(sli, ctx, "list-item") ?? definingItemNode(sli, ctx, "list-item");
1149
1392
  if (viaConref)
@@ -1165,7 +1408,9 @@ function convertBlockContent(el, ctx) {
1165
1408
  }[component.name];
1166
1409
  const children = build(el, ctx);
1167
1410
  if (children) {
1168
- const group = withProvenance({ type, ...(component.classes ? { props: { class: component.classes } } : {}), children }, el);
1411
+ const q = qualify(component.name, component.classes, ctx);
1412
+ const props = { ...q.props, ...(q.classes ? { class: q.classes } : {}) };
1413
+ const group = withProvenance({ type, ...(Object.keys(props).length ? { props } : {}), children }, el);
1169
1414
  return anchors.length ? [{ type: "paragraph", inline: anchors }, group] : [group];
1170
1415
  }
1171
1416
  ctx.note("@outputclass", `${component.name} on a <dl> with no usable entry — kept as a definition list`);
@@ -1177,9 +1422,17 @@ function convertBlockContent(el, ctx) {
1177
1422
  return [withProvenance(convertDefList(el, ctx, { entry: "plentry", term: "pt", def: "pd" }), el)];
1178
1423
  case "table":
1179
1424
  case "simpletable":
1180
- // Publishing: DITA-OT drops a properties table none of whose properties has a cell
1181
- if (ctx.publish && hasClassToken(el, "reference/properties") && propertiesColumns(el).propertyRows.length === 0)
1425
+ case "properties":
1426
+ // A properties table none of whose properties has a cell has no columns: DITA-OT drops it
1427
+ if (isReference(el, "properties") && propertiesColumns(el).propertyRows.length === 0) {
1428
+ if (!ctx.publish) {
1429
+ for (const row of elementChildren(el).filter((r) => r.attribs?.conref || r.attribs?.conkeyref)) {
1430
+ const ref = row.attribs.conref ?? row.attribs.conkeyref;
1431
+ ctx.note(row.attribs.conref ? "@conref" : "@conkeyref", `<${row.name.toLowerCase()}> pulls content from "${ref}" — ${conrefLedgerDetail(row.attribs, undefined, ctx.conref !== undefined)}`);
1432
+ }
1433
+ }
1182
1434
  return [];
1435
+ }
1183
1436
  return [withProvenance(convertTable(el, ctx), el)];
1184
1437
  case "syntaxdiagram":
1185
1438
  if (ctx.publish)
@@ -1414,4 +1667,3 @@ skip = new Set()) {
1414
1667
  flushInline();
1415
1668
  return out;
1416
1669
  }
1417
- //# sourceMappingURL=blocks.js.map
@@ -0,0 +1,15 @@
1
+ import type { AnyNode } from "domhandler";
2
+ /** An element's DITA 1.3 default @class (`inMap`: inside a map document), or undefined for a name the DTDs do not declare. */
3
+ export declare const classDefault: (name: string, inMap?: boolean) => string | undefined;
4
+ /**
5
+ * Fill in the @class a DTD would have defaulted, on every element that has
6
+ * none, as parsing against the DTDs (or DITA-OT preprocessing) does. An
7
+ * existing @class is kept. Returns the element names the DITA 1.3 DTDs do not
8
+ * declare (custom specializations, DITA 2.0), in document order, each once;
9
+ * those stay unclassed. Not reported: the `<dita>` wrapper of a composite
10
+ * topic, which has no @class by design, and foreign vocabularies (a
11
+ * namespace prefix, or an undeclared name inside a `<foreign>`
12
+ * specialization: MathML, SVG), which are not DITA elements and are left
13
+ * untouched. A DITA element inside one (`<mathmlref>`, `<svgref>`) is classed.
14
+ */
15
+ export declare function applyClassDefaults(nodes: AnyNode[]): string[];
@@ -0,0 +1,40 @@
1
+ import { isTag } from "domhandler";
2
+ import { DITA_CLASS_DEFAULTS, DITA_MAP_CLASS_DEFAULTS } from "./class-defaults.js";
3
+ const own = (table, name) => Object.prototype.hasOwnProperty.call(table, name) ? table[name] : undefined;
4
+ /** An element's DITA 1.3 default @class (`inMap`: inside a map document), or undefined for a name the DTDs do not declare. */
5
+ export const classDefault = (name, inMap = false) => (inMap ? own(DITA_MAP_CLASS_DEFAULTS, name) : undefined) ?? own(DITA_CLASS_DEFAULTS, name);
6
+ /**
7
+ * Fill in the @class a DTD would have defaulted, on every element that has
8
+ * none, as parsing against the DTDs (or DITA-OT preprocessing) does. An
9
+ * existing @class is kept. Returns the element names the DITA 1.3 DTDs do not
10
+ * declare (custom specializations, DITA 2.0), in document order, each once;
11
+ * those stay unclassed. Not reported: the `<dita>` wrapper of a composite
12
+ * topic, which has no @class by design, and foreign vocabularies (a
13
+ * namespace prefix, or an undeclared name inside a `<foreign>`
14
+ * specialization: MathML, SVG), which are not DITA elements and are left
15
+ * untouched. A DITA element inside one (`<mathmlref>`, `<svgref>`) is classed.
16
+ */
17
+ export function applyClassDefaults(nodes) {
18
+ const root = nodes.find(isTag);
19
+ const inMap = !!root && (["map", "bookmap", "subjectScheme"].includes(root.name) || ` ${root.attribs?.class ?? ""} `.includes(" map/map "));
20
+ const unknown = new Set();
21
+ const visit = (el, inForeign) => {
22
+ if (el.name.includes(":"))
23
+ return;
24
+ if (!el.attribs?.class?.trim()) {
25
+ const value = classDefault(el.name, inMap);
26
+ if (value)
27
+ el.attribs = { ...el.attribs, class: value };
28
+ else if (el.name !== "dita" && !inForeign)
29
+ unknown.add(el.name);
30
+ }
31
+ const foreign = inForeign || ` ${el.attribs?.class ?? ""} `.includes(" topic/foreign ");
32
+ for (const child of el.children)
33
+ if (isTag(child))
34
+ visit(child, foreign);
35
+ };
36
+ for (const node of nodes)
37
+ if (isTag(node))
38
+ visit(node, false);
39
+ return [...unknown];
40
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Each DITA 1.3 element's default @class, as its DTD declares it. GENERATED by
3
+ * scripts/generate-class-defaults.mjs from the .mod files under dita-ot-4.3.5's
4
+ * plugins/org.oasis-open.dita.v1_3/dtd/ (612 elements); do not edit.
5
+ */
6
+ export declare const DITA_CLASS_DEFAULTS: Readonly<Record<string, string>>;
7
+ /** Elements whose class differs inside a map (map.mod declares them too). */
8
+ export declare const DITA_MAP_CLASS_DEFAULTS: Readonly<Record<string, string>>;